diff options
Diffstat (limited to 'components/system')
| -rw-r--r-- | components/system/permissionDialog.tsx | 301 | ||||
| -rw-r--r-- | components/system/permissionsTree.tsx | 167 |
2 files changed, 468 insertions, 0 deletions
diff --git a/components/system/permissionDialog.tsx b/components/system/permissionDialog.tsx new file mode 100644 index 00000000..f7247672 --- /dev/null +++ b/components/system/permissionDialog.tsx @@ -0,0 +1,301 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip" +import { RoleView } from "@/db/schema/users" +import { + getAllRoleView, + getMenuPermissions, + upsertPermissions +} from "@/lib/roles/services" +import { useToast } from "@/hooks/use-toast" +import { Loader } from "lucide-react" +import { permissionLabelMap } from "@/config/permissionsConfig" + +interface PermissionDialogProps { + open: boolean + onOpenChange: (val: boolean) => void + itemKey?: string + itemTitle?: string +} + +export function PermissionDialog({ + open, + onOpenChange, + itemKey, + itemTitle, +}: PermissionDialogProps) { + // **(A)**: 체크박스에 의해 새로 추가할 권한(perms) + const [permissions, setPermissions] = React.useState<string[]>([]) + + // **(B)**: 체크된 Roles(새로 부여할 대상) + const [selectedRoles, setSelectedRoles] = React.useState<number[]>([]) + + // **(C)**: 전체 Role 목록 + const [roles, setRoles] = React.useState<RoleView[]>([]) + + // **(D)**: Role별 이미 존재하는 권한들 → UI 표시용 + const [rolePermsMap, setRolePermsMap] = React.useState<Record<number, string[]>>({}) + + const { toast } = useToast() + const [isPending, startTransition] = React.useTransition() + + // 1) Role 목록 로드 + React.useEffect(() => { + getAllRoleView("evcp").then((res) => { + setRoles(res) + }) + }, []) + + // 2) Dialog 열릴 때 → DB에서 “이미 부여된 권한” 로드 + React.useEffect(() => { + if (open && itemKey) { + // 기존에 어떤 Role들이 itemKey 퍼미션을 가지고 있는지 + getMenuPermissions(itemKey).then((rows) => { + // rows: { roleId, permKey: "itemKey.xxx" } + // rolePermsMap[r.roleId] = ["create","viewAll",...] + const rMap: Record<number, string[]> = {} + for (const row of rows) { + const splitted = row.permKey.split(".") + const shortPerm = splitted[1] + if (!rMap[row.roleId]) { + rMap[row.roleId] = [] + } + rMap[row.roleId].push(shortPerm) + } + setRolePermsMap(rMap) + + // 권한 체크박스(permissions)와 selectedRoles는 + // "항상 비어있는 상태"로 시작 (새로 추가할 용도) + setPermissions([]) + setSelectedRoles([]) + }) + } else if (!open) { + // Dialog가 닫힐 때 리셋 + setPermissions([]) + setSelectedRoles([]) + setRolePermsMap({}) + } + }, [open, itemKey]) + + // Checkbox toggle: 권한 + function togglePermission(perm: string) { + setPermissions((prev) => + prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] + ) + } + + // Checkbox toggle: Role + function toggleRole(roleId: number) { + setSelectedRoles((prev) => + prev.includes(roleId) ? prev.filter((p) => p !== roleId) : [...prev, roleId] + ) + } + + async function handleSave() { + if (!itemKey) { + toast({ + variant: "destructive", + title: "오류", + description: "선택한 메뉴가 없어 권한을 생성할 수 없습니다.", + }) + onOpenChange(false) + return + } + + // permission_key = itemKey.perm + const permissionKeys = permissions.map((perm) => `${itemKey}.${perm}`) + + startTransition(async () => { + try { + await upsertPermissions({ + roleIds: selectedRoles, + permissionKeys, + itemTitle, + }) + + toast({ + variant: "default", + title: "권한 설정 완료", + description: "새 권한이 정상적으로 설정되었습니다.", + }) + setPermissions([]) + setSelectedRoles([]) + setRolePermsMap({}) + onOpenChange(false) + } catch (err) { + toast({ + variant: "destructive", + title: "오류", + description: "권한 설정에 실패했습니다. 다시 시도해주세요.", + }) + } + }) + } + + function handleCancel() { + setPermissions([]) + setSelectedRoles([]) + setRolePermsMap({}) + onOpenChange(false) + } + + const isDisabled = isPending + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle> + 권한 설정 - <span className="text-primary">{itemTitle}</span> + </DialogTitle> + </DialogHeader> + + <div className="flex flex-col gap-6"> + {/* 1) Role 표시: 이미 가진 권한은 slash 구분 */} + <section> + <h2 className="font-bold mb-2">Role & 이미 부여된 권한</h2> + <div className="max-h-[200px] overflow-y-auto border p-2 rounded space-y-2"> + {roles.map((r) => { + const existPerms = rolePermsMap[r.id] || [] + const permsText = existPerms + .map((perm) => permissionLabelMap[perm] ?? perm) + // ↑ 매핑에 없는 키일 경우 대비해 ?? perm 로 처리 + .join(" / ") + + return ( + <div key={r.id} className="flex items-center gap-2"> + <Checkbox + checked={selectedRoles.includes(r.id)} + onCheckedChange={() => toggleRole(r.id)} + disabled={isDisabled} + /> + <Tooltip> + <TooltipTrigger asChild> + <span className="cursor-help text-sm font-medium"> + {r.name} + </span> + </TooltipTrigger> + <TooltipContent> + {r.description ?? "No description"} + </TooltipContent> + </Tooltip> + {/* 이미 가진 권한 텍스트 */} + {permsText && ( + <span className="ml-2 text-xs text-muted-foreground"> + {permsText} + </span> + )} + </div> + ) + })} + </div> + </section> + + {/* 2) 새 권한 체크박스 */} + <section> + <h2 className="font-bold mb-2">새로 부여할 권한</h2> + <div className="grid grid-cols-2 gap-6 border p-2 rounded"> + {/* 왼쪽 */} + <div className="space-y-4"> + {/* 생성 */} + <div> + <h3 className="mb-2 font-semibold">생성</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("create")} + onCheckedChange={() => togglePermission("create")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["create"]}</span> + </div> + </div> + + {/* 보기 */} + <div> + <h3 className="mb-2 font-semibold">보기</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("viewAll")} + onCheckedChange={() => togglePermission("viewAll")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["viewAll"]}</span> + </div> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("viewOwn")} + onCheckedChange={() => togglePermission("viewOwn")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["viewOwn"]}</span> + </div> + </div> + </div> + + {/* 오른쪽 */} + <div className="space-y-4"> + {/* 편집 */} + <div> + <h3 className="mb-2 font-semibold">편집</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("editAll")} + onCheckedChange={() => togglePermission("editAll")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["editAll"]}</span> + </div> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("editOwn")} + onCheckedChange={() => togglePermission("editOwn")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["editOwn"]}</span> + </div> + </div> + + {/* 삭제 */} + <div> + <h3 className="mb-2 font-semibold">삭제</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("deleteAll")} + onCheckedChange={() => togglePermission("deleteAll")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["deleteAll"]}</span> + </div> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("deleteOwn")} + onCheckedChange={() => togglePermission("deleteOwn")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["deleteOwn"]}</span> + </div> + </div> + </div> + </div> + </section> + </div> + + <DialogFooter> + <Button variant="secondary" onClick={handleCancel} disabled={isDisabled}> + 취소 + </Button> + <Button variant="default" onClick={handleSave} disabled={isDisabled}> + {isPending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/system/permissionsTree.tsx b/components/system/permissionsTree.tsx new file mode 100644 index 00000000..8f6adfb0 --- /dev/null +++ b/components/system/permissionsTree.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; +import { styled } from '@mui/material/styles'; +import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; +import { TreeItem, treeItemClasses } from '@mui/x-tree-view/TreeItem'; +import { Minus, MinusSquare, Plus, SquarePlus } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import { mainNav, additionalNav, MenuSection } from "@/config/menuConfig"; +import { PermissionDialog } from './permissionDialog'; + +// ------------------- Custom TreeItem Style ------------------- +const CustomTreeItem = styled(TreeItem)({ + [`& .${treeItemClasses.iconContainer}`]: { + '& .close': { + opacity: 0.3, + }, + }, +}); + +function CloseSquare(props: SvgIconProps) { + return ( + <SvgIcon + className="close" + fontSize="inherit" + style={{ width: 14, height: 14 }} + {...props} + > + {/* tslint:disable-next-line: max-line-length */} + <path d="M17.485 17.512q-.281.281-.682.281t-.696-.268l-4.12-4.147-4.12 4.147q-.294.268-.696.268t-.682-.281-.281-.682.294-.669l4.12-4.147-4.12-4.147q-.294-.268-.294-.669t.281-.682.682-.281.696.268l4.12 4.147 4.12-4.147q.294-.268.696-.268t.682.281 .281.669-.294.682l-4.12 4.147 4.12 4.147q.294.268 .294.669t-.281.682zM22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0z" /> + </SvgIcon> + ); +} + + +interface SelectedKey { + key: string; + title: string; +} + +export default function PermissionsTree() { + const [expandedItems, setExpandedItems] = React.useState<string[]>([]); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [selectedKey, setSelectedKey] = React.useState<SelectedKey | null>(null); + + const handleExpandedItemsChange = ( + event: React.SyntheticEvent, + itemIds: string[], + ) => { + setExpandedItems(itemIds); + }; + + const handleExpandClick = () => { + if (expandedItems.length === 0) { + // 모든 노드를 펼치기 + // 실제로는 mainNav와 additionalNav를 순회해 itemId를 전부 수집하는 방식 + setExpandedItems([...collectAllIds()]); + } else { + setExpandedItems([]); + } + }; + + // (4) 수동으로 "모든 TreeItem의 itemId"를 수집하는 함수 + const collectAllIds = React.useCallback(() => { + const ids: string[] = []; + + // mainNav: 상위 = section.title, 하위 = item.title + mainNav.forEach((section) => { + ids.push(section.title); // 상위 + section.items.forEach((itm) => ids.push(itm.title)); + }); + + // additionalNav를 "기타메뉴" 아래에 넣을 경우, "기타메뉴" 라는 itemId + each item + additionalNav.forEach((itm) => ids.push(itm.title)); + return ids; + }, []); + + + function handleItemClick(key: SelectedKey) { + // 1) Dialog 열기 + setSelectedKey(key); // 이 값은 Dialog에서 어떤 메뉴인지 식별에 사용 + setDialogOpen(true); + } + + // (5) 실제 렌더 + return ( + <div className='lg:max-w-2xl'> + <Stack spacing={2}> + <div> + <Button onClick={handleExpandClick} type='button'> + {expandedItems.length === 0 ? ( + <> + <Plus /> + Expand All + </> + ) : ( + <> + <Minus /> + Collapse All + </> + )} + </Button> + </div> + + <Box sx={{ minHeight: 352, minWidth: 250 }}> + <SimpleTreeView + // 아래 props로 아이콘 지정 + slots={{ + expandIcon: SquarePlus, + collapseIcon: MinusSquare, + endIcon: CloseSquare, + }} + expansionTrigger="iconContainer" + onExpandedItemsChange={handleExpandedItemsChange} + expandedItems={expandedItems} + > + {/* (A) mainNav를 트리로 렌더 */} + {mainNav.map((section) => ( + <CustomTreeItem + key={section.title} + itemId={section.title} + label={section.title} + > + {section.items.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + <CustomTreeItem + key={lastSegment} + itemId={lastSegment} + label={itm.title} + onClick={() => handleItemClick(key)} + /> + ); + })} + </CustomTreeItem> + ))} + + + {additionalNav.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + <CustomTreeItem + key={lastSegment} + itemId={lastSegment} + label={itm.title} + onClick={() => handleItemClick(key)} + /> + ); + })} + </SimpleTreeView> + </Box> + </Stack> + + <PermissionDialog + open={dialogOpen} + onOpenChange={setDialogOpen} + itemKey={selectedKey?.key} + itemTitle={selectedKey?.title} + /> + </div> + ); +}
\ No newline at end of file |
