diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-04 21:05:28 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-04 21:05:28 +0900 |
| commit | e5b36fa6a1b12446883f51fc5e7cd56d8df8d8f5 (patch) | |
| tree | c8f9fb50eb593dd5322d26d9276947c155997858 | |
| parent | 240f4f31b3b6ff6a46436978fb988588a1972721 (diff) | |
| parent | 04ed774ff60a83c00711d4e8615cb4122954dba5 (diff) | |
Merge branch 'jh-auth-menu' into dujinkim
22 files changed, 3384 insertions, 87 deletions
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 ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - {t('menu.information_system.menu_list')} - </h2> - <InformationButton pagePath="evcp/menu-list" /> - </div> - {/* <p className="text-muted-foreground"> - 각 메뉴별로 담당자를 지정하고 관리할 수 있습니다. - </p> */} - </div> - </div> - + <div className="container mx-auto py-6 space-y-6"> + <div> + <h1 className="text-2xl font-bold tracking-tight">Menu Management</h1> </div> - - - <React.Suspense - fallback={ - "" - } - > - <Card> - <CardContent className="pt-6"> - <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}> - <MenuListTable - initialMenus={translatedMenus} - initialUsers={usersResult.data || []} - /> - </Suspense> - </CardContent> - </Card> - </React.Suspense> - </Shell> - + + <MenuTreeManager initialDomain="evcp" /> + </div> ); } + 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 ( <div className="relative flex min-h-svh flex-col bg-background"> {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */} - <Header /> + <HeaderV2 /> {!skipPermissionCheck && ( <PermissionChecker authorized={isAuthorized} message={authMessage} /> )} 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 ( <div className="relative flex min-h-svh flex-col bg-background"> - <Header /> + <HeaderV2 /> <main className="flex flex-1 flex-col"> <div className='container-wrapper'> {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 ( + <div className="p-4 text-sm text-muted-foreground"> + 메뉴가 없습니다. + </div> + ); + } + + // 그룹별로 메뉴 분류 + const groupedMenus = new Map<string, MenuTreeNode[]>(); + 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 ( + <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]"> + {ungroupedMenus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + ); + } + + // 그룹별 렌더링 - 가로 스크롤 지원 + // 컨텐츠가 85vw를 초과할 때만 스크롤 발생 + return ( + <div className="p-4 max-w-[85vw]"> + <div className="flex gap-6 overflow-x-auto"> + {/* 그룹화되지 않은 메뉴 (있는 경우) */} + {ungroupedMenus.length > 0 && ( + <div className="w-[200px] flex-shrink-0"> + <ul className="space-y-2"> + {ungroupedMenus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + </div> + )} + + {/* 그룹별 메뉴 - 순서대로 가로 배치 */} + {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => ( + <div key={groupTitle} className="w-[200px] flex-shrink-0"> + <h4 className="mb-2 text-sm font-semibold text-muted-foreground whitespace-nowrap"> + {groupTitle} + </h4> + <ul className="space-y-2"> + {menus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + </div> + ))} + </div> + </div> + ); +} + +interface MenuListItemProps { + href: string; + title: string; + children?: React.ReactNode; + onClick?: () => void; +} + +function MenuListItem({ href, title, children, onClick }: MenuListItemProps) { + return ( + <li> + <NavigationMenuLink asChild> + <Link + href={href} + onClick={onClick} + className={cn( + "block select-none space-y-1 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" + )} + > + <div className="text-sm font-medium leading-none">{title}</div> + {children && ( + <p className="line-clamp-2 text-xs leading-snug text-muted-foreground"> + {children} + </p> + )} + </Link> + </NavigationMenuLink> + </li> + ); +} + 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<MenuDomain, string> = { + 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<string>(""); + + 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 ( + <> + <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="container-wrapper"> + <div className="container flex h-14 items-center"> + {/* 햄버거 메뉴 버튼 (모바일) */} + <Button + onClick={toggleMobileMenu} + variant="ghost" + className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth="1.5" + stroke="currentColor" + className="!size-6" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M3.75 9h16.5m-16.5 6.75h16.5" + /> + </svg> + <span className="sr-only">{t("menu.toggle_menu")}</span> + </Button> + + {/* 로고 영역 */} + <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6"> + <Link href={logoHref} className="flex items-center gap-2"> + <Image + className="dark:invert" + src="/images/vercel.svg" + alt="EVCP Logo" + width={20} + height={20} + /> + <span className="hidden font-bold lg:inline-block"> + {t(brandNameKey)} + </span> + </Link> + </div> + + {/* 네비게이션 메뉴 */} + <div className="hidden md:block flex-1 min-w-0"> + {isLoading ? ( + <div className="flex items-center justify-center h-10"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + </div> + ) : ( + <NavigationMenu + className="relative z-50" + value={openMenuKey} + onValueChange={setOpenMenuKey} + > + <div className="w-full overflow-x-auto pb-1"> + <NavigationMenuList className="flex-nowrap w-max"> + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + <NavigationMenuItem + key={node.id} + value={String(node.id)} + > + <NavigationMenuTrigger + className="px-2 xl:px-3 text-sm whitespace-nowrap" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + toggleMenu(String(node.id)); + }} + onPointerEnter={(e) => e.preventDefault()} + onPointerMove={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + </NavigationMenuTrigger> + + <NavigationMenuContent + className="max-h-[80vh] overflow-y-auto overflow-x-hidden" + onPointerEnter={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + forceMount={ + openMenuKey === String(node.id) + ? true + : undefined + } + > + <DynamicMenuRender + groups={node.children} + lng={lng} + getTitle={getTitle} + getDescription={getDescription} + onItemClick={() => setOpenMenuKey("")} + /> + </NavigationMenuContent> + </NavigationMenuItem> + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + <NavigationMenuItem key={node.id}> + <Link + href={`/${lng}${node.menuPath}`} + legacyBehavior + passHref + > + <NavigationMenuLink + className={cn( + navigationMenuTriggerStyle(), + "px-2 xl:px-3 text-sm whitespace-nowrap" + )} + onPointerEnter={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + </NavigationMenuLink> + </Link> + </NavigationMenuItem> + ); + } + + return null; + })} + </NavigationMenuList> + </div> + </NavigationMenu> + )} + </div> + + {/* 우측 영역 */} + <div className="ml-auto flex flex-shrink-0 items-center space-x-2"> + {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */} + <div className="hidden md:block md:w-auto"> + <CommandMenu /> + </div> + <Button + variant="ghost" + size="icon" + className="md:hidden" + aria-label={t("common.search")} + > + <SearchIcon className="h-5 w-5" /> + </Button> + + {/* 알림 버튼 */} + <NotificationDropdown /> + + {/* 사용자 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Avatar className="cursor-pointer h-8 w-8"> + <AvatarImage + src={`${session?.user?.image}` || "/user-avatar.jpg"} + alt="User Avatar" + /> + <AvatarFallback>{initials || "?"}</AvatarFallback> + </Avatar> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-48" align="end"> + <DropdownMenuLabel>{t("user.my_account")}</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem asChild> + <Link href={`${basePath}/settings`}>{t("user.settings")}</Link> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => + customSignOut({ + callbackUrl: `${window.location.origin}${basePath}`, + }) + } + > + {t("user.logout")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </div> + + {/* 모바일 메뉴 */} + {isMobileMenuOpen && ( + <MobileMenuV2 + lng={lng} + onClose={toggleMobileMenu} + tree={tree} + getTitle={getTitle} + getDescription={getDescription} + /> + )} + </header> + </> + ); +} 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<Set<number>>( + 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 ( + <div className="fixed inset-0 z-50 bg-background md:hidden"> + {/* 헤더 */} + <div className="flex items-center justify-between px-4 h-14 border-b"> + <span className="font-semibold">메뉴</span> + <Button variant="ghost" size="icon" onClick={onClose}> + <X className="h-5 w-5" /> + <span className="sr-only">닫기</span> + </Button> + </div> + + {/* 스크롤 영역 */} + <ScrollArea className="h-[calc(100vh-56px)]"> + <div className="px-4 py-4 space-y-2"> + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + <div key={node.id} className="space-y-2"> + {/* 메뉴그룹 헤더 */} + <button + onClick={() => toggleGroup(node.id)} + className="flex items-center justify-between w-full py-2 text-left font-semibold" + > + <span>{getTitle(node)}</span> + {expandedGroups.has(node.id) ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + + {/* 하위 메뉴 */} + {expandedGroups.has(node.id) && ( + <div className="pl-4 space-y-1"> + {node.children?.map((item) => { + if (item.nodeType === "group") { + // 그룹인 경우 + return ( + <div key={item.id} className="space-y-1"> + <div className="text-xs text-muted-foreground font-medium py-1"> + {getTitle(item)} + </div> + <div className="pl-2 space-y-1"> + {item.children?.map((menu) => ( + <MobileMenuLink + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onClose} + /> + ))} + </div> + </div> + ); + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 + return ( + <MobileMenuLink + key={item.id} + href={`/${lng}${item.menuPath}`} + title={getTitle(item)} + onClick={onClose} + /> + ); + } + return null; + })} + </div> + )} + </div> + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + <MobileMenuLink + key={node.id} + href={`/${lng}${node.menuPath}`} + title={getTitle(node)} + onClick={onClose} + /> + ); + } + + return null; + })} + </div> + </ScrollArea> + </div> + ); +} + +interface MobileMenuLinkProps { + href: string; + title: string; + onClick: () => void; +} + +function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) { + return ( + <Link + href={href} + onClick={onClick} + className={cn( + "block py-2 px-2 rounded-md text-sm", + "hover:bg-accent hover:text-accent-foreground", + "transition-colors" + )} + > + {title} + </Link> + ); +} 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<string, MenuItem[]>(); + 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<void>; +} + +/** + * 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<MenuTreeNode[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState<Error | null>(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<void>; +} + +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<FormData>({ + 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 ( + <Dialog open={open} onOpenChange={handleClose}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>{getTitle()}</DialogTitle> + <DialogDescription>{getDescription()}</DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4"> + {/* Korean Name */} + <div className="grid gap-2"> + <Label htmlFor="titleKo">Name (Korean) *</Label> + <Input + id="titleKo" + {...register("titleKo", { required: "Name is required" })} + placeholder="Master Data" + /> + {errors.titleKo && ( + <p className="text-xs text-destructive">{errors.titleKo.message}</p> + )} + </div> + + {/* English Name */} + <div className="grid gap-2"> + <Label htmlFor="titleEn">Name (English)</Label> + <Input + id="titleEn" + {...register("titleEn")} + placeholder="Master Data" + /> + </div> + + {/* Menu Path for Top-Level Menu */} + {type === "top_level_menu" && ( + <div className="grid gap-2"> + <Label htmlFor="menuPath">Menu Path *</Label> + <Input + id="menuPath" + {...register("menuPath", { + required: type === "top_level_menu" ? "Path is required" : false + })} + placeholder={`/${domain}/dashboard`} + /> + {errors.menuPath && ( + <p className="text-xs text-destructive">{errors.menuPath.message}</p> + )} + <p className="text-xs text-muted-foreground"> + e.g., /{domain}/report, /{domain}/faq + </p> + </div> + )} + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={handleClose}> + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +} 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 ( + <Tabs value={value} onValueChange={(v) => onChange(v as MenuDomain)}> + <TabsList> + <TabsTrigger value="evcp"> + EVCP (Internal) + </TabsTrigger> + <TabsTrigger value="partners"> + Partners (Vendors) + </TabsTrigger> + </TabsList> + </Tabs> + ); +} + 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<void>; +} + +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<FormData>({ + 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle>Edit {getTypeLabel()}</DialogTitle> + <DialogDescription> + {node?.menuPath && ( + <span className="text-xs text-muted-foreground">{node.menuPath}</span> + )} + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4"> + {/* Korean Name */} + <div className="grid gap-2"> + <Label htmlFor="titleKo">Name (Korean) *</Label> + <Input + id="titleKo" + {...register("titleKo", { required: true })} + placeholder="Project List" + /> + </div> + + {/* English Name */} + <div className="grid gap-2"> + <Label htmlFor="titleEn">Name (English)</Label> + <Input + id="titleEn" + {...register("titleEn")} + placeholder="Project List" + /> + </div> + + {/* Korean Description */} + {showMenuFields && ( + <div className="grid gap-2"> + <Label htmlFor="descriptionKo">Description (Korean)</Label> + <Textarea + id="descriptionKo" + {...register("descriptionKo")} + placeholder="Project list from MDG (C)" + rows={2} + /> + </div> + )} + + {/* English Description */} + {showMenuFields && ( + <div className="grid gap-2"> + <Label htmlFor="descriptionEn">Description (English)</Label> + <Textarea + id="descriptionEn" + {...register("descriptionEn")} + placeholder="Project list from MDG (C)" + rows={2} + /> + </div> + )} + + {/* Permission SCR_ID */} + {showMenuFields && ( + <div className="grid gap-2"> + <Label htmlFor="scrId">Permission SCR_ID (EVCP only)</Label> + <Input + id="scrId" + {...register("scrId")} + placeholder="SCR_001" + /> + <p className="text-xs text-muted-foreground"> + Linked with Oracle DB SCR_ID. If empty, auto-matched by URL. + </p> + </div> + )} + + {/* Active Status */} + <div className="flex items-center justify-between"> + <div className="space-y-0.5"> + <Label htmlFor="isActive">Show in Menu</Label> + <p className="text-xs text-muted-foreground"> + When disabled, hidden from the navigation menu. + </p> + </div> + <Switch + id="isActive" + checked={isActive} + onCheckedChange={(checked) => setValue("isActive", checked)} + /> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Saving..." : "Save"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/menu-v2/components/menu-tree-manager.tsx b/lib/menu-v2/components/menu-tree-manager.tsx new file mode 100644 index 00000000..337eaee4 --- /dev/null +++ b/lib/menu-v2/components/menu-tree-manager.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { useState, useEffect, useCallback, useTransition } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { RefreshCw, Plus, Loader2 } from "lucide-react"; +import { DomainTabs } from "./domain-tabs"; +import { MenuTree } from "./menu-tree"; +import { EditNodeDialog } from "./edit-node-dialog"; +import { AddNodeDialog } from "./add-node-dialog"; +import { MoveToDialog } from "./move-to-dialog"; +import { UnassignedMenusPanel } from "./unassigned-menus-panel"; +import { + getMenuTreeForAdmin, + createMenuGroup, + createGroup, + createTopLevelMenu, + updateNode, + moveNodeUp, + moveNodeDown, + moveNodeToParent, + getAvailableParents, + assignMenuToGroup, + activateAsTopLevelMenu, + syncDiscoveredMenus, +} from "../service"; +import type { + MenuDomain, + MenuTreeNode, + MenuTreeAdminResult, + UpdateNodeInput, + CreateMenuGroupInput, + CreateGroupInput, + CreateTopLevelMenuInput, +} from "../types"; + +interface MenuTreeManagerProps { + initialDomain?: MenuDomain; +} + +export function MenuTreeManager({ initialDomain = "evcp" }: MenuTreeManagerProps) { + const [domain, setDomain] = useState<MenuDomain>(initialDomain); + const [data, setData] = useState<MenuTreeAdminResult | null>(null); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isPending, startTransition] = useTransition(); + + // Dialog states + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingNode, setEditingNode] = useState<MenuTreeNode | null>(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [addDialogType, setAddDialogType] = useState<"menu_group" | "group" | "top_level_menu">("menu_group"); + const [addGroupParentId, setAddGroupParentId] = useState<number | undefined>(undefined); + + // Move dialog state + const [moveDialogOpen, setMoveDialogOpen] = useState(false); + const [movingNode, setMovingNode] = useState<MenuTreeNode | null>(null); + const [availableParents, setAvailableParents] = useState<{ id: number | null; title: string; depth: number }[]>([]); + + // Tree expansion state + const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); + + // Load data using server action + const loadData = useCallback(async (isRefresh = false) => { + if (!isRefresh) { + setIsInitialLoading(true); + } + try { + const result = await getMenuTreeForAdmin(domain); + setData(result); + } catch (error) { + console.error("Error loading menu tree:", error); + toast.error("Failed to load menu tree"); + } finally { + setIsInitialLoading(false); + } + }, [domain]); + + useEffect(() => { + setExpandedIds(new Set()); + loadData(); + }, [loadData]); + + const handleSync = async () => { + startTransition(async () => { + try { + const result = await syncDiscoveredMenus(domain); + toast.success(`Sync complete: ${result.added} menus added`); + loadData(true); + } catch (error) { + console.error("Error syncing menus:", error); + toast.error("Failed to sync menus"); + } + }); + }; + + const handleEdit = (node: MenuTreeNode) => { + setEditingNode(node); + setEditDialogOpen(true); + }; + + const handleSaveEdit = async (nodeId: number, input: UpdateNodeInput) => { + startTransition(async () => { + try { + await updateNode(nodeId, input); + toast.success("Saved successfully"); + loadData(true); + } catch (error) { + console.error("Error updating node:", error); + toast.error("Failed to save"); + } + }); + }; + + // Move up (within same parent) + const handleMoveUp = async (nodeId: number) => { + startTransition(async () => { + try { + await moveNodeUp(nodeId); + loadData(true); + } catch (error) { + console.error("Error moving node up:", error); + toast.error("Failed to move"); + } + }); + }; + + // Move down (within same parent) + const handleMoveDown = async (nodeId: number) => { + startTransition(async () => { + try { + await moveNodeDown(nodeId); + loadData(true); + } catch (error) { + console.error("Error moving node down:", error); + toast.error("Failed to move"); + } + }); + }; + + // Open move to dialog + const handleOpenMoveDialog = async (node: MenuTreeNode) => { + setMovingNode(node); + try { + const parents = await getAvailableParents(node.id, domain, node.nodeType); + setAvailableParents(parents); + setMoveDialogOpen(true); + } catch (error) { + console.error("Error loading available parents:", error); + toast.error("Failed to load move options"); + } + }; + + // Execute move to different parent + const handleMoveTo = async (newParentId: number | null) => { + if (!movingNode) return; + startTransition(async () => { + try { + await moveNodeToParent(movingNode.id, newParentId); + toast.success("Moved successfully"); + setMoveDialogOpen(false); + setMovingNode(null); + loadData(true); + } catch (error) { + console.error("Error moving node:", error); + toast.error("Failed to move"); + } + }); + }; + + const handleAddMenuGroup = () => { + setAddDialogType("menu_group"); + setAddGroupParentId(undefined); + setAddDialogOpen(true); + }; + + const handleAddGroup = (parentId: number) => { + setAddDialogType("group"); + setAddGroupParentId(parentId); + setAddDialogOpen(true); + }; + + const handleAddTopLevelMenu = () => { + setAddDialogType("top_level_menu"); + setAddGroupParentId(undefined); + setAddDialogOpen(true); + }; + + const handleSaveAdd = async ( + input: CreateMenuGroupInput | CreateGroupInput | CreateTopLevelMenuInput + ) => { + startTransition(async () => { + try { + if (addDialogType === "menu_group") { + await createMenuGroup(domain, input as CreateMenuGroupInput); + } else if (addDialogType === "group") { + await createGroup(domain, input as CreateGroupInput); + } else if (addDialogType === "top_level_menu") { + await createTopLevelMenu(domain, input as CreateTopLevelMenuInput); + } + toast.success("Created successfully"); + loadData(true); + } catch (error) { + console.error("Error creating node:", error); + toast.error("Failed to create"); + } + }); + }; + + const handleAssign = async (menuId: number, groupId: number) => { + startTransition(async () => { + try { + await assignMenuToGroup(menuId, groupId); + toast.success("Assigned successfully"); + loadData(true); + } catch (error) { + console.error("Error assigning menu:", error); + toast.error("Failed to assign"); + } + }); + }; + + const handleActivateAsTopLevel = async (menuId: number) => { + startTransition(async () => { + try { + await activateAsTopLevelMenu(menuId); + toast.success("Activated as top-level menu"); + loadData(true); + } catch (error) { + console.error("Error activating as top level:", error); + toast.error("Failed to activate"); + } + }); + }; + + // Build list of available groups for assignment + const getAvailableGroups = () => { + if (!data) return []; + + const groups: { id: number; title: string; parentTitle?: string }[] = []; + + for (const node of data.tree) { + if (node.nodeType !== 'menu_group') continue; + + groups.push({ id: node.id, title: node.titleKo }); + + if (node.children) { + for (const child of node.children) { + if (child.nodeType === "group") { + groups.push({ + id: child.id, + title: child.titleKo, + parentTitle: node.titleKo, + }); + } + } + } + } + + return groups; + }; + + if (isInitialLoading) { + return ( + <div className="flex items-center justify-center h-96"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ); + } + + return ( + <div className="space-y-6"> + {/* Header */} + <div className="flex items-center justify-between"> + <DomainTabs value={domain} onChange={setDomain} /> + <div className="flex items-center gap-2"> + {/* [jh] I've commented this button.. */} + {/* <Button variant="outline" size="sm" onClick={handleSync} disabled={isPending}> + <RefreshCw className={`mr-2 h-4 w-4 ${isPending ? "animate-spin" : ""}`} /> + Sync Pages + </Button> */} + <Button variant="outline" size="sm" onClick={handleAddTopLevelMenu} disabled={isPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Top-Level Menu + </Button> + <Button size="sm" onClick={handleAddMenuGroup} disabled={isPending}> + <Plus className="mr-2 h-4 w-4" /> + Add Menu Group + </Button> + </div> + </div> + + {/* Main Content */} + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> + {/* Menu Tree */} + <div className="lg:col-span-2"> + <Card> + <CardHeader> + <CardTitle>{domain === "evcp" ? "EVCP" : "Partners"} Menu Structure</CardTitle> + <CardDescription> + Use arrow buttons to reorder, or click Move To to change parent. + </CardDescription> + </CardHeader> + <CardContent> + {data?.tree && data.tree.length > 0 ? ( + <MenuTree + nodes={data.tree} + onEdit={handleEdit} + onMoveUp={handleMoveUp} + onMoveDown={handleMoveDown} + onMoveTo={handleOpenMoveDialog} + onAddGroup={handleAddGroup} + expandedIds={expandedIds} + onExpandedIdsChange={setExpandedIds} + isPending={isPending} + /> + ) : ( + <p className="text-sm text-muted-foreground text-center py-8"> + No menus. Add one using the buttons above. + </p> + )} + </CardContent> + </Card> + </div> + + {/* Unassigned Menus */} + <div className="lg:col-span-1"> + <UnassignedMenusPanel + menus={data?.unassigned || []} + onAssign={handleAssign} + onActivateAsTopLevel={handleActivateAsTopLevel} + onEdit={handleEdit} + availableGroups={getAvailableGroups()} + /> + </div> + </div> + + {/* Dialogs */} + <EditNodeDialog + open={editDialogOpen} + onOpenChange={setEditDialogOpen} + node={editingNode} + onSave={handleSaveEdit} + /> + + <AddNodeDialog + open={addDialogOpen} + onOpenChange={setAddDialogOpen} + type={addDialogType} + domain={domain} + parentId={addGroupParentId} + onSave={handleSaveAdd} + /> + + <MoveToDialog + open={moveDialogOpen} + onOpenChange={setMoveDialogOpen} + node={movingNode} + availableParents={availableParents} + onMove={handleMoveTo} + /> + </div> + ); +} diff --git a/lib/menu-v2/components/menu-tree.tsx b/lib/menu-v2/components/menu-tree.tsx new file mode 100644 index 00000000..7d3ab077 --- /dev/null +++ b/lib/menu-v2/components/menu-tree.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronRight, + ChevronDown, + ChevronUp, + Folder, + FolderOpen, + File, + Pencil, + Plus, + ArrowUpDown, + EyeOff, +} from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface MenuTreeProps { + nodes: MenuTreeNode[]; + onEdit: (node: MenuTreeNode) => void; + onMoveUp: (nodeId: number) => void; + onMoveDown: (nodeId: number) => void; + onMoveTo: (node: MenuTreeNode) => void; + onAddGroup: (parentId: number) => void; + expandedIds: Set<number>; + onExpandedIdsChange: (ids: Set<number>) => void; + isPending?: boolean; +} + +interface TreeItemProps { + node: MenuTreeNode; + depth: number; + isFirst: boolean; + isLast: boolean; + onEdit: (node: MenuTreeNode) => void; + onMoveUp: (nodeId: number) => void; + onMoveDown: (nodeId: number) => void; + onMoveTo: (node: MenuTreeNode) => void; + onAddGroup: (parentId: number) => void; + isExpanded: boolean; + onToggleExpand: () => void; + isPending?: boolean; +} + +function TreeItem({ + node, + depth, + isFirst, + isLast, + onEdit, + onMoveUp, + onMoveDown, + onMoveTo, + onAddGroup, + isExpanded, + onToggleExpand, + isPending, +}: TreeItemProps) { + const isMenuGroup = node.nodeType === "menu_group"; + const isGroup = node.nodeType === "group"; + const isMenu = node.nodeType === "menu"; + const isTopLevel = node.parentId === null; + const hasChildren = node.children && node.children.length > 0; + const isExpandable = isMenuGroup || isGroup; + + // Move To is disabled for: + // - menu_group (always at top level, cannot be moved) + // - top-level menu (parentId === null, can only reorder with up/down) + const canMoveTo = !isMenuGroup && !isTopLevel; + + const getIcon = () => { + if (isMenuGroup || isGroup) { + return isExpanded ? ( + <FolderOpen className="h-4 w-4 text-amber-500" /> + ) : ( + <Folder className="h-4 w-4 text-amber-500" /> + ); + } + return <File className="h-4 w-4 text-slate-500" />; + }; + + const getTypeLabel = () => { + switch (node.nodeType) { + case "menu_group": return "Menu Group"; + case "group": return "Group"; + case "menu": return "Menu"; + default: return ""; + } + }; + + return ( + <div + className={cn( + "flex items-center gap-2 px-2 py-1.5 rounded-md border bg-background hover:bg-accent/50 transition-colors", + !node.isActive && "opacity-50 bg-muted/30 border-dashed" + )} + style={{ marginLeft: depth * 24 }} + > + {/* Expand/Collapse */} + {isExpandable ? ( + <button + onClick={onToggleExpand} + className="p-0.5 hover:bg-accent rounded shrink-0" + > + {isExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + ) : ( + <div className="w-5 shrink-0" /> + )} + + {/* Icon */} + {getIcon()} + + {/* Title */} + <span className={cn( + "flex-1 text-sm font-medium truncate min-w-0", + !node.isActive && "line-through text-muted-foreground" + )}> + {node.titleKo} + {node.titleEn && ( + <span className="text-muted-foreground font-normal"> [{node.titleEn}]</span> + )} + </span> + + {/* Hidden indicator */} + {!node.isActive && ( + <EyeOff className="h-3.5 w-3.5 text-muted-foreground shrink-0" title="Hidden" /> + )} + + {/* Path (for menus) */} + {isMenu && node.menuPath && ( + <span className="text-xs text-muted-foreground truncate max-w-[150px] shrink-0"> + {node.menuPath} + </span> + )} + + {/* Type Badge */} + <Badge variant={node.isActive ? "default" : "secondary"} className="text-xs shrink-0"> + {getTypeLabel()} + </Badge> + + {/* Active indicator */} + <div + className={cn( + "w-2 h-2 rounded-full shrink-0", + node.isActive ? "bg-green-500" : "bg-gray-400" + )} + title={node.isActive ? "Visible" : "Hidden"} + /> + + {/* Actions */} + <div className="flex items-center gap-1 shrink-0"> + {/* Move Up */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveUp(node.id)} + disabled={isFirst || isPending} + title="Move Up" + > + <ChevronUp className="h-4 w-4" /> + </Button> + + {/* Move Down */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveDown(node.id)} + disabled={isLast || isPending} + title="Move Down" + > + <ChevronDown className="h-4 w-4" /> + </Button> + + {/* Move To (different parent) - disabled for top level nodes */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onMoveTo(node)} + disabled={!canMoveTo || isPending} + title={canMoveTo ? "Move To..." : "Cannot move top-level items"} + > + <ArrowUpDown className="h-4 w-4" /> + </Button> + + {/* Edit */} + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onEdit(node)} + disabled={isPending} + title="Edit" + > + <Pencil className="h-4 w-4" /> + </Button> + + {/* Add Sub-Group (for menu groups only) */} + {isMenuGroup && ( + <Button + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => onAddGroup(node.id)} + disabled={isPending} + title="Add Sub-Group" + > + <Plus className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ); +} + +export function MenuTree({ + nodes, + onEdit, + onMoveUp, + onMoveDown, + onMoveTo, + onAddGroup, + expandedIds, + onExpandedIdsChange, + isPending, +}: MenuTreeProps) { + const toggleExpand = useCallback((nodeId: number) => { + const next = new Set(expandedIds); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + onExpandedIdsChange(next); + }, [expandedIds, onExpandedIdsChange]); + + const renderTree = (nodeList: MenuTreeNode[], depth: number) => { + return nodeList.map((node, index) => { + const isExpanded = expandedIds.has(node.id); + const isExpandable = node.nodeType === "menu_group" || node.nodeType === "group"; + const hasChildren = node.children && node.children.length > 0; + + return ( + <div key={node.id} className="space-y-1"> + <TreeItem + node={node} + depth={depth} + isFirst={index === 0} + isLast={index === nodeList.length - 1} + onEdit={onEdit} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} + onMoveTo={onMoveTo} + onAddGroup={onAddGroup} + isExpanded={isExpanded} + onToggleExpand={() => toggleExpand(node.id)} + isPending={isPending} + /> + {isExpandable && isExpanded && hasChildren && ( + <div className="space-y-1"> + {renderTree(node.children!, depth + 1)} + </div> + )} + </div> + ); + }); + }; + + return <div className="space-y-1">{renderTree(nodes, 0)}</div>; +} + + diff --git a/lib/menu-v2/components/move-to-dialog.tsx b/lib/menu-v2/components/move-to-dialog.tsx new file mode 100644 index 00000000..7253708b --- /dev/null +++ b/lib/menu-v2/components/move-to-dialog.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Folder, FolderOpen, Home } from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface MoveToDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: MenuTreeNode | null; + availableParents: { id: number | null; title: string; depth: number }[]; + onMove: (newParentId: number | null) => void; +} + +export function MoveToDialog({ + open, + onOpenChange, + node, + availableParents, + onMove, +}: MoveToDialogProps) { + if (!node) return null; + + const isCurrent = (parentId: number | null) => node.parentId === parentId; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Move To</DialogTitle> + <DialogDescription> + Select a new location for "{node.titleKo}" + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[400px]"> + <div className="space-y-0.5 p-1"> + {availableParents.map((parent) => ( + <Button + key={parent.id ?? 'root'} + variant={isCurrent(parent.id) ? "secondary" : "ghost"} + className={cn( + "w-full justify-start h-auto py-2 text-sm", + parent.depth === 0 && "font-medium", + parent.depth === 1 && "font-medium", + parent.depth === 2 && "text-muted-foreground" + )} + style={{ paddingLeft: parent.depth * 20 + 8 }} + onClick={() => onMove(parent.id)} + disabled={isCurrent(parent.id)} + > + {parent.id === null ? ( + <Home className="mr-2 h-4 w-4 text-blue-500 shrink-0" /> + ) : parent.depth === 1 ? ( + <FolderOpen className="mr-2 h-4 w-4 text-amber-500 shrink-0" /> + ) : ( + <Folder className="mr-2 h-4 w-4 text-amber-400 shrink-0" /> + )} + <span className="truncate">{parent.title}</span> + {isCurrent(parent.id) && ( + <span className="ml-auto text-xs text-muted-foreground shrink-0">(current)</span> + )} + </Button> + ))} + </div> + </ScrollArea> + + <div className="flex justify-end"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + </div> + </DialogContent> + </Dialog> + ); +} + + diff --git a/lib/menu-v2/components/unassigned-menus-panel.tsx b/lib/menu-v2/components/unassigned-menus-panel.tsx new file mode 100644 index 00000000..2c914f2a --- /dev/null +++ b/lib/menu-v2/components/unassigned-menus-panel.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Search, FileQuestion, ArrowRight, Pencil, Link } from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface UnassignedMenusPanelProps { + menus: MenuTreeNode[]; + onAssign: (menuId: number, groupId: number) => void; + onActivateAsTopLevel: (menuId: number) => void; + onEdit: (menu: MenuTreeNode) => void; + availableGroups: { id: number; title: string; parentTitle?: string }[]; +} + +export function UnassignedMenusPanel({ + menus, + onAssign, + onActivateAsTopLevel, + onEdit, + availableGroups, +}: UnassignedMenusPanelProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedMenu, setSelectedMenu] = useState<number | null>(null); + + const filteredMenus = menus.filter( + (menu) => + menu.titleKo.toLowerCase().includes(searchTerm.toLowerCase()) || + menu.menuPath?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + <Card className="h-full"> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <FileQuestion className="h-4 w-4" /> + Unassigned Menus ({menus.length}) + </CardTitle> + <CardDescription> + Assign to a group or activate as a top-level link. + </CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + {/* Search */} + <div className="relative"> + <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="Search..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-8" + /> + </div> + + {/* Menu List */} + <ScrollArea className="h-[400px]"> + <div className="space-y-2"> + {filteredMenus.length === 0 ? ( + <p className="text-sm text-muted-foreground text-center py-4"> + {searchTerm ? "No results found." : "No unassigned menus."} + </p> + ) : ( + filteredMenus.map((menu) => ( + <div + key={menu.id} + className={cn( + "p-3 rounded-md border bg-background hover:bg-accent/50 transition-colors", + selectedMenu === menu.id && "ring-2 ring-primary" + )} + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-medium text-sm">{menu.titleKo}</span> + <Badge variant="secondary" className="text-xs"> + Inactive + </Badge> + </div> + <p className="text-xs text-muted-foreground truncate mt-1"> + {menu.menuPath} + </p> + </div> + <Button + variant="ghost" + size="icon" + className="h-7 w-7 shrink-0" + onClick={() => onEdit(menu)} + > + <Pencil className="h-3.5 w-3.5" /> + </Button> + </div> + + {/* Group Selection (expanded) */} + {selectedMenu === menu.id ? ( + <div className="mt-3 pt-3 border-t space-y-2"> + {/* Activate as Top-Level */} + <div> + <p className="text-xs text-muted-foreground mb-2"> + Activate as top-level link: + </p> + <Button + variant="default" + size="sm" + className="text-xs h-7" + onClick={() => { + onActivateAsTopLevel(menu.id); + setSelectedMenu(null); + }} + > + <Link className="mr-1 h-3 w-3" /> + Activate as Top-Level + </Button> + </div> + + {/* Assign to Group */} + {availableGroups.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2"> + Or assign to group: + </p> + <div className="flex flex-wrap gap-1"> + {availableGroups.map((group) => ( + <Button + key={group.id} + variant="outline" + size="sm" + className="text-xs h-7" + onClick={() => { + onAssign(menu.id, group.id); + setSelectedMenu(null); + }} + > + {group.parentTitle && ( + <span className="text-muted-foreground mr-1"> + {group.parentTitle} > + </span> + )} + {group.title} + <ArrowRight className="ml-1 h-3 w-3" /> + </Button> + ))} + </div> + </div> + )} + + <Button + variant="ghost" + size="sm" + className="text-xs" + onClick={() => setSelectedMenu(null)} + > + Cancel + </Button> + </div> + ) : ( + <Button + variant="ghost" + size="sm" + className="mt-2 text-xs w-full" + onClick={() => setSelectedMenu(menu.id)} + > + Assign / Activate + </Button> + )} + </div> + )) + )} + </div> + </ScrollArea> + </CardContent> + </Card> + ); +} diff --git a/lib/menu-v2/permission-service.ts b/lib/menu-v2/permission-service.ts new file mode 100644 index 00000000..e495ba23 --- /dev/null +++ b/lib/menu-v2/permission-service.ts @@ -0,0 +1,186 @@ +'use server'; + +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAllScreens, getAuthsByScreenId, getUserRoles, type ScreenEvcp, type RoleRelEvcp } from "@/lib/nonsap/db"; +import { getActiveMenuTree } from "./service"; +import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "./types"; +import db from "@/db/db"; +import { users } from "@/db/schema/users"; +import { eq } from "drizzle-orm"; + +/** + * Oracle 권한 체크 스킵 여부 확인 + * SKIP_ORACLE_PERMISSION_CHECK=true인 경우 Oracle DB 권한 체크를 건너뜀 + */ +function shouldSkipOraclePermissionCheck(): boolean { + return process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true'; +} + +/** + * 사용자 ID로 employeeNumber 조회 + */ +async function getEmployeeNumberByUserId(userId: number): Promise<string | null> { + const [user] = await db.select({ employeeNumber: users.employeeNumber }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.employeeNumber || null; +} + +/** + * Get menu tree filtered by user permissions + * + * @param domain - Domain (evcp | partners) + * @param userId - Optional user ID. If not provided, gets from session. + * + * Environment variable SKIP_ORACLE_PERMISSION_CHECK=true skips Oracle permission check + */ +export async function getVisibleMenuTree( + domain: MenuDomain, + userId?: number +): Promise<MenuTreeActiveResult> { + const { tree: menuTree } = await getActiveMenuTree(domain); + + // Partners domain uses its own permission system (not implemented) + if (domain === 'partners') { + return { tree: menuTree }; + } + + // Skip Oracle permission check in development + if (shouldSkipOraclePermissionCheck()) { + return { tree: menuTree }; + } + + // Get userId from session if not provided + let effectiveUserId = userId; + if (!effectiveUserId) { + const session = await getServerSession(authOptions); + effectiveUserId = session?.user?.id ? parseInt(session.user.id, 10) : undefined; + } + + if (!effectiveUserId) { + return { tree: menuTree }; + } + + // Get employeeNumber from userId + const empNo = await getEmployeeNumberByUserId(effectiveUserId); + if (!empNo) { + return { tree: menuTree }; + } + + let screens: ScreenEvcp[]; + let userRoles: RoleRelEvcp[]; + + try { + [screens, userRoles] = await Promise.all([ + getAllScreens(), + getUserRoles(empNo) + ]); + } catch (error) { + // Oracle DB 연결 실패 시 전체 메뉴 반환 (에러로 인한 접근 차단 방지) + console.error('[menu-v2] Oracle permission check failed, returning all menus:', error); + return { tree: menuTree }; + } + + const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID)); + const screenMap = new Map<string, ScreenEvcp>(screens.map(s => [s.SCR_URL, s])); + + // 메뉴 필터링 (최상위 menu, menu_group, group 모두 처리) + async function filterByPermission(nodes: MenuTreeNode[]): Promise<MenuTreeNode[]> { + const result: MenuTreeNode[] = []; + + for (const node of nodes) { + // 메뉴 노드 (최상위 단일 링크 또는 하위 메뉴) + if (node.nodeType === 'menu' && node.menuPath) { + const screen = screenMap.get(node.menuPath); + + // 화면 정보가 없거나 SCRT_CHK_YN === 'N' 이면 표시 + if (!screen || screen.SCRT_CHK_YN === 'N') { + result.push(node); + continue; + } + + // SCRT_CHK_YN === 'Y' 이면 권한 체크 + if (screen.SCRT_CHK_YN === 'Y') { + const scrIdToCheck = node.scrId || screen.SCR_ID; + const auths = await getAuthsByScreenId(scrIdToCheck); + + const hasAccess = auths.some(auth => { + if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true; + if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true; + return false; + }); + + if (hasAccess) result.push(node); + } + } + // 메뉴그룹 또는 그룹 (자식 필터링 후 자식이 있으면 포함) + else if (node.nodeType === 'menu_group' || node.nodeType === 'group') { + const filteredChildren = await filterByPermission(node.children || []); + if (filteredChildren.length > 0) { + result.push({ ...node, children: filteredChildren }); + } + } + } + + return result; + } + + const filteredTree = await filterByPermission(menuTree); + + return { tree: filteredTree }; +} + +/** + * 특정 메뉴 경로에 대한 접근 권한 확인 + * + * 환경변수 SKIP_ORACLE_PERMISSION_CHECK=true인 경우 항상 true 반환 + */ +export async function checkMenuAccess( + menuPath: string, + userId: number +): Promise<boolean> { + // Oracle 권한 체크 스킵 설정된 경우 + if (shouldSkipOraclePermissionCheck()) { + return true; + } + + const empNo = await getEmployeeNumberByUserId(userId); + if (!empNo) return false; + + try { + const screens = await getAllScreens(); + const screen = screens.find(s => s.SCR_URL === menuPath); + + // 등록되지 않은 화면 또는 권한 체크가 필요 없는 화면 + if (!screen || screen.SCRT_CHK_YN === 'N') { + return true; + } + + // 삭제된 화면 + if (screen.DEL_YN === 'Y') { + return false; + } + + // 권한 체크 + const [auths, userRoles] = await Promise.all([ + getAuthsByScreenId(screen.SCR_ID), + getUserRoles(empNo) + ]); + + const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID)); + + return auths.some(auth => { + if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true; + if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true; + return false; + }); + } catch (error) { + // Oracle DB 연결 실패 시 접근 허용 (에러로 인한 차단 방지) + console.error('[menu-v2] Oracle permission check failed for path:', menuPath, error); + return true; + } +} + diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts new file mode 100644 index 00000000..39ca144a --- /dev/null +++ b/lib/menu-v2/service.ts @@ -0,0 +1,605 @@ +'use server'; + +import fs from 'fs'; +import path from 'path'; +import db from "@/db/db"; +import { menuTreeNodes } from "@/db/schema/menu-v2"; +import { eq, and, asc, inArray, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import type { + MenuDomain, + MenuTreeNode, + MenuTreeAdminResult, + MenuTreeActiveResult, + CreateMenuGroupInput, + CreateGroupInput, + UpdateNodeInput, + ReorderNodeInput, + DiscoveredMenu +} from "./types"; +import { DOMAIN_APP_PATHS } from "./types"; + +// 도메인별 전체 트리 조회 (관리 화면용) +export async function getMenuTreeForAdmin(domain: MenuDomain): Promise<MenuTreeAdminResult> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.domain, domain)) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // 트리에 포함될 노드들: + // - menu_group (최상위 드롭다운) + // - group (드롭다운 내 그룹) + // - 배정된 menu (parentId !== null) + // - 최상위 menu (parentId === null, isActive === true) - 단일 링크 + const treeNodes = nodes.filter(n => + n.nodeType === 'menu_group' || + n.nodeType === 'group' || + (n.nodeType === 'menu' && n.parentId !== null) || + (n.nodeType === 'menu' && n.parentId === null && n.isActive) + ) as MenuTreeNode[]; + + const tree = buildTree(treeNodes); + + // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu) + const unassigned = nodes.filter(n => + n.nodeType === 'menu' && n.parentId === null && !n.isActive + ) as MenuTreeNode[]; + + return { tree, unassigned }; +} + +// 도메인별 활성 트리 조회 (헤더용) +export async function getActiveMenuTree(domain: MenuDomain): Promise<MenuTreeActiveResult> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + eq(menuTreeNodes.isActive, true) + )) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // 트리에 포함될 노드들: + // - menu_group (최상위 드롭다운) + // - group (드롭다운 내 그룹) + // - 배정된 menu (parentId !== null) + // - 최상위 menu (parentId === null) - 단일 링크 + const treeNodes = nodes.filter(n => + n.nodeType === 'menu_group' || + n.nodeType === 'group' || + n.nodeType === 'menu' + ) as MenuTreeNode[]; + + const tree = buildTree(treeNodes); + + return { tree }; +} + +// 메뉴그룹 생성 (드롭다운) +export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu_group', + titleKo: data.titleKo, + titleEn: data.titleEn, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 그룹 생성 (메뉴그룹 하위) +export async function createGroup(domain: MenuDomain, data: CreateGroupInput) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: data.parentId, + nodeType: 'group', + titleKo: data.titleKo, + titleEn: data.titleEn, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할) +export async function createTopLevelMenu(domain: MenuDomain, data: { + titleKo: string; + titleEn?: string; + menuPath: string; + sortOrder?: number; +}) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', + titleKo: data.titleKo, + titleEn: data.titleEn, + menuPath: data.menuPath, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 노드 이동 (드래그앤드롭) +export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) { + await db.update(menuTreeNodes) + .set({ + parentId: newParentId, + sortOrder: newSortOrder, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 노드 수정 +export async function updateNode(nodeId: number, data: UpdateNodeInput) { + await db.update(menuTreeNodes) + .set({ ...data, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 노드 삭제 +export async function deleteNode(nodeId: number) { + const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1); + + if (!node) return; + + if (node.nodeType === 'menu') { + // 최상위 메뉴(parentId === null)는 직접 삭제 가능 + // 하위 메뉴(parentId !== null)는 미배정으로 + if (node.parentId === null) { + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); + } else { + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + } + } else { + // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로 + const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType }) + .from(menuTreeNodes) + .where(eq(menuTreeNodes.parentId, nodeId)); + + for (const child of children) { + if (child.nodeType === 'menu') { + // 메뉴는 미배정으로 + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, child.id)); + } else if (child.nodeType === 'group') { + // 그룹의 하위 메뉴도 미배정으로 + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.parentId, child.id)); + + // 그룹 삭제 + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id)); + } + } + + // 본 노드 삭제 + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); + } + + revalidatePath('/evcp/menu-v2'); +} + +// 순서 일괄 변경 +export async function reorderNodes(updates: ReorderNodeInput[]) { + for (const { id, sortOrder } of updates) { + await db.update(menuTreeNodes) + .set({ sortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, id)); + } + revalidatePath('/evcp/menu-v2'); +} + +// 미배정 메뉴를 특정 그룹에 배정 +export async function assignMenuToGroup(menuId: number, groupId: number) { + await db.update(menuTreeNodes) + .set({ + parentId: groupId, + isActive: true, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, menuId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 미배정 메뉴를 최상위 메뉴로 활성화 +export async function activateAsTopLevelMenu(menuId: number) { + await db.update(menuTreeNodes) + .set({ + parentId: null, + isActive: true, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, menuId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 단일 노드 조회 +export async function getNodeById(nodeId: number): Promise<MenuTreeNode | null> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + return node as MenuTreeNode | null; +} + +// Helper: Convert flat list to tree +function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] { + const nodeMap = new Map<number, MenuTreeNode>(); + const roots: MenuTreeNode[] = []; + + nodes.forEach(node => { + nodeMap.set(node.id, { ...node, children: [] }); + }); + + nodes.forEach(node => { + const current = nodeMap.get(node.id)!; + if (node.parentId === null) { + roots.push(current); + } else { + const parent = nodeMap.get(node.parentId); + if (parent) { + if (!parent.children) parent.children = []; + parent.children.push(current); + } + } + }); + + const sortChildren = (nodes: MenuTreeNode[]) => { + nodes.sort((a, b) => a.sortOrder - b.sortOrder); + nodes.forEach(node => { + if (node.children?.length) { + sortChildren(node.children); + } + }); + }; + sortChildren(roots); + + return roots; +} + +// ============================================ +// Menu Discovery & Sync (Server Actions) +// ============================================ + +const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/; + +/** + * Discover pages from app router for a specific domain + */ +function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] { + const { appDir, basePath } = DOMAIN_APP_PATHS[domain]; + const menus: DiscoveredMenu[] = []; + + function scanDirectory(dir: string, currentPath: string[], routeGroup: string) { + const absoluteDir = path.resolve(process.cwd(), dir); + + if (!fs.existsSync(absoluteDir)) return; + + const entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(absoluteDir, entry.name); + + if (entry.isDirectory()) { + if (entry.name.startsWith('(') && entry.name.endsWith(')')) { + scanDirectory(fullPath, currentPath, entry.name); + } + else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) { + continue; + } + else { + scanDirectory(fullPath, [...currentPath, entry.name], routeGroup); + } + } + else if (entry.name === 'page.tsx') { + const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : ''); + menus.push({ + domain, + menuPath, + pageFilePath: fullPath, + routeGroup + }); + } + } + } + + scanDirectory(appDir, [], ''); + return menus; +} + +/** + * Sync discovered menus for a specific domain + */ +export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> { + const discovered = discoverMenusFromAppRouter(domain); + + const existing = await db.select({ + id: menuTreeNodes.id, + menuPath: menuTreeNodes.menuPath + }) + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + inArray(menuTreeNodes.nodeType, ['menu', 'additional']) + )); + + const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean)); + + const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath)); + let added = 0; + + for (const menu of newMenus) { + const pathSegments = menu.menuPath.split('/').filter(Boolean); + const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown'; + + await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', + sortOrder: 0, + titleKo: lastSegment, + titleEn: lastSegment, + menuPath: menu.menuPath, + isActive: false, + }); + added++; + } + + revalidatePath('/evcp/menu-v2'); + return { added, removed: 0 }; +} + +/** + * Sync all domains + */ +export async function syncAllDomains(): Promise<Record<MenuDomain, { added: number; removed: number }>> { + const [evcp, partners] = await Promise.all([ + syncDiscoveredMenus('evcp'), + syncDiscoveredMenus('partners') + ]); + return { evcp, partners }; +} + +/** + * Get discovered menus without syncing + */ +export async function getDiscoveredMenus(): Promise<Record<MenuDomain, DiscoveredMenu[]>> { + return { + evcp: discoverMenusFromAppRouter('evcp'), + partners: discoverMenusFromAppRouter('partners') + }; +} + +// ============================================ +// Move Node Helpers +// ============================================ + +/** + * Move node up within same parent (decrease sort order) + */ +export async function moveNodeUp(nodeId: number): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get siblings (nodes with same parent) + const siblings = await db.select() + .from(menuTreeNodes) + .where(node.parentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, node.parentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // Find current index + const currentIndex = siblings.findIndex(s => s.id === nodeId); + if (currentIndex <= 0) return; // Already at top + + // Swap sort orders with previous node + const prevNode = siblings[currentIndex - 1]; + const prevSortOrder = prevNode.sortOrder; + const currentSortOrder = node.sortOrder; + + // If sort orders are the same, assign unique values + if (prevSortOrder === currentSortOrder) { + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex - 1, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, prevNode.id)); + } else { + await db.update(menuTreeNodes) + .set({ sortOrder: prevSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, prevNode.id)); + } + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Move node down within same parent (increase sort order) + */ +export async function moveNodeDown(nodeId: number): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get siblings (nodes with same parent) + const siblings = await db.select() + .from(menuTreeNodes) + .where(node.parentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, node.parentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // Find current index + const currentIndex = siblings.findIndex(s => s.id === nodeId); + if (currentIndex >= siblings.length - 1) return; // Already at bottom + + // Swap sort orders with next node + const nextNode = siblings[currentIndex + 1]; + const nextSortOrder = nextNode.sortOrder; + const currentSortOrder = node.sortOrder; + + // If sort orders are the same, assign unique values + if (nextSortOrder === currentSortOrder) { + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex + 1, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nextNode.id)); + } else { + await db.update(menuTreeNodes) + .set({ sortOrder: nextSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nextNode.id)); + } + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Move node to a different parent + */ +export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get max sort order in new parent + const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder }) + .from(menuTreeNodes) + .where(newParentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, newParentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0; + + await db.update(menuTreeNodes) + .set({ + parentId: newParentId, + sortOrder: maxSortOrder, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Get all possible parent targets for a node (for Move To dialog) + * Returns items in tree order (same as Menu Structure display) + * + * Rules: + * - menu_group: Cannot be moved (always at top level) + * - group: Can only move to menu_group (not to root or other groups) + * - menu: Can move to root, menu_group, or group + */ +export async function getAvailableParents( + nodeId: number, + domain: MenuDomain, + nodeType: string +): Promise<{ id: number | null; title: string; depth: number }[]> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + inArray(menuTreeNodes.nodeType, ['menu_group', 'group']) + )) + .orderBy(asc(menuTreeNodes.sortOrder)); + + const result: { id: number | null; title: string; depth: number }[] = []; + + // For menu nodes, allow moving to root (as top-level menu) + if (nodeType === 'menu') { + result.push({ id: null, title: 'Top Level (Root)', depth: 0 }); + } + + // Build tree structure + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group'); + + // Helper to check if node is descendant of nodeId (prevent circular reference) + const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => { + let parent = checkNode.parentId; + while (parent !== null) { + if (parent === ancestorId) return true; + const parentNode = nodeMap.get(parent); + parent = parentNode?.parentId ?? null; + } + return false; + }; + + // Traverse tree in order (menu_group -> its children groups) + for (const menuGroup of menuGroups) { + // Skip if it's the node being moved or its descendant + if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue; + + // Add menu_group + result.push({ + id: menuGroup.id, + title: menuGroup.titleKo, + depth: 1 + }); + + // For group nodes, only menu_groups are valid targets (skip children) + if (nodeType === 'group') continue; + + // Add children groups (sorted by sortOrder) + const childGroups = nodes + .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group') + .sort((a, b) => a.sortOrder - b.sortOrder); + + for (const group of childGroups) { + // Skip if it's the node being moved or its descendant + if (group.id === nodeId || isDescendantOf(group, nodeId)) continue; + + result.push({ + id: group.id, + title: group.titleKo, + depth: 2 + }); + } + } + + return result; +} diff --git a/lib/menu-v2/types.ts b/lib/menu-v2/types.ts new file mode 100644 index 00000000..1be8a4fe --- /dev/null +++ b/lib/menu-v2/types.ts @@ -0,0 +1,103 @@ +// lib/menu-v2/types.ts + +export type NodeType = 'menu_group' | 'group' | 'menu' | 'additional'; +export type MenuDomain = 'evcp' | 'partners'; + +export interface MenuTreeNode { + id: number; + domain: MenuDomain; + parentId: number | null; + nodeType: NodeType; + sortOrder: number; + titleKo: string; + titleEn: string | null; + descriptionKo: string | null; + descriptionEn: string | null; + menuPath: string | null; + icon: string | null; + scrId: string | null; + isActive: boolean; + manager1Id: number | null; + manager2Id: number | null; + createdAt: Date; + updatedAt: Date; + // 조회 시 추가되는 필드 + children?: MenuTreeNode[]; +} + +export interface DiscoveredMenu { + domain: MenuDomain; + menuPath: string; + pageFilePath: string; + routeGroup: string; +} + +// 도메인별 앱 라우터 경로 설정 +export const DOMAIN_APP_PATHS: Record<MenuDomain, { + appDir: string; + basePath: string; +}> = { + evcp: { + appDir: 'app/[lng]/evcp/(evcp)', + basePath: '/evcp' + }, + partners: { + appDir: 'app/[lng]/partners', + basePath: '/partners' + } +}; + +// 관리자용 트리 조회 결과 타입 +// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합 +export interface MenuTreeAdminResult { + tree: MenuTreeNode[]; + unassigned: MenuTreeNode[]; +} + +// 헤더용 트리 조회 결과 타입 +// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합 +export interface MenuTreeActiveResult { + tree: MenuTreeNode[]; +} + +// 노드 생성 타입 +export interface CreateMenuGroupInput { + titleKo: string; + titleEn?: string; + sortOrder?: number; +} + +export interface CreateGroupInput { + parentId: number; + titleKo: string; + titleEn?: string; + sortOrder?: number; +} + +// 최상위 메뉴 생성 (단일 링크) +export interface CreateTopLevelMenuInput { + titleKo: string; + titleEn?: string; + menuPath: string; + sortOrder?: number; +} + +// 노드 업데이트 타입 +export interface UpdateNodeInput { + titleKo?: string; + titleEn?: string; + descriptionKo?: string; + descriptionEn?: string; + isActive?: boolean; + scrId?: string; + icon?: string; + manager1Id?: number | null; + manager2Id?: number | null; +} + +// 순서 변경 타입 +export interface ReorderNodeInput { + id: number; + sortOrder: number; +} + |
