diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index bddea0a8..4202ff2e 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -5322,4 +5322,20 @@ model data_relationship_bridge { @@index([company_code, is_active], map: "idx_data_bridge_company_active") } +// 데이터플로우 관계도 - JSON 구조로 저장 +model dataflow_diagrams { + diagram_id Int @id @default(autoincrement()) + diagram_name String @db.VarChar(255) + relationships Json // 모든 관계 정보를 JSON으로 저장 + company_code String @db.VarChar(50) + created_at DateTime? @default(now()) @db.Timestamp(6) + updated_at DateTime? @default(now()) @updatedAt @db.Timestamp(6) + created_by String? @db.VarChar(50) + updated_by String? @db.VarChar(50) + + @@unique([company_code, diagram_name], map: "unique_diagram_name_per_company") + @@index([company_code], map: "idx_dataflow_diagrams_company") + @@index([diagram_name], map: "idx_dataflow_diagrams_name") +} + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 18e83c0d..3c8b61cc 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -20,6 +20,7 @@ import dynamicFormRoutes from "./routes/dynamicFormRoutes"; import fileRoutes from "./routes/fileRoutes"; import companyManagementRoutes from "./routes/companyManagementRoutes"; import dataflowRoutes from "./routes/dataflowRoutes"; +import dataflowDiagramRoutes from "./routes/dataflowDiagramRoutes"; import webTypeStandardRoutes from "./routes/webTypeStandardRoutes"; import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes"; import screenStandardRoutes from "./routes/screenStandardRoutes"; @@ -108,6 +109,7 @@ app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); app.use("/api/company-management", companyManagementRoutes); app.use("/api/dataflow", dataflowRoutes); +app.use("/api/dataflow-diagrams", dataflowDiagramRoutes); app.use("/api/admin/web-types", webTypeStandardRoutes); app.use("/api/admin/button-actions", buttonActionStandardRoutes); app.use("/api/admin/template-standards", templateStandardRoutes); diff --git a/backend-node/src/controllers/dataflowDiagramController.ts b/backend-node/src/controllers/dataflowDiagramController.ts new file mode 100644 index 00000000..eb7f1567 --- /dev/null +++ b/backend-node/src/controllers/dataflowDiagramController.ts @@ -0,0 +1,247 @@ +import { Request, Response } from "express"; +import { + getDataflowDiagrams as getDataflowDiagramsService, + getDataflowDiagramById as getDataflowDiagramByIdService, + createDataflowDiagram as createDataflowDiagramService, + updateDataflowDiagram as updateDataflowDiagramService, + deleteDataflowDiagram as deleteDataflowDiagramService, + copyDataflowDiagram as copyDataflowDiagramService, +} from "../services/dataflowDiagramService"; +import { logger } from "../utils/logger"; + +/** + * 관계도 목록 조회 (페이지네이션) + */ +export const getDataflowDiagrams = async (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const size = parseInt(req.query.size as string) || 20; + const searchTerm = req.query.searchTerm as string; + const companyCode = (req.query.companyCode as string) || req.headers["x-company-code"] as string || "*"; + + const result = await getDataflowDiagramsService(companyCode, page, size, searchTerm); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + logger.error("관계도 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "관계도 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 특정 관계도 조회 + */ +export const getDataflowDiagramById = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const companyCode = (req.query.companyCode as string) || req.headers["x-company-code"] as string || "*"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const diagram = await getDataflowDiagramByIdService(diagramId, companyCode); + + if (!diagram) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: diagram, + }); + } catch (error) { + logger.error("관계도 조회 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 새로운 관계도 생성 + */ +export const createDataflowDiagram = async (req: Request, res: Response) => { + try { + const { diagram_name, relationships, company_code, created_by, updated_by } = req.body; + const companyCode = company_code || (req.query.companyCode as string) || req.headers["x-company-code"] as string || "*"; + const userId = created_by || updated_by || req.headers["x-user-id"] as string || "SYSTEM"; + + if (!diagram_name || !relationships) { + return res.status(400).json({ + success: false, + message: "관계도 이름과 관계 정보는 필수입니다.", + }); + } + + const newDiagram = await createDataflowDiagramService({ + diagram_name, + relationships, + company_code: companyCode, + created_by: userId, + updated_by: userId, + }); + + return res.status(201).json({ + success: true, + data: newDiagram, + message: "관계도가 성공적으로 생성되었습니다.", + }); + } catch (error) { + logger.error("관계도 생성 실패:", error); + + // 중복 이름 에러 처리 + if (error instanceof Error && error.message.includes("unique constraint")) { + return res.status(409).json({ + success: false, + message: "이미 존재하는 관계도 이름입니다.", + }); + } + + return res.status(500).json({ + success: false, + message: "관계도 생성 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 관계도 수정 + */ +export const updateDataflowDiagram = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const { updated_by } = req.body; + const companyCode = (req.query.companyCode as string) || req.headers["x-company-code"] as string || "*"; + const userId = updated_by || req.headers["x-user-id"] as string || "SYSTEM"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const updateData = { + ...req.body, + updated_by: userId, + }; + + const updatedDiagram = await updateDataflowDiagramService(diagramId, updateData, companyCode); + + if (!updatedDiagram) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: updatedDiagram, + message: "관계도가 성공적으로 수정되었습니다.", + }); + } catch (error) { + logger.error("관계도 수정 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 수정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 관계도 삭제 + */ +export const deleteDataflowDiagram = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const companyCode = (req.query.companyCode as string) || req.headers["x-company-code"] as string || "*"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const deleted = await deleteDataflowDiagramService(diagramId, companyCode); + + if (!deleted) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + message: "관계도가 성공적으로 삭제되었습니다.", + }); + } catch (error) { + logger.error("관계도 삭제 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 관계도 복제 + */ +export const copyDataflowDiagram = async (req: Request, res: Response) => { + try { + const diagramId = parseInt(req.params.diagramId); + const { new_name, companyCode: bodyCompanyCode, userId: bodyUserId } = req.body; + const companyCode = bodyCompanyCode || (req.query.companyCode as string) || req.headers["x-company-code"] as string || "*"; + const userId = bodyUserId || req.headers["x-user-id"] as string || "SYSTEM"; + + if (isNaN(diagramId)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 관계도 ID입니다.", + }); + } + + const copiedDiagram = await copyDataflowDiagramService(diagramId, companyCode, new_name, userId); + + if (!copiedDiagram) { + return res.status(404).json({ + success: false, + message: "복제할 관계도를 찾을 수 없습니다.", + }); + } + + return res.status(201).json({ + success: true, + data: copiedDiagram, + message: "관계도가 성공적으로 복제되었습니다.", + }); + } catch (error) { + logger.error("관계도 복제 실패:", error); + return res.status(500).json({ + success: false, + message: "관계도 복제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/routes/dataflowDiagramRoutes.ts b/backend-node/src/routes/dataflowDiagramRoutes.ts new file mode 100644 index 00000000..e160d6c3 --- /dev/null +++ b/backend-node/src/routes/dataflowDiagramRoutes.ts @@ -0,0 +1,49 @@ +import express from "express"; +import { + getDataflowDiagrams, + getDataflowDiagramById, + createDataflowDiagram, + updateDataflowDiagram, + deleteDataflowDiagram, + copyDataflowDiagram, +} from "../controllers/dataflowDiagramController"; + +const router = express.Router(); + +/** + * @route GET /api/dataflow-diagrams + * @desc 관계도 목록 조회 (페이지네이션) + */ +router.get("/", getDataflowDiagrams); + +/** + * @route GET /api/dataflow-diagrams/:diagramId + * @desc 특정 관계도 조회 + */ +router.get("/:diagramId", getDataflowDiagramById); + +/** + * @route POST /api/dataflow-diagrams + * @desc 새로운 관계도 생성 + */ +router.post("/", createDataflowDiagram); + +/** + * @route PUT /api/dataflow-diagrams/:diagramId + * @desc 관계도 수정 + */ +router.put("/:diagramId", updateDataflowDiagram); + +/** + * @route DELETE /api/dataflow-diagrams/:diagramId + * @desc 관계도 삭제 + */ +router.delete("/:diagramId", deleteDataflowDiagram); + +/** + * @route POST /api/dataflow-diagrams/:diagramId/copy + * @desc 관계도 복제 + */ +router.post("/:diagramId/copy", copyDataflowDiagram); + +export default router; diff --git a/backend-node/src/services/dataflowDiagramService.ts b/backend-node/src/services/dataflowDiagramService.ts new file mode 100644 index 00000000..070823e6 --- /dev/null +++ b/backend-node/src/services/dataflowDiagramService.ts @@ -0,0 +1,307 @@ +import { PrismaClient } from "@prisma/client"; +import { logger } from "../utils/logger"; + +const prisma = new PrismaClient(); + +// 타입 정의 +interface CreateDataflowDiagramData { + diagram_name: string; + relationships: any; // JSON 데이터 + company_code: string; + created_by: string; + updated_by: string; +} + +interface UpdateDataflowDiagramData { + diagram_name?: string; + relationships?: any; // JSON 데이터 + updated_by: string; +} + +/** + * 관계도 목록 조회 (페이지네이션) + */ +export const getDataflowDiagrams = async ( + companyCode: string, + page: number = 1, + size: number = 20, + searchTerm?: string +) => { + try { + const offset = (page - 1) * size; + + // 검색 조건 구성 + const whereClause: any = {}; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + if (searchTerm) { + whereClause.diagram_name = { + contains: searchTerm, + mode: "insensitive", + }; + } + + // 총 개수 조회 + const total = await prisma.dataflow_diagrams.count({ + where: whereClause, + }); + + // 데이터 조회 + const diagrams = await prisma.dataflow_diagrams.findMany({ + where: whereClause, + orderBy: { + updated_at: "desc", + }, + skip: offset, + take: size, + }); + + const totalPages = Math.ceil(total / size); + + return { + diagrams, + pagination: { + page, + size, + total, + totalPages, + }, + }; + } catch (error) { + logger.error("관계도 목록 조회 서비스 오류:", error); + throw error; + } +}; + +/** + * 특정 관계도 조회 + */ +export const getDataflowDiagramById = async ( + diagramId: number, + companyCode: string +) => { + try { + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const diagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + return diagram; + } catch (error) { + logger.error("관계도 조회 서비스 오류:", error); + throw error; + } +}; + +/** + * 새로운 관계도 생성 + */ +export const createDataflowDiagram = async ( + data: CreateDataflowDiagramData +) => { + try { + const newDiagram = await prisma.dataflow_diagrams.create({ + data: { + diagram_name: data.diagram_name, + relationships: data.relationships, + company_code: data.company_code, + created_by: data.created_by, + updated_by: data.updated_by, + }, + }); + + return newDiagram; + } catch (error) { + logger.error("관계도 생성 서비스 오류:", error); + throw error; + } +}; + +/** + * 관계도 수정 + */ +export const updateDataflowDiagram = async ( + diagramId: number, + data: UpdateDataflowDiagramData, + companyCode: string +) => { + try { + // 먼저 해당 관계도가 존재하는지 확인 + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const existingDiagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + if (!existingDiagram) { + return null; + } + + // 업데이트 실행 + const updatedDiagram = await prisma.dataflow_diagrams.update({ + where: { + diagram_id: diagramId, + }, + data: { + ...(data.diagram_name && { diagram_name: data.diagram_name }), + ...(data.relationships && { relationships: data.relationships }), + updated_by: data.updated_by, + updated_at: new Date(), + }, + }); + + return updatedDiagram; + } catch (error) { + logger.error("관계도 수정 서비스 오류:", error); + throw error; + } +}; + +/** + * 관계도 삭제 + */ +export const deleteDataflowDiagram = async ( + diagramId: number, + companyCode: string +) => { + try { + // 먼저 해당 관계도가 존재하는지 확인 + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const existingDiagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + if (!existingDiagram) { + return false; + } + + // 삭제 실행 + await prisma.dataflow_diagrams.delete({ + where: { + diagram_id: diagramId, + }, + }); + + return true; + } catch (error) { + logger.error("관계도 삭제 서비스 오류:", error); + throw error; + } +}; + +/** + * 관계도 복제 + */ +export const copyDataflowDiagram = async ( + diagramId: number, + companyCode: string, + newName?: string, + userId: string = "SYSTEM" +) => { + try { + // 원본 관계도 조회 + const whereClause: any = { + diagram_id: diagramId, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } + + const originalDiagram = await prisma.dataflow_diagrams.findFirst({ + where: whereClause, + }); + + if (!originalDiagram) { + return null; + } + + // 새로운 이름 생성 (제공되지 않은 경우) + let copyName = newName; + if (!copyName) { + // 기존 이름에서 (n) 패턴을 찾아서 증가 + const baseNameMatch = originalDiagram.diagram_name.match( + /^(.+?)(\s*\((\d+)\))?$/ + ); + const baseName = baseNameMatch + ? baseNameMatch[1] + : originalDiagram.diagram_name; + + // 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기 + const copyWhereClause: any = { + diagram_name: { + startsWith: baseName, + }, + }; + + // company_code가 '*'가 아닌 경우에만 필터링 + if (companyCode !== "*") { + copyWhereClause.company_code = companyCode; + } + + const existingCopies = await prisma.dataflow_diagrams.findMany({ + where: copyWhereClause, + select: { + diagram_name: true, + }, + }); + + let maxNumber = 0; + existingCopies.forEach((copy) => { + const match = copy.diagram_name.match(/\((\d+)\)$/); + if (match) { + const num = parseInt(match[1]); + if (num > maxNumber) { + maxNumber = num; + } + } + }); + + copyName = `${baseName} (${maxNumber + 1})`; + } + + // 새로운 관계도 생성 + const copiedDiagram = await prisma.dataflow_diagrams.create({ + data: { + diagram_name: copyName, + relationships: originalDiagram.relationships as any, + company_code: companyCode, + created_by: userId, + updated_by: userId, + }, + }); + + return copiedDiagram; + } catch (error) { + logger.error("관계도 복제 서비스 오류:", error); + throw error; + } +}; diff --git a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx index 0e3cf520..25b3f193 100644 --- a/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx +++ b/frontend/app/(main)/admin/dataflow/edit/[diagramId]/page.tsx @@ -23,9 +23,9 @@ export default function DataFlowEditPage() { // diagram_id로 관계도명 조회 const fetchDiagramName = async () => { try { - const relationships = await DataFlowAPI.getDiagramRelationshipsByDiagramId(id); - if (relationships.length > 0) { - setDiagramName(relationships[0].relationship_name); + const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(id); + if (jsonDiagram && jsonDiagram.diagram_name) { + setDiagramName(jsonDiagram.diagram_name); } else { setDiagramName(`관계도 ID: ${id}`); } diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index b8c3dec9..2e833c1e 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -151,84 +151,73 @@ export const ConnectionSetupModal: React.FC = ({ } }, [isOpen, connection]); - const handleConfirm = async () => { + const handleConfirm = () => { if (!config.relationshipName || !connection) { toast.error("필수 정보를 모두 입력해주세요."); return; } - try { - // 연결 종류별 설정을 준비 - let settings = {}; + // 연결 종류별 설정을 준비 + let settings = {}; - switch (config.connectionType) { - case "simple-key": - settings = simpleKeySettings; - break; - case "data-save": - settings = dataSaveSettings; - break; - case "external-call": - settings = externalCallSettings; - break; - } - - // 선택된 컬럼들 추출 - const selectedColumnsData = connection.selectedColumnsData || {}; - const tableNames = Object.keys(selectedColumnsData); - const fromTable = tableNames[0]; - const toTable = tableNames[1]; - - const fromColumns = selectedColumnsData[fromTable]?.columns || []; - const toColumns = selectedColumnsData[toTable]?.columns || []; - - if (fromColumns.length === 0 || toColumns.length === 0) { - toast.error("선택된 컬럼이 없습니다."); - return; - } - - toast.loading("관계를 생성하고 있습니다...", { id: "create-relationship" }); - - // 단일 관계 데이터 준비 (모든 선택된 컬럼 정보 포함) - // API 요청용 데이터 (camelCase) - const apiRequestData = { - ...(diagramId && diagramId > 0 ? { diagramId: diagramId } : {}), // diagramId가 유효할 때만 추가 - 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, - settings: { - ...settings, - multiColumnMapping: { - fromColumns: fromColumns, - toColumns: toColumns, - fromTable: selectedColumnsData[fromTable]?.displayName || fromTable, - toTable: selectedColumnsData[toTable]?.displayName || toTable, - }, - isMultiColumn: fromColumns.length > 1 || toColumns.length > 1, - columnCount: { - from: fromColumns.length, - to: toColumns.length, - }, - }, - }; - - // API 호출 - const createdRelationship = await DataFlowAPI.createRelationship(apiRequestData as any); - - toast.success("관계가 성공적으로 생성되었습니다!", { id: "create-relationship" }); - - // 성공 콜백 호출 - onConfirm(createdRelationship); - handleCancel(); // 모달 닫기 - } catch (error) { - console.error("관계 생성 오류:", error); - toast.error("관계 생성에 실패했습니다. 다시 시도해주세요.", { id: "create-relationship" }); + switch (config.connectionType) { + case "simple-key": + settings = simpleKeySettings; + break; + case "data-save": + settings = dataSaveSettings; + break; + case "external-call": + settings = externalCallSettings; + break; } + + // 선택된 컬럼들 추출 + const selectedColumnsData = connection.selectedColumnsData || {}; + const tableNames = Object.keys(selectedColumnsData); + const fromTable = tableNames[0]; + const toTable = tableNames[1]; + + const fromColumns = selectedColumnsData[fromTable]?.columns || []; + const toColumns = selectedColumnsData[toTable]?.columns || []; + + if (fromColumns.length === 0 || toColumns.length === 0) { + toast.error("선택된 컬럼이 없습니다."); + return; + } + + // 메모리 기반 시스템: 관계 데이터만 생성하여 부모로 전달 + const relationshipData: TableRelationship = { + relationship_name: config.relationshipName, + from_table_name: connection.fromNode.tableName, + to_table_name: connection.toNode.tableName, + from_column_name: fromColumns.join(","), // 여러 컬럼을 콤마로 구분 + to_column_name: toColumns.join(","), // 여러 컬럼을 콤마로 구분 + relationship_type: config.relationshipType as any, + connection_type: config.connectionType as any, + company_code: companyCode, + settings: { + ...settings, + description: config.description, + multiColumnMapping: { + fromColumns: fromColumns, + toColumns: toColumns, + fromTable: selectedColumnsData[fromTable]?.displayName || fromTable, + toTable: selectedColumnsData[toTable]?.displayName || toTable, + }, + isMultiColumn: fromColumns.length > 1 || toColumns.length > 1, + columnCount: { + from: fromColumns.length, + to: toColumns.length, + }, + }, + }; + + toast.success("관계가 생성되었습니다!"); + + // 부모 컴포넌트로 관계 데이터 전달 (DB 저장 없이) + onConfirm(relationshipData); + handleCancel(); // 모달 닫기 }; const handleCancel = () => { diff --git a/frontend/components/dataflow/DataFlowDesigner.tsx b/frontend/components/dataflow/DataFlowDesigner.tsx index a2fe059d..c14612ee 100644 --- a/frontend/components/dataflow/DataFlowDesigner.tsx +++ b/frontend/components/dataflow/DataFlowDesigner.tsx @@ -17,7 +17,16 @@ import "@xyflow/react/dist/style.css"; import { TableNode } from "./TableNode"; import { TableSelector } from "./TableSelector"; import { ConnectionSetupModal } from "./ConnectionSetupModal"; -import { TableDefinition, TableRelationship, DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow"; +import { + TableDefinition, + TableRelationship, + DataFlowAPI, + DataFlowDiagram, + JsonRelationship, + CreateDiagramRequest, +} from "@/lib/api/dataflow"; +import SaveDiagramModal from "./SaveDiagramModal"; +import { useAuth } from "@/hooks/useAuth"; // 고유 ID 생성 함수 const generateUniqueId = (prefix: string, diagramId?: number): string => { @@ -61,13 +70,18 @@ interface DataFlowDesignerProps { // TableRelationship 타입은 dataflow.ts에서 import export const DataFlowDesigner: React.FC = ({ - companyCode = "*", + companyCode: propCompanyCode = "*", diagramId, relationshipId, // 하위 호환성 유지 - onSave, + onSave, // eslint-disable-line @typescript-eslint/no-unused-vars selectedDiagram, onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars }) => { + const { user } = useAuth(); + + // 실제 사용자 회사 코드 사용 (prop보다 사용자 정보 우선) + const companyCode = user?.company_code || user?.companyCode || propCompanyCode; + const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedColumns, setSelectedColumns] = useState<{ @@ -89,9 +103,46 @@ export const DataFlowDesigner: React.FC = ({ } | null>(null); const [relationships, setRelationships] = useState([]); // eslint-disable-line @typescript-eslint/no-unused-vars const [currentDiagramId, setCurrentDiagramId] = useState(null); // 현재 화면의 diagram_id - const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null); // 선택된 엣지 정보 + const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{ + relationshipId: string; + relationshipName: string; + fromTable: string; + toTable: string; + fromColumns: string[]; + toColumns: string[]; + relationshipType: string; + connectionType: string; + connectionInfo: string; + } | null>(null); // 선택된 엣지 정보 + + // 새로운 메모리 기반 상태들 + const [tempRelationships, setTempRelationships] = useState([]); // 메모리에 저장된 관계들 + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 저장되지 않은 변경사항 + const [showSaveModal, setShowSaveModal] = useState(false); // 저장 모달 표시 상태 + const [isSaving, setIsSaving] = useState(false); // 저장 중 상태 + const [currentDiagramName, setCurrentDiagramName] = useState(""); // 현재 편집 중인 관계도 이름 const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars + // 편집 모드일 때 관계도 이름 로드 + useEffect(() => { + const loadDiagramName = async () => { + if (diagramId && diagramId > 0) { + try { + const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode); + if (jsonDiagram && jsonDiagram.diagram_name) { + setCurrentDiagramName(jsonDiagram.diagram_name); + } + } catch (error) { + console.error("관계도 이름 로드 실패:", error); + } + } else { + setCurrentDiagramName(""); // 신규 생성 모드 + } + }; + + loadDiagramName(); + }, [diagramId, companyCode]); + // 키보드 이벤트 핸들러 (Del 키로 선택된 노드 삭제) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -177,38 +228,28 @@ export const DataFlowDesigner: React.FC = ({ if (!currentDiagramId || isNaN(currentDiagramId)) return; try { - console.log("🔍 관계도 로드 시작 (diagramId):", currentDiagramId); + console.log("🔍 JSON 관계도 로드 시작 (diagramId):", currentDiagramId); toast.loading("관계도를 불러오는 중...", { id: "load-diagram" }); - // diagramId로 해당 관계도의 모든 관계 조회 - const diagramRelationships = await DataFlowAPI.getDiagramRelationshipsByDiagramId(currentDiagramId); - console.log("📋 관계도 관계 데이터:", diagramRelationships); + // 새로운 JSON API로 관계도 조회 + const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(currentDiagramId); + console.log("📋 JSON 관계도 데이터:", jsonDiagram); - if (!Array.isArray(diagramRelationships)) { - throw new Error("관계도 데이터 형식이 올바르지 않습니다."); + if (!jsonDiagram || !jsonDiagram.relationships) { + throw new Error("관계도 데이터를 찾을 수 없습니다."); } - console.log("📋 첫 번째 관계 상세:", diagramRelationships[0]); - console.log( - "📋 관계 객체 키들:", - diagramRelationships[0] ? Object.keys(diagramRelationships[0]) : "배열이 비어있음", - ); - setRelationships(diagramRelationships); + const relationships = jsonDiagram.relationships.relationships || []; + const tableNames = jsonDiagram.relationships.tables || []; - // 현재 diagram_id 설정 (기존 관계도 편집 시) - if (diagramRelationships.length > 0) { - setCurrentDiagramId(diagramRelationships[0].diagram_id || null); - } + console.log("📋 관계 목록:", relationships); + console.log("📊 테이블 목록:", tableNames); - // 관계도의 모든 테이블 추출 - const tableNames = new Set(); - diagramRelationships.forEach((rel) => { - if (rel && rel.from_table_name && rel.to_table_name) { - tableNames.add(rel.from_table_name); - tableNames.add(rel.to_table_name); - } - }); - console.log("📊 추출된 테이블 이름들:", Array.from(tableNames)); + // 메모리에 관계 저장 (기존 관계도 편집 시) + setTempRelationships(relationships); + setCurrentDiagramId(currentDiagramId); + + // 테이블 노드 생성을 위한 테이블 정보 로드 // 테이블 정보 로드 const allTables = await DataFlowAPI.getTables(); @@ -239,26 +280,15 @@ export const DataFlowDesigner: React.FC = ({ [tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } }; } = {}; - diagramRelationships.forEach((rel) => { - if (!rel || !rel.from_table_name || !rel.to_table_name || !rel.from_column_name || !rel.to_column_name) { - console.warn("⚠️ 관계 데이터가 불완전합니다:", rel); - return; - } - - const fromTable = rel.from_table_name; - const toTable = rel.to_table_name; - const fromColumns = rel.from_column_name - .split(",") - .map((col) => col.trim()) - .filter((col) => col); - const toColumns = rel.to_column_name - .split(",") - .map((col) => col.trim()) - .filter((col) => col); + relationships.forEach((rel: JsonRelationship) => { + const fromTable = rel.fromTable; + const toTable = rel.toTable; + const fromColumns = rel.fromColumns || []; + const toColumns = rel.toColumns || []; // 소스 테이블의 컬럼들을 source로 표시 if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {}; - fromColumns.forEach((col) => { + fromColumns.forEach((col: string) => { if (connectedColumnsInfo[fromTable][col]) { connectedColumnsInfo[fromTable][col].direction = "both"; } else { @@ -268,7 +298,7 @@ export const DataFlowDesigner: React.FC = ({ // 타겟 테이블의 컬럼들을 target으로 표시 if (!connectedColumnsInfo[toTable]) connectedColumnsInfo[toTable] = {}; - toColumns.forEach((col) => { + toColumns.forEach((col: string) => { if (connectedColumnsInfo[toTable][col]) { connectedColumnsInfo[toTable][col].direction = "both"; } else { @@ -312,25 +342,14 @@ export const DataFlowDesigner: React.FC = ({ console.log("📍 테이블 노드 상세:", tableNodes); setNodes(tableNodes); - // 관계를 엣지로 변환하여 표시 (테이블 간 번들 연결) + // JSON 관계를 엣지로 변환하여 표시 (테이블 간 번들 연결) const relationshipEdges: Edge[] = []; - diagramRelationships.forEach((rel) => { - if (!rel || !rel.from_table_name || !rel.to_table_name || !rel.from_column_name || !rel.to_column_name) { - console.warn("⚠️ 에지 생성 시 관계 데이터가 불완전합니다:", rel); - return; - } - - const fromTable = rel.from_table_name; - const toTable = rel.to_table_name; - const fromColumns = rel.from_column_name - .split(",") - .map((col) => col.trim()) - .filter((col) => col); - const toColumns = rel.to_column_name - .split(",") - .map((col) => col.trim()) - .filter((col) => col); + relationships.forEach((rel: JsonRelationship) => { + const fromTable = rel.fromTable; + const toTable = rel.toTable; + const fromColumns = rel.fromColumns || []; + const toColumns = rel.toColumns || []; if (fromColumns.length === 0 || toColumns.length === 0) { console.warn("⚠️ 컬럼 정보가 없습니다:", { fromColumns, toColumns }); @@ -339,7 +358,7 @@ export const DataFlowDesigner: React.FC = ({ // 테이블 간 하나의 번들 엣지 생성 (컬럼별 개별 엣지 대신) relationshipEdges.push({ - id: generateUniqueId("edge", rel.diagram_id), + id: generateUniqueId("edge", currentDiagramId), source: `table-${fromTable}`, target: `table-${toTable}`, type: "smoothstep", @@ -350,10 +369,10 @@ export const DataFlowDesigner: React.FC = ({ strokeDasharray: "none", }, data: { - relationshipId: rel.relationship_id, - relationshipName: rel.relationship_name, - relationshipType: rel.relationship_type, - connectionType: rel.connection_type, + relationshipId: rel.id, + relationshipName: "기존 관계", + relationshipType: rel.relationshipType, + connectionType: rel.connectionType, fromTable: fromTable, toTable: toTable, fromColumns: fromColumns, @@ -361,8 +380,8 @@ export const DataFlowDesigner: React.FC = ({ // 클릭 시 표시할 상세 정보 details: { connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`, - relationshipType: rel.relationship_type, - connectionType: rel.connection_type, + relationshipType: rel.relationshipType, + connectionType: rel.connectionType, }, }, }); @@ -383,57 +402,17 @@ export const DataFlowDesigner: React.FC = ({ if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음 try { - const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode); - setRelationships(existingRelationships); + // 새로운 JSON 기반 시스템에서는 기존 관계를 미리 로드하지 않음 + console.log("새 관계도 생성 모드: 빈 캔버스로 시작"); + setRelationships([]); - // 기존 관계를 엣지로 변환하여 표시 (컬럼별 연결) - const existingEdges: Edge[] = []; - - existingRelationships.forEach((rel) => { - const fromTable = rel.from_table_name; - const toTable = rel.to_table_name; - const fromColumns = rel.from_column_name.split(",").map((col) => col.trim()); - const toColumns = rel.to_column_name.split(",").map((col) => col.trim()); - - // 각 from 컬럼을 각 to 컬럼에 연결 - const maxConnections = Math.max(fromColumns.length, toColumns.length); - - for (let i = 0; i < maxConnections; i++) { - const fromColumn = fromColumns[i] || fromColumns[0]; - const toColumn = toColumns[i] || toColumns[0]; - - existingEdges.push({ - id: generateUniqueId("edge", rel.diagram_id), - source: `table-${fromTable}`, - target: `table-${toTable}`, - sourceHandle: `${fromTable}-${fromColumn}-source`, - targetHandle: `${toTable}-${toColumn}-target`, - type: "smoothstep", - animated: false, - style: { - stroke: "#3b82f6", - strokeWidth: 1.5, - strokeDasharray: "none", - }, - data: { - relationshipId: rel.relationship_id, - relationshipType: rel.relationship_type, - connectionType: rel.connection_type, - fromTable: fromTable, - toTable: toTable, - fromColumn: fromColumn, - toColumn: toColumn, - }, - }); - } - }); - - setEdges(existingEdges); + // 빈 캔버스로 시작 + setEdges([]); } catch (error) { console.error("기존 관계 로드 실패:", error); toast.error("기존 관계를 불러오는데 실패했습니다."); } - }, [companyCode, setEdges, selectedDiagram]); + }, [setEdges, selectedDiagram]); // 컴포넌트 마운트 시 관계 로드 useEffect(() => { @@ -468,7 +447,21 @@ export const DataFlowDesigner: React.FC = ({ // 엣지 클릭 시 연결 정보 표시 const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => { event.stopPropagation(); - const edgeData = edge.data as any; + const edgeData = edge.data as { + relationshipId: string; + relationshipName: string; + fromTable: string; + toTable: string; + fromColumns: string[]; + toColumns: string[]; + relationshipType: string; + connectionType: string; + details?: { + connectionInfo: string; + relationshipType: string; + connectionType: string; + }; + }; if (edgeData) { setSelectedEdgeInfo({ relationshipId: edgeData.relationshipId, @@ -644,33 +637,6 @@ export const DataFlowDesigner: React.FC = ({ [handleColumnClick, selectedColumns, setNodes], ); - // 샘플 테이블 노드 추가 (개발용) - const addSampleNode = useCallback(() => { - const tableName = `sample_table_${nodes.length + 1}`; - const newNode: Node = { - id: `sample-${Date.now()}`, - type: "tableNode", - position: { x: Math.random() * 300, y: Math.random() * 200 }, - data: { - table: { - tableName, - displayName: `샘플 테이블 ${nodes.length + 1}`, - description: `샘플 테이블 설명 ${nodes.length + 1}`, - columns: [ - { name: "id", type: "INTEGER", description: "고유 식별자" }, - { name: "name", type: "VARCHAR(100)", description: "이름" }, - { name: "code", type: "VARCHAR(50)", description: "코드" }, - { name: "created_date", type: "TIMESTAMP", description: "생성일시" }, - ], - }, - onColumnClick: handleColumnClick, - selectedColumns: selectedColumns[tableName] || [], - }, - }; - - setNodes((nds) => nds.concat(newNode)); - }, [nodes.length, handleColumnClick, selectedColumns, setNodes]); - // 노드 전체 삭제 const clearNodes = useCallback(() => { setNodes([]); @@ -691,7 +657,7 @@ export const DataFlowDesigner: React.FC = ({ (relationship: TableRelationship) => { if (!pendingConnection) return; - // 테이블 간 번들 에지 생성 (새 관계 생성 시) + // 메모리 기반 관계 생성 (DB 저장 없이) const fromTable = relationship.from_table_name; const toTable = relationship.to_table_name; const fromColumns = relationship.from_column_name @@ -703,8 +669,25 @@ export const DataFlowDesigner: React.FC = ({ .map((col) => col.trim()) .filter((col) => col); + // JSON 형태의 관계 객체 생성 + const newRelationship: JsonRelationship = { + id: generateUniqueId("rel", Date.now()), + fromTable, + toTable, + fromColumns, + toColumns, + relationshipType: relationship.relationship_type, + connectionType: relationship.connection_type, + settings: relationship.settings || {}, + }; + + // 메모리에 관계 추가 + setTempRelationships((prev) => [...prev, newRelationship]); + setHasUnsavedChanges(true); + + // 캔버스에 엣지 즉시 표시 const newEdge: Edge = { - id: generateUniqueId("edge", relationship.diagram_id), + id: generateUniqueId("edge", Date.now()), source: pendingConnection.fromNode.id, target: pendingConnection.toNode.id, type: "smoothstep", @@ -715,15 +698,14 @@ export const DataFlowDesigner: React.FC = ({ strokeDasharray: "none", }, data: { - relationshipId: relationship.relationship_id, - relationshipName: relationship.relationship_name, + relationshipId: newRelationship.id, + relationshipName: "임시 관계", relationshipType: relationship.relationship_type, connectionType: relationship.connection_type, - fromTable: fromTable, - toTable: toTable, - fromColumns: fromColumns, - toColumns: toColumns, - // 클릭 시 표시할 상세 정보 + fromTable, + toTable, + fromColumns, + toColumns, details: { connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`, relationshipType: relationship.relationship_type, @@ -733,26 +715,16 @@ export const DataFlowDesigner: React.FC = ({ }; setEdges((eds) => [...eds, newEdge]); - setRelationships((prev) => [...prev, relationship]); setPendingConnection(null); - // 첫 번째 관계 생성 시 currentDiagramId 설정 (새 관계도 생성 시) - if (!currentDiagramId && relationship.diagram_id) { - setCurrentDiagramId(relationship.diagram_id); - } - // 관계 생성 후 선택된 컬럼들 초기화 setSelectedColumns({}); setSelectionOrder([]); - console.log("관계 생성 완료:", relationship); - // 관계 생성 완료 후 자동으로 목록 새로고침을 위한 콜백 (선택적) - // 렌더링 중 상태 업데이트 방지를 위해 제거 - // if (onSave) { - // onSave([...relationships, relationship]); - // } + console.log("메모리에 관계 생성 완료:", newRelationship); + toast.success("관계가 생성되었습니다. 저장 버튼을 눌러 관계도를 저장하세요."); }, - [pendingConnection, setEdges, currentDiagramId], + [pendingConnection, setEdges], ); // 연결 설정 취소 @@ -760,6 +732,84 @@ export const DataFlowDesigner: React.FC = ({ setPendingConnection(null); }, []); + // 관계도 저장 함수 + const handleSaveDiagram = useCallback( + async (diagramName: string) => { + if (tempRelationships.length === 0) { + toast.error("저장할 관계가 없습니다."); + return; + } + + setIsSaving(true); + try { + // 연결된 테이블 목록 추출 + const connectedTables = Array.from( + new Set([...tempRelationships.map((rel) => rel.fromTable), ...tempRelationships.map((rel) => rel.toTable)]), + ).sort(); + + // 저장 요청 데이터 생성 + const createRequest: CreateDiagramRequest = { + diagram_name: diagramName, + relationships: { + relationships: tempRelationships, + tables: connectedTables, + }, + }; + + let savedDiagram; + + // 편집 모드 vs 신규 생성 모드 구분 + if (diagramId && diagramId > 0) { + // 편집 모드: 기존 관계도 업데이트 + savedDiagram = await DataFlowAPI.updateJsonDataFlowDiagram( + diagramId, + createRequest, + companyCode, + user?.userId || "SYSTEM", + ); + toast.success(`관계도 "${diagramName}"가 성공적으로 수정되었습니다.`); + } else { + // 신규 생성 모드: 새로운 관계도 생성 + savedDiagram = await DataFlowAPI.createJsonDataFlowDiagram( + createRequest, + companyCode, + user?.userId || "SYSTEM", + ); + toast.success(`관계도 "${diagramName}"가 성공적으로 생성되었습니다.`); + } + + // 성공 처리 + setHasUnsavedChanges(false); + setShowSaveModal(false); + setCurrentDiagramId(savedDiagram.diagram_id); + + console.log("관계도 저장 완료:", savedDiagram); + } catch (error) { + console.error("관계도 저장 실패:", error); + toast.error("관계도 저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); + } + }, + [tempRelationships, diagramId, companyCode, user?.userId], + ); + + // 저장 모달 열기 + const handleOpenSaveModal = useCallback(() => { + if (tempRelationships.length === 0) { + toast.error("저장할 관계가 없습니다. 먼저 테이블을 연결해주세요."); + return; + } + setShowSaveModal(true); + }, [tempRelationships.length]); + + // 저장 모달 닫기 + const handleCloseSaveModal = useCallback(() => { + if (!isSaving) { + setShowSaveModal(false); + } + }, [isSaving]); + return (
@@ -777,13 +827,6 @@ export const DataFlowDesigner: React.FC = ({ {/* 컨트롤 버튼들 */}
- -
@@ -811,10 +857,17 @@ export const DataFlowDesigner: React.FC = ({ 연결: {edges.length}개
+
+ 메모리 관계: + {tempRelationships.length}개 +
관계도 ID: {currentDiagramId || "미설정"}
+ {hasUnsavedChanges && ( +
⚠️ 저장되지 않은 변경사항이 있습니다
+ )}
@@ -979,6 +1032,20 @@ export const DataFlowDesigner: React.FC = ({ onConfirm={handleConfirmConnection} onCancel={handleCancelConnection} /> + + {/* 관계도 저장 모달 */} + 0 && currentDiagramName + ? currentDiagramName // 편집 모드: 기존 관계도 이름 + : `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름 + } + isLoading={isSaving} + /> ); }; diff --git a/frontend/components/dataflow/DataFlowList.tsx b/frontend/components/dataflow/DataFlowList.tsx index df58c209..ec962824 100644 --- a/frontend/components/dataflow/DataFlowList.tsx +++ b/frontend/components/dataflow/DataFlowList.tsx @@ -23,6 +23,7 @@ import { import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react"; import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow"; import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; interface DataFlowListProps { onDiagramSelect: (diagram: DataFlowDiagram) => void; @@ -31,6 +32,7 @@ interface DataFlowListProps { } export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesignDiagram }: DataFlowListProps) { + const { user } = useAuth(); const [diagrams, setDiagrams] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); @@ -38,6 +40,9 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig const [totalPages, setTotalPages] = useState(1); const [total, setTotal] = useState(0); + // 사용자 회사 코드 가져오기 (기본값: "*") + const companyCode = user?.company_code || user?.companyCode || "*"; + // 모달 상태 const [showCopyModal, setShowCopyModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -47,17 +52,35 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig const loadDiagrams = useCallback(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))); + const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode); + + // JSON API 응답을 기존 형식으로 변환 + const convertedDiagrams = response.diagrams.map((diagram) => ({ + diagramId: diagram.diagram_id, + relationshipId: diagram.diagram_id, // 호환성을 위해 추가 + diagramName: diagram.diagram_name, + connectionType: "json-based", // 새로운 JSON 기반 타입 + relationshipType: "multi-relationship", // 다중 관계 타입 + relationshipCount: diagram.relationships?.relationships?.length || 0, + tableCount: diagram.relationships?.tables?.length || 0, + tables: diagram.relationships?.tables || [], + createdAt: new Date(diagram.created_at || new Date()), + createdBy: diagram.created_by || "SYSTEM", + updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()), + updatedBy: diagram.updated_by || "SYSTEM", + lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(), + })); + + setDiagrams(convertedDiagrams); + setTotal(response.pagination.total || 0); + setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20))); } catch (error) { console.error("관계도 목록 조회 실패", error); toast.error("관계도 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } - }, [currentPage, searchTerm]); + }, [currentPage, searchTerm, companyCode]); // 관계도 목록 로드 useEffect(() => { @@ -84,8 +107,13 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig try { setLoading(true); - const newDiagramName = await DataFlowAPI.copyDiagram(selectedDiagramForAction.diagramName); - toast.success(`관계도가 성공적으로 복사되었습니다: ${newDiagramName}`); + const copiedDiagram = await DataFlowAPI.copyJsonDataFlowDiagram( + selectedDiagramForAction.diagramId, + companyCode, + undefined, + user?.userId || "SYSTEM", + ); + toast.success(`관계도가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`); // 목록 새로고침 await loadDiagrams(); @@ -105,8 +133,8 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig try { setLoading(true); - const deletedCount = await DataFlowAPI.deleteDiagram(selectedDiagramForAction.diagramName); - toast.success(`관계도가 삭제되었습니다 (${deletedCount}개 관계 삭제)`); + await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode); + toast.success(`관계도가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`); // 목록 새로고침 await loadDiagrams(); @@ -141,6 +169,12 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig 외부 호출 ); + case "json-based": + return ( + + JSON 기반 + + ); default: return {connectionType}; } @@ -173,6 +207,12 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig N:N ); + case "multi-relationship": + return ( + + 다중 관계 + + ); default: return {relationshipType}; } diff --git a/frontend/components/dataflow/SaveDiagramModal.tsx b/frontend/components/dataflow/SaveDiagramModal.tsx new file mode 100644 index 00000000..ad00be8d --- /dev/null +++ b/frontend/components/dataflow/SaveDiagramModal.tsx @@ -0,0 +1,212 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { JsonRelationship } from "@/lib/api/dataflow"; + +interface SaveDiagramModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (diagramName: string) => void; + relationships: JsonRelationship[]; + defaultName?: string; + isLoading?: boolean; +} + +const SaveDiagramModal: React.FC = ({ + isOpen, + onClose, + onSave, + relationships, + defaultName = "", + isLoading = false, +}) => { + const [diagramName, setDiagramName] = useState(defaultName); + const [nameError, setNameError] = useState(""); + + // defaultName이 변경될 때마다 diagramName 업데이트 + useEffect(() => { + setDiagramName(defaultName); + }, [defaultName]); + + const handleSave = () => { + const trimmedName = diagramName.trim(); + + if (!trimmedName) { + setNameError("관계도 이름을 입력해주세요."); + return; + } + + if (trimmedName.length < 2) { + setNameError("관계도 이름은 2글자 이상이어야 합니다."); + return; + } + + if (trimmedName.length > 100) { + setNameError("관계도 이름은 100글자를 초과할 수 없습니다."); + return; + } + + setNameError(""); + onSave(trimmedName); + }; + + const handleClose = () => { + if (!isLoading) { + setDiagramName(defaultName); + setNameError(""); + onClose(); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isLoading) { + handleSave(); + } + }; + + // 관련된 테이블 목록 추출 + const connectedTables = Array.from( + new Set([...relationships.map((rel) => rel.fromTable), ...relationships.map((rel) => rel.toTable)]), + ).sort(); + + return ( + + + + 📊 관계도 저장 + + +
+ {/* 관계도 이름 입력 */} +
+ + { + setDiagramName(e.target.value); + if (nameError) setNameError(""); + }} + onKeyPress={handleKeyPress} + placeholder="예: 사용자-부서 관계도" + disabled={isLoading} + className={nameError ? "border-red-500 focus:border-red-500" : ""} + /> + {nameError &&

{nameError}

} +
+ + {/* 관계 요약 정보 */} +
+
+
{relationships.length}
+
관계 수
+
+
+
{connectedTables.length}
+
연결된 테이블
+
+
+
+ {relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)} +
+
연결된 컬럼
+
+
+ + {/* 연결된 테이블 목록 */} + {connectedTables.length > 0 && ( + + + 연결된 테이블 + + +
+ {connectedTables.map((table) => ( + + 📋 {table} + + ))} +
+
+
+ )} + + {/* 관계 목록 미리보기 */} + {relationships.length > 0 && ( + + + 관계 목록 + + +
+ {relationships.map((relationship, index) => ( +
+
+
+ + {relationship.relationshipType} + + {relationship.fromTable} + + {relationship.toTable} +
+
+ {relationship.fromColumns.join(", ")} → {relationship.toColumns.join(", ")} +
+
+ + {relationship.connectionType} + +
+ ))} +
+
+
+ )} + + {/* 관계가 없는 경우 안내 */} + {relationships.length === 0 && ( +
+
📭
+
생성된 관계가 없습니다.
+
테이블을 추가하고 컬럼을 연결해서 관계를 생성해보세요.
+
+ )} +
+ + + + + +
+
+ ); +}; + +export default SaveDiagramModal; diff --git a/frontend/lib/api/dataflow.ts b/frontend/lib/api/dataflow.ts index d7182108..9fd91e0c 100644 --- a/frontend/lib/api/dataflow.ts +++ b/frontend/lib/api/dataflow.ts @@ -114,6 +114,50 @@ export interface DataFlowDiagramsResponse { hasPrev: boolean; } +// 새로운 JSON 기반 타입들 +export interface JsonDataFlowDiagram { + diagram_id: number; + diagram_name: string; + relationships: { + relationships: JsonRelationship[]; + tables: string[]; + }; + company_code: string; + created_at?: string; + updated_at?: string; + created_by?: string; + updated_by?: string; +} + +export interface JsonRelationship { + id: string; + fromTable: string; + toTable: string; + fromColumns: string[]; + toColumns: string[]; + relationshipType: string; + connectionType: string; + settings?: any; +} + +export interface CreateDiagramRequest { + diagram_name: string; + relationships: { + relationships: JsonRelationship[]; + tables: string[]; + }; +} + +export interface JsonDataFlowDiagramsResponse { + diagrams: JsonDataFlowDiagram[]; + pagination: { + page: number; + size: number; + total: number; + totalPages: number; + }; +} + // 테이블 간 데이터 관계 설정 API 클래스 export class DataFlowAPI { /** @@ -446,21 +490,217 @@ export class DataFlowAPI { } } - // 특정 관계도의 모든 관계 조회 (diagram_id로) - static async getDiagramRelationshipsByDiagramId(diagramId: number): Promise { + // 특정 관계도의 모든 관계 조회 (diagram_id로) - JSON 기반 시스템 + static async getDiagramRelationshipsByDiagramId( + diagramId: number, + companyCode: string = "*", + ): Promise { try { - const response = await apiClient.get>( - `/dataflow/diagrams/${diagramId}/relationships`, - ); + // 새로운 JSON 기반 시스템에서 관계도 조회 + const jsonDiagram = await this.getJsonDataFlowDiagramById(diagramId, companyCode); - if (!response.data.success) { - throw new Error(response.data.message || "관계도 관계 조회에 실패했습니다."); + if (!jsonDiagram || !jsonDiagram.relationships) { + return []; } - return response.data.data || []; + // JSON 관계를 TableRelationship 형식으로 변환 + const relationshipsData = jsonDiagram.relationships as { relationships: JsonRelationship[]; tables: string[] }; + const relationships: TableRelationship[] = relationshipsData.relationships.map((rel: JsonRelationship) => ({ + relationship_id: 0, // JSON 기반에서는 개별 relationship_id가 없음 + relationship_name: rel.id || "관계", + from_table_name: rel.fromTable, + to_table_name: rel.toTable, + from_column_name: rel.fromColumns.join(","), + to_column_name: rel.toColumns.join(","), + relationship_type: rel.relationshipType as "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many", + connection_type: rel.connectionType as "simple-key" | "data-save" | "external-call", + company_code: companyCode, // 실제 사용자 회사 코드 사용 + settings: rel.settings || {}, + created_at: jsonDiagram.created_at, + updated_at: jsonDiagram.updated_at, + created_by: jsonDiagram.created_by, + updated_by: jsonDiagram.updated_by, + })); + + return relationships; } catch (error) { console.error("관계도 관계 조회 오류:", error); throw error; } } + + // ==================== 새로운 JSON 기반 관계도 API ==================== + + /** + * JSON 기반 관계도 목록 조회 + */ + static async getJsonDataFlowDiagrams( + page: number = 1, + size: number = 20, + searchTerm: string = "", + companyCode: string = "*", + ): Promise { + try { + const params = new URLSearchParams({ + page: page.toString(), + size: size.toString(), + companyCode: companyCode, + ...(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 JsonDataFlowDiagramsResponse; + } catch (error) { + console.error("JSON 관계도 목록 조회 오류:", error); + throw error; + } + } + + /** + * JSON 기반 특정 관계도 조회 + */ + static async getJsonDataFlowDiagramById(diagramId: number, companyCode: string = "*"): Promise { + try { + const params = new URLSearchParams({ + companyCode: companyCode, + }); + + const response = await apiClient.get>( + `/dataflow-diagrams/${diagramId}?${params}`, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "관계도 조회에 실패했습니다."); + } + + return response.data.data as JsonDataFlowDiagram; + } catch (error) { + console.error("JSON 관계도 조회 오류:", error); + throw error; + } + } + + /** + * JSON 기반 관계도 생성 + */ + static async createJsonDataFlowDiagram( + request: CreateDiagramRequest, + companyCode: string = "*", + userId: string = "SYSTEM", + ): Promise { + try { + const requestWithUserInfo = { + ...request, + company_code: companyCode, + created_by: userId, + updated_by: userId, + }; + + const response = await apiClient.post>( + "/dataflow-diagrams", + requestWithUserInfo, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "관계도 생성에 실패했습니다."); + } + + return response.data.data as JsonDataFlowDiagram; + } catch (error) { + console.error("JSON 관계도 생성 오류:", error); + throw error; + } + } + + /** + * JSON 기반 관계도 수정 + */ + static async updateJsonDataFlowDiagram( + diagramId: number, + request: Partial, + companyCode: string = "*", + userId: string = "SYSTEM", + ): Promise { + try { + const params = new URLSearchParams({ + companyCode: companyCode, + }); + + const requestWithUserInfo = { + ...request, + updated_by: userId, + }; + + const response = await apiClient.put>( + `/dataflow-diagrams/${diagramId}?${params}`, + requestWithUserInfo, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "관계도 수정에 실패했습니다."); + } + + return response.data.data as JsonDataFlowDiagram; + } catch (error) { + console.error("JSON 관계도 수정 오류:", error); + throw error; + } + } + + /** + * JSON 기반 관계도 삭제 + */ + static async deleteJsonDataFlowDiagram(diagramId: number, companyCode: string = "*"): Promise { + try { + const params = new URLSearchParams({ + companyCode: companyCode, + }); + + const response = await apiClient.delete>(`/dataflow-diagrams/${diagramId}?${params}`); + + if (!response.data.success) { + throw new Error(response.data.message || "관계도 삭제에 실패했습니다."); + } + } catch (error) { + console.error("JSON 관계도 삭제 오류:", error); + throw error; + } + } + + /** + * JSON 기반 관계도 복제 + */ + static async copyJsonDataFlowDiagram( + diagramId: number, + companyCode: string = "*", + newName?: string, + userId: string = "SYSTEM", + ): Promise { + try { + const requestData = { + companyCode: companyCode, + userId: userId, + ...(newName && { new_name: newName }), + }; + + const response = await apiClient.post>( + `/dataflow-diagrams/${diagramId}/copy`, + requestData, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "관계도 복제에 실패했습니다."); + } + + return response.data.data as JsonDataFlowDiagram; + } catch (error) { + console.error("JSON 관계도 복제 오류:", error); + throw error; + } + } } diff --git a/src/controllers/dataflowDiagramController.ts b/src/controllers/dataflowDiagramController.ts new file mode 100644 index 00000000..3f926f54 --- /dev/null +++ b/src/controllers/dataflowDiagramController.ts @@ -0,0 +1,325 @@ +import { Request, Response } from "express"; +import { DataflowDiagramService } from "../services/dataflowDiagramService"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + companyCode: string; + }; +} + +const dataflowDiagramService = new DataflowDiagramService(); + +/** + * 관계도 목록 조회 + * GET /api/dataflow-diagrams + */ +export const getDataflowDiagrams = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { page = 1, size = 20, searchTerm = "" } = req.query; + const companyCode = req.user?.companyCode; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const result = await dataflowDiagramService.getDataflowDiagrams( + companyCode, + parseInt(page as string), + parseInt(size as string), + searchTerm as string + ); + + res.json({ + success: true, + message: "관계도 목록을 성공적으로 조회했습니다.", + data: result, + }); + } catch (error) { + console.error("관계도 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "관계도 목록 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 특정 관계도 조회 + * GET /api/dataflow-diagrams/:diagramId + */ +export const getDataflowDiagramById = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { diagramId } = req.params; + const companyCode = req.user?.companyCode; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const diagram = await dataflowDiagramService.getDataflowDiagramById( + parseInt(diagramId), + companyCode + ); + + if (!diagram) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "관계도를 성공적으로 조회했습니다.", + data: diagram, + }); + } catch (error) { + console.error("관계도 조회 실패:", error); + res.status(500).json({ + success: false, + message: "관계도 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 관계도 생성 + * POST /api/dataflow-diagrams + */ +export const createDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { diagram_name, relationships } = req.body; + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + if (!diagram_name || !relationships) { + return res.status(400).json({ + success: false, + message: "관계도 이름과 관계 정보는 필수입니다.", + }); + } + + const diagram = await dataflowDiagramService.createDataflowDiagram({ + diagram_name, + relationships, + company_code: companyCode, + created_by: userId, + }); + + res.status(201).json({ + success: true, + message: "관계도가 성공적으로 생성되었습니다.", + data: diagram, + }); + } catch (error: any) { + console.error("관계도 생성 실패:", error); + + // 중복 이름 오류 처리 + if ( + error.code === "P2002" && + error.meta?.target?.includes("diagram_name") + ) { + return res.status(409).json({ + success: false, + message: "같은 이름의 관계도가 이미 존재합니다.", + }); + } + + res.status(500).json({ + success: false, + message: "관계도 생성 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 관계도 수정 + * PUT /api/dataflow-diagrams/:diagramId + */ +export const updateDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { diagramId } = req.params; + const { diagram_name, relationships } = req.body; + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const diagram = await dataflowDiagramService.updateDataflowDiagram( + parseInt(diagramId), + companyCode, + { + diagram_name, + relationships, + updated_by: userId, + } + ); + + if (!diagram) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "관계도가 성공적으로 수정되었습니다.", + data: diagram, + }); + } catch (error: any) { + console.error("관계도 수정 실패:", error); + + // 중복 이름 오류 처리 + if ( + error.code === "P2002" && + error.meta?.target?.includes("diagram_name") + ) { + return res.status(409).json({ + success: false, + message: "같은 이름의 관계도가 이미 존재합니다.", + }); + } + + res.status(500).json({ + success: false, + message: "관계도 수정 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 관계도 삭제 + * DELETE /api/dataflow-diagrams/:diagramId + */ +export const deleteDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { diagramId } = req.params; + const companyCode = req.user?.companyCode; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const deleted = await dataflowDiagramService.deleteDataflowDiagram( + parseInt(diagramId), + companyCode + ); + + if (!deleted) { + return res.status(404).json({ + success: false, + message: "관계도를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "관계도가 성공적으로 삭제되었습니다.", + }); + } catch (error) { + console.error("관계도 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "관계도 삭제 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 관계도 복제 + * POST /api/dataflow-diagrams/:diagramId/copy + */ +export const copyDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { diagramId } = req.params; + const { new_name } = req.body; // 선택적 새 이름 + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const copiedDiagram = await dataflowDiagramService.copyDataflowDiagram( + parseInt(diagramId), + companyCode, + new_name, + userId + ); + + if (!copiedDiagram) { + return res.status(404).json({ + success: false, + message: "원본 관계도를 찾을 수 없습니다.", + }); + } + + res.status(201).json({ + success: true, + message: "관계도가 성공적으로 복제되었습니다.", + data: copiedDiagram, + }); + } catch (error: any) { + console.error("관계도 복제 실패:", error); + + // 중복 이름 오류 처리 + if ( + error.code === "P2002" && + error.meta?.target?.includes("diagram_name") + ) { + return res.status(409).json({ + success: false, + message: "같은 이름의 관계도가 이미 존재합니다.", + }); + } + + res.status(500).json({ + success: false, + message: "관계도 복제 중 오류가 발생했습니다.", + }); + } +}; diff --git a/src/routes/dataflowDiagramRoutes.ts b/src/routes/dataflowDiagramRoutes.ts new file mode 100644 index 00000000..d7efb901 --- /dev/null +++ b/src/routes/dataflowDiagramRoutes.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { + getDataflowDiagrams, + getDataflowDiagramById, + createDataflowDiagram, + updateDataflowDiagram, + deleteDataflowDiagram, + copyDataflowDiagram, +} from "../controllers/dataflowDiagramController"; + +const router = Router(); + +/** + * 데이터플로우 관계도 관리 API + * + * 모든 엔드포인트는 인증이 필요하며, 회사 코드로 데이터를 격리합니다. + */ + +// 관계도 목록 조회 (페이지네이션, 검색 지원) +// GET /api/dataflow-diagrams?page=1&size=20&searchTerm=검색어 +router.get("/", getDataflowDiagrams); + +// 특정 관계도 조회 +// GET /api/dataflow-diagrams/:diagramId +router.get("/:diagramId", getDataflowDiagramById); + +// 관계도 생성 +// POST /api/dataflow-diagrams +// Body: { diagram_name: string, relationships: object } +router.post("/", createDataflowDiagram); + +// 관계도 수정 +// PUT /api/dataflow-diagrams/:diagramId +// Body: { diagram_name?: string, relationships?: object } +router.put("/:diagramId", updateDataflowDiagram); + +// 관계도 삭제 +// DELETE /api/dataflow-diagrams/:diagramId +router.delete("/:diagramId", deleteDataflowDiagram); + +// 관계도 복제 +// POST /api/dataflow-diagrams/:diagramId/copy +// Body: { new_name?: string } (선택적) +router.post("/:diagramId/copy", copyDataflowDiagram); + +export default router; diff --git a/src/services/dataflowDiagramService.ts b/src/services/dataflowDiagramService.ts new file mode 100644 index 00000000..2958353c --- /dev/null +++ b/src/services/dataflowDiagramService.ts @@ -0,0 +1,206 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export interface DataflowDiagram { + diagram_id: number; + diagram_name: string; + relationships: any; // JSON 타입 + company_code: string; + created_at?: Date; + updated_at?: Date; + created_by?: string; + updated_by?: string; +} + +export interface CreateDataflowDiagramData { + diagram_name: string; + relationships: any; + company_code: string; + created_by?: string; +} + +export interface UpdateDataflowDiagramData { + diagram_name?: string; + relationships?: any; + updated_by?: string; +} + +export class DataflowDiagramService { + /** + * 관계도 목록 조회 (페이지네이션) + */ + async getDataflowDiagrams( + companyCode: string, + page: number = 1, + size: number = 20, + searchTerm: string = "" + ) { + const skip = (page - 1) * size; + + const whereClause: any = { + company_code: companyCode, + }; + + if (searchTerm) { + whereClause.diagram_name = { + contains: searchTerm, + mode: "insensitive", + }; + } + + const [diagrams, total] = await Promise.all([ + prisma.dataflow_diagrams.findMany({ + where: whereClause, + orderBy: { created_at: "desc" }, + skip, + take: size, + }), + prisma.dataflow_diagrams.count({ + where: whereClause, + }), + ]); + + return { + diagrams, + pagination: { + page, + size, + total, + totalPages: Math.ceil(total / size), + }, + }; + } + + /** + * 특정 관계도 조회 + */ + async getDataflowDiagramById( + diagramId: number, + companyCode: string + ): Promise { + return await prisma.dataflow_diagrams.findFirst({ + where: { + diagram_id: diagramId, + company_code: companyCode, + }, + }); + } + + /** + * 관계도 생성 + */ + async createDataflowDiagram( + data: CreateDataflowDiagramData + ): Promise { + return await prisma.dataflow_diagrams.create({ + data: { + diagram_name: data.diagram_name, + relationships: data.relationships, + company_code: data.company_code, + created_by: data.created_by, + }, + }); + } + + /** + * 관계도 수정 + */ + async updateDataflowDiagram( + diagramId: number, + companyCode: string, + data: UpdateDataflowDiagramData + ): Promise { + // 먼저 해당 관계도가 존재하는지 확인 + const existingDiagram = await this.getDataflowDiagramById( + diagramId, + companyCode + ); + if (!existingDiagram) { + return null; + } + + return await prisma.dataflow_diagrams.update({ + where: { + diagram_id: diagramId, + }, + data: { + ...(data.diagram_name && { diagram_name: data.diagram_name }), + ...(data.relationships && { relationships: data.relationships }), + ...(data.updated_by && { updated_by: data.updated_by }), + updated_at: new Date(), + }, + }); + } + + /** + * 관계도 삭제 + */ + async deleteDataflowDiagram( + diagramId: number, + companyCode: string + ): Promise { + // 먼저 해당 관계도가 존재하는지 확인 + const existingDiagram = await this.getDataflowDiagramById( + diagramId, + companyCode + ); + if (!existingDiagram) { + return false; + } + + await prisma.dataflow_diagrams.delete({ + where: { + diagram_id: diagramId, + }, + }); + + return true; + } + + /** + * 관계도 복제 + */ + async copyDataflowDiagram( + diagramId: number, + companyCode: string, + newName?: string, + createdBy?: string + ): Promise { + const originalDiagram = await this.getDataflowDiagramById( + diagramId, + companyCode + ); + if (!originalDiagram) { + return null; + } + + // 복제본 이름 생성 + let copyName = newName; + if (!copyName) { + // "(1)", "(2)" 형식으로 이름 생성 + const baseName = originalDiagram.diagram_name; + let counter = 1; + + while (true) { + copyName = `${baseName} (${counter})`; + const existing = await prisma.dataflow_diagrams.findFirst({ + where: { + company_code: companyCode, + diagram_name: copyName, + }, + }); + + if (!existing) break; + counter++; + } + } + + return await this.createDataflowDiagram({ + diagram_name: copyName, + relationships: originalDiagram.relationships, + company_code: companyCode, + created_by: createdBy, + }); + } +}