중간 세이브
This commit is contained in:
@@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user