2f398ae0b3
- 제어모드 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 후속 노트
274 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|