중간 세이브

This commit is contained in:
2026-04-10 13:33:37 +09:00
parent c6e81c4520
commit 9c36191ebf
97 changed files with 13844 additions and 482 deletions
@@ -0,0 +1,161 @@
'use client';
/**
* SVG 연결선 + 화살표 마커 4종 + 뱃지
* mockup drawTreeLine/addEdgeBadge 포팅
*/
interface ConnectionSvgProps {
children?: React.ReactNode;
}
/** SVG 컨테이너 (캔버스 위 오버레이, defs 포함) */
export function ConnectionSvg({ children }: ConnectionSvgProps) {
return (
<svg className="ctrl-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
<marker id="arr-fk" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".7" />
</marker>
<marker id="arr-auto" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-primary)" opacity=".8" />
</marker>
<marker id="arr-cond" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-amber)" opacity=".8" />
</marker>
<marker id="arr-src" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-pink)" opacity=".8" />
</marker>
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
</marker>
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
</marker>
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
</marker>
</defs>
{children}
</svg>
);
}
/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */
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}`;
}
/** 타입별 CSS 클래스 + 마커 */
export function lineStyle(type: string): { cls: string; marker: string } {
switch (type) {
case 'source': return { cls: 'ctrl-line-tpl', marker: 'url(#arr-src)' };
case 'auto': return { cls: 'ctrl-line-auto', marker: 'url(#arr-auto)' };
case 'cond': return { cls: 'ctrl-line-cond', marker: 'url(#arr-cond)' };
default: return { cls: 'ctrl-line', marker: 'url(#arr-fk)' };
}
}
interface FlowLineProps {
x1: number; y1: number;
x2: number; y2: number;
type: string;
animate?: boolean;
delay?: number;
}
/** 단일 연결선 (SVG path) */
export function FlowLine({ x1, y1, x2, y2, type, animate, delay }: FlowLineProps) {
const { cls, marker } = lineStyle(type);
const d = bezierPath(x1, y1, x2, y2);
if (animate) {
return (
<path
d={d}
className={cls}
markerEnd={marker}
style={{
strokeDasharray: '1000',
strokeDashoffset: '1000',
animation: 'none',
transition: `stroke-dashoffset 0.4s ease-out ${(delay ?? 0)}ms`,
}}
ref={(el) => {
if (!el) return;
const len = el.getTotalLength();
el.style.strokeDasharray = String(len);
el.style.strokeDashoffset = String(len);
requestAnimationFrame(() => {
el.style.strokeDashoffset = '0';
});
setTimeout(() => {
el.style.transition = 'none';
el.style.strokeDasharray = '';
el.style.strokeDashoffset = '';
el.style.animation = '';
}, 500 + (delay ?? 0));
}}
/>
);
}
return <path d={d} className={cls} markerEnd={marker} />;
}
interface FlowBadgeProps {
x: number; y: number;
label: string;
type: string;
animate?: boolean;
delay?: number;
}
/** 연결선 위 뱃지 (HTML) */
export function FlowBadge({ x, y, label, type, animate, delay }: FlowBadgeProps) {
const cls = type === 'source' ? 'tpl-link' : type === 'auto' ? 'auto' : type === 'cond' ? 'cond' : '';
if (type === 'cond') {
const parts = label.split('→');
const condText = (parts[0] || '조건').trim();
const actionText = (parts[1] || '실행').trim();
return (
<div
className={`ctrl-badge cond`}
style={{
left: x, top: y,
opacity: animate ? 0 : 1,
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
}}
ref={(el) => {
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
}}
>
<div className="cb-head"><div className="cb-icon"></div> </div>
<div className="cb-cond">{condText}</div>
<div className="cb-paths">
<span className="cb-yes">Yes {actionText}</span>
<span className="cb-no">No </span>
</div>
</div>
);
}
return (
<div
className={`ctrl-badge ${cls}`}
style={{
left: x, top: y,
opacity: animate ? 0 : 1,
transition: animate ? `opacity .3s ${(delay ?? 0)}ms` : undefined,
}}
ref={(el) => {
if (el && animate) requestAnimationFrame(() => { el.style.opacity = '1'; });
}}
>
{label}
</div>
);
}
@@ -0,0 +1,57 @@
'use client';
import { useRef } from 'react';
import { useControlMode } from './hooks/useControlMode';
import { ControlToolbar } from './ControlToolbar';
import { ControlPalette } from './ControlPalette';
import { FlowViewer } from './FlowViewer';
import { RuleBuilder } from './RuleBuilder';
import '@/styles/control-mode.css';
interface ControlModeProps {
dashboardId: string;
cards: Record<string, any>[];
canvasRef: React.RefObject<HTMLDivElement | null>;
}
/**
* 제어 모드 오버레이 — 캔버스 위에 렌더
* ⚡ 버튼으로 토글, 읽기/편집 모드 전환
*/
export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) {
const { active, mode } = useControlMode();
if (!active) return null;
return (
<>
{/* 제어 모드 툴바 */}
<ControlToolbar dashboardId={dashboardId} />
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */}
{mode === 'view' && (
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
)}
{/* 편집 모드: 규칙 빌더 */}
{mode === 'edit' && (
<RuleBuilder canvasRef={canvasRef} />
)}
</>
);
}
/**
* 제어 모드 팔레트 wrapper — 사이드바에 삽입
*/
export function ControlPaletteWrapper() {
const { active, mode, addRuleNode } = useControlMode();
if (!active || mode !== 'edit') return null;
return (
<ControlPalette
onDropTable={() => {}}
onDropControl={() => {}}
/>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useRef, useCallback } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { PortHandle } from './PortHandle';
interface ControlNodeProps {
node: Record<string, any>;
onDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
onDragEnd?: (nodeId: string, port: string) => void;
}
/**
* 제어 노드 (16종) — mockup buildCtrlNode 포팅
*/
export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) {
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode();
const nodeRef = useRef<HTMLDivElement>(null);
const def = CTRL_NODE_TYPES[node.type];
if (!def) return null;
const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }];
const handleHeadMouseDown = useCallback((e: React.MouseEvent) => {
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';
const mv = (ev: MouseEvent) => {
moveRuleNode(node.id, sl + ev.clientX - sx, st + ev.clientY - sy);
};
const up = () => {
if (el) el.style.zIndex = '20';
document.removeEventListener('mousemove', mv);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', mv);
document.addEventListener('mouseup', up);
}, [node.id, node.x, node.y, moveRuleNode]);
return (
<div
ref={nodeRef}
className="ctrl-action-node"
data-node-id={node.id}
data-node-type={node.type}
style={{
left: node.x,
top: node.y,
['--na-rgb' as string]: def.rgb,
}}
>
{/* Input 포트 */}
<PortHandle
nodeId={node.id}
port="in"
type="in"
onDragEnd={onDragEnd}
/>
{/* 헤더 */}
<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 || '클릭하여 설정'}
</div>
</div>
{/* Output 포트 */}
<div className="ctrl-an-ports-out">
{outPorts.map((p) => (
<PortHandle
key={p.port}
nodeId={node.id}
port={p.port}
type="out"
cls={p.cls}
label={p.label}
onDragStart={onDragStart}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,83 @@
'use client';
import { useEffect, useState } from 'react';
import { CTRL_NODE_TYPES } from './hooks/useControlMode';
import { getMetaTableList } from '@/lib/api/meta';
interface ControlPaletteProps {
onDropTable: (tableName: string, x: number, y: number) => void;
onDropControl: (type: string, x: number, y: number) => void;
}
/**
* 제어 모드 팔레트 — 사이드바 교체
* mockup renderCtrlPalette 포팅
*/
export function ControlPalette({ onDropTable, onDropControl }: ControlPaletteProps) {
const [tables, setTables] = useState<Record<string, any>[]>([]);
useEffect(() => {
getMetaTableList().then(setTables).catch(() => {});
}, []);
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>
);
})}
{/* 제어 노드 — 카테고리별 그룹 */}
{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>
);
})}
</div>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Eye, Wrench, Save, FolderOpen } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode';
import { getBusinessRuleList, getBusinessRuleInfo, insertBusinessRule, updateBusinessRule } from '@/lib/api/businessRule';
import { toast } from 'sonner';
interface ControlToolbarProps {
dashboardId: string;
}
export function ControlToolbar({ dashboardId }: ControlToolbarProps) {
const { mode, setMode, ruleNodes, ruleConnections, activeRuleId, setActiveRuleId, setRuleNodes, setRuleConnections } = useControlMode();
const [ruleList, setRuleList] = useState<Record<string, any>[]>([]);
const [showRuleList, setShowRuleList] = useState(false);
// ★ 편집 모드 진입 시 기존 규칙 목록 로드
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 () => {
try {
const data = {
name: `규칙 ${new Date().toLocaleString('ko-KR')}`,
nodes: ruleNodes,
connections: ruleConnections,
};
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('저장 실패');
}
};
return (
<div className="ctrl-toolbar">
<span style={{ fontWeight: 700, color: 'var(--ctrl-cyan)', marginRight: '.5rem' }}> </span>
<div className="ctrl-toolbar-mode">
<button
className={`ctrl-mode-btn${mode === 'view' ? ' on' : ''}`}
onClick={() => setMode('view')}
>
<Eye size={12} style={{ marginRight: 3 }} />
</button>
<button
className={`ctrl-mode-btn${mode === 'edit' ? ' on' : ''}`}
onClick={() => setMode('edit')}
>
<Wrench size={12} style={{ marginRight: 3 }} />
</button>
</div>
{mode === 'edit' && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: '.3rem', position: 'relative' }}>
{/* ★ 기존 규칙 로드 버튼 */}
<button className="ctrl-mode-btn" onClick={() => setShowRuleList(!showRuleList)}>
<FolderOpen size={12} style={{ marginRight: 3 }} />
{ruleList.length > 0 ? ` (${ruleList.length})` : ''}
</button>
{/* ★ 규칙 목록 드롭다운 */}
{showRuleList && ruleList.length > 0 && (
<div style={{
position: 'absolute', top: '100%', right: 0, marginTop: 4,
background: 'var(--ctrl-glass-strong, rgba(17,16,42,.65))',
border: '1px solid var(--ctrl-glass-border, rgba(162,155,254,.12))',
borderRadius: 8, padding: '.3rem', minWidth: 200, zIndex: 100,
backdropFilter: 'blur(20px) saturate(1.4)',
}}>
{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}
onClick={() => handleLoadRule(id)}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '.3rem .5rem', borderRadius: 6, border: 'none',
background: isActive ? 'rgba(0,206,201,.12)' : 'transparent',
color: isActive ? 'var(--ctrl-cyan)' : 'var(--v5-text-sec)',
fontSize: '.55rem', cursor: 'pointer',
}}
>
{name}
</button>
);
})}
</div>
)}
{ruleNodes.length > 0 && (
<button className="ctrl-mode-btn" onClick={handleSave}>
<Save size={12} style={{ marginRight: 3 }} />
</button>
)}
</div>
)}
</div>
);
}
+415
View File
@@ -0,0 +1,415 @@
'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,
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>[]>([]);
// 카드 클릭 → 흐름 표시
const handleCardClick = useCallback(async (cardId: string) => {
// 같은 카드 클릭 → 닫기
if (activeFlowCardId === cardId) {
clearFlow();
return;
}
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>
))}
</>
);
}
@@ -0,0 +1,269 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
/**
* 노드 설정 팝오버 (mockup showNodeConfig/_buildCfgForm 포팅)
* 노드 타입별 설정 폼
*/
export function NodeConfigPopover() {
const { configNodeId, ruleNodes, 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;
useEffect(() => {
if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true));
} else {
setOpen(false);
}
}, [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;
setConfigNodeId(null);
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, [configNodeId, setConfigNodeId]);
if (!node || !def) return null;
const handleSave = (summary: string, config: Record<string, any>) => {
updateRuleNode(node.id, { config: { ...node.config, ...config, summary } });
setConfigNodeId(null);
};
return (
<div
ref={popRef}
className={`ctrl-cfg-pop${open ? ' open' : ''}`}
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)} />
</div>
);
}
function ConfigForm({ type, config, onSave, onClose }: {
type: string; config: 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 handleSave = () => {
let summary = '';
switch (type) {
case 'condition':
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`;
break;
case 'status-change':
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`;
break;
case 'auto-insert':
summary = `${vals.table || '?'} INSERT`;
break;
case 'timer':
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break;
case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break;
case 'approval':
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`;
break;
case 'calculation':
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`;
break;
case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break;
case 'validation':
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`;
break;
case 'log':
summary = `로그: ${vals.content || '?'}`;
break;
default:
summary = vals.summary || '설정됨';
}
onSave(summary, vals);
};
return (
<>
{renderFields(type, vals, set)}
<div className="cfg-ft">
<button className="cfg-btn save" onClick={handleSave}></button>
<button className="cfg-btn" onClick={onClose}></button>
</div>
</>
);
}
function renderFields(
type: string,
vals: Record<string, any>,
set: (k: string, v: any) => void
) {
switch (type) {
case 'condition':
return (
<>
<CfgSec label="필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="STATUS" />
</CfgSec>
<CfgSec label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '기한 경과', '포함']} />
</CfgSec>
<CfgSec label="값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="비교값" />
</CfgSec>
</>
);
case 'status-change':
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
<CfgSec label="변경 필드">
<CfgInput value={vals.field ?? 'STATUS'} onChange={(v) => set('field', v)} />
</CfgSec>
<CfgSec label="변경값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="새 값" />
</CfgSec>
</>
);
case 'auto-insert':
return (
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
);
case 'timer':
return (
<>
<CfgSec label="기준 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="ORDER_DATE" />
</CfgSec>
<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 'notification':
return (
<>
<CfgSec label="채널">
<CfgSelect value={vals.channel ?? '이메일'} onChange={(v) => set('channel', v)}
options={['이메일', 'SMS', '푸시', 'Slack']} />
</CfgSec>
<CfgSec label="수신자">
<CfgSelect value={vals.target ?? '담당자'} onChange={(v) => set('target', v)}
options={['담당자', '관리자', '전체']} />
</CfgSec>
<CfgSec label="메시지">
<textarea className="cfg-ta" rows={2} value={vals.message ?? ''}
onChange={(e) => set('message', e.target.value)} />
</CfgSec>
</>
);
case 'approval':
return (
<>
<CfgSec label="승인자">
<CfgSelect value={vals.approver ?? '팀장'} onChange={(v) => set('approver', v)}
options={['팀장', '부서장', '관리자', '지정 사용자']} />
</CfgSec>
<CfgSec label="승인 조건">
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="조건식" />
</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 (
<>
<CfgSec label="URL">
<CfgInput value={vals.url ?? ''} onChange={(v) => set('url', v)} placeholder="https://..." />
</CfgSec>
<CfgSec label="메서드">
<CfgSelect value={vals.method ?? 'POST'} onChange={(v) => set('method', v)}
options={['POST', 'GET', 'PUT', 'DELETE']} />
</CfgSec>
</>
);
case 'validation':
return (
<>
<CfgSec label="대상 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec>
</>
);
case 'log':
return (
<CfgSec label="내용">
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
</CfgSec>
);
default:
return <div className="cfg-sec" style={{ color: 'var(--v5-text-muted)', fontSize: '.55rem' }}> </div>;
}
}
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="cfg-sec">
<label className="cfg-lb">{label}</label>
{children}
</div>
);
}
function CfgInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
return (
<input className="cfg-inp" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
);
}
function CfgSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: string[] }) {
return (
<select className="cfg-sel" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
@@ -0,0 +1,51 @@
'use client';
/**
* I/O 포트 핸들 — 노드 양쪽 원형 (드래그 연결 시작/끝점)
* mockup initPortEvents 포팅
*/
interface PortHandleProps {
nodeId: string;
port: string;
type: 'in' | 'out';
cls?: string;
label?: string;
isTable?: boolean;
onDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
onDragEnd?: (nodeId: string, port: string) => void;
}
export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) {
const handleMouseDown = (e: React.MouseEvent) => {
if (type !== 'out' || !onDragStart) return;
e.preventDefault();
e.stopPropagation();
onDragStart(nodeId, port, e);
};
const handleMouseUp = (e: React.MouseEvent) => {
if (type !== 'in' || !onDragEnd) return;
e.stopPropagation();
onDragEnd(nodeId, port);
};
const className = [
'ctrl-io-port',
`port-${type}`,
cls ?? '',
isTable ? 'tbl-io' : '',
].filter(Boolean).join(' ');
return (
<div
className={className}
data-node={nodeId}
data-port={port}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
{label && <span className="port-label">{label}</span>}
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
'use client';
import { useCallback, useRef } from 'react';
import { useControlMode, genNodeId, CTRL_NODE_TYPES } from './hooks/useControlMode';
import { usePortDrag } from './hooks/usePortDrag';
import { ControlNode } from './ControlNode';
import { TableNode } from './TableNode';
import { PortHandle } from './PortHandle';
import { NodeConfigPopover } from './NodeConfigPopover';
import { bezierPath } from './ConnectionLine';
import { getMetaFields } from '@/lib/api/meta';
import type { FieldConfig } from '@/types/invyone-component';
interface RuleBuilderProps {
canvasRef: React.RefObject<HTMLDivElement | null>;
}
/** 테이블 필드 캐시 */
const fieldCache: Record<string, FieldConfig[]> = {};
/**
* 규칙 빌더 — 편집 모드
* mockup initRuleBuilder/dropTable/dropControl 포팅
*/
export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
const {
ruleNodes,
ruleConnections,
addRuleNode,
moveRuleNode,
} = useControlMode();
const { startDrag, finishDrag } = usePortDrag(canvasRef);
// 캔버스 드롭 처리
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
let d: Record<string, any>;
try { d = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
if (!d?.kind) return;
const cv = canvasRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const x = e.clientX - r.left + cv.scrollLeft;
const y = e.clientY - r.top + cv.scrollTop;
if (d.kind === 'table') {
// 중복 방지
if (ruleNodes.find((n) => n.type === 'table' && n.table_name === d.name)) return;
// 필드 로드
let cols: FieldConfig[] = [];
if (fieldCache[d.name]) {
cols = fieldCache[d.name];
} else {
try {
const meta = await getMetaFields(d.name);
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8);
fieldCache[d.name] = cols;
} catch { /* 빈 필드 */ }
}
addRuleNode({
id: genNodeId('tbl'),
type: 'table',
table_name: d.name,
label: d.name,
x: x - 100,
y: y - 40,
columns: cols,
});
} else if (d.kind === 'control' && CTRL_NODE_TYPES[d.type]) {
addRuleNode({
id: genNodeId('ctrl'),
type: d.type,
label: CTRL_NODE_TYPES[d.type].label,
x: x - 80,
y: y - 30,
config: {},
});
}
}, [canvasRef, ruleNodes, addRuleNode]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}, []);
// 노드 좌표에서 포트 위치 계산
const portPos = useCallback((nodeId: string, port: string) => {
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 };
}
// 제어 노드
if (port === 'in') return { x: node.x, y: node.y + 40 };
// output 포트 — 타입별 위치
const def = CTRL_NODE_TYPES[node.type];
const outPorts = def?.out || [{ port: 'out', label: '→', cls: '' }];
const idx = outPorts.findIndex((p) => p.port === port);
const total = outPorts.length;
const nodeH = 80;
const centerY = node.y + nodeH / 2;
const gap = 8;
const startY = centerY - ((total - 1) * gap) / 2;
return { x: node.x + 160, y: startY + idx * gap };
}, [ruleNodes]);
return (
<>
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */}
<div
style={{ position: 'absolute', inset: 0, zIndex: 5 }}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
{/* 연결선 SVG */}
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
<marker id="arr-rule" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-cyan)" opacity=".8" />
</marker>
<marker id="arr-yes" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--ctrl-green)" opacity=".8" />
</marker>
<marker id="arr-no" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0,8 3,0 6" fill="var(--v5-text-muted, #888)" opacity=".5" />
</marker>
</defs>
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
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)';
return (
<path
key={c.id}
d={bezierPath(f.x, f.y, t.x, t.y)}
className={cls}
markerEnd={marker}
/>
);
})}
</svg>
{/* 연결 삭제 뱃지 */}
{ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port);
const t = portPos(c.to_node_id, c.to_port);
if (!f || !t) return null;
const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2;
return (
<div
key={`badge-${c.id}`}
className="rule-conn-badge"
style={{ left: mx, top: my }}
>
<span
className="conn-x"
onClick={(e) => {
e.stopPropagation();
useControlMode.getState().removeRuleConnection(c.id);
}}
>
</span>
</div>
);
})}
{/* 노드 렌더 */}
{ruleNodes.map((node) => {
if (node.type === 'table') {
return (
<div key={node.id} style={{ position: 'absolute', left: 0, top: 0 }}>
<TableNode
tableName={node.table_name}
label={node.label}
icon="🏢"
columns={node.columns ?? []}
x={node.x}
y={node.y}
onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
style={{ overflow: 'visible' }}
/>
{/* 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>
);
}
return (
<ControlNode
key={node.id}
node={node}
onDragStart={startDrag}
onDragEnd={finishDrag}
/>
);
})}
{/* 설정 팝오버 */}
<NodeConfigPopover />
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useRef, useCallback } from 'react';
interface TableNodeProps {
tableName: string;
label: string;
icon: string;
columns: Record<string, any>[];
x: number;
y: number;
style?: React.CSSProperties;
onMove?: (name: string, x: number, y: number) => void;
}
export function TableNode({ tableName, label, icon, columns, x, y, style, onMove }: TableNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!onMove) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const sl = x, st = y;
const el = nodeRef.current;
if (el) el.style.zIndex = '30';
const move = (ev: MouseEvent) => {
onMove(tableName, sl + ev.clientX - sx, st + ev.clientY - sy);
};
const up = () => {
if (el) el.style.zIndex = '20';
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
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: '🔗',
};
return (
<div
ref={nodeRef}
className="tbl-node"
data-table={tableName}
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';
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>
</div>
);
}
@@ -0,0 +1,166 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* 제어 노드 16종 정의 (mockup CTRL_NODE_TYPES)
* out: 커스텀 출력 포트. 없으면 기본 [{port:'out', label:'→'}]
*/
export const CTRL_NODE_TYPES: Record<string, {
icon: string; label: string; rgb: string; cat: string;
out?: { port: string; label: string; cls: string }[];
}> = {
'timer': { icon: '⏱', label: '타이머', rgb: '0,206,201', cat: '트리거' },
'condition': { icon: '◇', label: '조건분기', rgb: '253,203,110', cat: '조건',
out: [{ port: 'yes', label: 'Y', cls: 'port-yes' }, { port: 'no', label: 'N', cls: 'port-no' }] },
'validation': { icon: '✔', label: '데이터 검증', rgb: '255,107,129', cat: '조건',
out: [{ port: 'pass', label: '✓', cls: 'port-yes' }, { port: 'fail', label: '✗', cls: 'port-no' }] },
'status-change': { icon: '🔄', label: '상태 변경', rgb: '108,92,231', cat: '액션' },
'auto-insert': { icon: '📝', label: '자동 등록', rgb: '85,239,196', cat: '액션' },
'calculation': { icon: '🧮', label: '계산/수식', rgb: '45,152,218', cat: '액션' },
'delete': { icon: '🗑', label: '삭제/보관', rgb: '255,71,87', cat: '액션' },
'document': { icon: '📄', label: '문서 생성', rgb: '162,155,254', cat: '액션' },
'approval': { icon: '✋', label: '승인/결재', rgb: '255,165,2', cat: '흐름',
out: [{ port: 'approved', label: '✓', cls: 'port-yes' }, { port: 'rejected', label: '✗', cls: 'port-no' }] },
'delay': { icon: '⏳', label: '대기/지연', rgb: '72,219,251', cat: '흐름' },
'loop': { icon: '🔁', label: '반복', rgb: '223,142,254', cat: '흐름',
out: [{ port: 'each', label: '→', cls: '' }, { port: 'done', label: '✓', cls: 'port-yes' }] },
'parallel': { icon: '🔀', label: '병렬 실행', rgb: '0,206,201', cat: '흐름' },
'merge': { icon: '⤵', label: '병합/합류', rgb: '149,175,192', cat: '흐름' },
'webhook': { icon: '🌐', label: '외부 호출', rgb: '116,185,255', cat: '연동' },
'notification': { icon: '📨', label: '알림 발송', rgb: '253,121,168', cat: '연동' },
'log': { icon: '📜', label: '로그 기록', rgb: '150,150,160', cat: '기록' },
};
interface ControlModeState {
/** 제어 모드 활성 여부 */
active: boolean;
/** 읽기 / 편집 모드 */
mode: 'view' | 'edit';
/** 활성 흐름 — 클릭된 카드 ID */
activeFlowCardId: string | null;
/** 흐름 엣지 배열 (BFS 결과) */
flowEdges: Record<string, any>[];
/** 테이블 노드 위치 */
tablePositions: Record<string, { x: number; y: number }>;
/** 규칙 빌더 — 노드 */
ruleNodes: Record<string, any>[];
/** 규칙 빌더 — 연결 */
ruleConnections: Record<string, any>[];
/** 현재 편집 중인 룰 ID */
activeRuleId: string | null;
/** 설정 팝오버 대상 노드 ID */
configNodeId: string | null;
// 액션
toggleControlMode: () => void;
setMode: (mode: 'view' | 'edit') => void;
setActiveFlowCard: (cardId: string | null) => void;
setFlowEdges: (edges: Record<string, any>[]) => void;
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
setRuleNodes: (nodes: Record<string, any>[]) => void;
addRuleNode: (node: Record<string, any>) => void;
updateRuleNode: (nodeId: string, updates: Record<string, any>) => void;
removeRuleNode: (nodeId: string) => void;
moveRuleNode: (nodeId: string, x: number, y: number) => void;
setRuleConnections: (conns: Record<string, any>[]) => void;
addRuleConnection: (conn: Record<string, any>) => void;
removeRuleConnection: (connId: string) => void;
setActiveRuleId: (ruleId: string | null) => void;
setConfigNodeId: (nodeId: string | null) => void;
resetControlMode: () => void;
}
let _nodeSeq = 0;
export function genNodeId(prefix: string) { return `${prefix}-${++_nodeSeq}`; }
let _connSeq = 0;
export function genConnId() { return `conn-${++_connSeq}`; }
export const useControlMode = create<ControlModeState>()(
devtools(
(set) => ({
active: false,
mode: 'view',
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
ruleNodes: [],
ruleConnections: [],
activeRuleId: null,
configNodeId: null,
toggleControlMode: () =>
set((s) => ({
active: !s.active,
mode: 'view',
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
configNodeId: null,
})),
setMode: (mode) => set({ mode, configNodeId: null }),
setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }),
setFlowEdges: (edges) => set({ flowEdges: edges }),
setTablePositions: (pos) => set({ tablePositions: pos }),
setRuleNodes: (nodes) => set({ ruleNodes: nodes }),
addRuleNode: (node) => set((s) => ({ ruleNodes: [...s.ruleNodes, node] })),
updateRuleNode: (nodeId, updates) =>
set((s) => ({
ruleNodes: s.ruleNodes.map((n) =>
n.id === nodeId ? { ...n, ...updates } : n
),
})),
removeRuleNode: (nodeId) =>
set((s) => ({
ruleNodes: s.ruleNodes.filter((n) => n.id !== nodeId),
ruleConnections: s.ruleConnections.filter(
(c) => c.from_node_id !== nodeId && c.to_node_id !== nodeId
),
})),
moveRuleNode: (nodeId, x, y) =>
set((s) => ({
ruleNodes: s.ruleNodes.map((n) =>
n.id === nodeId ? { ...n, x, y } : n
),
})),
setRuleConnections: (conns) => set({ ruleConnections: conns }),
addRuleConnection: (conn) =>
set((s) => ({ ruleConnections: [...s.ruleConnections, conn] })),
removeRuleConnection: (connId) =>
set((s) => ({
ruleConnections: s.ruleConnections.filter((c) => c.id !== connId),
})),
setActiveRuleId: (ruleId) => set({ activeRuleId: ruleId }),
setConfigNodeId: (nodeId) => set({ configNodeId: nodeId }),
resetControlMode: () =>
set({
active: false,
mode: 'view',
activeFlowCardId: null,
flowEdges: [],
tablePositions: {},
ruleNodes: [],
ruleConnections: [],
activeRuleId: null,
configNodeId: null,
}),
}),
{ name: 'control-mode-store' }
)
);
@@ -0,0 +1,206 @@
import { useCallback } from 'react';
/**
* 트리 확산 애니메이션용 위치 계산 + 타이밍 (mockup calcFlowPositions 포팅)
*
* 카드 우측에서 depth별로 트리 형태 배치
* depth별 시간 지연으로 선 → 노드 순차 등장
*/
interface FlowEdge {
from: string;
to: string;
type: string;
label: string;
}
/**
* BFS로 카드에서 도달 가능한 전체 체인 계산
* ★ 양방향 탐색: outgoing (from===cur) + incoming (to===cur)
* → inbound 관계도 놓치지 않음
*/
export function buildFlowChain(
rootKey: string,
allEdges: FlowEdge[]
): { edges: FlowEdge[]; depths: Record<string, number> } {
const reachable = new Set([rootKey]);
const queue = [rootKey];
while (queue.length) {
const cur = queue.shift()!;
// ★ outgoing: from === cur
allEdges
.filter((e) => e.from === cur)
.forEach((e) => {
if (!reachable.has(e.to)) {
reachable.add(e.to);
queue.push(e.to);
}
});
// ★ incoming: to === cur (양방향 탐색)
allEdges
.filter((e) => e.to === cur)
.forEach((e) => {
if (!reachable.has(e.from)) {
reachable.add(e.from);
queue.push(e.from);
}
});
}
const edges = allEdges.filter((e) => reachable.has(e.from) && reachable.has(e.to));
// depth 계산 (양방향)
const depths: Record<string, number> = { [rootKey]: 0 };
const q2 = [rootKey];
while (q2.length) {
const cur = q2.shift()!;
// outgoing
edges
.filter((e) => e.from === cur)
.forEach((e) => {
if (depths[e.to] === undefined) {
depths[e.to] = depths[cur] + 1;
q2.push(e.to);
}
});
// incoming
edges
.filter((e) => e.to === cur)
.forEach((e) => {
if (depths[e.from] === undefined) {
depths[e.from] = depths[cur] + 1;
q2.push(e.from);
}
});
}
return { edges, depths };
}
/**
* 카드 우측에 테이블 노드를 트리 형태로 배치
*/
export function calcFlowPositions(
cardRight: number,
cardCenterY: number,
canvasHeight: number,
depths: Record<string, number>
): Record<string, { x: number; y: number }> {
const startX = cardRight + 80;
// depth별 노드 그룹핑 (CARD: 제외)
const depthNodes: Record<number, string[]> = {};
Object.entries(depths).forEach(([name, d]) => {
if (name.startsWith('CARD:')) return;
if (!depthNodes[d]) depthNodes[d] = [];
depthNodes[d].push(name);
});
const maxD = Math.max(1, ...Object.keys(depthNodes).map(Number));
const colGap = Math.max(270, Math.min(350, (1200 - startX - 230) / maxD));
const rowGap = 240;
const pos: Record<string, { x: number; y: number }> = {};
Object.entries(depthNodes).forEach(([dStr, nodes]) => {
const di = parseInt(dStr);
const totalH = nodes.length * rowGap;
const startY = Math.max(20, (canvasHeight - totalH) / 2);
nodes.forEach((name, i) => {
pos[name] = {
x: startX + (di - 1) * colGap,
y: startY + i * rowGap,
};
});
});
return pos;
}
/**
* depth별 애니메이션 타이밍 계산
* 선 → 노드 순서로 연쇄 등장
*/
export function calcAnimationTimings(
edges: FlowEdge[],
depths: Record<string, number>
): { edge: FlowEdge; lineDelay: number; nodeDelay: number }[] {
const STEP = 500;
const NODE_D = 350;
// 엣지를 depth별 그룹핑
const depthEdges: Record<number, FlowEdge[]> = {};
edges.forEach((edge) => {
const fd = depths[edge.from] ?? 0;
const d = fd + 1;
if (!depthEdges[d]) depthEdges[d] = [];
depthEdges[d].push(edge);
});
const result: { edge: FlowEdge; lineDelay: number; nodeDelay: number }[] = [];
const maxDepth = Math.max(0, ...Object.keys(depthEdges).map(Number));
for (let d = 1; d <= maxDepth; d++) {
const edgesAtDepth = depthEdges[d] || [];
const base = 300 + (d - 1) * STEP;
edgesAtDepth.forEach((edge, i) => {
result.push({
edge,
lineDelay: base + i * 120,
nodeDelay: base + i * 120 + NODE_D,
});
});
}
return result;
}
/**
* useFlowAnimation — 흐름 표시 관리 훅
*/
export function useFlowAnimation() {
const showFlow = useCallback(
(
cardId: string,
sourceTable: string,
relations: Record<string, any>[],
cardRect: { right: number; centerY: number },
canvasHeight: number
) => {
// 1. 엣지 구성: 카드 → 소스 테이블 + relations
const rootKey = `CARD:${cardId}`;
const allEdges: FlowEdge[] = [
{ from: rootKey, to: sourceTable, type: 'source', label: '데이터 소스' },
];
relations.forEach((rel) => {
const type = rel.relation_type ?? rel.RELATION_TYPE ?? 'auto';
const label = rel.label ?? rel.LABEL ?? `${rel.source_table ?? rel.SOURCE_TABLE}${rel.target_table ?? rel.TARGET_TABLE}`;
const from = rel.source_table ?? rel.SOURCE_TABLE;
const to = rel.target_table ?? rel.TARGET_TABLE;
if (from && to) {
allEdges.push({ from, to, type, label });
}
});
// 2. BFS 체인 + depth 계산
const { edges, depths } = buildFlowChain(rootKey, allEdges);
// 3. 위치 계산
const positions = calcFlowPositions(
cardRect.right,
cardRect.centerY,
canvasHeight,
depths
);
// 4. 애니메이션 타이밍
const timings = calcAnimationTimings(edges, depths);
return { edges, depths, positions, timings };
},
[]
);
return { showFlow };
}
@@ -0,0 +1,117 @@
import { useRef, useCallback, useEffect } from 'react';
import { useControlMode, genConnId } from './useControlMode';
import { bezierPath } from '../ConnectionLine';
/**
* 포트 연결 드래그 로직 (mockup startPortDrag/finishPortDrag 포팅)
* output 포트 mousedown → 임시 선 → input 포트 mouseup → 연결 생성
*/
export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
const { addRuleConnection, ruleConnections } = useControlMode();
const dragRef = useRef<{
fromNodeId: string;
fromPort: string;
line: SVGPathElement;
x1: number;
y1: number;
} | null>(null);
const startDrag = useCallback((nodeId: string, port: string, e: React.MouseEvent) => {
const cv = canvasRef.current;
if (!cv) return;
// SVG 확보
let svg = cv.querySelector('#rule-svg') as SVGSVGElement | null;
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'rule-svg';
svg.classList.add('ctrl-svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.style.overflow = 'visible';
cv.appendChild(svg);
}
const cr = cv.getBoundingClientRect();
const portEl = (e.target as HTMLElement).closest('.ctrl-io-port') as HTMLElement;
if (!portEl) return;
const pr = portEl.getBoundingClientRect();
const x1 = pr.left + pr.width / 2 - cr.left + cv.scrollLeft;
const y1 = pr.top + pr.height / 2 - cr.top + cv.scrollTop;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.classList.add('rule-temp-line');
line.setAttribute('d', `M${x1},${y1} L${x1},${y1}`);
svg.appendChild(line);
dragRef.current = { fromNodeId: nodeId, fromPort: port, line, x1, y1 };
cv.classList.add('port-dragging');
portEl.classList.add('port-active');
}, [canvasRef]);
const finishDrag = useCallback((toNodeId: string, toPort: string) => {
const d = dragRef.current;
if (!d) return;
// 같은 노드 연결 방지
if (d.fromNodeId === toNodeId) {
cleanup();
return;
}
// 중복 방지
if (ruleConnections.find((c) =>
c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
)) {
cleanup();
return;
}
addRuleConnection({
id: genConnId(),
from_node_id: d.fromNodeId,
from_port: d.fromPort,
to_node_id: toNodeId,
to_port: toPort,
});
cleanup();
}, [addRuleConnection, ruleConnections]);
const cleanup = useCallback(() => {
const d = dragRef.current;
if (!d) return;
d.line.remove();
canvasRef.current?.classList.remove('port-dragging');
document.querySelectorAll('.port-active').forEach((el) => el.classList.remove('port-active'));
document.querySelectorAll('.port-hover').forEach((el) => el.classList.remove('port-hover'));
dragRef.current = null;
}, [canvasRef]);
// 마우스 이동/종료 전역 핸들러
useEffect(() => {
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const cv = canvasRef.current;
if (!cv) return;
const cr = cv.getBoundingClientRect();
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));
};
const onUp = () => {
if (dragRef.current) cleanup();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [canvasRef, cleanup]);
return { startDrag, finishDrag };
}