"use client"; import React, { useState, useCallback, useEffect, useRef } from "react"; import toast from "react-hot-toast"; import { ReactFlow, Node, Edge, Controls, Background, useNodesState, useEdgesState, BackgroundVariant, SelectionMode, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { TableNode } from "./TableNode"; import { TableSelector } from "./TableSelector"; import { ConnectionSetupModal } from "./ConnectionSetupModal"; import { TableDefinition, TableRelationship, DataFlowAPI, DataFlowDiagram, JsonRelationship, CreateDiagramRequest, NodePositions, } from "@/lib/api/dataflow"; import SaveDiagramModal from "./SaveDiagramModal"; import { useAuth } from "@/hooks/useAuth"; // 고유 ID 생성 함수 const generateUniqueId = (prefix: string, diagramId?: number): string => { const timestamp = Date.now(); const random = Math.random().toString(36).substr(2, 9); return `${prefix}-${diagramId || timestamp}-${random}`; }; // 테이블 노드 데이터 타입 정의 interface TableNodeData extends Record { table: { tableName: string; displayName: string; description: string; columns: Array<{ name: string; type: string; description: string; }>; }; onColumnClick: (tableName: string, columnName: string) => void; selectedColumns: string[]; } // 노드 및 엣지 타입 정의 const nodeTypes = { tableNode: TableNode, }; const edgeTypes = {}; interface DataFlowDesignerProps { companyCode?: string; onSave?: (relationships: TableRelationship[]) => void; selectedDiagram?: DataFlowDiagram | string | null; diagramId?: number; relationshipId?: string; // 하위 호환성 유지 onBackToList?: () => void; onDiagramNameUpdate?: (diagramName: string) => void; // 관계도 이름 업데이트 콜백 추가 } // TableRelationship 타입은 dataflow.ts에서 import // 내부에서 사용할 확장된 JsonRelationship 타입 (connectionType 포함) interface ExtendedJsonRelationship extends JsonRelationship { connectionType: "simple-key" | "data-save" | "external-call"; settings?: { control?: { triggerType?: "insert" | "update" | "delete"; conditionTree?: Array<{ id: string; type: string; field?: string; operator_type?: string; value?: unknown; logicalOperator?: string; groupId?: string; groupLevel?: number; }>; }; actions?: Array<{ id: string; name: string; actionType: "insert" | "update" | "delete" | "upsert"; conditions?: Array<{ id: string; type: string; field?: string; operator_type?: string; value?: unknown; logicalOperator?: string; groupId?: string; groupLevel?: number; }>; fieldMappings: Array<{ sourceTable?: string; sourceField: string; targetTable?: string; targetField: string; defaultValue?: string; }>; splitConfig?: { sourceField: string; delimiter: string; targetField: string; }; }>; notes?: string; apiCall?: { url: string; method: string; headers: Array<{ key: string; value: string }>; body: string; successCriteria: string; }; }; } export const DataFlowDesigner: React.FC = ({ companyCode: propCompanyCode = "*", diagramId, relationshipId, // 하위 호환성 유지 onSave, // eslint-disable-line @typescript-eslint/no-unused-vars selectedDiagram, onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars onDiagramNameUpdate, // 관계도 이름 업데이트 콜백 }) => { const { user } = useAuth(); // 실제 사용자 회사 코드 사용 (prop보다 사용자 정보 우선) const companyCode = user?.company_code || user?.companyCode || propCompanyCode; const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedColumns, setSelectedColumns] = useState<{ [tableName: string]: string[]; }>({}); // selectionOrder는 더 이상 사용하지 않음 (테이블 노드 선택 방식으로 변경) const [selectedNodes, setSelectedNodes] = useState([]); const [pendingConnection, setPendingConnection] = useState<{ fromNode: { id: string; tableName: string; displayName: string }; toNode: { id: string; tableName: string; displayName: string }; fromColumn?: string; toColumn?: string; selectedColumnsData?: { [tableName: string]: { displayName: string; columns: string[]; }; }; existingRelationship?: { relationshipName: string; connectionType: string; settings?: Record; }; } | null>(null); const [relationships, setRelationships] = useState([]); // eslint-disable-line @typescript-eslint/no-unused-vars const [currentDiagramId, setCurrentDiagramId] = useState(null); // 현재 화면의 diagram_id const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{ relationshipId: string; relationshipName: string; fromTable: string; toTable: string; fromColumns: string[]; toColumns: string[]; connectionType: string; connectionInfo: string; } | null>(null); // 선택된 엣지 정보 // 새로운 메모리 기반 상태들 const [tempRelationships, setTempRelationships] = useState([]); // 메모리에 저장된 관계들 const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 저장되지 않은 변경사항 const [showSaveModal, setShowSaveModal] = useState(false); // 저장 모달 표시 상태 const [isSaving, setIsSaving] = useState(false); // 저장 중 상태 const [currentDiagramName, setCurrentDiagramName] = useState(""); // 현재 편집 중인 관계도 이름 const [currentDiagramCategory, setCurrentDiagramCategory] = useState("simple-key"); // 현재 관계도의 연결 종류 const [selectedEdgeForEdit, setSelectedEdgeForEdit] = useState(null); // 수정/삭제할 엣지 const [showEdgeActions, setShowEdgeActions] = useState(false); // 엣지 액션 버튼 표시 상태 const [edgeActionPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); // 액션 버튼 위치 (사용하지 않지만 기존 코드 호환성 유지) const [editingRelationshipId, setEditingRelationshipId] = useState(null); // 현재 수정 중인 관계 ID const [showRelationshipListModal, setShowRelationshipListModal] = useState(false); // 관계 목록 모달 표시 상태 const [selectedTablePairRelationships, setSelectedTablePairRelationships] = useState([]); // 선택된 테이블 쌍의 관계들 const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars // 편집 모드일 때 관계도 이름 로드 useEffect(() => { const loadDiagramName = async () => { if (diagramId && diagramId > 0) { try { const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode); if (jsonDiagram && jsonDiagram.diagram_name) { setCurrentDiagramName(jsonDiagram.diagram_name); } } catch (error) { console.error("관계도 이름 로드 실패:", error); } } else { setCurrentDiagramName(""); // 신규 생성 모드 } }; loadDiagramName(); }, [diagramId, companyCode]); // 키보드 이벤트 핸들러 (Del 키로 선택된 노드 삭제) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Delete" && selectedNodes.length > 0) { // 선택된 노드들 삭제 setNodes((prevNodes) => prevNodes.filter((node) => !selectedNodes.includes(node.id))); // 삭제된 노드들과 관련된 선택된 컬럼들도 정리 const deletedTableNames = selectedNodes .filter((nodeId) => nodeId.startsWith("table-")) .map((nodeId) => nodeId.replace("table-", "")); setSelectedColumns((prev) => { const newColumns = { ...prev }; deletedTableNames.forEach((tableName) => { delete newColumns[tableName]; }); return newColumns; }); // selectionOrder는 더 이상 사용하지 않음 // 선택된 노드 초기화 setSelectedNodes([]); toast.success(`${selectedNodes.length}개 테이블이 삭제되었습니다.`); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedNodes, setNodes]); // 컬럼 클릭 처리 비활성화 (테이블 노드 선택 방식으로 변경) const handleColumnClick = useCallback((tableName: string, columnName: string) => { // 컬럼 클릭으로는 더 이상 선택하지 않음 console.log(`컬럼 클릭 무시됨: ${tableName}.${columnName}`); return; }, []); // 선택된 관계도의 관계 로드 const loadSelectedDiagramRelationships = useCallback(async () => { const currentDiagramId = diagramId || (relationshipId ? parseInt(relationshipId) : null); if (!currentDiagramId || isNaN(currentDiagramId)) return; try { console.log("🔍 JSON 관계도 로드 시작 (diagramId):", currentDiagramId); toast.loading("관계도를 불러오는 중...", { id: "load-diagram" }); // 새로운 JSON API로 관계도 조회 const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(currentDiagramId); console.log("📋 JSON 관계도 데이터:", jsonDiagram); if (!jsonDiagram || !jsonDiagram.relationships) { throw new Error("관계도 데이터를 찾을 수 없습니다."); } const relationships = jsonDiagram.relationships.relationships || []; const tableNames = jsonDiagram.relationships.tables || []; console.log("📋 관계 목록:", relationships); console.log("📊 테이블 목록:", tableNames); // 🔥 수정: category 배열에서 각 관계의 connectionType 복원 const categoryMap = new Map(); if (Array.isArray(jsonDiagram.category)) { jsonDiagram.category.forEach((cat: { id: string; category: string }) => { if (cat.id && cat.category) { categoryMap.set(cat.id, cat.category); } }); } // 기존 데이터에서 relationshipName이 없는 경우 기본값 설정 const normalizedRelationships: ExtendedJsonRelationship[] = relationships.map((rel: JsonRelationship) => ({ ...rel, relationshipName: rel.relationshipName || `${rel.fromTable} → ${rel.toTable}`, // 기본값 설정 connectionType: (rel.connectionType || categoryMap.get(rel.id) || "simple-key") as | "simple-key" | "data-save" | "external-call", // category 배열에서 복원 })); // 메모리에 관계 저장 (기존 관계도 편집 시) console.log("🔥 정규화된 관계들:", normalizedRelationships); setTempRelationships(normalizedRelationships); setCurrentDiagramId(currentDiagramId); setCurrentDiagramCategory("simple-key"); // 관계도의 연결 종류 설정 (기본값) // 테이블 노드 생성을 위한 테이블 정보 로드 // 테이블 정보 로드 const allTables = await DataFlowAPI.getTables(); console.log("🏢 전체 테이블 수:", allTables.length); const tableDefinitions: TableDefinition[] = []; for (const tableName of tableNames) { const foundTable = allTables.find((t) => t.tableName === tableName); console.log(`🔍 테이블 ${tableName} 검색 결과:`, foundTable); if (foundTable) { // 각 테이블의 컬럼 정보를 별도로 가져옴 const columns = await DataFlowAPI.getTableColumns(tableName); const safeColumns = Array.isArray(columns) ? columns : []; console.log(`📋 테이블 ${tableName}의 컬럼 수:`, safeColumns.length); tableDefinitions.push({ tableName: foundTable.tableName, displayName: foundTable.displayName, description: foundTable.description, columns: safeColumns, }); } else { console.warn(`⚠️ 테이블 ${tableName}을 찾을 수 없습니다`); } } // 연결된 컬럼 정보 계산 const connectedColumnsInfo: { [tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } }; } = {}; relationships.forEach((rel: JsonRelationship) => { const fromTable = rel.fromTable; const toTable = rel.toTable; const fromColumns = rel.fromColumns || []; const toColumns = rel.toColumns || []; // 소스 테이블의 컬럼들을 source로 표시 if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {}; fromColumns.forEach((col: string) => { if (connectedColumnsInfo[fromTable][col]) { connectedColumnsInfo[fromTable][col].direction = "both"; } else { connectedColumnsInfo[fromTable][col] = { direction: "source" }; } }); // 타겟 테이블의 컬럼들을 target으로 표시 if (!connectedColumnsInfo[toTable]) connectedColumnsInfo[toTable] = {}; toColumns.forEach((col: string) => { if (connectedColumnsInfo[toTable][col]) { connectedColumnsInfo[toTable][col].direction = "both"; } else { connectedColumnsInfo[toTable][col] = { direction: "target" }; } }); }); console.log("🔌 연결된 컬럼 정보:", connectedColumnsInfo); // 저장된 노드 위치 정보 가져오기 const savedNodePositions = jsonDiagram.node_positions || {}; console.log("📍 저장된 노드 위치:", savedNodePositions); // 테이블을 노드로 변환 (저장된 위치 우선 사용, 없으면 자동 레이아웃) const tableNodes = tableDefinitions.map((table, index) => { // 저장된 위치가 있으면 사용, 없으면 자동 배치 const savedPosition = savedNodePositions[table.tableName]; const x = savedPosition ? savedPosition.x : (index % 3) * 400 + 100; // 3열 배치 const y = savedPosition ? savedPosition.y : Math.floor(index / 3) * 300 + 100; return { id: `table-${table.tableName}`, type: "tableNode", position: { x, y }, data: { table: { tableName: table.tableName, displayName: table.displayName, description: "", // 기존 로드된 노드도 description 없이 통일 columns: Array.isArray(table.columns) ? table.columns.map((col) => ({ name: col.columnName, type: col.dataType || "varchar", description: col.description || "", })) : [], }, onColumnClick: handleColumnClick, selectedColumns: [], // 관계도 로드 시에는 빈 상태로 시작 connectedColumns: connectedColumnsInfo[table.tableName] || {}, } as TableNodeData, }; }); console.log("🎨 생성된 테이블 노드 수:", tableNodes.length); console.log("📍 테이블 노드 상세:", tableNodes); setNodes(tableNodes); // JSON 관계를 엣지로 변환하여 표시 (각 관계마다 개별 엣지 생성) const relationshipEdges: Edge[] = []; const tableRelationshipCount: { [key: string]: number } = {}; // 테이블 쌍별 관계 개수 console.log("🔥 엣지 생성 시작 - 관계 개수:", normalizedRelationships.length); normalizedRelationships.forEach((rel: ExtendedJsonRelationship) => { console.log("🔥 관계 처리 중:", rel.id, rel.connectionType, rel.fromTable, "→", rel.toTable); const fromTable = rel.fromTable; const toTable = rel.toTable; const fromColumns = rel.fromColumns || []; const toColumns = rel.toColumns || []; // 🔥 수정: 컬럼 정보가 없어도 엣지는 생성 (data-save 연결 등에서는 컬럼이 없을 수 있음) if (fromColumns.length === 0 || toColumns.length === 0) { console.warn("⚠️ 컬럼 정보가 없지만 엣지는 생성합니다:", { fromColumns, toColumns, connectionType: rel.connectionType, }); } // 테이블 쌍 키 생성 (양방향 동일하게 처리) const tableKey = [fromTable, toTable].sort().join("-"); tableRelationshipCount[tableKey] = (tableRelationshipCount[tableKey] || 0) + 1; const relationshipIndex = tableRelationshipCount[tableKey]; // 각 관계마다 고유한 엣지 생성 (곡선 오프셋으로 구분) const curveOffset = (relationshipIndex - 1) * 30; // 30px씩 오프셋 relationshipEdges.push({ id: `edge-${rel.id}`, // 관계 ID를 기반으로 고유 ID 생성 source: `table-${fromTable}`, target: `table-${toTable}`, type: "smoothstep", animated: false, style: { stroke: "#3b82f6", strokeWidth: 2, strokeDasharray: "none", }, // 여러 관계가 있을 때 곡선 오프셋 적용 ...(relationshipIndex > 1 && { style: { stroke: "#3b82f6", strokeWidth: 2, strokeDasharray: "none", }, pathOptions: { offset: curveOffset, }, }), data: { relationshipId: rel.id, relationshipName: rel.relationshipName, connectionType: rel.connectionType, fromTable: fromTable, toTable: toTable, fromColumns: fromColumns, toColumns: toColumns, // 테이블 쌍의 모든 관계 정보 (엣지 클릭 시 사용) tableKey: tableKey, relationshipIndex: relationshipIndex, // 클릭 시 표시할 상세 정보 details: { connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`, connectionType: rel.connectionType, }, }, }); }); console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length); console.log("📍 관계 에지 상세:", relationshipEdges); console.log( "🔥 최종 엣지 설정 전 확인:", relationshipEdges.map((e) => ({ id: e.id, source: e.source, target: e.target, connectionType: e.data?.connectionType, })), ); setEdges(relationshipEdges); toast.success("관계도를 불러왔습니다.", { id: "load-diagram" }); } catch (error) { console.error("선택된 관계도 로드 실패:", error); toast.error("관계도를 불러오는데 실패했습니다.", { id: "load-diagram" }); } }, [diagramId, relationshipId, setNodes, setEdges, handleColumnClick]); // 기존 관계 로드 (새 관계도 생성 시) const loadExistingRelationships = useCallback(async () => { if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음 try { // 새로운 JSON 기반 시스템에서는 기존 관계를 미리 로드하지 않음 console.log("새 관계도 생성 모드: 빈 캔버스로 시작"); setRelationships([]); // 빈 캔버스로 시작 setEdges([]); } catch (error) { console.error("기존 관계 로드 실패:", error); toast.error("기존 관계를 불러오는데 실패했습니다."); } }, [setEdges, selectedDiagram]); // 컴포넌트 마운트 시 관계 로드 useEffect(() => { if (companyCode) { if (diagramId || relationshipId) { loadSelectedDiagramRelationships(); } else { loadExistingRelationships(); } } }, [companyCode, diagramId, relationshipId, loadExistingRelationships, loadSelectedDiagramRelationships]); // 노드 클릭 핸들러 (커스텀 다중 선택 구현) const onNodeClick = useCallback( (event: React.MouseEvent, node: Node) => { event.stopPropagation(); const nodeId = node.id; const isCurrentlySelected = selectedNodes.includes(nodeId); if (isCurrentlySelected) { // 이미 선택된 노드를 클릭하면 선택 해제 const newSelection = selectedNodes.filter((id) => id !== nodeId); setSelectedNodes(newSelection); // React Flow 노드 상태 업데이트 setNodes((prevNodes) => prevNodes.map((n) => ({ ...n, selected: newSelection.includes(n.id), })), ); } else { // 새로운 노드 선택 let newSelection: string[]; if (selectedNodes.length >= 2) { // 이미 2개가 선택되어 있으면 첫 번째를 제거하고 새로운 것을 추가 (FIFO) newSelection = [selectedNodes[1], nodeId]; } else { // 2개 미만이면 추가 newSelection = [...selectedNodes, nodeId]; } setSelectedNodes(newSelection); // React Flow 노드 상태 업데이트 setNodes((prevNodes) => prevNodes.map((n) => ({ ...n, selected: newSelection.includes(n.id), })), ); } }, [selectedNodes, setNodes], ); // 노드 선택 변경 핸들러 (React Flow 자체 선택 이벤트는 무시) const onSelectionChange = useCallback(() => { // React Flow의 자동 선택 변경은 무시하고 우리의 커스텀 로직만 사용 // 이 함수는 비워두거나 최소한의 동기화만 수행 }, []); // 캔버스 클릭 시 엣지 정보 섹션 닫기 const onPaneClick = useCallback(() => { if (selectedEdgeInfo) { setSelectedEdgeInfo(null); } if (showEdgeActions) { setShowEdgeActions(false); setSelectedEdgeForEdit(null); } // 컬럼 선택 해제 setSelectedColumns({}); }, [selectedEdgeInfo, showEdgeActions]); // 빈 onConnect 함수 (드래그 연결 비활성화) const onConnect = useCallback(() => { // 드래그로 연결하는 것을 방지 return; }, []); // 엣지 클릭 시 연결 정보 표시 및 관련 컬럼 하이라이트 const onEdgeClick = useCallback( (event: React.MouseEvent, edge: Edge) => { event.stopPropagation(); const edgeData = edge.data as { relationshipId: string; relationshipName: string; fromTable: string; toTable: string; fromColumns: string[]; toColumns: string[]; connectionType: string; tableKey: string; relationshipIndex: number; details?: { connectionInfo: string; connectionType: string; }; }; if (edgeData) { // 해당 테이블 쌍의 모든 관계 찾기 const fromTable = edgeData.fromTable; const toTable = edgeData.toTable; const tablePairRelationships = tempRelationships.filter( (rel) => (rel.fromTable === fromTable && rel.toTable === toTable) || (rel.fromTable === toTable && rel.toTable === fromTable), ); console.log(`🔗 ${fromTable} ↔ ${toTable} 간의 관계:`, tablePairRelationships); // 관계가 1개든 여러 개든 항상 관계 목록 모달 표시 setSelectedTablePairRelationships(tablePairRelationships); setShowRelationshipListModal(true); } }, [tempRelationships], ); // 엣지 마우스 엔터 시 색상 변경 const onEdgeMouseEnter = useCallback( (event: React.MouseEvent, edge: Edge) => { setEdges((eds) => eds.map((e) => e.id === edge.id ? { ...e, style: { ...e.style, stroke: "#1d4ed8", // hover 색상 strokeWidth: 3, }, } : e, ), ); }, [setEdges], ); // 엣지 마우스 리브 시 원래 색상으로 복원 const onEdgeMouseLeave = useCallback( (event: React.MouseEvent, edge: Edge) => { setEdges((eds) => eds.map((e) => e.id === edge.id ? { ...e, style: { ...e.style, stroke: "#3b82f6", // 기본 색상 strokeWidth: 2, }, } : e, ), ); }, [setEdges], ); // 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리 useEffect(() => { setNodes((prevNodes) => prevNodes.map((node) => ({ ...node, data: { ...node.data, selectedColumns: selectedColumns[node.data.table.tableName] || [], }, })), ); // selectionOrder는 더 이상 사용하지 않음 }, [selectedColumns, setNodes]); // 연결 가능한 상태인지 확인 (테이블 노드 선택 기반으로 변경) const canCreateConnection = () => { // 최소 2개의 테이블 노드가 선택되어야 함 return selectedNodes.length >= 2; }; // 테이블 노드 연결 설정 모달 열기 (컬럼 선택 불필요) const openConnectionModal = () => { if (selectedNodes.length < 2) return; // 선택된 첫 번째와 두 번째 노드 찾기 const firstNode = nodes.find((node) => node.id === selectedNodes[0]); const secondNode = nodes.find((node) => node.id === selectedNodes[1]); if (!firstNode || !secondNode) return; setPendingConnection({ fromNode: { id: firstNode.id, tableName: firstNode.data.table.tableName, displayName: firstNode.data.table.displayName, }, toNode: { id: secondNode.id, tableName: secondNode.data.table.tableName, displayName: secondNode.data.table.displayName, }, // 컬럼 선택 정보는 제거 (단순 키값 연결에서만 필요) }); }; // 실제 테이블 노드 추가 const addTableNode = useCallback( async (table: TableDefinition) => { try { const newNode: Node = { id: `table-${table.tableName}`, type: "tableNode", position: { x: Math.random() * 300, y: Math.random() * 200 }, data: { table: { tableName: table.tableName, displayName: table.displayName || table.tableName, description: "", // 새로 추가된 노드는 description 없이 통일 columns: Array.isArray(table.columns) ? table.columns.map((col) => ({ name: col.columnName || "unknown", type: col.dataType || "varchar", // 기존과 동일한 기본값 사용 description: col.description || "", })) : [], }, onColumnClick: handleColumnClick, selectedColumns: selectedColumns[table.tableName] || [], connectedColumns: {}, // 새로 추가된 노드는 연결 정보 없음 }, }; setNodes((nds) => nds.concat(newNode)); } catch (error) { console.error("테이블 노드 추가 실패:", error); toast.error("테이블 정보를 불러오는데 실패했습니다."); } }, [handleColumnClick, selectedColumns, setNodes], ); // 기존 clearNodes 함수 제거 (중복 방지) // 현재 추가된 테이블명 목록 가져오기 const getSelectedTableNames = useCallback(() => { return nodes.filter((node) => node.id.startsWith("table-")).map((node) => node.data.table.tableName); }, [nodes]); // 연결 설정 확인 const handleConfirmConnection = useCallback( (relationship: TableRelationship) => { if (!pendingConnection) return; // 메모리 기반 관계 생성 (DB 저장 없이) const fromTable = relationship.from_table_name; const toTable = relationship.to_table_name; const fromColumns = relationship.from_column_name .split(",") .map((col) => col.trim()) .filter((col) => col); const toColumns = relationship.to_column_name .split(",") .map((col) => col.trim()) .filter((col) => col); // JSON 형태의 관계 객체 생성 (중복 필드 제거) const newRelationship: ExtendedJsonRelationship = { id: editingRelationshipId || generateUniqueId("rel", Date.now()), // 수정 모드면 기존 ID 사용 relationshipName: relationship.relationship_name, // 연결 이름 추가 fromTable, toTable, fromColumns, toColumns, connectionType: relationship.connection_type as "simple-key" | "data-save" | "external-call", settings: relationship.settings || {}, }; // 수정 모드인 경우 기존 관계를 교체 if (editingRelationshipId) { setTempRelationships((prev) => prev.filter((rel) => rel.id !== editingRelationshipId)); setEdges((prev) => prev.filter((edge) => edge.data?.relationshipId !== editingRelationshipId)); setEditingRelationshipId(null); // 수정 모드 해제 } // 메모리에 관계 추가 setTempRelationships((prev) => [...prev, newRelationship]); setHasUnsavedChanges(true); console.log("🔥 새 관계 생성:", newRelationship); console.log("🔥 연결 타입:", newRelationship.connectionType); // 첫 번째 관계가 추가되면 관계도의 category를 해당 connectionType으로 설정 if ((tempRelationships || []).length === 0) { setCurrentDiagramCategory(relationship.connection_type); } // 캔버스에 엣지 즉시 표시 const newEdge: Edge = { id: generateUniqueId("edge", Date.now()), source: pendingConnection.fromNode.id, target: pendingConnection.toNode.id, type: "smoothstep", animated: false, style: { stroke: "#3b82f6", strokeWidth: 2, strokeDasharray: "none", }, data: { relationshipId: newRelationship.id, relationshipName: newRelationship.relationshipName, connectionType: newRelationship.connectionType, // 🔥 수정: newRelationship 사용 fromTable, toTable, fromColumns, toColumns, details: { connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`, connectionType: newRelationship.connectionType, // 🔥 수정: newRelationship 사용 }, }, }; console.log("🔥 새 엣지 생성:", newEdge); setEdges((eds) => [...eds, newEdge]); setPendingConnection(null); // 관계 생성 후 선택된 컬럼들 초기화 setSelectedColumns({}); console.log("메모리에 관계 생성 완료:", newRelationship); toast.success("관계가 생성되었습니다. 저장 버튼을 눌러 관계도를 저장하세요."); }, [pendingConnection, setEdges, editingRelationshipId, tempRelationships], ); // 연결 설정 취소 const handleCancelConnection = useCallback(() => { setPendingConnection(null); // 수정 모드였다면 해제 if (editingRelationshipId) { setEditingRelationshipId(null); // 편집 모드 취소 시 선택된 컬럼도 초기화 setSelectedColumns({}); } }, [editingRelationshipId]); // 관계도 저장 함수 const handleSaveDiagram = useCallback( async (diagramName: string) => { // 🔥 수정: 관계가 없어도 노드가 있으면 저장 가능 if (nodes.length === 0) { toast.error("저장할 테이블이 없습니다."); return; } setIsSaving(true); try { // 🔥 수정: 현재 캔버스의 모든 테이블 기반으로 변경 const connectedTables = nodes .map((node) => node.data?.table?.tableName) .filter((tableName) => tableName) .sort(); // 현재 노드 위치 추출 const nodePositions: NodePositions = {}; nodes.forEach((node) => { if (node.data?.table?.tableName) { nodePositions[node.data.table.tableName] = { x: node.position.x, y: node.position.y, }; } }); console.log("🔍 저장할 노드 위치 정보:", nodePositions); console.log("📊 현재 노드 개수:", nodes.length); console.log("📋 연결된 테이블 목록:", connectedTables); console.log("🔗 관계 개수:", (tempRelationships || []).length); // 🔥 주요 연결 타입 변수 제거 (더 이상 사용하지 않음) // 🔥 수정: relationships는 핵심 관계 정보만 포함, settings 전체 제거 const cleanRelationships = (tempRelationships || []).map((rel) => { // 🔥 settings 전체를 제거하고 핵심 정보만 유지 const cleanRel: JsonRelationship = { id: rel.id, fromTable: rel.fromTable, toTable: rel.toTable, relationshipName: rel.relationshipName, connectionType: rel.connectionType, // simple-key가 아닌 경우 컬럼 정보 제거 fromColumns: rel.connectionType === "simple-key" ? rel.fromColumns : [], toColumns: rel.connectionType === "simple-key" ? rel.toColumns : [], }; return cleanRel; }); // 저장 요청 데이터 생성 const createRequest: CreateDiagramRequest = { diagram_name: diagramName, relationships: { relationships: cleanRelationships as JsonRelationship[], tables: connectedTables, }, node_positions: nodePositions, // 🔥 수정: 각 관계별 category 정보를 배열로 저장 category: (tempRelationships || []).map((rel) => ({ id: rel.id, category: rel.connectionType, })), // 🔥 각 관계별 control 정보를 배열로 저장 (전체 실행 조건) control: (tempRelationships || []) .filter((rel) => rel.connectionType === "data-save") .map((rel) => { console.log("🔍 Control 데이터 추출 중:", { id: rel.id, settings: rel.settings, control: rel.settings?.control, settingsKeys: Object.keys(rel.settings || {}), }); const controlData = rel.settings?.control as { triggerType?: "insert" | "update" | "delete"; conditionTree?: Array<{ id: string; type: string; field?: string; operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE"; value?: unknown; logicalOperator?: "AND" | "OR"; groupId?: string; groupLevel?: number; }>; }; console.log("🔍 추출된 controlData:", controlData); console.log("🔍 conditionTree:", controlData?.conditionTree); // 🔥 조건 필터링: 괄호 포함 모든 유효한 조건 유지 const validConditions = (controlData?.conditionTree || []) .filter((cond) => { // 괄호 조건은 항상 유지 if (cond.type === "group-start" || cond.type === "group-end") { return true; } // 실제 조건은 field와 value가 있어야 함 return cond.type === "condition" && cond.field && cond.value !== undefined && cond.value !== ""; }) .map((cond, index) => { // 괄호 조건 처리 if (cond.type === "group-start" || cond.type === "group-end") { return { id: cond.id, type: cond.type, ...(cond.groupId && { groupId: cond.groupId }), ...(cond.groupLevel !== undefined && { groupLevel: cond.groupLevel }), // 첫 번째가 아닐 때만 logicalOperator 포함 ...(index > 0 && cond.logicalOperator && { logicalOperator: cond.logicalOperator }), }; } // 일반 조건 처리 return { id: cond.id, type: cond.type, field: cond.field, operator: cond.operator_type, value: cond.value, dataType: "string", // 기본값 // 첫 번째 조건이 아닐 때만 logicalOperator 포함 ...(index > 0 && cond.logicalOperator && { logicalOperator: cond.logicalOperator }), }; }); return { id: rel.id, // relationships의 id와 동일 triggerType: (controlData?.triggerType as "insert" | "update" | "delete") || "insert", conditions: validConditions, }; }), // 🔥 각 관계별 plan 정보를 배열로 저장 (저장 액션) plan: (tempRelationships || []) .filter((rel) => rel.connectionType === "data-save") .map((rel) => ({ id: rel.id, // relationships의 id와 동일 sourceTable: rel.fromTable, // 🔥 실제 사용자가 설정한 액션들 사용 actions: ( rel.settings?.actions as Array<{ id: string; name: string; actionType: "insert" | "update" | "delete" | "upsert"; fieldMappings: Array<{ sourceTable?: string; sourceField: string; targetTable?: string; targetField: string; defaultValue?: string; transformFunction?: string; }>; splitConfig?: { sourceField: string; delimiter: string; targetField: string; }; conditions?: Array<{ id: string; type: string; field?: string; operator_type?: string; value?: unknown; logicalOperator?: string; groupId?: string; groupLevel?: number; }>; }> )?.map((action) => ({ ...action, // fieldMappings에서 불필요한 transformFunction 제거 fieldMappings: action.fieldMappings?.map((mapping) => ({ sourceTable: mapping.sourceTable, sourceField: mapping.sourceField, targetTable: mapping.targetTable, targetField: mapping.targetField, defaultValue: mapping.defaultValue, // transformFunction 제거 - 불필요한 필드 })), // splitConfig 처리 - 사용자가 설정하지 않은 경우 포함하지 않음 ...(action.splitConfig && (action.splitConfig.sourceField || action.splitConfig.delimiter || action.splitConfig.targetField) && { splitConfig: { sourceField: action.splitConfig.sourceField || "", delimiter: action.splitConfig.delimiter || "", targetField: action.splitConfig.targetField || "", }, }), // 🔥 조건 처리: 괄호 포함 모든 유효한 조건 유지 ...(action.conditions && { conditions: action.conditions .filter((cond) => { // 괄호 조건은 항상 유지 if (cond.type === "group-start" || cond.type === "group-end") { return true; } // 실제 조건은 field와 value가 있어야 함 return cond.type === "condition" && cond.field && cond.value !== undefined && cond.value !== ""; }) .map((cond, index) => { // 괄호 조건 처리 if (cond.type === "group-start" || cond.type === "group-end") { return { id: cond.id, type: cond.type, ...(cond.groupId && { groupId: cond.groupId }), ...(cond.groupLevel !== undefined && { groupLevel: cond.groupLevel }), // 첫 번째가 아닐 때만 logicalOperator 포함 ...(index > 0 && cond.logicalOperator && { logicalOperator: cond.logicalOperator }), }; } // 일반 조건 처리 return { id: cond.id, type: cond.type, field: cond.field, value: cond.value, dataType: "string", // 기본값 operator_type: cond.operator_type, // 첫 번째 조건이 아닐 때만 logicalOperator 포함 ...(index > 0 && cond.logicalOperator && { logicalOperator: cond.logicalOperator }), }; }), }), })) || [], })), }; // 🔍 디버깅: tempRelationships 구조 확인 console.log("🔍 tempRelationships 전체 구조:", JSON.stringify(tempRelationships, null, 2)); tempRelationships.forEach((rel, index) => { console.log(`🔍 관계 ${index + 1} settings:`, rel.settings); console.log(`🔍 관계 ${index + 1} settings.control:`, rel.settings?.control); console.log(`🔍 관계 ${index + 1} settings.actions:`, rel.settings?.actions); }); console.log("🚀 API 요청 데이터:", JSON.stringify(createRequest, null, 2)); let savedDiagram; // 편집 모드 vs 신규 생성 모드 구분 (currentDiagramId 우선 사용) const effectiveDiagramId = currentDiagramId || diagramId; if (effectiveDiagramId && effectiveDiagramId > 0) { // 편집 모드: 기존 관계도 업데이트 savedDiagram = await DataFlowAPI.updateJsonDataFlowDiagram( effectiveDiagramId, createRequest, companyCode, user?.userId || "SYSTEM", ); toast.success(`관계도 "${diagramName}"가 성공적으로 수정되었습니다.`); } else { // 신규 생성 모드: 새로운 관계도 생성 savedDiagram = await DataFlowAPI.createJsonDataFlowDiagram( createRequest, companyCode, user?.userId || "SYSTEM", ); toast.success(`관계도 "${diagramName}"가 성공적으로 생성되었습니다.`); } // 성공 처리 setHasUnsavedChanges(false); setShowSaveModal(false); setCurrentDiagramId(savedDiagram.diagram_id); // 관계도 이름 업데이트 (편집 모드일 때만) if (effectiveDiagramId && effectiveDiagramId > 0 && onDiagramNameUpdate) { onDiagramNameUpdate(diagramName); } console.log("관계도 저장 완료:", savedDiagram); } catch (error) { console.error("관계도 저장 실패:", error); toast.error("관계도 저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } }, [tempRelationships, diagramId, currentDiagramId, companyCode, user?.userId, nodes, onDiagramNameUpdate], ); // 저장 모달 열기 const handleOpenSaveModal = useCallback(() => { // 관계가 0개여도 저장 가능하도록 수정 setShowSaveModal(true); }, []); // 저장 모달 닫기 const handleCloseSaveModal = useCallback(() => { if (!isSaving) { setShowSaveModal(false); } }, [isSaving]); // 고립된 노드 제거 함수 const removeOrphanedNodes = useCallback( (updatedRelationships: ExtendedJsonRelationship[], showMessage = true) => { setNodes((currentNodes) => { // 현재 관계에서 사용되는 테이블들 추출 const usedTables = new Set(); updatedRelationships.forEach((rel) => { usedTables.add(rel.fromTable); usedTables.add(rel.toTable); }); // 사용되지 않는 노드들 찾기 const orphanedNodes = currentNodes.filter((node) => { const tableName = node.data.table.tableName; return !usedTables.has(tableName); }); // 연결된 노드들만 유지 const connectedNodes = currentNodes.filter((node) => { const tableName = node.data.table.tableName; return usedTables.has(tableName); }); if (orphanedNodes.length > 0 && showMessage) { const orphanedTableNames = orphanedNodes.map((node) => node.data.table.displayName).join(", "); toast(`${orphanedNodes.length}개의 연결되지 않은 테이블 노드가 제거되었습니다: ${orphanedTableNames}`, { duration: 4000, }); } return connectedNodes; }); }, [setNodes], ); // 엣지 삭제 핸들러 const handleDeleteEdge = useCallback(() => { if (!selectedEdgeForEdit) return; const edgeData = selectedEdgeForEdit.data as { relationshipId: string; fromTable: string; toTable: string; }; // tempRelationships에서 해당 관계 제거 const updatedRelationships = tempRelationships.filter((rel) => rel.id !== edgeData.relationshipId); setTempRelationships(updatedRelationships); // 엣지 제거 setEdges((prev) => prev.filter((edge) => edge.id !== selectedEdgeForEdit.id)); // 고립된 노드 제거 removeOrphanedNodes(updatedRelationships); // 상태 초기화 setShowEdgeActions(false); setSelectedEdgeForEdit(null); setSelectedEdgeInfo(null); setSelectedColumns({}); setHasUnsavedChanges(true); toast.success("관계가 삭제되었습니다."); }, [selectedEdgeForEdit, tempRelationships, setEdges, removeOrphanedNodes]); // 엣지 수정 핸들러 (수정 모드 전환) const handleEditEdge = useCallback(() => { if (!selectedEdgeForEdit) return; const edgeData = selectedEdgeForEdit.data as { relationshipId: string; relationshipName: string; fromTable: string; toTable: string; fromColumns: string[]; toColumns: string[]; connectionType: string; }; // 기존 관계 찾기 const existingRelationship = tempRelationships.find((rel) => rel.id === edgeData.relationshipId); if (!existingRelationship) { toast.error("수정할 관계를 찾을 수 없습니다."); return; } // 수정 모드로 전환 (관계는 제거하지 않음) setEditingRelationshipId(edgeData.relationshipId); // 기존 관계를 기반으로 연결 정보 구성 const fromNode = nodes.find((node) => node.data.table.tableName === edgeData.fromTable); const toNode = nodes.find((node) => node.data.table.tableName === edgeData.toTable); if (!fromNode || !toNode) { toast.error("연결된 테이블을 찾을 수 없습니다."); return; } const connectionInfo = { fromNode: { id: fromNode.id, tableName: fromNode.data.table.tableName, displayName: fromNode.data.table.displayName, }, toNode: { id: toNode.id, tableName: toNode.data.table.tableName, displayName: toNode.data.table.displayName, }, selectedColumnsData: { [edgeData.fromTable]: { displayName: fromNode.data.table.displayName, columns: edgeData.fromColumns, }, [edgeData.toTable]: { displayName: toNode.data.table.displayName, columns: edgeData.toColumns, }, }, // 기존 관계 정보 추가 (연결 이름 유지를 위해) existingRelationship: { relationshipName: existingRelationship.relationshipName, connectionType: existingRelationship.connectionType, settings: existingRelationship.settings, }, }; // ConnectionSetupModal을 위한 연결 정보 설정 setPendingConnection(connectionInfo); // 상태 초기화 setShowEdgeActions(false); setSelectedEdgeForEdit(null); setSelectedEdgeInfo(null); toast("관계 수정 모드입니다. 원하는 대로 설정을 변경하고 확인을 눌러주세요.", { duration: 3000, }); }, [selectedEdgeForEdit, tempRelationships, nodes]); // 전체 삭제 핸들러 const clearNodes = useCallback(() => { setNodes([]); setEdges([]); setTempRelationships([]); setSelectedColumns({}); setSelectedNodes([]); setPendingConnection(null); setSelectedEdgeInfo(null); setShowEdgeActions(false); setSelectedEdgeForEdit(null); setHasUnsavedChanges(true); toast.success("모든 테이블과 관계가 삭제되었습니다."); }, [setNodes, setEdges]); return (
{/* 사이드바 */}

테이블 간 데이터 관계 설정

{/* 테이블 선택기 */} {/* 컨트롤 버튼들 */}
{/* 통계 정보 */}
통계
테이블 노드: {nodes.length}개
연결: {edges.length}개
메모리 관계: {(tempRelationships || []).length}개
관계도 ID: {currentDiagramId || "미설정"}
연결 종류: {currentDiagramCategory === "simple-key" && "단순 키값"} {currentDiagramCategory === "data-save" && "데이터 저장"} {currentDiagramCategory === "external-call" && "외부 호출"}
{hasUnsavedChanges && (
⚠️ 저장되지 않은 변경사항이 있습니다
)}
{/* 선택된 컬럼 정보 */}
{/* React Flow 캔버스 */}
{/* 관계 목록 모달 - 캔버스 내부 우측 상단에 배치 */} {showRelationshipListModal && (
{/* 헤더 */}
🔗
테이블 간 관계 목록
{/* 관계 목록 */}
{selectedTablePairRelationships.map((relationship) => (

{relationship.fromTable} → {relationship.toTable}

{/* 편집 버튼 */} {/* 삭제 버튼 */}

타입: {relationship.connectionType}

From: {relationship.fromTable}

To: {relationship.toTable}

))}
)}
{/* 선택된 테이블 노드 팝업 - 캔버스 좌측 상단 고정 (새 관계 생성 시에만 표시) */} {selectedNodes.length > 0 && !showEdgeActions && !pendingConnection && !editingRelationshipId && (
{/* 헤더 */}
📋
선택된 테이블
{selectedNodes.length === 1 ? "FROM 테이블 선택됨" : selectedNodes.length === 2 ? "FROM → TO 연결 준비" : `${selectedNodes.length}개 테이블`}
{/* 컨텐츠 */}
{selectedNodes.map((nodeId, index) => { const node = nodes.find((n) => n.id === nodeId); if (!node) return null; const { tableName, displayName } = node.data.table; return (
{/* 테이블 정보 */}
{displayName}
{selectedNodes.length === 2 && (
{index === 0 ? "FROM" : "TO"}
)}
{tableName}
{/* 연결 화살표 (마지막이 아닌 경우) */} {index < selectedNodes.length - 1 && (
)}
); })}
{/* 액션 버튼 */}
)} {/* 안내 메시지 */} {nodes.length === 0 && (
📊
테이블 간 데이터 관계 설정을 시작하세요
왼쪽 사이드바에서 테이블을 더블클릭하여 추가하세요
테이블 선택 후 Del 키로 삭제 가능
)}
{/* 연결 설정 모달 */} {/* 엣지 정보 및 액션 버튼 */} {showEdgeActions && selectedEdgeForEdit && selectedEdgeInfo && (
{/* 헤더 */}
🔗
{selectedEdgeInfo.relationshipName}
데이터 관계 정보
{/* 관계 정보 요약 */}
연결 유형
{selectedEdgeInfo.connectionType}
{/* 연결 정보 */}
{/* From 테이블 */}
FROM
{selectedEdgeInfo.fromTable}
{selectedEdgeInfo.fromColumns.map((column, index) => ( {column} ))}
{/* 관계 화살표 */}
{/* To 테이블 */}
TO
{selectedEdgeInfo.toTable}
{selectedEdgeInfo.toColumns.map((column, index) => ( {column} ))}
{/* 액션 버튼 */}
)} {/* 관계도 저장 모달 */} 0 && currentDiagramName ? currentDiagramName // 편집 모드: 기존 관계도 이름 : `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름 } isLoading={isSaving} />
); };