diff options
Diffstat (limited to 'lib/menu-v2')
| -rw-r--r-- | lib/menu-v2/components/add-node-dialog.tsx | 186 | ||||
| -rw-r--r-- | lib/menu-v2/components/domain-tabs.tsx | 25 | ||||
| -rw-r--r-- | lib/menu-v2/components/edit-node-dialog.tsx | 215 | ||||
| -rw-r--r-- | lib/menu-v2/components/menu-tree-manager.tsx | 364 | ||||
| -rw-r--r-- | lib/menu-v2/components/menu-tree.tsx | 282 | ||||
| -rw-r--r-- | lib/menu-v2/components/move-to-dialog.tsx | 87 | ||||
| -rw-r--r-- | lib/menu-v2/components/unassigned-menus-panel.tsx | 178 | ||||
| -rw-r--r-- | lib/menu-v2/permission-service.ts | 186 | ||||
| -rw-r--r-- | lib/menu-v2/service.ts | 605 | ||||
| -rw-r--r-- | lib/menu-v2/types.ts | 103 |
10 files changed, 2231 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 "{node.titleKo}" + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[400px]"> + <div className="space-y-0.5 p-1"> + {availableParents.map((parent) => ( + <Button + key={parent.id ?? 'root'} + variant={isCurrent(parent.id) ? "secondary" : "ghost"} + className={cn( + "w-full justify-start h-auto py-2 text-sm", + parent.depth === 0 && "font-medium", + parent.depth === 1 && "font-medium", + parent.depth === 2 && "text-muted-foreground" + )} + style={{ paddingLeft: parent.depth * 20 + 8 }} + onClick={() => onMove(parent.id)} + disabled={isCurrent(parent.id)} + > + {parent.id === null ? ( + <Home className="mr-2 h-4 w-4 text-blue-500 shrink-0" /> + ) : parent.depth === 1 ? ( + <FolderOpen className="mr-2 h-4 w-4 text-amber-500 shrink-0" /> + ) : ( + <Folder className="mr-2 h-4 w-4 text-amber-400 shrink-0" /> + )} + <span className="truncate">{parent.title}</span> + {isCurrent(parent.id) && ( + <span className="ml-auto text-xs text-muted-foreground shrink-0">(current)</span> + )} + </Button> + ))} + </div> + </ScrollArea> + + <div className="flex justify-end"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + </div> + </DialogContent> + </Dialog> + ); +} + + diff --git a/lib/menu-v2/components/unassigned-menus-panel.tsx b/lib/menu-v2/components/unassigned-menus-panel.tsx new file mode 100644 index 00000000..2c914f2a --- /dev/null +++ b/lib/menu-v2/components/unassigned-menus-panel.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Search, FileQuestion, ArrowRight, Pencil, Link } from "lucide-react"; +import type { MenuTreeNode } from "../types"; + +interface UnassignedMenusPanelProps { + menus: MenuTreeNode[]; + onAssign: (menuId: number, groupId: number) => void; + onActivateAsTopLevel: (menuId: number) => void; + onEdit: (menu: MenuTreeNode) => void; + availableGroups: { id: number; title: string; parentTitle?: string }[]; +} + +export function UnassignedMenusPanel({ + menus, + onAssign, + onActivateAsTopLevel, + onEdit, + availableGroups, +}: UnassignedMenusPanelProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedMenu, setSelectedMenu] = useState<number | null>(null); + + const filteredMenus = menus.filter( + (menu) => + menu.titleKo.toLowerCase().includes(searchTerm.toLowerCase()) || + menu.menuPath?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + <Card className="h-full"> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <FileQuestion className="h-4 w-4" /> + Unassigned Menus ({menus.length}) + </CardTitle> + <CardDescription> + Assign to a group or activate as a top-level link. + </CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + {/* Search */} + <div className="relative"> + <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="Search..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-8" + /> + </div> + + {/* Menu List */} + <ScrollArea className="h-[400px]"> + <div className="space-y-2"> + {filteredMenus.length === 0 ? ( + <p className="text-sm text-muted-foreground text-center py-4"> + {searchTerm ? "No results found." : "No unassigned menus."} + </p> + ) : ( + filteredMenus.map((menu) => ( + <div + key={menu.id} + className={cn( + "p-3 rounded-md border bg-background hover:bg-accent/50 transition-colors", + selectedMenu === menu.id && "ring-2 ring-primary" + )} + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="font-medium text-sm">{menu.titleKo}</span> + <Badge variant="secondary" className="text-xs"> + Inactive + </Badge> + </div> + <p className="text-xs text-muted-foreground truncate mt-1"> + {menu.menuPath} + </p> + </div> + <Button + variant="ghost" + size="icon" + className="h-7 w-7 shrink-0" + onClick={() => onEdit(menu)} + > + <Pencil className="h-3.5 w-3.5" /> + </Button> + </div> + + {/* Group Selection (expanded) */} + {selectedMenu === menu.id ? ( + <div className="mt-3 pt-3 border-t space-y-2"> + {/* Activate as Top-Level */} + <div> + <p className="text-xs text-muted-foreground mb-2"> + Activate as top-level link: + </p> + <Button + variant="default" + size="sm" + className="text-xs h-7" + onClick={() => { + onActivateAsTopLevel(menu.id); + setSelectedMenu(null); + }} + > + <Link className="mr-1 h-3 w-3" /> + Activate as Top-Level + </Button> + </div> + + {/* Assign to Group */} + {availableGroups.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2"> + Or assign to group: + </p> + <div className="flex flex-wrap gap-1"> + {availableGroups.map((group) => ( + <Button + key={group.id} + variant="outline" + size="sm" + className="text-xs h-7" + onClick={() => { + onAssign(menu.id, group.id); + setSelectedMenu(null); + }} + > + {group.parentTitle && ( + <span className="text-muted-foreground mr-1"> + {group.parentTitle} > + </span> + )} + {group.title} + <ArrowRight className="ml-1 h-3 w-3" /> + </Button> + ))} + </div> + </div> + )} + + <Button + variant="ghost" + size="sm" + className="text-xs" + onClick={() => setSelectedMenu(null)} + > + Cancel + </Button> + </div> + ) : ( + <Button + variant="ghost" + size="sm" + className="mt-2 text-xs w-full" + onClick={() => setSelectedMenu(menu.id)} + > + Assign / Activate + </Button> + )} + </div> + )) + )} + </div> + </ScrollArea> + </CardContent> + </Card> + ); +} diff --git a/lib/menu-v2/permission-service.ts b/lib/menu-v2/permission-service.ts new file mode 100644 index 00000000..e495ba23 --- /dev/null +++ b/lib/menu-v2/permission-service.ts @@ -0,0 +1,186 @@ +'use server'; + +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAllScreens, getAuthsByScreenId, getUserRoles, type ScreenEvcp, type RoleRelEvcp } from "@/lib/nonsap/db"; +import { getActiveMenuTree } from "./service"; +import type { MenuDomain, MenuTreeNode, MenuTreeActiveResult } from "./types"; +import db from "@/db/db"; +import { users } from "@/db/schema/users"; +import { eq } from "drizzle-orm"; + +/** + * Oracle 권한 체크 스킵 여부 확인 + * SKIP_ORACLE_PERMISSION_CHECK=true인 경우 Oracle DB 권한 체크를 건너뜀 + */ +function shouldSkipOraclePermissionCheck(): boolean { + return process.env.SKIP_ORACLE_PERMISSION_CHECK === 'true'; +} + +/** + * 사용자 ID로 employeeNumber 조회 + */ +async function getEmployeeNumberByUserId(userId: number): Promise<string | null> { + const [user] = await db.select({ employeeNumber: users.employeeNumber }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.employeeNumber || null; +} + +/** + * Get menu tree filtered by user permissions + * + * @param domain - Domain (evcp | partners) + * @param userId - Optional user ID. If not provided, gets from session. + * + * Environment variable SKIP_ORACLE_PERMISSION_CHECK=true skips Oracle permission check + */ +export async function getVisibleMenuTree( + domain: MenuDomain, + userId?: number +): Promise<MenuTreeActiveResult> { + const { tree: menuTree } = await getActiveMenuTree(domain); + + // Partners domain uses its own permission system (not implemented) + if (domain === 'partners') { + return { tree: menuTree }; + } + + // Skip Oracle permission check in development + if (shouldSkipOraclePermissionCheck()) { + return { tree: menuTree }; + } + + // Get userId from session if not provided + let effectiveUserId = userId; + if (!effectiveUserId) { + const session = await getServerSession(authOptions); + effectiveUserId = session?.user?.id ? parseInt(session.user.id, 10) : undefined; + } + + if (!effectiveUserId) { + return { tree: menuTree }; + } + + // Get employeeNumber from userId + const empNo = await getEmployeeNumberByUserId(effectiveUserId); + if (!empNo) { + return { tree: menuTree }; + } + + let screens: ScreenEvcp[]; + let userRoles: RoleRelEvcp[]; + + try { + [screens, userRoles] = await Promise.all([ + getAllScreens(), + getUserRoles(empNo) + ]); + } catch (error) { + // Oracle DB 연결 실패 시 전체 메뉴 반환 (에러로 인한 접근 차단 방지) + console.error('[menu-v2] Oracle permission check failed, returning all menus:', error); + return { tree: menuTree }; + } + + const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID)); + const screenMap = new Map<string, ScreenEvcp>(screens.map(s => [s.SCR_URL, s])); + + // 메뉴 필터링 (최상위 menu, menu_group, group 모두 처리) + async function filterByPermission(nodes: MenuTreeNode[]): Promise<MenuTreeNode[]> { + const result: MenuTreeNode[] = []; + + for (const node of nodes) { + // 메뉴 노드 (최상위 단일 링크 또는 하위 메뉴) + if (node.nodeType === 'menu' && node.menuPath) { + const screen = screenMap.get(node.menuPath); + + // 화면 정보가 없거나 SCRT_CHK_YN === 'N' 이면 표시 + if (!screen || screen.SCRT_CHK_YN === 'N') { + result.push(node); + continue; + } + + // SCRT_CHK_YN === 'Y' 이면 권한 체크 + if (screen.SCRT_CHK_YN === 'Y') { + const scrIdToCheck = node.scrId || screen.SCR_ID; + const auths = await getAuthsByScreenId(scrIdToCheck); + + const hasAccess = auths.some(auth => { + if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true; + if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true; + return false; + }); + + if (hasAccess) result.push(node); + } + } + // 메뉴그룹 또는 그룹 (자식 필터링 후 자식이 있으면 포함) + else if (node.nodeType === 'menu_group' || node.nodeType === 'group') { + const filteredChildren = await filterByPermission(node.children || []); + if (filteredChildren.length > 0) { + result.push({ ...node, children: filteredChildren }); + } + } + } + + return result; + } + + const filteredTree = await filterByPermission(menuTree); + + return { tree: filteredTree }; +} + +/** + * 특정 메뉴 경로에 대한 접근 권한 확인 + * + * 환경변수 SKIP_ORACLE_PERMISSION_CHECK=true인 경우 항상 true 반환 + */ +export async function checkMenuAccess( + menuPath: string, + userId: number +): Promise<boolean> { + // Oracle 권한 체크 스킵 설정된 경우 + if (shouldSkipOraclePermissionCheck()) { + return true; + } + + const empNo = await getEmployeeNumberByUserId(userId); + if (!empNo) return false; + + try { + const screens = await getAllScreens(); + const screen = screens.find(s => s.SCR_URL === menuPath); + + // 등록되지 않은 화면 또는 권한 체크가 필요 없는 화면 + if (!screen || screen.SCRT_CHK_YN === 'N') { + return true; + } + + // 삭제된 화면 + if (screen.DEL_YN === 'Y') { + return false; + } + + // 권한 체크 + const [auths, userRoles] = await Promise.all([ + getAuthsByScreenId(screen.SCR_ID), + getUserRoles(empNo) + ]); + + const userRoleIds = new Set(userRoles.map(r => r.ROLE_ID)); + + return auths.some(auth => { + if (auth.ACSR_GB_CD === 'U' && auth.ACSR_ID === empNo) return true; + if (auth.ACSR_GB_CD === 'R' && userRoleIds.has(auth.ACSR_ID)) return true; + return false; + }); + } catch (error) { + // Oracle DB 연결 실패 시 접근 허용 (에러로 인한 차단 방지) + console.error('[menu-v2] Oracle permission check failed for path:', menuPath, error); + return true; + } +} + diff --git a/lib/menu-v2/service.ts b/lib/menu-v2/service.ts new file mode 100644 index 00000000..39ca144a --- /dev/null +++ b/lib/menu-v2/service.ts @@ -0,0 +1,605 @@ +'use server'; + +import fs from 'fs'; +import path from 'path'; +import db from "@/db/db"; +import { menuTreeNodes } from "@/db/schema/menu-v2"; +import { eq, and, asc, inArray, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import type { + MenuDomain, + MenuTreeNode, + MenuTreeAdminResult, + MenuTreeActiveResult, + CreateMenuGroupInput, + CreateGroupInput, + UpdateNodeInput, + ReorderNodeInput, + DiscoveredMenu +} from "./types"; +import { DOMAIN_APP_PATHS } from "./types"; + +// 도메인별 전체 트리 조회 (관리 화면용) +export async function getMenuTreeForAdmin(domain: MenuDomain): Promise<MenuTreeAdminResult> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.domain, domain)) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // 트리에 포함될 노드들: + // - menu_group (최상위 드롭다운) + // - group (드롭다운 내 그룹) + // - 배정된 menu (parentId !== null) + // - 최상위 menu (parentId === null, isActive === true) - 단일 링크 + const treeNodes = nodes.filter(n => + n.nodeType === 'menu_group' || + n.nodeType === 'group' || + (n.nodeType === 'menu' && n.parentId !== null) || + (n.nodeType === 'menu' && n.parentId === null && n.isActive) + ) as MenuTreeNode[]; + + const tree = buildTree(treeNodes); + + // 미배정 메뉴 (parentId가 null이고 isActive가 false인 menu) + const unassigned = nodes.filter(n => + n.nodeType === 'menu' && n.parentId === null && !n.isActive + ) as MenuTreeNode[]; + + return { tree, unassigned }; +} + +// 도메인별 활성 트리 조회 (헤더용) +export async function getActiveMenuTree(domain: MenuDomain): Promise<MenuTreeActiveResult> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + eq(menuTreeNodes.isActive, true) + )) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // 트리에 포함될 노드들: + // - menu_group (최상위 드롭다운) + // - group (드롭다운 내 그룹) + // - 배정된 menu (parentId !== null) + // - 최상위 menu (parentId === null) - 단일 링크 + const treeNodes = nodes.filter(n => + n.nodeType === 'menu_group' || + n.nodeType === 'group' || + n.nodeType === 'menu' + ) as MenuTreeNode[]; + + const tree = buildTree(treeNodes); + + return { tree }; +} + +// 메뉴그룹 생성 (드롭다운) +export async function createMenuGroup(domain: MenuDomain, data: CreateMenuGroupInput) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu_group', + titleKo: data.titleKo, + titleEn: data.titleEn, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 그룹 생성 (메뉴그룹 하위) +export async function createGroup(domain: MenuDomain, data: CreateGroupInput) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: data.parentId, + nodeType: 'group', + titleKo: data.titleKo, + titleEn: data.titleEn, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 최상위 메뉴 생성 (단일 링크 - 기존 additional 역할) +export async function createTopLevelMenu(domain: MenuDomain, data: { + titleKo: string; + titleEn?: string; + menuPath: string; + sortOrder?: number; +}) { + const [result] = await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', + titleKo: data.titleKo, + titleEn: data.titleEn, + menuPath: data.menuPath, + sortOrder: data.sortOrder ?? 0, + isActive: true, + }).returning(); + + revalidatePath('/evcp/menu-v2'); + return result; +} + +// 노드 이동 (드래그앤드롭) +export async function moveNode(nodeId: number, newParentId: number | null, newSortOrder: number) { + await db.update(menuTreeNodes) + .set({ + parentId: newParentId, + sortOrder: newSortOrder, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 노드 수정 +export async function updateNode(nodeId: number, data: UpdateNodeInput) { + await db.update(menuTreeNodes) + .set({ ...data, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 노드 삭제 +export async function deleteNode(nodeId: number) { + const [node] = await db.select().from(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)).limit(1); + + if (!node) return; + + if (node.nodeType === 'menu') { + // 최상위 메뉴(parentId === null)는 직접 삭제 가능 + // 하위 메뉴(parentId !== null)는 미배정으로 + if (node.parentId === null) { + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); + } else { + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + } + } else { + // 메뉴그룹/그룹 삭제 시, 하위 메뉴는 미배정으로 + const children = await db.select({ id: menuTreeNodes.id, nodeType: menuTreeNodes.nodeType }) + .from(menuTreeNodes) + .where(eq(menuTreeNodes.parentId, nodeId)); + + for (const child of children) { + if (child.nodeType === 'menu') { + // 메뉴는 미배정으로 + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, child.id)); + } else if (child.nodeType === 'group') { + // 그룹의 하위 메뉴도 미배정으로 + await db.update(menuTreeNodes) + .set({ parentId: null, isActive: false, updatedAt: new Date() }) + .where(eq(menuTreeNodes.parentId, child.id)); + + // 그룹 삭제 + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, child.id)); + } + } + + // 본 노드 삭제 + await db.delete(menuTreeNodes).where(eq(menuTreeNodes.id, nodeId)); + } + + revalidatePath('/evcp/menu-v2'); +} + +// 순서 일괄 변경 +export async function reorderNodes(updates: ReorderNodeInput[]) { + for (const { id, sortOrder } of updates) { + await db.update(menuTreeNodes) + .set({ sortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, id)); + } + revalidatePath('/evcp/menu-v2'); +} + +// 미배정 메뉴를 특정 그룹에 배정 +export async function assignMenuToGroup(menuId: number, groupId: number) { + await db.update(menuTreeNodes) + .set({ + parentId: groupId, + isActive: true, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, menuId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 미배정 메뉴를 최상위 메뉴로 활성화 +export async function activateAsTopLevelMenu(menuId: number) { + await db.update(menuTreeNodes) + .set({ + parentId: null, + isActive: true, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, menuId)); + + revalidatePath('/evcp/menu-v2'); +} + +// 단일 노드 조회 +export async function getNodeById(nodeId: number): Promise<MenuTreeNode | null> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + return node as MenuTreeNode | null; +} + +// Helper: Convert flat list to tree +function buildTree(nodes: MenuTreeNode[]): MenuTreeNode[] { + const nodeMap = new Map<number, MenuTreeNode>(); + const roots: MenuTreeNode[] = []; + + nodes.forEach(node => { + nodeMap.set(node.id, { ...node, children: [] }); + }); + + nodes.forEach(node => { + const current = nodeMap.get(node.id)!; + if (node.parentId === null) { + roots.push(current); + } else { + const parent = nodeMap.get(node.parentId); + if (parent) { + if (!parent.children) parent.children = []; + parent.children.push(current); + } + } + }); + + const sortChildren = (nodes: MenuTreeNode[]) => { + nodes.sort((a, b) => a.sortOrder - b.sortOrder); + nodes.forEach(node => { + if (node.children?.length) { + sortChildren(node.children); + } + }); + }; + sortChildren(roots); + + return roots; +} + +// ============================================ +// Menu Discovery & Sync (Server Actions) +// ============================================ + +const DYNAMIC_SEGMENT_PATTERN = /^\[.+\]$/; + +/** + * Discover pages from app router for a specific domain + */ +function discoverMenusFromAppRouter(domain: MenuDomain): DiscoveredMenu[] { + const { appDir, basePath } = DOMAIN_APP_PATHS[domain]; + const menus: DiscoveredMenu[] = []; + + function scanDirectory(dir: string, currentPath: string[], routeGroup: string) { + const absoluteDir = path.resolve(process.cwd(), dir); + + if (!fs.existsSync(absoluteDir)) return; + + const entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(absoluteDir, entry.name); + + if (entry.isDirectory()) { + if (entry.name.startsWith('(') && entry.name.endsWith(')')) { + scanDirectory(fullPath, currentPath, entry.name); + } + else if (DYNAMIC_SEGMENT_PATTERN.test(entry.name)) { + continue; + } + else { + scanDirectory(fullPath, [...currentPath, entry.name], routeGroup); + } + } + else if (entry.name === 'page.tsx') { + const menuPath = basePath + (currentPath.length > 0 ? '/' + currentPath.join('/') : ''); + menus.push({ + domain, + menuPath, + pageFilePath: fullPath, + routeGroup + }); + } + } + } + + scanDirectory(appDir, [], ''); + return menus; +} + +/** + * Sync discovered menus for a specific domain + */ +export async function syncDiscoveredMenus(domain: MenuDomain): Promise<{ added: number; removed: number }> { + const discovered = discoverMenusFromAppRouter(domain); + + const existing = await db.select({ + id: menuTreeNodes.id, + menuPath: menuTreeNodes.menuPath + }) + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + inArray(menuTreeNodes.nodeType, ['menu', 'additional']) + )); + + const existingPaths = new Set(existing.map(e => e.menuPath).filter(Boolean)); + + const newMenus = discovered.filter(d => !existingPaths.has(d.menuPath)); + let added = 0; + + for (const menu of newMenus) { + const pathSegments = menu.menuPath.split('/').filter(Boolean); + const lastSegment = pathSegments[pathSegments.length - 1] || 'unknown'; + + await db.insert(menuTreeNodes).values({ + domain, + parentId: null, + nodeType: 'menu', + sortOrder: 0, + titleKo: lastSegment, + titleEn: lastSegment, + menuPath: menu.menuPath, + isActive: false, + }); + added++; + } + + revalidatePath('/evcp/menu-v2'); + return { added, removed: 0 }; +} + +/** + * Sync all domains + */ +export async function syncAllDomains(): Promise<Record<MenuDomain, { added: number; removed: number }>> { + const [evcp, partners] = await Promise.all([ + syncDiscoveredMenus('evcp'), + syncDiscoveredMenus('partners') + ]); + return { evcp, partners }; +} + +/** + * Get discovered menus without syncing + */ +export async function getDiscoveredMenus(): Promise<Record<MenuDomain, DiscoveredMenu[]>> { + return { + evcp: discoverMenusFromAppRouter('evcp'), + partners: discoverMenusFromAppRouter('partners') + }; +} + +// ============================================ +// Move Node Helpers +// ============================================ + +/** + * Move node up within same parent (decrease sort order) + */ +export async function moveNodeUp(nodeId: number): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get siblings (nodes with same parent) + const siblings = await db.select() + .from(menuTreeNodes) + .where(node.parentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, node.parentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // Find current index + const currentIndex = siblings.findIndex(s => s.id === nodeId); + if (currentIndex <= 0) return; // Already at top + + // Swap sort orders with previous node + const prevNode = siblings[currentIndex - 1]; + const prevSortOrder = prevNode.sortOrder; + const currentSortOrder = node.sortOrder; + + // If sort orders are the same, assign unique values + if (prevSortOrder === currentSortOrder) { + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex - 1, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, prevNode.id)); + } else { + await db.update(menuTreeNodes) + .set({ sortOrder: prevSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, prevNode.id)); + } + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Move node down within same parent (increase sort order) + */ +export async function moveNodeDown(nodeId: number): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get siblings (nodes with same parent) + const siblings = await db.select() + .from(menuTreeNodes) + .where(node.parentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, node.parentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + // Find current index + const currentIndex = siblings.findIndex(s => s.id === nodeId); + if (currentIndex >= siblings.length - 1) return; // Already at bottom + + // Swap sort orders with next node + const nextNode = siblings[currentIndex + 1]; + const nextSortOrder = nextNode.sortOrder; + const currentSortOrder = node.sortOrder; + + // If sort orders are the same, assign unique values + if (nextSortOrder === currentSortOrder) { + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex + 1, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentIndex, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nextNode.id)); + } else { + await db.update(menuTreeNodes) + .set({ sortOrder: nextSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nodeId)); + await db.update(menuTreeNodes) + .set({ sortOrder: currentSortOrder, updatedAt: new Date() }) + .where(eq(menuTreeNodes.id, nextNode.id)); + } + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Move node to a different parent + */ +export async function moveNodeToParent(nodeId: number, newParentId: number | null): Promise<void> { + const [node] = await db.select() + .from(menuTreeNodes) + .where(eq(menuTreeNodes.id, nodeId)) + .limit(1); + + if (!node) return; + + // Get max sort order in new parent + const siblings = await db.select({ sortOrder: menuTreeNodes.sortOrder }) + .from(menuTreeNodes) + .where(newParentId === null + ? and(eq(menuTreeNodes.domain, node.domain), isNull(menuTreeNodes.parentId)) + : eq(menuTreeNodes.parentId, newParentId) + ) + .orderBy(asc(menuTreeNodes.sortOrder)); + + const maxSortOrder = siblings.length > 0 ? Math.max(...siblings.map(s => s.sortOrder)) + 1 : 0; + + await db.update(menuTreeNodes) + .set({ + parentId: newParentId, + sortOrder: maxSortOrder, + updatedAt: new Date() + }) + .where(eq(menuTreeNodes.id, nodeId)); + + revalidatePath('/evcp/menu-v2'); +} + +/** + * Get all possible parent targets for a node (for Move To dialog) + * Returns items in tree order (same as Menu Structure display) + * + * Rules: + * - menu_group: Cannot be moved (always at top level) + * - group: Can only move to menu_group (not to root or other groups) + * - menu: Can move to root, menu_group, or group + */ +export async function getAvailableParents( + nodeId: number, + domain: MenuDomain, + nodeType: string +): Promise<{ id: number | null; title: string; depth: number }[]> { + const nodes = await db.select() + .from(menuTreeNodes) + .where(and( + eq(menuTreeNodes.domain, domain), + inArray(menuTreeNodes.nodeType, ['menu_group', 'group']) + )) + .orderBy(asc(menuTreeNodes.sortOrder)); + + const result: { id: number | null; title: string; depth: number }[] = []; + + // For menu nodes, allow moving to root (as top-level menu) + if (nodeType === 'menu') { + result.push({ id: null, title: 'Top Level (Root)', depth: 0 }); + } + + // Build tree structure + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const menuGroups = nodes.filter(n => n.parentId === null && n.nodeType === 'menu_group'); + + // Helper to check if node is descendant of nodeId (prevent circular reference) + const isDescendantOf = (checkNode: typeof nodes[0], ancestorId: number): boolean => { + let parent = checkNode.parentId; + while (parent !== null) { + if (parent === ancestorId) return true; + const parentNode = nodeMap.get(parent); + parent = parentNode?.parentId ?? null; + } + return false; + }; + + // Traverse tree in order (menu_group -> its children groups) + for (const menuGroup of menuGroups) { + // Skip if it's the node being moved or its descendant + if (menuGroup.id === nodeId || isDescendantOf(menuGroup, nodeId)) continue; + + // Add menu_group + result.push({ + id: menuGroup.id, + title: menuGroup.titleKo, + depth: 1 + }); + + // For group nodes, only menu_groups are valid targets (skip children) + if (nodeType === 'group') continue; + + // Add children groups (sorted by sortOrder) + const childGroups = nodes + .filter(n => n.parentId === menuGroup.id && n.nodeType === 'group') + .sort((a, b) => a.sortOrder - b.sortOrder); + + for (const group of childGroups) { + // Skip if it's the node being moved or its descendant + if (group.id === nodeId || isDescendantOf(group, nodeId)) continue; + + result.push({ + id: group.id, + title: group.titleKo, + depth: 2 + }); + } + } + + return result; +} diff --git a/lib/menu-v2/types.ts b/lib/menu-v2/types.ts new file mode 100644 index 00000000..1be8a4fe --- /dev/null +++ b/lib/menu-v2/types.ts @@ -0,0 +1,103 @@ +// lib/menu-v2/types.ts + +export type NodeType = 'menu_group' | 'group' | 'menu' | 'additional'; +export type MenuDomain = 'evcp' | 'partners'; + +export interface MenuTreeNode { + id: number; + domain: MenuDomain; + parentId: number | null; + nodeType: NodeType; + sortOrder: number; + titleKo: string; + titleEn: string | null; + descriptionKo: string | null; + descriptionEn: string | null; + menuPath: string | null; + icon: string | null; + scrId: string | null; + isActive: boolean; + manager1Id: number | null; + manager2Id: number | null; + createdAt: Date; + updatedAt: Date; + // 조회 시 추가되는 필드 + children?: MenuTreeNode[]; +} + +export interface DiscoveredMenu { + domain: MenuDomain; + menuPath: string; + pageFilePath: string; + routeGroup: string; +} + +// 도메인별 앱 라우터 경로 설정 +export const DOMAIN_APP_PATHS: Record<MenuDomain, { + appDir: string; + basePath: string; +}> = { + evcp: { + appDir: 'app/[lng]/evcp/(evcp)', + basePath: '/evcp' + }, + partners: { + appDir: 'app/[lng]/partners', + basePath: '/partners' + } +}; + +// 관리자용 트리 조회 결과 타입 +// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합 +export interface MenuTreeAdminResult { + tree: MenuTreeNode[]; + unassigned: MenuTreeNode[]; +} + +// 헤더용 트리 조회 결과 타입 +// tree: 메뉴그룹(드롭다운) + 최상위 메뉴(단일 링크) 통합 +export interface MenuTreeActiveResult { + tree: MenuTreeNode[]; +} + +// 노드 생성 타입 +export interface CreateMenuGroupInput { + titleKo: string; + titleEn?: string; + sortOrder?: number; +} + +export interface CreateGroupInput { + parentId: number; + titleKo: string; + titleEn?: string; + sortOrder?: number; +} + +// 최상위 메뉴 생성 (단일 링크) +export interface CreateTopLevelMenuInput { + titleKo: string; + titleEn?: string; + menuPath: string; + sortOrder?: number; +} + +// 노드 업데이트 타입 +export interface UpdateNodeInput { + titleKo?: string; + titleEn?: string; + descriptionKo?: string; + descriptionEn?: string; + isActive?: boolean; + scrId?: string; + icon?: string; + manager1Id?: number | null; + manager2Id?: number | null; +} + +// 순서 변경 타입 +export interface ReorderNodeInput { + id: number; + sortOrder: number; +} + |
