2026-02-04 11:07:32 +08:00
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' : '刪除' ,
2026-02-04 13:08:05 +08:00
2026-02-04 11:07:32 +08:00
'adjust' : '調整' ,
'transfer' : '調撥' ,
'count' : '盤點' ,
'safety_stock' : '安全庫存設定' ,
'export' : '匯出' ,
'complete' : '完成' ,
'view_cost' : '檢視成本' ,
'view_logs' : '檢視日誌' ,
'activate' : '啟用/停用' ,
2026-02-06 15:32:12 +08:00
'approve' : '核准/退回' ,
'cancel' : '取消' ,
2026-02-04 11:07:32 +08:00
} ;
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 ;
2026-02-06 15:32:12 +08:00
// 將權限分為「基本操作」與「狀態/進階操作」
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 ( ) || '' ) ) ;
2026-02-04 11:07:32 +08:00
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 }
2026-02-06 15:32:12 +08:00
onCheckedChange = { ( ) = > toggleGroup ( group . permissions ) }
2026-02-04 11:07:32 +08:00
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" >
2026-02-06 15:32:12 +08:00
< 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" >
基 本 功 能 權 限
2026-02-04 11:07:32 +08:00
< / div >
2026-02-06 15:32:12 +08:00
< 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 >
) }
2026-02-04 11:07:32 +08:00
< / div >
< / AccordionContent >
< / AccordionItem >
) ;
} )
) }
< / Accordion >
< / div >
) ;
}
2026-02-06 15:32:12 +08:00
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"
>
{ translate ( permission . name ) }
< / label >
< p className = "text-[10px] text-gray-400 font-mono" >
{ permission . name }
< / p >
< / div >
< / div >
) ;
}