diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-04 21:05:28 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-04 21:05:28 +0900 |
| commit | e5b36fa6a1b12446883f51fc5e7cd56d8df8d8f5 (patch) | |
| tree | c8f9fb50eb593dd5322d26d9276947c155997858 /lib/menu-v2/components/menu-tree-manager.tsx | |
| parent | 240f4f31b3b6ff6a46436978fb988588a1972721 (diff) | |
| parent | 04ed774ff60a83c00711d4e8615cb4122954dba5 (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.tsx | 364 |
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> + ); +} |
