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

1267 lines
47 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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,
} 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<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[];
}
// 노드 및 엣지 타입 정의
const nodeTypes = {
tableNode: TableNode,
};
const edgeTypes = {};
interface DataFlowDesignerProps {
companyCode?: string;
onSave?: (relationships: TableRelationship[]) => void;
selectedDiagram?: DataFlowDiagram | string | null;
diagramId?: number;
relationshipId?: string; // 하위 호환성 유지
onBackToList?: () => void;
}
// TableRelationship 타입은 dataflow.ts에서 import
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode: propCompanyCode = "*",
diagramId,
relationshipId, // 하위 호환성 유지
onSave, // eslint-disable-line @typescript-eslint/no-unused-vars
selectedDiagram,
onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars
}) => {
const { user } = useAuth();
// 실제 사용자 회사 코드 사용 (prop보다 사용자 정보 우선)
const companyCode = user?.company_code || user?.companyCode || propCompanyCode;
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selectedColumns, setSelectedColumns] = useState<{
[tableName: string]: string[];
}>({});
const [selectionOrder, setSelectionOrder] = useState<string[]>([]);
const [selectedNodes, setSelectedNodes] = useState<string[]>([]);
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<TableRelationship[]>([]); // eslint-disable-line @typescript-eslint/no-unused-vars
const [currentDiagramId, setCurrentDiagramId] = useState<number | null>(null); // 현재 화면의 diagram_id
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{
relationshipId: string;
relationshipName: string;
fromTable: string;
toTable: string;
fromColumns: string[];
toColumns: string[];
relationshipType: string;
connectionType: string;
connectionInfo: string;
} | null>(null); // 선택된 엣지 정보
// 새로운 메모리 기반 상태들
const [tempRelationships, setTempRelationships] = useState<JsonRelationship[]>([]); // 메모리에 저장된 관계들
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 저장되지 않은 변경사항
const [showSaveModal, setShowSaveModal] = useState(false); // 저장 모달 표시 상태
const [isSaving, setIsSaving] = useState(false); // 저장 중 상태
const [currentDiagramName, setCurrentDiagramName] = useState<string>(""); // 현재 편집 중인 관계도 이름
const [selectedEdgeForEdit, setSelectedEdgeForEdit] = useState<Edge | null>(null); // 수정/삭제할 엣지
const [showEdgeActions, setShowEdgeActions] = useState(false); // 엣지 액션 버튼 표시 상태
const [edgeActionPosition, setEdgeActionPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); // 액션 버튼 위치
const [editingRelationshipId, setEditingRelationshipId] = useState<string | null>(null); // 현재 수정 중인 관계 ID
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;
});
// 선택 순서도 정리
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 () => {
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);
// 메모리에 관계 저장 (기존 관계도 편집 시)
setTempRelationships(relationships);
setCurrentDiagramId(currentDiagramId);
// 테이블 노드 생성을 위한 테이블 정보 로드
// 테이블 정보 로드
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 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: "", // 기존 로드된 노드도 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[] = [];
relationships.forEach((rel: JsonRelationship) => {
const fromTable = rel.fromTable;
const toTable = rel.toTable;
const fromColumns = rel.fromColumns || [];
const toColumns = rel.toColumns || [];
if (fromColumns.length === 0 || toColumns.length === 0) {
console.warn("⚠️ 컬럼 정보가 없습니다:", { fromColumns, toColumns });
return;
}
// 테이블 간 하나의 번들 엣지 생성 (컬럼별 개별 엣지 대신)
relationshipEdges.push({
id: generateUniqueId("edge", currentDiagramId),
source: `table-${fromTable}`,
target: `table-${toTable}`,
type: "smoothstep",
animated: false,
style: {
stroke: "#3b82f6",
strokeWidth: 2,
strokeDasharray: "none",
},
data: {
relationshipId: rel.id,
relationshipName: "기존 관계",
relationshipType: rel.relationshipType,
connectionType: rel.connectionType,
fromTable: fromTable,
toTable: toTable,
fromColumns: fromColumns,
toColumns: toColumns,
// 클릭 시 표시할 상세 정보
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
relationshipType: rel.relationshipType,
connectionType: rel.connectionType,
},
},
});
});
console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length);
console.log("📍 관계 에지 상세:", relationshipEdges);
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 onSelectionChange = useCallback(({ nodes }: { nodes: Node<TableNodeData>[] }) => {
const selectedNodeIds = nodes.map((node) => node.id);
setSelectedNodes(selectedNodeIds);
}, []);
// 캔버스 클릭 시 엣지 정보 섹션 닫기
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[];
relationshipType: string;
connectionType: string;
details?: {
connectionInfo: string;
relationshipType: string;
connectionType: string;
};
};
if (edgeData) {
// 엣지 정보 설정
setSelectedEdgeInfo({
relationshipId: edgeData.relationshipId,
relationshipName: edgeData.relationshipName || "관계",
fromTable: edgeData.fromTable,
toTable: edgeData.toTable,
fromColumns: edgeData.fromColumns || [],
toColumns: edgeData.toColumns || [],
relationshipType: edgeData.relationshipType,
connectionType: edgeData.connectionType,
connectionInfo: edgeData.details?.connectionInfo || `${edgeData.fromTable}${edgeData.toTable}`,
});
// 관련 컬럼 하이라이트
const newSelectedColumns: { [tableName: string]: string[] } = {};
// fromTable의 컬럼들 선택
if (edgeData.fromTable && edgeData.fromColumns) {
newSelectedColumns[edgeData.fromTable] = [...edgeData.fromColumns];
}
// toTable의 컬럼들 선택
if (edgeData.toTable && edgeData.toColumns) {
newSelectedColumns[edgeData.toTable] = [...edgeData.toColumns];
}
setSelectedColumns(newSelectedColumns);
// 액션 버튼 표시
setSelectedEdgeForEdit(edge);
setEdgeActionPosition({ x: event.clientX, y: event.clientY });
setShowEdgeActions(true);
}
}, []);
// 엣지 마우스 엔터 시 색상 변경
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에서 선택되지 않은 테이블들 제거
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<TableNodeData> = {
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: JsonRelationship = {
id: editingRelationshipId || generateUniqueId("rel", Date.now()), // 수정 모드면 기존 ID 사용
fromTable,
toTable,
fromColumns,
toColumns,
relationshipType: relationship.relationship_type,
connectionType: relationship.connection_type,
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);
// 캔버스에 엣지 즉시 표시
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: "임시 관계",
relationshipType: relationship.relationship_type,
connectionType: relationship.connection_type,
fromTable,
toTable,
fromColumns,
toColumns,
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
relationshipType: relationship.relationship_type,
connectionType: relationship.connection_type,
},
},
};
setEdges((eds) => [...eds, newEdge]);
setPendingConnection(null);
// 관계 생성 후 선택된 컬럼들 초기화
setSelectedColumns({});
setSelectionOrder([]);
console.log("메모리에 관계 생성 완료:", newRelationship);
toast.success("관계가 생성되었습니다. 저장 버튼을 눌러 관계도를 저장하세요.");
},
[pendingConnection, setEdges],
);
// 연결 설정 취소
const handleCancelConnection = useCallback(() => {
setPendingConnection(null);
// 수정 모드였다면 해제
if (editingRelationshipId) {
setEditingRelationshipId(null);
}
}, [editingRelationshipId]);
// 관계도 저장 함수
const handleSaveDiagram = useCallback(
async (diagramName: string) => {
if (tempRelationships.length === 0) {
toast.error("저장할 관계가 없습니다.");
return;
}
setIsSaving(true);
try {
// 연결된 테이블 목록 추출
const connectedTables = Array.from(
new Set([...tempRelationships.map((rel) => rel.fromTable), ...tempRelationships.map((rel) => rel.toTable)]),
).sort();
// 저장 요청 데이터 생성
const createRequest: CreateDiagramRequest = {
diagram_name: diagramName,
relationships: {
relationships: tempRelationships,
tables: connectedTables,
},
};
let savedDiagram;
// 편집 모드 vs 신규 생성 모드 구분
if (diagramId && diagramId > 0) {
// 편집 모드: 기존 관계도 업데이트
savedDiagram = await DataFlowAPI.updateJsonDataFlowDiagram(
diagramId,
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);
console.log("관계도 저장 완료:", savedDiagram);
} catch (error) {
console.error("관계도 저장 실패:", error);
toast.error("관계도 저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
},
[tempRelationships, diagramId, companyCode, user?.userId],
);
// 저장 모달 열기
const handleOpenSaveModal = useCallback(() => {
// 관계가 0개여도 저장 가능하도록 수정
setShowSaveModal(true);
}, []);
// 저장 모달 닫기
const handleCloseSaveModal = useCallback(() => {
if (!isSaving) {
setShowSaveModal(false);
}
}, [isSaving]);
// 고립된 노드 제거 함수
const removeOrphanedNodes = useCallback(
(updatedRelationships: JsonRelationship[], showMessage = true) => {
setNodes((currentNodes) => {
// 현재 관계에서 사용되는 테이블들 추출
const usedTables = new Set<string>();
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[];
relationshipType: 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,
},
},
};
// 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 (
<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">
<h2 className="mb-6 text-xl font-bold text-gray-800"> </h2>
{/* 테이블 선택기 */}
<TableSelector
companyCode={companyCode}
onTableAdd={addTableNode}
selectedTables={getSelectedTableNames()}
/>
{/* 컨트롤 버튼들 */}
<div className="space-y-3">
<button
onClick={() => removeOrphanedNodes(tempRelationships)}
className="w-full rounded-lg bg-orange-500 p-3 font-medium text-white transition-colors hover:bg-orange-600"
disabled={nodes.length === 0}
>
🧹
</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={handleOpenSaveModal}
className={`w-full rounded-lg bg-green-500 p-3 font-medium text-white transition-colors hover:bg-green-600 ${
hasUnsavedChanges ? "animate-pulse" : ""
}`}
>
💾 {tempRelationships.length > 0 && `(${tempRelationships.length})`}
</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">
<span> :</span>
<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> :</span>
<span className="font-medium text-orange-600">{tempRelationships.length}</span>
</div>
<div className="flex justify-between">
<span> ID:</span>
<span className="font-medium">{currentDiagramId || "미설정"}</span>
</div>
{hasUnsavedChanges && (
<div className="mt-2 text-xs font-medium text-orange-600"> </div>
)}
</div>
</div>
{/* 선택된 컬럼 정보 */}
{Object.keys(selectedColumns).length > 0 && (
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="mb-3 text-sm font-semibold text-blue-800"> </div>
<div className="space-y-3">
{[...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 (
<div key={`selected-${tableName}-${index}`}>
<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">
<div className="flex-shrink-0 rounded px-2 py-1 text-xs font-medium text-blue-600">
{displayName}
</div>
</div>
<div className="flex w-full min-w-0 flex-wrap gap-1">
{columns.map((column, columnIndex) => (
<div
key={`${tableName}-${column}-${columnIndex}`}
className="max-w-full truncate rounded-full border border-blue-200 bg-blue-100 px-2 py-1 text-xs text-blue-800"
title={column}
>
{column}
</div>
))}
</div>
</div>
{/* 첫 번째 테이블 다음에 화살표 표시 */}
{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()}
className={`w-full rounded px-3 py-1 text-xs font-medium transition-colors ${
canCreateConnection()
? "cursor-pointer bg-blue-600 text-white hover:bg-blue-700"
: "cursor-not-allowed bg-gray-300 text-gray-500"
}`}
>
</button>
<button
onClick={() => {
setSelectedColumns({});
setSelectionOrder([]);
}}
className="w-full cursor-pointer rounded bg-gray-200 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-300"
>
</button>
</div>
</div>
</div>
)}
{/* 선택된 엣지 정보 */}
{selectedEdgeInfo && (
<div className="mt-6 space-y-4">
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-green-800">🔗 </div>
<button onClick={() => setSelectedEdgeInfo(null)} className="text-green-600 hover:text-green-800">
</button>
</div>
<div className="space-y-2 text-xs">
<div className="rounded bg-white p-2">
<div className="font-medium text-gray-700"></div>
<div className="text-gray-600">
{selectedEdgeInfo.fromTable}({selectedEdgeInfo.fromColumns.join(", ")}) {" "}
{selectedEdgeInfo.toTable}({selectedEdgeInfo.toColumns.join(", ")})
</div>
</div>
<div className="flex gap-2">
<div className="flex-1 rounded bg-white p-2">
<div className="font-medium text-gray-700"> </div>
<div className="text-gray-600">{selectedEdgeInfo.relationshipType}</div>
</div>
<div className="flex-1 rounded bg-white p-2">
<div className="font-medium text-gray-700"> </div>
<div className="text-gray-600">{selectedEdgeInfo.connectionType}</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* React Flow 캔버스 */}
<div className="relative flex-1">
<ReactFlow
className="[&_.react-flow\_\_pane]:cursor-default [&_.react-flow\_\_pane.dragging]:cursor-grabbing"
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onSelectionChange={onSelectionChange}
onPaneClick={onPaneClick}
onEdgeClick={onEdgeClick}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
attributionPosition="bottom-left"
panOnScroll={false}
zoomOnScroll={true}
zoomOnPinch={true}
panOnDrag={[1, 2]}
selectionOnDrag={true}
multiSelectionKeyCode={null}
selectNodesOnDrag={false}
selectionMode={SelectionMode.Partial}
>
<Controls />
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#E5E7EB" />
</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>
<div className="mb-1 text-lg font-medium"> </div>
<div className="text-sm">
<div> </div>
<div className="mt-1 text-xs text-gray-400"> Del </div>
</div>
</div>
</div>
)}
</div>
</div>
{/* 연결 설정 모달 */}
<ConnectionSetupModal
isOpen={!!pendingConnection}
connection={pendingConnection}
companyCode={companyCode}
diagramId={currentDiagramId || diagramId || (relationshipId ? parseInt(relationshipId) : undefined)}
onConfirm={handleConfirmConnection}
onCancel={handleCancelConnection}
/>
{/* 엣지 액션 버튼 */}
{showEdgeActions && selectedEdgeForEdit && (
<div
className="fixed z-50 flex gap-2 rounded-lg border bg-white p-2 shadow-lg"
style={{
left: edgeActionPosition.x - 80,
top: edgeActionPosition.y - 60,
}}
>
<button
onClick={handleEditEdge}
className="flex items-center gap-1 rounded bg-blue-500 px-3 py-1 text-xs text-white hover:bg-blue-600"
>
</button>
<button
onClick={handleDeleteEdge}
className="flex items-center gap-1 rounded bg-red-500 px-3 py-1 text-xs text-white hover:bg-red-600"
>
🗑
</button>
</div>
)}
{/* 관계도 저장 모달 */}
<SaveDiagramModal
isOpen={showSaveModal}
onClose={handleCloseSaveModal}
onSave={handleSaveDiagram}
relationships={tempRelationships}
defaultName={
diagramId && diagramId > 0 && currentDiagramName
? currentDiagramName // 편집 모드: 기존 관계도 이름
: `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름
}
isLoading={isSaving}
/>
</div>
);
};