feat: 完成權限管理系統、統一頁面標題樣式與表格對齊規範
This commit is contained in:
84
resources/js/Components/Permission/Can.tsx
Normal file
84
resources/js/Components/Permission/Can.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface CanProps {
|
||||
permission: string | string[];
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 權限判斷元件 - 類似 Blade 的 @can 指令
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Can permission="products.create">
|
||||
* <button>新增商品</button>
|
||||
* </Can>
|
||||
*
|
||||
* <Can permission={['products.edit', 'products.delete']}>
|
||||
* <div>管理操作</div>
|
||||
* </Can>
|
||||
* ```
|
||||
*/
|
||||
export function Can({ permission, children, fallback = null }: CanProps) {
|
||||
const { can, canAny } = usePermission();
|
||||
|
||||
const hasPermission = Array.isArray(permission)
|
||||
? canAny(permission)
|
||||
: can(permission);
|
||||
|
||||
return hasPermission ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
|
||||
interface HasRoleProps {
|
||||
role: string | string[];
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色判斷元件 - 類似 Blade 的 @role 指令
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <HasRole role="admin">
|
||||
* <Link href="/admin">管理後台</Link>
|
||||
* </HasRole>
|
||||
*
|
||||
* <HasRole role={['admin', 'manager']}>
|
||||
* <button>管理選項</button>
|
||||
* </HasRole>
|
||||
* ```
|
||||
*/
|
||||
export function HasRole({ role, children, fallback = null }: HasRoleProps) {
|
||||
const { hasRole, hasAnyRole } = usePermission();
|
||||
|
||||
const hasRequiredRole = Array.isArray(role)
|
||||
? hasAnyRole(role)
|
||||
: hasRole(role);
|
||||
|
||||
return hasRequiredRole ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
|
||||
interface CanAllProps {
|
||||
permissions: string[];
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否擁有所有權限
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CanAll permissions={['products.edit', 'products.delete']}>
|
||||
* <button>完整管理</button>
|
||||
* </CanAll>
|
||||
* ```
|
||||
*/
|
||||
export function CanAll({ permissions, children, fallback = null }: CanAllProps) {
|
||||
const { canAll } = usePermission();
|
||||
|
||||
return canAll(permissions) ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function PurchaseOrderActions({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href={`/purchase-orders/${order.id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function PurchaseOrderTable({
|
||||
<SortIcon field="status" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-semibold">操作</TableHead>
|
||||
<TableHead className="text-center font-semibold">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -214,7 +214,7 @@ export default function PurchaseOrderTable({
|
||||
<TableCell>
|
||||
<PurchaseOrderStatusBadge status={order.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-center">
|
||||
<PurchaseOrderActions
|
||||
order={order}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
FileText,
|
||||
LogOut,
|
||||
User,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Shield,
|
||||
Users
|
||||
} from "lucide-react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { useState, useEffect } from "react";
|
||||
@@ -101,6 +104,25 @@ export default function AuthenticatedLayout({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "system-management",
|
||||
label: "系統管理",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
children: [
|
||||
{
|
||||
id: "user-management",
|
||||
label: "使用者管理",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
route: "/admin/users",
|
||||
},
|
||||
{
|
||||
id: "role-management",
|
||||
label: "角色與權限",
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
route: "/admin/roles",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 初始化狀態:優先讀取 localStorage
|
||||
|
||||
197
resources/js/Pages/Admin/Role/Create.tsx
Normal file
197
resources/js/Pages/Admin/Role/Create.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Shield, ArrowLeft, Check } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GroupedPermission {
|
||||
key: string;
|
||||
name: string;
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
groupedPermissions: GroupedPermission[];
|
||||
}
|
||||
|
||||
export default function RoleCreate({ groupedPermissions }: Props) {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
name: '',
|
||||
permissions: [] as string[],
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('roles.store'));
|
||||
};
|
||||
|
||||
const togglePermission = (name: string) => {
|
||||
if (data.permissions.includes(name)) {
|
||||
setData('permissions', data.permissions.filter(p => p !== name));
|
||||
} else {
|
||||
setData('permissions', [...data.permissions, name]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (groupPermissions: Permission[]) => {
|
||||
const groupNames = groupPermissions.map(p => p.name);
|
||||
const allSelected = groupNames.every(name => data.permissions.includes(name));
|
||||
|
||||
if (allSelected) {
|
||||
// Unselect all
|
||||
setData('permissions', data.permissions.filter(p => !groupNames.includes(p)));
|
||||
} else {
|
||||
// Select all
|
||||
const newPermissions = [...data.permissions];
|
||||
groupNames.forEach(name => {
|
||||
if (!newPermissions.includes(name)) newPermissions.push(name);
|
||||
});
|
||||
setData('permissions', newPermissions);
|
||||
}
|
||||
};
|
||||
|
||||
// 翻譯權限後綴
|
||||
const translateAction = (permissionName: string) => {
|
||||
const parts = permissionName.split('.');
|
||||
if (parts.length < 2) return permissionName;
|
||||
const action = parts[1];
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'view': '檢視',
|
||||
'create': '新增',
|
||||
'edit': '編輯',
|
||||
'delete': '刪除',
|
||||
'publish': '發布',
|
||||
'adjust': '調整',
|
||||
'transfer': '調撥',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '系統管理', href: '#' },
|
||||
{ label: '角色與權限', href: route('roles.index') },
|
||||
{ label: '建立角色', href: route('roles.create'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="建立角色" />
|
||||
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-[#01ab83]" />
|
||||
建立新角色
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
設定角色名稱並分配對應的操作權限
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={route('roles.index')}>
|
||||
<Button variant="outline" type="button">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||
disabled={processing}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
儲存角色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Name */}
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="max-w-md space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">角色名稱 (英文代號)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g. sales-manager"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-red-500">{errors.name}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
請使用英文字母與連字號,例如: <code>warehouse-staff</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Matrix */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-grey-0">權限設定</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{groupedPermissions.map((group) => {
|
||||
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
||||
|
||||
return (
|
||||
<div key={group.key} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<span className="font-medium text-gray-700">{group.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleGroup(group.permissions)}
|
||||
className="text-xs h-7 text-[#01ab83] hover:text-[#01ab83] hover:bg-[#01ab83]/10"
|
||||
>
|
||||
{allGroupSelected ? '取消全選' : '全選'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 flex-1">
|
||||
<div className="space-y-3">
|
||||
{group.permissions.map((permission) => (
|
||||
<div key={permission.id} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={permission.name}
|
||||
checked={data.permissions.includes(permission.name)}
|
||||
onCheckedChange={() => togglePermission(permission.name)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={permission.name}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{translateAction(permission.name)}
|
||||
</label>
|
||||
<p className="text-[10px] text-gray-400 font-mono">
|
||||
{permission.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
210
resources/js/Pages/Admin/Role/Edit.tsx
Normal file
210
resources/js/Pages/Admin/Role/Edit.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Shield, ArrowLeft, Check, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GroupedPermission {
|
||||
key: string;
|
||||
name: string;
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
role: Role;
|
||||
groupedPermissions: GroupedPermission[];
|
||||
currentPermissions: string[];
|
||||
}
|
||||
|
||||
export default function RoleEdit({ role, groupedPermissions, currentPermissions }: Props) {
|
||||
const { data, setData, put, processing, errors } = useForm({
|
||||
name: role.name,
|
||||
permissions: currentPermissions,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
put(route('roles.update', role.id));
|
||||
};
|
||||
|
||||
const togglePermission = (name: string) => {
|
||||
if (data.permissions.includes(name)) {
|
||||
setData('permissions', data.permissions.filter(p => p !== name));
|
||||
} else {
|
||||
setData('permissions', [...data.permissions, name]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (groupPermissions: Permission[]) => {
|
||||
const groupNames = groupPermissions.map(p => p.name);
|
||||
const allSelected = groupNames.every(name => data.permissions.includes(name));
|
||||
|
||||
if (allSelected) {
|
||||
// Unselect all
|
||||
setData('permissions', data.permissions.filter(p => !groupNames.includes(p)));
|
||||
} else {
|
||||
// Select all
|
||||
const newPermissions = [...data.permissions];
|
||||
groupNames.forEach(name => {
|
||||
if (!newPermissions.includes(name)) newPermissions.push(name);
|
||||
});
|
||||
setData('permissions', newPermissions);
|
||||
}
|
||||
};
|
||||
|
||||
const translateAction = (permissionName: string) => {
|
||||
const parts = permissionName.split('.');
|
||||
if (parts.length < 2) return permissionName;
|
||||
const action = parts[1];
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'view': '檢視',
|
||||
'create': '新增',
|
||||
'edit': '編輯',
|
||||
'delete': '刪除',
|
||||
'publish': '發布',
|
||||
'adjust': '調整',
|
||||
'transfer': '調撥',
|
||||
};
|
||||
|
||||
return map[action] || action;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '系統管理', href: '#' },
|
||||
{ label: '角色與權限', href: route('roles.index') },
|
||||
{ label: '編輯角色', href: route('roles.edit', role.id), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title={`編輯角色 - ${role.name}`} />
|
||||
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-[#01ab83]" />
|
||||
編輯角色
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
修改角色資料與權限設定
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={route('roles.index')}>
|
||||
<Button variant="outline" type="button">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||
disabled={processing}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
儲存變更
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Name */}
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
|
||||
<div className="max-w-md space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">角色名稱 (英文代號)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
className="font-mono bg-gray-50"
|
||||
disabled={role.name === 'super-admin'} // Should be handled by controller redirect, but extra safety
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-red-500">{errors.name}</p>
|
||||
)}
|
||||
{role.name === 'super-admin' ? (
|
||||
<div className="flex items-center gap-2 text-amber-600 text-sm mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>超級管理員角色名稱不可修改</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">
|
||||
請使用英文字母與連字號,例如: <code>warehouse-staff</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Matrix */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-grey-0">權限設定</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{groupedPermissions.map((group) => {
|
||||
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
||||
|
||||
return (
|
||||
<div key={group.key} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<span className="font-medium text-gray-700">{group.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleGroup(group.permissions)}
|
||||
className="text-xs h-7 text-[#01ab83] hover:text-[#01ab83] hover:bg-[#01ab83]/10"
|
||||
>
|
||||
{allGroupSelected ? '取消全選' : '全選'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 flex-1">
|
||||
<div className="space-y-3">
|
||||
{group.permissions.map((permission) => (
|
||||
<div key={permission.id} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={permission.name}
|
||||
checked={data.permissions.includes(permission.name)}
|
||||
onCheckedChange={() => togglePermission(permission.name)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={permission.name}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{translateAction(permission.name)}
|
||||
</label>
|
||||
<p className="text-[10px] text-gray-400 font-mono">
|
||||
{permission.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
150
resources/js/Pages/Admin/Role/Index.tsx
Normal file
150
resources/js/Pages/Admin/Role/Index.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Shield, Plus, Pencil, Trash2, Users } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
users_count: number;
|
||||
permissions_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
export default function RoleIndex({ roles }: Props) {
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (confirm(`確定要刪除角色「${name}」嗎?此操作無法復原。`)) {
|
||||
router.delete(route('roles.destroy', id), {
|
||||
onSuccess: () => toast.success('角色已刪除'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const translateRoleName = (name: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'super-admin': '超級管理員',
|
||||
'admin': '管理員',
|
||||
'warehouse-manager': '倉庫主管',
|
||||
'purchaser': '採購人員',
|
||||
'viewer': '檢視者',
|
||||
};
|
||||
return map[name] || name;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '系統管理', href: '#' },
|
||||
{ label: '角色與權限', href: route('roles.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="角色管理" />
|
||||
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-[#01ab83]" />
|
||||
角色與權限
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
設定系統角色與功能存取權限
|
||||
</p>
|
||||
</div>
|
||||
<Link href={route('roles.create')}>
|
||||
<Button className="bg-[#01ab83] hover:bg-[#019a76]">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增角色
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">角色名稱</TableHead>
|
||||
<TableHead>代號</TableHead>
|
||||
<TableHead className="text-center">權限數量</TableHead>
|
||||
<TableHead className="text-center">使用者人數</TableHead>
|
||||
<TableHead className="text-left">建立時間</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<Shield className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
{translateRoleName(role.name)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 font-mono text-xs">
|
||||
{role.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{role.permissions_count} 項權限
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 text-gray-600">
|
||||
<Users className="h-3 w-3" />
|
||||
{role.users_count}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-left text-gray-500 text-sm">
|
||||
{format(new Date(role.created_at), 'yyyy/MM/dd')}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{role.name !== 'super-admin' && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href={route('roles.edit', role.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-8 w-8 p-0"
|
||||
title="編輯"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error h-8 w-8 p-0"
|
||||
title="刪除"
|
||||
disabled={role.users_count > 0}
|
||||
onClick={() => handleDelete(role.id, translateRoleName(role.name))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
202
resources/js/Pages/Admin/User/Create.tsx
Normal file
202
resources/js/Pages/Admin/User/Create.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Users, ArrowLeft, Check, Lock, Mail, User } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Props {
|
||||
roles: Record<string, string>; // ID -> Name map from pluck
|
||||
}
|
||||
|
||||
export default function UserCreate({ roles }: Props) {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
roles: [] as string[], // Role names
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
post(route('users.store'));
|
||||
};
|
||||
|
||||
const toggleRole = (roleName: string) => {
|
||||
if (data.roles.includes(roleName)) {
|
||||
setData('roles', data.roles.filter(r => r !== roleName));
|
||||
} else {
|
||||
setData('roles', [...data.roles, roleName]);
|
||||
}
|
||||
};
|
||||
|
||||
const translateRoleName = (name: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'super-admin': '超級管理員',
|
||||
'admin': '管理員',
|
||||
'warehouse-manager': '倉庫主管',
|
||||
'purchaser': '採購人員',
|
||||
'viewer': '檢視者',
|
||||
};
|
||||
return map[name] || name;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '系統管理', href: '#' },
|
||||
{ label: '使用者管理', href: route('users.index') },
|
||||
{ label: '新增使用者', href: route('users.create'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="新增使用者" />
|
||||
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||
新增使用者
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
建立新帳號並設定初始密碼與角色
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="outline" type="button">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||
disabled={processing}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
建立帳號
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">基本資料</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" /> 姓名
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
placeholder="例如:王小明"
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" /> 電子郵件 (選填)
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={e => setData('email', e.target.value)}
|
||||
placeholder="user@example.com (可省略)"
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" /> 使用者名稱 (登入用)
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={data.username}
|
||||
onChange={e => setData('username', e.target.value)}
|
||||
placeholder="請輸入登入帳號"
|
||||
/>
|
||||
{errors.username && <p className="text-sm text-red-500">{errors.username}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">安全設定</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" /> 設定密碼
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={data.password}
|
||||
onChange={e => setData('password', e.target.value)}
|
||||
/>
|
||||
{errors.password && <p className="text-sm text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password_confirmation" className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" /> 確認密碼
|
||||
</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
value={data.password_confirmation}
|
||||
onChange={e => setData('password_confirmation', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm h-full">
|
||||
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">角色分配</h3>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(roles).map(([id, name]) => (
|
||||
<div key={id} className="flex items-start space-x-3 p-2 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<Checkbox
|
||||
id={`role-${id}`}
|
||||
checked={data.roles.includes(name)}
|
||||
onCheckedChange={() => toggleRole(name)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={`role-${id}`}
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
{translateRoleName(name)}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{errors.roles && <p className="text-sm text-red-500">{errors.roles}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
223
resources/js/Pages/Admin/User/Edit.tsx
Normal file
223
resources/js/Pages/Admin/User/Edit.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Users, ArrowLeft, Check, Lock, Mail, User, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: UserData;
|
||||
roles: Role[];
|
||||
currentRoles: string[];
|
||||
}
|
||||
|
||||
export default function UserEdit({ user, roles, currentRoles }: Props) {
|
||||
const { data, setData, put, processing, errors } = useForm({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
username: user.username || '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
roles: currentRoles,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
put(route('users.update', user.id));
|
||||
};
|
||||
|
||||
const toggleRole = (roleName: string) => {
|
||||
if (data.roles.includes(roleName)) {
|
||||
setData('roles', data.roles.filter(r => r !== roleName));
|
||||
} else {
|
||||
setData('roles', [...data.roles, roleName]);
|
||||
}
|
||||
};
|
||||
|
||||
const translateRoleName = (name: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'super-admin': '超級管理員',
|
||||
'admin': '管理員',
|
||||
'warehouse-manager': '倉庫主管',
|
||||
'purchaser': '採購人員',
|
||||
'viewer': '檢視者',
|
||||
};
|
||||
return map[name] || name;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '系統管理', href: '#' },
|
||||
{ label: '使用者管理', href: route('users.index') },
|
||||
{ label: '編輯使用者', href: route('users.edit', user.id), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title={`編輯使用者 - ${user.name}`} />
|
||||
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||
編輯使用者
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
修改使用者資料、重設密碼或變更角色
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={route('users.index')}>
|
||||
<Button variant="outline" type="button">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||
disabled={processing}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
儲存變更
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">基本資料</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" /> 姓名
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
placeholder="例如:王小明"
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" /> 電子郵件 (選填)
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={e => setData('email', e.target.value)}
|
||||
placeholder="user@example.com (可省略)"
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" /> 使用者名稱 (登入用)
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={data.username}
|
||||
onChange={e => setData('username', e.target.value)}
|
||||
placeholder="請輸入登入帳號"
|
||||
/>
|
||||
{errors.username && <p className="text-sm text-red-500">{errors.username}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">重設密碼</h3>
|
||||
<div className="bg-amber-50 text-amber-800 p-3 rounded-lg text-sm flex items-start gap-2 mb-4">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
若不修改密碼,請留空以下欄位。
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" /> 新密碼
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={data.password}
|
||||
onChange={e => setData('password', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.password && <p className="text-sm text-red-500">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password_confirmation" className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" /> 確認新密碼
|
||||
</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
type="password"
|
||||
value={data.password_confirmation}
|
||||
onChange={e => setData('password_confirmation', e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm h-full">
|
||||
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">角色分配</h3>
|
||||
<div className="space-y-4">
|
||||
{roles.map((role) => (
|
||||
<div key={role.id} className="flex items-start space-x-3 p-2 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<Checkbox
|
||||
id={`role-${role.id}`}
|
||||
checked={data.roles.includes(role.name)}
|
||||
onCheckedChange={() => toggleRole(role.name)}
|
||||
// Prevent changing super-admin if user is editing themselves? Or just backend protection.
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={`role-${role.id}`}
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
{translateRoleName(role.name)}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{role.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{errors.roles && <p className="text-sm text-red-500">{errors.roles}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
223
resources/js/Pages/Admin/User/Index.tsx
Normal file
223
resources/js/Pages/Admin/User/Index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Users, Plus, Pencil, Trash2, Mail, Shield } from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
created_at: string;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: {
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
users: {
|
||||
data: User[];
|
||||
meta?: Pagination; // Standard Laravel Pagination resource structure, but if simple paginate() it's direct properties
|
||||
} & Pagination; // paginate() returns object with data and meta properties mixed
|
||||
}
|
||||
|
||||
export default function UserIndex({ users }: Props) {
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (confirm(`確定要刪除使用者「${name}」嗎?此操作無法復原。`)) {
|
||||
router.delete(route('users.destroy', id), {
|
||||
onSuccess: () => toast.success('使用者已刪除'),
|
||||
onError: () => toast.error('刪除失敗,請檢查權限'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const translateRoleName = (name: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'super-admin': '超級管理員',
|
||||
'admin': '管理員',
|
||||
'warehouse-manager': '倉庫主管',
|
||||
'purchaser': '採購人員',
|
||||
'viewer': '檢視者',
|
||||
};
|
||||
return map[name] || name;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '系統管理', href: '#' },
|
||||
{ label: '使用者管理', href: route('users.index'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="使用者管理" />
|
||||
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||
使用者管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
管理系統使用者帳號與角色分配
|
||||
</p>
|
||||
</div>
|
||||
<Link href={route('users.create')}>
|
||||
<Button className="bg-[#01ab83] hover:bg-[#019a76]">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增使用者
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">使用者</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead className="w-[200px]">加入時間</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.data.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-[#01ab83]/10 flex items-center justify-center text-[#01ab83] font-bold">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{user.name}</p>
|
||||
<div className="flex items-center text-xs text-gray-500">
|
||||
<Mail className="h-3 w-3 mr-1" />
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.length > 0 ? (
|
||||
user.roles.map(role => (
|
||||
<span
|
||||
key={role.id}
|
||||
className={cn(
|
||||
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border",
|
||||
role.name === 'super-admin'
|
||||
? "bg-purple-50 text-purple-700 border-purple-200"
|
||||
: "bg-gray-100 text-gray-700 border-gray-200"
|
||||
)}
|
||||
>
|
||||
{role.name === 'super-admin' && <Shield className="h-3 w-3 mr-1" />}
|
||||
{translateRoleName(role.name)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm italic">未分配角色</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{format(new Date(user.created_at), 'yyyy/MM/dd')}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href={route('users.edit', user.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-8 w-8 p-0"
|
||||
title="編輯"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error h-8 w-8 p-0"
|
||||
title="刪除"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination - Simple implementation */}
|
||||
{users.links && users.links.length > 3 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
{/* Mobile pagination */}
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
顯示第 <span className="font-medium">{users.current_page}</span> 頁
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{users.links.map((link, i) => {
|
||||
if (link.url === null) return null; // Skip null links usually
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
href={link.url}
|
||||
className={cn(
|
||||
"relative inline-flex items-center px-4 py-2 border text-sm font-medium",
|
||||
link.active
|
||||
? "z-10 bg-[#01ab83] border-[#01ab83] text-white"
|
||||
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50",
|
||||
i === 0 ? "rounded-l-md" : "",
|
||||
i === users.links.length - 1 ? "rounded-r-md" : ""
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
// Helper for conditional class names if not imported
|
||||
function cn(...classes: (string | undefined | null | false)[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Plus, Search, X } from "lucide-react";
|
||||
import { Plus, Search, Package, X } from 'lucide-react';
|
||||
import ProductTable from "@/Components/Product/ProductTable";
|
||||
import ProductDialog from "@/Components/Product/ProductDialog";
|
||||
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
|
||||
@@ -176,8 +176,11 @@ export default function ProductManagement({ products, categories, units, filters
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">商品資料管理</h1>
|
||||
<p className="text-gray-600">管理小小冰室原物料與成品資料</p>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Package className="h-6 w-6 text-[#01ab83]" />
|
||||
商品資料管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">管理小小冰室原物料與成品資料</p>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Search, X, ShoppingCart } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
@@ -92,16 +92,23 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="mb-2">管理採購單</h1>
|
||||
<p className="text-gray-600">追蹤並管理所有倉庫的採購申請與進度</p>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<ShoppingCart className="h-6 w-6 text-[#01ab83]" />
|
||||
採購單管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
建立與管理採購訂單,追蹤入庫狀態
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleNavigateToCreateOrder}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
建立採購單
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNavigateToCreateOrder}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
建立採購單
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
|
||||
9
resources/js/Pages/Vendor/Index.tsx
vendored
9
resources/js/Pages/Vendor/Index.tsx
vendored
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Plus, Search, X } from "lucide-react";
|
||||
import { Plus, Search, X, Contact2 } from "lucide-react";
|
||||
import VendorTable from "@/Components/Vendor/VendorTable";
|
||||
import VendorDialog from "@/Components/Vendor/VendorDialog";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
@@ -130,8 +130,11 @@ export default function VendorManagement({ vendors, filters }: PageProps) {
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">廠商資料管理</h1>
|
||||
<p className="text-gray-600">管理 ERP 系統供應商與聯絡資訊</p>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Contact2 className="h-6 w-6 text-[#01ab83]" />
|
||||
廠商資料管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">管理 ERP 系統供應商與聯絡資訊</p>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
@@ -35,9 +35,6 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
|
||||
const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false);
|
||||
|
||||
// 暫時的 Mock Inventories,直到後端 API 實作
|
||||
|
||||
|
||||
// 搜尋處理
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
@@ -49,7 +46,7 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
};
|
||||
|
||||
// 導航處理
|
||||
const handleViewInventory = (warehouseId: string) => {
|
||||
const handleViewInventory = (warehouseId: string | number) => {
|
||||
router.get(`/warehouses/${warehouseId}/inventory`);
|
||||
};
|
||||
|
||||
@@ -77,16 +74,14 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWarehouse = (id: string) => {
|
||||
const handleDeleteWarehouse = (id: string | number) => {
|
||||
router.delete(route('warehouses.destroy', id), {
|
||||
onSuccess: () => {
|
||||
toast.success('倉庫已刪除');
|
||||
setEditingWarehouse(null);
|
||||
},
|
||||
onError: (errors: any) => {
|
||||
// If backend returns error bag or flash error
|
||||
// Flash error is handled by AuthenticatedLayout usually via usePage props.
|
||||
// But we can also check errors bag here if needed.
|
||||
console.error(errors);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -114,8 +109,13 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">倉庫管理</h1>
|
||||
<p className="text-gray-600 font-medium mb-4">管理倉庫資訊與庫存配置</p>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<WarehouseIcon className="h-6 w-6 text-[#01ab83]" />
|
||||
倉庫管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
管理倉庫地點、負責人與庫存監控
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 工具列 */}
|
||||
|
||||
77
resources/js/hooks/usePermission.ts
Normal file
77
resources/js/hooks/usePermission.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types/global';
|
||||
|
||||
/**
|
||||
* 權限判斷 Hook
|
||||
* 提供權限與角色檢查功能
|
||||
*/
|
||||
export function usePermission() {
|
||||
const { auth } = usePage<PageProps>().props;
|
||||
const user = auth.user;
|
||||
|
||||
/**
|
||||
* 檢查使用者是否擁有指定權限
|
||||
*/
|
||||
const can = (permission: string): boolean => {
|
||||
if (!user) return false;
|
||||
return user.permissions.includes(permission);
|
||||
};
|
||||
|
||||
/**
|
||||
* 檢查使用者是否擁有任一指定權限
|
||||
*/
|
||||
const canAny = (permissions: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
return permissions.some(p => user.permissions.includes(p));
|
||||
};
|
||||
|
||||
/**
|
||||
* 檢查使用者是否擁有所有指定權限
|
||||
*/
|
||||
const canAll = (permissions: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
return permissions.every(p => user.permissions.includes(p));
|
||||
};
|
||||
|
||||
/**
|
||||
* 檢查使用者是否擁有指定角色
|
||||
*/
|
||||
const hasRole = (role: string): boolean => {
|
||||
if (!user) return false;
|
||||
return user.roles.includes(role);
|
||||
};
|
||||
|
||||
/**
|
||||
* 檢查使用者是否擁有任一指定角色
|
||||
*/
|
||||
const hasAnyRole = (roles: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
return roles.some(r => user.roles.includes(r));
|
||||
};
|
||||
|
||||
/**
|
||||
* 檢查使用者是否擁有所有指定角色
|
||||
*/
|
||||
const hasAllRoles = (roles: string[]): boolean => {
|
||||
if (!user) return false;
|
||||
return roles.every(r => user.roles.includes(r));
|
||||
};
|
||||
|
||||
/**
|
||||
* 檢查使用者是否為超級管理員
|
||||
*/
|
||||
const isSuperAdmin = (): boolean => {
|
||||
return hasRole('super-admin');
|
||||
};
|
||||
|
||||
return {
|
||||
can,
|
||||
canAny,
|
||||
canAll,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasAllRoles,
|
||||
isSuperAdmin,
|
||||
user
|
||||
};
|
||||
}
|
||||
19
resources/js/types/global.d.ts
vendored
19
resources/js/types/global.d.ts
vendored
@@ -1,6 +1,25 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { route as routeFn } from 'ziggy-js';
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface PageProps {
|
||||
auth: {
|
||||
user: AuthUser | null;
|
||||
};
|
||||
flash: {
|
||||
success?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
axios: AxiosInstance;
|
||||
|
||||
Reference in New Issue
Block a user