ERP-node/frontend/components/dataflow/node-editor/FlowEditor.tsx

378 lines
11 KiB
TypeScript

"use client";
/**
* 노드 기반 플로우 에디터 메인 컴포넌트
*/
import { useCallback, useRef, useEffect, useState, useMemo } from "react";
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
import "reactflow/dist/style.css";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { apiClient } from "@/lib/api/client";
import { NodePalette } from "./sidebar/NodePalette";
import { LeftUnifiedToolbar, ToolbarButton } from "@/components/screen/toolbar/LeftUnifiedToolbar";
import { Boxes, Settings } from "lucide-react";
import { PropertiesPanel } from "./panels/PropertiesPanel";
import { ValidationNotification } from "./ValidationNotification";
import { FlowToolbar } from "./FlowToolbar";
import { TableSourceNode } from "./nodes/TableSourceNode";
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
import { ConditionNode } from "./nodes/ConditionNode";
import { InsertActionNode } from "./nodes/InsertActionNode";
import { UpdateActionNode } from "./nodes/UpdateActionNode";
import { DeleteActionNode } from "./nodes/DeleteActionNode";
import { UpsertActionNode } from "./nodes/UpsertActionNode";
import { DataTransformNode } from "./nodes/DataTransformNode";
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
import { CommentNode } from "./nodes/CommentNode";
import { LogNode } from "./nodes/LogNode";
import { validateFlow } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
// 노드 타입들
const nodeTypes = {
// 데이터 소스
tableSource: TableSourceNode,
externalDBSource: ExternalDBSourceNode,
restAPISource: RestAPISourceNode,
referenceLookup: ReferenceLookupNode,
// 변환/조건
condition: ConditionNode,
dataTransform: DataTransformNode,
// 액션
insertAction: InsertActionNode,
updateAction: UpdateActionNode,
deleteAction: DeleteActionNode,
upsertAction: UpsertActionNode,
// 유틸리티
comment: CommentNode,
log: LogNode,
};
/**
* FlowEditor 내부 컴포넌트
*/
interface FlowEditorInnerProps {
initialFlowId?: number | null;
}
// 플로우 에디터 툴바 버튼 설정
const flowToolbarButtons: ToolbarButton[] = [
{
id: "nodes",
label: "노드",
icon: <Boxes className="h-5 w-5" />,
shortcut: "N",
group: "source",
panelWidth: 300,
},
{
id: "properties",
label: "속성",
icon: <Settings className="h-5 w-5" />,
shortcut: "P",
group: "editor",
panelWidth: 350,
},
];
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition, setCenter } = useReactFlow();
// 패널 표시 상태
const [showNodesPanel, setShowNodesPanel] = useState(true);
const [showPropertiesPanelLocal, setShowPropertiesPanelLocal] = useState(false);
const {
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
onNodeDragStart,
addNode,
selectNodes,
selectedNodes,
removeNodes,
undo,
redo,
loadFlow,
} = useFlowEditorStore();
// 🆕 실시간 플로우 검증
const validations = useMemo<FlowValidation[]>(() => {
return validateFlow(nodes, edges);
}, [nodes, edges]);
// 🆕 노드 클릭 핸들러 (검증 패널에서 사용)
const handleValidationNodeClick = useCallback(
(nodeId: string) => {
const node = nodes.find((n) => n.id === nodeId);
if (node) {
selectNodes([nodeId]);
setCenter(node.position.x + 125, node.position.y + 50, {
zoom: 1.5,
duration: 500,
});
}
},
[nodes, selectNodes, setCenter],
);
// 속성 패널 상태 동기화
useEffect(() => {
if (selectedNodes.length > 0 && !showPropertiesPanelLocal) {
setShowPropertiesPanelLocal(true);
}
}, [selectedNodes, showPropertiesPanelLocal]);
// 초기 플로우 로드
useEffect(() => {
const fetchAndLoadFlow = async () => {
if (initialFlowId) {
try {
const response = await apiClient.get(`/dataflow/node-flows/${initialFlowId}`);
if (response.data.success && response.data.data) {
const flow = response.data.data;
const flowData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
loadFlow(
flow.flowId,
flow.flowName,
flow.flowDescription || "",
flowData.nodes || [],
flowData.edges || [],
);
// 🆕 플로우 로드 후 첫 번째 노드 자동 선택
if (flowData.nodes && flowData.nodes.length > 0) {
const firstNode = flowData.nodes[0];
selectNodes([firstNode.id]);
setShowPropertiesPanelLocal(true);
console.log("✅ 첫 번째 노드 자동 선택:", firstNode.id);
}
}
} catch (error) {
console.error("플로우 로드 실패:", error);
}
}
};
fetchAndLoadFlow();
}, [initialFlowId, loadFlow, selectNodes]);
/**
* 노드 선택 변경 핸들러
*/
const onSelectionChange = useCallback(
({ nodes: selectedNodes }: { nodes: any[] }) => {
const selectedIds = selectedNodes.map((node) => node.id);
selectNodes(selectedIds);
console.log("🔍 선택된 노드:", selectedIds);
},
[selectNodes],
);
/**
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo)
*/
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
event.preventDefault();
console.log("⏪ Undo");
undo();
return;
}
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
if (
((event.ctrlKey || event.metaKey) && event.key === "y") ||
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
) {
event.preventDefault();
console.log("⏩ Redo");
redo();
return;
}
// Delete: Delete/Backspace 키로 노드 삭제
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
event.preventDefault();
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
removeNodes(selectedNodes);
}
},
[selectedNodes, removeNodes, undo, redo],
);
/**
* 드래그 앤 드롭 핸들러
*/
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// 🔥 노드 타입별 기본 데이터 설정
const defaultData: any = {
displayName: `${type} 노드`,
};
// REST API 소스 노드의 경우
if (type === "restAPISource") {
defaultData.method = "GET";
defaultData.url = "";
defaultData.headers = {};
defaultData.timeout = 30000;
defaultData.responseFields = []; // 빈 배열로 초기화
defaultData.responseMapping = "";
}
// 액션 노드의 경우 targetType 기본값 설정
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
defaultData.targetType = "internal"; // 기본값: 내부 DB
defaultData.fieldMappings = [];
defaultData.options = {};
if (type === "updateAction" || type === "deleteAction") {
defaultData.whereConditions = [];
}
if (type === "upsertAction") {
defaultData.conflictKeys = [];
}
}
const newNode: any = {
id: `node_${Date.now()}`,
type,
position,
data: defaultData,
};
addNode(newNode);
},
[screenToFlowPosition, addNode],
);
return (
<div className="flex h-full w-full" style={{ height: "100%", overflow: "hidden" }}>
{/* 좌측 통합 툴바 */}
<LeftUnifiedToolbar
buttons={flowToolbarButtons}
panelStates={{
nodes: { isOpen: showNodesPanel },
properties: { isOpen: showPropertiesPanelLocal },
}}
onTogglePanel={(panelId) => {
if (panelId === "nodes") {
setShowNodesPanel(!showNodesPanel);
} else if (panelId === "properties") {
setShowPropertiesPanelLocal(!showPropertiesPanelLocal);
}
}}
/>
{/* 노드 라이브러리 패널 */}
{showNodesPanel && (
<div className="h-full w-[300px] border-r bg-white">
<NodePalette />
</div>
)}
{/* 중앙 캔버스 */}
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
<ReactFlow
nodes={nodes as any}
edges={edges as any}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeDragStart={onNodeDragStart}
onSelectionChange={onSelectionChange}
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
fitView
className="bg-gray-50"
deleteKeyCode={["Delete", "Backspace"]}
>
{/* 배경 그리드 */}
<Background gap={16} size={1} color="#E5E7EB" />
{/* 컨트롤 버튼 */}
<Controls className="bg-white shadow-md" />
{/* 미니맵 */}
<MiniMap
className="bg-white shadow-md"
nodeColor={(node) => {
// 노드 타입별 색상 (추후 구현)
return "#3B82F6";
}}
maskColor="rgba(0, 0, 0, 0.1)"
/>
{/* 상단 툴바 */}
<Panel position="top-center" className="pointer-events-auto">
<FlowToolbar validations={validations} />
</Panel>
</ReactFlow>
</div>
{/* 우측 속성 패널 */}
{showPropertiesPanelLocal && selectedNodes.length > 0 && (
<div
style={{
height: "100%",
width: "350px",
display: "flex",
flexDirection: "column",
}}
className="border-l bg-white"
>
<PropertiesPanel />
</div>
)}
{/* 검증 알림 (우측 상단 플로팅) */}
<ValidationNotification validations={validations} onNodeClick={handleValidationNodeClick} />
</div>
);
}
/**
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
*/
interface FlowEditorProps {
initialFlowId?: number | null;
}
export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
return (
<div className="h-full w-full">
<ReactFlowProvider>
<FlowEditorInner initialFlowId={initialFlowId} />
</ReactFlowProvider>
</div>
);
}