Files
DDD1542 3eda684787
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s
사용자 대시보드 기능강화 및 인비온 스튜디오 메뉴관리 자잘한수정
2026-04-22 18:27:06 +09:00

355 lines
11 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>
{hasChildren && (
<div className={`v5-mm-sub${open ? " is-open" : ""}`}>
<div className="v5-mm-sub-inner">
{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>
</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>
);
}