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

1402 lines
53 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,
NodePositions,
} 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
// 내부에서 사용할 확장된 JsonRelationship 타입 (connectionType 포함)
interface ExtendedJsonRelationship extends JsonRelationship {
connectionType: string;
}
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[];
}>({});
// selectionOrder는 더 이상 사용하지 않음 (테이블 노드 선택 방식으로 변경)
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[];
};
};
existingRelationship?: {
relationshipName: string;
connectionType: string;
settings?: any;
};
} | 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[];
connectionType: string;
connectionInfo: string;
} | null>(null); // 선택된 엣지 정보
// 새로운 메모리 기반 상태들
const [tempRelationships, setTempRelationships] = useState<ExtendedJsonRelationship[]>([]); // 메모리에 저장된 관계들
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 저장되지 않은 변경사항
const [showSaveModal, setShowSaveModal] = useState(false); // 저장 모달 표시 상태
const [isSaving, setIsSaving] = useState(false); // 저장 중 상태
const [currentDiagramName, setCurrentDiagramName] = useState<string>(""); // 현재 편집 중인 관계도 이름
const [currentDiagramCategory, setCurrentDiagramCategory] = useState<string>("simple-key"); // 현재 관계도의 연결 종류
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;
});
// selectionOrder는 더 이상 사용하지 않음
// 선택된 노드 초기화
setSelectedNodes([]);
toast.success(`${selectedNodes.length}개 테이블이 삭제되었습니다.`);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodes, setNodes]);
// 컬럼 클릭 처리 비활성화 (테이블 노드 선택 방식으로 변경)
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
// 컬럼 클릭으로는 더 이상 선택하지 않음
console.log(`컬럼 클릭 무시됨: ${tableName}.${columnName}`);
return;
}, []);
// 선택된 관계도의 관계 로드
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);
// 기존 데이터에서 relationshipName이 없는 경우 기본값 설정
// category를 각 관계의 connectionType으로 복원
const normalizedRelationships: ExtendedJsonRelationship[] = relationships.map((rel: JsonRelationship) => ({
...rel,
relationshipName: rel.relationshipName || `${rel.fromTable}${rel.toTable}`, // 기본값 설정
connectionType: jsonDiagram.category || "simple-key", // 관계도의 category를 각 관계의 connectionType으로 복원
}));
// 메모리에 관계 저장 (기존 관계도 편집 시)
setTempRelationships(normalizedRelationships);
setCurrentDiagramId(currentDiagramId);
setCurrentDiagramCategory(jsonDiagram.category || "simple-key"); // 관계도의 연결 종류 설정
// 테이블 노드 생성을 위한 테이블 정보 로드
// 테이블 정보 로드
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 savedNodePositions = jsonDiagram.node_positions || {};
console.log("📍 저장된 노드 위치:", savedNodePositions);
// 테이블을 노드로 변환 (저장된 위치 우선 사용, 없으면 자동 레이아웃)
const tableNodes = tableDefinitions.map((table, index) => {
// 저장된 위치가 있으면 사용, 없으면 자동 배치
const savedPosition = savedNodePositions[table.tableName];
const x = savedPosition ? savedPosition.x : (index % 3) * 400 + 100; // 3열 배치
const y = savedPosition ? savedPosition.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[] = [];
normalizedRelationships.forEach((rel: ExtendedJsonRelationship) => {
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: rel.relationshipName,
connectionType: rel.connectionType,
fromTable: fromTable,
toTable: toTable,
fromColumns: fromColumns,
toColumns: toColumns,
// 클릭 시 표시할 상세 정보
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
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 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],
);
// 노드 선택 변경 핸들러 (React Flow 자체 선택 이벤트는 무시)
const onSelectionChange = useCallback(() => {
// React Flow의 자동 선택 변경은 무시하고 우리의 커스텀 로직만 사용
// 이 함수는 비워두거나 최소한의 동기화만 수행
}, []);
// 캔버스 클릭 시 엣지 정보 섹션 닫기
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[];
connectionType: string;
details?: {
connectionInfo: string;
connectionType: string;
};
};
if (edgeData) {
// 엣지 정보 설정
setSelectedEdgeInfo({
relationshipId: edgeData.relationshipId,
relationshipName: edgeData.relationshipName || "관계",
fromTable: edgeData.fromTable,
toTable: edgeData.toTable,
fromColumns: edgeData.fromColumns || [],
toColumns: edgeData.toColumns || [],
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는 더 이상 사용하지 않음
}, [selectedColumns, setNodes]);
// 연결 가능한 상태인지 확인 (테이블 노드 선택 기반으로 변경)
const canCreateConnection = () => {
// 최소 2개의 테이블 노드가 선택되어야 함
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 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: ExtendedJsonRelationship = {
id: editingRelationshipId || generateUniqueId("rel", Date.now()), // 수정 모드면 기존 ID 사용
relationshipName: relationship.relationship_name, // 연결 이름 추가
fromTable,
toTable,
fromColumns,
toColumns,
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);
// 첫 번째 관계가 추가되면 관계도의 category를 해당 connectionType으로 설정
if (tempRelationships.length === 0) {
setCurrentDiagramCategory(relationship.connection_type);
}
// 캔버스에 엣지 즉시 표시
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: newRelationship.relationshipName,
connectionType: relationship.connection_type,
fromTable,
toTable,
fromColumns,
toColumns,
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
connectionType: relationship.connection_type,
},
},
};
setEdges((eds) => [...eds, newEdge]);
setPendingConnection(null);
// 관계 생성 후 선택된 컬럼들 초기화
setSelectedColumns({});
console.log("메모리에 관계 생성 완료:", newRelationship);
toast.success("관계가 생성되었습니다. 저장 버튼을 눌러 관계도를 저장하세요.");
},
[pendingConnection, setEdges],
);
// 연결 설정 취소
const handleCancelConnection = useCallback(() => {
setPendingConnection(null);
// 수정 모드였다면 해제
if (editingRelationshipId) {
setEditingRelationshipId(null);
// 편집 모드 취소 시 선택된 컬럼도 초기화
setSelectedColumns({});
}
}, [editingRelationshipId]);
// 관계도 저장 함수
const handleSaveDiagram = useCallback(
async (diagramName: string) => {
// 🔥 수정: 관계가 없어도 노드가 있으면 저장 가능
if (nodes.length === 0) {
toast.error("저장할 테이블이 없습니다.");
return;
}
setIsSaving(true);
try {
// 🔥 수정: 현재 캔버스의 모든 테이블 기반으로 변경
const connectedTables = nodes
.map((node) => node.data?.table?.tableName)
.filter((tableName) => tableName)
.sort();
// 현재 노드 위치 추출
const nodePositions: NodePositions = {};
nodes.forEach((node) => {
if (node.data?.table?.tableName) {
nodePositions[node.data.table.tableName] = {
x: node.position.x,
y: node.position.y,
};
}
});
console.log("🔍 저장할 노드 위치 정보:", nodePositions);
console.log("📊 현재 노드 개수:", nodes.length);
console.log("📋 연결된 테이블 목록:", connectedTables);
console.log("🔗 관계 개수:", tempRelationships.length);
// 관계도의 주요 연결 타입 결정 (첫 번째 관계의 connectionType 사용)
const primaryConnectionType = tempRelationships.length > 0 ? tempRelationships[0].connectionType : "simple-key";
// connectionType을 관계에서 제거하고 관계도 레벨로 이동
const relationshipsWithoutConnectionType = tempRelationships.map((rel) => {
const { connectionType, ...relationshipWithoutType } = rel;
return relationshipWithoutType;
});
// 저장 요청 데이터 생성
const createRequest: CreateDiagramRequest = {
diagram_name: diagramName,
relationships: {
relationships: relationshipsWithoutConnectionType,
tables: connectedTables,
},
node_positions: nodePositions,
category: primaryConnectionType, // connectionType을 관계도 레벨의 category로 이동
};
console.log("🚀 API 요청 데이터:", JSON.stringify(createRequest, null, 2));
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, nodes],
);
// 저장 모달 열기
const handleOpenSaveModal = useCallback(() => {
// 관계가 0개여도 저장 가능하도록 수정
setShowSaveModal(true);
}, []);
// 저장 모달 닫기
const handleCloseSaveModal = useCallback(() => {
if (!isSaving) {
setShowSaveModal(false);
}
}, [isSaving]);
// 고립된 노드 제거 함수
const removeOrphanedNodes = useCallback(
(updatedRelationships: ExtendedJsonRelationship[], 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[];
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,
},
},
// 기존 관계 정보 추가 (연결 이름 유지를 위해)
existingRelationship: {
relationshipName: existingRelationship.relationshipName,
connectionType: existingRelationship.connectionType,
settings: existingRelationship.settings,
},
};
// 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>
<div className="flex justify-between">
<span> :</span>
<span className="font-medium">
{currentDiagramCategory === "simple-key" && "단순 키값"}
{currentDiagramCategory === "data-save" && "데이터 저장"}
{currentDiagramCategory === "external-call" && "외부 호출"}
</span>
</div>
{hasUnsavedChanges && (
<div className="mt-2 text-xs font-medium text-orange-600"> </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}
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={[1, 2]}
selectionOnDrag={false}
multiSelectionKeyCode={null}
selectNodesOnDrag={false}
selectionMode={SelectionMode.Partial}
>
<Controls />
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#E5E7EB" />
</ReactFlow>
{/* 선택된 테이블 노드 팝업 - 캔버스 좌측 상단 고정 (새 관계 생성 시에만 표시) */}
{selectedNodes.length > 0 && !showEdgeActions && !pendingConnection && !editingRelationshipId && (
<div className="pointer-events-auto absolute top-4 left-4 z-40 w-80 rounded-xl border border-blue-200 bg-white shadow-lg">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-t-xl border-b border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-3">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100">
<span className="text-sm text-blue-600">📋</span>
</div>
<div>
<div className="text-sm font-semibold text-gray-800"> </div>
<div className="text-xs text-gray-500">
{selectedNodes.length === 1
? "FROM 테이블 선택됨"
: selectedNodes.length === 2
? "FROM → TO 연결 준비"
: `${selectedNodes.length}개 테이블`}
</div>
</div>
</div>
<button
onClick={() => {
setSelectedNodes([]);
}}
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
</button>
</div>
{/* 컨텐츠 */}
<div className="max-h-80 overflow-y-auto p-3">
<div className="space-y-3">
{selectedNodes.map((nodeId, index) => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) return null;
const { tableName, displayName } = node.data.table;
return (
<div key={`selected-${nodeId}-${index}`}>
{/* 테이블 정보 */}
<div
className={`rounded-lg p-2 ${
index === 0
? "border-l-4 border-emerald-400 bg-emerald-50"
: index === 1
? "border-l-4 border-blue-400 bg-blue-50"
: "bg-gray-50"
}`}
>
<div className="mb-1 flex items-center justify-between">
<div
className={`text-xs font-medium ${
index === 0 ? "text-emerald-700" : index === 1 ? "text-blue-700" : "text-gray-700"
}`}
>
{displayName}
</div>
{selectedNodes.length === 2 && (
<div
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
index === 0 ? "bg-emerald-200 text-emerald-800" : "bg-blue-200 text-blue-800"
}`}
>
{index === 0 ? "FROM" : "TO"}
</div>
)}
</div>
<div className="text-xs text-gray-600">{tableName}</div>
</div>
{/* 연결 화살표 (마지막이 아닌 경우) */}
{index < selectedNodes.length - 1 && (
<div className="flex justify-center py-1">
<div className="text-gray-400"></div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-2 border-t border-blue-100 p-3">
<button
onClick={openConnectionModal}
disabled={!canCreateConnection()}
className={`flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs font-medium transition-colors ${
canCreateConnection()
? "bg-blue-500 text-white hover:bg-blue-600"
: "cursor-not-allowed bg-gray-300 text-gray-500"
}`}
>
<span>🔗</span>
<span> </span>
</button>
<button
onClick={() => {
setSelectedColumns({});
setSelectedNodes([]);
}}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-gray-600 hover:bg-gray-300"
>
<span>🗑</span>
<span></span>
</button>
</div>
</div>
)}
{/* 안내 메시지 */}
{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}
onConfirm={handleConfirmConnection}
onCancel={handleCancelConnection}
/>
{/* 엣지 정보 및 액션 버튼 */}
{showEdgeActions && selectedEdgeForEdit && selectedEdgeInfo && (
<div
className="fixed z-50 rounded-xl border border-gray-200 bg-white shadow-2xl"
style={{
left: edgeActionPosition.x - 160,
top: edgeActionPosition.y - 100,
minWidth: "320px",
maxWidth: "380px",
}}
>
{/* 헤더 */}
<div className="flex items-center justify-between rounded-t-xl border-b border-gray-200 bg-blue-600 p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
<span className="text-sm text-white">🔗</span>
</div>
<div>
<div className="text-sm font-bold text-white">{selectedEdgeInfo.relationshipName}</div>
<div className="text-xs text-blue-100"> </div>
</div>
</div>
<button
onClick={() => {
setSelectedEdgeInfo(null);
setShowEdgeActions(false);
setSelectedEdgeForEdit(null);
setSelectedColumns({});
}}
className="flex h-6 w-6 items-center justify-center rounded-full text-white/80 transition-all hover:bg-white/20 hover:text-white"
>
<span className="text-sm"></span>
</button>
</div>
{/* 관계 정보 요약 */}
<div className="border-b border-gray-100 bg-gray-50 p-3">
<div className="flex items-center justify-center gap-4">
<div className="text-center">
<div className="text-xs font-medium tracking-wide text-gray-500 uppercase"> </div>
<div className="mt-1 inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-800">
{selectedEdgeInfo.connectionType}
</div>
</div>
</div>
</div>
{/* 연결 정보 */}
<div className="space-y-3 p-4">
{/* From 테이블 */}
<div className="rounded-lg border-l-4 border-emerald-400 bg-emerald-50 p-3">
<div className="mb-2 text-xs font-bold tracking-wide text-emerald-700 uppercase">FROM</div>
<div className="mb-2 text-base font-bold text-gray-800">{selectedEdgeInfo.fromTable}</div>
<div className="space-y-1">
<div className="flex flex-wrap gap-2">
{selectedEdgeInfo.fromColumns.map((column, index) => (
<span
key={index}
className="inline-flex items-center rounded-md bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800 ring-1 ring-emerald-200"
>
{column}
</span>
))}
</div>
</div>
</div>
{/* 관계 화살표 */}
<div className="flex justify-center">
<span className="text-l text-gray-600"></span>
</div>
{/* To 테이블 */}
<div className="rounded-lg border-l-4 border-blue-400 bg-blue-50 p-3">
<div className="mb-2 text-xs font-bold tracking-wide text-blue-700 uppercase">TO</div>
<div className="mb-2 text-base font-bold text-gray-800">{selectedEdgeInfo.toTable}</div>
<div className="space-y-1">
<div className="flex flex-wrap gap-2">
{selectedEdgeInfo.toColumns.map((column, index) => (
<span
key={index}
className="inline-flex items-center rounded-md bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 ring-1 ring-blue-200"
>
{column}
</span>
))}
</div>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-2 border-t border-gray-200 bg-gray-50 p-3">
<button
onClick={handleEditEdge}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md"
>
<span></span>
</button>
<button
onClick={handleDeleteEdge}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white shadow-sm transition-all hover:bg-red-700 hover:shadow-md"
>
<span></span>
</button>
</div>
</div>
)}
{/* 관계도 저장 모달 */}
<SaveDiagramModal
isOpen={showSaveModal}
onClose={handleCloseSaveModal}
onSave={handleSaveDiagram}
relationships={tempRelationships}
defaultName={
diagramId && diagramId > 0 && currentDiagramName
? currentDiagramName // 편집 모드: 기존 관계도 이름
: `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름
}
isLoading={isSaving}
/>
</div>
);
};