summaryrefslogtreecommitdiff
path: root/lib/menu-v2/components/menu-tree-manager.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-04 21:05:28 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-04 21:05:28 +0900
commite5b36fa6a1b12446883f51fc5e7cd56d8df8d8f5 (patch)
treec8f9fb50eb593dd5322d26d9276947c155997858 /lib/menu-v2/components/menu-tree-manager.tsx
parent240f4f31b3b6ff6a46436978fb988588a1972721 (diff)
parent04ed774ff60a83c00711d4e8615cb4122954dba5 (diff)
Merge branch 'jh-auth-menu' into dujinkim
Diffstat (limited to 'lib/menu-v2/components/menu-tree-manager.tsx')
-rw-r--r--lib/menu-v2/components/menu-tree-manager.tsx364
1 files changed, 364 insertions, 0 deletions
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>
+ );
+}