feat: 完成權限管理系統、統一頁面標題樣式與表格對齊規範

This commit is contained in:
2026-01-13 13:30:51 +08:00
parent 6770a4ec2f
commit ecfcbb93ed
28 changed files with 2333 additions and 34 deletions

View 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>
);
}

View 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>
);
}

View 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(' ');
}