From 04ed774ff60a83c00711d4e8615cb4122954dba5 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 4 Dec 2025 19:46:55 +0900 Subject: (김준회) 메뉴 관리기능 초안 개발 (시딩 필요) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx | 79 +-- app/[lng]/evcp/(evcp)/layout.tsx | 7 +- app/[lng]/partners/(partners)/layout.tsx | 4 +- components/layout/DynamicMenuRender.tsx | 146 ++++++ components/layout/HeaderV2.tsx | 295 +++++++++++ components/layout/MobileMenuV2.tsx | 160 ++++++ db/schema/index.ts | 4 + db/schema/menu-v2.ts | 88 ++++ db/seeds/menu-v2-seed.js | 231 +++++++++ db/seeds/menu-v2-seed.ts | 145 ++++++ hooks/use-visible-menu-tree.ts | 49 ++ lib/information/service.ts | 32 +- lib/menu-v2/components/add-node-dialog.tsx | 186 +++++++ lib/menu-v2/components/domain-tabs.tsx | 25 + lib/menu-v2/components/edit-node-dialog.tsx | 215 ++++++++ lib/menu-v2/components/menu-tree-manager.tsx | 364 +++++++++++++ lib/menu-v2/components/menu-tree.tsx | 282 ++++++++++ lib/menu-v2/components/move-to-dialog.tsx | 87 ++++ lib/menu-v2/components/unassigned-menus-panel.tsx | 178 +++++++ lib/menu-v2/permission-service.ts | 186 +++++++ lib/menu-v2/service.ts | 605 ++++++++++++++++++++++ lib/menu-v2/types.ts | 103 ++++ 22 files changed, 3384 insertions(+), 87 deletions(-) create mode 100644 components/layout/DynamicMenuRender.tsx create mode 100644 components/layout/HeaderV2.tsx create mode 100644 components/layout/MobileMenuV2.tsx create mode 100644 db/schema/menu-v2.ts create mode 100644 db/seeds/menu-v2-seed.js create mode 100644 db/seeds/menu-v2-seed.ts create mode 100644 hooks/use-visible-menu-tree.ts create mode 100644 lib/menu-v2/components/add-node-dialog.tsx create mode 100644 lib/menu-v2/components/domain-tabs.tsx create mode 100644 lib/menu-v2/components/edit-node-dialog.tsx create mode 100644 lib/menu-v2/components/menu-tree-manager.tsx create mode 100644 lib/menu-v2/components/menu-tree.tsx create mode 100644 lib/menu-v2/components/move-to-dialog.tsx create mode 100644 lib/menu-v2/components/unassigned-menus-panel.tsx create mode 100644 lib/menu-v2/permission-service.ts create mode 100644 lib/menu-v2/service.ts create mode 100644 lib/menu-v2/types.ts diff --git a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx index 2cff434e..79923397 100644 --- a/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx +++ b/app/[lng]/evcp/(evcp)/(system)/menu-list/page.tsx @@ -1,75 +1,20 @@ -// app/evcp/menu-list/page.tsx +import { MenuTreeManager } from "@/lib/menu-v2/components/menu-tree-manager"; -import { Suspense } from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie"; -import { InitializeButton } from "@/lib/menu-list/table/initialize-button"; -import { MenuListTable } from "@/lib/menu-list/table/menu-list-table"; -import { Shell } from "@/components/shell" -import * as React from "react" -import { InformationButton } from "@/components/information/information-button"; -import { useTranslation } from "@/i18n"; -interface MenuListPageProps { - params: Promise<{ lng: string }> +interface PageProps { + params: Promise<{ lng: string }>; } -export default async function MenuListPage({ params }: MenuListPageProps) { - const { lng } = await params - const { t } = await useTranslation(lng, 'menu') +export default async function MenuV2Page({ params }: PageProps) { + const { lng } = await params; - // 초기 데이터 로드 - const [menusResult, usersResult] = await Promise.all([ - getMenuAssignments(), - getActiveUsers() - ]); - - // 서버사이드에서 번역된 메뉴 데이터 생성 - const translatedMenus = menusResult.data?.map(menu => ({ - ...menu, - sectionTitle: menu.sectionTitle || "", - translatedMenuTitle: t(menu.menuTitle || ""), - translatedSectionTitle: t(menu.sectionTitle || ""), - translatedMenuGroup: menu.menuGroup ? t(menu.menuGroup) : null, - translatedMenuDescription: menu.menuDescription ? t(menu.menuDescription) : null - })) || []; - return ( - -
-
-
-
-

- {t('menu.information_system.menu_list')} -

- -
- {/*

- 각 메뉴별로 담당자를 지정하고 관리할 수 있습니다. -

*/} -
-
- +
+
+

Menu Management

- - - - - - 로딩 중...
}> - - - - - - - + + +
); } + diff --git a/app/[lng]/evcp/(evcp)/layout.tsx b/app/[lng]/evcp/(evcp)/layout.tsx index c5e75a4c..093d9301 100644 --- a/app/[lng]/evcp/(evcp)/layout.tsx +++ b/app/[lng]/evcp/(evcp)/layout.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react'; -import { Header } from '@/components/layout/Header'; +import { HeaderV2 } from '@/components/layout/HeaderV2'; import { SiteFooter } from '@/components/layout/Footer'; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; @@ -20,7 +20,8 @@ export default async function EvcpLayout({ children }: { children: ReactNode }) try { const result = await verifyNonsapPermission( parseInt(session.user.id), - ['SEARCH'] + // ['SEARCH'] + [] // 아무런 실제 권한이 없어도, 등록된 상태라면 화면에 'SEARCH' 권한이 있는것처럼 동작하게 해달라고 함. (김희은 프로) ); isAuthorized = result.authorized; authMessage = result.message || ""; @@ -36,7 +37,7 @@ export default async function EvcpLayout({ children }: { children: ReactNode }) return (
{/*
*/} -
+ {!skipPermissionCheck && ( )} diff --git a/app/[lng]/partners/(partners)/layout.tsx b/app/[lng]/partners/(partners)/layout.tsx index 9dc39f7b..51a30028 100644 --- a/app/[lng]/partners/(partners)/layout.tsx +++ b/app/[lng]/partners/(partners)/layout.tsx @@ -1,11 +1,11 @@ import { ReactNode } from 'react'; -import { Header } from '@/components/layout/Header'; +import { HeaderV2 } from '@/components/layout/HeaderV2'; import { SiteFooter } from '@/components/layout/Footer'; export default function EvcpLayout({ children }: { children: ReactNode }) { return (
-
+
{children} diff --git a/components/layout/DynamicMenuRender.tsx b/components/layout/DynamicMenuRender.tsx new file mode 100644 index 00000000..f94223ae --- /dev/null +++ b/components/layout/DynamicMenuRender.tsx @@ -0,0 +1,146 @@ +"use client"; + +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { NavigationMenuLink } from "@/components/ui/navigation-menu"; +import type { MenuTreeNode } from "@/lib/menu-v2/types"; + +interface DynamicMenuRenderProps { + groups: MenuTreeNode[] | undefined; + lng: string; + getTitle: (node: MenuTreeNode) => string; + getDescription: (node: MenuTreeNode) => string | null; + onItemClick?: () => void; +} + +export default function DynamicMenuRender({ + groups, + lng, + getTitle, + getDescription, + onItemClick, +}: DynamicMenuRenderProps) { + if (!groups || groups.length === 0) { + return ( +
+ 메뉴가 없습니다. +
+ ); + } + + // 그룹별로 메뉴 분류 + const groupedMenus = new Map(); + const ungroupedMenus: MenuTreeNode[] = []; + + for (const item of groups) { + if (item.nodeType === "group") { + // 그룹인 경우, 그룹의 children을 해당 그룹에 추가 + const groupTitle = getTitle(item); + if (!groupedMenus.has(groupTitle)) { + groupedMenus.set(groupTitle, []); + } + if (item.children) { + groupedMenus.get(groupTitle)!.push(...item.children); + } + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 (그룹 없이 직접 메뉴그룹에 속한 경우) + ungroupedMenus.push(item); + } + } + + // 그룹이 없고 메뉴만 있는 경우 - 단순 그리드 렌더링 + if (groupedMenus.size === 0 && ungroupedMenus.length > 0) { + return ( +
    + {ungroupedMenus.map((menu) => ( + + {getDescription(menu)} + + ))} +
+ ); + } + + // 그룹별 렌더링 - 가로 스크롤 지원 + // 컨텐츠가 85vw를 초과할 때만 스크롤 발생 + return ( +
+
+ {/* 그룹화되지 않은 메뉴 (있는 경우) */} + {ungroupedMenus.length > 0 && ( +
+
    + {ungroupedMenus.map((menu) => ( + + {getDescription(menu)} + + ))} +
+
+ )} + + {/* 그룹별 메뉴 - 순서대로 가로 배치 */} + {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => ( +
+

+ {groupTitle} +

+
    + {menus.map((menu) => ( + + {getDescription(menu)} + + ))} +
+
+ ))} +
+
+ ); +} + +interface MenuListItemProps { + href: string; + title: string; + children?: React.ReactNode; + onClick?: () => void; +} + +function MenuListItem({ href, title, children, onClick }: MenuListItemProps) { + return ( +
  • + + +
    {title}
    + {children && ( +

    + {children} +

    + )} + +
    +
  • + ); +} + diff --git a/components/layout/HeaderV2.tsx b/components/layout/HeaderV2.tsx new file mode 100644 index 00000000..88d50cc5 --- /dev/null +++ b/components/layout/HeaderV2.tsx @@ -0,0 +1,295 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { SearchIcon, Loader2 } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { useSession } from "next-auth/react"; +import { customSignOut } from "@/lib/auth/custom-signout"; +import DynamicMenuRender from "./DynamicMenuRender"; +import { MobileMenuV2 } from "./MobileMenuV2"; +import { CommandMenu } from "./command-menu"; +import { NotificationDropdown } from "./NotificationDropdown"; +import { useVisibleMenuTree } from "@/hooks/use-visible-menu-tree"; +import { useTranslation } from "@/i18n/client"; +import type { MenuDomain, MenuTreeNode } from "@/lib/menu-v2/types"; + +// 도메인별 브랜드명 +const domainBrandingKeys: Record = { + evcp: "branding.evcp_main", + partners: "branding.evcp_partners", +}; + +export function HeaderV2() { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const pathname = usePathname(); + const { data: session } = useSession(); + const { t } = useTranslation(lng, "menu"); + + // 현재 도메인 결정 + const domain: MenuDomain = pathname?.includes("/partners") ? "partners" : "evcp"; + + // 메뉴 데이터 로드 (tree에 드롭다운과 단일 링크가 모두 포함됨) + const { tree, isLoading } = useVisibleMenuTree(domain); + + const userName = session?.user?.name || ""; + const initials = userName + .split(" ") + .map((word) => word[0]?.toUpperCase()) + .join(""); + + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + const [openMenuKey, setOpenMenuKey] = React.useState(""); + + const toggleMobileMenu = () => { + setIsMobileMenuOpen(!isMobileMenuOpen); + }; + + const toggleMenu = React.useCallback((menuKey: string) => { + setOpenMenuKey((prev) => (prev === menuKey ? "" : menuKey)); + }, []); + + // 페이지 이동 시 메뉴 닫기 + React.useEffect(() => { + setOpenMenuKey(""); + }, [pathname]); + + // 브랜딩 및 경로 설정 + const brandNameKey = domainBrandingKeys[domain]; + const logoHref = `/${lng}/${domain}`; + const basePath = `/${lng}/${domain}`; + + // 다국어 텍스트 선택 + const getTitle = (node: MenuTreeNode) => + lng === "ko" ? node.titleKo : node.titleEn || node.titleKo; + + const getDescription = (node: MenuTreeNode) => + lng === "ko" + ? node.descriptionKo + : node.descriptionEn || node.descriptionKo; + + // 메뉴 노드가 드롭다운(자식 있음)인지 단일 링크인지 판단 + const isDropdownMenu = (node: MenuTreeNode) => + node.nodeType === 'menu_group' && node.children && node.children.length > 0; + + return ( + <> +
    +
    +
    + {/* 햄버거 메뉴 버튼 (모바일) */} + + + {/* 로고 영역 */} +
    + + EVCP Logo + + {t(brandNameKey)} + + +
    + + {/* 네비게이션 메뉴 */} +
    + {isLoading ? ( +
    + +
    + ) : ( + +
    + + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + + { + e.preventDefault(); + e.stopPropagation(); + toggleMenu(String(node.id)); + }} + onPointerEnter={(e) => e.preventDefault()} + onPointerMove={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + + + e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + forceMount={ + openMenuKey === String(node.id) + ? true + : undefined + } + > + setOpenMenuKey("")} + /> + + + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + + + e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + + + + ); + } + + return null; + })} + +
    +
    + )} +
    + + {/* 우측 영역 */} +
    + {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */} +
    + +
    + + + {/* 알림 버튼 */} + + + {/* 사용자 메뉴 */} + + + + + {initials || "?"} + + + + {t("user.my_account")} + + + {t("user.settings")} + + + + customSignOut({ + callbackUrl: `${window.location.origin}${basePath}`, + }) + } + > + {t("user.logout")} + + + +
    +
    +
    + + {/* 모바일 메뉴 */} + {isMobileMenuOpen && ( + + )} +
    + + ); +} diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx new file mode 100644 index 00000000..c83ba779 --- /dev/null +++ b/components/layout/MobileMenuV2.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { X, ChevronDown, ChevronRight } from "lucide-react"; +import type { MenuTreeNode } from "@/lib/menu-v2/types"; + +interface MobileMenuV2Props { + lng: string; + onClose: () => void; + tree: MenuTreeNode[]; + getTitle: (node: MenuTreeNode) => string; + getDescription: (node: MenuTreeNode) => string | null; +} + +export function MobileMenuV2({ + lng, + onClose, + tree, + getTitle, + getDescription, +}: MobileMenuV2Props) { + const [expandedGroups, setExpandedGroups] = React.useState>( + new Set() + ); + + const toggleGroup = (groupId: number) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + // 드롭다운 메뉴인지 판단 + const isDropdownMenu = (node: MenuTreeNode) => + node.nodeType === 'menu_group' && node.children && node.children.length > 0; + + return ( +
    + {/* 헤더 */} +
    + 메뉴 + +
    + + {/* 스크롤 영역 */} + +
    + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( +
    + {/* 메뉴그룹 헤더 */} + + + {/* 하위 메뉴 */} + {expandedGroups.has(node.id) && ( +
    + {node.children?.map((item) => { + if (item.nodeType === "group") { + // 그룹인 경우 + return ( +
    +
    + {getTitle(item)} +
    +
    + {item.children?.map((menu) => ( + + ))} +
    +
    + ); + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 + return ( + + ); + } + return null; + })} +
    + )} +
    + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + + ); + } + + return null; + })} +
    +
    +
    + ); +} + +interface MobileMenuLinkProps { + href: string; + title: string; + onClick: () => void; +} + +function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) { + return ( + + {title} + + ); +} diff --git a/db/schema/index.ts b/db/schema/index.ts index 6463e0ec..022431cc 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -29,7 +29,11 @@ export * from './evaluation'; export * from './evaluationTarget'; export * from './evaluationCriteria'; export * from './projectGtc'; +// 기존 menu 스키마 (deprecated - menu-v2로 대체됨) export * from './menu'; + +// 새로운 메뉴 트리 스키마 (v2) +export * from './menu-v2'; export * from './information'; export * from './qna'; export * from './notice'; diff --git a/db/schema/menu-v2.ts b/db/schema/menu-v2.ts new file mode 100644 index 00000000..2d0282fa --- /dev/null +++ b/db/schema/menu-v2.ts @@ -0,0 +1,88 @@ +// db/schema/menu-v2.ts +import { pgTable, pgEnum, integer, varchar, text, timestamp, boolean, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { users } from "./users"; + +export const menuTreeNodeTypeEnum = pgEnum('menu_tree_node_type', [ + 'menu_group', // 메뉴그룹 (1단계) - 헤더에 표시되는 드롭다운 트리거 + 'group', // 그룹 (2단계) - 드롭다운 내 구분 영역 + 'menu', // 메뉴 (3단계) - 드롭다운 내 링크 + 'additional' // 추가 메뉴 - 최상위 단일 링크 (Dashboard, QNA, FAQ 등) +]); + +export const menuDomainEnum = pgEnum('menu_domain', [ + 'evcp', // 내부 사용자용 + 'partners' // 협력업체용 +]); + +export const menuTreeNodes = pgTable("menu_tree_nodes", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // 도메인 구분 + domain: menuDomainEnum("domain").notNull(), + + // 트리 구조 + parentId: integer("parent_id").references((): any => menuTreeNodes.id, { onDelete: "cascade" }), + nodeType: menuTreeNodeTypeEnum("node_type").notNull(), + sortOrder: integer("sort_order").notNull().default(0), + + // 다국어 텍스트 (DB 직접 관리) + titleKo: varchar("title_ko", { length: 255 }).notNull(), + titleEn: varchar("title_en", { length: 255 }), + descriptionKo: text("description_ko"), + descriptionEn: text("description_en"), + + // 메뉴 전용 필드 (nodeType === 'menu' 또는 'additional'일 때) + menuPath: varchar("menu_path", { length: 255 }), // href 값 (예: /evcp/projects) + icon: varchar("icon", { length: 100 }), + + // 권한 연동 + // evcp: Oracle DB SCR_ID 참조 + // partners: 자체 권한 시스템 (TODO) + scrId: varchar("scr_id", { length: 100 }), + + // 상태 + isActive: boolean("is_active").default(true).notNull(), + + // 담당자 (evcp 전용) + manager1Id: integer("manager1_id").references(() => users.id, { onDelete: "set null" }), + manager2Id: integer("manager2_id").references(() => users.id, { onDelete: "set null" }), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + domainIdx: index("menu_tree_domain_idx").on(table.domain), + parentIdx: index("menu_tree_parent_idx").on(table.parentId), + sortOrderIdx: index("menu_tree_sort_order_idx").on(table.sortOrder), + menuPathUnique: uniqueIndex("menu_tree_path_unique_idx").on(table.menuPath), + scrIdIdx: index("menu_tree_scr_id_idx").on(table.scrId), +})); + +// Relations 정의 +export const menuTreeNodesRelations = relations(menuTreeNodes, ({ one, many }) => ({ + parent: one(menuTreeNodes, { + fields: [menuTreeNodes.parentId], + references: [menuTreeNodes.id], + relationName: "parentChild", + }), + children: many(menuTreeNodes, { + relationName: "parentChild", + }), + manager1: one(users, { + fields: [menuTreeNodes.manager1Id], + references: [users.id], + relationName: "menuManager1", + }), + manager2: one(users, { + fields: [menuTreeNodes.manager2Id], + references: [users.id], + relationName: "menuManager2", + }), +})); + +// Type exports +export type MenuTreeNode = typeof menuTreeNodes.$inferSelect; +export type NewMenuTreeNode = typeof menuTreeNodes.$inferInsert; +export type NodeType = (typeof menuTreeNodeTypeEnum.enumValues)[number]; +export type MenuDomain = (typeof menuDomainEnum.enumValues)[number]; + diff --git a/db/seeds/menu-v2-seed.js b/db/seeds/menu-v2-seed.js new file mode 100644 index 00000000..e332f044 --- /dev/null +++ b/db/seeds/menu-v2-seed.js @@ -0,0 +1,231 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.seedMenuTree = seedMenuTree; +// db/seeds/menu-v2-seed.ts +var menuConfig_1 = require("@/config/menuConfig"); +var menu_json_1 = require("@/i18n/locales/ko/menu.json"); +var menu_json_2 = require("@/i18n/locales/en/menu.json"); +var db_1 = require("@/db/db"); +var menu_v2_1 = require("@/db/schema/menu-v2"); +// 중첩 키로 번역 값 가져오기 +function getTranslation(key, locale) { + var translations = locale === 'ko' ? menu_json_1.default : menu_json_2.default; + var keys = key.split('.'); + var value = translations; + for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) { + var k = keys_1[_i]; + if (typeof value === 'object' && value !== null) { + value = value[k]; + } + else { + return key; + } + if (value === undefined) + return key; + } + return typeof value === 'string' ? value : key; +} +function seedMenuTree() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + console.log('🌱 Starting menu tree seeding...'); + // 기존 데이터 삭제 + return [4 /*yield*/, db_1.default.delete(menu_v2_1.menuTreeNodes)]; + case 1: + // 기존 데이터 삭제 + _a.sent(); + console.log('✅ Cleared existing menu tree data'); + // evcp 도메인 seed + return [4 /*yield*/, seedDomainMenus('evcp', menuConfig_1.mainNav, menuConfig_1.additionalNav)]; + case 2: + // evcp 도메인 seed + _a.sent(); + console.log('✅ Seeded evcp menu tree'); + // partners 도메인 seed + return [4 /*yield*/, seedDomainMenus('partners', menuConfig_1.mainNavVendor, menuConfig_1.additionalNavVendor)]; + case 3: + // partners 도메인 seed + _a.sent(); + console.log('✅ Seeded partners menu tree'); + console.log('🎉 Menu tree seeding completed!'); + return [2 /*return*/]; + } + }); + }); +} +function seedDomainMenus(domain, navConfig, additionalConfig) { + return __awaiter(this, void 0, void 0, function () { + var globalSortOrder, _loop_1, _i, navConfig_1, section, additionalSortOrder, _a, additionalConfig_1, item; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + globalSortOrder = 0; + _loop_1 = function (section) { + var menuGroup, groupedItems, groupSortOrder, _c, groupedItems_1, _d, groupKey, items, parentId, group, menuSortOrder, _e, items_1, item; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: null, + nodeType: 'menu_group', + titleKo: getTranslation(section.titleKey, 'ko'), + titleEn: getTranslation(section.titleKey, 'en'), + sortOrder: globalSortOrder++, + isActive: true, + }).returning()]; + case 1: + menuGroup = (_f.sent())[0]; + groupedItems = new Map(); + section.items.forEach(function (item) { + var groupKey = item.groupKey || '__default__'; + if (!groupedItems.has(groupKey)) { + groupedItems.set(groupKey, []); + } + groupedItems.get(groupKey).push(item); + }); + groupSortOrder = 0; + _c = 0, groupedItems_1 = groupedItems; + _f.label = 2; + case 2: + if (!(_c < groupedItems_1.length)) return [3 /*break*/, 9]; + _d = groupedItems_1[_c], groupKey = _d[0], items = _d[1]; + parentId = menuGroup.id; + if (!(groupKey !== '__default__')) return [3 /*break*/, 4]; + return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: menuGroup.id, + nodeType: 'group', + titleKo: getTranslation(groupKey, 'ko'), + titleEn: getTranslation(groupKey, 'en'), + sortOrder: groupSortOrder++, + isActive: true, + }).returning()]; + case 3: + group = (_f.sent())[0]; + parentId = group.id; + _f.label = 4; + case 4: + menuSortOrder = 0; + _e = 0, items_1 = items; + _f.label = 5; + case 5: + if (!(_e < items_1.length)) return [3 /*break*/, 8]; + item = items_1[_e]; + return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: parentId, + nodeType: 'menu', + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + icon: item.icon || null, + sortOrder: menuSortOrder++, + isActive: true, + })]; + case 6: + _f.sent(); + _f.label = 7; + case 7: + _e++; + return [3 /*break*/, 5]; + case 8: + _c++; + return [3 /*break*/, 2]; + case 9: return [2 /*return*/]; + } + }); + }; + _i = 0, navConfig_1 = navConfig; + _b.label = 1; + case 1: + if (!(_i < navConfig_1.length)) return [3 /*break*/, 4]; + section = navConfig_1[_i]; + return [5 /*yield**/, _loop_1(section)]; + case 2: + _b.sent(); + _b.label = 3; + case 3: + _i++; + return [3 /*break*/, 1]; + case 4: + additionalSortOrder = 0; + _a = 0, additionalConfig_1 = additionalConfig; + _b.label = 5; + case 5: + if (!(_a < additionalConfig_1.length)) return [3 /*break*/, 8]; + item = additionalConfig_1[_a]; + return [4 /*yield*/, db_1.default.insert(menu_v2_1.menuTreeNodes).values({ + domain: domain, + parentId: null, + nodeType: 'additional', + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + sortOrder: additionalSortOrder++, + isActive: true, + })]; + case 6: + _b.sent(); + _b.label = 7; + case 7: + _a++; + return [3 /*break*/, 5]; + case 8: return [2 /*return*/]; + } + }); + }); +} +// CLI에서 직접 실행 가능하도록 +if (require.main === module) { + seedMenuTree() + .then(function () { + console.log('Seed completed successfully'); + process.exit(0); + }) + .catch(function (error) { + console.error('Seed failed:', error); + process.exit(1); + }); +} diff --git a/db/seeds/menu-v2-seed.ts b/db/seeds/menu-v2-seed.ts new file mode 100644 index 00000000..0c6b310d --- /dev/null +++ b/db/seeds/menu-v2-seed.ts @@ -0,0 +1,145 @@ +// db/seeds/menu-v2-seed.ts +import { mainNav, additionalNav, mainNavVendor, additionalNavVendor, MenuSection, MenuItem } from "@/config/menuConfig"; +import koMenu from '@/i18n/locales/ko/menu.json'; +import enMenu from '@/i18n/locales/en/menu.json'; +import db from "@/db/db"; +import { menuTreeNodes } from "@/db/schema/menu-v2"; +import type { MenuDomain } from "@/lib/menu-v2/types"; + +type TranslationObject = { [key: string]: string | TranslationObject }; + +// 중첩 키로 번역 값 가져오기 +function getTranslation(key: string, locale: 'ko' | 'en'): string { + const translations: TranslationObject = locale === 'ko' ? koMenu : enMenu; + const keys = key.split('.'); + let value: string | TranslationObject | undefined = translations; + + for (const k of keys) { + if (typeof value === 'object' && value !== null) { + value = value[k]; + } else { + return key; + } + if (value === undefined) return key; + } + + return typeof value === 'string' ? value : key; +} + +export async function seedMenuTree() { + console.log('🌱 Starting menu tree seeding...'); + + // 기존 데이터 삭제 + await db.delete(menuTreeNodes); + console.log('✅ Cleared existing menu tree data'); + + // evcp 도메인 seed + await seedDomainMenus('evcp', mainNav, additionalNav); + console.log('✅ Seeded evcp menu tree'); + + // partners 도메인 seed + await seedDomainMenus('partners', mainNavVendor, additionalNavVendor); + console.log('✅ Seeded partners menu tree'); + + console.log('🎉 Menu tree seeding completed!'); +} + +async function seedDomainMenus( + domain: MenuDomain, + navConfig: MenuSection[], + additionalConfig: MenuItem[] +) { + // 최상위 sortOrder (메뉴그룹과 최상위 메뉴 모두 같은 레벨에서 정렬) + let topLevelSortOrder = 0; + + // 메인 네비게이션 (메뉴그룹 → 그룹 → 메뉴) + for (const section of navConfig) { + // 1단계: 메뉴그룹 생성 + const [menuGroup] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu_group', + titleKo: getTranslation(section.titleKey, 'ko'), + titleEn: getTranslation(section.titleKey, 'en'), + sortOrder: topLevelSortOrder++, + isActive: true, + }).returning(); + + // groupKey별로 그룹화 + const groupedItems = new Map(); + section.items.forEach(item => { + const groupKey = item.groupKey || '__default__'; + if (!groupedItems.has(groupKey)) { + groupedItems.set(groupKey, []); + } + groupedItems.get(groupKey)!.push(item); + }); + + let groupSortOrder = 0; + for (const [groupKey, items] of groupedItems) { + let parentId = menuGroup.id; + + // groupKey가 있으면 2단계 그룹 생성 + if (groupKey !== '__default__') { + const [group] = await db.insert(menuTreeNodes).values({ + domain, + parentId: menuGroup.id, + nodeType: 'group', + titleKo: getTranslation(groupKey, 'ko'), + titleEn: getTranslation(groupKey, 'en'), + sortOrder: groupSortOrder++, + isActive: true, + }).returning(); + parentId = group.id; + } + + // 3단계: 메뉴 생성 + let menuSortOrder = 0; + for (const item of items) { + await db.insert(menuTreeNodes).values({ + domain, + parentId, + nodeType: 'menu', + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + icon: item.icon || null, + sortOrder: menuSortOrder++, + isActive: true, + }); + } + } + } + + // 최상위 단일 링크 메뉴 (기존 additional) + // nodeType을 'menu'로 설정하고 parentId를 null로 유지 + for (const item of additionalConfig) { + await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', // 'additional' 대신 'menu' 사용 + titleKo: getTranslation(item.titleKey, 'ko'), + titleEn: getTranslation(item.titleKey, 'en'), + descriptionKo: item.descriptionKey ? getTranslation(item.descriptionKey, 'ko') : null, + descriptionEn: item.descriptionKey ? getTranslation(item.descriptionKey, 'en') : null, + menuPath: item.href, + sortOrder: topLevelSortOrder++, // 메뉴그룹 다음 순서 + isActive: true, + }); + } +} + +// CLI에서 직접 실행 가능하도록 +if (require.main === module) { + seedMenuTree() + .then(() => { + console.log('Seed completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('Seed failed:', error); + process.exit(1); + }); +} diff --git a/hooks/use-visible-menu-tree.ts b/hooks/use-visible-menu-tree.ts new file mode 100644 index 00000000..bc7f1f73 --- /dev/null +++ b/hooks/use-visible-menu-tree.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { getVisibleMenuTree } from "@/lib/menu-v2/permission-service"; +import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "@/lib/menu-v2/types"; + +interface UseVisibleMenuTreeResult extends MenuTreeActiveResult { + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Hook to fetch user's visible menu tree (filtered by permissions) + * Tree contains both menu groups (dropdowns) and top-level menus (single links) + */ +export function useVisibleMenuTree(domain: MenuDomain): UseVisibleMenuTreeResult { + const [tree, setTree] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchMenuTree = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + // Call server action directly + const result = await getVisibleMenuTree(domain); + setTree(result.tree); + } catch (err) { + console.error("Error fetching visible menu tree:", err); + setError(err instanceof Error ? err : new Error("Unknown error")); + setTree([]); + } finally { + setIsLoading(false); + } + }, [domain]); + + useEffect(() => { + fetchMenuTree(); + }, [fetchMenuTree]); + + return { + tree, + isLoading, + error, + refetch: fetchMenuTree, + }; +} diff --git a/lib/information/service.ts b/lib/information/service.ts index 02efe616..39e810e4 100644 --- a/lib/information/service.ts +++ b/lib/information/service.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from "@/lib/handle-error" import { desc, or, eq } from "drizzle-orm" import db from "@/db/db" -import { pageInformation, menuAssignments, users } from "@/db/schema" +import { pageInformation, menuTreeNodes, users } from "@/db/schema" import { saveDRMFile } from "@/lib/file-stroage" import { decryptWithServerAction } from "@/components/drm/drmUtils" @@ -144,27 +144,27 @@ export async function checkInformationEditPermission(pagePath: string, userId: s pagePath // 원본 경로 정확한 매칭 ] - // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기 - const menuAssignment = await db + // menu_tree_nodes에서 해당 pagePath와 매칭되는 메뉴 찾기 + const menuNode = await db .select() - .from(menuAssignments) + .from(menuTreeNodes) .where( or( - ...menuPathQueries.map(path => eq(menuAssignments.menuPath, path)) + ...menuPathQueries.map(path => eq(menuTreeNodes.menuPath, path)) ) ) .limit(1) - if (menuAssignment.length === 0) { + if (menuNode.length === 0) { // 매칭되는 메뉴가 없으면 권한 없음 return false } - const assignment = menuAssignment[0] + const node = menuNode[0] const userIdNumber = parseInt(userId) // 현재 사용자가 manager1 또는 manager2인지 확인 - return assignment.manager1Id === userIdNumber || assignment.manager2Id === userIdNumber + return node.manager1Id === userIdNumber || node.manager2Id === userIdNumber } catch (error) { console.error("Failed to check information edit permission:", error) return false @@ -176,17 +176,21 @@ export async function getEditPermissionDirect(pagePath: string, userId: string) return await checkInformationEditPermission(pagePath, userId) } -// menu_assignments 기반으로 page_information 동기화 +// menu_tree_nodes 기반으로 page_information 동기화 export async function syncInformationFromMenuAssignments() { try { - // menu_assignments에서 모든 메뉴 가져오기 - const menuItems = await db.select().from(menuAssignments); + // menu_tree_nodes에서 메뉴 타입 노드만 가져오기 (menuPath가 있는 것) + const menuItems = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.nodeType, 'menu')); let processedCount = 0; // upsert를 사용하여 각 메뉴 항목 처리 for (const menu of menuItems) { try { + if (!menu.menuPath) continue; + // 맨 앞의 / 제거하여 pagePath 정규화 const normalizedPagePath = menu.menuPath.startsWith('/') ? menu.menuPath.slice(1) @@ -195,14 +199,14 @@ export async function syncInformationFromMenuAssignments() { await db.insert(pageInformation) .values({ pagePath: normalizedPagePath, - pageName: menu.menuTitle, + pageName: menu.titleKo, informationContent: "", isActive: true // 기본값으로 활성화 }) .onConflictDoUpdate({ target: pageInformation.pagePath, set: { - pageName: menu.menuTitle, + pageName: menu.titleKo, updatedAt: new Date() } }); @@ -213,8 +217,6 @@ export async function syncInformationFromMenuAssignments() { } } - // 캐시 무효화 제거됨 - return { success: true, message: `페이지 정보 동기화 완료: ${processedCount}개 처리됨` diff --git a/lib/menu-v2/components/add-node-dialog.tsx b/lib/menu-v2/components/add-node-dialog.tsx new file mode 100644 index 00000000..b6762820 --- /dev/null +++ b/lib/menu-v2/components/add-node-dialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { + MenuDomain, + CreateMenuGroupInput, + CreateGroupInput, + CreateTopLevelMenuInput +} from "../types"; + +type DialogType = "menu_group" | "group" | "top_level_menu"; + +interface AddNodeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: DialogType; + domain: MenuDomain; + parentId?: number; // group 생성 시 필요 + onSave: (data: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput) => Promise; +} + +interface FormData { + titleKo: string; + titleEn: string; + menuPath: string; +} + +export function AddNodeDialog({ + open, + onOpenChange, + type, + domain, + parentId, + onSave, +}: AddNodeDialogProps) { + const { + register, + handleSubmit, + reset, + formState: { isSubmitting, errors }, + } = useForm({ + defaultValues: { + titleKo: "", + titleEn: "", + menuPath: "", + }, + }); + + const getTitle = () => { + switch (type) { + case "menu_group": + return "Add Menu Group"; + case "group": + return "Add Group"; + case "top_level_menu": + return "Add Top-Level Menu"; + default: + return "Add"; + } + }; + + const getDescription = () => { + switch (type) { + case "menu_group": + return "A dropdown trigger displayed in the header navigation."; + case "group": + return "Groups menus within a menu group."; + case "top_level_menu": + return "A single link displayed in the header navigation."; + default: + return ""; + } + }; + + const onSubmit = async (data: FormData) => { + let saveData: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput; + + if (type === "menu_group") { + saveData = { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + }; + } else if (type === "group" && parentId) { + saveData = { + parentId, + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + }; + } else if (type === "top_level_menu") { + saveData = { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + menuPath: data.menuPath, + }; + } else { + return; + } + + await onSave(saveData); + reset(); + onOpenChange(false); + }; + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + return ( + + + + {getTitle()} + {getDescription()} + + +
    +
    + {/* Korean Name */} +
    + + + {errors.titleKo && ( +

    {errors.titleKo.message}

    + )} +
    + + {/* English Name */} +
    + + +
    + + {/* Menu Path for Top-Level Menu */} + {type === "top_level_menu" && ( +
    + + + {errors.menuPath && ( +

    {errors.menuPath.message}

    + )} +

    + e.g., /{domain}/report, /{domain}/faq +

    +
    + )} +
    + + + + + +
    +
    +
    + ); +} diff --git a/lib/menu-v2/components/domain-tabs.tsx b/lib/menu-v2/components/domain-tabs.tsx new file mode 100644 index 00000000..e52fa80b --- /dev/null +++ b/lib/menu-v2/components/domain-tabs.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { MenuDomain } from "../types"; + +interface DomainTabsProps { + value: MenuDomain; + onChange: (domain: MenuDomain) => void; +} + +export function DomainTabs({ value, onChange }: DomainTabsProps) { + return ( + onChange(v as MenuDomain)}> + + + EVCP (Internal) + + + Partners (Vendors) + + + + ); +} + diff --git a/lib/menu-v2/components/edit-node-dialog.tsx b/lib/menu-v2/components/edit-node-dialog.tsx new file mode 100644 index 00000000..9631a611 --- /dev/null +++ b/lib/menu-v2/components/edit-node-dialog.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +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 { Switch } from "@/components/ui/switch"; +import type { MenuTreeNode, UpdateNodeInput } from "../types"; + +interface EditNodeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: MenuTreeNode | null; + onSave: (nodeId: number, data: UpdateNodeInput) => Promise; +} + +interface FormData { + titleKo: string; + titleEn: string; + descriptionKo: string; + descriptionEn: string; + scrId: string; + isActive: boolean; +} + +export function EditNodeDialog({ + open, + onOpenChange, + node, + onSave, +}: EditNodeDialogProps) { + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + titleKo: "", + titleEn: "", + descriptionKo: "", + descriptionEn: "", + scrId: "", + isActive: true, + }, + }); + + const isActive = watch("isActive"); + + useEffect(() => { + if (node) { + reset({ + titleKo: node.titleKo, + titleEn: node.titleEn || "", + descriptionKo: node.descriptionKo || "", + descriptionEn: node.descriptionEn || "", + scrId: node.scrId || "", + isActive: node.isActive, + }); + } + }, [node, reset]); + + const onSubmit = async (data: FormData) => { + if (!node) return; + + await onSave(node.id, { + titleKo: data.titleKo, + titleEn: data.titleEn || undefined, + descriptionKo: data.descriptionKo || undefined, + descriptionEn: data.descriptionEn || undefined, + scrId: data.scrId || undefined, + isActive: data.isActive, + }); + + onOpenChange(false); + }; + + const getTypeLabel = () => { + switch (node?.nodeType) { + case "menu_group": + return "Menu Group"; + case "group": + return "Group"; + case "menu": + return "Menu"; + case "additional": + return "Additional Menu"; + default: + return "Node"; + } + }; + + const showMenuFields = node?.nodeType === "menu" || node?.nodeType === "additional"; + + return ( + + + + Edit {getTypeLabel()} + + {node?.menuPath && ( + {node.menuPath} + )} + + + +
    +
    + {/* Korean Name */} +
    + + +
    + + {/* English Name */} +
    + + +
    + + {/* Korean Description */} + {showMenuFields && ( +
    + +