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/menu-permission-manager.tsx | |
| parent | d62368d2b68d73da895977e60a18f9b1286b0545 (diff) | |
(대표님) 권한관리, 문서업로드, rfq첨부, SWP문서룰 등
(최겸) 입찰
Diffstat (limited to 'components/permissions/menu-permission-manager.tsx')
| -rw-r--r-- | components/permissions/menu-permission-manager.tsx | 552 |
1 files changed, 552 insertions, 0 deletions
diff --git a/components/permissions/menu-permission-manager.tsx b/components/permissions/menu-permission-manager.tsx new file mode 100644 index 00000000..1b771520 --- /dev/null +++ b/components/permissions/menu-permission-manager.tsx @@ -0,0 +1,552 @@ +// components/permissions/menu-permission-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 { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Menu, + Search, + Shield, + Lock, + Unlock, + Users, + User, + Settings, + FileText, + Eye, + Edit, + Trash, + Plus, + ChevronRight, + AlertCircle, + CheckCircle, + ExternalLink +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getMenuPermissions, + updateMenuPermissions, + getMenuManagers, + updateMenuManagers, +} from "@/lib/permissions/service"; + +// 메뉴 구조 타입 +interface MenuItem { + menuPath: string; + menuTitle: string; + menuDescription?: string; + sectionTitle?: string; + menuGroup?: string; + domain: string; + isActive: boolean; + manager1?: { id: number; name: string; email: string; imageUrl?: string }; + manager2?: { id: number; name: string; email: string; imageUrl?: string }; + requiredPermissions: Array<{ + id: number; + permissionKey: string; + name: string; + description?: string; + isRequired: boolean; + }>; + accessCount?: number; // 접근 통계 + lastAccessed?: Date; +} + +// 메뉴 섹션별 그룹화 +interface MenuSection { + title: string; + items: MenuItem[]; +} + +export function MenuPermissionManager() { + const [searchQuery, setSearchQuery] = useState(""); + const [menus, setMenus] = useState<MenuItem[]>([]); + const [selectedMenu, setSelectedMenu] = useState<MenuItem | null>(null); + const [availablePermissions, setAvailablePermissions] = useState<any[]>([]); + const [loading, setLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [selectedDomain, setSelectedDomain] = useState<string>("all"); + + useEffect(() => { + loadMenus(); + }, [selectedDomain]); + + const loadMenus = async () => { + setLoading(true); + try { + const data = await getMenuPermissions(selectedDomain); + setMenus(data.menus); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("메뉴 정보를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + // 메뉴 검색 필터링 + const filteredMenus = menus.filter(menu => + menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // 섹션별로 메뉴 그룹화 + const groupedMenus = filteredMenus.reduce((acc, menu) => { + const section = menu.sectionTitle || "기타"; + if (!acc[section]) { + acc[section] = []; + } + acc[section].push(menu); + return acc; + }, {} as Record<string, MenuItem[]>); + + return ( + <div className="grid grid-cols-3 gap-6"> + {/* 메뉴 목록 */} + <Card className="col-span-1"> + <CardHeader> + <CardTitle>메뉴 목록</CardTitle> + <CardDescription>권한을 설정할 메뉴를 선택하세요.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 도메인 필터 */} + <div className="flex gap-2"> + <Badge + variant={selectedDomain === "all" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("all")} + > + 전체 + </Badge> + <Badge + variant={selectedDomain === "evcp" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("evcp")} + > + EVCP + </Badge> + <Badge + variant={selectedDomain === "partners" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("partners")} + > + Partners + </Badge> + </div> + + {/* 검색 */} + <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> + + {/* 메뉴 트리 */} + <ScrollArea className="h-[500px]"> + <Accordion type="single" collapsible className="w-full"> + {Object.entries(groupedMenus).map(([section, items]) => ( + <AccordionItem key={section} value={section}> + <AccordionTrigger className="text-sm"> + <div className="flex items-center justify-between flex-1 mr-2"> + <span>{section}</span> + <Badge variant="secondary" className="text-xs"> + {items.length} + </Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-1"> + {items.map((menu) => ( + <button + key={menu.menuPath} + onClick={() => setSelectedMenu(menu)} + className={cn( + "w-full p-3 rounded-lg text-left transition-colors", + selectedMenu?.menuPath === menu.menuPath + ? "bg-primary/10 border border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-start justify-between"> + <div className="flex-1"> + <div className="font-medium text-sm">{menu.menuTitle}</div> + <div className="text-xs text-muted-foreground mt-1"> + {menu.menuPath} + </div> + </div> + <div className="flex flex-col items-end gap-1"> + {menu.requiredPermissions.length > 0 && ( + <Badge variant="outline" className="text-xs"> + {menu.requiredPermissions.length}개 권한 + </Badge> + )} + {!menu.isActive && ( + <Badge variant="destructive" className="text-xs"> + 비활성 + </Badge> + )} + </div> + </div> + </button> + ))} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </ScrollArea> + </div> + </CardContent> + </Card> + + {/* 메뉴 상세 및 권한 설정 */} + {selectedMenu ? ( + <Card className="col-span-2"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + {selectedMenu.menuTitle} + {!selectedMenu.isActive && ( + <Badge variant="destructive">비활성</Badge> + )} + </CardTitle> + <CardDescription>{selectedMenu.menuPath}</CardDescription> + {selectedMenu.menuDescription && ( + <p className="text-sm text-muted-foreground mt-2"> + {selectedMenu.menuDescription} + </p> + )} + </div> + <Button + onClick={() => setEditDialogOpen(true)} + size="sm" + > + <Settings className="mr-2 h-4 w-4" /> + 설정 + </Button> + </div> + </CardHeader> + <CardContent> + <div className="space-y-6"> + {/* 담당자 정보 */} + <div> + <h3 className="text-sm font-medium mb-3">담당자</h3> + <div className="space-y-2"> + <ManagerInfo + label="주 담당자" + manager={selectedMenu.manager1} + onEdit={() => {/* 담당자 변경 다이얼로그 */}} + /> + <ManagerInfo + label="부 담당자" + manager={selectedMenu.manager2} + onEdit={() => {/* 담당자 변경 다이얼로그 */}} + /> + </div> + </div> + + <Separator /> + + {/* 필수 권한 */} + <div> + <h3 className="text-sm font-medium mb-3">필수 권한</h3> + {selectedMenu.requiredPermissions.length > 0 ? ( + <div className="space-y-2"> + {selectedMenu.requiredPermissions.map((perm) => ( + <div + key={perm.id} + className="flex items-center justify-between p-3 border rounded-lg" + > + <div> + <div className="font-medium text-sm">{perm.name}</div> + <div className="text-xs text-muted-foreground"> + {perm.permissionKey} + </div> + {perm.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {perm.description} + </div> + )} + </div> + <div className="flex items-center gap-2"> + {perm.isRequired ? ( + <Badge variant="default">필수</Badge> + ) : ( + <Badge variant="outline">선택</Badge> + )} + </div> + </div> + ))} + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + <Unlock className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">설정된 권한이 없습니다.</p> + <p className="text-xs mt-1">모든 사용자가 접근 가능합니다.</p> + </div> + )} + </div> + + <Separator /> + + {/* 접근 통계 */} + <div> + <h3 className="text-sm font-medium mb-3">접근 통계</h3> + <div className="grid grid-cols-2 gap-4"> + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="text-2xl font-bold"> + {selectedMenu.accessCount || 0} + </div> + <div className="text-xs text-muted-foreground"> + 총 접근 횟수 + </div> + </div> + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="text-sm font-medium"> + {selectedMenu.lastAccessed + ? new Date(selectedMenu.lastAccessed).toLocaleDateString() + : "-"} + </div> + <div className="text-xs text-muted-foreground"> + 최근 접근일 + </div> + </div> + </div> + </div> + </div> + </CardContent> + </Card> + ) : ( + <Card className="col-span-2 flex items-center justify-center h-[600px]"> + <div className="text-center text-muted-foreground"> + <Menu className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>메뉴를 선택하면 권한 설정이 표시됩니다.</p> + </div> + </Card> + )} + + {/* 메뉴 권한 편집 다이얼로그 */} + {selectedMenu && ( + <MenuPermissionEditDialog + open={editDialogOpen} + onOpenChange={setEditDialogOpen} + menu={selectedMenu} + availablePermissions={availablePermissions} + onSuccess={() => { + loadMenus(); + setEditDialogOpen(false); + }} + /> + )} + </div> + ); +} + +// 담당자 정보 컴포넌트 +function ManagerInfo({ + label, + manager, + onEdit +}: { + label: string; + manager?: { id: number; name: string; email: string; imageUrl?: string }; + onEdit: () => void; +}) { + if (!manager) { + return ( + <div className="flex items-center justify-between p-2 border rounded-lg bg-muted/30"> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">{label}: 미지정</span> + </div> + <Button size="sm" variant="ghost" onClick={onEdit}> + <Plus className="h-4 w-4" /> + </Button> + </div> + ); + } + + return ( + <div className="flex items-center justify-between p-2 border rounded-lg"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={manager.imageUrl} /> + <AvatarFallback>{manager.name[0]}</AvatarFallback> + </Avatar> + <div> + <div className="text-sm font-medium">{manager.name}</div> + <div className="text-xs text-muted-foreground">{manager.email}</div> + </div> + </div> + <Button size="sm" variant="ghost" onClick={onEdit}> + <Edit className="h-4 w-4" /> + </Button> + </div> + ); +} + +// 메뉴 권한 편집 다이얼로그 +function MenuPermissionEditDialog({ + open, + onOpenChange, + menu, + availablePermissions, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + menu: MenuItem; + availablePermissions: any[]; + onSuccess: () => void; +}) { + const [selectedPermissions, setSelectedPermissions] = useState< + Array<{ id: number; isRequired: boolean }> + >(() => menu.requiredPermissions.map(p => ({ id: p.id, isRequired: p.isRequired }))) + + + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + try { + await updateMenuPermissions(menu.menuPath, selectedPermissions); + toast.success("메뉴 권한이 업데이트되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 업데이트에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + const togglePermission = (permissionId: number) => { + const existing = selectedPermissions.find(p => p.id === permissionId); + if (existing) { + setSelectedPermissions(selectedPermissions.filter(p => p.id !== permissionId)); + } else { + setSelectedPermissions([...selectedPermissions, { id: permissionId, isRequired: true }]); + } + }; + + const toggleRequired = (permissionId: number) => { + setSelectedPermissions( + selectedPermissions.map(p => + p.id === permissionId ? { ...p, isRequired: !p.isRequired } : p + ) + ); + }; + + // 권한을 카테고리별로 그룹화 + const groupedPermissions = availablePermissions.reduce((acc, perm) => { + const category = perm.resource || "기타"; + if (!acc[category]) acc[category] = []; + acc[category].push(perm); + return acc; + }, {} as Record<string, any[]>); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{menu.menuTitle} 권한 설정</DialogTitle> + <DialogDescription> + 이 메뉴에 접근하기 위한 필수/선택 권한을 설정합니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="h-[400px] pr-4"> + <div className="space-y-6"> + {Object.entries(groupedPermissions).map(([category, perms]) => ( + <div key={category}> + <h4 className="text-sm font-medium mb-3">{category}</h4> + <div className="space-y-2"> + {perms.map((permission) => { + const selected = selectedPermissions.find(p => p.id === permission.id); + return ( + <div + key={permission.id} + className={cn( + "flex items-center justify-between p-3 border rounded-lg", + selected && "bg-primary/5 border-primary" + )} + > + <div className="flex items-start gap-3"> + <Checkbox + checked={!!selected} + 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> + + {selected && ( + <div className="flex items-center gap-2"> + <Button + size="sm" + variant={selected.isRequired ? "default" : "outline"} + onClick={() => toggleRequired(permission.id)} + > + {selected.isRequired ? "필수" : "선택"} + </Button> + </div> + )} + </div> + ); + })} + </div> + </div> + ))} + </div> + </ScrollArea> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSave} disabled={saving}> + {saving ? "저장 중..." : "저장"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
