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

705 lines
24 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 } from "@/lib/api/dataflow";
import { useAuth } from "@/hooks/useAuth";
import { useDataFlowDesigner } from "@/hooks/useDataFlowDesigner";
import { DataFlowDesignerProps, TableNodeData } from "@/types/dataflowTypes";
import { extractTableNames } from "@/utils/dataflowUtils";
// 노드 및 엣지 타입 정의
const nodeTypes = {
tableNode: TableNode,
};
const edgeTypes = {};
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode: propCompanyCode = "*",
diagramId,
}) => {
const { user } = useAuth();
// 실제 사용자 회사 코드 사용 (prop보다 사용자 정보 우선)
const companyCode = user?.company_code || user?.companyCode || propCompanyCode;
// 커스텀 훅 사용
const {
nodes,
setNodes,
onNodesChange,
edges,
setEdges,
onEdgesChange,
selectedColumns,
setSelectedColumns,
selectedNodes,
setSelectedNodes,
pendingConnection,
setPendingConnection,
currentDiagramId,
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 {
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 || "",
settings: rel.settings || {},
}));
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);
console.log("✅ 관계도 데이터 로드 완료:", {
relationships: jsonDiagram.relationships?.relationships?.length || 0,
tables: Array.from(new Set(loadedRelationships.flatMap((rel) => [rel.fromTable, rel.toTable]))).length,
});
}
}
} catch (error) {
console.error("관계도 데이터 로드 실패:", error);
toast.error("관계도를 불러오는데 실패했습니다.");
}
} else {
// 신규 생성 모드
setCurrentDiagramName("");
setNodes([]);
setEdges([]);
setTempRelationships([]);
}
};
loadDiagramData();
}, [diagramId, companyCode, 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) => ({
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],
);
// 노드 클릭 핸들러 (커스텀 다중 선택 구현)
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),
);
console.log(`🔗 ${fromTable}${toTable} 간의 관계:`, tablePairRelationships);
// 관계가 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(() => {
if (!pendingConnection) return;
// 관계 생성 로직은 여기서 구현...
// 현재는 간단히 성공 메시지만 표시
toast.success("관계가 생성되었습니다.");
setPendingConnection(null);
setHasUnsavedChanges(true);
}, [pendingConnection, setPendingConnection, setHasUnsavedChanges]);
// 연결 설정 취소
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) => {
if (nodes.length === 0) {
toast.error("저장할 테이블이 없습니다.");
return;
}
setIsSaving(true);
try {
// 여기서 실제 저장 로직 구현
toast.success(`관계도 "${diagramName}"가 성공적으로 저장되었습니다.`);
setHasUnsavedChanges(false);
setShowSaveModal(false);
} catch (error) {
console.error("관계도 저장 실패:", error);
toast.error("관계도 저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
},
[nodes, setIsSaving, setHasUnsavedChanges, setShowSaveModal],
);
// 고립된 노드 제거 함수
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={[1, 2]}
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}
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={() => {}}
onDelete={() => {}}
/>
{/* 관계도 저장 모달 */}
<SaveDiagramModal
isOpen={showSaveModal}
onClose={handleCloseSaveModal}
onSave={handleSaveDiagram}
relationships={tempRelationships as JsonRelationship[]}
defaultName={
diagramId && diagramId > 0 && currentDiagramName
? currentDiagramName // 편집 모드: 기존 관계도 이름
: `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름
}
isLoading={isSaving}
/>
</div>
);
};