diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-29 13:31:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-29 13:31:40 +0000 |
| commit | 4614210aa9878922cfa1e424ce677ef893a1b6b2 (patch) | |
| tree | 5e7edcce05fbee207230af0a43ed08cd351d7c4f /components/permissions | |
| parent | e41e3af4e72870d44a94b03e0f3246d6ccaaca48 (diff) | |
(대표님) 구매 권한설정, data room 등
Diffstat (limited to 'components/permissions')
| -rw-r--r-- | components/permissions/permission-crud-manager.tsx | 92 | ||||
| -rw-r--r-- | components/permissions/permission-group-assignment-manager.tsx | 666 | ||||
| -rw-r--r-- | components/permissions/permission-group-manager.tsx | 294 | ||||
| -rw-r--r-- | components/permissions/role-permission-manager.tsx | 32 | ||||
| -rw-r--r-- | components/permissions/role-selector.tsx | 227 |
5 files changed, 1203 insertions, 108 deletions
diff --git a/components/permissions/permission-crud-manager.tsx b/components/permissions/permission-crud-manager.tsx index 01c9959f..a9b2f64e 100644 --- a/components/permissions/permission-crud-manager.tsx +++ b/components/permissions/permission-crud-manager.tsx @@ -26,6 +26,16 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Table, TableBody, TableCell, @@ -52,7 +62,8 @@ import { Key, Shield, Copy, - CheckCircle + CheckCircle, + AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -90,6 +101,8 @@ export function PermissionCrudManager() { const [loading, setLoading] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingPermission, setEditingPermission] = useState<Permission | null>(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingPermission, setDeletingPermission] = useState<Permission | null>(null); useEffect(() => { loadPermissions(); @@ -139,20 +152,25 @@ export function PermissionCrudManager() { setFilteredPermissions(filtered); }; - const handleDelete = async (id: number) => { - if (!confirm("이 권한을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { - return; - } - + const handleDelete = async () => { + if (!deletingPermission) return; + try { - await deletePermission(id); + await deletePermission(deletingPermission.id); toast.success("권한이 삭제되었습니다."); loadPermissions(); + setDeleteDialogOpen(false); + setDeletingPermission(null); } catch (error) { toast.error("권한 삭제에 실패했습니다."); } }; + const openDeleteDialog = (permission: Permission) => { + setDeletingPermission(permission); + setDeleteDialogOpen(true); + }; + return ( <div className="space-y-6"> {/* 헤더 및 필터 */} @@ -280,7 +298,7 @@ export function PermissionCrudManager() { </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem - onClick={() => handleDelete(permission.id)} + onClick={() => openDeleteDialog(permission)} className="text-destructive" disabled={permission.isSystem} > @@ -314,6 +332,64 @@ export function PermissionCrudManager() { loadPermissions(); }} /> + + {/* 삭제 확인 다이얼로그 */} + <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + <div className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-destructive" /> + 권한 삭제 확인 + </div> + </AlertDialogTitle> + <AlertDialogDescription> + {deletingPermission && ( + <div className="space-y-4"> + <p> + <span className="font-semibold">"{deletingPermission.name}"</span> 권한을 삭제하시겠습니까? + </p> + + <div className="p-3 bg-muted rounded-lg space-y-2"> + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">권한 키:</span> + <code className="px-2 py-0.5 bg-background rounded">{deletingPermission.permissionKey}</code> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">리소스:</span> + <span>{deletingPermission.resource}</span> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">액션:</span> + <span>{deletingPermission.action}</span> + </div> + </div> + + <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg"> + <p className="text-sm text-destructive font-medium"> + ⚠️ 주의: 이 작업은 되돌릴 수 없습니다 + </p> + <p className="text-sm text-muted-foreground mt-1"> + 이 권한과 관련된 모든 역할 및 사용자 할당이 제거됩니다. + </p> + </div> + </div> + )} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => setDeletingPermission(null)}> + 취소 + </AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </div> ); } diff --git a/components/permissions/permission-group-assignment-manager.tsx b/components/permissions/permission-group-assignment-manager.tsx new file mode 100644 index 00000000..cd7531a0 --- /dev/null +++ b/components/permissions/permission-group-assignment-manager.tsx @@ -0,0 +1,666 @@ +// components/permissions/permission-group-assignment-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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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 { + Users, + User, + Plus, + X, + Search, + Package, + Shield, + Loader2, + UserPlus, + ChevronRight +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getPermissionGroupAssignments, + assignGroupToRoles, + assignGroupToUsers, + removeGroupFromRole, + removeGroupFromUser, + searchRoles, + searchUsers, +} from "@/lib/permissions/permission-group-assignment-actions"; + +interface PermissionGroup { + id: number; + groupKey: string; + name: string; + description?: string; + domain?: string; + permissionCount: number; + isActive: boolean; +} + +interface AssignedRole { + id: number; + name: string; + domain: string; + userCount: number; + assignedAt: Date; + assignedBy?: string; +} + +interface AssignedUser { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + companyName?: string; + assignedAt: Date; + assignedBy?: string; +} + +export function PermissionGroupAssignmentManager() { + const [groups, setGroups] = useState<PermissionGroup[]>([]); + const [selectedGroup, setSelectedGroup] = useState<PermissionGroup | null>(null); + const [assignedRoles, setAssignedRoles] = useState<AssignedRole[]>([]); + const [assignedUsers, setAssignedUsers] = useState<AssignedUser[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [addRoleDialogOpen, setAddRoleDialogOpen] = useState(false); + const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); + + useEffect(() => { + loadGroups(); + }, []); + + useEffect(() => { + if (selectedGroup) { + loadAssignments(selectedGroup.id); + } + }, [selectedGroup]); + + const loadGroups = async () => { + setLoading(true); + try { + const data = await getPermissionGroupAssignments(); + setGroups(data.groups); + } catch (error) { + toast.error("권한 그룹을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const loadAssignments = async (groupId: number) => { + try { + const data = await getPermissionGroupAssignments(groupId); + setAssignedRoles(data.roles); + setAssignedUsers(data.users); + } catch (error) { + toast.error("할당 정보를 불러오는데 실패했습니다."); + } + }; + + const handleRemoveRole = async (roleId: number) => { + if (!selectedGroup) return; + + try { + await removeGroupFromRole(selectedGroup.id, roleId); + toast.success("역할에서 권한 그룹이 제거되었습니다."); + loadAssignments(selectedGroup.id); + } catch (error) { + toast.error("권한 그룹 제거에 실패했습니다."); + } + }; + + const handleRemoveUser = async (userId: number) => { + if (!selectedGroup) return; + + try { + await removeGroupFromUser(selectedGroup.id, userId); + toast.success("사용자에서 권한 그룹이 제거되었습니다."); + loadAssignments(selectedGroup.id); + } catch (error) { + toast.error("권한 그룹 제거에 실패했습니다."); + } + }; + + // 그룹 필터링 + const filteredGroups = groups.filter(g => + g.name.toLowerCase().includes(searchQuery.toLowerCase()) || + g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()) || + g.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <div className="grid grid-cols-2 gap-6"> + {/* 권한 그룹 목록 */} + <Card> + <CardHeader> + <CardTitle>권한 그룹</CardTitle> + <CardDescription>할당을 관리할 권한 그룹을 선택하세요.</CardDescription> + </CardHeader> + <CardContent> + <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> + + {/* 그룹 목록 */} + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <ScrollArea className="h-[500px]"> + <div className="space-y-2"> + {filteredGroups.map(group => ( + <button + key={group.id} + onClick={() => setSelectedGroup(group)} + className={cn( + "w-full text-left p-4 rounded-lg border transition-colors", + selectedGroup?.id === group.id + ? "bg-primary/10 border-primary" + : "hover:bg-muted" + )} + > + <div className="space-y-2"> + <div className="flex items-start justify-between"> + <div> + <div className="font-medium">{group.name}</div> + <code className="text-xs bg-muted px-1 py-0.5 rounded"> + {group.groupKey} + </code> + </div> + <div className="flex flex-col items-end gap-1"> + {group.domain && ( + <Badge variant="outline" className="text-xs"> + {group.domain} + </Badge> + )} + <Badge variant="secondary" className="text-xs"> + {group.permissionCount}개 권한 + </Badge> + </div> + </div> + {group.description && ( + <p className="text-sm text-muted-foreground"> + {group.description} + </p> + )} + </div> + </button> + ))} + </div> + </ScrollArea> + )} + </div> + </CardContent> + </Card> + + {/* 할당 관리 */} + {selectedGroup ? ( + <Card> + <CardHeader> + <div> + <CardTitle>{selectedGroup.name}</CardTitle> + <CardDescription className="mt-2"> + <div className="flex gap-2"> + <Badge>{selectedGroup.groupKey}</Badge> + {selectedGroup.domain && ( + <Badge variant="outline">{selectedGroup.domain}</Badge> + )} + <Badge variant="secondary">{selectedGroup.permissionCount}개 권한</Badge> + </div> + </CardDescription> + </div> + </CardHeader> + <CardContent> + <Tabs defaultValue="roles"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="roles"> + <Users className="mr-2 h-4 w-4" /> + 역할 ({assignedRoles.length}) + </TabsTrigger> + <TabsTrigger value="users"> + <User className="mr-2 h-4 w-4" /> + 사용자 ({assignedUsers.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="roles" className="mt-4"> + <div className="space-y-4"> + <Button + size="sm" + onClick={() => setAddRoleDialogOpen(true)} + > + <Plus className="mr-2 h-4 w-4" /> + 역할 추가 + </Button> + + <div className="space-y-2"> + {assignedRoles.map((role) => ( + <div key={role.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex-1"> + <div className="font-medium">{role.name}</div> + <div className="text-sm text-muted-foreground"> + {role.domain} • {role.userCount}명 사용자 + </div> + <div className="text-xs text-muted-foreground mt-1"> + {new Date(role.assignedAt).toLocaleDateString()} 할당 + {role.assignedBy && ` • ${role.assignedBy}`} + </div> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveRole(role.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + + {assignedRoles.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <Users className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">할당된 역할이 없습니다.</p> + </div> + )} + </div> + </div> + </TabsContent> + + <TabsContent value="users" className="mt-4"> + <div className="space-y-4"> + <Button + size="sm" + onClick={() => setAddUserDialogOpen(true)} + > + <Plus className="mr-2 h-4 w-4" /> + 사용자 추가 + </Button> + + <div className="space-y-2"> + {assignedUsers.map((user) => ( + <div key={user.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div> + <div className="font-medium">{user.name}</div> + <div className="text-sm text-muted-foreground">{user.email}</div> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="text-xs"> + {user.domain} + </Badge> + {user.companyName && ( + <span className="text-xs text-muted-foreground"> + {user.companyName} + </span> + )} + </div> + </div> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveUser(user.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + + {assignedUsers.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <User className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">할당된 사용자가 없습니다.</p> + </div> + )} + </div> + </div> + </TabsContent> + </Tabs> + </CardContent> + </Card> + ) : ( + <Card className="flex items-center justify-center"> + <div className="text-center text-muted-foreground"> + <Package className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>권한 그룹을 선택하면 할당 정보가 표시됩니다.</p> + </div> + </Card> + )} + + {/* 역할 추가 다이얼로그 */} + {selectedGroup && ( + <AddRoleDialog + open={addRoleDialogOpen} + onOpenChange={setAddRoleDialogOpen} + group={selectedGroup} + onSuccess={() => { + setAddRoleDialogOpen(false); + loadAssignments(selectedGroup.id); + }} + /> + )} + + {/* 사용자 추가 다이얼로그 */} + {selectedGroup && ( + <AddUserDialog + open={addUserDialogOpen} + onOpenChange={setAddUserDialogOpen} + group={selectedGroup} + onSuccess={() => { + setAddUserDialogOpen(false); + loadAssignments(selectedGroup.id); + }} + /> + )} + </div> + ); +} + +// 역할 추가 다이얼로그 +function AddRoleDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + onSuccess: () => void; +}) { + const [availableRoles, setAvailableRoles] = useState<any[]>([]); + const [selectedRoles, setSelectedRoles] = useState<number[]>([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + loadAvailableRoles(); + } + }, [open]); + + const loadAvailableRoles = async () => { + setLoading(true); + try { + const data = await searchRoles(group.id); + setAvailableRoles(data); + } catch (error) { + toast.error("역할 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async () => { + if (selectedRoles.length === 0) { + toast.error("역할을 선택해주세요."); + return; + } + + setSaving(true); + try { + await assignGroupToRoles(group.id, selectedRoles); + toast.success("역할에 권한 그룹이 추가되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 그룹 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>역할 추가</DialogTitle> + <DialogDescription> + "{group.name}" 그룹을 할당할 역할을 선택하세요. + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <div className="space-y-4"> + <ScrollArea className="h-[300px] border rounded-md p-4"> + <div className="space-y-2"> + {availableRoles.map((role) => ( + <label + key={role.id} + className="flex items-center gap-3 p-2 hover:bg-muted rounded cursor-pointer" + > + <Checkbox + checked={selectedRoles.includes(role.id)} + onCheckedChange={(checked) => { + if (checked) { + setSelectedRoles([...selectedRoles, role.id]); + } else { + setSelectedRoles(selectedRoles.filter(id => id !== role.id)); + } + }} + /> + <div className="flex-1"> + <div className="font-medium">{role.name}</div> + <div className="text-sm text-muted-foreground"> + {role.domain} • {role.userCount}명 사용자 + </div> + </div> + </label> + ))} + </div> + </ScrollArea> + + <div className="text-sm text-muted-foreground"> + {selectedRoles.length}개 역할 선택됨 + </div> + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// 사용자 추가 다이얼로그 +function AddUserDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + onSuccess: () => void; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const [availableUsers, setAvailableUsers] = useState<any[]>([]); + const [selectedUsers, setSelectedUsers] = useState<number[]>([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery && open) { + searchUsersData(searchQuery); + } + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, open]); + + const searchUsersData = async (query: string) => { + setLoading(true); + try { + const data = await searchUsers(query, group.id); + setAvailableUsers(data); + } catch (error) { + toast.error("사용자 검색에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async () => { + if (selectedUsers.length === 0) { + toast.error("사용자를 선택해주세요."); + return; + } + + setSaving(true); + try { + await assignGroupToUsers(group.id, selectedUsers); + toast.success("사용자에게 권한 그룹이 추가되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 그룹 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>사용자 추가</DialogTitle> + <DialogDescription> + "{group.name}" 그룹을 할당할 사용자를 검색하고 선택하세요. + </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> + + {/* 사용자 목록 */} + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <> + <ScrollArea className="h-[300px] border rounded-md p-4"> + <div className="space-y-2"> + {availableUsers.map((user) => ( + <label + key={user.id} + className="flex items-center gap-3 p-2 hover:bg-muted rounded cursor-pointer" + > + <Checkbox + checked={selectedUsers.includes(user.id)} + onCheckedChange={(checked) => { + if (checked) { + setSelectedUsers([...selectedUsers, user.id]); + } else { + setSelectedUsers(selectedUsers.filter(id => id !== user.id)); + } + }} + /> + <Avatar className="h-8 w-8"> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div className="flex-1"> + <div className="font-medium">{user.name}</div> + <div className="text-sm text-muted-foreground">{user.email}</div> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="text-xs"> + {user.domain} + </Badge> + {user.companyName && ( + <span className="text-xs text-muted-foreground"> + {user.companyName} + </span> + )} + </div> + </div> + </label> + ))} + </div> + </ScrollArea> + + {availableUsers.length > 0 && ( + <div className="text-sm text-muted-foreground"> + {selectedUsers.length}명 선택됨 + </div> + )} + </> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving || selectedUsers.length === 0}> + {saving ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-group-manager.tsx b/components/permissions/permission-group-manager.tsx index 11aac6cf..ff7bef7f 100644 --- a/components/permissions/permission-group-manager.tsx +++ b/components/permissions/permission-group-manager.tsx @@ -3,6 +3,9 @@ "use client"; import { useState, useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,6 +23,16 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Select, SelectContent, SelectItem, @@ -47,6 +60,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Shield, Plus, @@ -99,6 +121,19 @@ interface Permission { scope: string; } +// 폼 스키마 정의 +const permissionGroupFormSchema = z.object({ + groupKey: z.string() + .min(1, "그룹 키는 필수입니다.") + .regex(/^[a-z0-9_]+$/, "소문자, 숫자, 언더스코어만 사용 가능합니다."), + name: z.string().min(1, "그룹명은 필수입니다."), + description: z.string().optional(), + domain: z.string().optional(), + isActive: z.boolean().default(true), +}); + +type PermissionGroupFormValues = z.infer<typeof permissionGroupFormSchema>; + export function PermissionGroupManager() { const [groups, setGroups] = useState<PermissionGroup[]>([]); const [selectedGroup, setSelectedGroup] = useState<PermissionGroup | null>(null); @@ -109,6 +144,7 @@ export function PermissionGroupManager() { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingGroup, setEditingGroup] = useState<PermissionGroup | null>(null); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null); useEffect(() => { loadGroups(); @@ -143,19 +179,23 @@ export function PermissionGroupManager() { }; const handleDelete = async (id: number) => { - if (!confirm("이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { - return; - } + setDeletingGroupId(id); + }; + + const confirmDelete = async () => { + if (!deletingGroupId) return; try { - await deletePermissionGroup(id); + await deletePermissionGroup(deletingGroupId); toast.success("권한 그룹이 삭제되었습니다."); - if (selectedGroup?.id === id) { + if (selectedGroup?.id === deletingGroupId) { setSelectedGroup(null); } loadGroups(); } catch (error) { toast.error("권한 그룹 삭제에 실패했습니다."); + } finally { + setDeletingGroupId(null); } }; @@ -268,6 +308,24 @@ export function PermissionGroupManager() { }} /> )} + + {/* 삭제 확인 다이얼로그 */} + <AlertDialog open={!!deletingGroupId} onOpenChange={(open) => !open && setDeletingGroupId(null)}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>권한 그룹 삭제</AlertDialogTitle> + <AlertDialogDescription> + 이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거되며, 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => setDeletingGroupId(null)}>취소</AlertDialogCancel> + <AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </div> ); } @@ -290,6 +348,9 @@ function GroupCard({ onDelete: () => void; onManagePermissions: () => void; }) { + + console.log(group,"group") + return ( <Card className={cn( @@ -456,7 +517,7 @@ function GroupDetailCard({ ); } -// 그룹 생성/수정 폼 다이얼로그 +// 그룹 생성/수정 폼 다이얼로그 - react-hook-form 적용 function GroupFormDialog({ open, onOpenChange, @@ -468,48 +529,55 @@ function GroupFormDialog({ group?: PermissionGroup | null; onSuccess: () => void; }) { - const [formData, setFormData] = useState({ - groupKey: "", - name: "", - description: "", - domain: "", - isActive: true, - }); const [saving, setSaving] = useState(false); + + const form = useForm<PermissionGroupFormValues>({ + resolver: zodResolver(permissionGroupFormSchema), + defaultValues: { + groupKey: "", + name: "", + description: "", + domain: undefined, + isActive: true, + }, + }); + + console.log(form.getValues()) useEffect(() => { if (group) { - setFormData({ + form.reset({ groupKey: group.groupKey, name: group.name, description: group.description || "", - domain: group.domain || "", + domain: group.domain || undefined, isActive: group.isActive, }); } else { - setFormData({ + form.reset({ groupKey: "", name: "", description: "", - domain: "", + domain: undefined, isActive: true, }); } - }, [group]); - - const handleSubmit = async () => { - if (!formData.groupKey || !formData.name) { - toast.error("필수 항목을 입력해주세요."); - return; - } + }, [group, form]); + const onSubmit = async (values: PermissionGroupFormValues) => { setSaving(true); try { + // domain이 undefined인 경우 빈 문자열로 변환 + const submitData = { + ...values, + domain: values.domain || "", + }; + if (group) { - await updatePermissionGroup(group.id, formData); + await updatePermissionGroup(group.id, submitData); toast.success("권한 그룹이 수정되었습니다."); } else { - await createPermissionGroup(formData); + await createPermissionGroup(submitData); toast.success("권한 그룹이 생성되었습니다."); } onSuccess(); @@ -530,72 +598,131 @@ function GroupFormDialog({ </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" + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="groupKey" + render={({ field }) => ( + <FormItem> + <FormLabel>그룹 키 *</FormLabel> + <FormControl> + <Input + placeholder="예: rfq_manager" + {...field} + /> + </FormControl> + <FormDescription> + 소문자, 숫자, 언더스코어만 사용 가능합니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} /> - </div> - <div> - <Label>그룹명*</Label> - <Input - value={formData.name} - onChange={(e) => setFormData({ ...formData, name: e.target.value })} - placeholder="예: RFQ 관리자 권한" + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>그룹명 *</FormLabel> + <FormControl> + <Input + placeholder="예: RFQ 관리자 권한" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - <div> - <Label>설명</Label> - <Textarea - value={formData.description} - onChange={(e) => setFormData({ ...formData, description: e.target.value })} - placeholder="그룹에 대한 설명" + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="그룹에 대한 설명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </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> + <FormField + control={form.control} + name="domain" + render={({ field }) => ( + <FormItem> + <FormLabel>도메인</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value || "none"} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="도메인 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <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> + <FormDescription> + 권한 그룹이 속한 도메인을 선택하세요. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> - <div className="flex items-center gap-2"> - <Checkbox - id="isActive" - checked={formData.isActive} - onCheckedChange={(v) => setFormData({ ...formData, isActive: !!v })} + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel> + 활성 상태 + </FormLabel> + <FormDescription> + 비활성화 시 이 그룹의 권한이 적용되지 않습니다. + </FormDescription> + </div> + </FormItem> + )} /> - <Label htmlFor="isActive">활성 상태</Label> - </div> - </div> - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleSubmit} disabled={saving}> - {saving ? "저장 중..." : group ? "수정" : "생성"} - </Button> - </DialogFooter> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 취소 + </Button> + <Button type="submit" disabled={saving}> + {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {saving ? "저장 중..." : group ? "수정" : "생성"} + </Button> + </DialogFooter> + </form> + </Form> </DialogContent> </Dialog> ); @@ -790,6 +917,7 @@ function GroupPermissionsDialog({ 취소 </Button> <Button onClick={handleSave} disabled={saving}> + {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {saving ? "저장 중..." : "저장"} </Button> </DialogFooter> diff --git a/components/permissions/role-permission-manager.tsx b/components/permissions/role-permission-manager.tsx index b229ec57..63961300 100644 --- a/components/permissions/role-permission-manager.tsx +++ b/components/permissions/role-permission-manager.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/table"; import { toast } from "sonner"; import { assignPermissionsToRole, getRolePermissions } from "@/lib/permissions/service"; +import { RoleSelector } from "@/components/permissions/role-selector"; export function RolePermissionManager() { const [selectedRole, setSelectedRole] = useState<string>(""); @@ -31,9 +32,11 @@ export function RolePermissionManager() { const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>(new Set()); const [loading, setLoading] = useState(false); + console.log(selectedRole) + useEffect(() => { if (selectedRole) { - loadRolePermissions(selectedRole); + loadRolePermissions(selectedRole.id); } }, [selectedRole]); @@ -56,7 +59,7 @@ export function RolePermissionManager() { try { setLoading(true); await assignPermissionsToRole( - parseInt(selectedRole), + parseInt(selectedRole.id), Array.from(selectedPermissions) ); toast.success("권한이 성공적으로 저장되었습니다."); @@ -97,21 +100,16 @@ export function RolePermissionManager() { <div className="space-y-6"> {/* 역할 선택 */} <div className="flex items-center gap-4"> - <Select value={selectedRole} onValueChange={setSelectedRole}> - <SelectTrigger className="w-[300px]"> - <SelectValue placeholder="역할 선택..." /> - </SelectTrigger> - <SelectContent> - <SelectItem value="1">EVCP Admin</SelectItem> - <SelectItem value="2">EVCP Manager</SelectItem> - <SelectItem value="3">EVCP User</SelectItem> - <SelectItem value="4">Partner Admin</SelectItem> - <SelectItem value="5">Partner User</SelectItem> - </SelectContent> - </Select> - - <Button - onClick={handleSave} + <RoleSelector + selectedRoleId={selectedRole?.id} + onRoleSelect={setSelectedRole} + placeholder="역할을 선택하세요..." + className='max-w-[400px]' + domain="evcp" // 선택사항: 특정 도메인만 필터링 + /> + + <Button + onClick={handleSave} disabled={!selectedRole || loading} > 권한 저장 diff --git a/components/permissions/role-selector.tsx b/components/permissions/role-selector.tsx new file mode 100644 index 00000000..5f62ca51 --- /dev/null +++ b/components/permissions/role-selector.tsx @@ -0,0 +1,227 @@ +// components/permissions/role-selector.tsx + +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Shield, Users } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { getRoles } from "@/lib/permissions/service" + +export interface Role { + id: number; + name: string; + domain: string; + description?: string; + userCount?: number; +} + +interface RoleSelectorProps { + selectedRoleId?: number | null; + onRoleSelect: (role: Role) => void; + placeholder?: string; + domain?: string; // 특정 도메인의 역할만 필터링 + className?: string; +} + +export function RoleSelector({ + selectedRoleId, + onRoleSelect, + placeholder = "역할 선택...", + domain, + className +}: RoleSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [roles, setRoles] = React.useState<Role[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedRole, setSelectedRole] = React.useState<Role | null>(null) + + // 역할 데이터 로드 + React.useEffect(() => { + async function loadRoles() { + setIsLoading(true); + try { + const allRoles = await getRoles(); + + // domain이 지정된 경우 해당 도메인만 필터링 + const filteredByDomain = domain + ? allRoles.filter((r: Role) => r.domain === domain) + : allRoles; + + console.log(`Loaded ${filteredByDomain.length} roles${domain ? ` for domain: ${domain}` : ''}`); + setRoles(filteredByDomain); + + // 초기 선택된 역할이 있으면 설정 + if (selectedRoleId) { + const selected = filteredByDomain.find((r: Role) => r.id === selectedRoleId); + if (selected) { + setSelectedRole(selected); + } + } + } catch (error) { + console.error("역할 목록 로드 오류:", error); + setRoles([]); + } finally { + setIsLoading(false); + } + } + + loadRoles(); + }, [selectedRoleId, domain]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredRoles = React.useMemo(() => { + if (!searchTerm.trim()) return roles; + + const lowerSearch = searchTerm.toLowerCase(); + return roles.filter( + role => + role.name.toLowerCase().includes(lowerSearch) || + role.domain.toLowerCase().includes(lowerSearch) || + (role.description && role.description.toLowerCase().includes(lowerSearch)) + ); + }, [roles, searchTerm]); + + // 역할 선택 처리 + const handleSelectRole = (role: Role) => { + setSelectedRole(role); + onRoleSelect(role); + setOpen(false); + }; + + // 도메인별 색상 결정 + const getDomainColor = (domain: string) => { + switch(domain?.toLowerCase()) { + case 'evcp': + return 'default'; + case 'partners': + return 'secondary'; + case 'admin': + return 'destructive'; + default: + return 'outline'; + } + }; + + // 도메인별 그룹화 + const groupedRoles = React.useMemo(() => { + return filteredRoles.reduce((acc, role) => { + const key = role.domain || 'other'; + if (!acc[key]) acc[key] = []; + acc[key].push(role); + return acc; + }, {} as Record<string, Role[]>); + }, [filteredRoles]); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className={cn("w-full justify-between", className)} + disabled={isLoading} + > + {isLoading ? ( + <span className="text-muted-foreground">역할 로딩 중...</span> + ) : selectedRole ? ( + <div className="flex items-center gap-2 w-full"> + <Shield className="h-4 w-4 text-muted-foreground" /> + <span className="truncate">{selectedRole.name}</span> + <Badge + variant={getDomainColor(selectedRole.domain)} + className="ml-auto text-xs" + > + {selectedRole.domain} + </Badge> + {selectedRole.userCount !== undefined && ( + <Badge variant="outline" className="text-xs"> + {selectedRole.userCount}명 + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="역할명, 도메인으로 검색..." + onValueChange={setSearchTerm} + /> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + {isLoading ? ( + <div className="py-6 text-center text-sm text-muted-foreground"> + 역할 목록 로딩 중... + </div> + ) : Object.keys(groupedRoles).length === 0 ? ( + <CommandEmpty> + {searchTerm + ? "검색 결과가 없습니다" + : domain + ? `${domain} 도메인에 역할이 없습니다` + : "역할이 없습니다"} + </CommandEmpty> + ) : ( + Object.entries(groupedRoles).map(([groupDomain, groupRoles]) => ( + <CommandGroup key={groupDomain} heading={groupDomain.toUpperCase()}> + {groupRoles.map((role) => ( + <CommandItem + key={role.id} + value={`${role.name} ${role.domain} ${role.description || ''}`} + onSelect={() => handleSelectRole(role)} + className="cursor-pointer" + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedRole?.id === role.id + ? "opacity-100" + : "opacity-0" + )} + /> + <div className="flex items-center gap-2 flex-1"> + <Shield className="h-4 w-4 text-muted-foreground" /> + <div className="flex-1"> + <div className="font-medium">{role.name}</div> + {role.description && ( + <div className="text-xs text-muted-foreground truncate"> + {role.description} + </div> + )} + </div> + <div className="flex items-center gap-1"> + {role.userCount !== undefined && ( + <Badge variant="outline" className="text-xs"> + <Users className="h-3 w-3 mr-1" /> + {role.userCount} + </Badge> + )} + </div> + </div> + </CommandItem> + ))} + </CommandGroup> + )) + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file |
