diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-26 09:57:24 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-26 09:57:24 +0000 |
| commit | 8b23b471638a155fd1bfa3a8c853b26d9315b272 (patch) | |
| tree | 47353e9dd342011cb2f1dcd24b09661707a8421b /components/permissions/permission-group-manager.tsx | |
| parent | d62368d2b68d73da895977e60a18f9b1286b0545 (diff) | |
(대표님) 권한관리, 문서업로드, rfq첨부, SWP문서룰 등
(최겸) 입찰
Diffstat (limited to 'components/permissions/permission-group-manager.tsx')
| -rw-r--r-- | components/permissions/permission-group-manager.tsx | 799 |
1 files changed, 799 insertions, 0 deletions
diff --git a/components/permissions/permission-group-manager.tsx b/components/permissions/permission-group-manager.tsx new file mode 100644 index 00000000..11aac6cf --- /dev/null +++ b/components/permissions/permission-group-manager.tsx @@ -0,0 +1,799 @@ +// components/permissions/permission-group-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Shield, + Plus, + Edit, + Trash2, + Copy, + Users, + Key, + MoreVertical, + Package, + ChevronRight, + Loader2, + Search +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getPermissionGroups, + createPermissionGroup, + updatePermissionGroup, + deletePermissionGroup, + getGroupPermissions, + updateGroupPermissions, + clonePermissionGroup, + getGroupAssignments, +} from "@/lib/permissions/permission-group-actions"; + +interface PermissionGroup { + id: number; + groupKey: string; + name: string; + description?: string; + domain?: string; + isActive: boolean; + permissionCount: number; + roleCount: number; + userCount: number; + createdAt: Date; + updatedAt: Date; +} + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + resource: string; + action: string; + permissionType: string; + scope: string; +} + +export function PermissionGroupManager() { + const [groups, setGroups] = useState<PermissionGroup[]>([]); + const [selectedGroup, setSelectedGroup] = useState<PermissionGroup | null>(null); + const [groupPermissions, setGroupPermissions] = useState<Permission[]>([]); + const [availablePermissions, setAvailablePermissions] = useState<Permission[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState<PermissionGroup | null>(null); + const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + + useEffect(() => { + loadGroups(); + }, []); + + useEffect(() => { + if (selectedGroup) { + loadGroupPermissions(selectedGroup.id); + } + }, [selectedGroup]); + + const loadGroups = async () => { + setLoading(true); + try { + const data = await getPermissionGroups(); + setGroups(data); + } catch (error) { + toast.error("권한 그룹을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const loadGroupPermissions = async (groupId: number) => { + try { + const data = await getGroupPermissions(groupId); + setGroupPermissions(data.permissions); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("권한 정보를 불러오는데 실패했습니다."); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { + return; + } + + try { + await deletePermissionGroup(id); + toast.success("권한 그룹이 삭제되었습니다."); + if (selectedGroup?.id === id) { + setSelectedGroup(null); + } + loadGroups(); + } catch (error) { + toast.error("권한 그룹 삭제에 실패했습니다."); + } + }; + + const handleClone = async (group: PermissionGroup) => { + try { + const cloned = await clonePermissionGroup(group.id); + toast.success(`"${cloned.name}" 그룹이 생성되었습니다.`); + loadGroups(); + } catch (error) { + toast.error("권한 그룹 복제에 실패했습니다."); + } + }; + + // 검색 필터링 + const filteredGroups = groups.filter(group => + group.name.toLowerCase().includes(searchQuery.toLowerCase()) || + group.groupKey.toLowerCase().includes(searchQuery.toLowerCase()) || + group.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>권한 그룹</CardTitle> + <CardDescription> + 관련된 권한들을 그룹으로 묶어 효율적으로 관리합니다. + </CardDescription> + </div> + <Button onClick={() => setCreateDialogOpen(true)}> + <Plus className="mr-2 h-4 w-4" /> + 그룹 생성 + </Button> + </div> + </CardHeader> + <CardContent> + {/* 검색 */} + <div className="mb-4"> + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="그룹명, 키로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + </div> + + {/* 그룹 목록 */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {filteredGroups.map(group => ( + <GroupCard + key={group.id} + group={group} + isSelected={selectedGroup?.id === group.id} + onSelect={() => setSelectedGroup(group)} + onEdit={() => setEditingGroup(group)} + onClone={() => handleClone(group)} + onDelete={() => handleDelete(group.id)} + onManagePermissions={() => { + setSelectedGroup(group); + setPermissionDialogOpen(true); + }} + /> + ))} + </div> + </CardContent> + </Card> + + {/* 선택된 그룹 상세 */} + {selectedGroup && ( + <GroupDetailCard + group={selectedGroup} + permissions={groupPermissions} + onEditPermissions={() => setPermissionDialogOpen(true)} + /> + )} + + {/* 그룹 생성/수정 다이얼로그 */} + <GroupFormDialog + open={createDialogOpen || !!editingGroup} + onOpenChange={(open) => { + if (!open) { + setCreateDialogOpen(false); + setEditingGroup(null); + } + }} + group={editingGroup} + onSuccess={() => { + setCreateDialogOpen(false); + setEditingGroup(null); + loadGroups(); + }} + /> + + {/* 권한 관리 다이얼로그 */} + {selectedGroup && ( + <GroupPermissionsDialog + open={permissionDialogOpen} + onOpenChange={setPermissionDialogOpen} + group={selectedGroup} + groupPermissions={groupPermissions} + availablePermissions={availablePermissions} + onSuccess={() => { + loadGroupPermissions(selectedGroup.id); + loadGroups(); + }} + /> + )} + </div> + ); +} + +// 그룹 카드 컴포넌트 +function GroupCard({ + group, + isSelected, + onSelect, + onEdit, + onClone, + onDelete, + onManagePermissions, +}: { + group: PermissionGroup; + isSelected: boolean; + onSelect: () => void; + onEdit: () => void; + onClone: () => void; + onDelete: () => void; + onManagePermissions: () => void; +}) { + return ( + <Card + className={cn( + "cursor-pointer transition-colors", + isSelected && "ring-2 ring-primary" + )} + onClick={onSelect} + > + <CardHeader className="pb-3"> + <div className="flex items-start justify-between"> + <div> + <CardTitle className="text-lg">{group.name}</CardTitle> + <div className="flex items-center gap-2 mt-1"> + <code className="text-xs bg-muted px-1 py-0.5 rounded"> + {group.groupKey} + </code> + {group.domain && ( + <Badge variant="outline" className="text-xs"> + {group.domain} + </Badge> + )} + </div> + </div> + <DropdownMenu> + <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> + <Button variant="ghost" className="h-8 w-8 p-0"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onManagePermissions(); + }}> + <Key className="mr-2 h-4 w-4" /> + 권한 관리 + </DropdownMenuItem> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onEdit(); + }}> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onClone(); + }}> + <Copy className="mr-2 h-4 w-4" /> + 복제 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={(e) => { + e.stopPropagation(); + onDelete(); + }} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </CardHeader> + <CardContent> + {group.description && ( + <p className="text-sm text-muted-foreground mb-3"> + {group.description} + </p> + )} + <div className="flex items-center gap-4 text-sm"> + <div className="flex items-center gap-1"> + <Key className="h-3 w-3" /> + <span>{group.permissionCount}개 권한</span> + </div> + <div className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + <span>{group.roleCount}개 역할</span> + </div> + <div className="flex items-center gap-1"> + <Shield className="h-3 w-3" /> + <span>{group.userCount}명</span> + </div> + </div> + </CardContent> + </Card> + ); +} + +// 그룹 상세 카드 +function GroupDetailCard({ + group, + permissions, + onEditPermissions, +}: { + group: PermissionGroup; + permissions: Permission[]; + onEditPermissions: () => void; +}) { + // 리소스별로 권한 그룹화 + const groupedPermissions = permissions.reduce((acc, perm) => { + const resource = perm.resource; + if (!acc[resource]) acc[resource] = []; + acc[resource].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>{group.name} 권한 목록</CardTitle> + <CardDescription>이 그룹에 포함된 모든 권한입니다.</CardDescription> + </div> + <Button onClick={onEditPermissions}> + <Edit className="mr-2 h-4 w-4" /> + 권한 편집 + </Button> + </div> + </CardHeader> + <CardContent> + <Accordion type="multiple" className="w-full"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <AccordionItem key={resource} value={resource}> + <AccordionTrigger> + <div className="flex items-center justify-between flex-1 mr-2"> + <span className="font-medium">{resource}</span> + <Badge variant="secondary">{perms.length}개</Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-2"> + {perms.map(permission => ( + <div key={permission.id} className="flex items-start gap-3 p-2"> + <Badge variant="outline" className="mt-0.5"> + {permission.action} + </Badge> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + ))} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </CardContent> + </Card> + ); +} + +// 그룹 생성/수정 폼 다이얼로그 +function GroupFormDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group?: PermissionGroup | null; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + groupKey: "", + name: "", + description: "", + domain: "", + isActive: true, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (group) { + setFormData({ + groupKey: group.groupKey, + name: group.name, + description: group.description || "", + domain: group.domain || "", + isActive: group.isActive, + }); + } else { + setFormData({ + groupKey: "", + name: "", + description: "", + domain: "", + isActive: true, + }); + } + }, [group]); + + const handleSubmit = async () => { + if (!formData.groupKey || !formData.name) { + toast.error("필수 항목을 입력해주세요."); + return; + } + + setSaving(true); + try { + if (group) { + await updatePermissionGroup(group.id, formData); + toast.success("권한 그룹이 수정되었습니다."); + } else { + await createPermissionGroup(formData); + toast.success("권한 그룹이 생성되었습니다."); + } + onSuccess(); + } catch (error: any) { + toast.error(error.message || "권한 그룹 저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{group ? "권한 그룹 수정" : "권한 그룹 생성"}</DialogTitle> + <DialogDescription> + 권한 그룹 정보를 입력하세요. + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + <div> + <Label>그룹 키*</Label> + <Input + value={formData.groupKey} + onChange={(e) => setFormData({ ...formData, groupKey: e.target.value })} + placeholder="예: rfq_manager" + /> + </div> + + <div> + <Label>그룹명*</Label> + <Input + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + placeholder="예: RFQ 관리자 권한" + /> + </div> + + <div> + <Label>설명</Label> + <Textarea + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="그룹에 대한 설명" + /> + </div> + + <div> + <Label>도메인</Label> + <Select + value={formData.domain} + onValueChange={(v) => setFormData({ ...formData, domain: v })} + > + <SelectTrigger> + <SelectValue placeholder="도메인 선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="">전체</SelectItem> + <SelectItem value="evcp">EVCP</SelectItem> + <SelectItem value="partners">Partners</SelectItem> + <SelectItem value="procurement">Procurement</SelectItem> + <SelectItem value="sales">Sales</SelectItem> + <SelectItem value="engineering">Engineering</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="flex items-center gap-2"> + <Checkbox + id="isActive" + checked={formData.isActive} + onCheckedChange={(v) => setFormData({ ...formData, isActive: !!v })} + /> + <Label htmlFor="isActive">활성 상태</Label> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "저장 중..." : group ? "수정" : "생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// 그룹 권한 관리 다이얼로그 +function GroupPermissionsDialog({ + open, + onOpenChange, + group, + groupPermissions, + availablePermissions, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + groupPermissions: Permission[]; + availablePermissions: Permission[]; + onSuccess: () => void; +}) { + const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>( + new Set(groupPermissions.map(p => p.id)) + ); + const [saving, setSaving] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + // 검색 필터링 + const filteredPermissions = availablePermissions.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.permissionKey.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // 리소스별로 권한 그룹화 + const groupedPermissions = filteredPermissions.reduce((acc, perm) => { + const resource = perm.resource; + if (!acc[resource]) acc[resource] = []; + acc[resource].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + const handleSave = async () => { + setSaving(true); + try { + await updateGroupPermissions(group.id, Array.from(selectedPermissions)); + toast.success("권한이 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } catch (error) { + toast.error("권한 업데이트에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + const togglePermission = (permissionId: number) => { + const newSet = new Set(selectedPermissions); + if (newSet.has(permissionId)) { + newSet.delete(permissionId); + } else { + newSet.add(permissionId); + } + setSelectedPermissions(newSet); + }; + + const toggleResource = (resource: string) => { + const resourcePerms = groupedPermissions[resource] || []; + const allSelected = resourcePerms.every(p => selectedPermissions.has(p.id)); + + const newSet = new Set(selectedPermissions); + resourcePerms.forEach(p => { + if (allSelected) { + newSet.delete(p.id); + } else { + newSet.add(p.id); + } + }); + setSelectedPermissions(newSet); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{group.name} 권한 설정</DialogTitle> + <DialogDescription> + 이 그룹에 포함할 권한을 선택하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 검색 */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="권한 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* 선택 정보 */} + <div className="flex items-center justify-between p-2 bg-muted rounded"> + <span className="text-sm"> + {selectedPermissions.size}개 권한 선택됨 + </span> + <div className="flex gap-2"> + <Button + size="sm" + variant="outline" + onClick={() => setSelectedPermissions(new Set())} + > + 전체 해제 + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setSelectedPermissions(new Set(availablePermissions.map(p => p.id)))} + > + 전체 선택 + </Button> + </div> + </div> + + {/* 권한 목록 */} + <ScrollArea className="h-[400px] pr-4"> + <Accordion type="multiple" className="w-full"> + {Object.entries(groupedPermissions).map(([resource, perms]) => { + const allSelected = perms.every(p => selectedPermissions.has(p.id)); + const someSelected = perms.some(p => selectedPermissions.has(p.id)); + + return ( + <AccordionItem key={resource} value={resource}> + <AccordionTrigger> + <div className="flex items-center gap-2 flex-1"> + <Checkbox + checked={allSelected} + indeterminate={!allSelected && someSelected} + onCheckedChange={() => toggleResource(resource)} + onClick={(e) => e.stopPropagation()} + /> + <span className="font-medium">{resource}</span> + <Badge variant="secondary" className="ml-auto mr-2"> + {perms.filter(p => selectedPermissions.has(p.id)).length}/{perms.length} + </Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-2 pl-6"> + {perms.map(permission => ( + <label + key={permission.id} + className="flex items-start gap-3 cursor-pointer p-2 hover:bg-muted rounded" + > + <Checkbox + checked={selectedPermissions.has(permission.id)} + onCheckedChange={() => togglePermission(permission.id)} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + <div className="flex gap-1"> + <Badge variant="outline" className="text-xs"> + {permission.permissionType} + </Badge> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + </label> + ))} + </div> + </AccordionContent> + </AccordionItem> + ); + })} + </Accordion> + </ScrollArea> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSave} disabled={saving}> + {saving ? "저장 중..." : "저장"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
