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 후속 노트
148 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|