353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
"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<string, any>;
|
|
children: TreeNode[];
|
|
}
|
|
|
|
interface Props {
|
|
menus: MenuItem[];
|
|
selectedId: string;
|
|
expandedIds: Set<string>;
|
|
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<string, TreeNode>();
|
|
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 }) => (
|
|
<span className={`v5-mm-dot${on ? " on" : ""}`} />
|
|
);
|
|
|
|
interface RowProps {
|
|
node: TreeNode;
|
|
selectedId: string;
|
|
expandedIds: Set<string>;
|
|
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<RowProps> = ({
|
|
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 (
|
|
<>
|
|
<div
|
|
className={cls}
|
|
onClick={() => 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)}
|
|
>
|
|
<span
|
|
className="v5-mm-caret"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (hasChildren) onToggleExpand(node.id);
|
|
}}
|
|
>
|
|
{hasChildren && (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<path d="m9 5 7 7-7 7" />
|
|
</svg>
|
|
)}
|
|
</span>
|
|
{node.lev === 1 ? (
|
|
<span className="v5-mm-node-ico">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
|
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
|
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
|
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
|
</svg>
|
|
</span>
|
|
) : (
|
|
<StatusDot on={isActive} />
|
|
)}
|
|
<div className="v5-mm-node-body">
|
|
<div className="v5-mm-node-name">{node.name}</div>
|
|
{node.lev > 1 && node.url && <div className="v5-mm-node-meta">{node.url}</div>}
|
|
</div>
|
|
{node.lev === 1 && hasChildren && <span className="v5-mm-cnt">{node.children.length}</span>}
|
|
</div>
|
|
{open && hasChildren && (
|
|
<div className="v5-mm-sub" style={{ display: "block" }}>
|
|
{node.children.map((child) => (
|
|
<NodeRow
|
|
key={child.id}
|
|
node={child}
|
|
selectedId={selectedId}
|
|
expandedIds={expandedIds}
|
|
draggingId={draggingId}
|
|
dropHint={dropHint}
|
|
onSelect={onSelect}
|
|
onToggleExpand={onToggleExpand}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
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<string | null>(null);
|
|
const [dropHint, setDropHint] = useState<{ id: string; pos: DropPos } | null>(null);
|
|
const [treeRef] = useAutoAnimate<HTMLDivElement>({ 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 (
|
|
<section className="v5-mm-col v5-mm-col-tree">
|
|
<div className="v5-mm-col-hd">
|
|
<div>
|
|
<div className="v5-mm-step">
|
|
<span className="num">02</span>Tree
|
|
</div>
|
|
<h3>메뉴 트리</h3>
|
|
<p>
|
|
{rootCount}개 · {total}개 항목
|
|
</p>
|
|
</div>
|
|
<button className="v5-mm-hd-add" title="최상위 메뉴 추가" onClick={onAddTop}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="v5-mm-tree-srch">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
placeholder="트리 검색…"
|
|
value={searchText}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className="v5-mm-tree"
|
|
ref={treeRef}
|
|
onDragOver={(e) => {
|
|
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 ? (
|
|
<div className="v5-mm-empty">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<div>검색 결과가 없습니다</div>
|
|
</div>
|
|
) : (
|
|
filtered.map((node) => (
|
|
<NodeRow
|
|
key={node.id}
|
|
node={node}
|
|
selectedId={selectedId}
|
|
expandedIds={expandedIds}
|
|
draggingId={draggingId}
|
|
dropHint={dropHint}
|
|
onSelect={onSelect}
|
|
onToggleExpand={onToggleExpand}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|