summaryrefslogtreecommitdiff
path: root/components/permissions/permission-group-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/permission-group-manager.tsx
parentd62368d2b68d73da895977e60a18f9b1286b0545 (diff)
(대표님) 권한관리, 문서업로드, rfq첨부, SWP문서룰 등
(최겸) 입찰
Diffstat (limited to 'components/permissions/permission-group-manager.tsx')
-rw-r--r--components/permissions/permission-group-manager.tsx799
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