"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 } 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; } // TableRelationship 타입은 dataflow.ts에서 import export const DataFlowDesigner: React.FC = ({ companyCode, onSave }) => { 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); // 키보드 이벤트 핸들러 (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]); // 기존 관계 로드 const loadExistingRelationships = useCallback(async () => { try { const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode); setRelationships(existingRelationships); // 기존 관계를 엣지로 변환하여 표시 const existingEdges = existingRelationships.map((rel) => ({ id: generateUniqueId("edge", rel.relationshipId), source: `table-${rel.fromTableName}`, target: `table-${rel.toTableName}`, sourceHandle: "right", targetHandle: "left", type: "default", data: { relationshipId: rel.relationshipId, relationshipType: rel.relationshipType, connectionType: rel.connectionType, label: rel.relationshipName, fromColumn: rel.fromColumnName, toColumn: rel.toColumnName, }, })); setEdges(existingEdges); } catch (error) { console.error("기존 관계 로드 실패:", error); toast.error("기존 관계를 불러오는데 실패했습니다."); } }, [companyCode, setEdges]); // 컴포넌트 마운트 시 기존 관계 로드 useEffect(() => { if (companyCode) { loadExistingRelationships(); } }, [companyCode, loadExistingRelationships]); // 노드 선택 변경 핸들러 const onSelectionChange = useCallback(({ nodes }: { nodes: Node[] }) => { const selectedNodeIds = nodes.map((node) => node.id); setSelectedNodes(selectedNodeIds); }, []); // 빈 onConnect 함수 (드래그 연결 비활성화) const onConnect = useCallback(() => { // 드래그로 연결하는 것을 방지 return; }, []); // 컬럼 클릭 처리 (토글 방식, 최대 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 { // 선택 추가 - 새로운 테이블이고 이미 2개 테이블이 선택되어 있으면 거부 if (!prev[tableName] && selectedTables.length >= 2) { // 토스트 중복 방지를 위한 ref 사용 if (!toastShownRef.current) { toastShownRef.current = true; setTimeout(() => { toast.error("최대 2개의 테이블에서만 컬럼을 선택할 수 있습니다.", { duration: 3000, position: "top-center", }); // 3초 후 플래그 리셋 setTimeout(() => { toastShownRef.current = false; }, 3000); }, 0); } return prev; } // 새로운 테이블이면 선택 순서에 추가, 기존 테이블이면 맨 뒤로 이동 (다음 렌더링에서) setTimeout(() => { setSelectionOrder((order) => { // 기존에 있던 테이블이면 제거 후 맨 뒤에 추가 (순서 갱신) const filteredOrder = order.filter((name) => name !== tableName); return [...filteredOrder, tableName]; }); }, 0); return { ...prev, [tableName]: [...currentColumns, columnName] }; } }); }, []); // 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 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 newEdge = { id: generateUniqueId("edge", relationship.relationshipId), source: pendingConnection.fromNode.id, target: pendingConnection.toNode.id, sourceHandle: "right", targetHandle: "left", type: "default", data: { relationshipId: relationship.relationshipId, relationshipType: relationship.relationshipType, connectionType: relationship.connectionType, label: relationship.relationshipName, fromColumn: relationship.fromColumnName, toColumn: relationship.toColumnName, }, }; setEdges((eds) => [...eds, newEdge]); 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 키로 삭제 가능
)}
{/* 연결 설정 모달 */}
); };