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

1694 lines
68 KiB
TypeScript
Raw Normal View History

2025-09-05 11:30:27 +09:00
"use client";
2025-09-05 16:19:31 +09:00
import React, { useState, useCallback, useEffect, useRef } from "react";
import toast from "react-hot-toast";
2025-09-05 11:30:27 +09:00
import {
ReactFlow,
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
2025-09-05 18:00:18 +09:00
BackgroundVariant,
2025-09-08 10:33:00 +09:00
SelectionMode,
2025-09-05 11:30:27 +09:00
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
2025-09-05 18:00:18 +09:00
import { TableNode } from "./TableNode";
import { TableSelector } from "./TableSelector";
2025-09-05 16:19:31 +09:00
import { ConnectionSetupModal } from "./ConnectionSetupModal";
import {
TableDefinition,
TableRelationship,
DataFlowAPI,
DataFlowDiagram,
JsonRelationship,
CreateDiagramRequest,
NodePositions,
} from "@/lib/api/dataflow";
import SaveDiagramModal from "./SaveDiagramModal";
import { useAuth } from "@/hooks/useAuth";
2025-09-08 16:46:53 +09:00
// 고유 ID 생성 함수
const generateUniqueId = (prefix: string, diagramId?: number): string => {
2025-09-08 16:46:53 +09:00
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 9);
return `${prefix}-${diagramId || timestamp}-${random}`;
2025-09-08 16:46:53 +09:00
};
2025-09-05 18:00:18 +09:00
// 테이블 노드 데이터 타입 정의
interface TableNodeData extends Record<string, unknown> {
table: {
tableName: string;
displayName: string;
description: string;
columns: Array<{
name: string;
type: string;
description: string;
}>;
};
onColumnClick: (tableName: string, columnName: string) => void;
selectedColumns: string[];
}
2025-09-05 11:30:27 +09:00
// 노드 및 엣지 타입 정의
const nodeTypes = {
2025-09-05 18:00:18 +09:00
tableNode: TableNode,
2025-09-05 11:30:27 +09:00
};
2025-09-05 18:00:18 +09:00
const edgeTypes = {};
2025-09-05 11:30:27 +09:00
interface DataFlowDesignerProps {
companyCode?: string;
2025-09-05 18:00:18 +09:00
onSave?: (relationships: TableRelationship[]) => void;
selectedDiagram?: DataFlowDiagram | string | null;
diagramId?: number;
relationshipId?: string; // 하위 호환성 유지
2025-09-09 11:35:05 +09:00
onBackToList?: () => void;
onDiagramNameUpdate?: (diagramName: string) => void; // 관계도 이름 업데이트 콜백 추가
2025-09-05 18:00:18 +09:00
}
2025-09-08 16:46:53 +09:00
// TableRelationship 타입은 dataflow.ts에서 import
2025-09-05 11:30:27 +09:00
2025-09-15 15:12:02 +09:00
// 내부에서 사용할 확장된 JsonRelationship 타입 (connectionType 포함)
interface ExtendedJsonRelationship extends JsonRelationship {
2025-09-15 20:07:28 +09:00
connectionType: "simple-key" | "data-save" | "external-call";
2025-09-15 15:12:02 +09:00
}
2025-09-09 11:35:05 +09:00
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode: propCompanyCode = "*",
diagramId,
relationshipId, // 하위 호환성 유지
onSave, // eslint-disable-line @typescript-eslint/no-unused-vars
2025-09-09 11:35:05 +09:00
selectedDiagram,
2025-09-09 12:00:58 +09:00
onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars
onDiagramNameUpdate, // 관계도 이름 업데이트 콜백
2025-09-09 11:35:05 +09:00
}) => {
const { user } = useAuth();
// 실제 사용자 회사 코드 사용 (prop보다 사용자 정보 우선)
const companyCode = user?.company_code || user?.companyCode || propCompanyCode;
2025-09-05 18:00:18 +09:00
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selectedColumns, setSelectedColumns] = useState<{
[tableName: string]: string[];
2025-09-05 16:19:31 +09:00
}>({});
2025-09-15 15:12:02 +09:00
// selectionOrder는 더 이상 사용하지 않음 (테이블 노드 선택 방식으로 변경)
2025-09-05 18:21:28 +09:00
const [selectedNodes, setSelectedNodes] = useState<string[]>([]);
2025-09-05 16:19:31 +09:00
const [pendingConnection, setPendingConnection] = useState<{
2025-09-05 18:00:18 +09:00
fromNode: { id: string; tableName: string; displayName: string };
toNode: { id: string; tableName: string; displayName: string };
fromColumn?: string;
toColumn?: string;
selectedColumnsData?: {
[tableName: string]: {
displayName: string;
columns: string[];
2025-09-05 16:19:31 +09:00
};
};
2025-09-11 10:45:16 +09:00
existingRelationship?: {
relationshipName: string;
connectionType: string;
2025-09-15 20:07:28 +09:00
settings?: Record<string, unknown>;
2025-09-11 10:45:16 +09:00
};
2025-09-05 11:30:27 +09:00
} | null>(null);
const [relationships, setRelationships] = useState<TableRelationship[]>([]); // eslint-disable-line @typescript-eslint/no-unused-vars
const [currentDiagramId, setCurrentDiagramId] = useState<number | null>(null); // 현재 화면의 diagram_id
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{
relationshipId: string;
relationshipName: string;
fromTable: string;
toTable: string;
fromColumns: string[];
toColumns: string[];
connectionType: string;
connectionInfo: string;
} | null>(null); // 선택된 엣지 정보
// 새로운 메모리 기반 상태들
2025-09-15 15:12:02 +09:00
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>(""); // 현재 편집 중인 관계도 이름
2025-09-15 15:12:02 +09:00
const [currentDiagramCategory, setCurrentDiagramCategory] = useState<string>("simple-key"); // 현재 관계도의 연결 종류
2025-09-10 17:25:41 +09:00
const [selectedEdgeForEdit, setSelectedEdgeForEdit] = useState<Edge | null>(null); // 수정/삭제할 엣지
const [showEdgeActions, setShowEdgeActions] = useState(false); // 엣지 액션 버튼 표시 상태
2025-09-15 20:07:28 +09:00
const [edgeActionPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); // 액션 버튼 위치 (사용하지 않지만 기존 코드 호환성 유지)
2025-09-10 17:25:41 +09:00
const [editingRelationshipId, setEditingRelationshipId] = useState<string | null>(null); // 현재 수정 중인 관계 ID
const [showRelationshipListModal, setShowRelationshipListModal] = useState(false); // 관계 목록 모달 표시 상태
const [selectedTablePairRelationships, setSelectedTablePairRelationships] = useState<ExtendedJsonRelationship[]>([]); // 선택된 테이블 쌍의 관계들
2025-09-09 12:00:58 +09:00
const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars
2025-09-05 11:30:27 +09:00
// 편집 모드일 때 관계도 이름 로드
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]);
2025-09-05 18:21:28 +09:00
// 키보드 이벤트 핸들러 (Del 키로 선택된 노드 삭제)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Delete" && selectedNodes.length > 0) {
// 선택된 노드들 삭제
setNodes((prevNodes) => prevNodes.filter((node) => !selectedNodes.includes(node.id)));
// 삭제된 노드들과 관련된 선택된 컬럼들도 정리
const deletedTableNames = selectedNodes
.filter((nodeId) => nodeId.startsWith("table-"))
.map((nodeId) => nodeId.replace("table-", ""));
setSelectedColumns((prev) => {
const newColumns = { ...prev };
deletedTableNames.forEach((tableName) => {
delete newColumns[tableName];
});
return newColumns;
});
2025-09-15 15:12:02 +09:00
// selectionOrder는 더 이상 사용하지 않음
2025-09-05 18:21:28 +09:00
// 선택된 노드 초기화
setSelectedNodes([]);
toast.success(`${selectedNodes.length}개 테이블이 삭제되었습니다.`);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodes, setNodes]);
2025-09-15 15:12:02 +09:00
// 컬럼 클릭 처리 비활성화 (테이블 노드 선택 방식으로 변경)
2025-09-09 11:35:05 +09:00
const handleColumnClick = useCallback((tableName: string, columnName: string) => {
2025-09-15 15:12:02 +09:00
// 컬럼 클릭으로는 더 이상 선택하지 않음
console.log(`컬럼 클릭 무시됨: ${tableName}.${columnName}`);
return;
2025-09-09 11:35:05 +09:00
}, []);
// 선택된 관계도의 관계 로드
const loadSelectedDiagramRelationships = useCallback(async () => {
const currentDiagramId = diagramId || (relationshipId ? parseInt(relationshipId) : null);
if (!currentDiagramId || isNaN(currentDiagramId)) return;
2025-09-09 11:35:05 +09:00
try {
console.log("🔍 JSON 관계도 로드 시작 (diagramId):", currentDiagramId);
2025-09-09 11:35:05 +09:00
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 || [];
2025-09-09 11:35:05 +09:00
console.log("📋 관계 목록:", relationships);
console.log("📊 테이블 목록:", tableNames);
2025-09-15 20:07:28 +09:00
// 🔥 수정: category 배열에서 각 관계의 connectionType 복원
const categoryMap = new Map<string, string>();
if (Array.isArray(jsonDiagram.category)) {
jsonDiagram.category.forEach((cat: { id: string; category: string }) => {
if (cat.id && cat.category) {
categoryMap.set(cat.id, cat.category);
}
});
}
2025-09-10 18:30:22 +09:00
// 기존 데이터에서 relationshipName이 없는 경우 기본값 설정
2025-09-15 15:12:02 +09:00
const normalizedRelationships: ExtendedJsonRelationship[] = relationships.map((rel: JsonRelationship) => ({
2025-09-10 18:30:22 +09:00
...rel,
relationshipName: rel.relationshipName || `${rel.fromTable}${rel.toTable}`, // 기본값 설정
2025-09-15 20:07:28 +09:00
connectionType: (rel.connectionType || categoryMap.get(rel.id) || "simple-key") as
| "simple-key"
| "data-save"
| "external-call", // category 배열에서 복원
2025-09-10 18:30:22 +09:00
}));
// 메모리에 관계 저장 (기존 관계도 편집 시)
2025-09-15 20:07:28 +09:00
console.log("🔥 정규화된 관계들:", normalizedRelationships);
2025-09-10 18:30:22 +09:00
setTempRelationships(normalizedRelationships);
setCurrentDiagramId(currentDiagramId);
2025-09-15 15:12:02 +09:00
setCurrentDiagramCategory(jsonDiagram.category || "simple-key"); // 관계도의 연결 종류 설정
// 테이블 노드 생성을 위한 테이블 정보 로드
2025-09-09 11:35:05 +09:00
// 테이블 정보 로드
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);
2025-09-10 11:27:05 +09:00
const safeColumns = Array.isArray(columns) ? columns : [];
console.log(`📋 테이블 ${tableName}의 컬럼 수:`, safeColumns.length);
2025-09-09 11:35:05 +09:00
tableDefinitions.push({
tableName: foundTable.tableName,
displayName: foundTable.displayName,
description: foundTable.description,
2025-09-10 11:27:05 +09:00
columns: safeColumns,
2025-09-09 11:35:05 +09:00
});
} else {
console.warn(`⚠️ 테이블 ${tableName}을 찾을 수 없습니다`);
}
}
2025-09-09 12:00:58 +09:00
// 연결된 컬럼 정보 계산
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 || [];
2025-09-09 12:00:58 +09:00
// 소스 테이블의 컬럼들을 source로 표시
if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {};
fromColumns.forEach((col: string) => {
2025-09-09 12:00:58 +09:00
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) => {
2025-09-09 12:00:58 +09:00
if (connectedColumnsInfo[toTable][col]) {
connectedColumnsInfo[toTable][col].direction = "both";
} else {
connectedColumnsInfo[toTable][col] = { direction: "target" };
}
});
});
2025-09-10 11:27:05 +09:00
console.log("🔌 연결된 컬럼 정보:", connectedColumnsInfo);
// 저장된 노드 위치 정보 가져오기
const savedNodePositions = jsonDiagram.node_positions || {};
console.log("📍 저장된 노드 위치:", savedNodePositions);
// 테이블을 노드로 변환 (저장된 위치 우선 사용, 없으면 자동 레이아웃)
2025-09-09 11:35:05 +09:00
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;
2025-09-09 11:35:05 +09:00
return {
id: `table-${table.tableName}`,
type: "tableNode",
position: { x, y },
data: {
table: {
tableName: table.tableName,
displayName: table.displayName,
2025-09-10 11:27:05 +09:00
description: "", // 기존 로드된 노드도 description 없이 통일
columns: Array.isArray(table.columns)
? table.columns.map((col) => ({
name: col.columnName,
type: col.dataType || "varchar",
description: col.description || "",
}))
: [],
2025-09-09 11:35:05 +09:00
},
onColumnClick: handleColumnClick,
2025-09-10 11:27:05 +09:00
selectedColumns: [], // 관계도 로드 시에는 빈 상태로 시작
2025-09-09 12:00:58 +09:00
connectedColumns: connectedColumnsInfo[table.tableName] || {},
2025-09-09 11:35:05 +09:00
} as TableNodeData,
};
});
console.log("🎨 생성된 테이블 노드 수:", tableNodes.length);
console.log("📍 테이블 노드 상세:", tableNodes);
setNodes(tableNodes);
// JSON 관계를 엣지로 변환하여 표시 (각 관계마다 개별 엣지 생성)
2025-09-09 12:00:58 +09:00
const relationshipEdges: Edge[] = [];
const tableRelationshipCount: { [key: string]: number } = {}; // 테이블 쌍별 관계 개수
2025-09-09 12:00:58 +09:00
2025-09-15 20:07:28 +09:00
console.log("🔥 엣지 생성 시작 - 관계 개수:", normalizedRelationships.length);
2025-09-15 15:12:02 +09:00
normalizedRelationships.forEach((rel: ExtendedJsonRelationship) => {
2025-09-15 20:07:28 +09:00
console.log("🔥 관계 처리 중:", rel.id, rel.connectionType, rel.fromTable, "→", rel.toTable);
const fromTable = rel.fromTable;
const toTable = rel.toTable;
const fromColumns = rel.fromColumns || [];
const toColumns = rel.toColumns || [];
2025-09-09 12:00:58 +09:00
2025-09-15 20:07:28 +09:00
// 🔥 수정: 컬럼 정보가 없어도 엣지는 생성 (data-save 연결 등에서는 컬럼이 없을 수 있음)
if (fromColumns.length === 0 || toColumns.length === 0) {
2025-09-15 20:07:28 +09:00
console.warn("⚠️ 컬럼 정보가 없지만 엣지는 생성합니다:", {
fromColumns,
toColumns,
connectionType: rel.connectionType,
});
}
// 테이블 쌍 키 생성 (양방향 동일하게 처리)
const tableKey = [fromTable, toTable].sort().join("-");
tableRelationshipCount[tableKey] = (tableRelationshipCount[tableKey] || 0) + 1;
const relationshipIndex = tableRelationshipCount[tableKey];
// 각 관계마다 고유한 엣지 생성 (곡선 오프셋으로 구분)
const curveOffset = (relationshipIndex - 1) * 30; // 30px씩 오프셋
2025-09-10 11:27:05 +09:00
relationshipEdges.push({
id: `edge-${rel.id}`, // 관계 ID를 기반으로 고유 ID 생성
2025-09-10 11:27:05 +09:00
source: `table-${fromTable}`,
target: `table-${toTable}`,
type: "smoothstep",
animated: false,
style: {
stroke: "#3b82f6",
strokeWidth: 2,
strokeDasharray: "none",
},
// 여러 관계가 있을 때 곡선 오프셋 적용
...(relationshipIndex > 1 && {
style: {
stroke: "#3b82f6",
strokeWidth: 2,
strokeDasharray: "none",
},
pathOptions: {
offset: curveOffset,
},
}),
2025-09-10 11:27:05 +09:00
data: {
relationshipId: rel.id,
2025-09-10 18:30:22 +09:00
relationshipName: rel.relationshipName,
connectionType: rel.connectionType,
2025-09-10 11:27:05 +09:00
fromTable: fromTable,
toTable: toTable,
fromColumns: fromColumns,
toColumns: toColumns,
// 테이블 쌍의 모든 관계 정보 (엣지 클릭 시 사용)
tableKey: tableKey,
relationshipIndex: relationshipIndex,
2025-09-10 11:27:05 +09:00
// 클릭 시 표시할 상세 정보
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
connectionType: rel.connectionType,
2025-09-09 12:00:58 +09:00
},
2025-09-10 11:27:05 +09:00
},
});
2025-09-09 12:00:58 +09:00
});
2025-09-09 11:35:05 +09:00
console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length);
console.log("📍 관계 에지 상세:", relationshipEdges);
2025-09-15 20:07:28 +09:00
console.log(
"🔥 최종 엣지 설정 전 확인:",
relationshipEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
connectionType: e.data?.connectionType,
})),
);
2025-09-09 11:35:05 +09:00
setEdges(relationshipEdges);
toast.success("관계도를 불러왔습니다.", { id: "load-diagram" });
2025-09-09 11:35:05 +09:00
} catch (error) {
console.error("선택된 관계도 로드 실패:", error);
toast.error("관계도를 불러오는데 실패했습니다.", { id: "load-diagram" });
}
2025-09-10 11:27:05 +09:00
}, [diagramId, relationshipId, setNodes, setEdges, handleColumnClick]);
2025-09-09 11:35:05 +09:00
// 기존 관계 로드 (새 관계도 생성 시)
2025-09-08 16:46:53 +09:00
const loadExistingRelationships = useCallback(async () => {
2025-09-09 11:35:05 +09:00
if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음
2025-09-08 16:46:53 +09:00
try {
// 새로운 JSON 기반 시스템에서는 기존 관계를 미리 로드하지 않음
console.log("새 관계도 생성 모드: 빈 캔버스로 시작");
setRelationships([]);
2025-09-08 16:46:53 +09:00
// 빈 캔버스로 시작
setEdges([]);
2025-09-08 16:46:53 +09:00
} catch (error) {
console.error("기존 관계 로드 실패:", error);
toast.error("기존 관계를 불러오는데 실패했습니다.");
}
}, [setEdges, selectedDiagram]);
2025-09-08 16:46:53 +09:00
2025-09-09 11:35:05 +09:00
// 컴포넌트 마운트 시 관계 로드
2025-09-08 16:46:53 +09:00
useEffect(() => {
if (companyCode) {
if (diagramId || relationshipId) {
2025-09-09 11:35:05 +09:00
loadSelectedDiagramRelationships();
} else {
loadExistingRelationships();
}
2025-09-08 16:46:53 +09:00
}
}, [companyCode, diagramId, relationshipId, loadExistingRelationships, loadSelectedDiagramRelationships]);
2025-09-08 16:46:53 +09:00
2025-09-15 15:12:02 +09:00
// 노드 클릭 핸들러 (커스텀 다중 선택 구현)
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의 자동 선택 변경은 무시하고 우리의 커스텀 로직만 사용
// 이 함수는 비워두거나 최소한의 동기화만 수행
2025-09-05 18:21:28 +09:00
}, []);
2025-09-10 11:27:05 +09:00
// 캔버스 클릭 시 엣지 정보 섹션 닫기
const onPaneClick = useCallback(() => {
if (selectedEdgeInfo) {
setSelectedEdgeInfo(null);
}
2025-09-10 17:25:41 +09:00
if (showEdgeActions) {
setShowEdgeActions(false);
setSelectedEdgeForEdit(null);
}
// 컬럼 선택 해제
setSelectedColumns({});
}, [selectedEdgeInfo, showEdgeActions]);
2025-09-10 11:27:05 +09:00
2025-09-05 16:19:31 +09:00
// 빈 onConnect 함수 (드래그 연결 비활성화)
const onConnect = useCallback(() => {
// 드래그로 연결하는 것을 방지
return;
}, []);
2025-09-10 17:25:41 +09:00
// 엣지 클릭 시 연결 정보 표시 및 관련 컬럼 하이라이트
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;
tableKey: string;
relationshipIndex: number;
details?: {
connectionInfo: string;
connectionType: string;
};
};
2025-09-10 17:25:41 +09:00
if (edgeData) {
// 해당 테이블 쌍의 모든 관계 찾기
const fromTable = edgeData.fromTable;
const toTable = edgeData.toTable;
2025-09-10 17:25:41 +09:00
const tablePairRelationships = tempRelationships.filter(
(rel) =>
(rel.fromTable === fromTable && rel.toTable === toTable) ||
(rel.fromTable === toTable && rel.toTable === fromTable),
);
2025-09-10 17:25:41 +09:00
console.log(`🔗 ${fromTable}${toTable} 간의 관계:`, tablePairRelationships);
2025-09-10 17:25:41 +09:00
// 관계가 1개든 여러 개든 항상 관계 목록 모달 표시
setSelectedTablePairRelationships(tablePairRelationships);
setShowRelationshipListModal(true);
2025-09-10 17:25:41 +09:00
}
},
[tempRelationships],
);
2025-09-10 11:27:05 +09:00
// 엣지 마우스 엔터 시 색상 변경
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],
);
2025-09-05 18:00:18 +09:00
// 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
2025-09-05 16:19:31 +09:00
useEffect(() => {
setNodes((prevNodes) =>
prevNodes.map((node) => ({
...node,
2025-09-05 11:30:27 +09:00
data: {
2025-09-05 16:19:31 +09:00
...node.data,
2025-09-05 18:00:18 +09:00
selectedColumns: selectedColumns[node.data.table.tableName] || [],
2025-09-05 11:30:27 +09:00
},
2025-09-05 16:19:31 +09:00
})),
);
2025-09-15 15:12:02 +09:00
// selectionOrder는 더 이상 사용하지 않음
2025-09-05 18:00:18 +09:00
}, [selectedColumns, setNodes]);
2025-09-05 16:19:31 +09:00
2025-09-15 15:12:02 +09:00
// 연결 가능한 상태인지 확인 (테이블 노드 선택 기반으로 변경)
2025-09-05 16:19:31 +09:00
const canCreateConnection = () => {
2025-09-15 15:12:02 +09:00
// 최소 2개의 테이블 노드가 선택되어야 함
return selectedNodes.length >= 2;
2025-09-05 16:19:31 +09:00
};
2025-09-15 15:12:02 +09:00
// 테이블 노드 연결 설정 모달 열기 (컬럼 선택 불필요)
2025-09-05 16:19:31 +09:00
const openConnectionModal = () => {
2025-09-15 15:12:02 +09:00
if (selectedNodes.length < 2) return;
2025-09-05 16:19:31 +09:00
2025-09-15 15:12:02 +09:00
// 선택된 첫 번째와 두 번째 노드 찾기
const firstNode = nodes.find((node) => node.id === selectedNodes[0]);
const secondNode = nodes.find((node) => node.id === selectedNodes[1]);
2025-09-05 16:19:31 +09:00
if (!firstNode || !secondNode) return;
setPendingConnection({
fromNode: {
id: firstNode.id,
2025-09-05 18:00:18 +09:00
tableName: firstNode.data.table.tableName,
displayName: firstNode.data.table.displayName,
2025-09-05 16:19:31 +09:00
},
toNode: {
id: secondNode.id,
2025-09-05 18:00:18 +09:00
tableName: secondNode.data.table.tableName,
displayName: secondNode.data.table.displayName,
2025-09-05 16:19:31 +09:00
},
2025-09-15 15:12:02 +09:00
// 컬럼 선택 정보는 제거 (단순 키값 연결에서만 필요)
2025-09-05 16:19:31 +09:00
});
};
2025-09-05 18:00:18 +09:00
// 실제 테이블 노드 추가
const addTableNode = useCallback(
async (table: TableDefinition) => {
2025-09-05 16:19:31 +09:00
try {
2025-09-05 18:00:18 +09:00
const newNode: Node<TableNodeData> = {
id: `table-${table.tableName}`,
type: "tableNode",
2025-09-05 16:19:31 +09:00
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
2025-09-05 18:00:18 +09:00
table: {
tableName: table.tableName,
displayName: table.displayName || table.tableName,
2025-09-10 11:27:05 +09:00
description: "", // 새로 추가된 노드는 description 없이 통일
columns: Array.isArray(table.columns)
? table.columns.map((col) => ({
name: col.columnName || "unknown",
type: col.dataType || "varchar", // 기존과 동일한 기본값 사용
description: col.description || "",
}))
: [],
2025-09-05 16:19:31 +09:00
},
2025-09-05 18:00:18 +09:00
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[table.tableName] || [],
2025-09-10 11:27:05 +09:00
connectedColumns: {}, // 새로 추가된 노드는 연결 정보 없음
2025-09-05 16:19:31 +09:00
},
};
setNodes((nds) => nds.concat(newNode));
} catch (error) {
2025-09-05 18:00:18 +09:00
console.error("테이블 노드 추가 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
2025-09-05 16:19:31 +09:00
}
2025-09-05 11:30:27 +09:00
},
2025-09-05 18:00:18 +09:00
[handleColumnClick, selectedColumns, setNodes],
2025-09-05 11:30:27 +09:00
);
2025-09-10 17:25:41 +09:00
// 기존 clearNodes 함수 제거 (중복 방지)
2025-09-05 11:30:27 +09:00
2025-09-05 18:00:18 +09:00
// 현재 추가된 테이블명 목록 가져오기
const getSelectedTableNames = useCallback(() => {
return nodes.filter((node) => node.id.startsWith("table-")).map((node) => node.data.table.tableName);
2025-09-05 16:19:31 +09:00
}, [nodes]);
// 연결 설정 확인
const handleConfirmConnection = useCallback(
2025-09-08 16:46:53 +09:00
(relationship: TableRelationship) => {
2025-09-05 16:19:31 +09:00
if (!pendingConnection) return;
// 메모리 기반 관계 생성 (DB 저장 없이)
2025-09-09 12:00:58 +09:00
const fromTable = relationship.from_table_name;
const toTable = relationship.to_table_name;
2025-09-10 11:27:05 +09:00
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);
2025-09-15 15:12:02 +09:00
// JSON 형태의 관계 객체 생성 (중복 필드 제거)
const newRelationship: ExtendedJsonRelationship = {
2025-09-10 17:25:41 +09:00
id: editingRelationshipId || generateUniqueId("rel", Date.now()), // 수정 모드면 기존 ID 사용
2025-09-10 18:30:22 +09:00
relationshipName: relationship.relationship_name, // 연결 이름 추가
fromTable,
toTable,
fromColumns,
toColumns,
2025-09-15 20:07:28 +09:00
connectionType: relationship.connection_type as "simple-key" | "data-save" | "external-call",
settings: relationship.settings || {},
};
2025-09-10 17:25:41 +09:00
// 수정 모드인 경우 기존 관계를 교체
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);
2025-09-15 20:07:28 +09:00
console.log("🔥 새 관계 생성:", newRelationship);
console.log("🔥 연결 타입:", newRelationship.connectionType);
2025-09-15 15:12:02 +09:00
// 첫 번째 관계가 추가되면 관계도의 category를 해당 connectionType으로 설정
if (tempRelationships.length === 0) {
setCurrentDiagramCategory(relationship.connection_type);
}
// 캔버스에 엣지 즉시 표시
2025-09-10 11:27:05 +09:00
const newEdge: Edge = {
id: generateUniqueId("edge", Date.now()),
2025-09-10 11:27:05 +09:00
source: pendingConnection.fromNode.id,
target: pendingConnection.toNode.id,
type: "smoothstep",
animated: false,
style: {
stroke: "#3b82f6",
strokeWidth: 2,
strokeDasharray: "none",
},
data: {
relationshipId: newRelationship.id,
2025-09-10 18:30:22 +09:00
relationshipName: newRelationship.relationshipName,
2025-09-15 20:07:28 +09:00
connectionType: newRelationship.connectionType, // 🔥 수정: newRelationship 사용
fromTable,
toTable,
fromColumns,
toColumns,
2025-09-10 11:27:05 +09:00
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
2025-09-15 20:07:28 +09:00
connectionType: newRelationship.connectionType, // 🔥 수정: newRelationship 사용
2025-09-09 12:00:58 +09:00
},
2025-09-10 11:27:05 +09:00
},
};
2025-09-05 16:19:31 +09:00
2025-09-15 20:07:28 +09:00
console.log("🔥 새 엣지 생성:", newEdge);
2025-09-10 11:27:05 +09:00
setEdges((eds) => [...eds, newEdge]);
2025-09-05 16:19:31 +09:00
setPendingConnection(null);
2025-09-10 11:27:05 +09:00
// 관계 생성 후 선택된 컬럼들 초기화
setSelectedColumns({});
console.log("메모리에 관계 생성 완료:", newRelationship);
toast.success("관계가 생성되었습니다. 저장 버튼을 눌러 관계도를 저장하세요.");
2025-09-05 16:19:31 +09:00
},
2025-09-15 20:07:28 +09:00
[pendingConnection, setEdges, editingRelationshipId, tempRelationships.length],
2025-09-05 16:19:31 +09:00
);
// 연결 설정 취소
const handleCancelConnection = useCallback(() => {
setPendingConnection(null);
2025-09-10 17:25:41 +09:00
// 수정 모드였다면 해제
if (editingRelationshipId) {
setEditingRelationshipId(null);
// 편집 모드 취소 시 선택된 컬럼도 초기화
setSelectedColumns({});
2025-09-10 17:25:41 +09:00
}
}, [editingRelationshipId]);
2025-09-05 16:19:31 +09:00
// 관계도 저장 함수
const handleSaveDiagram = useCallback(
async (diagramName: string) => {
2025-09-15 15:12:02 +09:00
// 🔥 수정: 관계가 없어도 노드가 있으면 저장 가능
if (nodes.length === 0) {
toast.error("저장할 테이블이 없습니다.");
return;
}
setIsSaving(true);
try {
2025-09-15 15:12:02 +09:00
// 🔥 수정: 현재 캔버스의 모든 테이블 기반으로 변경
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,
};
}
});
2025-09-15 15:12:02 +09:00
console.log("🔍 저장할 노드 위치 정보:", nodePositions);
console.log("📊 현재 노드 개수:", nodes.length);
console.log("📋 연결된 테이블 목록:", connectedTables);
console.log("🔗 관계 개수:", tempRelationships.length);
2025-09-15 20:07:28 +09:00
// 🔥 주요 연결 타입 변수 제거 (더 이상 사용하지 않음)
2025-09-15 15:12:02 +09:00
2025-09-15 20:07:28 +09:00
// 🔥 수정: relationships는 핵심 관계 정보만 포함, settings 전체 제거
const cleanRelationships = tempRelationships.map((rel) => {
// 🔥 settings 전체를 제거하고 핵심 정보만 유지
const cleanRel: JsonRelationship = {
id: rel.id,
fromTable: rel.fromTable,
toTable: rel.toTable,
relationshipName: rel.relationshipName,
connectionType: rel.connectionType,
// simple-key가 아닌 경우 컬럼 정보 제거
fromColumns: rel.connectionType === "simple-key" ? rel.fromColumns : [],
toColumns: rel.connectionType === "simple-key" ? rel.toColumns : [],
};
return cleanRel;
2025-09-15 15:12:02 +09:00
});
// 저장 요청 데이터 생성
const createRequest: CreateDiagramRequest = {
diagram_name: diagramName,
relationships: {
2025-09-15 20:07:28 +09:00
relationships: cleanRelationships as JsonRelationship[],
tables: connectedTables,
},
node_positions: nodePositions,
2025-09-15 20:07:28 +09:00
// 🔥 수정: 각 관계별 category 정보를 배열로 저장
category: tempRelationships.map((rel) => ({
id: rel.id,
category: rel.connectionType,
})),
// 🔥 각 관계별 control 정보를 배열로 저장 (전체 실행 조건)
control: tempRelationships
.filter((rel) => rel.connectionType === "data-save")
.map((rel) => {
console.log("🔍 Control 데이터 추출 중:", {
id: rel.id,
settings: rel.settings,
control: rel.settings?.control,
settingsKeys: Object.keys(rel.settings || {}),
});
const controlData = rel.settings?.control as {
triggerType?: "insert" | "update" | "delete";
conditionTree?: Array<{
field: string;
operator_type: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value: unknown;
logicalOperator?: "AND" | "OR";
}>;
};
console.log("🔍 추출된 controlData:", controlData);
console.log("🔍 conditionTree:", controlData?.conditionTree);
return {
id: rel.id, // relationships의 id와 동일
triggerType: (controlData?.triggerType as "insert" | "update" | "delete") || "insert",
// 🔥 실제 저장된 conditionTree에서 조건 추출
conditions: (controlData?.conditionTree || []).map((cond) => ({
field: cond.field,
operator: cond.operator_type,
value: cond.value,
logicalOperator: cond.logicalOperator || "AND",
})),
};
}),
// 🔥 각 관계별 plan 정보를 배열로 저장 (저장 액션)
plan: tempRelationships
.filter((rel) => rel.connectionType === "data-save")
.map((rel) => ({
id: rel.id, // relationships의 id와 동일
sourceTable: rel.fromTable,
// 🔥 실제 사용자가 설정한 액션들 사용
actions:
(rel.settings?.actions as Array<{
id: string;
name: string;
actionType: "insert" | "update" | "delete" | "upsert";
fieldMappings: Array<{
sourceTable?: string;
sourceField: string;
targetTable?: string;
targetField: string;
defaultValue?: string;
transformFunction?: string;
}>;
conditions?: Array<{
id: string;
type: string;
field: string;
operator_type: string;
value: unknown;
logicalOperator?: string;
}>;
}>) || [],
})),
};
2025-09-15 20:07:28 +09:00
// 🔍 디버깅: tempRelationships 구조 확인
console.log("🔍 tempRelationships 전체 구조:", JSON.stringify(tempRelationships, null, 2));
tempRelationships.forEach((rel, index) => {
console.log(`🔍 관계 ${index + 1} settings:`, rel.settings);
console.log(`🔍 관계 ${index + 1} settings.control:`, rel.settings?.control);
console.log(`🔍 관계 ${index + 1} settings.actions:`, rel.settings?.actions);
});
2025-09-15 15:12:02 +09:00
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);
// 관계도 이름 업데이트 (편집 모드일 때만)
if (diagramId && diagramId > 0 && onDiagramNameUpdate) {
onDiagramNameUpdate(diagramName);
}
console.log("관계도 저장 완료:", savedDiagram);
} catch (error) {
console.error("관계도 저장 실패:", error);
toast.error("관계도 저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
},
[tempRelationships, diagramId, companyCode, user?.userId, nodes, onDiagramNameUpdate],
);
// 저장 모달 열기
const handleOpenSaveModal = useCallback(() => {
2025-09-10 17:25:41 +09:00
// 관계가 0개여도 저장 가능하도록 수정
setShowSaveModal(true);
2025-09-10 17:25:41 +09:00
}, []);
// 저장 모달 닫기
const handleCloseSaveModal = useCallback(() => {
if (!isSaving) {
setShowSaveModal(false);
}
}, [isSaving]);
2025-09-10 17:25:41 +09:00
// 고립된 노드 제거 함수
const removeOrphanedNodes = useCallback(
2025-09-15 15:12:02 +09:00
(updatedRelationships: ExtendedJsonRelationship[], showMessage = true) => {
2025-09-10 17:25:41 +09:00
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,
},
},
2025-09-11 10:45:16 +09:00
// 기존 관계 정보 추가 (연결 이름 유지를 위해)
existingRelationship: {
relationshipName: existingRelationship.relationshipName,
connectionType: existingRelationship.connectionType,
settings: existingRelationship.settings,
},
2025-09-10 17:25:41 +09:00
};
// 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]);
2025-09-05 11:30:27 +09:00
return (
<div className="data-flow-designer h-screen bg-gray-100">
<div className="flex h-full">
{/* 사이드바 */}
<div className="w-80 border-r border-gray-200 bg-white shadow-lg">
<div className="p-6">
2025-09-05 18:00:18 +09:00
<h2 className="mb-6 text-xl font-bold text-gray-800"> </h2>
2025-09-05 11:30:27 +09:00
2025-09-05 18:00:18 +09:00
{/* 테이블 선택기 */}
<TableSelector
2025-09-05 16:19:31 +09:00
companyCode={companyCode}
2025-09-05 18:00:18 +09:00
onTableAdd={addTableNode}
selectedTables={getSelectedTableNames()}
2025-09-05 16:19:31 +09:00
/>
2025-09-05 11:30:27 +09:00
{/* 컨트롤 버튼들 */}
<div className="space-y-3">
2025-09-10 17:25:41 +09:00
<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>
2025-09-05 11:30:27 +09:00
<button
onClick={clearNodes}
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
>
2025-09-10 17:25:41 +09:00
🗑
2025-09-05 11:30:27 +09:00
</button>
<button
onClick={handleOpenSaveModal}
2025-09-10 17:25:41 +09:00
className={`w-full rounded-lg bg-green-500 p-3 font-medium text-white transition-colors hover:bg-green-600 ${
hasUnsavedChanges ? "animate-pulse" : ""
}`}
2025-09-05 11:30:27 +09:00
>
💾 {tempRelationships.length > 0 && `(${tempRelationships.length})`}
2025-09-05 11:30:27 +09:00
</button>
</div>
{/* 통계 정보 */}
<div className="mt-6 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-semibold text-gray-700"></div>
<div className="space-y-1 text-sm text-gray-600">
<div className="flex justify-between">
2025-09-05 18:00:18 +09:00
<span> :</span>
2025-09-05 11:30:27 +09:00
<span className="font-medium">{nodes.length}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium">{edges.length}</span>
</div>
<div className="flex justify-between">
<span> :</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>
2025-09-15 15:12:02 +09:00
<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>
)}
2025-09-05 11:30:27 +09:00
</div>
</div>
2025-09-05 18:00:18 +09:00
{/* 선택된 컬럼 정보 */}
2025-09-05 11:30:27 +09:00
</div>
</div>
{/* React Flow 캔버스 */}
<div className="relative flex-1">
<ReactFlow
2025-09-10 11:27:05 +09:00
className="[&_.react-flow\_\_pane]:cursor-default [&_.react-flow\_\_pane.dragging]:cursor-grabbing"
2025-09-05 11:30:27 +09:00
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
2025-09-05 18:21:28 +09:00
onSelectionChange={onSelectionChange}
2025-09-15 15:12:02 +09:00
onNodeClick={onNodeClick}
2025-09-10 11:27:05 +09:00
onPaneClick={onPaneClick}
onEdgeClick={onEdgeClick}
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
2025-09-05 11:30:27 +09:00
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
attributionPosition="bottom-left"
2025-09-05 16:19:31 +09:00
panOnScroll={false}
zoomOnScroll={true}
zoomOnPinch={true}
2025-09-08 10:33:00 +09:00
panOnDrag={[1, 2]}
2025-09-15 15:12:02 +09:00
selectionOnDrag={false}
2025-09-08 10:33:00 +09:00
multiSelectionKeyCode={null}
selectNodesOnDrag={false}
selectionMode={SelectionMode.Partial}
2025-09-05 11:30:27 +09:00
>
<Controls />
2025-09-05 18:00:18 +09:00
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#E5E7EB" />
{/* 관계 목록 모달 - 캔버스 내부 우측 상단에 배치 */}
{showRelationshipListModal && (
<div className="pointer-events-auto absolute top-4 right-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="rounded-full bg-blue-100 p-1">
<span className="text-sm text-blue-600">🔗</span>
</div>
<div className="text-sm font-semibold text-gray-800"> </div>
</div>
<button
onClick={() => setShowRelationshipListModal(false)}
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 관계 목록 */}
<div className="p-3">
<div className="max-h-96 space-y-2 overflow-y-auto">
2025-09-15 20:07:28 +09:00
{selectedTablePairRelationships.map((relationship) => (
<div
key={relationship.id}
2025-09-15 20:07:28 +09:00
className="rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50"
>
<div className="mb-1 flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-900">
{relationship.fromTable} {relationship.toTable}
</h4>
2025-09-15 20:07:28 +09:00
<div className="flex items-center gap-1">
{/* 편집 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
// 관계 선택 시 수정 모드로 전환
setEditingRelationshipId(relationship.id);
// 관련 컬럼 하이라이트
const newSelectedColumns: { [tableName: string]: string[] } = {};
if (relationship.fromTable && relationship.fromColumns) {
newSelectedColumns[relationship.fromTable] = [...relationship.fromColumns];
}
if (relationship.toTable && relationship.toColumns) {
newSelectedColumns[relationship.toTable] = [...relationship.toColumns];
}
setSelectedColumns(newSelectedColumns);
// 🔥 수정: 연결 설정 모달 열기
const fromTable = nodes.find(
(node) => node.data?.table?.tableName === relationship.fromTable,
);
const toTable = nodes.find(
(node) => node.data?.table?.tableName === relationship.toTable,
);
if (fromTable && toTable) {
setPendingConnection({
fromNode: {
id: fromTable.id,
tableName: relationship.fromTable,
displayName: fromTable.data?.table?.displayName || relationship.fromTable,
},
toNode: {
id: toTable.id,
tableName: relationship.toTable,
displayName: toTable.data?.table?.displayName || relationship.toTable,
},
selectedColumnsData: {
[relationship.fromTable]: {
displayName: fromTable.data?.table?.displayName || relationship.fromTable,
columns: relationship.fromColumns || [],
},
[relationship.toTable]: {
displayName: toTable.data?.table?.displayName || relationship.toTable,
columns: relationship.toColumns || [],
},
},
existingRelationship: {
relationshipName: relationship.relationshipName,
connectionType: relationship.connectionType,
settings: relationship.settings || {},
},
});
}
// 모달 닫기
setShowRelationshipListModal(false);
}}
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-blue-100 hover:text-blue-600"
title="관계 편집"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{/* 삭제 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
// 관계 삭제
setTempRelationships((prev) => prev.filter((rel) => rel.id !== relationship.id));
setEdges((prev) =>
prev.filter((edge) => edge.data?.relationshipId !== relationship.id),
);
setHasUnsavedChanges(true);
// 선택된 컬럼 초기화
setSelectedColumns({});
// 편집 모드 해제
if (editingRelationshipId === relationship.id) {
setEditingRelationshipId(null);
}
// 모달 닫기
setShowRelationshipListModal(false);
}}
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-600"
title="관계 삭제"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-600">
<p>: {relationship.connectionType}</p>
<p>From: {relationship.fromTable}</p>
<p>To: {relationship.toTable}</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
2025-09-05 11:30:27 +09:00
</ReactFlow>
2025-09-15 15:12:02 +09:00
{/* 선택된 테이블 노드 팝업 - 캔버스 좌측 상단 고정 (새 관계 생성 시에만 표시) */}
{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>
2025-09-10 18:35:55 +09:00
</div>
</div>
2025-09-15 15:12:02 +09:00
<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>
2025-09-10 18:35:55 +09:00
2025-09-15 15:12:02 +09:00
{/* 컨텐츠 */}
<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}
2025-09-10 18:35:55 +09:00
</div>
2025-09-15 15:12:02 +09:00
{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>
2025-09-15 15:12:02 +09:00
<div className="text-xs text-gray-600">{tableName}</div>
</div>
2025-09-10 18:35:55 +09:00
2025-09-15 15:12:02 +09:00
{/* 연결 화살표 (마지막이 아닌 경우) */}
{index < selectedNodes.length - 1 && (
<div className="flex justify-center py-1">
<div className="text-gray-400"></div>
</div>
)}
</div>
);
})}
</div>
2025-09-10 18:35:55 +09:00
</div>
2025-09-15 15:12:02 +09:00
{/* 액션 버튼 */}
<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>
)}
2025-09-10 18:35:55 +09:00
2025-09-05 11:30:27 +09:00
{/* 안내 메시지 */}
{nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<div className="mb-2 text-2xl">📊</div>
2025-09-05 18:00:18 +09:00
<div className="mb-1 text-lg font-medium"> </div>
2025-09-05 18:21:28 +09:00
<div className="text-sm">
<div> </div>
<div className="mt-1 text-xs text-gray-400"> Del </div>
</div>
2025-09-05 11:30:27 +09:00
</div>
</div>
)}
</div>
</div>
2025-09-05 16:19:31 +09:00
{/* 연결 설정 모달 */}
<ConnectionSetupModal
isOpen={!!pendingConnection}
connection={pendingConnection}
2025-09-08 16:46:53 +09:00
companyCode={companyCode}
2025-09-05 16:19:31 +09:00
onConfirm={handleConfirmConnection}
onCancel={handleCancelConnection}
/>
2025-09-10 18:30:22 +09:00
{/* 엣지 정보 및 액션 버튼 */}
{showEdgeActions && selectedEdgeForEdit && selectedEdgeInfo && (
2025-09-10 17:25:41 +09:00
<div
className="fixed z-50 rounded-xl border border-gray-200 bg-white shadow-2xl"
2025-09-10 17:25:41 +09:00
style={{
2025-09-10 18:30:22 +09:00
left: edgeActionPosition.x - 160,
top: edgeActionPosition.y - 100,
minWidth: "320px",
maxWidth: "380px",
2025-09-10 17:25:41 +09:00
}}
>
2025-09-10 18:30:22 +09:00
{/* 헤더 */}
<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>
2025-09-10 18:30:22 +09:00
</div>
<div>
<div className="text-sm font-bold text-white">{selectedEdgeInfo.relationshipName}</div>
<div className="text-xs text-blue-100"> </div>
2025-09-10 18:30:22 +09:00
</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"
2025-09-10 18:30:22 +09:00
>
<span className="text-sm"></span>
2025-09-10 18:30:22 +09:00
</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>
2025-09-10 18:30:22 +09:00
{/* 연결 정보 */}
<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>
2025-09-10 18:30:22 +09:00
</div>
</div>
{/* 관계 화살표 */}
2025-09-10 18:30:22 +09:00
<div className="flex justify-center">
<span className="text-l text-gray-600"></span>
2025-09-10 18:30:22 +09:00
</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>
2025-09-10 18:30:22 +09:00
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-2 border-t border-gray-200 bg-gray-50 p-3">
2025-09-10 18:30:22 +09:00
<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"
2025-09-10 18:30:22 +09:00
>
<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"
2025-09-10 18:30:22 +09:00
>
<span></span>
</button>
</div>
2025-09-10 17:25:41 +09:00
</div>
)}
{/* 관계도 저장 모달 */}
<SaveDiagramModal
isOpen={showSaveModal}
onClose={handleCloseSaveModal}
onSave={handleSaveDiagram}
2025-09-15 20:07:28 +09:00
relationships={tempRelationships as JsonRelationship[]} // 타입 단언 추가
defaultName={
diagramId && diagramId > 0 && currentDiagramName
? currentDiagramName // 편집 모드: 기존 관계도 이름
: `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름
}
isLoading={isSaving}
/>
2025-09-05 11:30:27 +09:00
</div>
);
};