summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/information/service.ts32
-rw-r--r--lib/menu-v2/components/add-node-dialog.tsx186
-rw-r--r--lib/menu-v2/components/domain-tabs.tsx25
-rw-r--r--lib/menu-v2/components/edit-node-dialog.tsx215
-rw-r--r--lib/menu-v2/components/menu-tree-manager.tsx364
-rw-r--r--lib/menu-v2/components/menu-tree.tsx282
-rw-r--r--lib/menu-v2/components/move-to-dialog.tsx87
-rw-r--r--lib/menu-v2/components/unassigned-menus-panel.tsx178
-rw-r--r--lib/menu-v2/permission-service.ts186
-rw-r--r--lib/menu-v2/service.ts605
-rw-r--r--lib/menu-v2/types.ts103
11 files changed, 2248 insertions, 15 deletions
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 &quot;{node.titleKo}&quot;
+ </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} &gt;
+ </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;
+}
+