From a0c450d22927858797bfd7e12d1b7e892967bf2b Mon Sep 17 00:00:00 2001 From: sky121113 Date: Wed, 4 Feb 2026 11:07:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor(role):=20=E9=87=8D=E6=A7=8B=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=AC=8A=E9=99=90=E9=81=B8=E6=93=87=E4=BB=8B=E9=9D=A2?= =?UTF-8?q?=E4=B8=A6=E6=96=B0=E5=A2=9E=E5=BF=AB=E9=80=9F=E6=90=9C=E5=B0=8B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 PermissionSelector 組件,採用 Accordion 折疊式設計 2. 實作全選/取消全選、展開/收合全部功能 3. 新增權限搜尋過濾器,支援自動展開與中文關鍵字搜尋 4. 優化 UI細節:修正邊框顯示、調整全選框位置與邏輯 --- resources/js/Pages/Admin/Role/Create.tsx | 114 +------ resources/js/Pages/Admin/Role/Edit.tsx | 129 +------- .../Pages/Admin/Role/PermissionSelector.tsx | 277 ++++++++++++++++++ 3 files changed, 289 insertions(+), 231 deletions(-) create mode 100644 resources/js/Pages/Admin/Role/PermissionSelector.tsx diff --git a/resources/js/Pages/Admin/Role/Create.tsx b/resources/js/Pages/Admin/Role/Create.tsx index f0ea561..92a80a7 100644 --- a/resources/js/Pages/Admin/Role/Create.tsx +++ b/resources/js/Pages/Admin/Role/Create.tsx @@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check } 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 Permission { - id: number; - name: string; -} - -interface GroupedPermission { - key: string; - name: string; - permissions: Permission[]; -} +import PermissionSelector, { GroupedPermission } from './PermissionSelector'; interface Props { groupedPermissions: GroupedPermission[]; @@ -34,56 +23,6 @@ export default function RoleCreate({ groupedPermissions }: Props) { 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 = { - 'view': '檢視', - 'create': '新增', - 'edit': '編輯', - 'delete': '刪除', - 'publish': '發布', - 'adjust': '調整', - 'transfer': '調撥', - 'safety_stock': '安全庫存設定', - 'export': '匯出', - 'complete': '完成', - 'view_cost': '檢視成本', - 'view_logs': '檢視日誌', - 'activate': '啟用/停用', - }; - - return map[action] || action; - }; - return (

權限設定

-
- {groupedPermissions.map((group) => { - const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name)); - - return ( -
-
- {group.name} - -
-
-
- {group.permissions.map((permission) => ( -
- togglePermission(permission.name)} - /> -
- -

- {permission.name} -

-
-
- ))} -
-
-
- ); - })} -
+ setData('permissions', permissions)} + /> diff --git a/resources/js/Pages/Admin/Role/Edit.tsx b/resources/js/Pages/Admin/Role/Edit.tsx index 3dec114..4bb0a30 100644 --- a/resources/js/Pages/Admin/Role/Edit.tsx +++ b/resources/js/Pages/Admin/Role/Edit.tsx @@ -4,19 +4,8 @@ import { Shield, ArrowLeft, Check, 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 Permission { - id: number; - name: string; -} - -interface GroupedPermission { - key: string; - name: string; - permissions: Permission[]; -} +import PermissionSelector, { GroupedPermission } from './PermissionSelector'; interface Role { id: number; @@ -42,71 +31,6 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions 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 = { - '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 (

權限設定

-
- {groupedPermissions.map((group) => { - const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name)); - - return ( -
-
- {group.name} - -
-
-
- {group.permissions.map((permission) => ( -
- togglePermission(permission.name)} - /> -
- -

- {permission.name} -

-
-
- ))} -
-
-
- ); - })} -
+ setData('permissions', permissions)} + /> diff --git a/resources/js/Pages/Admin/Role/PermissionSelector.tsx b/resources/js/Pages/Admin/Role/PermissionSelector.tsx new file mode 100644 index 0000000..c765b3a --- /dev/null +++ b/resources/js/Pages/Admin/Role/PermissionSelector.tsx @@ -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 = { + '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([]); + 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 ( +
+
+
+ setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ +
+
+ +
+
+ 已選擇 {selectedPermissions.length} 項 +
+
+ +
+ +
+
+
+ + + {filteredGroups.length === 0 ? ( +
+ 沒有符合「{searchQuery}」的權限項目 +
+ ) : ( + filteredGroups.map((group) => { + const selectedCount = group.permissions.filter(p => selectedPermissions.includes(p.name)).length; + const totalCount = group.permissions.length; + const isAllSelected = selectedCount === totalCount; + + return ( + +
+ {/* Group Selection Checkbox - Moved outside trigger to avoid bubbling issues, positioned left */} +
+ { + // 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" + /> +
+ + +
+ 0 ? "text-primary-main" : "text-gray-700" + )}> + {group.name} + + + {selectedCount} / {totalCount} + +
+
+
+ +
+ {/* Permissions Grid */} +
+ {group.permissions.map((permission) => ( +
+ togglePermission(permission.name)} + /> +
+ +

+ {permission.name} +

+
+
+ ))} +
+
+
+
+ ); + }) + )} +
+
+ ); +}