"use client"; import React, { useState, useCallback, useEffect, useRef } from "react"; import toast from "react-hot-toast"; import { ReactFlow, Node, Edge, Controls, Background, MiniMap, useNodesState, useEdgesState, addEdge, Connection, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { ScreenNode } from "./ScreenNode"; import { CustomEdge } from "./CustomEdge"; import { ScreenSelector } from "./ScreenSelector"; import { ConnectionSetupModal } from "./ConnectionSetupModal"; import { DataFlowAPI, ScreenDefinition, ColumnInfo, ScreenWithFields } from "@/lib/api/dataflow"; // 노드 및 엣지 타입 정의 const nodeTypes = { screenNode: ScreenNode, }; const edgeTypes = { customEdge: CustomEdge, }; interface DataFlowDesignerProps { companyCode: string; onSave?: (relationships: any[]) => void; } export const DataFlowDesigner: React.FC = ({ companyCode, onSave }) => { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedFields, setSelectedFields] = useState<{ [screenId: string]: string[]; }>({}); const [selectionOrder, setSelectionOrder] = useState([]); const [loadingScreens, setLoadingScreens] = useState>(new Set()); const [pendingConnection, setPendingConnection] = useState<{ fromNode: { id: string; screenName: string; tableName: string }; toNode: { id: string; screenName: string; tableName: string }; fromField?: string; toField?: string; selectedFieldsData?: { [screenId: string]: { screenName: string; fields: string[]; }; }; } | null>(null); const [isOverNodeScrollArea, setIsOverNodeScrollArea] = useState(false); const toastShownRef = useRef(false); // 빈 onConnect 함수 (드래그 연결 비활성화) const onConnect = useCallback(() => { // 드래그로 연결하는 것을 방지 return; }, []); // 필드 클릭 처리 (토글 방식, 최대 2개 화면만 허용) const handleFieldClick = useCallback((screenId: string, fieldName: string) => { setSelectedFields((prev) => { const currentFields = prev[screenId] || []; const isSelected = currentFields.includes(fieldName); const selectedScreens = Object.keys(prev).filter((id) => prev[id] && prev[id].length > 0); if (isSelected) { // 선택 해제 const newFields = currentFields.filter((field) => field !== fieldName); if (newFields.length === 0) { const { [screenId]: _, ...rest } = prev; // 선택 순서에서도 제거 (다음 렌더링에서) setTimeout(() => { setSelectionOrder((order) => order.filter((id) => id !== screenId)); }, 0); return rest; } return { ...prev, [screenId]: newFields }; } else { // 선택 추가 - 새로운 화면이고 이미 2개 화면이 선택되어 있으면 거부 if (!prev[screenId] && selectedScreens.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((id) => id !== screenId); return [...filteredOrder, screenId]; }); }, 0); return { ...prev, [screenId]: [...currentFields, fieldName] }; } }); }, []); // 선택된 필드가 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리 useEffect(() => { setNodes((prevNodes) => prevNodes.map((node) => ({ ...node, data: { ...node.data, selectedFields: selectedFields[node.data.screen.screenId] || [], }, })), ); // selectionOrder에서 선택되지 않은 화면들 제거 const activeScreens = Object.keys(selectedFields).filter( (screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0, ); setSelectionOrder((prev) => prev.filter((screenId) => activeScreens.includes(screenId))); }, [selectedFields, setNodes]); // 연결 가능한 상태인지 확인 const canCreateConnection = () => { const selectedScreens = Object.keys(selectedFields).filter( (screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0, ); // 최소 2개의 서로 다른 테이블에서 필드가 선택되어야 함 return selectedScreens.length >= 2; }; // 필드 연결 설정 모달 열기 const openConnectionModal = () => { const selectedScreens = Object.keys(selectedFields).filter( (screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0, ); if (selectedScreens.length < 2) return; // 선택 순서에 따라 첫 번째와 두 번째 화면 설정 const orderedScreens = selectionOrder.filter((id) => selectedScreens.includes(id)); const firstScreenId = orderedScreens[0]; const secondScreenId = orderedScreens[1]; const firstNode = nodes.find((node) => node.data.screen.screenId === firstScreenId); const secondNode = nodes.find((node) => node.data.screen.screenId === secondScreenId); if (!firstNode || !secondNode) return; setPendingConnection({ fromNode: { id: firstNode.id, screenName: firstNode.data.screen.screenName, tableName: firstNode.data.screen.tableName, }, toNode: { id: secondNode.id, screenName: secondNode.data.screen.screenName, tableName: secondNode.data.screen.tableName, }, // 선택된 모든 필드 정보를 선택 순서대로 전달 selectedFieldsData: (() => { const orderedData: { [key: string]: { screenName: string; fields: string[] } } = {}; // selectionOrder 순서대로 데이터 구성 (첫 번째 선택이 먼저) orderedScreens.forEach((screenId) => { const node = nodes.find((n) => n.data.screen.screenId === screenId); if (node && selectedFields[screenId]) { orderedData[screenId] = { screenName: node.data.screen.screenName, fields: selectedFields[screenId], }; } }); return orderedData; })(), // 명시적인 순서 정보 전달 orderedScreenIds: orderedScreens, }); }; // 실제 화면 노드 추가 const addScreenNode = useCallback( async (screen: ScreenDefinition) => { try { setLoadingScreens((prev) => new Set(prev).add(screen.screenId)); // 테이블 컬럼 정보 조회 const columns = await DataFlowAPI.getTableColumns(screen.tableName); const newNode: Node = { id: `screen-${screen.screenId}`, type: "screenNode", position: { x: Math.random() * 300, y: Math.random() * 200 }, data: { screen: { screenId: screen.screenId.toString(), screenName: screen.screenName, screenCode: screen.screenCode, tableName: screen.tableName, fields: columns.map((col) => ({ name: col.columnName || "unknown", type: col.dataType || col.dbType || "UNKNOWN", description: col.columnLabel || col.displayName || col.description || col.columnName || "No description", })), }, onFieldClick: handleFieldClick, onScrollAreaEnter: () => setIsOverNodeScrollArea(true), onScrollAreaLeave: () => setIsOverNodeScrollArea(false), selectedFields: selectedFields[screen.screenId] || [], }, }; setNodes((nds) => nds.concat(newNode)); } catch (error) { console.error("화면 노드 추가 실패:", error); alert("화면 정보를 불러오는데 실패했습니다."); } finally { setLoadingScreens((prev) => { const newSet = new Set(prev); newSet.delete(screen.screenId); return newSet; }); } }, [handleFieldClick, setNodes], ); // 샘플 화면 노드 추가 (개발용) const addSampleNode = useCallback(() => { const newNode: Node = { id: `sample-${Date.now()}`, type: "screenNode", position: { x: Math.random() * 300, y: Math.random() * 200 }, data: { screen: { screenId: `sample-${Date.now()}`, screenName: `샘플 화면 ${nodes.length + 1}`, screenCode: `SAMPLE${nodes.length + 1}`, tableName: `sample_table_${nodes.length + 1}`, fields: [ { name: "id", type: "INTEGER", description: "고유 식별자" }, { name: "name", type: "VARCHAR(100)", description: "이름" }, { name: "code", type: "VARCHAR(50)", description: "코드" }, { name: "created_date", type: "TIMESTAMP", description: "생성일시" }, ], }, onFieldClick: handleFieldClick, }, }; setNodes((nds) => nds.concat(newNode)); }, [nodes.length, handleFieldClick, setNodes]); // 노드 전체 삭제 const clearNodes = useCallback(() => { setNodes([]); setEdges([]); }, [setNodes, setEdges]); // 현재 추가된 화면 ID 목록 가져오기 const getSelectedScreenIds = useCallback(() => { return nodes .filter((node) => node.id.startsWith("screen-")) .map((node) => parseInt(node.id.replace("screen-", ""))) .filter((id) => !isNaN(id)); }, [nodes]); // 연결 설정 확인 const handleConfirmConnection = useCallback( (config: any) => { if (!pendingConnection) return; const newEdge = { id: `edge-${Date.now()}`, source: pendingConnection.fromNode.id, target: pendingConnection.toNode.id, type: "customEdge", data: { relationshipType: config.relationshipType, connectionType: config.connectionType, label: config.relationshipName, }, }; setEdges((eds) => [...eds, newEdge]); setPendingConnection(null); // TODO: 백엔드 API 호출하여 관계 저장 console.log("연결 설정:", config); }, [pendingConnection, setEdges], ); // 연결 설정 취소 const handleCancelConnection = useCallback(() => { setPendingConnection(null); }, []); return (
{/* 사이드바 */}

데이터 흐름 관리

{/* 회사 정보 */}
회사 코드
{companyCode}
{/* 화면 선택기 */} {/* 컨트롤 버튼들 */}
{/* 통계 정보 */}
통계
화면 노드: {nodes.length}개
연결: {edges.length}개
{/* 선택된 필드 정보 */} {Object.keys(selectedFields).length > 0 && (
선택된 필드
{[...new Set(selectionOrder)] .filter((screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0) .map((screenId, index, filteredOrder) => { const fields = selectedFields[screenId]; const node = nodes.find((n) => n.data.screen.screenId === screenId); const screenName = node?.data.screen.screenName || screenId; return (
{screenName}
ID: {screenId}
{fields.map((field, fieldIndex) => (
{field}
))}
{/* 첫 번째 화면 다음에 화살표 표시 */} {index === 0 && filteredOrder.length > 1 && (
)}
); })}
)}
{/* React Flow 캔버스 */}
{ switch (node.type) { case "screenNode": return "#3B82F6"; default: return "#6B7280"; } }} /> {/* 안내 메시지 */} {nodes.length === 0 && (
📊
데이터 흐름 설계를 시작하세요
왼쪽 사이드바에서 화면을 선택하여 추가하세요
)}
{/* 연결 설정 모달 */}
); };