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 후속 노트
This commit is contained in:
DDD1542
2026-05-19 21:31:03 +09:00
parent 467a41a3a8
commit 2f398ae0b3
138 changed files with 13662 additions and 13999 deletions
+15 -3
View File
@@ -41,10 +41,22 @@ export function ConnectionSvg({ children }: ConnectionSvgProps) {
);
}
/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */
/**
* 연결선 path — mockup v3 EditCanvas 의 orthogonal-with-rounded-corners 스타일
* from(x1,y1) → 가로 → 둥근 코너 → 세로 → 둥근 코너 → 가로 → to(x2,y2)
* 같은 y 면 직선, 역방향(x1>x2)이면 부드러운 베지어로 fallback (어색한 backward 회피)
*/
export function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
return `M${x1},${y1} C${x1 + dx * 0.5},${y1} ${x1 + dx * 0.5},${y2} ${x2},${y2}`;
// 역방향 (오른쪽→왼쪽): 직각 라우팅이 카드 위로 휘감으면 어색 → 베지어 사용
if (x2 < x1 - 20) {
const dx = x2 - x1;
return `M ${x1} ${y1} C ${x1 + Math.abs(dx) * 0.4} ${y1}, ${x2 - Math.abs(dx) * 0.4} ${y2}, ${x2} ${y2}`;
}
const sign = Math.sign(y2 - y1);
if (sign === 0) return `M ${x1} ${y1} L ${x2} ${y2}`;
const mx = (x1 + x2) / 2;
const r = Math.min(10, Math.abs(y2 - y1) / 2, Math.abs(x2 - x1) / 4);
return `M ${x1} ${y1} L ${mx - r} ${y1} Q ${mx} ${y1}, ${mx} ${y1 + sign * r} L ${mx} ${y2 - sign * r} Q ${mx} ${y2}, ${mx + r} ${y2} L ${x2} ${y2}`;
}
/** 타입별 CSS 클래스 + 마커 */
@@ -0,0 +1,217 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Eye, Wrench, Save, FolderOpen, X, Database } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode';
import {
getBusinessRuleList,
getBusinessRuleInfo,
insertBusinessRule,
updateBusinessRule,
} from '@/lib/api/businessRule';
import { toast } from 'sonner';
interface ControlCardPanelProps {
dashboardId: string;
card: Record<string, any>;
}
/**
* 선택된 카드의 부속 제어 패널
* - 카드가 좌측 상단(예: left:20, top:90, 320x240)으로 축소되면
* 이 패널이 카드 바로 오른쪽 (left:360, top:90 부근)에 floating
* - 패널 안: 카드명 / [읽기 | 편집] 토글 / 액션 / ✕ 닫기
*/
export function ControlCardPanel({ dashboardId, card }: ControlCardPanelProps) {
const {
mode,
setMode,
setSelectedCardId,
ruleNodes,
ruleConnections,
activeRuleId,
setActiveRuleId,
setRuleNodes,
setRuleConnections,
} = useControlMode();
const [ruleList, setRuleList] = useState<Record<string, any>[]>([]);
const [showRuleList, setShowRuleList] = useState(false);
const cardLabel =
card.title ?? card.TITLE ?? card.template_name ?? card.TEMPLATE_NAME ?? '제목 없음';
const cardTable =
card.primary_table ?? card.PRIMARY_TABLE ?? card.source_table ?? card.SOURCE_TABLE ?? null;
const cardType =
card.component_type ?? card.COMPONENT_TYPE ?? card.template_type ?? card.TEMPLATE_TYPE ?? null;
// 편집 모드에서만 규칙 목록 로드
useEffect(() => {
if (mode !== 'edit') return;
getBusinessRuleList(dashboardId)
.then((res) => setRuleList(res?.list ?? res?.data ?? []))
.catch(() => setRuleList([]));
}, [mode, dashboardId]);
const handleLoadRule = useCallback(
async (ruleId: string) => {
try {
const detail = await getBusinessRuleInfo(ruleId);
if (!detail) {
toast.error('규칙을 찾을 수 없습니다');
return;
}
setRuleNodes(detail.nodes ?? []);
setRuleConnections(detail.connections ?? []);
setActiveRuleId(ruleId);
setShowRuleList(false);
toast.success(`"${detail.name ?? ruleId}" 로드됨`);
} catch {
toast.error('규칙 로드 실패');
}
},
[setRuleNodes, setRuleConnections, setActiveRuleId],
);
const handleSave = async () => {
if (ruleNodes.length === 0) {
toast.warning('저장할 노드가 없습니다');
return;
}
try {
const data = {
name: `${cardLabel} 규칙 ${new Date().toLocaleString('ko-KR')}`,
nodes: ruleNodes,
connections: ruleConnections,
card_id: card.card_id ?? card.CARD_ID ?? card.id,
};
if (activeRuleId) {
await updateBusinessRule(activeRuleId, data);
toast.success('규칙 저장됨');
} else {
const result = await insertBusinessRule(dashboardId, data);
if (result?.rule_id) setActiveRuleId(result.rule_id);
toast.success('규칙 생성됨');
}
} catch {
toast.error('저장 실패');
}
};
const handleSourceDragStart = (e: React.DragEvent) => {
if (!cardTable) return;
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name: cardTable }));
e.dataTransfer.effectAllowed = 'copy';
};
return (
<div className="ctrl-card-panel">
{/* 헤더 — "제어" + ✕ 닫기 (카드명은 좌측 카드 자체에 이미 보이므로 중복 X) */}
<div className="ctrl-card-panel-head">
<div className="ctrl-card-panel-icon"></div>
<div className="ctrl-card-panel-title-wrap">
<div className="ctrl-card-panel-title"></div>
{cardType && <div className="ctrl-card-panel-type">{cardType}</div>}
</div>
<button
className="ctrl-card-panel-close"
onClick={() => setSelectedCardId(null)}
title="제어 해제"
>
<X size={11} />
</button>
</div>
{/* 데이터 소스 칩 (드래그 가능, 편집 모드에서 룰 빌더로 추가) */}
{cardTable && (
<div className="ctrl-card-panel-source">
<span
className="ctrl-card-panel-source-chip"
draggable={mode === 'edit'}
onDragStart={mode === 'edit' ? handleSourceDragStart : undefined}
title={mode === 'edit' ? '드래그해서 룰 빌더에 추가' : '데이터 소스'}
>
<Database size={9} />
<span>{cardTable}</span>
</span>
</div>
)}
{/* 모드 토글 — 카드 컨텍스트 안의 segmented */}
<div className="ctrl-card-panel-mode">
<button
className={`ctrl-card-panel-mode-btn${mode === 'view' ? ' on' : ''}`}
onClick={() => setMode('view')}
title="읽기 — 자동 트리 자람"
>
<Eye size={10} />
<span></span>
</button>
<button
className={`ctrl-card-panel-mode-btn${mode === 'edit' ? ' on' : ''}`}
onClick={() => setMode('edit')}
title="편집 — 팔레트에서 직접 작성"
>
<Wrench size={10} />
<span></span>
</button>
</div>
{/* 편집 모드 액션 */}
{mode === 'edit' && (
<>
<div className="ctrl-card-panel-actions">
<div style={{ position: 'relative', flex: 1 }}>
<button
className="ctrl-card-panel-btn"
onClick={() => setShowRuleList(!showRuleList)}
disabled={ruleList.length === 0}
title="저장된 규칙 불러오기"
>
<FolderOpen size={10} />
<span>{ruleList.length > 0 ? ` (${ruleList.length})` : ''}</span>
</button>
{showRuleList && ruleList.length > 0 && (
<div className="ctrl-card-panel-dropdown">
{ruleList.map((rule) => {
const id = rule.rule_id ?? rule.RULE_ID;
const name = rule.name ?? rule.NAME ?? id;
const isActive = id === activeRuleId;
return (
<button
key={id}
className={`ctrl-card-panel-dropdown-item${isActive ? ' active' : ''}`}
onClick={() => handleLoadRule(id)}
>
{name}
</button>
);
})}
</div>
)}
</div>
<button
className="ctrl-card-panel-btn primary"
onClick={handleSave}
disabled={ruleNodes.length === 0}
title="현재 룰 저장"
>
<Save size={10} />
<span></span>
</button>
</div>
{ruleNodes.length > 0 && (
<div className="ctrl-card-panel-status">
{ruleNodes.length} · {ruleConnections.length}
</div>
)}
</>
)}
{mode === 'view' && (
<div className="ctrl-card-panel-hint">
</div>
)}
</div>
);
}
+156 -28
View File
@@ -1,11 +1,17 @@
'use client';
import { useRef } from 'react';
import { useEffect, useRef } from 'react';
import { MousePointerClick } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode';
import { ControlToolbar } from './ControlToolbar';
import { ControlPalette } from './ControlPalette';
import { FlowViewer } from './FlowViewer';
import { RuleBuilder } from './RuleBuilder';
import { getMetaFields } from '@/lib/api/meta';
import type { FieldConfig } from '@/types/invyone-component';
import { ContextBar } from './ide/ContextBar';
import { LeftRail } from './ide/LeftRail';
import { RightRail } from './ide/RightRail';
import { Canvas } from './ide/Canvas';
import { StatusBar } from './ide/StatusBar';
import { CtrlFab } from './ide/CtrlFab';
import '@/styles/control-mode.css';
interface ControlModeProps {
@@ -15,43 +21,165 @@ interface ControlModeProps {
}
/**
* 제어 모드 오버레이 — 캔버스 위에 렌더
* ⚡ 버튼으로 토글, 읽기/편집 모드 전환
* 제어 모드 — Control IDE (v3 V3Takeover 베이스)
*
* 흐름:
* 1) ⚡ 토글 ON → 대시보드 카드들이 흐려지고 FlowViewer 가 호버 토폴로지 표시 + ctrl-mode-hint + FAB
* 2) 카드 클릭 → IDE 5-분할 takeover (ContextBar / LeftRail / Canvas / RightRail / StatusBar)
* 3) ContextBar 의 4-segmented tabs 로 READ / EDIT / RUN / HISTORY 전환
* 4) ContextBar 의 ✕ 닫기 → 카드 선택 해제 (제어 유지)
* 5) ContextBar 의 제어 종료 → 제어 모드 OFF
*/
export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) {
const { active, mode } = useControlMode();
const active = useControlMode((s) => s.active);
const mode = useControlMode((s) => s.mode);
const selectedCardId = useControlMode((s) => s.selectedCardId);
const tablePositions = useControlMode((s) => s.tablePositions);
const flowEdges = useControlMode((s) => s.flowEdges);
const setSelectedCardId = useControlMode((s) => s.setSelectedCardId);
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
const setRuleNodes = useControlMode((s) => s.setRuleNodes);
const setRuleConnections = useControlMode((s) => s.setRuleConnections);
const editInitDone = useRef<string | null>(null);
const selectedCard = selectedCardId
? cards.find((c) => (c.card_id ?? c.CARD_ID ?? c.id) === selectedCardId) ?? null
: null;
// edit 진입 시 자동 노드 등록:
// - view 에서 펼쳐진 테이블이 있으면 그것들 + 관계선
// - 없으면 primary_table 1개만 좌측에 카드로 등장 (사용자가 거기서 컬럼별 마우스 연결로 룰 작성)
useEffect(() => {
if (!active || mode !== 'edit' || !selectedCardId) return;
const key = `${selectedCardId}:${mode}:${Object.keys(tablePositions).join(',')}`;
if (editInitDone.current === key) return;
const { ruleNodes } = useControlMode.getState();
if (ruleNodes.length > 0) {
editInitDone.current = key;
return;
}
editInitDone.current = key;
const hasView = Object.keys(tablePositions).length > 0;
if (!hasView) return; // primary_table 자동 등장 X — 사용자가 LeftRail 에서 드래그할 때만 추가
// view 에서 펼쳐진 테이블 우선
if (hasView) {
const tableIdMap: Record<string, string> = {};
Object.keys(tablePositions).forEach((name) => { tableIdMap[name] = `tbl-${name}`; });
const xs = Object.values(tablePositions).map((p) => p.x);
const ys = Object.values(tablePositions).map((p) => p.y);
const minX = xs.length ? Math.min(...xs) : 0;
const minY = ys.length ? Math.min(...ys) : 0;
(async () => {
const newNodes: Record<string, any>[] = await Promise.all(
Object.entries(tablePositions).map(async ([name, pos]) => {
let columns: FieldConfig[] = [];
let label = name;
try {
const meta = await getMetaFields(name);
columns = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용)
label = meta.table_label ?? name;
} catch { /* 빈 컬럼 */ }
return {
id: tableIdMap[name], type: 'table', table_name: name, label,
x: pos.x - minX + 50, y: pos.y - minY + 50, columns,
};
}),
);
const newConns: Record<string, any>[] = [];
flowEdges.forEach((edge: Record<string, any>, i: number) => {
if (typeof edge.from === 'string' && edge.from.startsWith('CARD:')) return;
const fromId = tableIdMap[edge.from];
const toId = tableIdMap[edge.to];
if (!fromId || !toId) return;
newConns.push({
id: `conn-edit-${i}`,
from_node_id: fromId, from_port: 'out',
to_node_id: toId, to_port: 'in',
});
});
setRuleNodes(newNodes);
setRuleConnections(newConns);
})();
}
}, [active, mode, selectedCardId, tablePositions, flowEdges, setRuleNodes, setRuleConnections]);
// mode 가 view 로 돌아가거나 카드 변경 시 init guard 리셋
useEffect(() => {
if (mode === 'view' || !selectedCardId) {
editInitDone.current = null;
}
}, [mode, selectedCardId]);
// 캔버스에 mode 클래스 + 선택 카드 강조 클래스
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
cv.classList.toggle('control-mode-edit', active && mode === 'edit');
return () => {
cv.classList.remove('control-mode-edit');
};
}, [active, mode, canvasRef]);
// 선택된 카드 element 에 data-flow-active 토글
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
cv.querySelectorAll<HTMLElement>('[data-card-id]').forEach((el) => {
const id = el.dataset.cardId;
if (active && id === selectedCardId) {
el.setAttribute('data-flow-active', '1');
} else {
el.removeAttribute('data-flow-active');
}
});
return () => {
cv.querySelectorAll<HTMLElement>('[data-card-id]').forEach((el) => {
el.removeAttribute('data-flow-active');
});
};
}, [active, selectedCardId, canvasRef]);
if (!active) return null;
return (
<>
{/* 제어 모드 툴바 */}
<ControlToolbar dashboardId={dashboardId} />
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */}
{mode === 'view' && (
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
{/* 카드 미선택 — FlowViewer (호버 토폴로지) + 안내 칩 + FAB */}
{!selectedCard && (
<>
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
<div className="ctrl-mode-hint">
<MousePointerClick size={13} style={{ color: 'rgb(0, 154, 150)' }} />
<span> <b>Control IDE</b> </span>
</div>
<CtrlFab onExit={toggleControlMode} />
</>
)}
{/* 편집 모드: 규칙 빌더 */}
{mode === 'edit' && (
<RuleBuilder canvasRef={canvasRef} />
{/* 카드 선택 — IDE 5-분할 takeover */}
{selectedCard && (
<div className="ctrl-ide-shell">
<ContextBar
selectedCard={selectedCard}
onExit={() => setSelectedCardId(null)}
onCtrlExit={toggleControlMode}
/>
<LeftRail cards={cards} selectedCardId={selectedCardId!} />
<div className="ctrl-ide-canvas">
<Canvas card={selectedCard} canvasRef={canvasRef} dashboardId={dashboardId} />
</div>
<RightRail selectedCard={selectedCard} />
<StatusBar selectedCard={selectedCard} />
</div>
)}
</>
);
}
/**
* 제어 모드 팔레트 wrapper — 사이드바에 삽입
*/
/** 호환성 stub — 외부에서 이름으로만 import 하는 경우 */
export function ControlPaletteWrapper() {
const { active, mode, addRuleNode } = useControlMode();
if (!active || mode !== 'edit') return null;
return (
<ControlPalette
onDropTable={() => {}}
onDropControl={() => {}}
/>
);
return null;
}
+81 -37
View File
@@ -2,6 +2,7 @@
import { useRef, useCallback } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getNodeIcon } from './schemas';
import { PortHandle } from './PortHandle';
interface ControlNodeProps {
@@ -11,80 +12,124 @@ interface ControlNodeProps {
}
/**
* 제어 노드 (16종) — mockup buildCtrlNode 포팅
* 제어 노드 (16종) — mockup V3RuleNode 비주얼 (cat-stripe + cat-chip header + label + summary + ports)
*/
export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) {
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode();
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 handleHeadMouseDown = useCallback((e: React.MouseEvent) => {
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) => {
moveRuleNode(node.id, sl + ev.clientX - sx, st + ev.clientY - sy);
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]);
}, [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="ctrl-action-node"
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,
['--na-rgb' as string]: def.rgb,
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)',
}}
>
{/* Input 포트 */}
<PortHandle
nodeId={node.id}
port="in"
type="in"
onDragEnd={onDragEnd}
/>
{/* cat-color stripe */}
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
{/* 헤더 */}
<div className="ctrl-an-head" onMouseDown={handleHeadMouseDown}>
<div className="ctrl-an-icon">{def.icon}</div>
<span className="ctrl-an-name">{def.label}</span>
<button
className="ctrl-an-del"
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
>
</button>
</div>
{/* 본문 */}
<div
className="ctrl-an-body"
onClick={() => setConfigNodeId(node.id)}
>
<div className="ctrl-an-summary">
{node.config?.summary || '클릭하여 설정'}
{/* 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>
{/* Output 포트 */}
{/* 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
@@ -93,7 +138,6 @@ export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps)
port={p.port}
type="out"
cls={p.cls}
label={p.label}
onDragStart={onDragStart}
/>
))}
+190 -52
View File
@@ -1,7 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { CTRL_NODE_TYPES } from './hooks/useControlMode';
import { useEffect, useMemo, useState } from 'react';
import { Search, Star } from 'lucide-react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getMetaTableList } from '@/lib/api/meta';
interface ControlPaletteProps {
@@ -11,73 +12,210 @@ interface ControlPaletteProps {
/**
* 제어 모드 팔레트 — 사이드바 교체
* mockup renderCtrlPalette 포팅
* - 검색박스
* - ⭐ 시연용 추천 (화이트리스트)
* - DB 테이블 max-height + 내부 스크롤
* - 영어/한국어 동시 표시
* - 제어 노드 16종 카테고리별 그룹
*/
export function ControlPalette({ onDropTable, onDropControl }: ControlPaletteProps) {
// 시연용 추천 화이트리스트 (있을 만한 ERP 표준 테이블 + 메뉴 캡쳐에서 확인된 것)
const RECOMMENDED_TABLES = [
'user_info',
'department',
'role_info',
'menu_master',
'authority_master',
'approval_definitions',
'approval_requests',
'approval_lines',
'audit_log',
'attach_file_info',
];
// 도메인 아이콘 매핑 (prefix 기준)
function pickIcon(name: string): string {
const n = name.toLowerCase();
if (n.startsWith('user') || n === 'user_info') return '👤';
if (n.startsWith('department') || n.startsWith('dept')) return '🏢';
if (n.startsWith('role') || n.startsWith('authority')) return '🛡';
if (n.startsWith('menu')) return '📂';
if (n.startsWith('approval')) return '✋';
if (n.startsWith('audit') || n.startsWith('log')) return '📜';
if (n.startsWith('attach') || n.startsWith('file')) return '📎';
if (n.startsWith('mail')) return '📨';
if (n.startsWith('ai_')) return '🤖';
if (n.startsWith('order')) return '📦';
if (n.startsWith('project')) return '📋';
if (n.startsWith('barcode') || n.startsWith('label')) return '🏷';
if (n.startsWith('batch')) return '⚙';
if (n.startsWith('config') || n.startsWith('setting')) return '⚙';
return '🗂';
}
export function ControlPalette(_props: ControlPaletteProps) {
const [tables, setTables] = useState<Record<string, any>[]>([]);
const [search, setSearch] = useState('');
const mode = useControlMode((s) => s.mode);
const isEditMode = mode === 'edit';
useEffect(() => {
getMetaTableList().then(setTables).catch(() => {});
}, []);
// 검색 + 추천/일반 분리
const { recommended, others } = useMemo(() => {
const q = search.trim().toLowerCase();
const filtered = q
? tables.filter((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase();
return name.includes(q) || label.includes(q);
})
: tables;
const rec: Record<string, any>[] = [];
const oth: Record<string, any>[] = [];
filtered.forEach((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
if (RECOMMENDED_TABLES.includes(name)) rec.push(t);
else oth.push(t);
});
// 추천은 화이트리스트 순서 유지
rec.sort((a, b) => {
const an = String(a.table_name ?? a.TABLE_NAME ?? '').toLowerCase();
const bn = String(b.table_name ?? b.TABLE_NAME ?? '').toLowerCase();
return RECOMMENDED_TABLES.indexOf(an) - RECOMMENDED_TABLES.indexOf(bn);
});
return { recommended: rec, others: oth };
}, [tables, search]);
const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => {
e.dataTransfer.setData('text/plain', JSON.stringify(data));
e.dataTransfer.effectAllowed = 'copy';
};
const catLabels: Record<string, string> = {
'트리거': '트리거',
'조건': '조건 / 분기',
'액션': '액션',
'흐름': '흐름 제어',
'연동': '외부 연동',
'기록': '기록',
: '트리거',
: '조건 / 분기',
: '액션',
: '흐름 제어',
: '외부 연동',
: '기록',
};
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
return (
<div style={{ overflowY: 'auto', flex: 1 }}>
{/* DB 테이블 섹션 */}
<div className="ctrl-palette-section">DB </div>
{tables.map((t) => {
const name = t.table_name ?? t.TABLE_NAME;
const label = t.table_label ?? t.TABLE_LABEL ?? name;
return (
<div
key={name}
className="ctrl-palette-item"
draggable
title={`${label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
>
<span className="cp-icon">🏢</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
</div>
);
})}
const renderTableItem = (t: Record<string, any>, isRecommended: boolean) => {
const name = t.table_name ?? t.TABLE_NAME;
const rawLabel = t.table_label ?? t.TABLE_LABEL;
const label = rawLabel && rawLabel !== name ? rawLabel : null;
const icon = pickIcon(String(name));
return (
<div
key={name}
className={`ctrl-palette-item${isRecommended ? ' ctrl-palette-item-rec' : ''}`}
draggable
title={`${label ?? name}${label ? ` (${name})` : ''} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
>
<span className="cp-icon">{icon}</span>
<span className="cp-label">
<span className="cp-label-main">{label ?? name}</span>
{label && <span className="cp-label-sub">{name}</span>}
</span>
{isRecommended && <Star size={9} className="cp-rec-star" />}
</div>
);
};
{/* 제어 노드 — 카테고리별 그룹 */}
{cats.map((cat) => {
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat);
if (!items.length) return null;
return (
<div key={cat}>
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
{items.map(([type, def]) => (
<div
key={type}
className="ctrl-palette-item"
draggable
title={`${def.label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
>
<span className="cp-icon">{def.icon}</span>
<span>{def.label}</span>
</div>
))}
</div>
);
})}
return (
<div className="ctrl-palette">
{/* 헤더 */}
<div className="ctrl-palette-header">
<span className="ctrl-palette-header-title"> </span>
{!isEditMode && (
<span className="ctrl-palette-header-hint"> </span>
)}
</div>
{/* 검색박스 */}
<div className="ctrl-palette-search-wrap">
<Search size={11} className="ctrl-palette-search-icon" />
<input
type="text"
className="ctrl-palette-search"
placeholder="테이블 / 노드 검색…"
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={!isEditMode}
/>
</div>
<div
className={`ctrl-palette-scroll${!isEditMode ? ' disabled' : ''}`}
style={{ pointerEvents: isEditMode ? 'auto' : 'none' }}
>
{/* 주요 테이블 (자주 쓰는 ERP 표준) */}
{recommended.length > 0 && (
<>
<div className="ctrl-palette-section ctrl-palette-section-rec">
<Star size={9} style={{ marginRight: 3, fill: 'currentColor' }} />
<span className="ctrl-palette-section-count">{recommended.length}</span>
</div>
<div className="ctrl-palette-tables">
{recommended.map((t) => renderTableItem(t, true))}
</div>
</>
)}
{/* 전체 DB 테이블 (max-height + 내부 스크롤) */}
<div className="ctrl-palette-section">
DB
{others.length > 0 && <span className="ctrl-palette-section-count">{others.length}</span>}
</div>
<div className="ctrl-palette-tables ctrl-palette-tables-others">
{others.map((t) => renderTableItem(t, false))}
{others.length === 0 && search && (
<div className="ctrl-palette-empty"> </div>
)}
{others.length === 0 && !search && tables.length === 0 && (
<div className="ctrl-palette-empty"> </div>
)}
</div>
{/* 제어 노드 카테고리별 */}
{cats.map((cat) => {
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => {
if (d.cat !== cat) return false;
if (!search.trim()) return true;
const q = search.trim().toLowerCase();
return d.label.toLowerCase().includes(q);
});
if (!items.length) return null;
return (
<div key={cat}>
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
{items.map(([type, def]) => (
<div
key={type}
className="ctrl-palette-item"
draggable
title={`${def.label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
>
<span className="cp-icon">{def.icon}</span>
<span className="cp-label">
<span className="cp-label-main">{def.label}</span>
</span>
</div>
))}
</div>
);
})}
</div>
</div>
);
}
+5 -1
View File
@@ -79,6 +79,7 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
flowEdges,
tablePositions,
setActiveFlowCard,
setSelectedCardId,
setFlowEdges,
setTablePositions,
} = useControlMode();
@@ -90,14 +91,17 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]);
const animRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// 카드 클릭 → 흐름 표시
// 카드 클릭 → 흐름 표시 + 카드 선택 (selectedCardId 동기화)
const handleCardClick = useCallback(async (cardId: string) => {
// 같은 카드 클릭 → 닫기
if (activeFlowCardId === cardId) {
clearFlow();
setSelectedCardId(null);
return;
}
setSelectedCardId(cardId);
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return;
+403 -50
View File
@@ -1,20 +1,37 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
/**
* 노드 설정 팝오버 (mockup showNodeConfig/_buildCfgForm 포팅)
* 노드 타입별 설정 폼
* 노드 설정 팝오버 — Phase 2: schema-driven dropdown
*
* 핵심: 노드와 연결된 테이블의 컬럼/enum 메타를 dropdown 으로 자동 매핑.
* - 영어 자유 입력 폐기 (실사용 불가)
* - 한글 라벨 우선 + 영문 컬럼 sub
* - enum 컬럼이면 값도 dropdown
* - multi-table 시 optgroup 으로 namespace 구분
* - 저장은 fully qualified { table, column } 객체 (Phase 3 준비)
*/
export function NodeConfigPopover() {
const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode();
const { configNodeId, ruleNodes, ruleConnections, setConfigNodeId, updateRuleNode } = useControlMode();
const popRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
const def = node ? CTRL_NODE_TYPES[node.type] : null;
// 현재 노드와 연결된 테이블 노드들 (양방향 — from/to 어느 쪽이든)
const connectedTables = useMemo<Record<string, any>[]>(() => {
if (!configNodeId) return [];
const tableNodeIds = new Set<string>();
ruleConnections.forEach((c) => {
if (c.from_node_id === configNodeId) tableNodeIds.add(c.to_node_id);
if (c.to_node_id === configNodeId) tableNodeIds.add(c.from_node_id);
});
return ruleNodes.filter((n) => n.type === 'table' && tableNodeIds.has(n.id));
}, [configNodeId, ruleNodes, ruleConnections]);
useEffect(() => {
if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true));
@@ -23,12 +40,14 @@ export function NodeConfigPopover() {
}
}, [configNodeId, node]);
// 외부 클릭 닫기
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!configNodeId) return;
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return;
if ((e.target as HTMLElement).closest('.ctrl-an-body')) return;
const t = e.target as HTMLElement;
if (t.closest('.ctrl-cfg-pop')) return;
if (t.closest('.v3-rule-node')) return;
if (t.closest('.tbl-node')) return;
if (t.closest('.ctrl-an-body')) return;
setConfigNodeId(null);
};
document.addEventListener('click', handler);
@@ -49,52 +68,291 @@ export function NodeConfigPopover() {
style={{ left: node.x + 172, top: node.y }}
>
<div className="cfg-hd">{def.icon} {def.label} </div>
<ConfigForm type={node.type} config={node.config ?? {}} onSave={handleSave} onClose={() => setConfigNodeId(null)} />
<ConfigForm
type={node.type}
config={node.config ?? {}}
connectedTables={connectedTables}
onSave={handleSave}
onClose={() => setConfigNodeId(null)}
/>
</div>
);
}
function ConfigForm({ type, config, onSave, onClose }: {
type: string; config: Record<string, any>;
/* ─── Helpers ─── */
interface ColumnMeta {
tableName: string;
tableLabel: string;
column: string;
label: string;
type: string;
options?: Array<{ value: string; label: string }>;
pk?: boolean;
}
/** 연결된 테이블들의 모든 컬럼을 flat 으로 + 표시 정보 포함 */
function flattenColumns(tables: Record<string, any>[]): ColumnMeta[] {
const out: ColumnMeta[] = [];
tables.forEach((t) => {
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
(t.columns ?? []).forEach((c: Record<string, any>) => {
const colName = c.column ?? c.name ?? c.COLUMN_NAME ?? '';
if (!colName) return;
out.push({
tableName: tName,
tableLabel: tLabel,
column: colName,
label: c.label ?? c.dname ?? colName,
type: c.type ?? c.dtype ?? 'text',
options: c.options,
pk: !!c.pk,
});
});
});
return out;
}
/** fully qualified id ↔ 객체 변환 */
function serializeField(field: any): string {
if (!field) return '';
if (typeof field === 'string') return field; // legacy
if (field.table && field.column) return `${field.table}|${field.column}`;
return '';
}
function deserializeField(s: string): { table: string; column: string } | null {
if (!s || !s.includes('|')) return null;
const [table, column] = s.split('|');
return { table, column };
}
/** field value (string or {table,column}) 으로 ColumnMeta 찾기 */
function findColumn(cols: ColumnMeta[], field: any): ColumnMeta | null {
if (!field) return null;
if (typeof field === 'string') return cols.find((c) => c.column === field) ?? null;
if (field.table && field.column) {
return cols.find((c) => c.tableName === field.table && c.column === field.column) ?? null;
}
return null;
}
/** 한글 라벨 표시 (field) */
function displayField(field: any, cols: ColumnMeta[]): string {
const col = findColumn(cols, field);
if (col) return col.label;
if (typeof field === 'string') return field;
if (field?.column) return field.column;
return '?';
}
/* ─── Reusable pickers ─── */
function FieldPicker({
tables, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
value: any;
onChange: (field: { table: string; column: string }) => void;
placeholder?: string;
}) {
const cols = useMemo(() => flattenColumns(tables), [tables]);
if (tables.length === 0) {
return <div className="cfg-empty"> </div>;
}
const currentId = serializeField(value);
return (
<select
className="cfg-sel"
value={currentId}
onChange={(e) => {
const f = deserializeField(e.target.value);
if (f) onChange(f);
}}
>
<option value="">{placeholder ?? '컬럼 선택...'}</option>
{tables.map((tbl) => {
const tName = tbl.table_name ?? tbl.tableName ?? '';
const tLabel = tbl.label ?? tName;
const tableCols = cols.filter((c) => c.tableName === tName);
if (tableCols.length === 0) return null;
const groupLabel = tLabel !== tName ? `${tLabel} · ${tName}` : tName;
return (
<optgroup key={tName} label={groupLabel}>
{tableCols.map((c) => {
const id = `${c.tableName}|${c.column}`;
const dispLabel = c.label !== c.column ? `${c.label} (${c.column})` : c.column;
return (
<option key={id} value={id}>
{dispLabel}{c.pk ? ' · PK' : ''}{c.type === 'select' ? ' · enum' : ''}
</option>
);
})}
</optgroup>
);
})}
</select>
);
}
function TablePicker({
tables, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
value: any;
onChange: (tableName: string) => void;
placeholder?: string;
}) {
// 자동 채움 — Strict 모드 안전 useEffect (committed lifecycle 에서만 실행)
const single = tables.length === 1
? (tables[0].table_name ?? tables[0].tableName ?? '')
: null;
useEffect(() => {
if (single && value !== single) onChange(single);
}, [single, value, onChange]);
if (tables.length === 0) {
return <div className="cfg-empty"> </div>;
}
// 1개면 자동 readonly
if (tables.length === 1) {
const t = tables[0];
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
return (
<div className="cfg-static">
<span className="cfg-static-main">{tLabel}</span>
{tLabel !== tName && <span className="cfg-static-sub">{tName}</span>}
<span className="cfg-static-hint">()</span>
</div>
);
}
// 2개+ 면 dropdown
const current = typeof value === 'string' ? value : (value?.table ?? '');
return (
<select className="cfg-sel" value={current} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder ?? '테이블 선택...'}</option>
{tables.map((t) => {
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
return (
<option key={tName} value={tName}>
{tLabel}{tLabel !== tName ? ` (${tName})` : ''}
</option>
);
})}
</select>
);
}
function ValuePicker({
tables, fieldRef, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
fieldRef: any; // 어느 컬럼의 값인지
value: any;
onChange: (v: string) => void;
placeholder?: string;
}) {
const cols = useMemo(() => flattenColumns(tables), [tables]);
const col = findColumn(cols, fieldRef);
// enum 컬럼이면 dropdown
if (col?.type === 'select' && col.options && col.options.length > 0) {
return (
<select className="cfg-sel" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder ?? '값 선택...'}</option>
{col.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}{opt.label !== opt.value ? ` (${opt.value})` : ''}
</option>
))}
</select>
);
}
// 기본 typed input
return (
<input
className="cfg-inp"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? (col ? `${col.label}` : '값 입력')}
/>
);
}
/* ─── ConfigForm ─── */
function ConfigForm({
type, config, connectedTables, onSave, onClose,
}: {
type: string;
config: Record<string, any>;
connectedTables: Record<string, any>[];
onSave: (summary: string, config: Record<string, any>) => void;
onClose: () => void;
}) {
const [vals, setVals] = useState<Record<string, any>>(config);
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
const cols = useMemo(() => flattenColumns(connectedTables), [connectedTables]);
const handleSave = () => {
let summary = '';
const fLabel = (f: any) => displayField(f, cols);
const tLabel = (tName: string) => {
const t = connectedTables.find((x) => (x.table_name ?? x.tableName) === tName);
return t?.label ?? tName ?? '?';
};
switch (type) {
case 'condition':
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`;
summary = `${fLabel(vals.field)} ${vals.op || '='} "${vals.value || '?'}"`;
break;
case 'status-change':
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`;
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} → "${vals.value || '?'}"`;
break;
case 'auto-insert':
summary = `${vals.table || '?'} INSERT`;
summary = `${tLabel(vals.table)} INSERT`;
break;
case 'timer':
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`;
summary = `${fLabel(vals.field)} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break;
case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break;
case 'approval':
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`;
summary = `${vals.approver || '팀장'} 승인${vals.condition ? ` (${vals.condition})` : ''}`;
break;
case 'calculation':
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`;
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} = ${vals.formula || '?'}`;
break;
case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break;
case 'validation':
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`;
summary = `${fLabel(vals.field)} ${vals.rule || '필수값'}`;
break;
case 'log':
summary = `로그: ${vals.content || '?'}`;
break;
case 'delete':
summary = `${tLabel(vals.table)} ${vals.mode === 'soft' ? 'soft delete' : 'hard delete'}`;
break;
case 'document':
summary = `${vals.template || '?'}${vals.format || 'pdf'}`;
break;
case 'delay':
summary = `${vals.amount || 0}${vals.unit || '분'} 대기`;
break;
case 'loop':
summary = vals.iterField ? `for each ${vals.iterField}` : `${vals.count || 1}회 반복`;
break;
case 'parallel':
summary = `${vals.branches || 2}개 병렬 실행`;
break;
case 'merge':
summary = vals.strategy === 'all' ? '모든 분기 대기 (all)' : '먼저 도착 (any)';
break;
default:
summary = vals.summary || '설정됨';
}
@@ -103,7 +361,7 @@ function ConfigForm({ type, config, onSave, onClose }: {
return (
<>
{renderFields(type, vals, set)}
{renderFields(type, vals, set, connectedTables)}
<div className="cfg-ft">
<button className="cfg-btn save" onClick={handleSave}></button>
<button className="cfg-btn" onClick={onClose}></button>
@@ -115,21 +373,25 @@ function ConfigForm({ type, config, onSave, onClose }: {
function renderFields(
type: string,
vals: Record<string, any>,
set: (k: string, v: any) => void
set: (k: string, v: any) => void,
tables: Record<string, any>[],
) {
switch (type) {
/* ─── Phase 2 schema-driven 4종 ─── */
case 'condition':
return (
<>
<CfgSec label="필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="STATUS" />
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="비교할 컬럼 선택..." />
</CfgSec>
<CfgSec label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '기한 경과', '포함']} />
options={['=', '≠', '>', '<', '≥', '≤', '포함', '기한 경과']} />
</CfgSec>
<CfgSec label="값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="비교값" />
<ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
onChange={(v) => set('value', v)} />
</CfgSec>
</>
);
@@ -137,27 +399,61 @@ function renderFields(
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="변경 필드">
<CfgInput value={vals.field ?? 'STATUS'} onChange={(v) => set('field', v)} />
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="변경할 컬럼 선택..." />
</CfgSec>
<CfgSec label="변경값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="새 값" />
<ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
onChange={(v) => set('value', v)} placeholder="새 값" />
</CfgSec>
</>
);
case 'calculation':
return (
<>
<CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="결과 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="저장할 컬럼 선택..." />
</CfgSec>
<CfgSec label="수식">
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)}
placeholder="QTY * UNIT_PRICE" />
</CfgSec>
</>
);
case 'validation':
return (
<>
<CfgSec label="대상 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="검증할 컬럼 선택..." />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec>
</>
);
/* ─── 기존 케이스 유지 (테이블 컬럼 의존성 없는 노드들) ─── */
case 'auto-insert':
return (
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
);
case 'timer':
return (
<>
<CfgSec label="기준 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="ORDER_DATE" />
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="시간 기준 컬럼..." />
</CfgSec>
<CfgSec label="경과 기준">
<div style={{ display: 'flex', gap: '.3rem' }}>
@@ -196,20 +492,6 @@ function renderFields(
</CfgSec>
</>
);
case 'calculation':
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
<CfgSec label="결과 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
</CfgSec>
<CfgSec label="수식">
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)} placeholder="QTY * UNIT_PRICE" />
</CfgSec>
</>
);
case 'webhook':
return (
<>
@@ -222,22 +504,91 @@ function renderFields(
</CfgSec>
</>
);
case 'validation':
case 'log':
return (
<>
<CfgSec label="대상 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
<CfgSec label="로그 레벨">
<CfgSelect value={vals.level ?? 'info'} onChange={(v) => set('level', v)}
options={['info', 'warn', 'error', 'debug']} />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
<CfgSec label="내용">
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
</CfgSec>
</>
);
case 'log':
case 'delete':
return (
<CfgSec label="내용">
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
<>
<CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="삭제 방식">
<CfgSelect value={vals.mode ?? 'soft'} onChange={(v) => set('mode', v)}
options={['soft', 'hard']} />
</CfgSec>
<CfgSec label="조건 (WHERE)">
<CfgInput value={vals.where ?? ''} onChange={(v) => set('where', v)} placeholder="id = ?" />
</CfgSec>
</>
);
case 'document':
return (
<>
<CfgSec label="템플릿">
<CfgInput value={vals.template ?? ''} onChange={(v) => set('template', v)} placeholder="출고확인서.docx" />
</CfgSec>
<CfgSec label="출력 경로">
<CfgInput value={vals.output ?? ''} onChange={(v) => set('output', v)} placeholder="/docs/{id}.pdf" />
</CfgSec>
<CfgSec label="포맷">
<CfgSelect value={vals.format ?? 'pdf'} onChange={(v) => set('format', v)}
options={['pdf', 'docx', 'xlsx', 'html']} />
</CfgSec>
</>
);
case 'delay':
return (
<CfgSec label="지연 시간">
<div style={{ display: 'flex', gap: '.3rem' }}>
<CfgInput value={vals.amount ?? '0'} onChange={(v) => set('amount', v)} placeholder="0" />
<CfgSelect value={vals.unit ?? '분'} onChange={(v) => set('unit', v)}
options={['초', '분', '시간', '일']} />
</div>
</CfgSec>
);
case 'loop':
return (
<>
<CfgSec label="반복 방식">
<CfgSelect value={vals.mode ?? 'count'} onChange={(v) => set('mode', v)}
options={['count', 'forEach', 'while']} />
</CfgSec>
{vals.mode === 'forEach' ? (
<CfgSec label="반복 대상 필드">
<FieldPicker tables={tables} value={vals.iterField} onChange={(f) => set('iterField', f)} />
</CfgSec>
) : vals.mode === 'while' ? (
<CfgSec label="조건식">
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="x < 10" />
</CfgSec>
) : (
<CfgSec label="횟수">
<CfgInput value={vals.count ?? '1'} onChange={(v) => set('count', v)} placeholder="1" />
</CfgSec>
)}
</>
);
case 'parallel':
return (
<CfgSec label="병렬 분기 수">
<CfgInput value={vals.branches ?? '2'} onChange={(v) => set('branches', v)} placeholder="2" />
</CfgSec>
);
case 'merge':
return (
<CfgSec label="합류 전략">
<CfgSelect value={vals.strategy ?? 'any'} onChange={(v) => set('strategy', v)}
options={['any', 'all']} />
</CfgSec>
);
default:
@@ -245,6 +596,8 @@ function renderFields(
}
}
/* ─── 공통 atoms ─── */
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="cfg-sec">
+4 -2
View File
@@ -17,15 +17,17 @@ interface PortHandleProps {
}
export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) {
// 단일 동그라미가 mousedown(연결 시작) + mouseup(연결 종료) 둘 다 받음
// (테이블 컬럼 port 처럼 시각적으로 하나만 보이는 경우)
const handleMouseDown = (e: React.MouseEvent) => {
if (type !== 'out' || !onDragStart) return;
if (!onDragStart) return;
e.preventDefault();
e.stopPropagation();
onDragStart(nodeId, port, e);
};
const handleMouseUp = (e: React.MouseEvent) => {
if (type !== 'in' || !onDragEnd) return;
if (!onDragEnd) return;
e.stopPropagation();
onDragEnd(nodeId, port);
};
+81 -34
View File
@@ -56,7 +56,7 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
} else {
try {
const meta = await getMetaFields(d.name);
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8);
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용)
fieldCache[d.name] = cols;
} catch { /* 빈 필드 */ }
}
@@ -88,13 +88,20 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
}, []);
// 노드 좌표에서 포트 위치 계산
const portPos = useCallback((nodeId: string, port: string) => {
// 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') {
if (port === 'in') return { x: node.x, y: node.y + 18 };
return { x: node.x + 200, y: node.y + 18 };
// 테이블 단위 단일 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 };
}
// 제어 노드
@@ -114,14 +121,12 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
}, [ruleNodes]);
return (
<>
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */}
<div
style={{ position: 'absolute', inset: 0, zIndex: 5 }}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
<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>
@@ -137,32 +142,76 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
</defs>
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
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 cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes'
: c.from_port === 'no' ? 'rule-conn-path conn-no'
: 'rule-conn-path';
const marker = c.from_port === 'yes' ? 'url(#arr-yes)'
: c.from_port === 'no' ? 'url(#arr-no)'
: 'url(#arr-rule)';
// 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 (
<path
key={c.id}
d={bezierPath(f.x, f.y, t.x, t.y)}
className={cls}
markerEnd={marker}
/>
<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);
const t = portPos(c.to_node_id, c.to_port);
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;
@@ -199,12 +248,10 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
y={node.y}
onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
style={{ overflow: 'visible' }}
nodeId={node.id}
onPortDragStart={startDrag}
onPortDragEnd={finishDrag}
/>
{/* I/O 포트 */}
<PortHandle nodeId={node.id} port="in" type="in" isTable onDragEnd={finishDrag} />
<div style={{ position: 'absolute', left: node.x + 194, top: node.y + 12 }}>
<PortHandle nodeId={node.id} port="out" type="out" isTable label="→" onDragStart={startDrag} />
</div>
</div>
);
}
@@ -221,6 +268,6 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
{/* 설정 팝오버 */}
<NodeConfigPopover />
</>
</div>
);
}
+82 -30
View File
@@ -1,31 +1,56 @@
'use client';
import { useRef, useCallback } from 'react';
import { Database, X } from 'lucide-react';
import { PortHandle } from './PortHandle';
import { useControlMode } from './hooks/useControlMode';
interface TableNodeProps {
tableName: string;
label: string;
icon: string;
/** 호환용 — 더 이상 사용 X (V3 컴팩트로 갈아엎으면서 이모지 폐기, Lucide Database 아이콘 고정) */
icon?: string;
columns: Record<string, any>[];
x: number;
y: number;
style?: React.CSSProperties;
onMove?: (name: string, x: number, y: number) => void;
/** 룰 노드 ID (PortHandle 연결용). 없으면 시각 카드만 (read-only) */
nodeId?: string;
onPortDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
onPortDragEnd?: (nodeId: string, port: string) => void;
}
export function TableNode({ tableName, label, icon, columns, x, y, style, onMove }: TableNodeProps) {
/**
* 테이블 카드 — V3RuleNode 와 일관된 컴팩트 디자인
* - 180px 폭, cyan top stripe, Lucide Database 아이콘
* - 한글 라벨 메인 + mono 영문 sub
* - stats row: `{N} cols · {K} FK`
* - 좌·우 edge 에 단일 port 1개씩 (테이블 단위 연결 — 컬럼은 노드 설정창 dropdown 에서)
*/
export function TableNode({
tableName, label, columns, x, y, style, onMove, nodeId, onPortDragStart, onPortDragEnd,
}: TableNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null);
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!onMove) return;
const target = e.target as HTMLElement;
if (target.closest('.ctrl-io-port, button')) return;
e.preventDefault();
e.stopPropagation();
const sx = e.clientX, sy = e.clientY;
const sl = x, st = y;
const el = nodeRef.current;
if (el) el.style.zIndex = '30';
let moved = false;
const move = (ev: MouseEvent) => {
onMove(tableName, sl + ev.clientX - sx, st + ev.clientY - sy);
const dx = ev.clientX - sx, dy = ev.clientY - sy;
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
moved = true;
onMove(tableName, sl + dx, st + dy);
};
const up = () => {
if (el) el.style.zIndex = '20';
@@ -36,42 +61,69 @@ export function TableNode({ tableName, label, icon, columns, x, y, style, onMove
document.addEventListener('mouseup', up);
}, [onMove, tableName, x, y]);
const dtypeIcons: Record<string, string> = {
text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡',
textarea: 'Aa', datetime: '📅', entity: '🔗',
};
// stats
const totalCols = columns?.length ?? 0;
const fkCount = (columns ?? []).filter((c) => c.mark === 'FK' || c.type === 'entity').length;
const pkCount = (columns ?? []).filter((c) => c.pk).length;
const hasKoLabel = label && label !== tableName;
return (
<div
ref={nodeRef}
className="tbl-node"
className="tbl-node tbl-node-compact"
data-table={tableName}
data-node-id={nodeId}
onMouseDown={handleMouseDown}
style={{ left: x, top: y, ...style }}
>
<div className="tbl-node-head" onMouseDown={handleMouseDown}>
<div className="tbl-icon">{icon}</div>
<span className="tbl-name">{tableName}</span>
<span className="tbl-badge">{label}</span>
</div>
<div className="tbl-node-cols">
{columns.map((col) => {
const name = col.column ?? col.name ?? col.COLUMN_NAME ?? '';
const type = col.type ?? col.dtype ?? 'text';
const mark = col.pk ? 'PK' : col.mark === 'FK' ? 'FK' : '';
const portCls = mark === 'PK' ? 'pk' : mark === 'FK' ? 'fk' : '';
const displayName = col.label ?? col.dname ?? name;
const dtIcon = dtypeIcons[type] || 'Aa';
{/* cyan top stripe (V3RuleNode cat-stripe 와 일관) */}
<div className="tbl-node-stripe" />
return (
<div key={name} className="tbl-col" data-col={name}>
<div className={`tbl-port ${portCls}`} />
<span className="tbl-col-name">{displayName}</span>
<span className="tbl-col-type">{dtIcon} {type}</span>
{mark && <span className={`tbl-col-mark ${mark.toLowerCase()}`}>{mark}</span>}
</div>
);
})}
<div className="tbl-node-head">
<div className="tbl-node-ico"><Database size={11} /></div>
<div className="tbl-node-title">
<div className="tbl-node-label">{hasKoLabel ? label : tableName}</div>
{hasKoLabel && <div className="tbl-node-sub">{tableName}</div>}
</div>
{nodeId && (
<button
type="button"
className="tbl-node-del"
title="삭제"
onClick={(e) => { e.stopPropagation(); removeRuleNode(nodeId); }}
>
<X size={10} />
</button>
)}
</div>
<div className="tbl-node-stats">
<span>{totalCols} cols</span>
{pkCount > 0 && <span>· {pkCount} PK</span>}
{fkCount > 0 && <span>· {fkCount} FK</span>}
</div>
{/* 좌·우 단일 port — 테이블 단위 연결 (컬럼 선택은 노드 설정창 dropdown) */}
{nodeId && (
<>
<PortHandle
nodeId={nodeId}
port="in"
type="in"
onDragEnd={onPortDragEnd}
onDragStart={onPortDragStart}
/>
<div className="ctrl-an-ports-out">
<PortHandle
nodeId={nodeId}
port="out"
type="out"
onDragStart={onPortDragStart}
onDragEnd={onPortDragEnd}
/>
</div>
</>
)}
</div>
);
}
@@ -34,9 +34,11 @@ export const CTRL_NODE_TYPES: Record<string, {
interface ControlModeState {
/** 제어 모드 활성 여부 */
active: boolean;
/** 읽기 / 편집 모드 */
mode: 'view' | 'edit';
/** 활성 흐름 — 클릭된 카드 ID */
/** 읽기 / 편집 / 실행 / 이력 모드 (선택된 카드 컨텍스트 안의 토글, v3 — IDE 4-segmented tabs) */
mode: 'view' | 'edit' | 'run' | 'history';
/** 선택된 카드 ID — 카드 클릭 시 좌측 축소 + 그 옆에 제어 패널 */
selectedCardId: string | null;
/** 활성 흐름 — FlowViewer 내부 상태 (selectedCardId 와 동기화) */
activeFlowCardId: string | null;
/** 흐름 엣지 배열 (BFS 결과) */
flowEdges: Record<string, any>[];
@@ -55,7 +57,8 @@ interface ControlModeState {
// 액션
toggleControlMode: () => void;
setMode: (mode: 'view' | 'edit') => void;
setMode: (mode: 'view' | 'edit' | 'run' | 'history') => void;
setSelectedCardId: (cardId: string | null) => void;
setActiveFlowCard: (cardId: string | null) => void;
setFlowEdges: (edges: Record<string, any>[]) => void;
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
@@ -82,6 +85,7 @@ export const useControlMode = create<ControlModeState>()(
(set) => ({
active: false,
mode: 'view',
selectedCardId: null,
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
@@ -94,14 +98,29 @@ export const useControlMode = create<ControlModeState>()(
set((s) => ({
active: !s.active,
mode: 'view',
selectedCardId: null,
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
ruleNodes: [],
ruleConnections: [],
configNodeId: null,
})),
setMode: (mode) => set({ mode, configNodeId: null }),
setSelectedCardId: (cardId) =>
set({
selectedCardId: cardId,
// 카드 바꾸면 모드/룰 초기화 (각 카드는 자기 제어 컨텍스트)
mode: 'view',
activeFlowCardId: cardId,
ruleNodes: [],
ruleConnections: [],
activeRuleId: null,
configNodeId: null,
}),
setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }),
setFlowEdges: (edges) => set({ flowEdges: edges }),
@@ -152,6 +171,7 @@ export const useControlMode = create<ControlModeState>()(
set({
active: false,
mode: 'view',
selectedCardId: null,
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
@@ -59,24 +59,58 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
cleanup();
return;
}
// 중복 방지
if (ruleConnections.find((c) =>
// ★ [HIGH] port direction validation — output → output 역방향 엣지 차단
// from_port 는 in/out/yes/no/pass/fail/approved/rejected 등 (output port 만 허용)
// to_port 는 in 만 허용 (input port 도착점)
// 단 테이블 port 는 양방향 (in/out 둘 다 가능, PortHandle 단일 dot 양방향화)
// → 노드 type 으로 분기
const stateForValidate = useControlMode.getState();
const fromNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === d.fromNodeId);
const toNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === toNodeId);
// 도착이 action 노드면 to_port 는 'in' 이어야 함 (action 노드는 좌측 in 만 mouseup 받음)
if (toNodeForVal && toNodeForVal.type !== 'table' && toPort !== 'in') {
cleanup();
return;
}
// 출발이 action 노드면 from_port 는 in 이 아니어야 함 (action 노드의 in 에서 시작은 의미 없음)
if (fromNodeForVal && fromNodeForVal.type !== 'table' && d.fromPort === 'in') {
cleanup();
return;
}
// 중복 방지 — getState() 로 최신 ruleConnections 사용 (render-captured stale 회피)
const currentConns = stateForValidate.ruleConnections;
if (currentConns.find((c) =>
c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
)) {
cleanup();
return;
}
// Phase 3: edge_type 자동 추론 (위 validation 에서 가져온 노드 재사용)
// table → table = lookup (FK 참조)
// table → action = data-context (테이블 데이터를 노드 입력으로)
// action → table = table-mutation (노드 결과를 테이블에 저장/수정)
// action → action = execution-flow (실행 순서)
const fromIsTable = fromNodeForVal?.type === 'table';
const toIsTable = toNodeForVal?.type === 'table';
let edgeType: 'data-context' | 'execution-flow' | 'table-mutation' | 'lookup';
if (fromIsTable && toIsTable) edgeType = 'lookup';
else if (fromIsTable && !toIsTable) edgeType = 'data-context';
else if (!fromIsTable && toIsTable) edgeType = 'table-mutation';
else edgeType = 'execution-flow';
addRuleConnection({
id: genConnId(),
from_node_id: d.fromNodeId,
from_port: d.fromPort,
to_node_id: toNodeId,
to_port: toPort,
edge_type: edgeType,
});
cleanup();
}, [addRuleConnection, ruleConnections]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [addRuleConnection]);
const cleanup = useCallback(() => {
const d = dragRef.current;
@@ -89,6 +123,8 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
}, [canvasRef]);
// 마우스 이동/종료 전역 핸들러
// ★ mouseup 시 e.target 의 closest .ctrl-io-port 를 직접 찾아서 finishDrag 호출
// (PortHandle 의 onMouseUp 에 의존하면 race + 6px hit-target 문제로 연결 실패)
useEffect(() => {
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
@@ -99,10 +135,43 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
const x2 = e.clientX - cr.left + cv.scrollLeft;
const y2 = e.clientY - cr.top + cv.scrollTop;
d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2));
// 호버 중인 port 강조
document.querySelectorAll('.ctrl-io-port.port-hover').forEach((el) => el.classList.remove('port-hover'));
const hoverPort = (e.target as HTMLElement)?.closest?.('.ctrl-io-port') as HTMLElement | null;
if (hoverPort && hoverPort.dataset.node !== d.fromNodeId) {
hoverPort.classList.add('port-hover');
}
};
const onUp = () => {
if (dragRef.current) cleanup();
const onUp = (e: MouseEvent) => {
if (!dragRef.current) return;
// ① e.target 의 closest 로 port 찾기 (정확히 port 위에서 mouseup 한 경우)
let portEl = (e.target as HTMLElement | null)?.closest?.('.ctrl-io-port') as HTMLElement | null;
// ② 못 찾으면 마우스 좌표 주변 20px 내 가장 가까운 port 검색 (port 근처에서 mouseup)
if (!portEl) {
const candidates = document.querySelectorAll<HTMLElement>('.ctrl-io-port');
let best: { el: HTMLElement; dist: number } | null = null;
candidates.forEach((el) => {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const dx = e.clientX - cx, dy = e.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 24 && (!best || dist < best.dist)) {
best = { el, dist };
}
});
if (best) portEl = (best as { el: HTMLElement; dist: number }).el;
}
if (portEl) {
const toNodeId = portEl.dataset.node;
const toPort = portEl.dataset.port;
if (toNodeId && toPort) {
finishDrag(toNodeId, toPort);
return;
}
}
cleanup();
};
document.addEventListener('mousemove', onMove);
@@ -111,7 +180,7 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [canvasRef, cleanup]);
}, [canvasRef, cleanup, finishDrag]);
return { startDrag, finishDrag };
}
+396
View File
@@ -0,0 +1,396 @@
'use client';
/**
* Canvas — 4-모드 중앙 캔버스 (v3 V3Canvas / V3ViewCanvas / V3EditCanvas / V3RunCanvas / V3HistoryCanvas)
*
* view : 관계 트리 (listRelations API)
* edit : 룰 에디터 (기존 RuleBuilder 호출, 단계 6 에서 PanZoomStage 베이스로 갈아끼움)
* run : 단계별 실행 시각화 (mock 진행)
* history : 실행 이력 테이블 (listExecutionHistory API)
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Table2, History as HistoryIcon, Play, Pause, SkipBack, SkipForward,
ChevronLeft, ChevronRight, Check,
} from 'lucide-react';
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { PanZoomStage } from './PanZoomStage';
import { RuleBuilder } from '../RuleBuilder';
import {
listRelations, listExecutionHistory,
type TableRelation, type ExecutionRecord,
} from '@/lib/api/control';
interface CanvasProps {
card: Record<string, any>;
/** DashboardCanvas ref (호환용, IDE EditCanvas 는 자체 ref 사용) */
canvasRef: React.RefObject<HTMLDivElement | null>;
dashboardId: string;
}
export function Canvas({ card, dashboardId }: CanvasProps) {
const mode = useControlMode((s) => s.mode);
return (
<div className="ctrl-ide-canvas-inner">
{mode === 'view' && <ViewCanvas card={card} dashboardId={dashboardId} />}
{mode === 'edit' && <EditCanvas />}
{mode === 'run' && <RunCanvas />}
{mode === 'history' && <HistoryCanvas card={card} />}
</div>
);
}
/* ─── VIEW — 관계 트리 ─── */
function ViewCanvas({ card }: { card: Record<string, any>; dashboardId: string }) {
const tableName = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const cardTitle = card.title ?? card.TITLE ?? '카드';
const [rels, setRels] = useState<TableRelation[]>([]);
useEffect(() => {
if (!tableName) return;
let alive = true;
listRelations(tableName).then((r) => { if (alive) setRels(r); });
return () => { alive = false; };
}, [tableName]);
const W = 1000, H = 540;
const targets = useMemo(() => {
if (rels.length === 0) return [];
return rels.map((r, i) => {
const t = rels.length === 1 ? 0.5 : i / (rels.length - 1);
return { x: 750, y: 80 + t * 380, name: r.to, type: r.type, edgeLabel: r.label };
});
}, [rels]);
return (
<PanZoomStage width={W} height={H}>
<svg width={W} height={H} style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="v3-dots" width={16} height={16} patternUnits="userSpaceOnUse">
<circle cx={1} cy={1} r={0.7} fill="rgba(var(--v5-cyan-rgb), .16)" />
</pattern>
</defs>
<rect width={W} height={H} fill="url(#v3-dots)" />
{/* edges */}
{targets.map((t, i) => {
const isAuto = t.type === 'auto';
const rgb = isAuto ? '108,92,231' : '0,154,150';
const x1 = 250, y1 = H / 2, x2 = t.x - 100, y2 = t.y;
const mx = (x1 + x2) / 2;
return (
<g key={i}>
<path
d={`M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`}
stroke={`rgb(${rgb})`} strokeWidth={2} opacity={0.5}
fill="none" strokeDasharray={isAuto ? '0' : '6 4'}
/>
<g transform={`translate(${mx}, ${(y1 + y2) / 2 - 10})`}>
<rect x={-40} y={-10} width={80} height={20} rx={10}
fill="var(--v5-surface-solid)" stroke={`rgba(${rgb}, .45)`} strokeWidth={1.2} />
<text y={4} textAnchor="middle" fontSize={10} fontWeight={700} fill={`rgb(${rgb})`}>
{t.edgeLabel}
</text>
</g>
</g>
);
})}
{/* source highlight */}
<rect x={50} y={H / 2 - 56} width={200} height={112} rx={12}
fill="rgba(var(--v5-cyan-rgb), .05)" stroke="rgb(var(--v5-cyan-rgb))" strokeWidth={2} />
</svg>
{/* source label */}
<div style={{
position: 'absolute', left: 50, top: H / 2 - 56, width: 200, height: 112,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
}}>
<div className="ctrl-canvas-tag" style={{ color: 'rgb(0, 154, 150)' }}>SOURCE</div>
<div style={{ fontSize: '.85rem', fontWeight: 800, letterSpacing: '-.01em' }}>{cardTitle}</div>
<div className="ctrl-canvas-mono">{tableName || '—'}</div>
</div>
{/* target nodes */}
{targets.map((t) => (
<div key={t.name} className="ctrl-canvas-relnode" style={{
position: 'absolute', left: t.x - 100, top: t.y - 36, width: 200,
borderColor: t.type === 'auto'
? 'rgba(var(--v5-primary-rgb), .5)'
: 'rgba(var(--v5-cyan-rgb), .5)',
}}>
<div className="ctrl-canvas-tag" style={{
color: t.type === 'auto' ? 'rgb(var(--v5-primary-rgb))' : 'rgb(0, 154, 150)',
marginBottom: 5, display: 'flex', alignItems: 'center', gap: 5,
}}>
<Table2 size={10} />
{t.type === 'auto' ? 'AUTO' : 'FK'}
</div>
<div style={{ fontSize: '.78rem', fontWeight: 700, marginBottom: 4 }}>{t.name}</div>
<div className="ctrl-canvas-mono">
{t.type === 'auto' ? '동기화' : '참조'}
</div>
</div>
))}
{targets.length === 0 && (
<div className="ctrl-canvas-empty">
<small>API: GET /api/control/tables/{tableName}/relations</small>
</div>
)}
</PanZoomStage>
);
}
/* ─── EDIT — RuleBuilder 위임 (컬럼별 마우스 연결 + 노드 드래그 + 팔레트 드롭) ─── */
function EditCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
return (
<div ref={canvasRef} className="ctrl-edit-canvas-host">
<RuleBuilder canvasRef={canvasRef} />
</div>
);
}
/* ─── RUN — 단계별 실행 시각화 (mock 진행) ─── */
function RunCanvas() {
const ruleNodes = useControlMode((s) => s.ruleNodes);
const [playState, setPlayState] = useState<'paused' | 'playing'>('paused');
const [playStep, setPlayStep] = useState(0);
const totalSteps = Math.max(ruleNodes.length, 1);
const current = Math.min(playStep, totalSteps);
useEffect(() => {
if (playState !== 'playing') return;
if (current >= totalSteps) return;
const t = setTimeout(() => setPlayStep((s) => s + 1), 700);
return () => clearTimeout(t);
}, [playState, current, totalSteps]);
return (
<div className="ctrl-run-shell">
{/* top — playback controls */}
<div className="ctrl-run-top">
<div className={`ctrl-run-state ${playState}`}>
{playState === 'playing' ? <Play size={16} /> : <Pause size={16} />}
</div>
<div>
<div className={`ctrl-canvas-tag ${playState === 'playing' ? 'is-play' : 'is-pause'}`}>
{playState === 'playing' ? 'LIVE TRACE · 재생 중' : 'LIVE TRACE · 일시정지'}
</div>
<div style={{ fontSize: '.92rem', fontWeight: 700, marginTop: 2 }}>
{ruleNodes.length === 0 ? '룰 없음' : `노드 ${ruleNodes.length}`}
</div>
</div>
<div style={{ flex: 1 }} />
<div className="ctrl-run-btns">
<PlayBtn Ic={SkipBack} onClick={() => setPlayStep(0)} title="처음" />
<PlayBtn Ic={ChevronLeft} onClick={() => setPlayStep((s) => Math.max(0, s - 1))} title="이전" />
<PlayBtn
Ic={playState === 'playing' ? Pause : Play}
primary
onClick={() => setPlayState((p) => (p === 'playing' ? 'paused' : 'playing'))}
title={playState === 'playing' ? '일시정지' : '재생'}
/>
<PlayBtn Ic={ChevronRight} onClick={() => setPlayStep((s) => Math.min(totalSteps, s + 1))} title="다음" />
<PlayBtn Ic={SkipForward} onClick={() => setPlayStep(totalSteps)} title="끝" />
</div>
<div className="ctrl-run-counter">
<div className="ctrl-run-counter-num">{current}/{totalSteps}</div>
<div className="ctrl-canvas-mono">{Math.round((current / totalSteps) * 100)}%</div>
</div>
</div>
{/* progress */}
<div className="ctrl-run-progress">
<div className="ctrl-run-progress-bar" style={{ width: `${(current / totalSteps) * 100}%` }} />
</div>
{/* steps */}
<div className="ctrl-run-steps">
{ruleNodes.length === 0 && (
<div className="ctrl-canvas-empty">
EDIT
</div>
)}
{ruleNodes.map((n, i) => {
const def = CTRL_NODE_TYPES[n.type];
const rgb = def?.rgb ?? '108,92,231';
const done = i < current;
const active = i === current - 1 && playState === 'playing';
const pending = i >= current;
return (
<div
key={n.id}
className={`ctrl-run-step ${active ? 'is-active' : ''} ${done ? 'is-done' : ''} ${pending ? 'is-pending' : ''}`}
>
<div
className="ctrl-run-step-num"
style={{
background: done ? 'var(--v5-green)' : active ? `rgb(${rgb})` : 'var(--v5-bg-subtle)',
color: done || active ? '#fff' : 'var(--v5-text-muted)',
boxShadow: active ? `0 0 12px rgba(${rgb}, .5)` : 'none',
}}
>
{done ? <Check size={10} /> : i + 1}
</div>
<div
className="ctrl-run-step-ico"
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}
>
{def?.icon ?? '?'}
</div>
<div>
<div style={{ fontSize: '.73rem', fontWeight: 700 }}>{n.label ?? def?.label ?? n.type}</div>
<div className="ctrl-canvas-mono">{n.summary?.[0] ?? ''}</div>
</div>
<LatencyBar ms={Math.round(20 + Math.random() * 60)} max={100} />
<span className={`ctrl-run-step-status ${active ? 'is-active' : ''}`}>
{done ? '완료' : active ? '진행 중…' : '대기'}
</span>
</div>
);
})}
</div>
</div>
);
}
function PlayBtn({
Ic, onClick, primary, title,
}: { Ic: any; onClick: () => void; primary?: boolean; title: string }) {
return (
<button
onClick={onClick}
title={title}
className={`ctrl-run-btn${primary ? ' primary' : ''}`}
>
<Ic size={11} />
</button>
);
}
function LatencyBar({ ms, max }: { ms: number; max: number }) {
const pct = Math.min(100, (ms / max) * 100);
const color = pct < 50 ? 'var(--v5-green)' : pct < 80 ? 'var(--v5-amber)' : 'var(--v5-red)';
return (
<div className="ctrl-latency-bar" title={`${ms}ms`}>
<div style={{ width: `${pct}%`, background: color }} />
<span>{ms}ms</span>
</div>
);
}
/* ─── HISTORY — 실행 이력 테이블 ─── */
function HistoryCanvas({ card }: { card: Record<string, any> }) {
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
const [items, setItems] = useState<ExecutionRecord[]>([]);
const [filter, setFilter] = useState<'all' | 'ok' | 'fail'>('all');
useEffect(() => {
if (!cardId) return;
let alive = true;
listExecutionHistory(cardId, { limit: 50 }).then((r) => { if (alive) setItems(r); });
return () => { alive = false; };
}, [cardId]);
const filtered = useMemo(() => {
if (filter === 'all') return items;
if (filter === 'ok') return items.filter((i) => i.ok);
return items.filter((i) => !i.ok);
}, [items, filter]);
const okCount = items.filter((i) => i.ok).length;
const failCount = items.length - okCount;
return (
<div className="ctrl-history-shell">
<div className="ctrl-history-top">
<div className="ctrl-history-tag">
<HistoryIcon size={11} />
EXECUTION HISTORY
</div>
<div className="ctrl-canvas-mono">
<b>{items.length}</b> · 24h
</div>
<div style={{ flex: 1 }} />
<select
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'ok' | 'fail')}
className="ctrl-history-select"
>
<option value="all"> ({items.length})</option>
<option value="ok"> ({okCount})</option>
<option value="fail"> ({failCount})</option>
</select>
</div>
<div className="ctrl-history-body">
<table className="ctrl-history-table">
<thead>
<tr>
<th></th>
<th>TS</th>
<th>TRIGGER</th>
<th>WHO</th>
<th style={{ textAlign: 'right' }}>STEPS</th>
<th style={{ textAlign: 'right' }}>LATENCY</th>
<th>RESULT</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map((ex) => (
<tr key={ex.id}>
<td>
<span
className="ctrl-history-dot"
style={{
background: ex.ok ? 'var(--v5-green)' : 'var(--v5-red)',
boxShadow: ex.ok
? '0 0 6px var(--v5-green)'
: '0 0 6px var(--v5-red)',
}}
/>
</td>
<td className="ctrl-history-mono">{ex.ts}</td>
<td className="ctrl-history-mono">{ex.trig}</td>
<td className="ctrl-history-mono ctrl-history-sec">{ex.who}</td>
<td className="ctrl-history-mono" style={{ textAlign: 'right' }}>{ex.steps}/8</td>
<td style={{ textAlign: 'right' }}>
<LatencyBar ms={ex.ms} max={400} />
</td>
<td>
<span className={`ctrl-history-result ${ex.ok ? 'ok' : 'fail'}`}>
{ex.ok ? 'OK' : 'FAIL'}
</span>
</td>
<td>
<button className="ctrl-history-more">
<ChevronRight size={11} />
</button>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={8}>
<div className="ctrl-canvas-empty" style={{ position: 'static' }}>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import {
Eye, Pencil, Play, History, Zap, LayoutDashboard,
Save, Undo2, FolderOpen, Search, X,
} from 'lucide-react';
import { useControlMode } from '../hooks/useControlMode';
import { listPresence, type PresenceUser } from '@/lib/api/control';
interface ContextBarProps {
selectedCard: Record<string, any>;
onExit: () => void; // 카드 닫기 (제어 유지)
onCtrlExit: () => void; // 제어 종료
}
const MODE_TABS = [
{ k: 'view' as const, Ic: Eye, label: 'READ' },
{ k: 'edit' as const, Ic: Pencil, label: 'EDIT' },
{ k: 'run' as const, Ic: Play, label: 'RUN' },
{ k: 'history' as const, Ic: History, label: 'HISTORY' },
];
export function ContextBar({ selectedCard, onExit, onCtrlExit }: ContextBarProps) {
const mode = useControlMode((s) => s.mode);
const setMode = useControlMode((s) => s.setMode);
const [presence, setPresence] = useState<PresenceUser[]>([]);
useEffect(() => {
let alive = true;
listPresence('').then((p) => { if (alive) setPresence(p); });
return () => { alive = false; };
}, []);
const tableName = selectedCard.primary_table ?? selectedCard.PRIMARY_TABLE ?? '';
const cardTitle = selectedCard.title ?? selectedCard.TITLE ?? '카드';
const dirtyCount = 0; // TODO 단계 6에서 store 도입
return (
<div className="ctrl-ide-ctxbar">
{/* 좌측 — 배지 + brumb */}
<div className="ctrl-ide-badge">
<Zap size={10} strokeWidth={2.5} />
CONTROL IDE
</div>
<span className="ctrl-ide-sep">/</span>
<button className="ctrl-ide-tool" disabled>
<LayoutDashboard size={11} />
</button>
<span className="ctrl-ide-sep">/</span>
<div className="ctrl-ide-crumb-card">
{cardTitle}
{tableName && <span className="ctrl-ide-crumb-tbl">{tableName}</span>}
</div>
<div style={{ flex: 1 }} />
{/* presence stack — 빈 배열이면 미렌더 */}
{presence.length > 0 && (
<>
<div className="ctrl-presence">
{presence.slice(0, 4).map((p, i) => (
<span
key={i}
className={`ctrl-presence-avatar${p.mode === 'edit' ? ' is-edit' : ''}`}
style={{ background: `rgb(${p.color})` }}
title={`${p.name} · ${p.mode === 'edit' ? '편집중' : '보는중'}`}
>
{p.short}
</span>
))}
{presence.length > 4 && (
<span className="ctrl-presence-more">+{presence.length - 4}</span>
)}
</div>
<span className="ctrl-ide-vsep" aria-hidden="true" />
</>
)}
{/* cmd-k */}
<button className="ctrl-ide-tool ctrl-ide-cmdk" title="명령 팔레트 (⌘K)">
<Search size={10} />
K
</button>
<span className="ctrl-ide-vsep" aria-hidden="true" />
{/* mode 4-segmented tabs */}
<div className="ctrl-ide-mode-tabs">
{MODE_TABS.map(({ k, Ic, label }) => (
<button
key={k}
className={`ctrl-ide-mode-tab${mode === k ? ' on' : ''}`}
onClick={() => setMode(k)}
>
<Ic size={10} />
{label}
</button>
))}
</div>
{/* toolbar */}
<button className="ctrl-ide-tool" title="불러오기">
<FolderOpen size={11} />
<span></span>
</button>
<button className={`ctrl-ide-tool${dirtyCount > 0 ? ' primary' : ''}`} title="저장">
<Save size={11} />
<span>{dirtyCount > 0 ? `저장 · ${dirtyCount}` : '저장'}</span>
</button>
<button className="ctrl-ide-tool" title="되돌리기">
<Undo2 size={11} />
</button>
<span className="ctrl-ide-vsep" aria-hidden="true" />
{/* 카드 닫기 (제어 유지) */}
<button
onClick={onExit}
title="닫고 대시보드로 (제어 유지)"
className="ctrl-ide-tool ctrl-ide-close"
>
<X size={11} />
<span></span>
</button>
{/* 제어 종료 */}
<button
onClick={onCtrlExit}
title="제어 모드 종료"
className="ctrl-ide-tool ctrl-ide-exit"
>
</button>
</div>
);
}
@@ -0,0 +1,21 @@
'use client';
import { X, Zap } from 'lucide-react';
interface CtrlFabProps {
onExit: () => void;
}
export function CtrlFab({ onExit }: CtrlFabProps) {
return (
<div className="ctrl-fab">
<span className="ctrl-fab-dot" />
<Zap size={11} strokeWidth={2.5} />
<span> </span>
<span className="ctrl-fab-sep" />
<button onClick={onExit} className="ctrl-fab-x" title="제어 종료">
<X />
</button>
</div>
);
}
@@ -0,0 +1,229 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search, LayoutDashboard, Boxes, Database, ChevronRight } from 'lucide-react';
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { NODE_CATEGORIES, ctrlCatToV3, getNodeIcon } from '../schemas';
import { getMetaTableList } from '@/lib/api/meta';
interface LeftRailProps {
cards: Record<string, any>[];
selectedCardId: string;
}
/**
* LeftRail — v3 V3LeftRail 베이스 + invyone 테이블 팔레트
*
* 섹션:
* 1) 이 대시보드의 카드
* 2) DB 테이블 — 한글 라벨 가나다순 우선, 영문 name 보조. 이모티콘 / 추천 화이트리스트 없음
* 3) 노드 팔레트 (edit 모드만)
*
* dataTransfer 포맷: text/plain = JSON({ kind: 'table'|'control', name|type })
*/
export function LeftRail({ cards, selectedCardId }: LeftRailProps) {
const mode = useControlMode((s) => s.mode);
const setSelectedCardId = useControlMode((s) => s.setSelectedCardId);
const [query, setQuery] = useState('');
const [tables, setTables] = useState<Record<string, any>[]>([]);
useEffect(() => {
if (mode !== 'edit') return;
getMetaTableList().then(setTables).catch(() => {});
}, [mode]);
const { sortedTables, nodeEntries } = useMemo(() => {
const q = query.trim().toLowerCase();
// 테이블 필터 + 정렬
const filtered = q
? tables.filter((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase();
return name.includes(q) || label.includes(q);
})
: tables;
// 정렬: 한글 label 있는 것 가나다순 → label 없는 것 (영문 name) 알파벳순
const koCollator = new Intl.Collator('ko');
const sorted = [...filtered].sort((a, b) => {
const aLabel = String(a.table_label ?? a.TABLE_LABEL ?? '');
const bLabel = String(b.table_label ?? b.TABLE_LABEL ?? '');
const aName = String(a.table_name ?? a.TABLE_NAME ?? '');
const bName = String(b.table_name ?? b.TABLE_NAME ?? '');
const aHasKo = !!aLabel && aLabel !== aName;
const bHasKo = !!bLabel && bLabel !== bName;
if (aHasKo !== bHasKo) return aHasKo ? -1 : 1;
if (aHasKo) return koCollator.compare(aLabel, bLabel);
return aName.localeCompare(bName);
});
// 노드 필터
const filteredNodes = Object.entries(CTRL_NODE_TYPES).filter(([type, def]) => {
if (!q) return true;
return def.label.toLowerCase().includes(q) || type.toLowerCase().includes(q);
});
return { sortedTables: sorted, nodeEntries: filteredNodes };
}, [tables, query]);
/** 드래그 시작 — text/plain JSON, EditCanvas.handleCanvasDrop 와 호환 */
const onDragTable = (e: React.DragEvent, name: string) => {
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name }));
e.dataTransfer.effectAllowed = 'copy';
};
const onDragNode = (e: React.DragEvent, type: string) => {
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'control', type }));
e.dataTransfer.effectAllowed = 'copy';
};
const renderTableItem = (t: Record<string, any>) => {
const name = t.table_name ?? t.TABLE_NAME;
const rawLabel = t.table_label ?? t.TABLE_LABEL;
const hasKoLabel = !!rawLabel && rawLabel !== name;
return (
<div
key={name}
className="ctrl-rail-tbl"
draggable
title={`${rawLabel ?? name}${hasKoLabel ? ` (${name})` : ''} — 캔버스로 드래그`}
onDragStart={(e) => onDragTable(e, name)}
>
<Database size={11} className="ctrl-rail-tbl-ico" />
<span className="ctrl-rail-tbl-main">
<span className="ctrl-rail-tbl-label">{hasKoLabel ? rawLabel : name}</span>
{hasKoLabel && <span className="ctrl-rail-tbl-sub">{name}</span>}
</span>
</div>
);
};
return (
<div className="ctrl-ide-leftrail">
{/* 검색 */}
<div className="ctrl-rail-search">
<Search size={11} />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="테이블 / 노드 검색…"
/>
</div>
{/* ① 카드 */}
<RailSection ic={<LayoutDashboard size={11} />} title="이 대시보드의 카드" count={cards.length}>
<div className="ctrl-rail-cards">
{cards.map((c) => {
const id = c.card_id ?? c.CARD_ID ?? c.id;
const title = c.title ?? c.TITLE ?? '카드';
const table = c.primary_table ?? c.PRIMARY_TABLE ?? '';
const sel = id === selectedCardId;
return (
<button
key={id}
type="button"
className={`ctrl-rail-card${sel ? ' on' : ''}`}
onClick={() => setSelectedCardId(id)}
>
<div className="ctrl-rail-card-ico">
<Database size={12} />
</div>
<div className="ctrl-rail-card-body">
<div className="ctrl-rail-card-title">{title}</div>
{table && <div className="ctrl-rail-card-tbl">{table}</div>}
</div>
{sel && <ChevronRight size={10} className="ctrl-rail-card-chev" />}
</button>
);
})}
{cards.length === 0 && <div className="ctrl-rail-empty"> </div>}
</div>
</RailSection>
{/* ② DB 테이블 (edit 모드일 때만) — 한글 라벨 가나다순 우선, 이모티콘 없음 */}
{mode === 'edit' && (
<RailSection ic={<Database size={11} />} title="DB 테이블" count={sortedTables.length} expand>
<div className="ctrl-rail-tbls">
{sortedTables.map((t) => renderTableItem(t))}
{sortedTables.length === 0 && query && (
<div className="ctrl-rail-empty"> </div>
)}
{sortedTables.length === 0 && !query && tables.length === 0 && (
<div className="ctrl-rail-empty"> </div>
)}
</div>
</RailSection>
)}
{/* ③ 노드 팔레트 (edit 모드만) */}
{mode === 'edit' && (
<RailSection ic={<Boxes size={11} />} title="노드 팔레트" count={Object.keys(CTRL_NODE_TYPES).length} expand>
<div className="ctrl-rail-nodes">
{NODE_CATEGORIES.map((cat) => {
const items = nodeEntries.filter(([, def]) => ctrlCatToV3(def.cat) === cat.id);
if (items.length === 0) return null;
return (
<div key={cat.id} className="ctrl-rail-nodecat">
<div className="ctrl-rail-cat-label" style={{ color: `rgb(${cat.rgb})` }}>
<span className="ctrl-rail-cat-dot" style={{ background: `rgb(${cat.rgb})` }} />
<span>{cat.label}</span>
<span className="ctrl-rail-cat-count">{items.length}</span>
</div>
<div className="ctrl-rail-nodes-grid">
{items.map(([type, def]) => {
const Ic = getNodeIcon(type);
return (
<div
key={type}
className="ctrl-rail-node"
draggable
onDragStart={(e) => onDragNode(e, type)}
title={`${def.label} (${type}) — 캔버스로 드래그`}
>
<Ic size={10} style={{ color: `rgb(${def.rgb})`, flexShrink: 0 }} />
<span className="ctrl-rail-node-label">{def.label}</span>
</div>
);
})}
</div>
</div>
);
})}
{nodeEntries.length === 0 && (
<div className="ctrl-rail-empty"> </div>
)}
</div>
</RailSection>
)}
{mode !== 'edit' && (
<div className="ctrl-rail-hint">
<Boxes size={14} />
<span>EDIT DB / </span>
</div>
)}
</div>
);
}
function RailSection({
ic, title, count, expand, children,
}: {
ic: React.ReactNode;
title: string;
count: number;
expand?: boolean;
children: React.ReactNode;
}) {
return (
<div className={`ctrl-rail-sec${expand ? ' expand' : ''}`}>
<div className="ctrl-rail-sec-head">
{ic}
<span className="ctrl-rail-sec-title">{title}</span>
<span className="ctrl-rail-sec-count">{count}</span>
</div>
<div className="ctrl-rail-sec-body">{children}</div>
</div>
);
}
@@ -0,0 +1,182 @@
'use client';
/**
* PanZoomStage — 휠 줌 + 드래그 팬 + 드롭 핸들러
* v3 rich-ui.jsx 의 PanZoomStage 포팅
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { ZoomIn, ZoomOut, Maximize, Hand } from 'lucide-react';
interface PanZoomStageProps {
width: number;
height: number;
initialFit?: boolean;
minScale?: number;
maxScale?: number;
onCanvasDrop?: (drop: { x: number; y: number; type: string }) => void;
children: ReactNode | ((ctx: { scale: number }) => ReactNode);
}
export function PanZoomStage({
width, height,
initialFit = true,
minScale = 0.25, maxScale = 1.6,
onCanvasDrop,
children,
}: PanZoomStageProps) {
const ref = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1);
const [dragging, setDragging] = useState(false);
const [dropHover, setDropHover] = useState(false);
const dragStart = useRef<{ x: number; y: number } | null>(null);
const panStart = useRef<{ x: number; y: number } | null>(null);
const scaleRef = useRef(1);
const panRef = useRef({ x: 0, y: 0 });
useEffect(() => { scaleRef.current = scale; }, [scale]);
useEffect(() => { panRef.current = pan; }, [pan]);
// initial fit + recompute on resize
useEffect(() => {
if (!ref.current) return;
const fit = () => {
const el = ref.current; if (!el) return;
const pw = el.clientWidth, ph = el.clientHeight;
if (initialFit) {
const s = Math.min(pw / width, ph / height, 1);
setScale(s);
setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 });
} else {
setPan({ x: (pw - width) / 2, y: (ph - height) / 2 });
}
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(ref.current);
return () => ro.disconnect();
}, [width, height, initialFit]);
const screenToCanvas = (clientX: number, clientY: number) => {
if (!ref.current) return { x: 0, y: 0 };
const rect = ref.current.getBoundingClientRect();
return {
x: (clientX - rect.left - panRef.current.x) / scaleRef.current,
y: (clientY - rect.top - panRef.current.y) / scaleRef.current,
};
};
const onMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('[data-pz-node], button, input, select, textarea, a')) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
panStart.current = { ...pan };
e.preventDefault();
};
useEffect(() => {
if (!dragging) return;
const onMove = (e: MouseEvent) => {
if (!dragStart.current || !panStart.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
};
const onUp = () => setDragging(false);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [dragging]);
const onWheel = (e: React.WheelEvent) => {
if (!ref.current) return;
e.preventDefault();
const delta = -e.deltaY * 0.0015;
const next = Math.max(minScale, Math.min(maxScale, scale * (1 + delta)));
const rect = ref.current.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const k = next / scale;
setPan({ x: cx - (cx - pan.x) * k, y: cy - (cy - pan.y) * k });
setScale(next);
};
const onDragOver = (e: React.DragEvent) => {
if (!onCanvasDrop) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setDropHover(true);
};
const onDragLeave = () => setDropHover(false);
const onDrop = (e: React.DragEvent) => {
if (!onCanvasDrop) return;
e.preventDefault();
setDropHover(false);
const type = e.dataTransfer.getData('application/x-ctrl-node-type')
|| e.dataTransfer.getData('text/plain');
if (!type) return;
const { x, y } = screenToCanvas(e.clientX, e.clientY);
onCanvasDrop({ x, y, type });
};
const zoomIn = () => setScale((s) => Math.min(maxScale, s * 1.15));
const zoomOut = () => setScale((s) => Math.max(minScale, s / 1.15));
const reset = () => {
if (!ref.current) return;
const pw = ref.current.clientWidth, ph = ref.current.clientHeight;
const s = Math.min(pw / width, ph / height, 1);
setScale(s);
setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 });
};
const childOut = typeof children === 'function' ? children({ scale }) : children;
return (
<>
<div
ref={ref}
onMouseDown={onMouseDown}
onWheel={onWheel}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className="ctrl-pz-stage"
style={{
cursor: dragging ? 'grabbing' : 'grab',
background: dropHover ? 'rgba(var(--v5-cyan-rgb), .04)' : 'transparent',
boxShadow: dropHover ? 'inset 0 0 0 2px rgba(var(--v5-cyan-rgb), .4)' : 'none',
userSelect: dragging ? 'none' : 'auto',
}}
>
<div
ref={innerRef}
style={{
position: 'absolute', left: 0, top: 0,
width, height,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: '0 0',
}}
>
{childOut}
</div>
</div>
<div className="ctrl-pz-zoom">
<button onClick={zoomOut} title="축소"><ZoomOut size={11} /></button>
<button onClick={reset} title="맞춤"><Maximize size={11} /></button>
<button onClick={zoomIn} title="확대"><ZoomIn size={11} /></button>
<span className="ctrl-pz-pct">{Math.round(scale * 100)}%</span>
</div>
<div className="ctrl-pz-hint">
<Hand size={10} />
{onCanvasDrop ? '드래그로 이동 · 휠로 확대/축소 · 팔레트 드롭으로 노드 추가' : '드래그로 이동 · 휠로 확대/축소'}
</div>
</>
);
}
@@ -0,0 +1,276 @@
'use client';
import { useEffect, useState } from 'react';
import { Info, Database, ScrollText, Trash2, Activity, Wrench } from 'lucide-react';
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { NODE_TYPE_SCHEMAS, type NodeFieldSchema } from '../schemas';
import { getNodeStats, listNodeComments, type NodeStats, type NodeComment } from '@/lib/api/control';
interface RightRailProps {
selectedCard: Record<string, any>;
}
export function RightRail({ selectedCard }: RightRailProps) {
const configNodeId = useControlMode((s) => s.configNodeId);
const ruleNodes = useControlMode((s) => s.ruleNodes);
const updateRuleNode = useControlMode((s) => s.updateRuleNode);
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
const setConfigNodeId = useControlMode((s) => s.setConfigNodeId);
const selectedNode = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
return (
<div className="ctrl-ide-rightrail">
{/* 섹션 1: 노드 설정 / 카드 정보 */}
<div className="ctrl-rail-sec">
<div className="ctrl-rail-sec-head">
{selectedNode ? <Wrench size={11} /> : <Info size={11} />}
<span className="ctrl-rail-sec-title">
{selectedNode ? '노드 설정' : '데이터 인스펙터'}
</span>
<span className="ctrl-rail-sec-count">
{selectedNode ? selectedNode.id : '—'}
</span>
</div>
<div className="ctrl-rail-sec-body">
{selectedNode ? (
<NodeInspector
node={selectedNode}
onChange={(patch) => updateRuleNode(selectedNode.id, patch)}
onDelete={() => { removeRuleNode(selectedNode.id); setConfigNodeId(null); }}
/>
) : (
<CardInfo card={selectedCard} />
)}
</div>
</div>
{/* 섹션 2: 실행 상태 (v3 V3LiveItem 4개 미러) — 실 데이터 없으면 '—' fallback */}
<ActivitySection />
</div>
);
}
function ActivitySection() {
// 실 데이터 연결 전: 모든 값 '—' (control.ts 에 getControlActivity API 추가 시 연결)
// TODO: API listControlActivity(cardId) 추가 후 useEffect 로 fetch
const items: Array<{ label: string; value: string; dot?: 'ok' | 'warn' | 'bad' }> = [
{ label: '최근 트리거', value: '—' },
{ label: '오늘 실행', value: '—' },
{ label: '평균 latency', value: '—' },
{ label: '대기 큐', value: '—' },
];
return (
<div className="ctrl-rail-sec">
<div className="ctrl-rail-sec-head">
<Activity size={11} />
<span className="ctrl-rail-sec-title"> </span>
<span className="ctrl-rail-sec-count">live</span>
</div>
<div className="ctrl-rail-sec-body">
<div className="ctrl-activity">
{items.map((it) => (
<div key={it.label} className="ctrl-activity-row">
<span className="ctrl-activity-label">{it.label}</span>
<span className="ctrl-activity-value">
{it.dot && <span className={`ctrl-activity-dot ${it.dot}`} />}
{it.value}
</span>
</div>
))}
</div>
</div>
</div>
);
}
function NodeInspector({
node, onChange, onDelete,
}: {
node: Record<string, any>;
onChange: (patch: Record<string, any>) => void;
onDelete: () => void;
}) {
const schema: NodeFieldSchema[] = NODE_TYPE_SCHEMAS[node.type] ?? [];
const config: Record<string, any> = node.config ?? {};
const def = CTRL_NODE_TYPES[node.type];
const [stats, setStats] = useState<NodeStats | null>(null);
const [comments, setComments] = useState<NodeComment[]>([]);
useEffect(() => {
let alive = true;
getNodeStats(node.id).then((s) => { if (alive) setStats(s); });
listNodeComments(node.id).then((c) => { if (alive) setComments(c); });
return () => { alive = false; };
}, [node.id]);
return (
<>
<div className="ctrl-sec-head">
<span className="ctrl-sec-ico"><Info size={11} /></span>
Inspector
<span className="ctrl-sec-count">{def?.label ?? node.type}</span>
<span className="ctrl-sec-right">
<button
type="button"
className="ctrl-ide-tool ctrl-ide-mini"
onClick={onDelete}
title="노드 삭제"
>
<Trash2 size={11} />
</button>
</span>
</div>
<div className="ctrl-ide-inspector">
{/* node 식별 */}
<div className="ctrl-ide-field ctrl-ide-field-meta">
<div>
<span className="ctrl-ide-field-k"> ID</span>
<code>{node.id}</code>
</div>
{def && (
<div>
<span className="ctrl-ide-field-k"></span>
<span style={{ color: `rgb(${def.rgb})`, fontWeight: 700 }}>
{def.icon} {def.label}
</span>
</div>
)}
</div>
{/* schema 기반 필드 */}
{schema.length === 0 && (
<div className="ctrl-ide-empty"> </div>
)}
{schema.map((f) => (
<div key={f.k} className="ctrl-ide-field">
<label className="ctrl-ide-field-label">
{f.l}
{f.locked && <span className="ctrl-ide-field-locked"> · </span>}
</label>
{f.select ? (
<select
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
>
{f.select.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : f.multiline ? (
<textarea
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
placeholder={f.hint}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
rows={3}
/>
) : (
<input
type="text"
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
placeholder={f.hint}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
/>
)}
{f.hint && !f.multiline && <div className="ctrl-ide-field-hint">{f.hint}</div>}
</div>
))}
{/* 통계 */}
{stats && (
<>
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
<span className="ctrl-sec-ico"><Activity size={11} /></span>
</div>
<div className="ctrl-ide-stats">
<div><span className="ctrl-ide-field-k"></span><code>{stats.runs}</code></div>
<div><span className="ctrl-ide-field-k"> ms</span><code>{stats.lastMs ?? '—'}</code></div>
<div>
<span className="ctrl-ide-field-k"></span>
<span className={`ctrl-validation-dot ${stats.valid ? 'ok' : 'bad'}`} />
</div>
{stats.alert && (
<div className="ctrl-ide-stat-alert">{stats.alert}</div>
)}
</div>
</>
)}
{/* 댓글 */}
{comments.length > 0 && (
<>
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
<span className="ctrl-sec-count">{comments.length}</span>
</div>
<div className="ctrl-ide-comments">
{comments.map((c, i) => (
<div key={i} className="ctrl-ide-comment">
<span
className="ctrl-ide-avatar"
style={{ background: `rgb(${c.color})` }}
title={c.who}
>
{c.short}
</span>
<div>
<div className="ctrl-ide-comment-meta">
<b>{c.who}</b><span> · {c.at}</span>
</div>
<div className="ctrl-ide-comment-text">{c.text}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</>
);
}
function CardInfo({ card }: { card: Record<string, any> }) {
const title = card.title ?? card.TITLE ?? '카드';
const table = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
return (
<>
<div className="ctrl-sec-head">
<span className="ctrl-sec-ico"><Database size={11} /></span>
</div>
<div className="ctrl-ide-card-info">
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k"></span>
<span>{title}</span>
</div>
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k"></span>
<code>{table || '—'}</code>
</div>
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k">ID</span>
<code>{cardId}</code>
</div>
</div>
<div className="ctrl-sec-head" style={{ marginTop: 16 }}>
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
</div>
<div className="ctrl-ide-help">
<p> .</p>
<p> .</p>
<p> <b>READ / EDIT / RUN / HISTORY</b> .</p>
</div>
</>
);
}
@@ -0,0 +1,48 @@
'use client';
/**
* StatusBar — v3 V3StatusBar 미러
* 좌측: Workflow icon + 룰 이름 + dirty + 노드/연결 카운트
* 중간: 진행 dot (pulse) + 라텐시
* 우측: 최근 실행 시간
*
* 실 데이터 없는 필드는 '—' fallback (시안 mock 글자 박지 않음)
*/
import { Workflow } from 'lucide-react';
import { useControlMode } from '../hooks/useControlMode';
interface StatusBarProps {
selectedCard: Record<string, any>;
}
export function StatusBar({ selectedCard }: StatusBarProps) {
void selectedCard;
const mode = useControlMode((s) => s.mode);
const ruleNodes = useControlMode((s) => s.ruleNodes);
const ruleConnections = useControlMode((s) => s.ruleConnections);
const activeRuleId = useControlMode((s) => s.activeRuleId);
// 룰 이름 — store 에 룰 메타 없으면 '—' (룰 메타 API 연결 후 채워짐)
const ruleName = activeRuleId ?? '—';
return (
<div className="ctrl-ide-statusbar">
<span className="ctrl-status-rule">
<Workflow size={11} style={{ color: 'rgb(var(--v5-primary-rgb))' }} />
{ruleName}
<span className="ctrl-status-ver">v0</span>
</span>
<span><b>NODES</b> <code>{ruleNodes.length}</code></span>
<span><b>EDGES</b> <code>{ruleConnections.length}</code></span>
<span><b>MODE</b> <code>{mode.toUpperCase()}</code></span>
<div style={{ flex: 1 }} />
<span className="ctrl-status-pulse" title="live" />
<span><b> </b> <code></code></span>
<span><b></b> <code>ms</code></span>
</div>
);
}
@@ -0,0 +1,159 @@
'use client';
/**
* V3RuleNode — v3 시안 v3-canvas.jsx 의 V3RuleNode 정확 포팅
* cat-color stripe + validation dot + comment avatar + cat-chip header + label + summary + stats + ports
*/
import { CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { getNodeIcon } from '../schemas';
import type { NodeStats, NodeComment } from '@/lib/api/control';
interface V3RuleNodeProps {
node: Record<string, any> & { cx: number; cy: number };
scale: number;
selected: boolean;
dim: boolean;
stats?: NodeStats;
comments?: NodeComment[];
onSelect: () => void;
onDrag: (dx: number, dy: number) => void;
onContextMenu: (canvasX: number, canvasY: number) => void;
}
export function V3RuleNode({
node, scale, selected, dim, stats, comments,
onSelect, onDrag, onContextMenu,
}: V3RuleNodeProps) {
const def = CTRL_NODE_TYPES[node.type];
if (!def) return null;
const rgb = def.rgb;
const Ic = getNodeIcon(node.type);
// Pointer Events + setPointerCapture — transform/scale 안에서도 mouse 이벤트 안정적으로 받음
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button, input, select, textarea')) return;
e.stopPropagation();
const el = e.currentTarget as HTMLDivElement;
const pointerId = e.pointerId;
try { el.setPointerCapture(pointerId); } catch { /* unsupported */ }
const start = { x: e.clientX, y: e.clientY };
let moved = false;
const onMove = (ev: Event) => {
const pe = ev as PointerEvent;
const dx = (pe.clientX - start.x) / (scale || 1);
const dy = (pe.clientY - start.y) / (scale || 1);
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
moved = true;
onDrag(dx, dy);
start.x = pe.clientX;
start.y = pe.clientY;
};
const onUp = () => {
try { el.releasePointerCapture(pointerId); } catch { /* */ }
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
if (!moved) onSelect();
};
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
};
const onCtx = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(node.cx, node.cy + 70);
};
const lastMs = stats?.lastMs ?? null;
const latColor =
lastMs == null ? undefined :
lastMs < 30 ? 'var(--v5-green)' :
lastMs < 100 ? 'var(--v5-text-sec)' :
'var(--v5-amber)';
const summary = node.summary?.[0]
?? (node.config ? Object.entries(node.config).slice(0, 1).map(([k, v]) => `${k}: ${v}`)[0] : null);
const firstComment = comments?.[0];
return (
<div
data-pz-node="true"
draggable={false}
className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
onPointerDown={onPointerDown}
onContextMenu={onCtx}
style={{
left: node.cx, top: node.cy,
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)',
touchAction: 'none',
}}
>
{/* cat-color stripe */}
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
{/* validation dot */}
{stats && (
<span
className="v3-rule-node-vdot"
style={{
background: stats.valid
? (stats.alert ? 'var(--v5-amber)' : 'var(--v5-green)')
: 'var(--v5-red)',
boxShadow: stats.valid
? (stats.alert ? '0 0 5px var(--v5-amber)' : '0 0 5px var(--v5-green)')
: '0 0 5px var(--v5-red)',
}}
title={stats.alert || (stats.valid ? '정상' : '검증 실패')}
/>
)}
{/* comment avatar */}
{firstComment && (
<span
className="v3-rule-node-comment"
title={firstComment.text}
style={{ background: `rgb(${firstComment.color})` }}
>
{firstComment.short}
</span>
)}
{/* 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>
</div>
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
{summary && <div className="v3-rule-node-summary">{summary}</div>}
{stats && (
<div className="v3-rule-node-stats">
<span>{stats.runs.toLocaleString()} runs</span>
{lastMs != null && (
<span style={{ fontWeight: 700, color: latColor }}>{lastMs}ms</span>
)}
</div>
)}
</div>
{/* ports */}
<div className="v3-rule-node-port v3-rule-node-port-in"
style={{ borderColor: `rgb(${rgb})` }} />
<div className="v3-rule-node-port v3-rule-node-port-out"
style={{ background: `rgb(${rgb})` }} />
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* 제어 모드 — 노드 타입별 설정 schema (Inspector 자동 렌더용)
* v3 시안 shared.jsx 의 NODE_TYPE_SCHEMAS 미러
*
* 이건 코드 상수 (DB 에 들어갈 데이터 아님). 노드 16종의 "필드 정의" 자체.
* 노드 인스턴스의 실제 값은 ruleNode.config 에 들어감.
*/
export interface NodeFieldSchema {
k: string;
l: string;
v?: string;
mono?: boolean;
select?: string[];
multiline?: boolean;
hint?: string;
locked?: boolean;
}
export const NODE_TYPE_SCHEMAS: Record<string, NodeFieldSchema[]> = {
'timer': [
{ k: 'schedule', l: '스케줄 (cron)', v: '0 0 * * *', mono: true, hint: '매일 자정' },
{ k: 'timezone', l: '타임존', v: 'Asia/Seoul', select: ['Asia/Seoul', 'UTC', 'America/Los_Angeles'] },
{ k: 'max_runs', l: '1회 최대 실행 수', v: '1000', mono: true },
],
'status-change': [
{ k: 'table', l: '대상 테이블', v: '', mono: true, locked: true },
{ k: 'from', l: '이전 상태', v: '', mono: true },
{ k: 'to', l: '변경 상태', v: '', mono: true, hint: '트리거 조건' },
],
'condition': [
{ k: 'expr', l: '조건식', v: '', mono: true, hint: 'JS 표현식 — true/false 반환' },
{ k: 'yes_label', l: 'YES 분기 라벨', v: '예' },
{ k: 'no_label', l: 'NO 분기 라벨', v: '아니오' },
],
'validation': [
{ k: 'rules', l: '검증 룰', v: '', mono: true, multiline: true },
{ k: 'on_fail', l: '실패 시 동작', v: 'abort', select: ['abort', 'skip', 'log'] },
{ k: 'alert_owner', l: '실패 알림 대상', v: '' },
],
'auto-insert': [
{ k: 'target', l: '대상 테이블', v: '', mono: true },
{ k: 'mapping', l: '필드 매핑', v: '', mono: true, multiline: true },
{ k: 'fk_link', l: 'FK 연결 키', v: '', mono: true },
],
'calculation': [
{ k: 'expr', l: '수식', v: '', mono: true },
{ k: 'out_field', l: '결과 필드', v: '', mono: true },
{ k: 'round', l: '소수점', v: '2' },
],
'delete': [
{ k: 'target', l: '대상 테이블', v: '', mono: true },
{ k: 'soft_delete', l: 'Soft delete', v: 'true', select: ['true', 'false'] },
{ k: 'archive_to', l: '보관 테이블', v: '', mono: true },
],
'document': [
{ k: 'template', l: '템플릿', v: '', mono: true },
{ k: 'output', l: '출력 경로', v: '', mono: true },
{ k: 'format', l: '포맷', v: 'pdf', select: ['pdf', 'docx', 'html'] },
],
'approval': [
{ k: 'approver', l: '결재자', v: '' },
{ k: 'sla', l: 'SLA (시간)', v: '4', mono: true },
{ k: 'on_reject', l: '반려 시', v: 'rollback', select: ['rollback', 'manual', 'log'] },
],
'delay': [
{ k: 'duration', l: '대기 시간', v: '30m', mono: true, hint: '예: 30m / 2h / 1d' },
{ k: 'unit', l: '단위', v: 'minute', select: ['second', 'minute', 'hour', 'day'] },
],
'loop': [
{ k: 'source', l: '반복 대상', v: '', mono: true },
{ k: 'max', l: '최대 반복', v: '100', mono: true },
],
'parallel': [
{ k: 'branches', l: '병렬 브랜치 수', v: '2', mono: true },
{ k: 'wait', l: 'join 대기', v: 'all', select: ['all', 'any', 'first'] },
],
'merge': [
{ k: 'strategy', l: '병합 전략', v: 'overwrite', select: ['overwrite', 'keep', 'custom'] },
],
'webhook': [
{ k: 'url', l: 'URL', v: '', mono: true },
{ k: 'method', l: '메서드', v: 'POST', select: ['GET', 'POST', 'PUT', 'DELETE'] },
{ k: 'headers', l: '헤더', v: '', mono: true, multiline: true },
],
'notification': [
{ k: 'channel', l: '채널', v: 'slack', select: ['slack', 'email', 'teams', 'webhook'] },
{ k: 'target', l: '대상', v: '', mono: true },
{ k: 'template', l: '메시지', v: '', mono: true, multiline: true },
],
'log': [
{ k: 'table', l: '대상', v: 'audit_log', mono: true },
{ k: 'level', l: '레벨', v: 'info', select: ['debug', 'info', 'warn', 'error'] },
{ k: 'msg', l: '메시지', v: '', mono: true },
],
};
/** 카테고리 메타 — palette / inspector 색상 매핑 */
export const NODE_CATEGORIES: Array<{
id: 'trigger' | 'cond' | 'action' | 'flow' | 'extern' | 'log';
label: string;
cls: string;
rgb: string;
}> = [
{ id: 'trigger', label: '트리거', cls: 'c-trigger', rgb: '0,206,201' },
{ id: 'cond', label: '조건', cls: 'c-cond', rgb: '253,203,110' },
{ id: 'action', label: '액션', cls: 'c-action', rgb: '108,92,231' },
{ id: 'flow', label: '흐름', cls: 'c-flow', rgb: '253,121,168' },
{ id: 'extern', label: '연동', cls: 'c-extern', rgb: '0,184,148' },
{ id: 'log', label: '기록', cls: 'c-log', rgb: '107,107,118' },
];
/** invyone CTRL_NODE_TYPES 의 cat (한글) → v3 cat (영문) 매핑 */
export function ctrlCatToV3(catKo: string): 'trigger' | 'cond' | 'action' | 'flow' | 'extern' | 'log' {
switch (catKo) {
case '트리거': return 'trigger';
case '조건': return 'cond';
case '액션': return 'action';
case '흐름': return 'flow';
case '연동': return 'extern';
case '기록': return 'log';
default: return 'action';
}
}
import {
Clock4, Activity, GitBranch, ShieldCheck,
FilePlus2, Calculator, Archive, FileText,
Stamp, Timer, Repeat, GitMerge, Combine,
Webhook, BellRing, ScrollText, Circle,
type LucideIcon,
} from 'lucide-react';
/** 노드 타입 → Lucide 아이콘 매핑 (v3 시안 NODE_TYPES.icon 미러) */
const NODE_LUCIDE: Record<string, LucideIcon> = {
'timer': Clock4,
'status-change': Activity,
'condition': GitBranch,
'validation': ShieldCheck,
'auto-insert': FilePlus2,
'calculation': Calculator,
'delete': Archive,
'document': FileText,
'approval': Stamp,
'delay': Timer,
'loop': Repeat,
'parallel': GitMerge,
'merge': Combine,
'webhook': Webhook,
'notification': BellRing,
'log': ScrollText,
};
export function getNodeIcon(nodeType: string): LucideIcon {
return NODE_LUCIDE[nodeType] ?? Circle;
}