'use client'; import { useRef, useCallback } from 'react'; import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; import { getNodeIcon } from './schemas'; import { PortHandle } from './PortHandle'; interface ControlNodeProps { node: Record; onDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void; onDragEnd?: (nodeId: string, port: string) => void; } /** * 제어 노드 (16종) — mockup V3RuleNode 비주얼 (cat-stripe + cat-chip header + label + summary + ports) */ export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) { const { removeRuleNode, moveRuleNode, setConfigNodeId, configNodeId } = useControlMode(); const nodeRef = useRef(null); const def = CTRL_NODE_TYPES[node.type]; if (!def) return null; const rgb = def.rgb; const Ic = getNodeIcon(node.type); const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }]; const selected = configNodeId === node.id; const dim = !!configNodeId && configNodeId !== node.id; const handleNodeMouseDown = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; // port / del 버튼 클릭은 드래그 X if (target.closest('.ctrl-io-port, button')) return; e.preventDefault(); e.stopPropagation(); const sx = e.clientX, sy = e.clientY; const sl = node.x, st = node.y; const el = nodeRef.current; if (el) el.style.zIndex = '30'; let moved = false; const mv = (ev: MouseEvent) => { const dx = ev.clientX - sx, dy = ev.clientY - sy; if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return; moved = true; moveRuleNode(node.id, sl + dx, st + dy); }; const up = () => { if (el) el.style.zIndex = '20'; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!moved) setConfigNodeId(node.id === configNodeId ? null : node.id); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }, [node.id, node.x, node.y, moveRuleNode, setConfigNodeId, configNodeId]); // summary 표시 우선순위: // 1. node.config.summary — NodeConfigPopover 가 저장한 한글 라벨 (예: "결재상태 = '결재완료'") // 2. node.summary[0] — mock/seed 데이터의 summary // 3. config entries fallback — { field, op, value, ... } 의 핵심 값을 chip 으로 // 4. '클릭하여 설정' const formatVal = (v: any): string => { if (v == null || v === '') return ''; if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v); if (typeof v === 'object') { // fully qualified field { table, column } if (v.column) return String(v.label ?? v.column); return ''; } return String(v); }; const summary = (() => { if (node.config?.summary) return String(node.config.summary); if (node.summary?.[0]) return String(node.summary[0]); if (node.config && Object.keys(node.config).length > 0) { const parts = Object.entries(node.config) .filter(([k]) => k !== 'summary') .map(([k, v]) => `${k}: ${formatVal(v)}`) .filter((s) => !s.endsWith(': ')) .slice(0, 2); if (parts.length > 0) return parts.join(' · '); } return '클릭하여 설정'; })(); return (
{/* cat-color stripe */}
{/* body */}
{def.label}
{node.label ?? def.label}
{summary &&
{summary}
}
{/* Input 포트 (좌측) */} {/* Output 포트 (우측, 다중 지원) — label 텍스트(✓/✗) 없이 색만으로 구분 (yes=초록, no=회색 dashed) */}
{outPorts.map((p) => ( ))}
); }