refactor(role): 重構角色權限選擇介面並新增快速搜尋功能
1. 新增 PermissionSelector 組件,採用 Accordion 折疊式設計 2. 實作全選/取消全選、展開/收合全部功能 3. 新增權限搜尋過濾器,支援自動展開與中文關鍵字搜尋 4. 優化 UI細節:修正邊框顯示、調整全選框位置與邏輯
This commit is contained in:
@@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check } from 'lucide-react';
|
|||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Input } from '@/Components/ui/input';
|
import { Input } from '@/Components/ui/input';
|
||||||
import { Label } from '@/Components/ui/label';
|
import { Label } from '@/Components/ui/label';
|
||||||
import { Checkbox } from '@/Components/ui/checkbox';
|
|
||||||
import { FormEvent } from 'react';
|
import { FormEvent } from 'react';
|
||||||
|
import PermissionSelector, { GroupedPermission } from './PermissionSelector';
|
||||||
interface Permission {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedPermission {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
permissions: Permission[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groupedPermissions: GroupedPermission[];
|
groupedPermissions: GroupedPermission[];
|
||||||
@@ -34,56 +23,6 @@ export default function RoleCreate({ groupedPermissions }: Props) {
|
|||||||
post(route('roles.store'));
|
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': '調撥',
|
|
||||||
'safety_stock': '安全庫存設定',
|
|
||||||
'export': '匯出',
|
|
||||||
'complete': '完成',
|
|
||||||
'view_cost': '檢視成本',
|
|
||||||
'view_logs': '檢視日誌',
|
|
||||||
'activate': '啟用/停用',
|
|
||||||
};
|
|
||||||
|
|
||||||
return map[action] || action;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -171,52 +110,11 @@ export default function RoleCreate({ groupedPermissions }: Props) {
|
|||||||
{/* Permissions Matrix */}
|
{/* Permissions Matrix */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-bold text-grey-0">權限設定</h2>
|
<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">
|
<PermissionSelector
|
||||||
{groupedPermissions.map((group) => {
|
groupedPermissions={groupedPermissions}
|
||||||
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
selectedPermissions={data.permissions}
|
||||||
|
onChange={(permissions) => setData('permissions', permissions)}
|
||||||
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-primary-main hover:text-primary-main hover:bg-primary-main/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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check, AlertCircle } from 'lucide-react';
|
|||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Input } from '@/Components/ui/input';
|
import { Input } from '@/Components/ui/input';
|
||||||
import { Label } from '@/Components/ui/label';
|
import { Label } from '@/Components/ui/label';
|
||||||
import { Checkbox } from '@/Components/ui/checkbox';
|
|
||||||
import { FormEvent } from 'react';
|
import { FormEvent } from 'react';
|
||||||
|
import PermissionSelector, { GroupedPermission } from './PermissionSelector';
|
||||||
interface Permission {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedPermission {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
permissions: Permission[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Role {
|
interface Role {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -42,71 +31,6 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
|
|||||||
put(route('roles.update', role.id));
|
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[parts.length - 1];
|
|
||||||
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
'view': '檢視',
|
|
||||||
'create': '新增',
|
|
||||||
'edit': '編輯',
|
|
||||||
'delete': '刪除',
|
|
||||||
'publish': '發佈',
|
|
||||||
'adjust': '調整',
|
|
||||||
'transfer': '調撥',
|
|
||||||
'count': '盤點',
|
|
||||||
// 'inventory_count': '盤點', // Hide prefix
|
|
||||||
// 'inventory_adjust': '盤調', // Hide prefix
|
|
||||||
// 'inventory_transfer': '調撥', // Hide prefix
|
|
||||||
'safety_stock': '安全庫存設定',
|
|
||||||
'export': '匯出',
|
|
||||||
'complete': '完成',
|
|
||||||
'view_cost': '檢視成本',
|
|
||||||
'view_logs': '檢視日誌',
|
|
||||||
'activate': '啟用/停用',
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -201,52 +125,11 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
|
|||||||
{/* Permissions Matrix */}
|
{/* Permissions Matrix */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-bold text-grey-0">權限設定</h2>
|
<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">
|
<PermissionSelector
|
||||||
{groupedPermissions.map((group) => {
|
groupedPermissions={groupedPermissions}
|
||||||
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
selectedPermissions={data.permissions}
|
||||||
|
onChange={(permissions) => setData('permissions', permissions)}
|
||||||
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-primary-main hover:text-primary-main hover:bg-primary-main/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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
277
resources/js/Pages/Admin/Role/PermissionSelector.tsx
Normal file
277
resources/js/Pages/Admin/Role/PermissionSelector.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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': '刪除',
|
||||||
|
'publish': '發布',
|
||||||
|
'adjust': '調整',
|
||||||
|
'transfer': '調撥',
|
||||||
|
'count': '盤點',
|
||||||
|
'safety_stock': '安全庫存設定',
|
||||||
|
'export': '匯出',
|
||||||
|
'complete': '完成',
|
||||||
|
'view_cost': '檢視成本',
|
||||||
|
'view_logs': '檢視日誌',
|
||||||
|
'activate': '啟用/停用',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = 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;
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* Group Selection Checkbox - Moved outside trigger to avoid bubbling issues, positioned left */}
|
||||||
|
<div className="flex items-center pl-2 pr-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`group-select-${group.key}`}
|
||||||
|
checked={isAllSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
// Stop propagation to prevent accordion from toggling
|
||||||
|
// This is implicitly handled by the checkbox being a sibling,
|
||||||
|
// but if it were a child of AccordionTrigger, stopPropagation would be needed.
|
||||||
|
// For clarity, we can add it here if needed, but the current structure makes it unnecessary.
|
||||||
|
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-3 pt-1">
|
||||||
|
{/* Permissions Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
|
||||||
|
{group.permissions.map((permission) => (
|
||||||
|
<div key={permission.id} className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={permission.name}
|
||||||
|
checked={selectedPermissions.includes(permission.name)}
|
||||||
|
onCheckedChange={() => togglePermission(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"
|
||||||
|
>
|
||||||
|
{translateAction(permission.name)}
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-gray-400 font-mono">
|
||||||
|
{permission.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user