summaryrefslogtreecommitdiff
path: root/components/system
diff options
context:
space:
mode:
Diffstat (limited to 'components/system')
-rw-r--r--components/system/permissionDialog.tsx301
-rw-r--r--components/system/permissionsTree.tsx167
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