Files
star-erp/resources/js/Pages/Admin/User/Index.tsx
sky121113 7367577f6a
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat: 統一採購單與操作紀錄 UI、增強各模組操作紀錄功能
- 統一採購單篩選列與表單樣式 (移除舊元件、標準化 Input)
- 增強操作紀錄功能 (加入篩選、快照、詳細異動比對)
- 統一刪除確認視窗與按鈕樣式
- 修復庫存編輯頁面樣式
- 實作採購單品項異動紀錄
- 實作角色分配異動紀錄
- 擴充供應商與倉庫模組紀錄
2026-01-19 17:07:45 +08:00

308 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';
import { Users, Plus, Pencil, Trash2, Mail, Shield, ArrowUpDown, ArrowUp, ArrowDown } 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 { Can } from '@/Components/Permission/Can';
import { cn } from "@/lib/utils";
import Pagination from "@/Components/shared/Pagination";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
interface Role {
id: number;
name: string;
display_name: string;
}
interface User {
id: number;
name: string;
email: string;
username: string | null;
created_at: string;
roles: Role[];
}
interface PaginationLinks {
url: string | null;
label: string;
active: boolean;
}
interface Props {
users: {
data: User[];
from: number;
links: PaginationLinks[];
};
filters: {
per_page?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
};
}
export default function UserIndex({ users, filters }: Props) {
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleteName, setDeleteName] = useState<string>('');
const [modelOpen, setModelOpen] = useState(false);
const confirmDelete = (id: number, name: string) => {
setDeleteId(id);
setDeleteName(name);
setModelOpen(true);
};
const handleDelete = () => {
if (deleteId) {
router.delete(route('users.destroy', deleteId), {
onSuccess: () => {
setModelOpen(false);
},
onFinish: () => setModelOpen(false),
});
}
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route('users.index'),
{ ...filters, per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleSort = (field: string) => {
let newSortBy: string | undefined = field;
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
if (filters.sort_by === field) {
if (filters.sort_order === 'asc') {
newSortOrder = 'desc';
} else {
newSortBy = undefined;
newSortOrder = undefined;
}
}
router.get(
route('users.index'),
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
{ preserveState: true, replace: true }
);
};
const SortIcon = ({ field }: { field: string }) => {
if (filters.sort_by !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (filters.sort_order === "asc") {
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
}
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '系統管理', href: '#' },
{ label: '使用者管理', href: route('users.index'), isPage: true },
]}
>
<Head title="使用者管理" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Users className="h-6 w-6 text-primary-main" />
使
</h1>
<p className="text-gray-500 mt-1">
使
</p>
</div>
<Can permission="users.create">
<Link href={route('users.create')}>
<Button className="button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
使
</Button>
</Link>
</Can>
</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-[50px] text-center">#</TableHead>
<TableHead>
<button
onClick={() => handleSort('name')}
className="flex items-center gap-1 hover:text-gray-900 transition-colors"
>
使 <SortIcon field="name" />
</button>
</TableHead>
<TableHead></TableHead>
<TableHead>
<button
onClick={() => handleSort('created_at')}
className="flex items-center gap-1 hover:text-gray-900 transition-colors"
>
<SortIcon field="created_at" />
</button>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.data.map((user, index) => (
<TableRow key={user.id}>
<TableCell className="text-gray-500 font-medium text-center">
{users.from + index}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<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-2">
{user.roles.length > 0 ? (
user.roles.map(role => (
<div
key={role.id}
className={cn(
"inline-flex items-center px-2.5 py-1 rounded-md border",
role.name === 'super-admin'
? "bg-purple-50 border-purple-200"
: "bg-gray-50 border-gray-200"
)}
>
<div className="flex items-center gap-1.5">
{role.name === 'super-admin' && <Shield className="h-3.5 w-3.5 text-purple-600" />}
<span className={cn(
"text-sm font-medium",
role.name === 'super-admin' ? "text-purple-700" : "text-gray-900"
)}>
{role.display_name}
</span>
</div>
</div>
))
) : (
<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">
<Can permission="users.edit">
<Link href={route('users.edit', user.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
<Can permission="users.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => confirmDelete(user.id, user.name)}
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 分頁元件 - 統一樣式 */}
<div className="mt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
<Pagination links={users.links} />
</div>
</div>
</div>
<AlertDialog open={modelOpen} onOpenChange={setModelOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>使</AlertDialogTitle>
<AlertDialogDescription>
使{deleteName}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AuthenticatedLayout >
);
}