"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 } from "@/lib/api/dataflow"; // 고유 ID 생성 함수 const generateUniqueId = (prefix: string, relationshipId?: number): string => { const timestamp = Date.now(); const random = Math.random().toString(36).substr(2, 9); return `${prefix}-${relationshipId || 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; relationshipId?: string; onBackToList?: () => void; } // TableRelationship 타입은 dataflow.ts에서 import export const DataFlowDesigner: React.FC = ({ companyCode = "*", relationshipId, onSave, selectedDiagram, onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars }) => { const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedColumns, setSelectedColumns] = useState<{ [tableName: string]: string[]; }>({}); const [selectionOrder, setSelectionOrder] = useState([]); 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[]; }; }; } | null>(null); const [relationships, setRelationships] = useState([]); // eslint-disable-line @typescript-eslint/no-unused-vars const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars // 키보드 이벤트 핸들러 (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; }); // 선택 순서도 정리 setSelectionOrder((prev) => prev.filter((tableName) => !deletedTableNames.includes(tableName))); // 선택된 노드 초기화 setSelectedNodes([]); toast.success(`${selectedNodes.length}개 테이블이 삭제되었습니다.`); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedNodes, setNodes]); // 컬럼 클릭 처리 (토글 방식, 최대 2개 테이블만 허용) const handleColumnClick = useCallback((tableName: string, columnName: string) => { setSelectedColumns((prev) => { const currentColumns = prev[tableName] || []; const isSelected = currentColumns.includes(columnName); const selectedTables = Object.keys(prev).filter((name) => prev[name] && prev[name].length > 0); if (isSelected) { // 선택 해제 const newColumns = currentColumns.filter((column) => column !== columnName); if (newColumns.length === 0) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [tableName]: removed, ...rest } = prev; // 선택 순서에서도 제거 (다음 렌더링에서) setTimeout(() => { setSelectionOrder((order) => order.filter((name) => name !== tableName)); }, 0); return rest; } return { ...prev, [tableName]: newColumns }; } else { // 새 선택 if (selectedTables.length >= 2 && !selectedTables.includes(tableName)) { toast.error("최대 2개 테이블까지만 선택할 수 있습니다."); return prev; } const newColumns = [...currentColumns, columnName]; const newSelection = { ...prev, [tableName]: newColumns }; // 선택 순서 업데이트 (다음 렌더링에서) setTimeout(() => { setSelectionOrder((order) => { if (!order.includes(tableName)) { return [...order, tableName]; } return order; }); }, 0); return newSelection; } }); }, []); // 선택된 관계도의 관계 로드 const loadSelectedDiagramRelationships = useCallback(async () => { if (!relationshipId) return; try { console.log("🔍 관계도 로드 시작 (relationshipId):", relationshipId); toast.loading("관계도를 불러오는 중...", { id: "load-diagram" }); // relationshipId로 해당 관계도의 모든 관계 조회 const diagramRelationships = await DataFlowAPI.getDiagramRelationshipsByRelationshipId(relationshipId); console.log("📋 관계도 관계 데이터:", diagramRelationships); console.log("📋 첫 번째 관계 상세:", diagramRelationships[0]); console.log( "📋 관계 객체 키들:", diagramRelationships[0] ? Object.keys(diagramRelationships[0]) : "배열이 비어있음", ); setRelationships(diagramRelationships); // 관계도의 모든 테이블 추출 const tableNames = new Set(); diagramRelationships.forEach((rel) => { tableNames.add(rel.from_table_name); tableNames.add(rel.to_table_name); }); console.log("📊 추출된 테이블 이름들:", Array.from(tableNames)); // 테이블 정보 로드 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); console.log(`📋 테이블 ${tableName}의 컬럼 수:`, columns.length); tableDefinitions.push({ tableName: foundTable.tableName, displayName: foundTable.displayName, description: foundTable.description, columns: columns, }); } else { console.warn(`⚠️ 테이블 ${tableName}을 찾을 수 없습니다`); } } // 연결된 컬럼 정보 계산 const connectedColumnsInfo: { [tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } }; } = {}; diagramRelationships.forEach((rel) => { const fromTable = rel.from_table_name; const toTable = rel.to_table_name; const fromColumns = rel.from_column_name.split(",").map((col) => col.trim()); const toColumns = rel.to_column_name.split(",").map((col) => col.trim()); // 소스 테이블의 컬럼들을 source로 표시 if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {}; fromColumns.forEach((col) => { if (connectedColumnsInfo[fromTable][col]) { connectedColumnsInfo[fromTable][col].direction = "both"; } else { connectedColumnsInfo[fromTable][col] = { direction: "source" }; } }); // 타겟 테이블의 컬럼들을 target으로 표시 if (!connectedColumnsInfo[toTable]) connectedColumnsInfo[toTable] = {}; toColumns.forEach((col) => { if (connectedColumnsInfo[toTable][col]) { connectedColumnsInfo[toTable][col].direction = "both"; } else { connectedColumnsInfo[toTable][col] = { direction: "target" }; } }); }); // 테이블을 노드로 변환 (자동 레이아웃) const tableNodes = tableDefinitions.map((table, index) => { const x = (index % 3) * 400 + 100; // 3열 배치 const 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: table.description || "", columns: table.columns.map((col) => ({ name: col.columnName, type: col.dataType || "varchar", description: col.description || "", })), }, onColumnClick: handleColumnClick, selectedColumns: selectedColumns[table.tableName] || [], connectedColumns: connectedColumnsInfo[table.tableName] || {}, } as TableNodeData, }; }); console.log("🎨 생성된 테이블 노드 수:", tableNodes.length); console.log("📍 테이블 노드 상세:", tableNodes); setNodes(tableNodes); // 관계를 엣지로 변환하여 표시 (컬럼별 연결) const relationshipEdges: Edge[] = []; diagramRelationships.forEach((rel) => { const fromTable = rel.from_table_name; const toTable = rel.to_table_name; const fromColumns = rel.from_column_name.split(",").map((col) => col.trim()); const toColumns = rel.to_column_name.split(",").map((col) => col.trim()); // 각 from 컬럼을 각 to 컬럼에 연결 (1:1 매핑이거나 many:many인 경우) const maxConnections = Math.max(fromColumns.length, toColumns.length); for (let i = 0; i < maxConnections; i++) { const fromColumn = fromColumns[i] || fromColumns[0]; // 컬럼이 부족하면 첫 번째 컬럼 재사용 const toColumn = toColumns[i] || toColumns[0]; // 컬럼이 부족하면 첫 번째 컬럼 재사용 relationshipEdges.push({ id: generateUniqueId("edge", rel.relationship_id), source: `table-${fromTable}`, target: `table-${toTable}`, sourceHandle: `${fromTable}-${fromColumn}-source`, targetHandle: `${toTable}-${toColumn}-target`, type: "smoothstep", animated: false, style: { stroke: "#3b82f6", strokeWidth: 1.5, strokeDasharray: "none", }, data: { relationshipId: rel.relationship_id, relationshipType: rel.relationship_type, connectionType: rel.connection_type, fromTable: fromTable, toTable: toTable, fromColumn: fromColumn, toColumn: toColumn, }, }); } }); console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length); console.log("📍 관계 에지 상세:", relationshipEdges); setEdges(relationshipEdges); toast.success("관계도를 불러왔습니다.", { id: "load-diagram" }); } catch (error) { console.error("선택된 관계도 로드 실패:", error); toast.error("관계도를 불러오는데 실패했습니다.", { id: "load-diagram" }); } }, [relationshipId, setNodes, setEdges, selectedColumns, handleColumnClick]); // 기존 관계 로드 (새 관계도 생성 시) const loadExistingRelationships = useCallback(async () => { if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음 try { const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode); setRelationships(existingRelationships); // 기존 관계를 엣지로 변환하여 표시 (컬럼별 연결) const existingEdges: Edge[] = []; existingRelationships.forEach((rel) => { const fromTable = rel.from_table_name; const toTable = rel.to_table_name; const fromColumns = rel.from_column_name.split(",").map((col) => col.trim()); const toColumns = rel.to_column_name.split(",").map((col) => col.trim()); // 각 from 컬럼을 각 to 컬럼에 연결 const maxConnections = Math.max(fromColumns.length, toColumns.length); for (let i = 0; i < maxConnections; i++) { const fromColumn = fromColumns[i] || fromColumns[0]; const toColumn = toColumns[i] || toColumns[0]; existingEdges.push({ id: generateUniqueId("edge", rel.relationship_id), source: `table-${fromTable}`, target: `table-${toTable}`, sourceHandle: `${fromTable}-${fromColumn}-source`, targetHandle: `${toTable}-${toColumn}-target`, type: "smoothstep", animated: false, style: { stroke: "#3b82f6", strokeWidth: 1.5, strokeDasharray: "none", }, data: { relationshipId: rel.relationship_id, relationshipType: rel.relationship_type, connectionType: rel.connection_type, fromTable: fromTable, toTable: toTable, fromColumn: fromColumn, toColumn: toColumn, }, }); } }); setEdges(existingEdges); } catch (error) { console.error("기존 관계 로드 실패:", error); toast.error("기존 관계를 불러오는데 실패했습니다."); } }, [companyCode, setEdges, selectedDiagram]); // 컴포넌트 마운트 시 관계 로드 useEffect(() => { if (companyCode) { if (relationshipId) { loadSelectedDiagramRelationships(); } else { loadExistingRelationships(); } } }, [companyCode, relationshipId, loadExistingRelationships, loadSelectedDiagramRelationships]); // 노드 선택 변경 핸들러 const onSelectionChange = useCallback(({ nodes }: { nodes: Node[] }) => { const selectedNodeIds = nodes.map((node) => node.id); setSelectedNodes(selectedNodeIds); }, []); // 빈 onConnect 함수 (드래그 연결 비활성화) const onConnect = useCallback(() => { // 드래그로 연결하는 것을 방지 return; }, []); // 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리 useEffect(() => { setNodes((prevNodes) => prevNodes.map((node) => ({ ...node, data: { ...node.data, selectedColumns: selectedColumns[node.data.table.tableName] || [], }, })), ); // selectionOrder에서 선택되지 않은 테이블들 제거 const activeTables = Object.keys(selectedColumns).filter( (tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0, ); setSelectionOrder((prev) => prev.filter((tableName) => activeTables.includes(tableName))); }, [selectedColumns, setNodes]); // 연결 가능한 상태인지 확인 const canCreateConnection = () => { const selectedTables = Object.keys(selectedColumns).filter( (tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0, ); // 최소 2개의 서로 다른 테이블에서 컬럼이 선택되어야 함 return selectedTables.length >= 2; }; // 컬럼 연결 설정 모달 열기 const openConnectionModal = () => { const selectedTables = Object.keys(selectedColumns).filter( (tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0, ); if (selectedTables.length < 2) return; // 선택 순서에 따라 첫 번째와 두 번째 테이블 설정 const orderedTables = selectionOrder.filter((name) => selectedTables.includes(name)); const firstTableName = orderedTables[0]; const secondTableName = orderedTables[1]; const firstNode = nodes.find((node) => node.data.table.tableName === firstTableName); const secondNode = nodes.find((node) => node.data.table.tableName === secondTableName); if (!firstNode || !secondNode) return; // 첫 번째로 선택된 컬럼들 가져오기 const firstTableColumns = selectedColumns[firstTableName] || []; const secondTableColumns = selectedColumns[secondTableName] || []; 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, }, // 선택된 첫 번째 컬럼을 연결 컬럼으로 설정 fromColumn: firstTableColumns[0] || "", toColumn: secondTableColumns[0] || "", // 선택된 모든 컬럼 정보를 선택 순서대로 전달 selectedColumnsData: (() => { const orderedData: { [key: string]: { displayName: string; columns: string[] } } = {}; // selectionOrder 순서대로 데이터 구성 (첫 번째 선택이 먼저) orderedTables.forEach((tableName) => { const node = nodes.find((n) => n.data.table.tableName === tableName); if (node && selectedColumns[tableName]) { orderedData[tableName] = { displayName: node.data.table.displayName, columns: selectedColumns[tableName], }; } }); return orderedData; })(), }); }; // 실제 테이블 노드 추가 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: table.description || "", columns: table.columns.map((col) => ({ name: col.columnName || "unknown", type: col.dataType || col.dbType || "UNKNOWN", description: col.columnLabel || col.displayName || col.description || col.columnName || "No description", })), }, onColumnClick: handleColumnClick, selectedColumns: selectedColumns[table.tableName] || [], }, }; setNodes((nds) => nds.concat(newNode)); } catch (error) { console.error("테이블 노드 추가 실패:", error); toast.error("테이블 정보를 불러오는데 실패했습니다."); } }, [handleColumnClick, selectedColumns, setNodes], ); // 샘플 테이블 노드 추가 (개발용) const addSampleNode = useCallback(() => { const tableName = `sample_table_${nodes.length + 1}`; const newNode: Node = { id: `sample-${Date.now()}`, type: "tableNode", position: { x: Math.random() * 300, y: Math.random() * 200 }, data: { table: { tableName, displayName: `샘플 테이블 ${nodes.length + 1}`, description: `샘플 테이블 설명 ${nodes.length + 1}`, columns: [ { name: "id", type: "INTEGER", description: "고유 식별자" }, { name: "name", type: "VARCHAR(100)", description: "이름" }, { name: "code", type: "VARCHAR(50)", description: "코드" }, { name: "created_date", type: "TIMESTAMP", description: "생성일시" }, ], }, onColumnClick: handleColumnClick, selectedColumns: selectedColumns[tableName] || [], }, }; setNodes((nds) => nds.concat(newNode)); }, [nodes.length, handleColumnClick, selectedColumns, setNodes]); // 노드 전체 삭제 const clearNodes = useCallback(() => { setNodes([]); setEdges([]); setSelectedColumns({}); setSelectionOrder([]); setSelectedNodes([]); }, [setNodes, setEdges]); // 현재 추가된 테이블명 목록 가져오기 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; // 컬럼별 에지 생성 const newEdges: Edge[] = []; const fromTable = relationship.from_table_name; const toTable = relationship.to_table_name; const fromColumns = relationship.from_column_name.split(",").map((col) => col.trim()); const toColumns = relationship.to_column_name.split(",").map((col) => col.trim()); const maxConnections = Math.max(fromColumns.length, toColumns.length); for (let i = 0; i < maxConnections; i++) { const fromColumn = fromColumns[i] || fromColumns[0]; const toColumn = toColumns[i] || toColumns[0]; newEdges.push({ id: generateUniqueId("edge", relationship.relationship_id), source: pendingConnection.fromNode.id, target: pendingConnection.toNode.id, sourceHandle: `${fromTable}-${fromColumn}-source`, targetHandle: `${toTable}-${toColumn}-target`, type: "smoothstep", animated: false, style: { stroke: "#3b82f6", strokeWidth: 1.5, strokeDasharray: "none", }, data: { relationshipId: relationship.relationship_id, relationshipType: relationship.relationship_type, connectionType: relationship.connection_type, fromTable: fromTable, toTable: toTable, fromColumn: fromColumn, toColumn: toColumn, }, }); } setEdges((eds) => [...eds, ...newEdges]); setRelationships((prev) => [...prev, relationship]); setPendingConnection(null); console.log("관계 생성 완료:", relationship); // 저장 콜백 호출 (필요한 경우) if (onSave) { // 현재 모든 관계를 수집하여 전달 setRelationships((currentRelationships) => { onSave([...currentRelationships, relationship]); return currentRelationships; }); } }, [pendingConnection, setEdges, onSave], ); // 연결 설정 취소 const handleCancelConnection = useCallback(() => { setPendingConnection(null); }, []); return (
{/* 사이드바 */}

테이블 간 데이터 관계 설정

{/* 테이블 선택기 */} {/* 컨트롤 버튼들 */}
{/* 통계 정보 */}
통계
테이블 노드: {nodes.length}개
연결: {edges.length}개
{/* 선택된 컬럼 정보 */} {Object.keys(selectedColumns).length > 0 && (
선택된 컬럼
{[...new Set(selectionOrder)] .filter((tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0) .map((tableName, index, filteredOrder) => { const columns = selectedColumns[tableName]; const node = nodes.find((n) => n.data.table.tableName === tableName); const displayName = node?.data.table.displayName || tableName; return (
{displayName}
{columns.map((column, columnIndex) => (
{column}
))}
{/* 첫 번째 테이블 다음에 화살표 표시 */} {index === 0 && filteredOrder.length > 1 && (
)}
); })}
)}
{/* React Flow 캔버스 */}
{/* 안내 메시지 */} {nodes.length === 0 && (
📊
테이블 간 데이터 관계 설정을 시작하세요
왼쪽 사이드바에서 테이블을 더블클릭하여 추가하세요
테이블 선택 후 Del 키로 삭제 가능
)}
{/* 연결 설정 모달 */}
); };