summaryrefslogtreecommitdiff
path: root/lib/menu-v2/components
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-04 19:46:55 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-04 19:46:55 +0900
commit04ed774ff60a83c00711d4e8615cb4122954dba5 (patch)
treebfc18b5c4c40de94596d307fdf76990593c0d868 /lib/menu-v2/components
parent5699e866201566366981ae8399a835fc7fa9fa47 (diff)
(김준회) 메뉴 관리기능 초안 개발 (시딩 필요)
Diffstat (limited to 'lib/menu-v2/components')
-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
7 files changed, 1337 insertions, 0 deletions
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>
+ );
+}