Files
invyone/frontend/components/control/RuleBuilder.tsx
T
2026-04-10 13:33:37 +09:00

227 lines
7.1 KiB
TypeScript

'use client';
import { useCallback, useRef } from 'react';
import { useControlMode, genNodeId, CTRL_NODE_TYPES } from './hooks/useControlMode';
import { usePortDrag } from './hooks/usePortDrag';
import { ControlNode } from './ControlNode';
import { TableNode } from './TableNode';
import { PortHandle } from './PortHandle';
import { NodeConfigPopover } from './NodeConfigPopover';
import { bezierPath } from './ConnectionLine';
import { getMetaFields } from '@/lib/api/meta';
import type { FieldConfig } from '@/types/invyone-component';
interface RuleBuilderProps {
canvasRef: React.RefObject<HTMLDivElement | null>;
}
/** 테이블 필드 캐시 */
const fieldCache: Record<string, FieldConfig[]> = {};
/**
* 규칙 빌더 — 편집 모드
* mockup initRuleBuilder/dropTable/dropControl 포팅
*/
export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
const {
ruleNodes,
ruleConnections,
addRuleNode,
moveRuleNode,
} = useControlMode();
const { startDrag, finishDrag } = usePortDrag(canvasRef);
// 캔버스 드롭 처리
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
let d: Record<string, any>;
try { d = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
if (!d?.kind) return;
const cv = canvasRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const x = e.clientX - r.left + cv.scrollLeft;
const y = e.clientY - r.top + cv.scrollTop;
if (d.kind === 'table') {
// 중복 방지
if (ruleNodes.find((n) => n.type === 'table' && n.table_name === d.name)) return;
// 필드 로드
let cols: FieldConfig[] = [];
if (fieldCache[d.name]) {
cols = fieldCache[d.name];
} else {
try {
const meta = await getMetaFields(d.name);
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8);
fieldCache[d.name] = cols;
} catch { /* 빈 필드 */ }
}
addRuleNode({
id: genNodeId('tbl'),
type: 'table',
table_name: d.name,
label: d.name,
x: x - 100,
y: y - 40,
columns: cols,
});
} else if (d.kind === 'control' && CTRL_NODE_TYPES[d.type]) {
addRuleNode({
id: genNodeId('ctrl'),
type: d.type,
label: CTRL_NODE_TYPES[d.type].label,
x: x - 80,
y: y - 30,
config: {},
});
}
}, [canvasRef, ruleNodes, addRuleNode]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}, []);
// 노드 좌표에서 포트 위치 계산
const portPos = useCallback((nodeId: string, port: string) => {
const node = ruleNodes.find((n) => n.id === nodeId);
if (!node) return null;
if (node.type === 'table') {
if (port === 'in') return { x: node.x, y: node.y + 18 };
return { x: node.x + 200, y: node.y + 18 };
}
// 제어 노드
if (port === 'in') return { x: node.x, y: node.y + 40 };
// output 포트 — 타입별 위치
const def = CTRL_NODE_TYPES[node.type];
const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }];
const idx = outPorts.findIndex((p) => p.port === port);
const total = outPorts.length;
const nodeH = 80;
const centerY = node.y + nodeH / 2;
const gap = 8;
const startY = centerY - ((total - 1) * gap) / 2;
return { x: node.x + 160, y: startY + idx * gap };
}, [ruleNodes]);
return (
<>
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */}
<div
style={{ position: 'absolute', inset: 0, zIndex: 5 }}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
{/* 연결선 SVG */}
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
</marker>
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
</marker>
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
</marker>
</defs>
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
if (!f || !t) return null;
const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes'
: c.from_port === 'no' ? 'rule-conn-path conn-no'
: 'rule-conn-path';
const marker = c.from_port === 'yes' ? 'url(#arr-yes)'
: c.from_port === 'no' ? 'url(#arr-no)'
: 'url(#arr-rule)';
return (
<path
key={c.id}
d={bezierPath(f.x, f.y, t.x, t.y)}
className={cls}
markerEnd={marker}
/>
);
})}
</svg>
{/* 연결 삭제 뱃지 */}
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
if (!f || !t) return null;
const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2;
return (
<div
key={`badge-${c.id}`}
className="rule-conn-badge"
style={{ left: mx, top: my }}
>
<span
className="conn-x"
onClick={(e) => {
e.stopPropagation();
useControlMode.getState().removeRuleConnection(c.id);
}}
>
</span>
</div>
);
})}
{/* 노드 렌더 */}
{ruleNodes.map((node) => {
if (node.type === 'table') {
return (
<div key={node.id} style={{ position: 'absolute', left: 0, top: 0 }}>
<TableNode
tableName={node.table_name}
label={node.label}
icon="🏢"
columns={node.columns ?? []}
x={node.x}
y={node.y}
onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
style={{ overflow: 'visible' }}
/>
{/* I/O 포트 */}
<PortHandle nodeId={node.id} port="in" type="in" isTable onDragEnd={finishDrag} />
<div style={{ position: 'absolute', left: node.x + 194, top: node.y + 12 }}>
<PortHandle nodeId={node.id} port="out" type="out" isTable label="→" onDragStart={startDrag} />
</div>
</div>
);
}
return (
<ControlNode
key={node.id}
node={node}
onDragStart={startDrag}
onDragEnd={finishDrag}
/>
);
})}
{/* 설정 팝오버 */}
<NodeConfigPopover />
</>
);
}