"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 { AggregateNode } from "./nodes/AggregateNode"; 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, aggregate: AggregateNode, // 액션 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: , shortcut: "N", group: "source", panelWidth: 300, }, { id: "properties", label: "속성", icon: , shortcut: "P", group: "editor", panelWidth: 350, }, ]; function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { const reactFlowWrapper = useRef(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(() => { 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 (
{/* 좌측 통합 툴바 */} { if (panelId === "nodes") { setShowNodesPanel(!showNodesPanel); } else if (panelId === "properties") { setShowPropertiesPanelLocal(!showPropertiesPanelLocal); } }} /> {/* 노드 라이브러리 패널 */} {showNodesPanel && (
)} {/* 중앙 캔버스 */}
{/* 배경 그리드 */} {/* 컨트롤 버튼 */} {/* 미니맵 */} { // 노드 타입별 색상 (추후 구현) return "#3B82F6"; }} maskColor="rgba(0, 0, 0, 0.1)" /> {/* 상단 툴바 */}
{/* 우측 속성 패널 */} {showPropertiesPanelLocal && selectedNodes.length > 0 && (
)} {/* 검증 알림 (우측 상단 플로팅) */}
); } /** * FlowEditor 메인 컴포넌트 (Provider로 감싸기) */ interface FlowEditorProps { initialFlowId?: number | null; } export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) { return (
); }