2f398ae0b3
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control - 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합 - InvLegacyButtonConfigPanel cp 마이그레이션 - canonical data view cleanup 후속 노트
420 lines
15 KiB
TypeScript
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>
|
|
))}
|
|
</>
|
|
);
|
|
}
|