ERP-node/frontend/components/dataflow/DataFlowDesigner.tsx

866 lines
33 KiB
TypeScript
Raw Normal View History

2025-09-05 11:30:27 +09:00
"use client";
2025-09-05 16:19:31 +09:00
import React, { useState, useCallback, useEffect, useRef } from "react";
import toast from "react-hot-toast";
2025-09-05 11:30:27 +09:00
import {
ReactFlow,
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
2025-09-05 18:00:18 +09:00
BackgroundVariant,
2025-09-08 10:33:00 +09:00
SelectionMode,
2025-09-05 11:30:27 +09:00
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
2025-09-05 18:00:18 +09:00
import { TableNode } from "./TableNode";
import { TableSelector } from "./TableSelector";
2025-09-05 16:19:31 +09:00
import { ConnectionSetupModal } from "./ConnectionSetupModal";
2025-09-09 11:35:05 +09:00
import { TableDefinition, TableRelationship, DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
2025-09-08 16:46:53 +09:00
// 고유 ID 생성 함수
const generateUniqueId = (prefix: string, diagramId?: number): string => {
2025-09-08 16:46:53 +09:00
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 9);
return `${prefix}-${diagramId || timestamp}-${random}`;
2025-09-08 16:46:53 +09:00
};
2025-09-05 18:00:18 +09:00
// 테이블 노드 데이터 타입 정의
interface TableNodeData extends Record<string, unknown> {
table: {
tableName: string;
displayName: string;
description: string;
columns: Array<{
name: string;
type: string;
description: string;
}>;
};
onColumnClick: (tableName: string, columnName: string) => void;
selectedColumns: string[];
}
2025-09-05 11:30:27 +09:00
// 노드 및 엣지 타입 정의
const nodeTypes = {
2025-09-05 18:00:18 +09:00
tableNode: TableNode,
2025-09-05 11:30:27 +09:00
};
2025-09-05 18:00:18 +09:00
const edgeTypes = {};
2025-09-05 11:30:27 +09:00
interface DataFlowDesignerProps {
companyCode?: string;
2025-09-05 18:00:18 +09:00
onSave?: (relationships: TableRelationship[]) => void;
selectedDiagram?: DataFlowDiagram | string | null;
diagramId?: number;
relationshipId?: string; // 하위 호환성 유지
2025-09-09 11:35:05 +09:00
onBackToList?: () => void;
2025-09-05 18:00:18 +09:00
}
2025-09-08 16:46:53 +09:00
// TableRelationship 타입은 dataflow.ts에서 import
2025-09-05 11:30:27 +09:00
2025-09-09 11:35:05 +09:00
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode = "*",
diagramId,
relationshipId, // 하위 호환성 유지
2025-09-09 11:35:05 +09:00
onSave,
selectedDiagram,
2025-09-09 12:00:58 +09:00
onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars
2025-09-09 11:35:05 +09:00
}) => {
2025-09-05 18:00:18 +09:00
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selectedColumns, setSelectedColumns] = useState<{
[tableName: string]: string[];
2025-09-05 16:19:31 +09:00
}>({});
const [selectionOrder, setSelectionOrder] = useState<string[]>([]);
2025-09-05 18:21:28 +09:00
const [selectedNodes, setSelectedNodes] = useState<string[]>([]);
2025-09-05 16:19:31 +09:00
const [pendingConnection, setPendingConnection] = useState<{
2025-09-05 18:00:18 +09:00
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[];
2025-09-05 16:19:31 +09:00
};
};
2025-09-05 11:30:27 +09:00
} | null>(null);
const [relationships, setRelationships] = useState<TableRelationship[]>([]); // eslint-disable-line @typescript-eslint/no-unused-vars
const [currentDiagramId, setCurrentDiagramId] = useState<number | null>(null); // 현재 화면의 diagram_id
2025-09-09 12:00:58 +09:00
const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars
2025-09-05 11:30:27 +09:00
2025-09-05 18:21:28 +09:00
// 키보드 이벤트 핸들러 (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]);
2025-09-09 11:35:05 +09:00
// 컬럼 클릭 처리 (토글 방식, 최대 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 () => {
const currentDiagramId = diagramId || (relationshipId ? parseInt(relationshipId) : null);
if (!currentDiagramId || isNaN(currentDiagramId)) return;
2025-09-09 11:35:05 +09:00
try {
console.log("🔍 관계도 로드 시작 (diagramId):", currentDiagramId);
2025-09-09 11:35:05 +09:00
toast.loading("관계도를 불러오는 중...", { id: "load-diagram" });
// diagramId로 해당 관계도의 모든 관계 조회
const diagramRelationships = await DataFlowAPI.getDiagramRelationshipsByDiagramId(currentDiagramId);
2025-09-09 11:35:05 +09:00
console.log("📋 관계도 관계 데이터:", diagramRelationships);
if (!Array.isArray(diagramRelationships)) {
throw new Error("관계도 데이터 형식이 올바르지 않습니다.");
}
2025-09-09 11:35:05 +09:00
console.log("📋 첫 번째 관계 상세:", diagramRelationships[0]);
console.log(
"📋 관계 객체 키들:",
diagramRelationships[0] ? Object.keys(diagramRelationships[0]) : "배열이 비어있음",
);
setRelationships(diagramRelationships);
// 현재 diagram_id 설정 (기존 관계도 편집 시)
if (diagramRelationships.length > 0) {
setCurrentDiagramId(diagramRelationships[0].diagram_id || null);
}
2025-09-09 11:35:05 +09:00
// 관계도의 모든 테이블 추출
const tableNames = new Set<string>();
diagramRelationships.forEach((rel) => {
if (rel && rel.from_table_name && rel.to_table_name) {
tableNames.add(rel.from_table_name);
tableNames.add(rel.to_table_name);
}
2025-09-09 11:35:05 +09:00
});
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}을 찾을 수 없습니다`);
}
}
2025-09-09 12:00:58 +09:00
// 연결된 컬럼 정보 계산
const connectedColumnsInfo: {
[tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } };
} = {};
diagramRelationships.forEach((rel) => {
if (!rel || !rel.from_table_name || !rel.to_table_name || !rel.from_column_name || !rel.to_column_name) {
console.warn("⚠️ 관계 데이터가 불완전합니다:", rel);
return;
}
2025-09-09 12:00:58 +09:00
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
const toColumns = rel.to_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
2025-09-09 12:00:58 +09:00
// 소스 테이블의 컬럼들을 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" };
}
});
});
2025-09-09 11:35:05 +09:00
// 테이블을 노드로 변환 (자동 레이아웃)
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] || [],
2025-09-09 12:00:58 +09:00
connectedColumns: connectedColumnsInfo[table.tableName] || {},
2025-09-09 11:35:05 +09:00
} as TableNodeData,
};
});
console.log("🎨 생성된 테이블 노드 수:", tableNodes.length);
console.log("📍 테이블 노드 상세:", tableNodes);
setNodes(tableNodes);
2025-09-09 12:00:58 +09:00
// 관계를 엣지로 변환하여 표시 (컬럼별 연결)
const relationshipEdges: Edge[] = [];
diagramRelationships.forEach((rel) => {
if (!rel || !rel.from_table_name || !rel.to_table_name || !rel.from_column_name || !rel.to_column_name) {
console.warn("⚠️ 에지 생성 시 관계 데이터가 불완전합니다:", rel);
return;
}
2025-09-09 12:00:58 +09:00
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
const toColumns = rel.to_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
2025-09-09 12:00:58 +09:00
// 각 from 컬럼을 각 to 컬럼에 연결 (1:1 매핑이거나 many:many인 경우)
if (fromColumns.length === 0 || toColumns.length === 0) {
console.warn("⚠️ 컬럼 정보가 없습니다:", { fromColumns, toColumns });
return;
}
2025-09-09 12:00:58 +09:00
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.diagram_id),
2025-09-09 12:00:58 +09:00
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,
},
});
}
});
2025-09-09 11:35:05 +09:00
console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length);
console.log("📍 관계 에지 상세:", relationshipEdges);
setEdges(relationshipEdges);
toast.success("관계도를 불러왔습니다.", { id: "load-diagram" });
2025-09-09 11:35:05 +09:00
} catch (error) {
console.error("선택된 관계도 로드 실패:", error);
toast.error("관계도를 불러오는데 실패했습니다.", { id: "load-diagram" });
}
}, [diagramId, relationshipId, setNodes, setEdges, selectedColumns, handleColumnClick]);
2025-09-09 11:35:05 +09:00
// 기존 관계 로드 (새 관계도 생성 시)
2025-09-08 16:46:53 +09:00
const loadExistingRelationships = useCallback(async () => {
2025-09-09 11:35:05 +09:00
if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음
2025-09-08 16:46:53 +09:00
try {
const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode);
setRelationships(existingRelationships);
2025-09-09 12:00:58 +09:00
// 기존 관계를 엣지로 변환하여 표시 (컬럼별 연결)
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.diagram_id),
2025-09-09 12:00:58 +09:00
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,
},
});
}
});
2025-09-08 16:46:53 +09:00
setEdges(existingEdges);
} catch (error) {
console.error("기존 관계 로드 실패:", error);
toast.error("기존 관계를 불러오는데 실패했습니다.");
}
2025-09-09 11:35:05 +09:00
}, [companyCode, setEdges, selectedDiagram]);
2025-09-08 16:46:53 +09:00
2025-09-09 11:35:05 +09:00
// 컴포넌트 마운트 시 관계 로드
2025-09-08 16:46:53 +09:00
useEffect(() => {
if (companyCode) {
if (diagramId || relationshipId) {
2025-09-09 11:35:05 +09:00
loadSelectedDiagramRelationships();
} else {
loadExistingRelationships();
}
2025-09-08 16:46:53 +09:00
}
}, [companyCode, diagramId, relationshipId, loadExistingRelationships, loadSelectedDiagramRelationships]);
2025-09-08 16:46:53 +09:00
2025-09-05 18:21:28 +09:00
// 노드 선택 변경 핸들러
const onSelectionChange = useCallback(({ nodes }: { nodes: Node<TableNodeData>[] }) => {
const selectedNodeIds = nodes.map((node) => node.id);
setSelectedNodes(selectedNodeIds);
}, []);
2025-09-05 16:19:31 +09:00
// 빈 onConnect 함수 (드래그 연결 비활성화)
const onConnect = useCallback(() => {
// 드래그로 연결하는 것을 방지
return;
}, []);
2025-09-05 18:00:18 +09:00
// 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
2025-09-05 16:19:31 +09:00
useEffect(() => {
setNodes((prevNodes) =>
prevNodes.map((node) => ({
...node,
2025-09-05 11:30:27 +09:00
data: {
2025-09-05 16:19:31 +09:00
...node.data,
2025-09-05 18:00:18 +09:00
selectedColumns: selectedColumns[node.data.table.tableName] || [],
2025-09-05 11:30:27 +09:00
},
2025-09-05 16:19:31 +09:00
})),
);
2025-09-05 18:00:18 +09:00
// selectionOrder에서 선택되지 않은 테이블들 제거
const activeTables = Object.keys(selectedColumns).filter(
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
2025-09-05 16:19:31 +09:00
);
2025-09-05 18:00:18 +09:00
setSelectionOrder((prev) => prev.filter((tableName) => activeTables.includes(tableName)));
}, [selectedColumns, setNodes]);
2025-09-05 16:19:31 +09:00
// 연결 가능한 상태인지 확인
const canCreateConnection = () => {
2025-09-05 18:00:18 +09:00
const selectedTables = Object.keys(selectedColumns).filter(
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
2025-09-05 16:19:31 +09:00
);
2025-09-05 18:00:18 +09:00
// 최소 2개의 서로 다른 테이블에서 컬럼이 선택되어야 함
return selectedTables.length >= 2;
2025-09-05 16:19:31 +09:00
};
2025-09-05 18:00:18 +09:00
// 컬럼 연결 설정 모달 열기
2025-09-05 16:19:31 +09:00
const openConnectionModal = () => {
2025-09-05 18:00:18 +09:00
const selectedTables = Object.keys(selectedColumns).filter(
(tableName) => selectedColumns[tableName] && selectedColumns[tableName].length > 0,
2025-09-05 16:19:31 +09:00
);
2025-09-05 18:00:18 +09:00
if (selectedTables.length < 2) return;
2025-09-05 16:19:31 +09:00
2025-09-05 18:00:18 +09:00
// 선택 순서에 따라 첫 번째와 두 번째 테이블 설정
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);
2025-09-05 16:19:31 +09:00
if (!firstNode || !secondNode) return;
2025-09-08 16:46:53 +09:00
// 첫 번째로 선택된 컬럼들 가져오기
const firstTableColumns = selectedColumns[firstTableName] || [];
const secondTableColumns = selectedColumns[secondTableName] || [];
2025-09-05 16:19:31 +09:00
setPendingConnection({
fromNode: {
id: firstNode.id,
2025-09-05 18:00:18 +09:00
tableName: firstNode.data.table.tableName,
displayName: firstNode.data.table.displayName,
2025-09-05 16:19:31 +09:00
},
toNode: {
id: secondNode.id,
2025-09-05 18:00:18 +09:00
tableName: secondNode.data.table.tableName,
displayName: secondNode.data.table.displayName,
2025-09-05 16:19:31 +09:00
},
2025-09-08 16:46:53 +09:00
// 선택된 첫 번째 컬럼을 연결 컬럼으로 설정
fromColumn: firstTableColumns[0] || "",
toColumn: secondTableColumns[0] || "",
2025-09-05 18:00:18 +09:00
// 선택된 모든 컬럼 정보를 선택 순서대로 전달
selectedColumnsData: (() => {
const orderedData: { [key: string]: { displayName: string; columns: string[] } } = {};
2025-09-05 16:19:31 +09:00
// selectionOrder 순서대로 데이터 구성 (첫 번째 선택이 먼저)
2025-09-05 18:00:18 +09:00
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],
2025-09-05 16:19:31 +09:00
};
}
});
return orderedData;
})(),
});
};
2025-09-05 18:00:18 +09:00
// 실제 테이블 노드 추가
const addTableNode = useCallback(
async (table: TableDefinition) => {
2025-09-05 16:19:31 +09:00
try {
2025-09-05 18:00:18 +09:00
const newNode: Node<TableNodeData> = {
id: `table-${table.tableName}`,
type: "tableNode",
2025-09-05 16:19:31 +09:00
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
2025-09-05 18:00:18 +09:00
table: {
tableName: table.tableName,
displayName: table.displayName || table.tableName,
description: table.description || "",
columns: table.columns.map((col) => ({
2025-09-05 16:19:31 +09:00
name: col.columnName || "unknown",
type: col.dataType || col.dbType || "UNKNOWN",
description:
col.columnLabel || col.displayName || col.description || col.columnName || "No description",
})),
},
2025-09-05 18:00:18 +09:00
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[table.tableName] || [],
2025-09-05 16:19:31 +09:00
},
};
setNodes((nds) => nds.concat(newNode));
} catch (error) {
2025-09-05 18:00:18 +09:00
console.error("테이블 노드 추가 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
2025-09-05 16:19:31 +09:00
}
2025-09-05 11:30:27 +09:00
},
2025-09-05 18:00:18 +09:00
[handleColumnClick, selectedColumns, setNodes],
2025-09-05 11:30:27 +09:00
);
2025-09-05 18:00:18 +09:00
// 샘플 테이블 노드 추가 (개발용)
2025-09-05 11:30:27 +09:00
const addSampleNode = useCallback(() => {
2025-09-05 18:00:18 +09:00
const tableName = `sample_table_${nodes.length + 1}`;
const newNode: Node<TableNodeData> = {
2025-09-05 16:19:31 +09:00
id: `sample-${Date.now()}`,
2025-09-05 18:00:18 +09:00
type: "tableNode",
2025-09-05 11:30:27 +09:00
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
2025-09-05 18:00:18 +09:00
table: {
tableName,
displayName: `샘플 테이블 ${nodes.length + 1}`,
description: `샘플 테이블 설명 ${nodes.length + 1}`,
columns: [
2025-09-05 11:30:27 +09:00
{ name: "id", type: "INTEGER", description: "고유 식별자" },
{ name: "name", type: "VARCHAR(100)", description: "이름" },
{ name: "code", type: "VARCHAR(50)", description: "코드" },
{ name: "created_date", type: "TIMESTAMP", description: "생성일시" },
],
},
2025-09-05 18:00:18 +09:00
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[tableName] || [],
2025-09-05 11:30:27 +09:00
},
};
setNodes((nds) => nds.concat(newNode));
2025-09-05 18:00:18 +09:00
}, [nodes.length, handleColumnClick, selectedColumns, setNodes]);
2025-09-05 11:30:27 +09:00
// 노드 전체 삭제
const clearNodes = useCallback(() => {
setNodes([]);
setEdges([]);
2025-09-05 18:00:18 +09:00
setSelectedColumns({});
setSelectionOrder([]);
2025-09-05 18:21:28 +09:00
setSelectedNodes([]);
setCurrentDiagramId(null); // 현재 diagram_id도 초기화
2025-09-05 11:30:27 +09:00
}, [setNodes, setEdges]);
2025-09-05 18:00:18 +09:00
// 현재 추가된 테이블명 목록 가져오기
const getSelectedTableNames = useCallback(() => {
return nodes.filter((node) => node.id.startsWith("table-")).map((node) => node.data.table.tableName);
2025-09-05 16:19:31 +09:00
}, [nodes]);
// 연결 설정 확인
const handleConfirmConnection = useCallback(
2025-09-08 16:46:53 +09:00
(relationship: TableRelationship) => {
2025-09-05 16:19:31 +09:00
if (!pendingConnection) return;
2025-09-09 12:00:58 +09:00
// 컬럼별 에지 생성
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.diagram_id),
2025-09-09 12:00:58 +09:00
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,
},
});
}
2025-09-05 16:19:31 +09:00
2025-09-09 12:00:58 +09:00
setEdges((eds) => [...eds, ...newEdges]);
2025-09-08 16:46:53 +09:00
setRelationships((prev) => [...prev, relationship]);
2025-09-05 16:19:31 +09:00
setPendingConnection(null);
// 첫 번째 관계 생성 시 currentDiagramId 설정 (새 관계도 생성 시)
if (!currentDiagramId && relationship.diagram_id) {
setCurrentDiagramId(relationship.diagram_id);
2025-09-08 16:46:53 +09:00
}
console.log("관계 생성 완료:", relationship);
// 관계 생성 완료 후 자동으로 목록 새로고침을 위한 콜백 (선택적)
// 렌더링 중 상태 업데이트 방지를 위해 제거
// if (onSave) {
// onSave([...relationships, relationship]);
// }
2025-09-05 16:19:31 +09:00
},
[pendingConnection, setEdges, currentDiagramId],
2025-09-05 16:19:31 +09:00
);
// 연결 설정 취소
const handleCancelConnection = useCallback(() => {
setPendingConnection(null);
}, []);
2025-09-05 11:30:27 +09:00
return (
<div className="data-flow-designer h-screen bg-gray-100">
<div className="flex h-full">
{/* 사이드바 */}
<div className="w-80 border-r border-gray-200 bg-white shadow-lg">
<div className="p-6">
2025-09-05 18:00:18 +09:00
<h2 className="mb-6 text-xl font-bold text-gray-800"> </h2>
2025-09-05 11:30:27 +09:00
2025-09-05 18:00:18 +09:00
{/* 테이블 선택기 */}
<TableSelector
2025-09-05 16:19:31 +09:00
companyCode={companyCode}
2025-09-05 18:00:18 +09:00
onTableAdd={addTableNode}
selectedTables={getSelectedTableNames()}
2025-09-05 16:19:31 +09:00
/>
2025-09-05 11:30:27 +09:00
{/* 컨트롤 버튼들 */}
<div className="space-y-3">
<button
onClick={addSampleNode}
2025-09-05 16:19:31 +09:00
className="w-full rounded-lg bg-gray-500 p-3 font-medium text-white transition-colors hover:bg-gray-600"
2025-09-05 11:30:27 +09:00
>
2025-09-05 18:00:18 +09:00
+ ()
2025-09-05 11:30:27 +09:00
</button>
<button
onClick={clearNodes}
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
>
</button>
<button
onClick={() => onSave && onSave([])}
className="w-full rounded-lg bg-green-500 p-3 font-medium text-white transition-colors hover:bg-green-600"
>
</button>
</div>
{/* 통계 정보 */}
<div className="mt-6 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-semibold text-gray-700"></div>
<div className="space-y-1 text-sm text-gray-600">
<div className="flex justify-between">
2025-09-05 18:00:18 +09:00
<span> :</span>
2025-09-05 11:30:27 +09:00
<span className="font-medium">{nodes.length}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium">{edges.length}</span>
</div>
<div className="flex justify-between">
<span> ID:</span>
<span className="font-medium">{currentDiagramId || "미설정"}</span>
</div>
2025-09-05 11:30:27 +09:00
</div>
</div>
2025-09-05 18:00:18 +09:00
{/* 선택된 컬럼 정보 */}
{Object.keys(selectedColumns).length > 0 && (
2025-09-05 16:19:31 +09:00
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
2025-09-05 18:00:18 +09:00
<div className="mb-3 text-sm font-semibold text-blue-800"> </div>
2025-09-05 16:19:31 +09:00
<div className="space-y-3">
{[...new Set(selectionOrder)]
2025-09-05 18:00:18 +09:00
.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;
2025-09-05 16:19:31 +09:00
return (
2025-09-05 18:00:18 +09:00
<div key={`selected-${tableName}-${index}`}>
2025-09-05 16:19:31 +09:00
<div className="w-full min-w-0 rounded-lg border border-blue-300 bg-white p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
2025-09-08 10:33:00 +09:00
<div className="flex-shrink-0 rounded px-2 py-1 text-xs font-medium text-blue-600">
2025-09-05 18:00:18 +09:00
{displayName}
2025-09-05 16:19:31 +09:00
</div>
</div>
<div className="flex w-full min-w-0 flex-wrap gap-1">
2025-09-05 18:00:18 +09:00
{columns.map((column, columnIndex) => (
2025-09-05 16:19:31 +09:00
<div
2025-09-05 18:00:18 +09:00
key={`${tableName}-${column}-${columnIndex}`}
2025-09-05 16:19:31 +09:00
className="max-w-full truncate rounded-full border border-blue-200 bg-blue-100 px-2 py-1 text-xs text-blue-800"
2025-09-05 18:00:18 +09:00
title={column}
2025-09-05 16:19:31 +09:00
>
2025-09-05 18:00:18 +09:00
{column}
2025-09-05 16:19:31 +09:00
</div>
))}
</div>
</div>
2025-09-05 18:00:18 +09:00
{/* 첫 번째 테이블 다음에 화살표 표시 */}
2025-09-05 16:19:31 +09:00
{index === 0 && filteredOrder.length > 1 && (
<div className="flex justify-center py-2">
<div className="text-gray-400"></div>
</div>
)}
</div>
);
})}
</div>
<div className="mt-3 flex gap-2">
<button
onClick={openConnectionModal}
disabled={!canCreateConnection()}
2025-09-08 10:33:00 +09:00
className={`w-full rounded px-3 py-1 text-xs font-medium transition-colors ${
2025-09-05 16:19:31 +09:00
canCreateConnection()
2025-09-08 10:33:00 +09:00
? "cursor-pointer bg-blue-600 text-white hover:bg-blue-700"
2025-09-05 16:19:31 +09:00
: "cursor-not-allowed bg-gray-300 text-gray-500"
}`}
>
2025-09-05 18:00:18 +09:00
2025-09-05 16:19:31 +09:00
</button>
<button
onClick={() => {
2025-09-05 18:00:18 +09:00
setSelectedColumns({});
2025-09-05 16:19:31 +09:00
setSelectionOrder([]);
}}
2025-09-08 10:33:00 +09:00
className="w-full cursor-pointer rounded bg-gray-200 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-300"
2025-09-05 16:19:31 +09:00
>
</button>
</div>
2025-09-05 11:30:27 +09:00
</div>
</div>
)}
</div>
</div>
{/* React Flow 캔버스 */}
<div className="relative flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
2025-09-05 18:21:28 +09:00
onSelectionChange={onSelectionChange}
2025-09-05 11:30:27 +09:00
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
attributionPosition="bottom-left"
2025-09-05 16:19:31 +09:00
panOnScroll={false}
zoomOnScroll={true}
zoomOnPinch={true}
2025-09-08 10:33:00 +09:00
panOnDrag={[1, 2]}
selectionOnDrag={true}
multiSelectionKeyCode={null}
selectNodesOnDrag={false}
selectionMode={SelectionMode.Partial}
2025-09-05 11:30:27 +09:00
>
<Controls />
2025-09-08 10:33:00 +09:00
2025-09-05 18:00:18 +09:00
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#E5E7EB" />
2025-09-05 11:30:27 +09:00
</ReactFlow>
{/* 안내 메시지 */}
{nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<div className="mb-2 text-2xl">📊</div>
2025-09-05 18:00:18 +09:00
<div className="mb-1 text-lg font-medium"> </div>
2025-09-05 18:21:28 +09:00
<div className="text-sm">
<div> </div>
<div className="mt-1 text-xs text-gray-400"> Del </div>
</div>
2025-09-05 11:30:27 +09:00
</div>
</div>
)}
</div>
</div>
2025-09-05 16:19:31 +09:00
{/* 연결 설정 모달 */}
<ConnectionSetupModal
isOpen={!!pendingConnection}
connection={pendingConnection}
2025-09-08 16:46:53 +09:00
companyCode={companyCode}
diagramId={currentDiagramId || diagramId || (relationshipId ? parseInt(relationshipId) : undefined)}
2025-09-05 16:19:31 +09:00
onConfirm={handleConfirmConnection}
onCancel={handleCancelConnection}
/>
2025-09-05 11:30:27 +09:00
</div>
);
};