Files
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

420 lines
15 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useControlMode, CTRL_NODE_TYPES } from './hooks/useControlMode';
import { useFlowAnimation } from './hooks/useFlowAnimation';
import { getMetaFields, getMetaRelations } from '@/lib/api/meta';
import { getBusinessRuleList, getBusinessRuleInfo } from '@/lib/api/businessRule';
import { TableNode } from './TableNode';
import { ControlNode } from './ControlNode';
import { ConnectionSvg, FlowLine, FlowBadge, bezierPath } from './ConnectionLine';
import type { FieldConfig } from '@/types/invyone-component';
interface FlowViewerProps {
cards: Record<string, any>[];
canvasRef: React.RefObject<HTMLDivElement | null>;
dashboardId: string;
}
/** 저장된 룰 그래프 (노드+연결선, 별도 오버레이) */
interface RuleOverlay {
ruleName: string;
nodes: Record<string, any>[];
connections: Record<string, any>[];
}
/**
* 룰 노드의 포트 위치 계산 (RuleBuilder.portPos와 동일 로직)
* - table 노드: width 200, 포트 in=좌측, out=우측 (y+18)
* - control 노드: width 160, in=좌측 (y+40), out=우측 (다중 포트 분배)
*/
function computePortPos(node: Record<string, any>, port: string): { x: number; y: number } | null {
if (!node) return null;
const nx = node.x ?? 0;
const ny = node.y ?? 0;
if (node.type === 'table') {
if (port === 'in') return { x: nx, y: ny + 18 };
return { x: nx + 200, y: ny + 18 };
}
// 제어 노드
if (port === 'in') return { x: nx, y: ny + 40 };
// output 포트 — 타입별 위치
const def = (CTRL_NODE_TYPES as Record<string, any>)[node.type];
const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }];
const idx = outPorts.findIndex((p: Record<string, any>) => p.port === port);
const total = outPorts.length;
const nodeH = 80;
const centerY = ny + nodeH / 2;
const gap = 8;
const startY = centerY - ((total - 1) * gap) / 2;
return { x: nx + 160, y: startY + (idx >= 0 ? idx : 0) * gap };
}
/** 테이블 메타 캐시 */
const metaCache: Record<string, { label: string; icon: string; columns: FieldConfig[] }> = {};
async function loadTableMeta(tableName: string) {
if (metaCache[tableName]) return metaCache[tableName];
try {
const meta = await getMetaFields(tableName);
const result = {
label: meta.table_label ?? tableName,
icon: '🏢',
columns: (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8),
};
metaCache[tableName] = result;
return result;
} catch {
return { label: tableName, icon: '🏢', columns: [] };
}
}
export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
const {
activeFlowCardId,
flowEdges,
tablePositions,
setActiveFlowCard,
setSelectedCardId,
setFlowEdges,
setTablePositions,
} = useControlMode();
const { showFlow } = useFlowAnimation();
const [tableMetas, setTableMetas] = useState<Record<string, { label: string; icon: string; columns: FieldConfig[] }>>({});
const [animTimings, setAnimTimings] = useState<{ edge: Record<string, any>; lineDelay: number; nodeDelay: number }[]>([]);
const [revealedNodes, setRevealedNodes] = useState<Set<string>>(new Set());
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;
const sourceTable = card.primary_table ?? card.PRIMARY_TABLE;
if (!sourceTable) return;
// 카드 위치 계산
const cv = canvasRef.current;
if (!cv) return;
const cardEl = cv.querySelector(`[data-card-id="${cardId}"]`) as HTMLElement;
if (!cardEl) return;
const cardRight = cardEl.offsetLeft + cardEl.offsetWidth;
const cardCenterY = cardEl.offsetTop + cardEl.offsetHeight / 2;
const canvasHeight = cv.clientHeight;
// ★ 2소스 분리 조회: table_relationships (구조) + business_rules (자동화)
let relations: Record<string, any>[] = [];
try { relations = await getMetaRelations(sourceTable); } catch { /* 빈 배열 사용 */ }
// ★ 현재 대시보드의 활성 비즈니스 룰 조회 → 별도 오버레이 (BFS에 합치지 않음)
// ★ 단, sourceTable과 관련된 룰만 표시 (해당 테이블 노드를 포함하는 룰)
const overlays: RuleOverlay[] = [];
try {
const rulesRes = await getBusinessRuleList(dashboardId);
const ruleList = (rulesRes?.list ?? rulesRes?.data ?? [])
.filter((r: Record<string, any>) => r.is_enabled === true || r.IS_ENABLED === true);
for (const rule of ruleList) {
const ruleId = rule.rule_id ?? rule.RULE_ID;
if (!ruleId) continue;
const ruleDetail = await getBusinessRuleInfo(ruleId);
if (!ruleDetail) continue;
const nodes: Record<string, any>[] = ruleDetail.nodes ?? [];
// ★ 룰이 sourceTable을 포함하는지 확인 (table 타입 노드의 table_name 매칭)
const involvesSourceTable = nodes.some((n) =>
n.type === 'table' && (n.table_name === sourceTable || n.tableName === sourceTable)
);
if (!involvesSourceTable) continue;
overlays.push({
ruleName: rule.name ?? rule.NAME ?? ruleId,
nodes,
connections: ruleDetail.connections ?? [],
});
}
} catch { /* 룰 조회 실패 시 관계만 표시 */ }
setRuleOverlays(overlays);
// 흐름 계산 (★ table_relationships만 — 룰은 별도 오버레이)
const result = showFlow(cardId, sourceTable, relations, { right: cardRight, centerY: cardCenterY }, canvasHeight);
// 테이블 메타 로드
const tableNames = new Set<string>();
result.edges.forEach((e) => {
if (!e.to.startsWith('CARD:')) tableNames.add(e.to);
if (!e.from.startsWith('CARD:')) tableNames.add(e.from);
});
const metas: Record<string, { label: string; icon: string; columns: FieldConfig[] }> = {};
await Promise.all(
Array.from(tableNames).map(async (name) => {
metas[name] = await loadTableMeta(name);
})
);
setTableMetas(metas);
// 상태 업데이트
setActiveFlowCard(cardId);
setFlowEdges(result.edges);
setTablePositions(result.positions);
setAnimTimings(result.timings);
// 애니메이션: 순차 reveal
const revealed = new Set<string>();
setRevealedNodes(new Set());
animRef.current.forEach(clearTimeout);
animRef.current = [];
result.timings.forEach(({ edge, nodeDelay }) => {
const t = setTimeout(() => {
if (!edge.to.startsWith('CARD:') && !revealed.has(edge.to)) {
revealed.add(edge.to);
setRevealedNodes(new Set(revealed));
}
}, nodeDelay);
animRef.current.push(t);
});
}, [activeFlowCardId, cards, canvasRef, setActiveFlowCard, setFlowEdges, setTablePositions, showFlow]);
// 클릭 이벤트 위임
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
const handler = (e: MouseEvent) => {
const cardEl = (e.target as HTMLElement).closest('[data-card-id]') as HTMLElement;
if (cardEl) {
const id = cardEl.dataset.cardId;
if (id) handleCardClick(id);
return;
}
// 빈 영역 클릭 → 흐름 닫기
if ((e.target as HTMLElement).closest('.tbl-node') ||
(e.target as HTMLElement).closest('.ctrl-badge')) return;
if (activeFlowCardId) clearFlow();
};
cv.addEventListener('click', handler);
return () => cv.removeEventListener('click', handler);
}, [canvasRef, handleCardClick, activeFlowCardId]);
const clearFlow = useCallback(() => {
animRef.current.forEach(clearTimeout);
animRef.current = [];
setActiveFlowCard(null);
setFlowEdges([]);
setTablePositions({});
setAnimTimings([]);
setRevealedNodes(new Set());
setTableMetas({});
setRuleOverlays([]);
}, [setActiveFlowCard, setFlowEdges, setTablePositions]);
// 테이블 노드 드래그 → 위치 업데이트 + 선 재그리기
const handleNodeMove = useCallback((name: string, x: number, y: number) => {
setTablePositions({ ...tablePositions, [name]: { x, y } });
}, [tablePositions, setTablePositions]);
if (!activeFlowCardId || flowEdges.length === 0) return null;
// 카드 위치 가져오기
const getCardPos = (cardId: string) => {
const cv = canvasRef.current;
if (!cv) return { right: 0, centerY: 0 };
const el = cv.querySelector(`[data-card-id="${cardId}"]`) as HTMLElement;
if (!el) return { right: 0, centerY: 0 };
return { right: el.offsetLeft + el.offsetWidth, centerY: el.offsetTop + el.offsetHeight / 2 };
};
// 좌표 계산
const getFromPos = (from: string) => {
if (from.startsWith('CARD:')) {
const cardId = from.split(':')[1];
const pos = getCardPos(cardId);
return { x: pos.right, y: pos.centerY };
}
const p = tablePositions[from];
if (!p) return null;
return { x: p.x + 200, y: p.y + 80 }; // 노드 우측 중앙
};
const getToPos = (to: string) => {
const p = tablePositions[to];
if (!p) return null;
return { x: p.x, y: p.y + 80 }; // 노드 좌측 중앙
};
return (
<>
{/* SVG 연결선 */}
<ConnectionSvg>
{animTimings.map(({ edge, lineDelay }, idx) => {
const from = getFromPos(edge.from);
const to = getToPos(edge.to);
if (!from || !to) return null;
return (
<FlowLine
key={`line-${idx}`}
x1={from.x} y1={from.y}
x2={to.x} y2={to.y}
type={edge.type}
animate
delay={lineDelay}
/>
);
})}
</ConnectionSvg>
{/* 연결선 뱃지 */}
{animTimings.map(({ edge, nodeDelay }, idx) => {
const from = getFromPos(edge.from);
const to = getToPos(edge.to);
if (!from || !to) return null;
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
return (
<FlowBadge
key={`badge-${idx}`}
x={mx} y={my}
label={edge.label}
type={edge.type}
animate
delay={nodeDelay}
/>
);
})}
{/* 테이블 노드 (table_relationships 레이어) */}
{Object.entries(tablePositions).map(([name, pos]) => {
const meta = tableMetas[name];
if (!meta) return null;
const revealed = revealedNodes.has(name);
return (
<TableNode
key={name}
tableName={name}
label={meta.label}
icon={meta.icon}
columns={meta.columns}
x={pos.x}
y={pos.y}
onMove={handleNodeMove}
style={{
opacity: revealed ? 1 : 0,
transform: revealed ? 'scale(1)' : 'scale(0.3)',
transition: 'opacity .35s ease-out, transform .35s cubic-bezier(.16,1,.3,1)',
pointerEvents: revealed ? 'auto' : 'none',
}}
/>
);
})}
{/* ★ 비즈니스 룰 오버레이 (별도 레이어 — 저장된 노드 type별로 렌더) */}
{ruleOverlays.map((overlay, oi) => (
<div key={`rule-overlay-${oi}`}>
{/* 룰 노드 → type별로 TableNode 또는 ControlNode (read-only) */}
{overlay.nodes.map((node) => {
// ★ type === 'table'이면 TableNode로 렌더
if (node.type === 'table') {
const tableName = node.table_name ?? node.tableName ?? node.label ?? node.id;
const columns: FieldConfig[] = (node.columns ?? []).slice(0, 8);
return (
<div
key={node.id}
style={{
position: 'absolute',
left: node.x ?? 0,
top: node.y ?? 0,
opacity: 0.85,
pointerEvents: 'none',
}}
>
<TableNode
tableName={tableName}
label={node.label ?? tableName}
icon="🏢"
columns={columns}
x={0}
y={0}
onMove={() => {}}
/>
</div>
);
}
// ★ 그 외는 제어 노드 (CTRL_NODE_TYPES)
const nodeType = node.type ?? 'auto-insert';
const typeDef = (CTRL_NODE_TYPES as Record<string, any>)[nodeType];
return (
<div
key={node.id}
className="ctrl-action-node"
style={{
position: 'absolute',
left: node.x ?? 0,
top: node.y ?? 0,
width: 160,
opacity: 0.85,
pointerEvents: 'none',
// @ts-ignore
'--na-rgb': typeDef?.rgb ?? '108,92,231',
} as React.CSSProperties}
>
<div className="ctrl-an-head" style={{ cursor: 'default' }}>
<div className="ctrl-an-icon">{typeDef?.icon ?? '⚡'}</div>
<span className="ctrl-an-name">{typeDef?.label ?? nodeType}</span>
</div>
<div className="ctrl-an-body">
<div className="ctrl-an-summary" style={{ fontSize: '.45rem', color: 'var(--v5-text-muted)' }}>
[{overlay.ruleName}]
</div>
</div>
</div>
);
})}
{/* 룰 연결선 (SVG) — RuleBuilder.portPos와 동일한 앵커 계산 */}
<ConnectionSvg>
{overlay.connections.map((conn, ci) => {
const fromNode = overlay.nodes.find((n) => n.id === (conn.from_node_id ?? conn.fromNodeId));
const toNode = overlay.nodes.find((n) => n.id === (conn.to_node_id ?? conn.toNodeId));
if (!fromNode || !toNode) return null;
const fromPort = conn.from_port ?? conn.fromPort ?? 'out';
const toPort = conn.to_port ?? conn.toPort ?? 'in';
const f = computePortPos(fromNode, fromPort);
const t = computePortPos(toNode, toPort);
if (!f || !t) return null;
const lineType = fromPort === 'yes' ? 'auto' : fromPort === 'no' ? 'cond' : 'auto';
return (
<FlowLine
key={`rule-line-${oi}-${ci}`}
x1={f.x} y1={f.y} x2={t.x} y2={t.y}
type={lineType}
animate={false}
delay={0}
/>
);
})}
</ConnectionSvg>
</div>
))}
</>
);
}