Files
star-erp/resources/js/Pages/Admin/Role/PermissionSelector.tsx

319 lines
15 KiB
TypeScript
Raw Normal View History

import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/Components/ui/accordion";
import { Button } from "@/Components/ui/button";
import { Checkbox } from "@/Components/ui/checkbox";
import { Input } from "@/Components/ui/input";
import { cn } from "@/lib/utils";
import { useState, useEffect, useMemo } from "react";
import { ChevronsDown, ChevronsUp } from "lucide-react";
export interface Permission {
id: number;
name: string;
display_name?: string;
}
export interface GroupedPermission {
key: string;
name: string;
permissions: Permission[];
}
interface PermissionSelectorProps {
groupedPermissions: GroupedPermission[];
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
}
export default function PermissionSelector({ groupedPermissions, selectedPermissions, onChange }: PermissionSelectorProps) {
// 翻譯權限後綴
const translateAction = (permissionName: string) => {
const parts = permissionName.split('.');
if (parts.length < 2) return permissionName;
const action = parts[parts.length - 1];
const map: Record<string, string> = {
'view': '檢視',
'create': '新增',
'edit': '編輯',
'delete': '刪除',
'adjust': '調整',
'transfer': '調撥',
'count': '盤點',
'safety_stock': '安全庫存設定',
'export': '匯出',
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
'activate': '啟用/停用',
'approve': '核准/退回',
'cancel': '取消',
};
const actionText = map[action] || action;
// 處理多段式權限 (例如 inventory_count.view)
if (parts.length >= 2) {
const middleKey = parts[parts.length - 2];
// 如果中間那段有翻譯且不等於動作本身,則顯示為 "標籤: 動作"
if (map[middleKey] && middleKey !== action) {
return `${map[middleKey]}: ${actionText}`;
}
}
return actionText;
};
const togglePermission = (name: string) => {
if (selectedPermissions.includes(name)) {
onChange(selectedPermissions.filter(p => p !== name));
} else {
onChange([...selectedPermissions, name]);
}
};
const toggleGroup = (groupPermissions: Permission[]) => {
const groupNames = groupPermissions.map(p => p.name);
const allSelected = groupNames.every(name => selectedPermissions.includes(name));
if (allSelected) {
// Unselect all
onChange(selectedPermissions.filter(p => !groupNames.includes(p)));
} else {
// Select all
const newPermissions = [...selectedPermissions];
groupNames.forEach(name => {
if (!newPermissions.includes(name)) newPermissions.push(name);
});
onChange(newPermissions);
}
};
// State for controlling accordion items
const [openItems, setOpenItems] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
// Memoize filtered groups to prevent infinite loops in useEffect
const filteredGroups = useMemo(() => {
return groupedPermissions.map(group => {
// If search is empty, return group as is
if (!searchQuery.trim()) return group;
// Check if group name matches
const groupNameMatch = group.name.toLowerCase().includes(searchQuery.toLowerCase());
// Filter permissions that match
const matchingPermissions = group.permissions.filter(p => {
const translatedName = p.display_name || translateAction(p.name);
return translatedName.includes(searchQuery) ||
p.name.toLowerCase().includes(searchQuery.toLowerCase());
});
// If group name matches, show all permissions. Otherwise show only matching permissions.
if (groupNameMatch) {
return group;
}
return {
...group,
permissions: matchingPermissions
};
}).filter(group => group.permissions.length > 0);
}, [groupedPermissions, searchQuery]);
const currentDisplayKeys = useMemo(() => filteredGroups.map(g => g.key), [filteredGroups]);
const onExpandAll = () => setOpenItems(currentDisplayKeys);
const onCollapseAll = () => setOpenItems([]);
// Auto-expand groups when searching
useEffect(() => {
if (searchQuery.trim()) {
const filteredKeys = filteredGroups.map(g => g.key);
setOpenItems(prev => {
const next = new Set([...prev, ...filteredKeys]);
return Array.from(next);
});
}
// removing the 'else' block which forced collapse on empty search
// We let the user manually collapse if they want, or we could reset only when search is cleared explicitly?
// User behavior: if I finish searching, I might want to see my previous state, but "Expand All" failing was the main issue.
// The issue was the effect running on every render resetting state.
}, [searchQuery]); // Only run when query changes. We actually depend on filteredGroups result but only when query changes matters most for "auto" trigger.
// Actually, correctly: if filteredGroups changes due to search change, we expand.
// Better interaction: When search query *changes* and is not empty, we expand matches.
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-2">
<div className="relative flex-1 max-w-sm">
<Input
placeholder="搜尋權限名稱..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
<div className="absolute left-2.5 top-2.5 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
</div>
</div>
<div className="flex items-center justify-between sm:justify-end gap-4 w-full sm:w-auto">
<div className="text-sm text-gray-500 whitespace-nowrap">
{selectedPermissions.length}
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onExpandAll}
className="h-8 text-xs text-gray-600 hover:text-primary-main gap-1.5"
>
<ChevronsDown className="h-3.5 w-3.5" />
</Button>
<div className="h-4 w-px bg-gray-200" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCollapseAll}
className="h-8 text-xs text-gray-600 hover:text-primary-main gap-1.5"
>
<ChevronsUp className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<Accordion
type="multiple"
value={openItems}
onValueChange={setOpenItems}
className="w-full space-y-2"
>
{filteredGroups.length === 0 ? (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
{searchQuery}
</div>
) : (
filteredGroups.map((group) => {
const selectedCount = group.permissions.filter(p => selectedPermissions.includes(p.name)).length;
const totalCount = group.permissions.length;
const isAllSelected = selectedCount === totalCount;
// 將權限分為「基本操作」與「狀態/進階操作」
const statusActions = ['approve', 'cancel', 'complete', 'activate'];
const normalPermissions = group.permissions.filter(p => !statusActions.includes(p.name.split('.').pop() || ''));
const specialPermissions = group.permissions.filter(p => statusActions.includes(p.name.split('.').pop() || ''));
return (
<AccordionItem
key={group.key}
value={group.key}
className="bg-white border rounded-lg px-2 data-[state=open]:bg-gray-50/50 last:border-b"
>
<div className="flex items-center w-full">
<div className="flex items-center pl-2 pr-1">
<Checkbox
id={`group-select-${group.key}`}
checked={isAllSelected}
onCheckedChange={() => toggleGroup(group.permissions)}
className="data-[state=checked]:bg-primary-main"
/>
</div>
<AccordionTrigger className="px-2 hover:no-underline hover:bg-gray-50 rounded-lg group flex-1 py-3">
<div className="flex items-center gap-3">
<span className={cn(
"font-medium",
selectedCount > 0 ? "text-primary-main" : "text-gray-700"
)}>
{group.name}
</span>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full font-mono">
{selectedCount} / {totalCount}
</span>
</div>
</AccordionTrigger>
</div>
<AccordionContent className="px-2 pb-4">
<div className="pl-10 space-y-6 pt-1">
{/* 基本操作 */}
{normalPermissions.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
{normalPermissions.map((permission) => (
<PermissionItem
key={permission.id}
permission={permission}
selectedPermissions={selectedPermissions}
onToggle={togglePermission}
translate={translateAction}
/>
))}
</div>
</div>
)}
{/* 狀態操作/進階權限 */}
{specialPermissions.length > 0 && (
<div className="space-y-3 pt-2 border-t border-gray-100 italic">
<div className="text-xs font-semibold text-amber-600/70 uppercase tracking-wider flex items-center gap-1">
<span className="w-1 h-3 bg-amber-500 rounded-full" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
{specialPermissions.map((permission) => (
<PermissionItem
key={permission.id}
permission={permission}
selectedPermissions={selectedPermissions}
onToggle={togglePermission}
translate={translateAction}
/>
))}
</div>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
);
})
)}
</Accordion>
</div>
);
}
function PermissionItem({ permission, selectedPermissions, onToggle, translate }: any) {
return (
<div className="flex items-start space-x-3">
<Checkbox
id={permission.name}
checked={selectedPermissions.includes(permission.name)}
onCheckedChange={() => onToggle(permission.name)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={permission.name}
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer text-gray-700 hover:text-primary-main transition-colors"
>
{permission.display_name || translate(permission.name)}
</label>
<p className="text-[10px] text-gray-400 font-mono">
{permission.name}
</p>
</div>
</div>
);
}