Files
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

274 lines
9.5 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); // 모든 컬럼 로드 (Phase 2 dropdown 용)
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';
}, []);
// 노드 좌표에서 포트 위치 계산
// dir: 'from' (출력측, 우측) | 'to' (입력측, 좌측) — 컬럼별 port 의 좌/우 결정용
const portPos = useCallback((nodeId: string, port: string, dir: 'from' | 'to' = 'from') => {
const node = ruleNodes.find((n) => n.id === nodeId);
if (!node) return null;
if (node.type === 'table') {
// 테이블 단위 단일 port — 카드 좌측(in) / 우측(out) 중앙
// (Phase 1: 컬럼별 port 폐기. 컬럼 선택은 NodeConfigPopover dropdown 에서)
void dir;
const cardW = 180;
const cardH = 70; // stripe + head + stats
const yMid = node.y + cardH / 2;
if (port === 'in') return { x: node.x, y: yMid };
return { x: node.x + cardW, y: yMid };
}
// 제어 노드
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
className="rule-builder-canvas"
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
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, 'from');
const t = portPos(c.to_node_id, c.to_port, 'to');
if (!f || !t) return null;
// Phase 3: edge_type 별 stroke 분기 (yes/no 우선, 그 다음 edge_type)
const portCls = c.from_port === 'yes' ? 'conn-yes'
: c.from_port === 'no' ? 'conn-no' : '';
const edgeCls = c.edge_type ? `edge-${c.edge_type}` : '';
const cls = ['rule-conn-path', portCls, edgeCls].filter(Boolean).join(' ');
// 선 중간 라벨 — yes/no 같은 분기 + edge_type 시각화 (mockup v3 EditCanvas style)
const portLabel =
c.label ??
(c.from_port === 'yes' ? '예'
: c.from_port === 'no' ? '아니오'
: c.from_port === 'pass' ? '통과'
: c.from_port === 'fail' ? '실패'
: c.from_port === 'approved'? '승인'
: c.from_port === 'rejected'? '반려'
: c.from_port === 'each' ? '반복'
: c.from_port === 'done' ? '완료'
: null);
const labelColor = c.from_port === 'yes' ? 'var(--ctrl-green)'
: c.from_port === 'no' ? 'var(--v5-text-muted, #888)'
: c.from_port === 'pass' ? 'var(--ctrl-green)'
: c.from_port === 'fail' ? 'rgb(255, 71, 87)'
: c.from_port === 'approved' ? 'var(--ctrl-green)'
: c.from_port === 'rejected' ? 'var(--v5-text-muted, #888)'
: c.edge_type === 'table-mutation' ? 'rgb(253, 121, 168)'
: c.edge_type === 'execution-flow' ? 'var(--ctrl-primary)'
: c.edge_type === 'lookup' ? 'var(--ctrl-green)'
: 'var(--ctrl-cyan)';
const mx = (f.x + t.x) / 2;
const my = (f.y + t.y) / 2;
const labelW = Math.max(36, (portLabel?.length ?? 0) * 8 + 14);
return (
<g key={c.id}>
<path d={bezierPath(f.x, f.y, t.x, t.y)} className={cls} />
{portLabel && (
<g transform={`translate(${mx}, ${my - 11})`}>
<rect
x={-labelW / 2} y={-9}
width={labelW} height={18} rx={4}
fill="var(--v5-surface-solid)"
stroke={labelColor}
strokeWidth={1}
opacity={0.95}
/>
<text
y={4}
textAnchor="middle"
fontSize={10}
fontWeight={700}
fill={labelColor}
fontFamily="var(--v5-font-mono)"
>
{portLabel}
</text>
</g>
)}
</g>
);
})}
</svg>
{/* 연결 삭제 뱃지 */}
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port, 'from');
const t = portPos(c.to_node_id, c.to_port, 'to');
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' }}
nodeId={node.id}
onPortDragStart={startDrag}
onPortDragEnd={finishDrag}
/>
</div>
);
}
return (
<ControlNode
key={node.id}
node={node}
onDragStart={startDrag}
onDragEnd={finishDrag}
/>
);
})}
{/* 설정 팝오버 */}
<NodeConfigPopover />
</div>
);
}