summaryrefslogtreecommitdiff
path: root/components/permissions/menu-permission-manager.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-26 09:57:24 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-26 09:57:24 +0000
commit8b23b471638a155fd1bfa3a8c853b26d9315b272 (patch)
tree47353e9dd342011cb2f1dcd24b09661707a8421b /components/permissions/menu-permission-manager.tsx
parentd62368d2b68d73da895977e60a18f9b1286b0545 (diff)
(대표님) 권한관리, 문서업로드, rfq첨부, SWP문서룰 등
(최겸) 입찰
Diffstat (limited to 'components/permissions/menu-permission-manager.tsx')
-rw-r--r--components/permissions/menu-permission-manager.tsx552
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