"use client"; import React, { useMemo, useState } from "react"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import type { MenuItem } from "@/lib/api/menu"; export type DropPos = "before" | "after" | "into"; interface TreeNode { id: string; parentId: string; name: string; url: string; lev: number; status: string; raw: Record; children: TreeNode[]; } interface Props { menus: MenuItem[]; selectedId: string; expandedIds: Set; searchText: string; onSelect: (id: string) => void; onToggleExpand: (id: string) => void; onSearchChange: (s: string) => void; onAddTop: () => void; onMove: (sourceId: string, targetId: string, pos: DropPos) => void; } const buildTree = (menus: MenuItem[]): TreeNode[] => { const map = new Map(); const roots: TreeNode[] = []; for (const m of menus) { const id = String(m.objid ?? m.OBJID ?? ""); if (!id) continue; map.set(id, { id, parentId: String(m.parent_obj_id ?? m.PARENT_OBJ_ID ?? "0"), name: m.menu_name_kor ?? m.MENU_NAME_KOR ?? "(no name)", url: m.menu_url ?? m.MENU_URL ?? "", lev: Number(m.lev ?? m.LEV ?? 1), status: (m.status ?? m.STATUS ?? "").toString().toLowerCase(), raw: m as any, children: [], }); } map.forEach((node) => { if (node.parentId && node.parentId !== "0" && map.has(node.parentId)) { map.get(node.parentId)!.children.push(node); } else { roots.push(node); } }); const sortRec = (nodes: TreeNode[]) => { nodes.sort((a, b) => (Number(a.raw.seq ?? a.raw.SEQ ?? 0) - Number(b.raw.seq ?? b.raw.SEQ ?? 0))); nodes.forEach((n) => sortRec(n.children)); }; sortRec(roots); return roots; }; const filterTree = (nodes: TreeNode[], q: string): TreeNode[] => { if (!q) return nodes; const lower = q.toLowerCase(); const rec = (node: TreeNode): TreeNode | null => { const matchSelf = node.name.toLowerCase().includes(lower) || node.url.toLowerCase().includes(lower); const filteredChildren = node.children.map(rec).filter(Boolean) as TreeNode[]; if (matchSelf || filteredChildren.length) { return { ...node, children: filteredChildren }; } return null; }; return nodes.map(rec).filter(Boolean) as TreeNode[]; }; const StatusDot = ({ on }: { on: boolean }) => ( ); interface RowProps { node: TreeNode; selectedId: string; expandedIds: Set; draggingId: string | null; dropHint: { id: string; pos: DropPos } | null; onSelect: (id: string) => void; onToggleExpand: (id: string) => void; onDragStart: (id: string) => void; onDragEnd: () => void; onDragOver: (e: React.DragEvent, id: string, lev: number) => void; onDrop: (e: React.DragEvent, id: string) => void; } const NodeRow: React.FC = ({ node, selectedId, expandedIds, draggingId, dropHint, onSelect, onToggleExpand, onDragStart, onDragEnd, onDragOver, onDrop, }) => { const hasChildren = node.children.length > 0; const open = expandedIds.has(node.id); const sel = selectedId === node.id; const dragging = draggingId === node.id; const dropCls = dropHint && dropHint.id === node.id ? dropHint.pos === "before" ? "drop-before" : dropHint.pos === "after" ? "drop-after" : "drop-into" : ""; const cls = [ "v5-mm-node", node.lev === 1 ? "l1" : node.lev === 2 ? "l2" : "l3", hasChildren ? "" : "leaf", open ? "open" : "", sel ? "on" : "", dragging ? "dragging" : "", dropCls, ] .filter(Boolean) .join(" "); const isActive = node.status === "active"; return ( <>
onSelect(node.id)} draggable onDragStart={(e) => { e.stopPropagation(); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", node.id); onDragStart(node.id); }} onDragEnd={onDragEnd} onDragOver={(e) => onDragOver(e, node.id, node.lev)} onDrop={(e) => onDrop(e, node.id)} > { e.stopPropagation(); if (hasChildren) onToggleExpand(node.id); }} > {hasChildren && ( )} {node.lev === 1 ? ( ) : ( )}
{node.name}
{node.lev > 1 && node.url &&
{node.url}
}
{node.lev === 1 && hasChildren && {node.children.length}}
{open && hasChildren && (
{node.children.map((child) => ( ))}
)} ); }; export function MenuTreePanel({ menus, selectedId, expandedIds, searchText, onSelect, onToggleExpand, onSearchChange, onAddTop, onMove, }: Props) { const tree = useMemo(() => buildTree(menus), [menus]); const filtered = useMemo(() => filterTree(tree, searchText), [tree, searchText]); const total = menus.length; const rootCount = tree.length; const [draggingId, setDraggingId] = useState(null); const [dropHint, setDropHint] = useState<{ id: string; pos: DropPos } | null>(null); const [treeRef] = useAutoAnimate({ duration: 280, easing: "cubic-bezier(.16,1,.3,1)" }); const handleDragStart = (id: string) => setDraggingId(id); const handleDragEnd = () => { setDraggingId(null); setDropHint(null); }; const handleDragOver = (e: React.DragEvent, id: string, lev: number) => { e.preventDefault(); e.stopPropagation(); if (!draggingId || draggingId === id) return; e.dataTransfer.dropEffect = "move"; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const y = e.clientY - rect.top; const h = rect.height; // L1: 카드형 — before/after 50/50 (자식 승격이 용이) // L2/L3: 3-zone — 상단 33% before / 중앙 34% into (하위로 넣기) / 하단 33% after let pos: DropPos; if (lev === 1) { pos = y < h * 0.5 ? "before" : "after"; } else { if (y < h * 0.33) pos = "before"; else if (y > h * 0.67) pos = "after"; else pos = "into"; } setDropHint((prev) => (prev && prev.id === id && prev.pos === pos ? prev : { id, pos })); }; const handleDrop = (e: React.DragEvent, id: string) => { e.preventDefault(); e.stopPropagation(); const sourceId = draggingId || e.dataTransfer.getData("text/plain"); const pos = dropHint?.pos || "after"; if (sourceId && sourceId !== id) { onMove(sourceId, id, pos); } setDraggingId(null); setDropHint(null); }; return (
02Tree

메뉴 트리

{rootCount}개 · {total}개 항목

onSearchChange(e.target.value)} />
{ if (!draggingId) return; // 자식 노드가 이벤트 처리 중이면(dropHint 이미 있음) 무시 if (dropHint) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; }} onDrop={(e) => { if (!draggingId) return; if (dropHint) return; // 자식 노드가 처리 e.preventDefault(); // 마지막 L1의 after로 이동 const lastL1 = [...filtered].reverse()[0]; if (lastL1 && draggingId !== lastL1.id) { onMove(draggingId, lastL1.id, "after"); } setDraggingId(null); setDropHint(null); }} > {filtered.length === 0 ? (
검색 결과가 없습니다
) : ( filtered.map((node) => ( )) )}
); }