1021 lines
36 KiB
TypeScript
1021 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect } from "react";
|
|
import toast from "react-hot-toast";
|
|
import { ReactFlow, Controls, Background, BackgroundVariant, SelectionMode, Node, Edge } from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
|
|
import { TableNode } from "./TableNode";
|
|
import { ConnectionSetupModal } from "./ConnectionSetupModal";
|
|
import { DataFlowSidebar } from "./DataFlowSidebar";
|
|
import { SelectedTablesPanel } from "./SelectedTablesPanel";
|
|
import { RelationshipListModal } from "./RelationshipListModal";
|
|
import { EdgeInfoPanel } from "./EdgeInfoPanel";
|
|
import SaveDiagramModal from "./SaveDiagramModal";
|
|
|
|
import {
|
|
TableDefinition,
|
|
DataFlowAPI,
|
|
JsonRelationship,
|
|
TableRelationship,
|
|
CreateDiagramRequest,
|
|
} from "@/lib/api/dataflow";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useDataFlowDesigner } from "@/hooks/useDataFlowDesigner";
|
|
import { DataFlowDesignerProps, TableNodeData } from "@/types/dataflowTypes";
|
|
import { extractTableNames, extractNodePositions } from "@/utils/dataflowUtils";
|
|
|
|
// 노드 및 엣지 타입 정의
|
|
const nodeTypes = {
|
|
tableNode: TableNode,
|
|
};
|
|
|
|
const edgeTypes = {};
|
|
|
|
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
|
|
companyCode: propCompanyCode = "*",
|
|
diagramId,
|
|
}) => {
|
|
const { user: authUser } = useAuth();
|
|
|
|
// 실제 사용자 회사 코드 사용 (prop보다 사용자 정보 우선)
|
|
const companyCode = authUser?.company_code || authUser?.companyCode || propCompanyCode;
|
|
|
|
// 커스텀 훅 사용
|
|
const {
|
|
nodes,
|
|
setNodes,
|
|
onNodesChange,
|
|
edges,
|
|
setEdges,
|
|
onEdgesChange,
|
|
selectedColumns,
|
|
setSelectedColumns,
|
|
selectedNodes,
|
|
setSelectedNodes,
|
|
pendingConnection,
|
|
setPendingConnection,
|
|
currentDiagramId,
|
|
setCurrentDiagramId,
|
|
currentDiagramName,
|
|
setCurrentDiagramName,
|
|
currentDiagramCategory,
|
|
tempRelationships,
|
|
setTempRelationships,
|
|
hasUnsavedChanges,
|
|
setHasUnsavedChanges,
|
|
showSaveModal,
|
|
setShowSaveModal,
|
|
isSaving,
|
|
setIsSaving,
|
|
showRelationshipListModal,
|
|
setShowRelationshipListModal,
|
|
selectedTablePairRelationships,
|
|
setSelectedTablePairRelationships,
|
|
selectedEdgeInfo,
|
|
setSelectedEdgeInfo,
|
|
setSelectedEdgeForEdit,
|
|
showEdgeActions,
|
|
setShowEdgeActions,
|
|
edgeActionPosition,
|
|
editingRelationshipId,
|
|
setEditingRelationshipId,
|
|
} = useDataFlowDesigner();
|
|
|
|
// 컬럼 클릭 처리 비활성화 (테이블 노드 선택 방식으로 변경)
|
|
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
|
|
// 컬럼 클릭으로는 더 이상 선택하지 않음
|
|
console.log(`컬럼 클릭 무시됨: ${tableName}.${columnName}`);
|
|
return;
|
|
}, []);
|
|
|
|
// 편집 모드일 때 관계도 데이터 로드
|
|
useEffect(() => {
|
|
const loadDiagramData = async () => {
|
|
if (diagramId && diagramId > 0) {
|
|
try {
|
|
// 편집 모드일 때 currentDiagramId 설정
|
|
setCurrentDiagramId(diagramId);
|
|
|
|
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode);
|
|
if (jsonDiagram) {
|
|
// 관계도 이름 설정
|
|
if (jsonDiagram.diagram_name) {
|
|
setCurrentDiagramName(jsonDiagram.diagram_name);
|
|
}
|
|
|
|
// 관계 데이터 로드
|
|
if (jsonDiagram.relationships?.relationships && Array.isArray(jsonDiagram.relationships.relationships)) {
|
|
const loadedRelationships = jsonDiagram.relationships.relationships.map((rel) => ({
|
|
id: rel.id || `rel-${Date.now()}-${Math.random()}`,
|
|
fromTable: rel.fromTable,
|
|
toTable: rel.toTable,
|
|
fromColumns: Array.isArray(rel.fromColumns) ? rel.fromColumns : [],
|
|
toColumns: Array.isArray(rel.toColumns) ? rel.toColumns : [],
|
|
connectionType: rel.connectionType || "simple-key",
|
|
relationshipName: rel.relationshipName || "",
|
|
note: rel.note || "", // 🔥 연결 설명 로드
|
|
}));
|
|
|
|
setTempRelationships(loadedRelationships);
|
|
|
|
// 관계 데이터로부터 테이블 노드들을 생성
|
|
const tableNames = new Set<string>();
|
|
loadedRelationships.forEach((rel) => {
|
|
tableNames.add(rel.fromTable);
|
|
tableNames.add(rel.toTable);
|
|
});
|
|
|
|
// 각 테이블의 정보를 API에서 가져와서 노드 생성
|
|
const loadedNodes = await Promise.all(
|
|
Array.from(tableNames).map(async (tableName) => {
|
|
try {
|
|
const columns = await DataFlowAPI.getTableColumns(tableName);
|
|
return {
|
|
id: `table-${tableName}`,
|
|
type: "tableNode",
|
|
position: jsonDiagram.node_positions?.[tableName] || {
|
|
x: Math.random() * 300,
|
|
y: Math.random() * 200,
|
|
},
|
|
data: {
|
|
table: {
|
|
tableName,
|
|
displayName: tableName,
|
|
description: "",
|
|
columns: Array.isArray(columns)
|
|
? columns.map((col) => ({
|
|
name: col.columnName || "unknown",
|
|
type: col.dataType || "varchar",
|
|
description: col.description || "",
|
|
}))
|
|
: [],
|
|
},
|
|
onColumnClick: handleColumnClick,
|
|
selectedColumns: [],
|
|
connectedColumns: {},
|
|
},
|
|
selected: false,
|
|
};
|
|
} catch (error) {
|
|
console.warn(`테이블 ${tableName} 정보 로드 실패:`, error);
|
|
return {
|
|
id: `table-${tableName}`,
|
|
type: "tableNode",
|
|
position: jsonDiagram.node_positions?.[tableName] || {
|
|
x: Math.random() * 300,
|
|
y: Math.random() * 200,
|
|
},
|
|
data: {
|
|
table: {
|
|
tableName,
|
|
displayName: tableName,
|
|
description: "",
|
|
columns: [],
|
|
},
|
|
onColumnClick: handleColumnClick,
|
|
selectedColumns: [],
|
|
connectedColumns: {},
|
|
},
|
|
selected: false,
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
setNodes(loadedNodes);
|
|
|
|
// 관계 데이터로부터 엣지 생성
|
|
const loadedEdges = loadedRelationships.map((rel) => ({
|
|
id: `edge-${rel.fromTable}-${rel.toTable}-${rel.id}`,
|
|
source: `table-${rel.fromTable}`,
|
|
target: `table-${rel.toTable}`,
|
|
type: "step",
|
|
data: {
|
|
relationshipId: rel.id,
|
|
fromTable: rel.fromTable,
|
|
toTable: rel.toTable,
|
|
connectionType: rel.connectionType,
|
|
relationshipName: rel.relationshipName,
|
|
},
|
|
style: {
|
|
stroke: "#3b82f6",
|
|
strokeWidth: 2,
|
|
},
|
|
animated: false,
|
|
}));
|
|
|
|
setEdges(loadedEdges);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("관계도 데이터 로드 실패:", error);
|
|
toast.error("관계도를 불러오는데 실패했습니다.");
|
|
}
|
|
} else {
|
|
// 신규 생성 모드
|
|
setCurrentDiagramName("");
|
|
setNodes([]);
|
|
setEdges([]);
|
|
setTempRelationships([]);
|
|
}
|
|
};
|
|
|
|
loadDiagramData();
|
|
}, [
|
|
diagramId,
|
|
companyCode,
|
|
setCurrentDiagramId,
|
|
setCurrentDiagramName,
|
|
setNodes,
|
|
setEdges,
|
|
setTempRelationships,
|
|
handleColumnClick,
|
|
]);
|
|
|
|
// 키보드 이벤트 핸들러 (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;
|
|
});
|
|
|
|
// 선택된 노드 초기화
|
|
setSelectedNodes([]);
|
|
|
|
toast.success(`${selectedNodes.length}개 테이블이 삭제되었습니다.`);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [selectedNodes, setNodes, setSelectedColumns, setSelectedNodes]);
|
|
|
|
// 현재 추가된 테이블명 목록 가져오기
|
|
const getSelectedTableNames = useCallback(() => {
|
|
return extractTableNames(nodes);
|
|
}, [nodes]);
|
|
|
|
// 실제 테이블 노드 추가
|
|
const addTableNode = useCallback(
|
|
async (table: TableDefinition) => {
|
|
try {
|
|
const newNode = {
|
|
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) => ({
|
|
columnName: col.columnName || "unknown",
|
|
name: col.columnName || "unknown", // 호환성을 위해 유지
|
|
displayName: col.displayName, // 한국어 라벨
|
|
columnLabel: col.columnLabel, // 한국어 라벨
|
|
type: col.dataType || "varchar",
|
|
dataType: 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],
|
|
);
|
|
|
|
// 노드 클릭 핸들러 (커스텀 다중 선택 구현)
|
|
const onNodeClick = useCallback(
|
|
(event: React.MouseEvent, node: Node<TableNodeData>) => {
|
|
event.stopPropagation();
|
|
|
|
const nodeId = node.id;
|
|
const isCurrentlySelected = selectedNodes.includes(nodeId);
|
|
|
|
if (isCurrentlySelected) {
|
|
// 이미 선택된 노드를 클릭하면 선택 해제
|
|
const newSelection = selectedNodes.filter((id) => id !== nodeId);
|
|
setSelectedNodes(newSelection);
|
|
|
|
// React Flow 노드 상태 업데이트
|
|
setNodes((prevNodes) =>
|
|
prevNodes.map((n) => ({
|
|
...n,
|
|
selected: newSelection.includes(n.id),
|
|
})),
|
|
);
|
|
} else {
|
|
// 새로운 노드 선택
|
|
let newSelection: string[];
|
|
|
|
if (selectedNodes.length >= 2) {
|
|
// 이미 2개가 선택되어 있으면 첫 번째를 제거하고 새로운 것을 추가 (FIFO)
|
|
newSelection = [selectedNodes[1], nodeId];
|
|
} else {
|
|
// 2개 미만이면 추가
|
|
newSelection = [...selectedNodes, nodeId];
|
|
}
|
|
|
|
setSelectedNodes(newSelection);
|
|
|
|
// React Flow 노드 상태 업데이트
|
|
setNodes((prevNodes) =>
|
|
prevNodes.map((n) => ({
|
|
...n,
|
|
selected: newSelection.includes(n.id),
|
|
})),
|
|
);
|
|
}
|
|
},
|
|
[selectedNodes, setNodes, setSelectedNodes],
|
|
);
|
|
|
|
// 노드 선택 변경 핸들러 (React Flow 자체 선택 이벤트는 무시)
|
|
const onSelectionChange = useCallback(() => {
|
|
// React Flow의 자동 선택 변경은 무시하고 우리의 커스텀 로직만 사용
|
|
// 이 함수는 비워두거나 최소한의 동기화만 수행
|
|
}, []);
|
|
|
|
// 캔버스 클릭 시 엣지 정보 섹션 닫기
|
|
const onPaneClick = useCallback(() => {
|
|
if (selectedEdgeInfo) {
|
|
setSelectedEdgeInfo(null);
|
|
}
|
|
if (showEdgeActions) {
|
|
setShowEdgeActions(false);
|
|
setSelectedEdgeForEdit(null);
|
|
}
|
|
// 컬럼 선택 해제
|
|
setSelectedColumns({});
|
|
}, [
|
|
selectedEdgeInfo,
|
|
showEdgeActions,
|
|
setSelectedEdgeInfo,
|
|
setShowEdgeActions,
|
|
setSelectedEdgeForEdit,
|
|
setSelectedColumns,
|
|
]);
|
|
|
|
// 빈 onConnect 함수 (드래그 연결 비활성화)
|
|
const onConnect = useCallback(() => {
|
|
// 드래그로 연결하는 것을 방지
|
|
return;
|
|
}, []);
|
|
|
|
// 엣지 클릭 시 연결 정보 표시 및 관련 컬럼 하이라이트
|
|
const onEdgeClick = useCallback(
|
|
(event: React.MouseEvent, edge: Edge) => {
|
|
event.stopPropagation();
|
|
const edgeData = edge.data;
|
|
|
|
if (edgeData) {
|
|
// 해당 테이블 쌍의 모든 관계 찾기
|
|
const fromTable = edgeData.fromTable;
|
|
const toTable = edgeData.toTable;
|
|
|
|
const tablePairRelationships = tempRelationships.filter(
|
|
(rel) =>
|
|
(rel.fromTable === fromTable && rel.toTable === toTable) ||
|
|
(rel.fromTable === toTable && rel.toTable === fromTable),
|
|
);
|
|
|
|
// 관계가 1개든 여러 개든 항상 관계 목록 모달 표시
|
|
setSelectedTablePairRelationships(tablePairRelationships);
|
|
setShowRelationshipListModal(true);
|
|
}
|
|
},
|
|
[tempRelationships, setSelectedTablePairRelationships, setShowRelationshipListModal],
|
|
);
|
|
|
|
// 엣지 마우스 엔터 시 색상 변경
|
|
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],
|
|
);
|
|
|
|
// 연결 가능한 상태인지 확인
|
|
const canCreateConnection = () => {
|
|
return selectedNodes.length >= 2;
|
|
};
|
|
|
|
// 테이블 노드 연결 설정 모달 열기
|
|
const openConnectionModal = () => {
|
|
if (selectedNodes.length < 2) return;
|
|
|
|
// 선택된 첫 번째와 두 번째 노드 찾기
|
|
const firstNode = nodes.find((node) => node.id === selectedNodes[0]);
|
|
const secondNode = nodes.find((node) => node.id === selectedNodes[1]);
|
|
|
|
if (!firstNode || !secondNode) return;
|
|
|
|
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,
|
|
},
|
|
});
|
|
};
|
|
|
|
// 연결 설정 확인
|
|
const handleConfirmConnection = useCallback(
|
|
(relationshipData: TableRelationship) => {
|
|
if (!pendingConnection || !relationshipData) return;
|
|
|
|
if (editingRelationshipId) {
|
|
// 편집 모드: 기존 관계 업데이트
|
|
const updatedRelationship = {
|
|
id: editingRelationshipId,
|
|
fromTable: relationshipData.from_table_name,
|
|
toTable: relationshipData.to_table_name,
|
|
fromColumns: relationshipData.from_column_name ? relationshipData.from_column_name.split(",") : [],
|
|
toColumns: relationshipData.to_column_name ? relationshipData.to_column_name.split(",") : [],
|
|
connectionType: relationshipData.connection_type as "simple-key" | "data-save" | "external-call",
|
|
relationshipName: relationshipData.relationship_name,
|
|
note: (relationshipData.settings as any)?.notes || "", // 🔥 notes를 note로 변환
|
|
settings: relationshipData.settings || {},
|
|
};
|
|
|
|
// tempRelationships에서 기존 관계 업데이트
|
|
setTempRelationships((prev) =>
|
|
prev.map((rel) => (rel.id === editingRelationshipId ? updatedRelationship : rel)),
|
|
);
|
|
|
|
// 기존 엣지 업데이트
|
|
setEdges((prevEdges) =>
|
|
prevEdges.map((edge) =>
|
|
edge.data?.relationshipId === editingRelationshipId
|
|
? {
|
|
...edge,
|
|
data: {
|
|
...edge.data,
|
|
relationshipId: editingRelationshipId,
|
|
fromTable: relationshipData.from_table_name,
|
|
toTable: relationshipData.to_table_name,
|
|
connectionType: relationshipData.connection_type,
|
|
relationshipName: relationshipData.relationship_name,
|
|
},
|
|
}
|
|
: edge,
|
|
),
|
|
);
|
|
|
|
// 편집 모드 종료
|
|
setEditingRelationshipId(null);
|
|
} else {
|
|
// 새로 생성 모드: 새로운 관계 추가
|
|
const newRelationship = {
|
|
id: `rel-${Date.now()}`,
|
|
fromTable: relationshipData.from_table_name,
|
|
toTable: relationshipData.to_table_name,
|
|
fromColumns: relationshipData.from_column_name ? relationshipData.from_column_name.split(",") : [],
|
|
toColumns: relationshipData.to_column_name ? relationshipData.to_column_name.split(",") : [],
|
|
connectionType: relationshipData.connection_type as "simple-key" | "data-save" | "external-call",
|
|
relationshipName: relationshipData.relationship_name,
|
|
note: (relationshipData.settings as any)?.notes || "", // 🔥 notes를 note로 변환
|
|
settings: relationshipData.settings || {},
|
|
};
|
|
|
|
// tempRelationships 상태 업데이트
|
|
setTempRelationships((prev) => [...prev, newRelationship]);
|
|
|
|
// 새로운 엣지 생성
|
|
const newEdge = {
|
|
id: `edge-${relationshipData.from_table_name}-${relationshipData.to_table_name}-${Date.now()}`,
|
|
source: `table-${relationshipData.from_table_name}`,
|
|
target: `table-${relationshipData.to_table_name}`,
|
|
type: "step",
|
|
data: {
|
|
relationshipId: newRelationship.id,
|
|
fromTable: relationshipData.from_table_name,
|
|
toTable: relationshipData.to_table_name,
|
|
connectionType: relationshipData.connection_type,
|
|
relationshipName: relationshipData.relationship_name,
|
|
},
|
|
style: {
|
|
stroke: "#3b82f6",
|
|
strokeWidth: 2,
|
|
},
|
|
animated: false,
|
|
};
|
|
|
|
// 엣지 추가
|
|
setEdges((prevEdges) => [...prevEdges, newEdge]);
|
|
}
|
|
|
|
setPendingConnection(null);
|
|
setHasUnsavedChanges(true);
|
|
},
|
|
[
|
|
pendingConnection,
|
|
setPendingConnection,
|
|
setHasUnsavedChanges,
|
|
setTempRelationships,
|
|
setEdges,
|
|
editingRelationshipId,
|
|
setEditingRelationshipId,
|
|
],
|
|
);
|
|
|
|
// 연결 설정 취소
|
|
const handleCancelConnection = useCallback(() => {
|
|
setPendingConnection(null);
|
|
if (editingRelationshipId) {
|
|
setEditingRelationshipId(null);
|
|
setSelectedColumns({});
|
|
}
|
|
}, [editingRelationshipId, setPendingConnection, setEditingRelationshipId, setSelectedColumns]);
|
|
|
|
// 저장 모달 열기
|
|
const handleOpenSaveModal = useCallback(() => {
|
|
setShowSaveModal(true);
|
|
}, [setShowSaveModal]);
|
|
|
|
// 저장 모달 닫기
|
|
const handleCloseSaveModal = useCallback(() => {
|
|
if (!isSaving) {
|
|
setShowSaveModal(false);
|
|
}
|
|
}, [isSaving, setShowSaveModal]);
|
|
|
|
// 관계도 저장 함수
|
|
const handleSaveDiagram = useCallback(
|
|
async (diagramName: string): Promise<{ success: boolean; error?: string }> => {
|
|
if (nodes.length === 0) {
|
|
toast.error("저장할 테이블이 없습니다.");
|
|
return { success: false, error: "저장할 테이블이 없습니다." };
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
// 노드 위치 정보 추출
|
|
const nodePositions = extractNodePositions(nodes);
|
|
|
|
// 연결된 테이블 목록 추출
|
|
const tableNames = extractTableNames(nodes);
|
|
|
|
// 관계 데이터를 JsonRelationship 형태로 변환 (note 필드 포함)
|
|
const jsonRelationships: JsonRelationship[] = tempRelationships.map((rel) => ({
|
|
id: rel.id,
|
|
relationshipName: rel.relationshipName, // 🔥 핵심: 관계 이름 포함
|
|
fromTable: rel.fromTable,
|
|
toTable: rel.toTable,
|
|
fromColumns: rel.fromColumns,
|
|
toColumns: rel.toColumns,
|
|
connectionType: rel.connectionType,
|
|
note: rel.note, // 🔥 연결 설명 포함
|
|
}));
|
|
|
|
// 저장 요청 데이터 구성
|
|
const saveRequest: CreateDiagramRequest = {
|
|
diagram_name: diagramName,
|
|
relationships: {
|
|
relationships: jsonRelationships,
|
|
tables: tableNames,
|
|
},
|
|
node_positions: nodePositions,
|
|
// 카테고리 정보 추가
|
|
category: tempRelationships.map((rel) => ({
|
|
id: rel.id,
|
|
category: rel.connectionType,
|
|
})),
|
|
// 조건부 연결 설정이 있는 경우 추가
|
|
control: tempRelationships
|
|
.filter((rel) => rel.settings?.control)
|
|
.map((rel) => ({
|
|
id: rel.id,
|
|
triggerType: rel.settings?.control?.triggerType || "insert",
|
|
conditions: (rel.settings?.control?.conditionTree || []).map((condition: Record<string, unknown>) => ({
|
|
...condition,
|
|
logicalOperator:
|
|
condition.logicalOperator === "AND" || condition.logicalOperator === "OR"
|
|
? condition.logicalOperator
|
|
: undefined,
|
|
})),
|
|
})),
|
|
// 데이터 저장 액션이 있는 경우 추가 (transformFunction 제거)
|
|
plan: tempRelationships
|
|
.filter((rel) => rel.settings?.actions && Array.isArray(rel.settings.actions))
|
|
.map((rel) => ({
|
|
id: rel.id,
|
|
sourceTable: rel.fromTable,
|
|
actions: (rel.settings?.actions || []).map((action: Record<string, unknown>) => ({
|
|
id: action.id as string,
|
|
name: action.name as string,
|
|
actionType: action.actionType as "insert" | "update" | "delete" | "upsert",
|
|
logicalOperator: action.logicalOperator as "AND" | "OR" | undefined, // 논리 연산자 추가
|
|
fieldMappings: ((action.fieldMappings as Record<string, unknown>[]) || []).map(
|
|
(mapping: Record<string, unknown>) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { transformFunction, ...cleanMapping } = mapping;
|
|
return cleanMapping as any; // transformFunction 제거 후 타입 캐스팅
|
|
},
|
|
),
|
|
splitConfig: action.splitConfig,
|
|
conditions: action.conditions,
|
|
})),
|
|
})) as any, // plan 전체를 any로 캐스팅
|
|
};
|
|
|
|
if ((diagramId && diagramId > 0) || (currentDiagramId && currentDiagramId > 0)) {
|
|
// 기존 관계도 수정 (prop diagramId 또는 내부 currentDiagramId 사용)
|
|
const targetDiagramId = diagramId || currentDiagramId;
|
|
await DataFlowAPI.updateJsonDataFlowDiagram(
|
|
targetDiagramId!,
|
|
saveRequest,
|
|
companyCode,
|
|
authUser?.userId || "SYSTEM",
|
|
);
|
|
} else {
|
|
// 새로운 관계도 생성
|
|
const newDiagram = await DataFlowAPI.createJsonDataFlowDiagram(
|
|
saveRequest,
|
|
companyCode,
|
|
authUser?.userId || "SYSTEM",
|
|
);
|
|
|
|
// 새로 생성된 다이어그램 ID를 내부 상태에 저장 (다음 저장부터는 업데이트 모드)
|
|
setCurrentDiagramId(newDiagram.diagram_id);
|
|
setCurrentDiagramName(newDiagram.diagram_name);
|
|
}
|
|
|
|
setHasUnsavedChanges(false);
|
|
// 성공 모달은 SaveDiagramModal에서 처리하므로 여기서는 toast 제거
|
|
return { success: true };
|
|
} catch (error) {
|
|
// 에러 메시지 분석
|
|
let errorMessage = "관계도 저장 중 오류가 발생했습니다.";
|
|
let isDuplicateError = false;
|
|
|
|
// Axios 에러 처리
|
|
if (error && typeof error === "object" && "response" in error) {
|
|
const axiosError = error as any;
|
|
if (axiosError.response?.status === 409) {
|
|
// 중복 이름 에러 (409 Conflict)
|
|
errorMessage = "중복된 이름입니다.";
|
|
isDuplicateError = true;
|
|
} else if (axiosError.response?.data?.message) {
|
|
// 백엔드에서 제공한 에러 메시지 사용
|
|
if (axiosError.response.data.message.includes("중복된 이름입니다")) {
|
|
errorMessage = "중복된 이름입니다.";
|
|
isDuplicateError = true;
|
|
} else {
|
|
errorMessage = axiosError.response.data.message;
|
|
}
|
|
}
|
|
} else if (error instanceof Error) {
|
|
if (
|
|
error.message.includes("중복") ||
|
|
error.message.includes("duplicate") ||
|
|
error.message.includes("already exists")
|
|
) {
|
|
errorMessage = "중복된 이름입니다.";
|
|
isDuplicateError = true;
|
|
} else {
|
|
errorMessage = error.message;
|
|
}
|
|
}
|
|
|
|
// 중복 에러가 아닌 경우만 콘솔에 로그 출력
|
|
if (!isDuplicateError) {
|
|
console.error("관계도 저장 실패:", error);
|
|
}
|
|
|
|
return { success: false, error: errorMessage };
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
},
|
|
[
|
|
nodes,
|
|
tempRelationships,
|
|
diagramId,
|
|
currentDiagramId,
|
|
companyCode,
|
|
authUser?.userId,
|
|
setIsSaving,
|
|
setHasUnsavedChanges,
|
|
setShowSaveModal,
|
|
setCurrentDiagramId,
|
|
setCurrentDiagramName,
|
|
],
|
|
);
|
|
|
|
// 고립된 노드 제거 함수
|
|
const removeOrphanedNodes = useCallback(() => {
|
|
toast.success("고립된 노드가 정리되었습니다.");
|
|
}, []);
|
|
|
|
// 전체 삭제 핸들러
|
|
const clearNodes = useCallback(() => {
|
|
setNodes([]);
|
|
setEdges([]);
|
|
setTempRelationships([]);
|
|
setSelectedColumns({});
|
|
setSelectedNodes([]);
|
|
setPendingConnection(null);
|
|
setSelectedEdgeInfo(null);
|
|
setShowEdgeActions(false);
|
|
setSelectedEdgeForEdit(null);
|
|
setHasUnsavedChanges(true);
|
|
|
|
toast.success("모든 테이블과 관계가 삭제되었습니다.");
|
|
}, [
|
|
setNodes,
|
|
setEdges,
|
|
setTempRelationships,
|
|
setSelectedColumns,
|
|
setSelectedNodes,
|
|
setPendingConnection,
|
|
setSelectedEdgeInfo,
|
|
setShowEdgeActions,
|
|
setSelectedEdgeForEdit,
|
|
setHasUnsavedChanges,
|
|
]);
|
|
|
|
return (
|
|
<div className="data-flow-designer h-screen bg-gray-100">
|
|
<div className="flex h-full">
|
|
{/* 사이드바 */}
|
|
<DataFlowSidebar
|
|
companyCode={companyCode}
|
|
nodes={nodes}
|
|
edges={edges}
|
|
tempRelationships={tempRelationships}
|
|
hasUnsavedChanges={hasUnsavedChanges}
|
|
currentDiagramId={currentDiagramId}
|
|
currentDiagramCategory={currentDiagramCategory}
|
|
onTableAdd={addTableNode}
|
|
onRemoveOrphanedNodes={removeOrphanedNodes}
|
|
onClearAll={clearNodes}
|
|
onOpenSaveModal={handleOpenSaveModal}
|
|
getSelectedTableNames={getSelectedTableNames}
|
|
/>
|
|
|
|
{/* 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}
|
|
onNodeClick={onNodeClick}
|
|
onPaneClick={onPaneClick}
|
|
onEdgeClick={onEdgeClick}
|
|
onEdgeMouseEnter={onEdgeMouseEnter}
|
|
onEdgeMouseLeave={onEdgeMouseLeave}
|
|
nodeTypes={nodeTypes}
|
|
edgeTypes={edgeTypes}
|
|
fitView
|
|
attributionPosition="bottom-left"
|
|
panOnScroll={false}
|
|
zoomOnScroll={true}
|
|
zoomOnPinch={true}
|
|
panOnDrag={true}
|
|
selectionOnDrag={false}
|
|
multiSelectionKeyCode={null}
|
|
selectNodesOnDrag={false}
|
|
selectionMode={SelectionMode.Partial}
|
|
>
|
|
<Controls />
|
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#E5E7EB" />
|
|
|
|
{/* 관계 목록 모달 */}
|
|
<RelationshipListModal
|
|
isOpen={showRelationshipListModal}
|
|
relationships={selectedTablePairRelationships.map((rel) => {
|
|
// 최신 tempRelationships에서 해당 관계 찾기
|
|
const updatedRel = tempRelationships.find((tempRel) => tempRel.id === rel.id);
|
|
return updatedRel || rel; // 업데이트된 관계가 있으면 사용, 없으면 원본 사용
|
|
})}
|
|
nodes={nodes}
|
|
diagramId={diagramId}
|
|
companyCode={companyCode}
|
|
editingRelationshipId={editingRelationshipId}
|
|
onClose={() => setShowRelationshipListModal(false)}
|
|
onEdit={() => {}}
|
|
onDelete={(relationshipId) => {
|
|
setTempRelationships((prev) => prev.filter((rel) => rel.id !== relationshipId));
|
|
setEdges((prev) => prev.filter((edge) => edge.data?.relationshipId !== relationshipId));
|
|
setHasUnsavedChanges(true);
|
|
}}
|
|
onSetEditingId={setEditingRelationshipId}
|
|
onSetSelectedColumns={setSelectedColumns}
|
|
onSetPendingConnection={setPendingConnection}
|
|
/>
|
|
</ReactFlow>
|
|
|
|
{/* 선택된 테이블 노드 팝업 */}
|
|
{selectedNodes.length > 0 && !showEdgeActions && !pendingConnection && !editingRelationshipId && (
|
|
<SelectedTablesPanel
|
|
selectedNodes={selectedNodes}
|
|
nodes={nodes}
|
|
onClose={() => setSelectedNodes([])}
|
|
onOpenConnectionModal={openConnectionModal}
|
|
onClear={() => {
|
|
setSelectedColumns({});
|
|
setSelectedNodes([]);
|
|
}}
|
|
canCreateConnection={canCreateConnection()}
|
|
/>
|
|
)}
|
|
|
|
{/* 안내 메시지 */}
|
|
{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
|
|
key={
|
|
pendingConnection
|
|
? `${pendingConnection.fromNode?.tableName || "unknown"}-${pendingConnection.toNode?.tableName || "unknown"}`
|
|
: "connection-modal"
|
|
}
|
|
isOpen={!!pendingConnection}
|
|
connection={pendingConnection}
|
|
companyCode={companyCode}
|
|
onConfirm={handleConfirmConnection}
|
|
onCancel={handleCancelConnection}
|
|
/>
|
|
|
|
{/* 엣지 정보 및 액션 버튼 */}
|
|
<EdgeInfoPanel
|
|
isOpen={showEdgeActions}
|
|
edgeInfo={selectedEdgeInfo}
|
|
position={edgeActionPosition}
|
|
onClose={() => {
|
|
setSelectedEdgeInfo(null);
|
|
setShowEdgeActions(false);
|
|
setSelectedEdgeForEdit(null);
|
|
setSelectedColumns({});
|
|
}}
|
|
onEdit={() => {
|
|
if (selectedEdgeInfo) {
|
|
// 기존 관계 찾기
|
|
const existingRelationship = tempRelationships.find((rel) => rel.id === selectedEdgeInfo.relationshipId);
|
|
|
|
if (existingRelationship) {
|
|
// 편집 모드로 설정
|
|
setEditingRelationshipId(selectedEdgeInfo.relationshipId);
|
|
|
|
// 연결 설정 모달 열기
|
|
const fromTable = nodes.find((node) => node.data?.table?.tableName === selectedEdgeInfo.fromTable);
|
|
const toTable = nodes.find((node) => node.data?.table?.tableName === selectedEdgeInfo.toTable);
|
|
|
|
if (fromTable && toTable) {
|
|
setPendingConnection({
|
|
fromNode: {
|
|
id: fromTable.id,
|
|
tableName: selectedEdgeInfo.fromTable,
|
|
displayName: fromTable.data?.table?.displayName || selectedEdgeInfo.fromTable,
|
|
},
|
|
toNode: {
|
|
id: toTable.id,
|
|
tableName: selectedEdgeInfo.toTable,
|
|
displayName: toTable.data?.table?.displayName || selectedEdgeInfo.toTable,
|
|
},
|
|
selectedColumnsData: {
|
|
[selectedEdgeInfo.fromTable]: {
|
|
displayName: fromTable.data?.table?.displayName || selectedEdgeInfo.fromTable,
|
|
columns: selectedEdgeInfo.fromColumns || [],
|
|
},
|
|
[selectedEdgeInfo.toTable]: {
|
|
displayName: toTable.data?.table?.displayName || selectedEdgeInfo.toTable,
|
|
columns: selectedEdgeInfo.toColumns || [],
|
|
},
|
|
},
|
|
existingRelationship: {
|
|
relationshipName: existingRelationship.relationshipName,
|
|
connectionType: existingRelationship.connectionType,
|
|
settings: existingRelationship.settings,
|
|
},
|
|
});
|
|
|
|
// 패널 닫기
|
|
setSelectedEdgeInfo(null);
|
|
setShowEdgeActions(false);
|
|
setSelectedEdgeForEdit(null);
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
onDelete={() => {
|
|
if (selectedEdgeInfo) {
|
|
// 관계 삭제
|
|
setTempRelationships((prev) => prev.filter((rel) => rel.id !== selectedEdgeInfo.relationshipId));
|
|
|
|
// 엣지 삭제
|
|
setEdges((prev) => prev.filter((edge) => edge.data?.relationshipId !== selectedEdgeInfo.relationshipId));
|
|
|
|
// 변경사항 표시
|
|
setHasUnsavedChanges(true);
|
|
|
|
// 패널 닫기
|
|
setSelectedEdgeInfo(null);
|
|
setShowEdgeActions(false);
|
|
setSelectedEdgeForEdit(null);
|
|
setSelectedColumns({});
|
|
|
|
toast.success("관계가 삭제되었습니다.");
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* 관계도 저장 모달 */}
|
|
<SaveDiagramModal
|
|
isOpen={showSaveModal}
|
|
onClose={handleCloseSaveModal}
|
|
onSave={handleSaveDiagram}
|
|
relationships={tempRelationships as JsonRelationship[]}
|
|
defaultName={
|
|
diagramId && diagramId > 0 && currentDiagramName
|
|
? currentDiagramName // 편집 모드: 기존 관계도 이름
|
|
: `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름
|
|
}
|
|
isLoading={isSaving}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|