From 7260ad733bf82ddeb95d26ac4b9a1b998543df87 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Tue, 9 Sep 2025 11:35:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=83=9D=EC=84=B1=EB=90=9C=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=EB=8F=84=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dataflowController.ts | 110 ++++++ backend-node/src/routes/dataflowRoutes.ts | 16 + backend-node/src/services/dataflowService.ts | 160 +++++++++ frontend/app/(main)/admin/dataflow/page.tsx | 127 ++++++- .../dataflow/ConnectionSetupModal.tsx | 20 +- .../components/dataflow/DataFlowDesigner.tsx | 269 +++++++++----- frontend/components/dataflow/DataFlowList.tsx | 331 ++++++++++++++++++ frontend/lib/api/dataflow.ts | 92 ++++- 8 files changed, 1018 insertions(+), 107 deletions(-) create mode 100644 frontend/components/dataflow/DataFlowList.tsx diff --git a/backend-node/src/controllers/dataflowController.ts b/backend-node/src/controllers/dataflowController.ts index 93d20f96..ff69ce5c 100644 --- a/backend-node/src/controllers/dataflowController.ts +++ b/backend-node/src/controllers/dataflowController.ts @@ -608,3 +608,113 @@ export async function getTableData(req: Request, res: Response): Promise { res.status(500).json(response); } } + +/** + * 관계도 그룹 목록 조회 (관계도 이름별로 그룹화) + */ +export async function getDataFlowDiagrams( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 관계도 목록 조회 시작 ==="); + + const { page = 1, size = 20, searchTerm = "" } = req.query; + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + + const pageNum = parseInt(page as string, 10); + const sizeNum = parseInt(size as string, 10); + + const dataflowService = new DataflowService(); + const result = await dataflowService.getDataFlowDiagrams( + companyCode, + pageNum, + sizeNum, + searchTerm as string + ); + + logger.info(`관계도 목록 조회 완료: ${result.total}개`); + + const response: ApiResponse = { + success: true, + message: "관계도 목록을 성공적으로 조회했습니다.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("관계도 목록 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "관계도 목록 조회 중 오류가 발생했습니다.", + error: { + code: "DATAFLOW_DIAGRAMS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 특정 관계도의 모든 관계 조회 + */ +export async function getDiagramRelationships( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + logger.info("=== 관계도 관계 조회 시작 ==="); + + const { diagramName } = req.params; + + if (!diagramName) { + const response: ApiResponse = { + success: false, + message: "관계도 이름이 필요합니다.", + error: { + code: "MISSING_DIAGRAM_NAME", + details: "diagramName 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + // 사용자 정보에서 회사 코드 가져오기 + const companyCode = (req.user as any)?.company_code || "*"; + + const dataflowService = new DataflowService(); + const relationships = await dataflowService.getDiagramRelationships( + companyCode, + decodeURIComponent(diagramName) + ); + + logger.info(`관계도 관계 조회 완료: ${relationships.length}개`); + + const response: ApiResponse = { + success: true, + message: "관계도 관계를 성공적으로 조회했습니다.", + data: relationships, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("관계도 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "관계도 관계 조회 중 오류가 발생했습니다.", + error: { + code: "DIAGRAM_RELATIONSHIPS_GET_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/dataflowRoutes.ts b/backend-node/src/routes/dataflowRoutes.ts index 75569771..e84e1c0d 100644 --- a/backend-node/src/routes/dataflowRoutes.ts +++ b/backend-node/src/routes/dataflowRoutes.ts @@ -10,6 +10,8 @@ import { getLinkedDataByRelationship, deleteDataLink, getTableData, + getDataFlowDiagrams, + getDiagramRelationships, } from "../controllers/dataflowController"; const router = express.Router(); @@ -78,4 +80,18 @@ router.delete("/data-links/:bridgeId", deleteDataLink); */ router.get("/table-data/:tableName", getTableData); +// ==================== 관계도 관리 라우트 ==================== + +/** + * 관계도 목록 조회 (관계도 이름별로 그룹화) + * GET /api/dataflow/diagrams + */ +router.get("/diagrams", getDataFlowDiagrams); + +/** + * 특정 관계도의 모든 관계 조회 + * GET /api/dataflow/diagrams/:diagramName/relationships + */ +router.get("/diagrams/:diagramName/relationships", getDiagramRelationships); + export default router; diff --git a/backend-node/src/services/dataflowService.ts b/backend-node/src/services/dataflowService.ts index 3c167e48..b30e2fa8 100644 --- a/backend-node/src/services/dataflowService.ts +++ b/backend-node/src/services/dataflowService.ts @@ -729,4 +729,164 @@ export class DataflowService { throw error; } } + + /** + * 관계도 그룹 목록 조회 (관계도 이름별로 그룹화) + */ + async getDataFlowDiagrams( + companyCode: string, + page: number = 1, + size: number = 20, + searchTerm: string = "" + ) { + try { + logger.info( + `DataflowService: 관계도 목록 조회 시작 - ${companyCode}, page: ${page}, size: ${size}, search: ${searchTerm}` + ); + + // 관계도 이름별로 그룹화하여 조회 + const whereCondition = { + company_code: companyCode, + is_active: "Y", + ...(searchTerm && { + OR: [ + { + relationship_name: { + contains: searchTerm, + mode: "insensitive" as any, + }, + }, + { + from_table_name: { + contains: searchTerm, + mode: "insensitive" as any, + }, + }, + { + to_table_name: { + contains: searchTerm, + mode: "insensitive" as any, + }, + }, + ], + }), + }; + + // 관계도별로 그룹화된 데이터 조회 (관계도 이름을 기준으로) + const relationships = await prisma.table_relationships.findMany({ + where: whereCondition, + select: { + relationship_name: true, + from_table_name: true, + to_table_name: true, + connection_type: true, + relationship_type: true, + created_date: true, + created_by: true, + updated_date: true, + updated_by: true, + }, + orderBy: [{ relationship_name: "asc" }, { created_date: "desc" }], + }); + + // 관계도 이름별로 그룹화 + const diagramMap = new Map(); + + relationships.forEach((rel) => { + const diagramName = rel.relationship_name; + + if (!diagramMap.has(diagramName)) { + diagramMap.set(diagramName, { + diagramName: diagramName, + connectionType: rel.connection_type, + relationshipType: rel.relationship_type, + tableCount: new Set(), + relationshipCount: 0, + createdAt: rel.created_date, + createdBy: rel.created_by, + updatedAt: rel.updated_date, + updatedBy: rel.updated_by, + tables: [], + }); + } + + const diagram = diagramMap.get(diagramName); + diagram.tableCount.add(rel.from_table_name); + diagram.tableCount.add(rel.to_table_name); + diagram.relationshipCount++; + + // 최신 업데이트 시간 유지 + if (rel.updated_date && rel.updated_date > diagram.updatedAt) { + diagram.updatedAt = rel.updated_date; + diagram.updatedBy = rel.updated_by; + } + }); + + // Set을 배열로 변환하고 테이블 개수 계산 + const diagrams = Array.from(diagramMap.values()).map((diagram) => ({ + ...diagram, + tableCount: diagram.tableCount.size, + tables: Array.from(diagram.tableCount), + })); + + // 페이징 처리 + const total = diagrams.length; + const startIndex = (page - 1) * size; + const endIndex = startIndex + size; + const paginatedDiagrams = diagrams.slice(startIndex, endIndex); + + const result = { + diagrams: paginatedDiagrams, + total, + page, + size, + totalPages: Math.ceil(total / size), + hasNext: page < Math.ceil(total / size), + hasPrev: page > 1, + }; + + logger.info( + `DataflowService: 관계도 목록 조회 완료 - 총 ${total}개 관계도 중 ${paginatedDiagrams.length}개 조회` + ); + + return result; + } catch (error) { + logger.error("DataflowService: 관계도 목록 조회 실패", error); + throw error; + } + } + + /** + * 특정 관계도의 모든 관계 조회 + */ + async getDiagramRelationships(companyCode: string, diagramName: string) { + try { + logger.info( + `DataflowService: 관계도 관계 조회 시작 - ${companyCode}, diagram: ${diagramName}` + ); + + const relationships = await prisma.table_relationships.findMany({ + where: { + company_code: companyCode, + relationship_name: diagramName, + is_active: "Y", + }, + orderBy: { + created_date: "asc", + }, + }); + + logger.info( + `DataflowService: 관계도 관계 조회 완료 - ${diagramName}, ${relationships.length}개 관계` + ); + + return relationships; + } catch (error) { + logger.error( + `DataflowService: 관계도 관계 조회 실패 - ${diagramName}`, + error + ); + throw error; + } + } } diff --git a/frontend/app/(main)/admin/dataflow/page.tsx b/frontend/app/(main)/admin/dataflow/page.tsx index 69111e56..65b049b5 100644 --- a/frontend/app/(main)/admin/dataflow/page.tsx +++ b/frontend/app/(main)/admin/dataflow/page.tsx @@ -1,23 +1,136 @@ "use client"; -import React from "react"; -import { Toaster } from "react-hot-toast"; +import { useState } from "react"; import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner"; +import DataFlowList from "@/components/dataflow/DataFlowList"; +import { TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow"; import { useAuth } from "@/hooks/useAuth"; -import { TableRelationship } from "@/lib/api/dataflow"; +import { ArrowLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type Step = "list" | "design"; export default function DataFlowPage() { const { user } = useAuth(); + const [currentStep, setCurrentStep] = useState("list"); + const [selectedDiagram, setSelectedDiagram] = useState(null); + const [stepHistory, setStepHistory] = useState(["list"]); + + // 단계별 제목과 설명 + const stepConfig = { + list: { + title: "데이터 흐름 관계도 관리", + description: "생성된 관계도들을 확인하고 관리하세요", + icon: "📊", + }, + design: { + title: selectedDiagram ? `${selectedDiagram.diagramName} 관계도 설계` : "새 관계도 설계", + description: selectedDiagram + ? "기존 관계도를 수정하거나 새로운 관계를 추가하세요" + : "테이블 간 데이터 관계를 시각적으로 설계하세요", + icon: "🎨", + }, + }; + + // 다음 단계로 이동 + const goToNextStep = (nextStep: Step) => { + setStepHistory((prev) => [...prev, nextStep]); + setCurrentStep(nextStep); + }; + + // 이전 단계로 이동 + const goToPreviousStep = () => { + if (stepHistory.length > 1) { + const newHistory = stepHistory.slice(0, -1); + const previousStep = newHistory[newHistory.length - 1]; + setStepHistory(newHistory); + setCurrentStep(previousStep); + + // list로 돌아갈 때 선택된 관계도 초기화 + if (previousStep === "list") { + setSelectedDiagram(null); + } + } + }; + + // 특정 단계로 이동 + const goToStep = (step: Step) => { + setCurrentStep(step); + // 해당 단계까지의 히스토리만 유지 + const stepIndex = stepHistory.findIndex((s) => s === step); + if (stepIndex !== -1) { + setStepHistory(stepHistory.slice(0, stepIndex + 1)); + } + + // list로 이동할 때 선택된 관계도 초기화 + if (step === "list") { + setSelectedDiagram(null); + } + }; const handleSave = (relationships: TableRelationship[]) => { console.log("저장된 관계:", relationships); - // TODO: API 호출로 관계 저장 + // 저장 후 목록으로 돌아가기 + goToStep("list"); + }; + + const handleDiagramSelect = (diagram: DataFlowDiagram) => { + setSelectedDiagram(diagram); + }; + + const handleDesignDiagram = (diagram: DataFlowDiagram | null) => { + setSelectedDiagram(diagram); + goToNextStep("design"); }; return ( -
- - +
+ {/* 헤더 */} +
+
+
+ {currentStep !== "list" && ( + + )} +
+

+ {stepConfig[currentStep].icon} + {stepConfig[currentStep].title} +

+

{stepConfig[currentStep].description}

+
+
+
+
+ + {/* 단계별 내용 */} +
+ {/* 관계도 목록 단계 */} + {currentStep === "list" && ( +
+ +
+ )} + + {/* 관계도 설계 단계 */} + {currentStep === "design" && ( +
+ goToStep("list")} + /> +
+ )} +
); } diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 5ed118ef..2aa98137 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -188,15 +188,15 @@ export const ConnectionSetupModal: React.FC = ({ toast.loading("관계를 생성하고 있습니다...", { id: "create-relationship" }); // 단일 관계 데이터 준비 (모든 선택된 컬럼 정보 포함) - const relationshipData: Omit = { - relationshipName: config.relationshipName, - fromTableName: connection.fromNode.tableName, - fromColumnName: fromColumns.join(","), // 여러 컬럼을 콤마로 구분 - toTableName: connection.toNode.tableName, - toColumnName: toColumns.join(","), // 여러 컬럼을 콤마로 구분 - relationshipType: config.relationshipType, - connectionType: config.connectionType, - companyCode: companyCode, + const relationshipData: Omit = { + relationship_name: config.relationshipName, + from_table_name: connection.fromNode.tableName, + from_column_name: fromColumns.join(","), // 여러 컬럼을 콤마로 구분 + to_table_name: connection.toNode.tableName, + to_column_name: toColumns.join(","), // 여러 컬럼을 콤마로 구분 + relationship_type: config.relationshipType, + connection_type: config.connectionType, + company_code: companyCode, settings: { ...settings, multiColumnMapping: { @@ -211,7 +211,7 @@ export const ConnectionSetupModal: React.FC = ({ to: toColumns.length, }, }, - isActive: "Y", + is_active: "Y", }; // API 호출 diff --git a/frontend/components/dataflow/DataFlowDesigner.tsx b/frontend/components/dataflow/DataFlowDesigner.tsx index 61db73d1..43b6c272 100644 --- a/frontend/components/dataflow/DataFlowDesigner.tsx +++ b/frontend/components/dataflow/DataFlowDesigner.tsx @@ -17,7 +17,7 @@ import "@xyflow/react/dist/style.css"; import { TableNode } from "./TableNode"; import { TableSelector } from "./TableSelector"; import { ConnectionSetupModal } from "./ConnectionSetupModal"; -import { TableDefinition, TableRelationship, DataFlowAPI } from "@/lib/api/dataflow"; +import { TableDefinition, TableRelationship, DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow"; // 고유 ID 생성 함수 const generateUniqueId = (prefix: string, relationshipId?: number): string => { @@ -52,11 +52,18 @@ const edgeTypes = {}; interface DataFlowDesignerProps { companyCode: string; onSave?: (relationships: TableRelationship[]) => void; + selectedDiagram?: DataFlowDiagram | null; + onBackToList?: () => void; } // TableRelationship 타입은 dataflow.ts에서 import -export const DataFlowDesigner: React.FC = ({ companyCode, onSave }) => { +export const DataFlowDesigner: React.FC = ({ + companyCode, + onSave, + selectedDiagram, + onBackToList, +}) => { const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedColumns, setSelectedColumns] = useState<{ @@ -113,56 +120,6 @@ export const DataFlowDesigner: React.FC = ({ companyCode, return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedNodes, setNodes]); - // 기존 관계 로드 - const loadExistingRelationships = useCallback(async () => { - try { - const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode); - setRelationships(existingRelationships); - - // 기존 관계를 엣지로 변환하여 표시 - const existingEdges = existingRelationships.map((rel) => ({ - id: generateUniqueId("edge", rel.relationshipId), - source: `table-${rel.fromTableName}`, - target: `table-${rel.toTableName}`, - sourceHandle: "right", - targetHandle: "left", - type: "default", - data: { - relationshipId: rel.relationshipId, - relationshipType: rel.relationshipType, - connectionType: rel.connectionType, - label: rel.relationshipName, - fromColumn: rel.fromColumnName, - toColumn: rel.toColumnName, - }, - })); - - setEdges(existingEdges); - } catch (error) { - console.error("기존 관계 로드 실패:", error); - toast.error("기존 관계를 불러오는데 실패했습니다."); - } - }, [companyCode, setEdges]); - - // 컴포넌트 마운트 시 기존 관계 로드 - useEffect(() => { - if (companyCode) { - loadExistingRelationships(); - } - }, [companyCode, loadExistingRelationships]); - - // 노드 선택 변경 핸들러 - const onSelectionChange = useCallback(({ nodes }: { nodes: Node[] }) => { - const selectedNodeIds = nodes.map((node) => node.id); - setSelectedNodes(selectedNodeIds); - }, []); - - // 빈 onConnect 함수 (드래그 연결 비활성화) - const onConnect = useCallback(() => { - // 드래그로 연결하는 것을 방지 - return; - }, []); - // 컬럼 클릭 처리 (토글 방식, 최대 2개 테이블만 허용) const handleColumnClick = useCallback((tableName: string, columnName: string) => { setSelectedColumns((prev) => { @@ -184,39 +141,191 @@ export const DataFlowDesigner: React.FC = ({ companyCode, } return { ...prev, [tableName]: newColumns }; } else { - // 선택 추가 - 새로운 테이블이고 이미 2개 테이블이 선택되어 있으면 거부 - if (!prev[tableName] && selectedTables.length >= 2) { - // 토스트 중복 방지를 위한 ref 사용 - if (!toastShownRef.current) { - toastShownRef.current = true; - setTimeout(() => { - toast.error("최대 2개의 테이블에서만 컬럼을 선택할 수 있습니다.", { - duration: 3000, - position: "top-center", - }); - // 3초 후 플래그 리셋 - setTimeout(() => { - toastShownRef.current = false; - }, 3000); - }, 0); - } + // 새 선택 + if (selectedTables.length >= 2 && !selectedTables.includes(tableName)) { + toast.error("최대 2개 테이블까지만 선택할 수 있습니다."); return prev; } - // 새로운 테이블이면 선택 순서에 추가, 기존 테이블이면 맨 뒤로 이동 (다음 렌더링에서) + const newColumns = [...currentColumns, columnName]; + const newSelection = { ...prev, [tableName]: newColumns }; + + // 선택 순서 업데이트 (다음 렌더링에서) setTimeout(() => { setSelectionOrder((order) => { - // 기존에 있던 테이블이면 제거 후 맨 뒤에 추가 (순서 갱신) - const filteredOrder = order.filter((name) => name !== tableName); - return [...filteredOrder, tableName]; + if (!order.includes(tableName)) { + return [...order, tableName]; + } + return order; }); }, 0); - return { ...prev, [tableName]: [...currentColumns, columnName] }; + return newSelection; } }); }, []); + // 선택된 관계도의 관계 로드 + const loadSelectedDiagramRelationships = useCallback(async () => { + if (!selectedDiagram) return; + + try { + console.log("🔍 관계도 로드 시작:", selectedDiagram.diagramName); + toast.loading("관계도를 불러오는 중...", { id: "load-diagram" }); + const diagramRelationships = await DataFlowAPI.getDiagramRelationships(selectedDiagram.diagramName); + console.log("📋 관계도 관계 데이터:", diagramRelationships); + console.log("📋 첫 번째 관계 상세:", diagramRelationships[0]); + console.log( + "📋 관계 객체 키들:", + diagramRelationships[0] ? Object.keys(diagramRelationships[0]) : "배열이 비어있음", + ); + setRelationships(diagramRelationships); + + // 관계도의 모든 테이블 추출 + const tableNames = new Set(); + diagramRelationships.forEach((rel) => { + tableNames.add(rel.from_table_name); + tableNames.add(rel.to_table_name); + }); + console.log("📊 추출된 테이블 이름들:", Array.from(tableNames)); + + // 테이블 정보 로드 + 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); + console.log(`📋 테이블 ${tableName}의 컬럼 수:`, columns.length); + tableDefinitions.push({ + tableName: foundTable.tableName, + displayName: foundTable.displayName, + description: foundTable.description, + columns: columns, + }); + } else { + console.warn(`⚠️ 테이블 ${tableName}을 찾을 수 없습니다`); + } + } + + // 테이블을 노드로 변환 (자동 레이아웃) + const tableNodes = tableDefinitions.map((table, index) => { + const x = (index % 3) * 400 + 100; // 3열 배치 + const y = Math.floor(index / 3) * 300 + 100; + + return { + id: `table-${table.tableName}`, + type: "tableNode", + position: { x, y }, + data: { + table: { + tableName: table.tableName, + displayName: table.displayName, + description: table.description || "", + columns: table.columns.map((col) => ({ + name: col.columnName, + type: col.dataType || "varchar", + description: col.description || "", + })), + }, + onColumnClick: handleColumnClick, + selectedColumns: selectedColumns[table.tableName] || [], + } as TableNodeData, + }; + }); + + console.log("🎨 생성된 테이블 노드 수:", tableNodes.length); + console.log("📍 테이블 노드 상세:", tableNodes); + setNodes(tableNodes); + + // 관계를 엣지로 변환하여 표시 + const relationshipEdges = diagramRelationships.map((rel) => ({ + id: generateUniqueId("edge", rel.relationship_id), + source: `table-${rel.from_table_name}`, + target: `table-${rel.to_table_name}`, + sourceHandle: "right", + targetHandle: "left", + type: "default", + data: { + relationshipId: rel.relationship_id, + relationshipType: rel.relationship_type, + connectionType: rel.connection_type, + label: rel.relationship_name, + fromColumn: rel.from_column_name, + toColumn: rel.to_column_name, + }, + })); + + console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length); + console.log("📍 관계 에지 상세:", relationshipEdges); + setEdges(relationshipEdges); + toast.success(`"${selectedDiagram.diagramName}" 관계도를 불러왔습니다.`, { id: "load-diagram" }); + } catch (error) { + console.error("선택된 관계도 로드 실패:", error); + toast.error("관계도를 불러오는데 실패했습니다.", { id: "load-diagram" }); + } + }, [selectedDiagram, companyCode, setNodes, setEdges, selectedColumns, handleColumnClick]); + + // 기존 관계 로드 (새 관계도 생성 시) + const loadExistingRelationships = useCallback(async () => { + if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음 + + try { + const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode); + setRelationships(existingRelationships); + + // 기존 관계를 엣지로 변환하여 표시 + const existingEdges = existingRelationships.map((rel) => ({ + id: generateUniqueId("edge", rel.relationship_id), + source: `table-${rel.from_table_name}`, + target: `table-${rel.to_table_name}`, + sourceHandle: "right", + targetHandle: "left", + type: "default", + data: { + relationshipId: rel.relationship_id, + relationshipType: rel.relationship_type, + connectionType: rel.connection_type, + label: rel.relationship_name, + fromColumn: rel.from_column_name, + toColumn: rel.to_column_name, + }, + })); + + setEdges(existingEdges); + } catch (error) { + console.error("기존 관계 로드 실패:", error); + toast.error("기존 관계를 불러오는데 실패했습니다."); + } + }, [companyCode, setEdges, selectedDiagram]); + + // 컴포넌트 마운트 시 관계 로드 + useEffect(() => { + if (companyCode) { + if (selectedDiagram) { + loadSelectedDiagramRelationships(); + } else { + loadExistingRelationships(); + } + } + }, [companyCode, selectedDiagram, loadExistingRelationships, loadSelectedDiagramRelationships]); + + // 노드 선택 변경 핸들러 + const onSelectionChange = useCallback(({ nodes }: { nodes: Node[] }) => { + const selectedNodeIds = nodes.map((node) => node.id); + setSelectedNodes(selectedNodeIds); + }, []); + + // 빈 onConnect 함수 (드래그 연결 비활성화) + const onConnect = useCallback(() => { + // 드래그로 연결하는 것을 방지 + return; + }, []); + // 선택된 컬럼이 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리 useEffect(() => { setNodes((prevNodes) => @@ -380,19 +489,19 @@ export const DataFlowDesigner: React.FC = ({ companyCode, if (!pendingConnection) return; const newEdge = { - id: generateUniqueId("edge", relationship.relationshipId), + id: generateUniqueId("edge", relationship.relationship_id), source: pendingConnection.fromNode.id, target: pendingConnection.toNode.id, sourceHandle: "right", targetHandle: "left", type: "default", data: { - relationshipId: relationship.relationshipId, - relationshipType: relationship.relationshipType, - connectionType: relationship.connectionType, - label: relationship.relationshipName, - fromColumn: relationship.fromColumnName, - toColumn: relationship.toColumnName, + relationshipId: relationship.relationship_id, + relationshipType: relationship.relationship_type, + connectionType: relationship.connection_type, + label: relationship.relationship_name, + fromColumn: relationship.from_column_name, + toColumn: relationship.to_column_name, }, }; diff --git a/frontend/components/dataflow/DataFlowList.tsx b/frontend/components/dataflow/DataFlowList.tsx new file mode 100644 index 00000000..2ea2e204 --- /dev/null +++ b/frontend/components/dataflow/DataFlowList.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Network, Database, Calendar, User } from "lucide-react"; +import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow"; +import { toast } from "sonner"; + +interface DataFlowListProps { + onDiagramSelect: (diagram: DataFlowDiagram) => void; + selectedDiagram: DataFlowDiagram | null; + onDesignDiagram: (diagram: DataFlowDiagram | null) => void; +} + +export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesignDiagram }: DataFlowListProps) { + const [diagrams, setDiagrams] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + + // 관계도 목록 로드 + useEffect(() => { + let abort = false; + const load = async () => { + try { + setLoading(true); + const response = await DataFlowAPI.getDataFlowDiagrams(currentPage, 20, searchTerm); + if (abort) return; + + setDiagrams(response.diagrams || []); + setTotal(response.total || 0); + setTotalPages(Math.max(1, Math.ceil((response.total || 0) / 20))); + } catch (error) { + console.error("관계도 목록 조회 실패", error); + setDiagrams([]); + setTotalPages(1); + setTotal(0); + toast.error("관계도 목록을 불러오는데 실패했습니다."); + } finally { + if (!abort) setLoading(false); + } + }; + load(); + return () => { + abort = true; + }; + }, [currentPage, searchTerm]); + + // 관계도 목록 다시 로드 + const reloadDiagrams = async () => { + try { + setLoading(true); + const response = await DataFlowAPI.getDataFlowDiagrams(currentPage, 20, searchTerm); + setDiagrams(response.diagrams || []); + setTotal(response.total || 0); + setTotalPages(Math.max(1, Math.ceil((response.total || 0) / 20))); + } catch (error) { + console.error("관계도 목록 조회 실패", error); + toast.error("관계도 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleDiagramSelect = (diagram: DataFlowDiagram) => { + onDiagramSelect(diagram); + }; + + const handleEdit = (diagram: DataFlowDiagram) => { + // 편집 모달 열기 + console.log("편집:", diagram); + }; + + const handleDelete = (diagram: DataFlowDiagram) => { + if (confirm(`"${diagram.diagramName}" 관계도를 삭제하시겠습니까?`)) { + // 삭제 API 호출 + console.log("삭제:", diagram); + toast.info("삭제 기능은 아직 구현되지 않았습니다."); + } + }; + + const handleCopy = (diagram: DataFlowDiagram) => { + console.log("복사:", diagram); + toast.info("복사 기능은 아직 구현되지 않았습니다."); + }; + + const handleView = (diagram: DataFlowDiagram) => { + // 미리보기 모달 열기 + console.log("미리보기:", diagram); + toast.info("미리보기 기능은 아직 구현되지 않았습니다."); + }; + + // 연결 타입에 따른 배지 색상 + const getConnectionTypeBadge = (connectionType: string) => { + switch (connectionType) { + case "simple-key": + return ( + + 단순 키값 + + ); + case "data-save": + return ( + + 데이터 저장 + + ); + case "external-call": + return ( + + 외부 호출 + + ); + default: + return {connectionType}; + } + }; + + // 관계 타입에 따른 배지 색상 + const getRelationshipTypeBadge = (relationshipType: string) => { + switch (relationshipType) { + case "one-to-one": + return ( + + 1:1 + + ); + case "one-to-many": + return ( + + 1:N + + ); + case "many-to-one": + return ( + + N:1 + + ); + case "many-to-many": + return ( + + N:N + + ); + default: + return {relationshipType}; + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* 검색 및 필터 */} +
+
+
+ + setSearchTerm(e.target.value)} + className="w-80 pl-10" + /> +
+
+ +
+ + {/* 관계도 목록 테이블 */} + + + + + + 데이터 흐름 관계도 ({total}) + + + + + + + + 관계도명 + 연결 타입 + 관계 타입 + 테이블 수 + 관계 수 + 최근 수정 + 작업 + + + + {diagrams.map((diagram) => ( + handleDiagramSelect(diagram)} + > + +
+
+ + {diagram.diagramName} +
+
+ 테이블: {diagram.tables.slice(0, 3).join(", ")} + {diagram.tables.length > 3 && ` 외 ${diagram.tables.length - 3}개`} +
+
+
+ {getConnectionTypeBadge(diagram.connectionType)} + {getRelationshipTypeBadge(diagram.relationshipType)} + +
+ + {diagram.tableCount} +
+
+ +
+ + {diagram.relationshipCount} +
+
+ +
+ + {new Date(diagram.updatedAt).toLocaleDateString()} +
+
+ + {diagram.updatedBy} +
+
+ + + + + + + onDesignDiagram(diagram)}> + + 관계도 설계 + + handleView(diagram)}> + + 미리보기 + + handleEdit(diagram)}> + + 편집 + + handleCopy(diagram)}> + + 복사 + + handleDelete(diagram)} className="text-red-600"> + + 삭제 + + + + +
+ ))} +
+
+ + {diagrams.length === 0 && ( +
+ +
관계도가 없습니다
+
새 관계도를 생성하여 테이블 간 데이터 관계를 설정해보세요.
+
+ )} +
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+ ); +} diff --git a/frontend/lib/api/dataflow.ts b/frontend/lib/api/dataflow.ts index 182ffbfe..4b2cf695 100644 --- a/frontend/lib/api/dataflow.ts +++ b/frontend/lib/api/dataflow.ts @@ -38,17 +38,17 @@ export interface TableInfo { } export interface TableRelationship { - relationshipId?: number; - relationshipName: string; - fromTableName: string; - fromColumnName: string; - toTableName: string; - toColumnName: string; - relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; - connectionType: "simple-key" | "data-save" | "external-call"; + relationship_id?: number; + relationship_name: string; + from_table_name: string; + from_column_name: string; + to_table_name: string; + to_column_name: string; + relationship_type: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"; + connection_type: "simple-key" | "data-save" | "external-call"; settings?: Record; - companyCode: string; - isActive?: string; + company_code: string; + is_active?: string; } // 데이터 연결 중계 테이블 타입 @@ -87,6 +87,31 @@ export interface TableDataResponse { }; } +// 관계도 정보 인터페이스 +export interface DataFlowDiagram { + diagramName: string; + connectionType: string; + relationshipType: string; + tableCount: number; + relationshipCount: number; + tables: string[]; + createdAt: Date; + createdBy: string; + updatedAt: Date; + updatedBy: string; +} + +// 관계도 목록 응답 인터페이스 +export interface DataFlowDiagramsResponse { + diagrams: DataFlowDiagram[]; + total: number; + page: number; + size: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; +} + // 테이블 간 데이터 관계 설정 API 클래스 export class DataFlowAPI { /** @@ -323,4 +348,51 @@ export class DataFlowAPI { throw error; } } + + // ==================== 관계도 관리 ==================== + + // 관계도 목록 조회 + static async getDataFlowDiagrams( + page: number = 1, + size: number = 20, + searchTerm: string = "", + ): Promise { + try { + const params = new URLSearchParams({ + page: page.toString(), + size: size.toString(), + ...(searchTerm && { searchTerm }), + }); + + const response = await apiClient.get>(`/dataflow/diagrams?${params}`); + + if (!response.data.success) { + throw new Error(response.data.message || "관계도 목록 조회에 실패했습니다."); + } + + return response.data.data as DataFlowDiagramsResponse; + } catch (error) { + console.error("관계도 목록 조회 오류:", error); + throw error; + } + } + + // 특정 관계도의 모든 관계 조회 + static async getDiagramRelationships(diagramName: string): Promise { + try { + const encodedDiagramName = encodeURIComponent(diagramName); + const response = await apiClient.get>( + `/dataflow/diagrams/${encodedDiagramName}/relationships`, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "관계도 관계 조회에 실패했습니다."); + } + + return response.data.data as TableRelationship[]; + } catch (error) { + console.error("관계도 관계 조회 오류:", error); + throw error; + } + } }