Files
star-erp/resources/js/Pages/Admin/User/Index.tsx
sky121113 d671c08338
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m1s
feat: 實作使用者啟停用功能與安全性強化
- 新增使用者「啟用/停用」狀態切換功能 (含後端 API、權限控管、活動紀錄)
- 強化安全性:隱藏超級管理員角色的可見度與操作權限
- 更新開發規範:加入多租戶資料同步規範於 framework.md
- 前端優化:使用 Switch 元件進行狀態快速切換,調整表格欄位順序
2026-02-03 11:51:46 +08:00

439 lines
20 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, useCallback } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';
import { Users, Plus, Pencil, Trash2, Mail, Shield, ArrowUpDown, ArrowUp, ArrowDown, Search, X } from 'lucide-react';
import { Input } from "@/Components/ui/input";
import { debounce } from "lodash";
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 { Switch } from "@/Components/ui/switch";
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[];
is_active: boolean;
}
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';
search?: string;
role?: string;
is_active?: string;
};
roles: Role[];
}
export default function UserIndex({ users, roles, filters }: Props) {
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [roleFilter, setRoleFilter] = useState<string>(filters.role || "all");
const [isActiveFilter, setIsActiveFilter] = useState<string>(filters.is_active || "all");
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, search: searchTerm, role: roleFilter, is_active: isActiveFilter },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
// Debounced Search Handler
const debouncedSearch = useCallback(
debounce((term: string, role: string, isActive: string) => {
router.get(
route('users.index'),
{ ...filters, search: term, role: role, is_active: isActive },
{ preserveState: true, replace: true, preserveScroll: true }
);
}, 500),
[]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedSearch(term, roleFilter, isActiveFilter);
};
const handleRoleChange = (value: string) => {
setRoleFilter(value);
router.get(
route('users.index'),
{ ...filters, search: searchTerm, role: value, is_active: isActiveFilter },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleIsActiveChange = (value: string) => {
setIsActiveFilter(value);
router.get(
route('users.index'),
{ ...filters, search: searchTerm, role: roleFilter, is_active: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleClearSearch = () => {
setSearchTerm("");
router.get(
route('users.index'),
{ ...filters, search: "", role: roleFilter, is_active: isActiveFilter },
{ preserveState: true, 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>
</div>
{/* Toolbar */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋姓名、Email、帳號..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10"
/>
{searchTerm && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Role Filter */}
<SearchableSelect
value={roleFilter}
onValueChange={handleRoleChange}
options={[
{ label: "全部角色", value: "all" },
...roles.map((role) => ({ label: role.display_name, value: role.id.toString() }))
]}
placeholder="角色篩選"
className="w-full md:w-[180px]"
/>
{/* Status Filter */}
<SearchableSelect
value={isActiveFilter}
onValueChange={handleIsActiveChange}
options={[
{ label: "全部狀態", value: "all" },
{ label: "啟用", value: "1" },
{ label: "停用", value: "0" },
]}
placeholder="狀態篩選"
className="w-full md:w-[120px]"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="users.create">
<Link href={route('users.create')} className="w-full md:w-auto">
<Button className="w-full md:w-auto button-filled-primary">
<Plus className="h-4 w-4 mr-2" />
使
</Button>
</Link>
</Can>
</div>
</div>
</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 className="w-[150px] text-center"></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-center">
<div className="flex items-center justify-center gap-3">
<div className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border whitespace-nowrap",
user.is_active
? "bg-green-50 text-green-700 border-green-200"
: "bg-gray-100 text-gray-700 border-gray-200"
)}>
<span className={cn(
"w-1.5 h-1.5 rounded-full mr-1.5",
user.is_active ? "bg-green-500" : "bg-gray-400"
)} />
{user.is_active ? '啟用' : '停用'}
</div>
<Can permission="users.activate">
<Switch
checked={user.is_active}
onCheckedChange={() => router.patch(route('users.toggle-active', user.id), {}, { preserveScroll: true })}
className="data-[state=checked]:bg-green-500"
/>
</Can>
</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 >
);
}