Files
invyone/frontend/components/control/ControlNode.tsx
T
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

148 lines
5.3 KiB
TypeScript

'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<string, any>;
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<HTMLDivElement>(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 (
<div
ref={nodeRef}
className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
data-node-id={node.id}
data-node-type={node.type}
onMouseDown={handleNodeMouseDown}
style={{
left: node.x, top: node.y,
borderColor: `rgba(${rgb}, ${selected ? 0.85 : 0.4})`,
boxShadow: selected
? `0 0 0 4px rgba(${rgb}, .14), 0 0 24px rgba(${rgb}, .22)`
: '0 4px 12px -4px rgba(0, 0, 0, .08)',
}}
>
{/* cat-color stripe */}
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
{/* body */}
<div className="v3-rule-node-body">
<div className="v3-rule-node-cat">
<div className="v3-rule-node-cat-ico"
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}>
<Ic size={11} />
</div>
<span className="v3-rule-node-cat-label" style={{ color: `rgb(${rgb})` }}>
{def.label}
</span>
<button
type="button"
className="ctrl-an-del"
title="삭제"
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
style={{ marginLeft: 'auto' }}
>
</button>
</div>
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
{summary && <div className="v3-rule-node-summary">{summary}</div>}
</div>
{/* Input 포트 (좌측) */}
<PortHandle nodeId={node.id} port="in" type="in" onDragEnd={onDragEnd} />
{/* Output 포트 (우측, 다중 지원) — label 텍스트(✓/✗) 없이 색만으로 구분 (yes=초록, no=회색 dashed) */}
<div className="ctrl-an-ports-out">
{outPorts.map((p) => (
<PortHandle
key={p.port}
nodeId={node.id}
port={p.port}
type="out"
cls={p.cls}
onDragStart={onDragStart}
/>
))}
</div>
</div>
);
}