Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-09-12 16:49:42 +09:00
commit c4bf8b727a
29 changed files with 8546 additions and 50 deletions

View File

@ -5211,53 +5211,6 @@ model grid_standards {
@@index([company_code], map: "idx_grid_standards_company")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model data_relationship_bridge {
bridge_id Int @id @default(autoincrement())
relationship_id Int?
from_table_name String @db.VarChar(100)
from_column_name String @db.VarChar(100)
from_key_value String? @db.VarChar(500)
from_record_id String? @db.VarChar(100)
to_table_name String @db.VarChar(100)
to_column_name String @db.VarChar(100)
to_key_value String? @db.VarChar(500)
to_record_id String? @db.VarChar(100)
connection_type String @db.VarChar(20)
company_code String @db.VarChar(50)
created_at DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_at DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
is_active String? @default("Y") @db.Char(1)
bridge_data Json?
table_relationships table_relationships? @relation(fields: [relationship_id], references: [relationship_id], onDelete: NoAction, onUpdate: NoAction)
@@index([company_code, is_active], map: "idx_data_bridge_company_active")
@@index([connection_type], map: "idx_data_bridge_connection_type")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model table_relationships {
relationship_id Int @id @default(autoincrement())
relationship_name String @db.VarChar(200)
from_table_name String @db.VarChar(100)
from_column_name String @db.VarChar(100)
to_table_name String @db.VarChar(100)
to_column_name String @db.VarChar(100)
relationship_type String @db.VarChar(20)
connection_type String @db.VarChar(20)
company_code String @db.VarChar(50)
settings Json?
is_active String? @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
data_relationship_bridge data_relationship_bridge[]
@@index([to_table_name], map: "idx_table_relationships_to_table")
}
// 템플릿 표준 관리 테이블
model template_standards {
@ -5333,3 +5286,88 @@ model layout_standards {
@@index([category], map: "idx_layout_standards_category")
@@index([company_code], map: "idx_layout_standards_company")
}
model table_relationships {
relationship_id Int @id @default(autoincrement())
diagram_id Int // 관계도 그룹 식별자
relationship_name String @db.VarChar(200)
from_table_name String @db.VarChar(100)
from_column_name String @db.VarChar(100)
to_table_name String @db.VarChar(100)
to_column_name String @db.VarChar(100)
relationship_type String @db.VarChar(20) // 'one-to-one', 'one-to-many', 'many-to-one', 'many-to-many'
connection_type String @db.VarChar(20) // 'simple-key', 'data-save', 'external-call'
company_code String @db.VarChar(50)
settings Json? // 연결 종류별 세부 설정
is_active String? @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
// 역참조 관계
bridges data_relationship_bridge[]
@@index([company_code], map: "idx_table_relationships_company_code")
@@index([diagram_id], map: "idx_table_relationships_diagram_id")
@@index([from_table_name], map: "idx_table_relationships_from_table")
@@index([to_table_name], map: "idx_table_relationships_to_table")
@@index([company_code, diagram_id], map: "idx_table_relationships_company_diagram")
}
// 테이블 간 데이터 관계 중계 테이블 - 실제 데이터 연결 정보 저장
model data_relationship_bridge {
bridge_id Int @id @default(autoincrement())
relationship_id Int
// 소스 테이블 정보
from_table_name String @db.VarChar(100)
from_column_name String @db.VarChar(100)
// 타겟 테이블 정보
to_table_name String @db.VarChar(100)
to_column_name String @db.VarChar(100)
// 메타데이터
connection_type String @db.VarChar(20) // 'simple-key', 'data-save', 'external-call'
company_code String @db.VarChar(50)
created_at DateTime @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_at DateTime @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
is_active String @default("Y") @db.Char(1)
// 추가 설정 (JSON)
bridge_data Json? // 연결 종류별 추가 데이터
// 관계 설정
relationship table_relationships @relation(fields: [relationship_id], references: [relationship_id], onDelete: Cascade)
@@index([relationship_id], map: "idx_data_bridge_relationship")
@@index([from_table_name], map: "idx_data_bridge_from_table")
@@index([to_table_name], map: "idx_data_bridge_to_table")
@@index([company_code], map: "idx_data_bridge_company")
@@index([is_active], map: "idx_data_bridge_active")
@@index([connection_type], map: "idx_data_bridge_connection_type")
@@index([from_table_name, from_column_name], map: "idx_data_bridge_from_lookup")
@@index([to_table_name, to_column_name], map: "idx_data_bridge_to_lookup")
@@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으로 저장
node_positions 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")
}

View File

@ -19,6 +19,8 @@ import commonCodeRoutes from "./routes/commonCodeRoutes";
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 +110,8 @@ app.use("/api/common-codes", commonCodeRoutes);
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);

View File

@ -0,0 +1,940 @@
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common";
import { DataflowService } from "../services/dataflowService";
/**
*
*/
export async function createTableRelationship(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 테이블 관계 생성 시작 ===");
const {
diagramId,
relationshipName,
fromTableName,
fromColumnName,
toTableName,
toColumnName,
relationshipType,
connectionType,
settings,
} = req.body;
// 필수 필드 검증
if (
!relationshipName ||
!fromTableName ||
!fromColumnName ||
!toTableName ||
!toColumnName
) {
const response: ApiResponse<null> = {
success: false,
message: "필수 필드가 누락되었습니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details:
"relationshipName, fromTableName, fromColumnName, toTableName, toColumnName는 필수입니다.",
},
};
res.status(400).json(response);
return;
}
// 사용자 정보에서 회사 코드 가져오기
const companyCode = (req.user as any)?.company_code || "*";
const userId = (req.user as any)?.userId || "system";
const dataflowService = new DataflowService();
const relationship = await dataflowService.createTableRelationship({
diagramId: diagramId ? parseInt(diagramId) : undefined,
relationshipName,
fromTableName,
fromColumnName,
toTableName,
toColumnName,
relationshipType: relationshipType || "one-to-one",
connectionType: connectionType || "simple-key",
companyCode,
settings: settings || {},
createdBy: userId,
});
logger.info(`테이블 관계 생성 완료: ${relationship.relationship_id}`);
const response: ApiResponse<any> = {
success: true,
message: "테이블 관계가 성공적으로 생성되었습니다.",
data: relationship,
};
res.status(201).json(response);
} catch (error) {
logger.error("테이블 관계 생성 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계 생성 중 오류가 발생했습니다.",
error: {
code: "TABLE_RELATIONSHIP_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* ()
*/
export async function getTableRelationships(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 테이블 관계 목록 조회 시작 ===");
// 사용자 정보에서 회사 코드 가져오기
const companyCode = (req.user as any)?.company_code || "*";
const dataflowService = new DataflowService();
const relationships =
await dataflowService.getTableRelationships(companyCode);
logger.info(`테이블 관계 목록 조회 완료: ${relationships.length}`);
const response: ApiResponse<any[]> = {
success: true,
message: "테이블 관계 목록을 성공적으로 조회했습니다.",
data: relationships,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 관계 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계 목록 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_RELATIONSHIPS_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function updateTableRelationship(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 테이블 관계 수정 시작 ===");
const { relationshipId } = req.params;
const updateData = req.body;
if (!relationshipId) {
const response: ApiResponse<null> = {
success: false,
message: "관계 ID가 필요합니다.",
error: {
code: "MISSING_RELATIONSHIP_ID",
details: "relationshipId 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
// 사용자 정보에서 회사 코드와 사용자 ID 가져오기
const companyCode = (req.user as any)?.company_code || "*";
const userId = (req.user as any)?.userId || "system";
const dataflowService = new DataflowService();
const relationship = await dataflowService.updateTableRelationship(
parseInt(relationshipId),
{
...updateData,
updatedBy: userId,
},
companyCode
);
if (!relationship) {
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계를 찾을 수 없습니다.",
error: {
code: "TABLE_RELATIONSHIP_NOT_FOUND",
details: `관계 ID ${relationshipId}를 찾을 수 없습니다.`,
},
};
res.status(404).json(response);
return;
}
logger.info(`테이블 관계 수정 완료: ${relationshipId}`);
const response: ApiResponse<any> = {
success: true,
message: "테이블 관계가 성공적으로 수정되었습니다.",
data: relationship,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 관계 수정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계 수정 중 오류가 발생했습니다.",
error: {
code: "TABLE_RELATIONSHIP_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function deleteTableRelationship(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 테이블 관계 삭제 시작 ===");
const { relationshipId } = req.params;
if (!relationshipId) {
const response: ApiResponse<null> = {
success: false,
message: "관계 ID가 필요합니다.",
error: {
code: "MISSING_RELATIONSHIP_ID",
details: "relationshipId 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
// 사용자 정보에서 회사 코드 가져오기
const companyCode = (req.user as any)?.company_code || "*";
const dataflowService = new DataflowService();
const success = await dataflowService.deleteTableRelationship(
parseInt(relationshipId),
companyCode
);
if (!success) {
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계를 찾을 수 없습니다.",
error: {
code: "TABLE_RELATIONSHIP_NOT_FOUND",
details: `관계 ID ${relationshipId}를 찾을 수 없습니다.`,
},
};
res.status(404).json(response);
return;
}
logger.info(`테이블 관계 삭제 완료: ${relationshipId}`);
const response: ApiResponse<null> = {
success: true,
message: "테이블 관계가 성공적으로 삭제되었습니다.",
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 관계 삭제 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계 삭제 중 오류가 발생했습니다.",
error: {
code: "TABLE_RELATIONSHIP_DELETE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function getTableRelationship(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 테이블 관계 조회 시작 ===");
const { relationshipId } = req.params;
if (!relationshipId) {
const response: ApiResponse<null> = {
success: false,
message: "관계 ID가 필요합니다.",
error: {
code: "MISSING_RELATIONSHIP_ID",
details: "relationshipId 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
// 사용자 정보에서 회사 코드 가져오기
const companyCode = (req.user as any)?.company_code || "*";
const dataflowService = new DataflowService();
const relationship = await dataflowService.getTableRelationship(
parseInt(relationshipId),
companyCode
);
if (!relationship) {
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계를 찾을 수 없습니다.",
error: {
code: "TABLE_RELATIONSHIP_NOT_FOUND",
details: `관계 ID ${relationshipId}를 찾을 수 없습니다.`,
},
};
res.status(404).json(response);
return;
}
logger.info(`테이블 관계 조회 완료: ${relationshipId}`);
const response: ApiResponse<any> = {
success: true,
message: "테이블 관계를 성공적으로 조회했습니다.",
data: relationship,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 관계 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_RELATIONSHIP_GET_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
// ==================== 데이터 연결 관리 API ====================
/**
*
*/
export async function createDataLink(
req: Request,
res: Response
): Promise<void> {
try {
const {
relationshipId,
fromTableName,
fromColumnName,
toTableName,
toColumnName,
connectionType,
bridgeData,
} = req.body;
// 필수 필드 검증
if (
!relationshipId ||
!fromTableName ||
!fromColumnName ||
!toTableName ||
!toColumnName ||
!connectionType
) {
const response: ApiResponse<null> = {
success: false,
message: "필수 필드가 누락되었습니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details:
"필수 필드: relationshipId, fromTableName, fromColumnName, toTableName, toColumnName, connectionType",
},
};
res.status(400).json(response);
return;
}
const userInfo = (req as any).user;
const companyCode = userInfo?.company_code || "*";
const createdBy = userInfo?.userId || "system";
const dataflowService = new DataflowService();
const bridge = await dataflowService.createDataLink({
relationshipId,
fromTableName,
fromColumnName,
toTableName,
toColumnName,
connectionType,
companyCode,
bridgeData,
createdBy,
});
const response: ApiResponse<typeof bridge> = {
success: true,
message: "데이터 연결이 성공적으로 생성되었습니다.",
data: bridge,
};
res.status(201).json(response);
} catch (error) {
logger.error("데이터 연결 생성 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "데이터 연결 생성 중 오류가 발생했습니다.",
error: {
code: "DATA_LINK_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function getLinkedDataByRelationship(
req: Request,
res: Response
): Promise<void> {
try {
const relationshipId = parseInt(req.params.relationshipId);
if (!relationshipId || isNaN(relationshipId)) {
const response: ApiResponse<null> = {
success: false,
message: "유효하지 않은 관계 ID입니다.",
error: {
code: "INVALID_RELATIONSHIP_ID",
details: "관계 ID는 숫자여야 합니다.",
},
};
res.status(400).json(response);
return;
}
const userInfo = (req as any).user;
const companyCode = userInfo?.company_code || "*";
const dataflowService = new DataflowService();
const linkedData = await dataflowService.getLinkedDataByRelationship(
relationshipId,
companyCode
);
const response: ApiResponse<typeof linkedData> = {
success: true,
message: "연결된 데이터를 성공적으로 조회했습니다.",
data: linkedData,
};
res.status(200).json(response);
} catch (error) {
logger.error("연결된 데이터 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "연결된 데이터 조회 중 오류가 발생했습니다.",
error: {
code: "LINKED_DATA_GET_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function deleteDataLink(
req: Request,
res: Response
): Promise<void> {
try {
const bridgeId = parseInt(req.params.bridgeId);
if (!bridgeId || isNaN(bridgeId)) {
const response: ApiResponse<null> = {
success: false,
message: "유효하지 않은 Bridge ID입니다.",
error: {
code: "INVALID_BRIDGE_ID",
details: "Bridge ID는 숫자여야 합니다.",
},
};
res.status(400).json(response);
return;
}
const userInfo = (req as any).user;
const companyCode = userInfo?.company_code || "*";
const deletedBy = userInfo?.userId || "system";
const dataflowService = new DataflowService();
await dataflowService.deleteDataLink(bridgeId, companyCode, deletedBy);
const response: ApiResponse<null> = {
success: true,
message: "데이터 연결이 성공적으로 삭제되었습니다.",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("데이터 연결 삭제 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "데이터 연결 삭제 중 오류가 발생했습니다.",
error: {
code: "DATA_LINK_DELETE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
// ==================== 테이블 데이터 조회 ====================
/**
* ()
* GET /api/dataflow/table-data/:tableName
*/
export async function getTableData(req: Request, res: Response): Promise<void> {
try {
const { tableName } = req.params;
const {
page = "1",
limit = "10",
search = "",
searchColumn = "",
} = req.query;
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명을 제공해주세요.",
},
};
res.status(400).json(response);
return;
}
const pageNum = parseInt(page as string) || 1;
const limitNum = parseInt(limit as string) || 10;
const userInfo = (req as any).user;
const companyCode = userInfo?.company_code || "*";
const dataflowService = new DataflowService();
const result = await dataflowService.getTableData(
tableName,
pageNum,
limitNum,
search as string,
searchColumn as string,
companyCode
);
const response: ApiResponse<typeof result> = {
success: true,
message: "테이블 데이터를 성공적으로 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_DATA_GET_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* ( )
*/
export async function getDataFlowDiagrams(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
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<typeof result> = {
success: true,
message: "관계도 목록을 성공적으로 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("관계도 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
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<void> {
try {
logger.info("=== 관계도 관계 조회 시작 ===");
const { diagramName } = req.params;
if (!diagramName) {
const response: ApiResponse<null> = {
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<any[]> = {
success: true,
message: "관계도 관계를 성공적으로 조회했습니다.",
data: relationships,
};
res.status(200).json(response);
} catch (error) {
logger.error("관계도 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "관계도 관계 조회 중 오류가 발생했습니다.",
error: {
code: "DIAGRAM_RELATIONSHIPS_GET_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function copyDiagram(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { diagramName } = req.params;
const companyCode = (req.user as any)?.company_code || "*";
if (!diagramName) {
const response: ApiResponse<null> = {
success: false,
message: "관계도 이름이 필요합니다.",
error: {
code: "MISSING_DIAGRAM_NAME",
details: "diagramName 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const dataflowService = new DataflowService();
const newDiagramName = await dataflowService.copyDiagram(
companyCode,
decodeURIComponent(diagramName)
);
const response: ApiResponse<{ newDiagramName: string }> = {
success: true,
message: "관계도가 성공적으로 복사되었습니다.",
data: { newDiagramName },
};
res.status(200).json(response);
} catch (error) {
logger.error("관계도 복사 실패:", error);
const response: ApiResponse<null> = {
success: false,
message: "관계도 복사에 실패했습니다.",
error: {
code: "DIAGRAM_COPY_FAILED",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function deleteDiagram(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { diagramName } = req.params;
const companyCode = (req.user as any)?.company_code || "*";
if (!diagramName) {
const response: ApiResponse<null> = {
success: false,
message: "관계도 이름이 필요합니다.",
error: {
code: "MISSING_DIAGRAM_NAME",
details: "diagramName 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const dataflowService = new DataflowService();
const deletedCount = await dataflowService.deleteDiagram(
companyCode,
decodeURIComponent(diagramName)
);
const response: ApiResponse<{ deletedCount: number }> = {
success: true,
message: "관계도가 성공적으로 삭제되었습니다.",
data: { deletedCount },
};
res.status(200).json(response);
} catch (error) {
logger.error("관계도 삭제 실패:", error);
const response: ApiResponse<null> = {
success: false,
message: "관계도 삭제에 실패했습니다.",
error: {
code: "DIAGRAM_DELETE_FAILED",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
};
res.status(500).json(response);
}
}
/**
* diagram_id로
*/
export async function getDiagramRelationshipsByDiagramId(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { diagramId } = req.params;
const companyCode = (req.user as any)?.company_code || "*";
if (!diagramId) {
const response: ApiResponse<null> = {
success: false,
message: "관계도 ID가 필요합니다.",
error: {
code: "MISSING_DIAGRAM_ID",
details: "diagramId 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const dataflowService = new DataflowService();
const relationships =
await dataflowService.getDiagramRelationshipsByDiagramId(
companyCode,
parseInt(diagramId)
);
const response: ApiResponse<any[]> = {
success: true,
message: "관계도 관계 목록을 성공적으로 조회했습니다.",
data: relationships,
};
res.status(200).json(response);
} catch (error) {
logger.error("관계도 관계 조회 실패:", error);
const response: ApiResponse<null> = {
success: false,
message: "관계도 관계 조회에 실패했습니다.",
error: {
code: "DIAGRAM_RELATIONSHIPS_FETCH_FAILED",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
};
res.status(500).json(response);
}
}
/**
* relationship_id로 ( )
*/
export async function getDiagramRelationshipsByRelationshipId(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { relationshipId } = req.params;
const companyCode = (req.user as any)?.company_code || "*";
if (!relationshipId) {
const response: ApiResponse<null> = {
success: false,
message: "관계 ID가 필요합니다.",
error: {
code: "MISSING_RELATIONSHIP_ID",
details: "relationshipId 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const dataflowService = new DataflowService();
const relationships =
await dataflowService.getDiagramRelationshipsByRelationshipId(
companyCode,
parseInt(relationshipId)
);
const response: ApiResponse<any[]> = {
success: true,
message: "관계도 관계 목록을 성공적으로 조회했습니다.",
data: relationships,
};
res.status(200).json(response);
} catch (error) {
logger.error("관계도 관계 조회 실패:", error);
const response: ApiResponse<null> = {
success: false,
message: "관계도 관계 조회에 실패했습니다.",
error: {
code: "DIAGRAM_RELATIONSHIPS_FETCH_FAILED",
details:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
},
};
res.status(500).json(response);
}
}

View File

@ -0,0 +1,299 @@
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,
node_positions,
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,
node_positions,
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",
});
}
};

View File

@ -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;

View File

@ -0,0 +1,131 @@
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
createTableRelationship,
getTableRelationships,
getTableRelationship,
updateTableRelationship,
deleteTableRelationship,
createDataLink,
getLinkedDataByRelationship,
deleteDataLink,
getTableData,
getDataFlowDiagrams,
getDiagramRelationships,
getDiagramRelationshipsByDiagramId,
getDiagramRelationshipsByRelationshipId,
copyDiagram,
deleteDiagram,
} from "../controllers/dataflowController";
const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
*
* POST /api/dataflow/table-relationships
*/
router.post("/table-relationships", createTableRelationship);
/**
* ()
* GET /api/dataflow/table-relationships
*/
router.get("/table-relationships", getTableRelationships);
/**
*
* GET /api/dataflow/table-relationships/:relationshipId
*/
router.get("/table-relationships/:relationshipId", getTableRelationship);
/**
*
* PUT /api/dataflow/table-relationships/:relationshipId
*/
router.put("/table-relationships/:relationshipId", updateTableRelationship);
/**
*
* DELETE /api/dataflow/table-relationships/:relationshipId
*/
router.delete("/table-relationships/:relationshipId", deleteTableRelationship);
// ==================== 데이터 연결 관리 라우트 ====================
/**
*
* POST /api/dataflow/data-links
*/
router.post("/data-links", createDataLink);
/**
*
* GET /api/dataflow/data-links/relationship/:relationshipId
*/
router.get(
"/data-links/relationship/:relationshipId",
getLinkedDataByRelationship
);
/**
*
* DELETE /api/dataflow/data-links/:bridgeId
*/
router.delete("/data-links/:bridgeId", deleteDataLink);
// ==================== 테이블 데이터 조회 라우트 ====================
/**
*
* GET /api/dataflow/table-data/:tableName
*/
router.get("/table-data/:tableName", getTableData);
// ==================== 관계도 관리 라우트 ====================
/**
* ( )
* GET /api/dataflow/diagrams
*/
router.get("/diagrams", getDataFlowDiagrams);
/**
* (diagram_id로)
* GET /api/dataflow/diagrams/:diagramId/relationships
*/
router.get(
"/diagrams/:diagramId/relationships",
getDiagramRelationshipsByDiagramId
);
/**
* (diagramName으로 - )
* GET /api/dataflow/diagrams/name/:diagramName/relationships
*/
router.get(
"/diagrams/name/:diagramName/relationships",
getDiagramRelationships
);
/**
*
* POST /api/dataflow/diagrams/:diagramName/copy
*/
router.post("/diagrams/:diagramName/copy", copyDiagram);
/**
*
* DELETE /api/dataflow/diagrams/:diagramName
*/
router.delete("/diagrams/:diagramName", deleteDiagram);
// relationship_id로 관계도 관계 조회 (하위 호환성)
router.get(
"/relationships/:relationshipId/diagram",
getDiagramRelationshipsByRelationshipId
);
export default router;

View File

@ -0,0 +1,313 @@
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
// 타입 정의
interface CreateDataflowDiagramData {
diagram_name: string;
relationships: any; // JSON 데이터
node_positions?: any; // JSON 데이터 (노드 위치 정보)
company_code: string;
created_by: string;
updated_by: string;
}
interface UpdateDataflowDiagramData {
diagram_name?: string;
relationships?: any; // JSON 데이터
node_positions?: 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,
node_positions: data.node_positions || null,
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 }),
...(data.node_positions !== undefined && {
node_positions: data.node_positions,
}),
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;
}
};

File diff suppressed because it is too large Load Diff

View File

@ -123,12 +123,14 @@ export class TableManagementService {
SELECT
c.column_name as "columnName",
COALESCE(cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType",
c.data_type as "dbType",
COALESCE(cl.web_type, 'text') as "webType",
COALESCE(cl.input_type, 'direct') as "inputType",
COALESCE(cl.detail_settings, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision",
@ -141,6 +143,15 @@ export class TableManagementService {
cl.is_visible as "isVisible"
FROM information_schema.columns c
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
LEFT JOIN (
SELECT kcu.column_name, kcu.table_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_name = ${tableName}
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
WHERE c.table_name = ${tableName}
ORDER BY c.ordinal_position
LIMIT ${size} OFFSET ${offset}

View File

@ -10,12 +10,14 @@ export interface TableInfo {
export interface ColumnTypeInfo {
columnName: string;
displayName: string;
dataType: string; // 추가: 데이터 타입 (dbType과 동일하지만 별도 필드)
dbType: string;
webType: string;
inputType?: "direct" | "auto";
detailSettings: string;
description: string;
isNullable: string;
isPrimaryKey: boolean; // 추가: 기본키 여부
defaultValue?: string;
maxLength?: number;
numericPrecision?: number;

View File

@ -0,0 +1,724 @@
# 테이블 간 데이터 관계 설정 시스템 설계
## 📋 목차
1. [시스템 개요](#시스템-개요)
2. [핵심 기능](#핵심-기능)
3. [데이터베이스 설계](#데이터베이스-설계)
4. [프론트엔드 설계](#프론트엔드-설계)
5. [API 설계](#api-설계)
6. [사용 시나리오](#사용-시나리오)
7. [구현 계획](#구현-계획)
## 🎯 시스템 개요
### 테이블 간 데이터 관계 설정 시스템이란?
테이블 간 데이터 관계 설정 시스템은 회사별로 데이터베이스 테이블들 간의 데이터 관계를 시각적으로 설계하고 관리할 수 있는 시스템입니다. React Flow 라이브러리를 활용하여 직관적인 노드 기반 인터페이스로 1:1, 1:N, N:1, N:N 관계를 지원하며, 다양한 연결 방식과 종류로 복합적인 데이터 관계를 설계할 수 있습니다.
### 주요 특징
- **React Flow 기반 인터페이스**: 직관적인 노드와 엣지 기반 시각적 설계
- **회사별 관계 관리**: 사용자 회사 코드에 따른 테이블 관계 접근 제어
- **시각적 관계 설계**: 드래그앤드롭으로 테이블 노드 배치 및 컬럼 간 연결
- **다양한 관계 타입**: 1:1, 1:N, N:1, N:N 관계 지원
- **연결 종류별 세부 설정**: 단순 키값, 데이터 저장, 외부 호출
- **실시간 시뮬레이션**: 설계한 관계의 데이터 흐름 시뮬레이션
- **중계 테이블 자동 생성**: N:N 관계에서 중계 테이블 자동 생성
- **인터랙티브 캔버스**: 줌, 팬, 미니맵 등 React Flow의 고급 기능 활용
### 지원하는 관계 타입
- **1:1 (One to One)**: 한 테이블의 컬럼과 다른 테이블의 컬럼이 1:1로 연결
- **1:N (One to Many)**: 한 테이블의 컬럼이 여러 테이블의 컬럼과 연결
- **N:1 (Many to One)**: 여러 테이블의 컬럼이 한 테이블의 컬럼과 연결
- **N:N (Many to Many)**: 여러 테이블의 컬럼이 여러 테이블의 컬럼과 연결
### 지원하는 연결 종류
- **단순 키값 연결**: 중계 테이블을 통한 참조 관계
- **데이터 저장**: 컬럼 매핑을 통한 데이터 저장
- **외부 호출**: API, 이메일, 웹훅 등을 통한 외부 시스템 연동
## 🚀 핵심 기능
### 1. React Flow 기반 테이블 노드 관리
- **테이블 추가**: 데이터베이스 테이블 목록에서 관계를 설정할 테이블들을 React Flow 캔버스에 추가
- **테이블 배치**: 드래그앤드롭으로 테이블 노드를 원하는 위치에 배치
- **테이블 이동**: React Flow의 내장 기능으로 테이블 노드 자유롭게 이동
- **노드 선택**: 단일 또는 다중 노드 선택 지원
- **자동 정렬**: React Flow의 레이아웃 알고리즘을 활용한 자동 정렬
### 2. React Flow 기반 컬럼 간 연결 설정
- **컬럼 선택**: 첫 번째 테이블의 컬럼을 클릭하여 연결 시작
- **대상 컬럼 선택**: 두 번째 테이블의 컬럼을 클릭하여 연결 대상 지정
- **드래그 연결**: React Flow의 핸들(Handle)을 드래그하여 시각적 연결
- **관계 타입 선택**: 1:1, 1:N, N:1, N:N 중 선택
- **연결 종류 선택**: 단순 키값, 데이터 저장, 외부 호출 중 선택
- **엣지 커스터마이징**: 연결 타입별 색상, 스타일, 라벨 설정
### 3. 연결 종류별 세부 설정
#### 단순 키값 연결
- **중계 테이블명**: 자동 생성 또는 사용자 정의
- **연결 규칙**: 중계 테이블 생성 및 관리 규칙
- **참조 무결성**: 외래키 제약조건 설정
#### 데이터 저장
- **컬럼 매핑**: 소스 컬럼과 대상 컬럼 매핑 설정
- **저장 조건**: 데이터 저장 조건 정의
- **데이터 변환**: 컬럼 값 변환 규칙
#### 외부 호출
- **REST API**: API URL, HTTP Method, Headers, Body Template
- **이메일**: SMTP 서버, 발신자, 수신자, 제목/본문 템플릿
- **웹훅**: 웹훅 URL, Payload 형식, Payload 템플릿
- **FTP**: FTP 서버, 업로드 경로, 파일명 템플릿
- **메시지 큐**: 큐 시스템, 큐 이름, 메시지 형식
### 4. React Flow 기반 시각적 관계 관리
- **엣지 렌더링**: React Flow의 커스텀 엣지로 테이블 간 관계를 시각적으로 표현
- **관계 타입별 스타일링**: 연결 종류에 따른 색상, 선 스타일, 라벨 구분
- **인터랙티브 캔버스**: 줌, 팬, 미니맵을 통한 대규모 다이어그램 탐색
- **실시간 시뮬레이션**: 데이터 흐름 애니메이션 및 시뮬레이션
- **관계 검증**: 연결 가능성 및 무결성 검증
- **엣지 편집**: 연결선 클릭으로 관계 설정 수정
### 5. 관계 통계 및 관리
- **연결 통계**: 관계 타입별 연결 수 표시
- **중계 테이블 관리**: 생성된 중계 테이블 목록 및 관리
- **관계 목록**: 생성된 모든 관계 목록 조회
- **관계 삭제**: 불필요한 관계 삭제
## 🗄️ 데이터베이스 설계
### 1. 테이블 관계 테이블
```sql
-- 테이블 간 관계 정의
CREATE TABLE table_relationships (
relationship_id SERIAL PRIMARY KEY,
relationship_name VARCHAR(200) NOT NULL,
from_table_name VARCHAR(100) NOT NULL,
from_column_name VARCHAR(100) NOT NULL,
to_table_name VARCHAR(100) NOT NULL,
to_column_name VARCHAR(100) NOT NULL,
relationship_type VARCHAR(20) NOT NULL, -- 'one-to-one', 'one-to-many', 'many-to-one', 'many-to-many'
connection_type VARCHAR(20) NOT NULL, -- 'simple-key', 'data-save', 'external-call'
company_code VARCHAR(50) NOT NULL,
settings JSONB, -- 연결 종류별 세부 설정
is_active CHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50)
);
-- 회사 코드 인덱스
CREATE INDEX idx_table_relationships_company_code ON table_relationships(company_code);
-- 테이블명 인덱스
CREATE INDEX idx_table_relationships_from_table ON table_relationships(from_table_name);
CREATE INDEX idx_table_relationships_to_table ON table_relationships(to_table_name);
```
### 2. 중계 테이블 관리
```sql
-- 중계 테이블 정의
CREATE TABLE bridge_tables (
bridge_id SERIAL PRIMARY KEY,
bridge_name VARCHAR(200) NOT NULL,
table_name VARCHAR(100) NOT NULL,
relationship_id INTEGER NOT NULL,
company_code VARCHAR(50) NOT NULL,
description TEXT,
is_active CHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
-- 외래키 제약조건
CONSTRAINT fk_bridge_tables_relationship
FOREIGN KEY (relationship_id) REFERENCES table_relationships(relationship_id)
);
-- 회사 코드 인덱스
CREATE INDEX idx_bridge_tables_company_code ON bridge_tables(company_code);
```
### 3. 외부 호출 설정
```sql
-- 외부 호출 설정
CREATE TABLE external_call_configs (
config_id SERIAL PRIMARY KEY,
relationship_id INTEGER NOT NULL,
call_type VARCHAR(50) NOT NULL, -- 'rest-api', 'email', 'webhook', 'ftp', 'queue'
parameters JSONB NOT NULL, -- 호출 유형별 파라미터
is_active CHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
-- 외래키 제약조건
CONSTRAINT fk_external_call_configs_relationship
FOREIGN KEY (relationship_id) REFERENCES table_relationships(relationship_id)
);
```
### 4. 테이블 간 연계 관계
```
table_relationships (테이블 관계)
↓ (1:N)
bridge_tables (중계 테이블)
↓ (1:N)
external_call_configs (외부 호출 설정)
```
## 🎨 프론트엔드 설계
### 1. React Flow 기반 메인 컴포넌트
```typescript
// DataFlowDesigner.tsx
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
Connection,
EdgeChange,
NodeChange,
} from "reactflow";
import "reactflow/dist/style.css";
interface DataFlowDesignerProps {
companyCode: string;
onSave?: (relationships: TableRelationship[]) => void;
}
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode,
onSave,
}) => {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selectedField, setSelectedField] = useState<FieldSelection | null>(
null
);
const [pendingConnection, setPendingConnection] =
useState<PendingConnection | null>(null);
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) => addEdge(params, eds));
},
[setEdges]
);
return (
<div className="data-flow-designer h-screen">
<div className="flex h-full">
{/* 사이드바 */}
<div className="w-80 bg-gray-50 border-r">
<TableSelector
companyCode={companyCode}
onTableAdd={handleTableAdd}
/>
<ConnectionStatus
selectedColumn={selectedColumn}
onCancel={handleCancelConnection}
/>
<ConnectionStats relationships={relationships} />
</div>
{/* React Flow 캔버스 */}
<div className="flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
>
<Controls />
<MiniMap />
<Background variant="dots" gap={12} size={1} />
</ReactFlow>
</div>
</div>
<ConnectionSetupModal
isOpen={!!pendingConnection}
connection={pendingConnection}
onConfirm={handleConfirmConnection}
onCancel={handleCancelConnection}
/>
</div>
);
};
```
### 2. React Flow 테이블 노드 컴포넌트
```typescript
// TableNode.tsx
import { Handle, Position } from "reactflow";
interface TableNodeData {
table: TableDefinition;
onColumnClick: (tableName: string, columnName: string) => void;
}
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { table, onColumnClick } = data;
return (
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-80">
{/* 노드 헤더 */}
<div className="bg-blue-500 text-white p-3 rounded-t-lg">
<div className="font-bold text-sm">{table.tableName}</div>
<div className="text-xs opacity-90">테이블</div>
<div className="text-xs opacity-75">컬럼: {table.columns.length}개</div>
</div>
{/* 컬럼 목록 */}
<div className="p-3">
<div className="text-xs font-semibold text-gray-600 mb-2">
컬럼 목록 ({table.columns.length}개)
</div>
<div className="space-y-1">
{table.columns.map((column) => (
<div
key={column.name}
className="flex items-center justify-between p-2 hover:bg-gray-50 rounded cursor-pointer"
onClick={() => onColumnClick(table.tableName, column.name)}
>
<div className="flex-1">
<div className="text-sm font-medium">{column.name}</div>
<div className="text-xs text-gray-500">
{column.description}
</div>
</div>
<div className="text-xs text-blue-600 font-mono">
{column.type}
</div>
</div>
))}
</div>
</div>
{/* React Flow 핸들 */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-blue-500"
/>
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-green-500"
/>
</div>
);
};
// 노드 타입 정의
export const nodeTypes = {
tableNode: TableNode,
};
```
### 3. 연결 설정 모달
```typescript
// ConnectionSetupModal.tsx
interface ConnectionSetupModalProps {
isOpen: boolean;
connection: PendingConnection | null;
onConfirm: (config: ConnectionConfig) => void;
onCancel: () => void;
}
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
isOpen,
connection,
onConfirm,
onCancel,
}) => {
const [connectionType, setConnectionType] =
useState<ConnectionType>("simple-key");
const [relationshipType, setRelationshipType] =
useState<RelationshipType>("one-to-one");
const [settings, setSettings] = useState<ConnectionSettings>({});
return (
<Dialog open={isOpen} onOpenChange={onCancel}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>필드 연결 설정</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 연결 정보 표시 */}
<ConnectionInfo connection={connection} />
{/* 관계 타입 선택 */}
<RelationshipTypeSelector
value={relationshipType}
onChange={setRelationshipType}
/>
{/* 연결 종류 선택 */}
<ConnectionTypeSelector
value={connectionType}
onChange={setConnectionType}
/>
{/* 연결 종류별 세부 설정 */}
<ConnectionSettingsPanel
type={connectionType}
settings={settings}
onChange={setSettings}
/>
</div>
<DialogFooter>
<Button onClick={onCancel}>취소</Button>
<Button
onClick={() =>
onConfirm({ connectionType, relationshipType, settings })
}
>
연결 생성
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
```
### 4. React Flow 엣지 컴포넌트
```typescript
// CustomEdge.tsx
import {
EdgeProps,
getBezierPath,
EdgeLabelRenderer,
BaseEdge,
} from "reactflow";
interface CustomEdgeData {
relationshipType: string;
connectionType: string;
label?: string;
}
export const CustomEdge: React.FC<EdgeProps<CustomEdgeData>> = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
}) => {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const getEdgeColor = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return "#3B82F6"; // 파란색
case "data-save":
return "#10B981"; // 초록색
case "external-call":
return "#F59E0B"; // 주황색
default:
return "#6B7280"; // 회색
}
};
const getEdgeStyle = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return { strokeWidth: 2, strokeDasharray: "5,5" };
case "data-save":
return { strokeWidth: 3 };
case "external-call":
return { strokeWidth: 2, strokeDasharray: "10,5" };
default:
return { strokeWidth: 2 };
}
};
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
stroke: getEdgeColor(data?.connectionType || ""),
...getEdgeStyle(data?.connectionType || ""),
}}
/>
<EdgeLabelRenderer>
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
background: "white",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "12px",
fontWeight: 500,
border: "1px solid #E5E7EB",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
}}
className="nodrag nopan"
>
{data?.label || data?.relationshipType}
</div>
</EdgeLabelRenderer>
</>
);
};
// 엣지 타입 정의
export const edgeTypes = {
customEdge: CustomEdge,
};
```
## 🌐 API 설계
### 1. 테이블 관계 관리 API
```typescript
// 테이블 관계 생성
POST /api/table-relationships
Body: {
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';
settings: ConnectionSettings;
}
// 테이블 관계 목록 조회 (회사별)
GET /api/table-relationships?companyCode=COMP001
// 테이블 관계 수정
PUT /api/table-relationships/:id
// 테이블 관계 삭제
DELETE /api/table-relationships/:id
// 관계 시뮬레이션
POST /api/table-relationships/:id/simulate
```
### 2. 중계 테이블 관리 API
```typescript
// 중계 테이블 생성
POST /api/bridge-tables
Body: {
bridgeName: string;
tableName: string;
relationshipId: number;
description?: string;
}
// 중계 테이블 목록 조회
GET /api/bridge-tables?companyCode=COMP001
// 중계 테이블 삭제
DELETE /api/bridge-tables/:id
```
### 3. 외부 호출 설정 API
```typescript
// 외부 호출 설정 생성
POST /api/external-call-configs
Body: {
relationshipId: number;
callType: 'rest-api' | 'email' | 'webhook' | 'ftp' | 'queue';
parameters: Record<string, any>;
}
// 외부 호출 설정 조회
GET /api/external-call-configs?relationshipId=123
// 외부 호출 설정 수정
PUT /api/external-call-configs/:id
```
## 🎬 사용 시나리오
### 1. 기본 관계 설정
1. **테이블 추가**: 데이터베이스 테이블 목록에서 관계를 설정할 테이블들을 캔버스에 추가
2. **컬럼 선택**: 첫 번째 테이블의 컬럼을 클릭하여 연결 시작
3. **대상 컬럼 선택**: 두 번째 테이블의 컬럼을 클릭하여 연결 대상 지정
4. **관계 설정**: 관계 타입과 연결 종류를 선택하고 세부 설정 구성
5. **연결 생성**: 설정 완료 후 연결 생성
### 2. 복합 데이터 흐름 설계
1. **다중 테이블 배치**: 관련된 여러 테이블을 캔버스에 배치
2. **다양한 연결 타입**: 단순 키값, 데이터 저장, 외부 호출을 조합
3. **중계 테이블 활용**: N:N 관계에서 중계 테이블 자동 생성
4. **시각적 검증**: 연결선과 색상으로 관계 유형 구분
### 3. 외부 시스템 연동
1. **API 연결**: REST API 호출을 통한 외부 시스템 연동
2. **이메일 알림**: 데이터 변경 시 이메일 자동 전송
3. **웹훅 설정**: 실시간 데이터 동기화
4. **메시지 큐**: 비동기 데이터 처리
## 📅 구현 계획
### Phase 1: React Flow 기본 설정 (1주) ✅ **완료**
- [x] React Flow 라이브러리 설치 및 설정 (@xyflow/react 12.8.4)
- [x] 기본 노드와 엣지 컴포넌트 구현
- [x] 테이블 노드 컴포넌트 구현 (TableNode.tsx)
- [x] 기본 연결선 그리기 (CustomEdge.tsx)
- [x] 메인 데이터 흐름 관리 컴포넌트 구현 (DataFlowDesigner.tsx)
- [x] /admin/dataflow 페이지 생성
- [x] 메뉴 시스템 연동 (SQL 스크립트 제공)
- [x] 샘플 노드 추가/삭제 기능
- [x] 노드 간 드래그앤드롭 연결 기능
- [x] 줌, 팬, 미니맵 등 React Flow 기본 기능
### Phase 2: 관계 설정 기능 (2주) - 🚧 **진행 중 (85% 완료)**
- [x] 연결 설정 모달 UI 구현
- [x] 1:1, 1:N, N:1, N:N 관계 타입 선택 UI
- [x] 단순 키값, 데이터 저장, 외부 호출 연결 종류 UI
- [x] 컬럼-to-컬럼 연결 시스템 (클릭 기반)
- [x] 선택된 컬럼 정보 표시 및 순서 보장
- [x] 드래그 다중 선택 기능 (부분 터치 선택 지원)
- [x] 테이블 기반 시스템으로 전환 (화면 → 테이블)
- [x] 코드 정리 및 최적화 (불필요한 props 제거)
- [ ] 연결 생성 로직 구현 (모달에서 실제 엣지 생성)
- [ ] 생성된 연결의 시각적 표시 (React Flow 엣지)
- [ ] 연결 데이터 백엔드 저장 API 연동
- [ ] 기존 연결 수정/삭제 기능
### Phase 3: 고급 연결 타입 (2-3주)
- [ ] 데이터 저장 연결
- [ ] 외부 호출 연결
- [ ] 중계 테이블 자동 생성
- [ ] 커스텀 엣지 스타일링
### Phase 4: React Flow 고급 기능 (1-2주)
- [ ] 줌, 팬, 미니맵 기능
- [ ] 노드 선택 및 다중 선택
- [ ] 키보드 단축키 지원
- [ ] 레이아웃 자동 정렬
### Phase 5: 시각화 및 관리 (1-2주)
- [ ] 관계 시뮬레이션
- [ ] 연결 통계 및 관리
- [ ] 관계 검증
- [ ] 데이터 흐름 애니메이션
### Phase 6: 고급 기능 (2-3주)
- [ ] N:N 관계 지원
- [ ] 복합 데이터 흐름
- [ ] 외부 시스템 연동
- [ ] 성능 최적화
## 🎯 결론
**테이블 간 데이터 관계 설정 시스템**을 통해 ERP 시스템의 테이블들 간 데이터 관계를 시각적으로 설계하고 관리할 수 있습니다. React Flow 라이브러리를 활용한 직관적인 노드 기반 인터페이스와 회사별 권한 관리, 기존 테이블관리 시스템과의 완벽한 연동을 통해 체계적인 데이터 관계 관리가 가능합니다.
## 📊 구현 현황
### ✅ Phase 1 완료 (2024-12-19)
**구현된 기능:**
- React Flow 12.8.4 기반 시각적 캔버스
- 테이블 노드 컴포넌트 (컬럼 정보, 타입별 색상 구분, 노드 리사이징)
- 커스텀 엣지 컴포넌트 (관계 타입별 스타일링)
- 드래그앤드롭 노드 배치 및 연결
- 줌, 팬, 미니맵 등 고급 시각화 기능 (스크롤 충돌 해결)
- 실제 테이블 데이터 연동 (테이블 관리 API 연결)
- 컬럼-to-컬럼 연결 시스템 (클릭 기반, 2개 테이블 제한)
- 연결 설정 모달 (관계 타입, 연결 종류 선택 UI)
- /admin/dataflow 경로 설정
- 메뉴 시스템 연동 완료
- 사용자 경험 개선 (토스트 알림, 선택 순서 보장)
**구현된 파일:**
- `frontend/components/dataflow/DataFlowDesigner.tsx` - 메인 캔버스 컴포넌트
- `frontend/components/dataflow/TableNode.tsx` - 테이블 노드 컴포넌트 (NodeResizer 포함)
- `frontend/components/dataflow/CustomEdge.tsx` - 커스텀 엣지 컴포넌트
- `frontend/components/dataflow/ConnectionSetupModal.tsx` - 연결 설정 모달
- `frontend/app/(main)/admin/dataflow/page.tsx` - 데이터 흐름 관리 페이지
- `frontend/lib/api/dataflow.ts` - 데이터 흐름 API 클라이언트
- `docs/add_dataflow_menu.sql` - 메뉴 추가 스크립트
**주요 개선사항:**
1. **스크롤 충돌 해결**: 노드 내부 스크롤과 React Flow 줌/팬 기능 분리
2. **테이블 기반 시스템**: 화면 기반에서 테이블 기반으로 완전 전환
3. **컬럼-to-컬럼 연결**: 드래그앤드롭 대신 클릭 기반 컬럼 선택 방식
4. **2개 테이블 제한**: 최대 2개 테이블에서만 컬럼 선택 가능
5. **선택 순서 보장**: 사이드바와 모달에서 컬럼 선택 순서 정확히 반영
6. **실제 데이터 연동**: 테이블 관리 시스템의 실제 테이블/컬럼 데이터 사용
7. **사용자 경험**: react-hot-toast를 통한 친화적인 알림 시스템
8. **React 안정성**: 렌더링 중 상태 변경 문제 해결
9. **드래그 다중 선택**: 부분 터치로도 노드 선택 가능한 고급 선택 기능
10. **코드 최적화**: 불필요한 props 및 컴포넌트 제거로 성능 향상
**다음 단계:** Phase 2 - 실제 연결 생성 및 시각적 표시 기능 구현
### 주요 가치
- **React Flow 기반 시각적 설계**: 복잡한 테이블 관계를 직관적인 노드와 엣지로 설계
- **인터랙티브 캔버스**: 줌, 팬, 미니맵 등 고급 시각화 기능 제공
- **회사별 관리**: 각 회사별로 독립적인 테이블 관계 관리
- **다양한 연결 타입**: 업무 요구사항에 맞는 다양한 연결 방식
- **자동화**: 중계 테이블 자동 생성 및 외부 시스템 연동
- **확장성**: 새로운 연결 타입과 관계 유형 쉽게 추가
- **사용자 친화적**: 드래그앤드롭 기반의 직관적인 사용자 인터페이스

View File

@ -0,0 +1,81 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
import { DataFlowAPI } from "@/lib/api/dataflow";
import { toast } from "sonner";
export default function DataFlowEditPage() {
const params = useParams();
const router = useRouter();
const [diagramId, setDiagramId] = useState<number>(0);
const [diagramName, setDiagramName] = useState<string>("");
useEffect(() => {
if (params.diagramId) {
// URL에서 diagram_id 설정
const id = parseInt(params.diagramId as string);
setDiagramId(id);
// diagram_id로 관계도명 조회
const fetchDiagramName = async () => {
try {
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(id);
if (jsonDiagram && jsonDiagram.diagram_name) {
setDiagramName(jsonDiagram.diagram_name);
} else {
setDiagramName(`관계도 ID: ${id}`);
}
} catch (error) {
console.error("관계도명 조회 실패:", error);
setDiagramName(`관계도 ID: ${id}`);
}
};
fetchDiagramName();
}
}, [params.diagramId]);
const handleBackToList = () => {
router.push("/admin/dataflow");
};
if (!diagramId || !diagramName) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-gray-500"> ...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center space-x-2">
<ArrowLeft className="h-4 w-4" />
<span></span>
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">📊 </h1>
<p className="mt-1 text-gray-600">
<span className="font-medium text-blue-600">{diagramName}</span>
</p>
</div>
</div>
</div>
{/* 데이터플로우 디자이너 */}
<div className="rounded-lg border border-gray-200 bg-white">
<DataFlowDesigner selectedDiagram={diagramName} diagramId={diagramId} onBackToList={handleBackToList} />
</div>
</div>
);
}

View File

@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
type Step = "list" | "design";
export default function DataFlowPage() {
const { user } = useAuth();
const router = useRouter();
const [currentStep, setCurrentStep] = useState<Step>("list");
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
// 단계별 제목과 설명
const stepConfig = {
list: {
title: "데이터 흐름 관계도 관리",
description: "생성된 관계도들을 확인하고 관리하세요",
icon: "📊",
},
design: {
title: "새 관계도 설계",
description: "테이블 간 데이터 관계를 시각적으로 설계하세요",
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);
}
};
// 특정 단계로 이동
const goToStep = (step: Step) => {
setCurrentStep(step);
// 해당 단계까지의 히스토리만 유지
const stepIndex = stepHistory.findIndex((s) => s === step);
if (stepIndex !== -1) {
setStepHistory(stepHistory.slice(0, stepIndex + 1));
}
};
const handleSave = (relationships: TableRelationship[]) => {
console.log("저장된 관계:", relationships);
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
setTimeout(() => {
goToStep("list");
}, 0);
};
const handleDesignDiagram = (diagram: DataFlowDiagram | null) => {
if (diagram) {
// 기존 관계도 편집 - 새로운 URL로 이동
router.push(`/admin/dataflow/edit/${diagram.diagramId}`);
} else {
// 새 관계도 생성 - 현재 페이지에서 처리
goToNextStep("design");
}
};
return (
<div className="flex h-full w-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{currentStep !== "list" && (
<Button variant="outline" size="sm" onClick={goToPreviousStep} className="flex items-center">
<ArrowLeft className="mr-1 h-4 w-4" />
</Button>
)}
<div>
<h1 className="flex items-center text-2xl font-bold text-gray-900">
<span className="mr-2">{stepConfig[currentStep].icon}</span>
{stepConfig[currentStep].title}
</h1>
<p className="mt-1 text-sm text-gray-600">{stepConfig[currentStep].description}</p>
</div>
</div>
</div>
</div>
{/* 단계별 내용 */}
<div className="flex-1 overflow-hidden">
{/* 관계도 목록 단계 */}
{currentStep === "list" && (
<div className="h-full p-6">
<DataFlowList onDesignDiagram={handleDesignDiagram} />
</div>
)}
{/* 관계도 설계 단계 */}
{currentStep === "design" && (
<div className="h-full">
<DataFlowDesigner
companyCode={user?.company_code || "COMP001"}
onSave={handleSave}
selectedDiagram={null}
onBackToList={() => goToStep("list")}
/>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,761 @@
"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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { ArrowRight, Link, Key, Save, Globe, Plus } from "lucide-react";
import { DataFlowAPI, TableRelationship, TableInfo, ColumnInfo } from "@/lib/api/dataflow";
import toast from "react-hot-toast";
// 연결 정보 타입
interface ConnectionInfo {
fromNode: {
id: string;
tableName: string;
displayName: string;
};
toNode: {
id: string;
tableName: string;
displayName: string;
};
fromColumn?: string;
toColumn?: string;
selectedColumnsData?: {
[tableName: string]: {
displayName: string;
columns: string[];
};
};
existingRelationship?: {
relationshipName: string;
relationshipType: string;
connectionType: string;
settings?: any;
};
}
// 연결 설정 타입
interface ConnectionConfig {
relationshipName: string;
relationshipType: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
connectionType: "simple-key" | "data-save" | "external-call";
fromColumnName: string;
toColumnName: string;
settings?: Record<string, unknown>;
description?: string;
}
// 단순 키값 연결 설정
interface SimpleKeySettings {
notes: string;
}
// 데이터 저장 설정
interface DataSaveSettings {
sourceField: string;
targetField: string;
saveConditions: string;
}
// 외부 호출 설정
interface ExternalCallSettings {
callType: "rest-api" | "email" | "webhook" | "ftp" | "queue";
apiUrl?: string;
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
headers?: string;
bodyTemplate?: string;
}
interface ConnectionSetupModalProps {
isOpen: boolean;
connection: ConnectionInfo | null;
companyCode: string;
diagramId?: number;
onConfirm: (relationship: TableRelationship) => void;
onCancel: () => void;
}
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
isOpen,
connection,
companyCode,
diagramId,
onConfirm,
onCancel,
}) => {
const [config, setConfig] = useState<ConnectionConfig>({
relationshipName: "",
relationshipType: "one-to-one",
connectionType: "simple-key",
fromColumnName: "",
toColumnName: "",
description: "",
settings: {},
});
// 연결 종류별 설정 상태
const [simpleKeySettings, setSimpleKeySettings] = useState<SimpleKeySettings>({
notes: "",
});
const [dataSaveSettings, setDataSaveSettings] = useState<DataSaveSettings>({
sourceField: "",
targetField: "",
saveConditions: "",
});
const [externalCallSettings, setExternalCallSettings] = useState<ExternalCallSettings>({
callType: "rest-api",
apiUrl: "",
httpMethod: "POST",
headers: "{}",
bodyTemplate: "{}",
});
// 테이블 및 컬럼 선택을 위한 새로운 상태들
const [availableTables, setAvailableTables] = useState<TableInfo[]>([]);
const [selectedFromTable, setSelectedFromTable] = useState<string>("");
const [selectedToTable, setSelectedToTable] = useState<string>("");
const [fromTableColumns, setFromTableColumns] = useState<ColumnInfo[]>([]);
const [toTableColumns, setToTableColumns] = useState<ColumnInfo[]>([]);
const [selectedFromColumns, setSelectedFromColumns] = useState<string[]>([]);
const [selectedToColumns, setSelectedToColumns] = useState<string[]>([]);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
try {
const tables = await DataFlowAPI.getTables();
setAvailableTables(tables);
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
toast.error("테이블 목록을 불러오는데 실패했습니다.");
}
};
if (isOpen) {
loadTables();
}
}, [isOpen]);
// 모달이 열릴 때 기본값 설정
useEffect(() => {
if (isOpen && connection) {
const fromTableName = connection.fromNode.tableName;
const toTableName = connection.toNode.tableName;
const fromDisplayName = connection.fromNode.displayName;
const toDisplayName = connection.toNode.displayName;
// 테이블 선택 설정
setSelectedFromTable(fromTableName);
setSelectedToTable(toTableName);
// 기존 관계 정보가 있으면 사용, 없으면 기본값 설정
const existingRel = connection.existingRelationship;
setConfig({
relationshipName: existingRel?.relationshipName || `${fromDisplayName}${toDisplayName}`,
relationshipType:
(existingRel?.relationshipType as "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many") ||
"one-to-one",
connectionType: (existingRel?.connectionType as "simple-key" | "data-save" | "external-call") || "simple-key",
fromColumnName: "",
toColumnName: "",
description: existingRel?.settings?.description || `${fromDisplayName}${toDisplayName} 간의 데이터 관계`,
settings: existingRel?.settings || {},
});
// 단순 키값 연결 기본값 설정
setSimpleKeySettings({
notes: `${fromDisplayName}${toDisplayName} 간의 키값 연결`,
});
// 데이터 저장 기본값 설정
setDataSaveSettings({
sourceField: "",
targetField: "",
saveConditions: "데이터 저장 조건을 입력하세요",
});
// 외부 호출 기본값 설정
setExternalCallSettings({
callType: "rest-api",
apiUrl: "https://api.example.com/webhook",
httpMethod: "POST",
headers: "{}",
bodyTemplate: "{}",
});
// 선택된 컬럼 정보가 있다면 설정
if (connection.selectedColumnsData) {
const fromColumns = connection.selectedColumnsData[fromTableName]?.columns || [];
const toColumns = connection.selectedColumnsData[toTableName]?.columns || [];
setSelectedFromColumns(fromColumns);
setSelectedToColumns(toColumns);
setConfig((prev) => ({
...prev,
fromColumnName: fromColumns.join(", "),
toColumnName: toColumns.join(", "),
}));
}
}
}, [isOpen, connection]);
// From 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadFromColumns = async () => {
if (selectedFromTable) {
try {
const columns = await DataFlowAPI.getTableColumns(selectedFromTable);
setFromTableColumns(columns);
} catch (error) {
console.error("From 테이블 컬럼 로드 실패:", error);
toast.error("From 테이블 컬럼을 불러오는데 실패했습니다.");
}
}
};
loadFromColumns();
}, [selectedFromTable]);
// To 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadToColumns = async () => {
if (selectedToTable) {
try {
const columns = await DataFlowAPI.getTableColumns(selectedToTable);
setToTableColumns(columns);
} catch (error) {
console.error("To 테이블 컬럼 로드 실패:", error);
toast.error("To 테이블 컬럼을 불러오는데 실패했습니다.");
}
}
};
loadToColumns();
}, [selectedToTable]);
// 선택된 컬럼들이 변경될 때 config 업데이트
useEffect(() => {
setConfig((prev) => ({
...prev,
fromColumnName: selectedFromColumns.join(", "),
toColumnName: selectedToColumns.join(", "),
}));
}, [selectedFromColumns, selectedToColumns]);
const handleConfirm = () => {
if (!config.relationshipName || !connection) {
toast.error("필수 정보를 모두 입력해주세요.");
return;
}
// 연결 종류별 설정을 준비
let settings = {};
switch (config.connectionType) {
case "simple-key":
settings = simpleKeySettings;
break;
case "data-save":
settings = dataSaveSettings;
break;
case "external-call":
settings = externalCallSettings;
break;
}
// 선택된 컬럼들 검증
if (selectedFromColumns.length === 0 || selectedToColumns.length === 0) {
toast.error("선택된 컬럼이 없습니다. From과 To 테이블에서 각각 최소 1개 이상의 컬럼을 선택해주세요.");
return;
}
// 선택된 테이블과 컬럼 정보 사용
const fromTableName = selectedFromTable || connection.fromNode.tableName;
const toTableName = selectedToTable || connection.toNode.tableName;
// 메모리 기반 시스템: 관계 데이터만 생성하여 부모로 전달
const relationshipData: TableRelationship = {
relationship_name: config.relationshipName,
from_table_name: fromTableName,
to_table_name: toTableName,
from_column_name: selectedFromColumns.join(","), // 여러 컬럼을 콤마로 구분
to_column_name: selectedToColumns.join(","), // 여러 컬럼을 콤마로 구분
relationship_type: config.relationshipType as any,
connection_type: config.connectionType as any,
company_code: companyCode,
settings: {
...settings,
description: config.description,
multiColumnMapping: {
fromColumns: selectedFromColumns,
toColumns: selectedToColumns,
fromTable: fromTableName,
toTable: toTableName,
},
isMultiColumn: selectedFromColumns.length > 1 || selectedToColumns.length > 1,
columnCount: {
from: selectedFromColumns.length,
to: selectedToColumns.length,
},
},
};
toast.success("관계가 생성되었습니다!");
// 부모 컴포넌트로 관계 데이터 전달 (DB 저장 없이)
onConfirm(relationshipData);
handleCancel(); // 모달 닫기
};
const handleCancel = () => {
setConfig({
relationshipName: "",
relationshipType: "one-to-one",
connectionType: "simple-key",
fromColumnName: "",
toColumnName: "",
description: "",
});
onCancel();
};
if (!connection) return null;
// 선택된 컬럼 데이터 가져오기
const selectedColumnsData = connection.selectedColumnsData || {};
const tableNames = Object.keys(selectedColumnsData);
const fromTable = tableNames[0];
const toTable = tableNames[1];
const fromTableData = selectedColumnsData[fromTable];
const toTableData = selectedColumnsData[toTable];
// 연결 종류별 설정 패널 렌더링
const renderConnectionTypeSettings = () => {
switch (config.connectionType) {
case "simple-key":
return (
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-blue-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Key className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="notes" className="text-sm">
</Label>
<Textarea
id="notes"
value={simpleKeySettings.notes}
onChange={(e) => setSimpleKeySettings({ ...simpleKeySettings, notes: e.target.value })}
placeholder="데이터 연결에 대한 설명을 입력하세요"
rows={2}
className="text-sm"
/>
</div>
</div>
</div>
);
case "data-save":
return (
<div className="rounded-lg border border-l-4 border-l-green-500 bg-green-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Save className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="sourceField" className="text-sm">
</Label>
<Input
id="sourceField"
value={dataSaveSettings.sourceField}
onChange={(e) => setDataSaveSettings({ ...dataSaveSettings, sourceField: e.target.value })}
placeholder="소스 필드"
className="text-sm"
/>
</div>
<div>
<Label htmlFor="targetField" className="text-sm">
</Label>
<div className="flex items-center gap-2">
<Input
id="targetField"
value={dataSaveSettings.targetField}
onChange={(e) => setDataSaveSettings({ ...dataSaveSettings, targetField: e.target.value })}
placeholder="대상 필드"
className="text-sm"
/>
<Button size="sm" variant="outline">
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<div>
<Label htmlFor="saveConditions" className="text-sm">
</Label>
<Textarea
id="saveConditions"
value={dataSaveSettings.saveConditions}
onChange={(e) => setDataSaveSettings({ ...dataSaveSettings, saveConditions: e.target.value })}
placeholder="데이터 저장 조건을 입력하세요"
rows={2}
className="text-sm"
/>
</div>
</div>
</div>
);
case "external-call":
return (
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
<Globe className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="callType" className="text-sm">
</Label>
<Select
value={externalCallSettings.callType}
onValueChange={(value: "rest-api" | "email" | "webhook" | "ftp" | "queue") =>
setExternalCallSettings({ ...externalCallSettings, callType: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rest-api">REST API </SelectItem>
<SelectItem value="email"> </SelectItem>
<SelectItem value="webhook"></SelectItem>
<SelectItem value="ftp">FTP </SelectItem>
<SelectItem value="queue"> </SelectItem>
</SelectContent>
</Select>
</div>
{externalCallSettings.callType === "rest-api" && (
<>
<div>
<Label htmlFor="apiUrl" className="text-sm">
API URL
</Label>
<Input
id="apiUrl"
value={externalCallSettings.apiUrl}
onChange={(e) => setExternalCallSettings({ ...externalCallSettings, apiUrl: e.target.value })}
placeholder="https://api.example.com/webhook"
className="text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="httpMethod" className="text-sm">
HTTP Method
</Label>
<Select
value={externalCallSettings.httpMethod}
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
setExternalCallSettings({ ...externalCallSettings, httpMethod: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="headers" className="text-sm">
Headers
</Label>
<Textarea
id="headers"
value={externalCallSettings.headers}
onChange={(e) => setExternalCallSettings({ ...externalCallSettings, headers: e.target.value })}
placeholder="{}"
rows={1}
className="text-sm"
/>
</div>
</div>
<div>
<Label htmlFor="bodyTemplate" className="text-sm">
Body Template
</Label>
<Textarea
id="bodyTemplate"
value={externalCallSettings.bodyTemplate}
onChange={(e) =>
setExternalCallSettings({ ...externalCallSettings, bodyTemplate: e.target.value })
}
placeholder="{}"
rows={2}
className="text-sm"
/>
</div>
</>
)}
</div>
</div>
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={handleCancel}>
<DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Link className="h-4 w-4" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 테이블 및 컬럼 선택 */}
<div className="rounded-lg border bg-gray-50 p-4">
<div className="mb-4 text-sm font-medium"> </div>
{/* 현재 선택된 테이블 표시 */}
<div className="mb-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedFromTable})</span>
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedToTable})</span>
</div>
</div>
</div>
{/* 컬럼 선택 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{fromTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedFromColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedFromColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedFromColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{fromTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{toTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedToColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedToColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedToColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{toTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
</div>
</div>
{/* 선택된 컬럼 미리보기 */}
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600"> From </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedFromColumns.length > 0 ? (
selectedFromColumns.map((column) => (
<Badge key={column} variant="outline" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600"> To </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedToColumns.length > 0 ? (
selectedToColumns.map((column) => (
<Badge key={column} variant="secondary" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
</div>
)}
</div>
{/* 기본 연결 설정 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="relationshipName"> </Label>
<Input
id="relationshipName"
value={config.relationshipName}
onChange={(e) => setConfig({ ...config, relationshipName: e.target.value })}
placeholder="employee_id_department_id_연결"
className="text-sm"
/>
</div>
<div>
<Label htmlFor="relationshipType"> </Label>
<Select
value={config.relationshipType}
onValueChange={(value: "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many") =>
setConfig({ ...config, relationshipType: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one-to-one">1:1 (One to One)</SelectItem>
<SelectItem value="one-to-many">1:N (One to Many)</SelectItem>
<SelectItem value="many-to-one">N:1 (Many to One)</SelectItem>
<SelectItem value="many-to-many">N:N (Many to Many)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 연결 종류 선택 */}
<div>
<Label className="text-sm font-medium"> </Label>
<div className="mt-2 grid grid-cols-3 gap-2">
<div
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
config.connectionType === "simple-key"
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => setConfig({ ...config, connectionType: "simple-key" })}
>
<Key className="mx-auto h-6 w-6 text-blue-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600"> </div>
</div>
<div
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
config.connectionType === "data-save"
? "border-green-500 bg-green-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => setConfig({ ...config, connectionType: "data-save" })}
>
<Save className="mx-auto h-6 w-6 text-green-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600"> </div>
</div>
<div
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
config.connectionType === "external-call"
? "border-orange-500 bg-orange-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => setConfig({ ...config, connectionType: "external-call" })}
>
<Globe className="mx-auto h-6 w-6 text-orange-500" />
<div className="mt-1 text-xs font-medium"> </div>
<div className="text-xs text-gray-600">API/ </div>
</div>
</div>
</div>
{/* 연결 종류별 상세 설정 */}
{renderConnectionTypeSettings()}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm} disabled={!config.relationshipName}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,162 @@
"use client";
import React from "react";
import { EdgeProps, getBezierPath, EdgeLabelRenderer, BaseEdge } from "@xyflow/react";
interface CustomEdgeData {
relationshipType: string;
connectionType: string;
label?: string;
}
export const CustomEdge: React.FC<EdgeProps<CustomEdgeData>> = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
selected,
}) => {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
// 연결 타입에 따른 색상 반환
const getEdgeColor = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return "#3B82F6"; // 파란색 - 단순 키값 연결
case "data-save":
return "#10B981"; // 초록색 - 데이터 저장
case "external-call":
return "#F59E0B"; // 주황색 - 외부 호출
default:
return "#6B7280"; // 회색 - 기본
}
};
// 연결 타입에 따른 스타일 반환
const getEdgeStyle = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return {
strokeWidth: 2,
strokeDasharray: "5,5",
opacity: selected ? 1 : 0.8,
};
case "data-save":
return {
strokeWidth: 3,
opacity: selected ? 1 : 0.8,
};
case "external-call":
return {
strokeWidth: 2,
strokeDasharray: "10,5",
opacity: selected ? 1 : 0.8,
};
default:
return {
strokeWidth: 2,
opacity: selected ? 1 : 0.6,
};
}
};
// 관계 타입에 따른 아이콘 반환
const getRelationshipIcon = (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 "1:1";
}
};
// 연결 타입에 따른 설명 반환
const getConnectionTypeDescription = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return "단순 키값";
case "data-save":
return "데이터 저장";
case "external-call":
return "외부 호출";
default:
return "연결";
}
};
const edgeColor = getEdgeColor(data?.connectionType || "");
const edgeStyle = getEdgeStyle(data?.connectionType || "");
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
stroke: edgeColor,
...edgeStyle,
}}
/>
<EdgeLabelRenderer>
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
background: "white",
padding: "8px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 600,
border: `2px solid ${edgeColor}`,
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
color: edgeColor,
minWidth: "80px",
textAlign: "center",
}}
className={`nodrag nopan transition-all duration-200 ${selected ? "scale-110" : "hover:scale-105"}`}
>
<div className="flex flex-col items-center">
<div className="text-sm font-bold">
{data?.label || getRelationshipIcon(data?.relationshipType || "one-to-one")}
</div>
<div className="mt-1 text-xs opacity-75">
{getConnectionTypeDescription(data?.connectionType || "simple-key")}
</div>
</div>
</div>
</EdgeLabelRenderer>
{/* 선택된 상태일 때 추가 시각적 효과 */}
{selected && (
<BaseEdge
id={`${id}-glow`}
path={edgePath}
style={{
stroke: edgeColor,
strokeWidth: 6,
opacity: 0.3,
}}
/>
)}
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,343 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 {
onDesignDiagram: (diagram: DataFlowDiagram | null) => void;
}
export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
const { user } = useAuth();
const [diagrams, setDiagrams] = useState<DataFlowDiagram[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
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);
const [selectedDiagramForAction, setSelectedDiagramForAction] = useState<DataFlowDiagram | null>(null);
// 목록 로드 함수 분리
const loadDiagrams = useCallback(async () => {
try {
setLoading(true);
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 || [],
companyCode: diagram.company_code, // 회사 코드 추가
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, companyCode]);
// 관계도 목록 로드
useEffect(() => {
loadDiagrams();
}, [loadDiagrams]);
const handleDelete = (diagram: DataFlowDiagram) => {
setSelectedDiagramForAction(diagram);
setShowDeleteModal(true);
};
const handleCopy = (diagram: DataFlowDiagram) => {
setSelectedDiagramForAction(diagram);
setShowCopyModal(true);
};
// 복사 확인
const handleConfirmCopy = async () => {
if (!selectedDiagramForAction) return;
try {
setLoading(true);
const copiedDiagram = await DataFlowAPI.copyJsonDataFlowDiagram(
selectedDiagramForAction.diagramId,
companyCode,
undefined,
user?.userId || "SYSTEM",
);
toast.success(`관계도가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
// 목록 새로고침
await loadDiagrams();
} catch (error) {
console.error("관계도 복사 실패:", error);
toast.error("관계도 복사에 실패했습니다.");
} finally {
setLoading(false);
setShowCopyModal(false);
setSelectedDiagramForAction(null);
}
};
// 삭제 확인
const handleConfirmDelete = async () => {
if (!selectedDiagramForAction) return;
try {
setLoading(true);
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
toast.success(`관계도가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
// 목록 새로고침
await loadDiagrams();
} catch (error) {
console.error("관계도 삭제 실패:", error);
toast.error("관계도 삭제에 실패했습니다.");
} finally {
setLoading(false);
setShowDeleteModal(false);
setSelectedDiagramForAction(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="관계도명, 테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-80 pl-10"
/>
</div>
</div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 관계도 목록 테이블 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center">
<Network className="mr-2 h-5 w-5" />
({total})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diagrams.map((diagram) => (
<TableRow key={diagram.diagramId} className="hover:bg-gray-50">
<TableCell>
<div>
<div className="flex items-center font-medium text-gray-900">
<Database className="mr-2 h-4 w-4 text-gray-500" />
{diagram.diagramName}
</div>
<div className="mt-1 text-sm text-gray-500">
: {diagram.tables.slice(0, 3).join(", ")}
{diagram.tables.length > 3 && `${diagram.tables.length - 3}`}
</div>
</div>
</TableCell>
<TableCell>{diagram.companyCode || "*"}</TableCell>
<TableCell>
<div className="flex items-center">
<Database className="mr-1 h-3 w-3 text-gray-400" />
{diagram.tableCount}
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
<Network className="mr-1 h-3 w-3 text-gray-400" />
{diagram.relationshipCount}
</div>
</TableCell>
<TableCell>
<div className="flex items-center text-sm text-gray-600">
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
{new Date(diagram.updatedAt).toLocaleDateString()}
</div>
<div className="flex items-center text-xs text-gray-400">
<User className="mr-1 h-3 w-3" />
{diagram.updatedBy}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDesignDiagram(diagram)}>
<Network className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(diagram)}>
<Copy className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(diagram)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{diagrams.length === 0 && (
<div className="py-8 text-center text-gray-500">
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
<div className="mb-2 text-lg font-medium"> </div>
<div className="text-sm"> .</div>
</div>
)}
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-gray-600">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
{/* 복사 확인 모달 */}
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br />
(1), (2), (3)... .
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCopyModal(false)}>
</Button>
<Button onClick={handleConfirmCopy} disabled={loading}>
{loading ? "복사 중..." : "복사"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 모달 */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-red-600"> </DialogTitle>
<DialogDescription>
&ldquo;{selectedDiagramForAction?.diagramName}&rdquo; ?
<br />
<span className="font-medium text-red-600">
, .
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteModal(false)}>
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={loading}>
{loading ? "삭제 중..." : "삭제"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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<SaveDiagramModalProps> = ({
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 (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">📊 </DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 관계도 이름 입력 */}
<div className="space-y-2">
<Label htmlFor="diagram-name" className="text-sm font-medium">
*
</Label>
<Input
id="diagram-name"
value={diagramName}
onChange={(e) => {
setDiagramName(e.target.value);
if (nameError) setNameError("");
}}
onKeyPress={handleKeyPress}
placeholder="예: 사용자-부서 관계도"
disabled={isLoading}
className={nameError ? "border-red-500 focus:border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
{/* 관계 요약 정보 */}
<div className="grid grid-cols-3 gap-4 rounded-lg bg-gray-50 p-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{relationships.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{connectedTables.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)}
</div>
<div className="text-sm text-gray-600"> </div>
</div>
</div>
{/* 연결된 테이블 목록 */}
{connectedTables.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{connectedTables.map((table) => (
<Badge key={table} variant="outline" className="text-xs">
📋 {table}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* 관계 목록 미리보기 */}
{relationships.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-60 space-y-3 overflow-y-auto">
{relationships.map((relationship, index) => (
<div
key={relationship.id || index}
className="flex items-center justify-between rounded-lg border bg-white p-3 hover:bg-gray-50"
>
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="text-xs">
{relationship.relationshipType}
</Badge>
<span className="font-medium">{relationship.fromTable}</span>
<span className="text-gray-500"></span>
<span className="font-medium">{relationship.toTable}</span>
</div>
<div className="mt-1 text-xs text-gray-600">
{relationship.fromColumns.join(", ")} {relationship.toColumns.join(", ")}
</div>
</div>
<Badge variant="outline" className="text-xs">
{relationship.connectionType}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 관계가 없는 경우 안내 */}
{relationships.length === 0 && (
<div className="py-8 text-center text-gray-500">
<div className="mb-2 text-4xl">📭</div>
<div className="text-sm"> .</div>
<div className="mt-1 text-xs text-gray-400"> .</div>
</div>
)}
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button
onClick={handleSave}
disabled={isLoading || relationships.length === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
...
</div>
) : (
"💾 저장하기"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default SaveDiagramModal;

View File

@ -0,0 +1,70 @@
"use client";
import React from "react";
import { Handle, Position } from "@xyflow/react";
interface TableColumn {
name: string;
type: string;
description: string;
}
interface Table {
tableName: string;
displayName: string;
description: string;
columns: TableColumn[];
}
interface TableNodeData {
table: Table;
onColumnClick: (tableName: string, columnName: string) => void;
onScrollAreaEnter?: () => void;
onScrollAreaLeave?: () => void;
selectedColumns?: string[]; // 선택된 컬럼 목록
}
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { table, onColumnClick, onScrollAreaEnter, onScrollAreaLeave, selectedColumns = [] } = data;
return (
<div className="relative flex min-w-[280px] flex-col overflow-hidden rounded-lg border-2 border-gray-300 bg-white shadow-lg">
{/* React Flow Handles - 숨김 처리 */}
<Handle type="target" position={Position.Left} id="left" className="!invisible !h-1 !w-1" />
<Handle type="source" position={Position.Right} id="right" className="!invisible !h-1 !w-1" />
{/* 테이블 헤더 - 통일된 디자인 */}
<div className="bg-blue-600 p-3 text-white">
<h3 className="truncate text-sm font-semibold">{table.displayName}</h3>
{table.description && <p className="mt-1 truncate text-xs opacity-75">{table.description}</p>}
</div>
{/* 컬럼 목록 */}
<div className="flex-1 overflow-hidden p-2" onMouseEnter={onScrollAreaEnter} onMouseLeave={onScrollAreaLeave}>
<div className="space-y-1">
{table.columns.map((column) => {
const isSelected = selectedColumns.includes(column.name);
return (
<div
key={column.name}
className={`relative cursor-pointer rounded px-2 py-1 text-xs transition-colors ${
isSelected ? "bg-blue-100 text-blue-800 ring-2 ring-blue-500" : "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => onColumnClick(table.tableName, column.name)}
>
{/* 핸들 제거됨 - 컬럼 클릭으로만 연결 생성 */}
<div className="flex items-center justify-between">
<span className="font-mono font-medium">{column.name}</span>
<span className="text-gray-500">{column.type}</span>
</div>
{column.description && <div className="mt-0.5 text-gray-500">{column.description}</div>}
</div>
);
})}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,154 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Search, Database } from "lucide-react";
import { DataFlowAPI, TableDefinition, TableInfo } from "@/lib/api/dataflow";
interface TableSelectorProps {
companyCode: string;
onTableAdd: (table: TableDefinition) => void;
selectedTables?: string[]; // 이미 추가된 테이블들의 이름
}
export const TableSelector: React.FC<TableSelectorProps> = ({ companyCode, onTableAdd, selectedTables = [] }) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [filteredTables, setFilteredTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 테이블 목록 로드
useEffect(() => {
loadTables();
}, []);
// 검색 필터링
useEffect(() => {
if (searchTerm.trim() === "") {
setFilteredTables(tables);
} else {
const filtered = tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.description && table.description.toLowerCase().includes(searchTerm.toLowerCase())),
);
setFilteredTables(filtered);
}
}, [tables, searchTerm]);
const loadTables = async () => {
try {
setLoading(true);
setError(null);
const tableList = await DataFlowAPI.getTables();
setTables(tableList);
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
setError("테이블 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
const handleAddTable = async (tableInfo: TableInfo) => {
try {
// 테이블 상세 정보 (컬럼 포함) 조회
const tableWithColumns = await DataFlowAPI.getTableWithColumns(tableInfo.tableName);
if (tableWithColumns) {
onTableAdd(tableWithColumns);
}
} catch (error) {
console.error("테이블 추가 실패:", error);
setError("테이블을 추가하는데 실패했습니다.");
}
};
const isTableSelected = (tableName: string) => {
return selectedTables.includes(tableName);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<Button onClick={loadTables} variant="outline" size="sm" disabled={loading}>
{loading ? "로딩중..." : "새로고침"}
</Button>
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="테이블명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
{/* 오류 메시지 */}
{error && <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>}
{/* 테이블 목록 */}
<div className="max-h-96 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-gray-500"> ...</div>
</div>
) : filteredTables.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-center text-sm text-gray-500">
{searchTerm ? "검색 결과가 없습니다." : "등록된 테이블이 없습니다."}
</div>
</div>
) : (
filteredTables.map((table) => {
const isSelected = isTableSelected(table.tableName);
return (
<Card
key={table.tableName}
className={`cursor-pointer transition-all hover:shadow-md ${
isSelected ? "cursor-not-allowed border-blue-500 bg-blue-50 opacity-60" : "hover:border-gray-300"
}`}
onDoubleClick={() => !isSelected && handleAddTable(table)}
>
<CardHeader className="pb-2">
<div>
<CardTitle className="text-sm font-medium">{table.displayName}</CardTitle>
<div className="mt-1 text-xs text-gray-500">{table.columnCount} </div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-600">
<Database className="h-3 w-3" />
<span className="font-mono">{table.tableName}</span>
{isSelected && <span className="font-medium text-blue-600">()</span>}
</div>
{table.description && <p className="line-clamp-2 text-xs text-gray-500">{table.description}</p>}
</div>
</CardContent>
</Card>
);
})
)}
</div>
{/* 통계 정보 */}
<div className="rounded-lg bg-gray-50 p-3 text-xs text-gray-600">
<div className="flex items-center justify-between">
<span> : {tables.length}</span>
{searchTerm && <span> : {filteredTables.length}</span>}
</div>
{selectedTables.length > 0 && <div className="mt-1"> : {selectedTables.length}</div>}
</div>
</div>
);
};

View File

@ -1,4 +1,4 @@
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog } from "lucide-react";
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
import { MenuItem } from "@/types/menu";
import { MENU_ICONS, MESSAGES } from "@/constants/layout";
@ -29,6 +29,9 @@ const getMenuIcon = (menuName: string) => {
if (MENU_ICONS.SETTINGS.some((keyword) => menuName.includes(keyword))) {
return <Cog className="h-4 w-4" />;
}
if (MENU_ICONS.DATAFLOW.some((keyword) => menuName.includes(keyword))) {
return <GitBranch className="h-4 w-4" />;
}
return <FileText className="h-4 w-4" />;
};

View File

@ -30,3 +30,12 @@ export const MESSAGES = {
NO_DATA: "데이터가 없습니다.",
NO_MENUS: "사용 가능한 메뉴가 없습니다.",
} as const;
export const MENU_ICONS = {
HOME: ["홈", "메인", "대시보드"],
DOCUMENT: ["문서", "게시판", "공지"],
USERS: ["사용자", "회원", "직원", "인사"],
STATISTICS: ["통계", "분석", "리포트", "차트"],
SETTINGS: ["설정", "관리", "시스템"],
DATAFLOW: ["데이터", "흐름", "관계", "연결"],
} as const;

View File

@ -15,8 +15,8 @@ const getApiBaseUrl = (): string => {
port: currentPort,
});
// 로컬 개발환경: localhost:9771 → localhost:8080
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && currentPort === "9771") {
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && (currentPort === "9771" || currentPort === "3000")) {
console.log("🏠 로컬 개발 환경 감지 → localhost:8080/api");
return "http://localhost:8080/api";
}

View File

@ -0,0 +1,720 @@
import { apiClient, ApiResponse } from "./client";
// 테이블 간 데이터 관계 설정 관련 타입 정의
export interface ColumnInfo {
columnName: string;
columnLabel?: string;
displayName?: string;
dataType?: string;
dbType?: string;
webType?: string;
isNullable?: string;
columnDefault?: string;
characterMaximumLength?: number;
numericPrecision?: number;
numericScale?: number;
detailSettings?: string;
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
isVisible?: string;
displayOrder?: number;
description?: string;
}
export interface TableDefinition {
tableName: string;
displayName?: string;
description?: string;
columns: ColumnInfo[];
}
export interface TableInfo {
tableName: string;
displayName: string;
description: string;
columnCount: number;
}
export interface TableRelationship {
relationship_id?: number;
diagram_id?: number; // 새 관계도 생성 시에는 optional
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<string, unknown>;
company_code: string;
is_active?: string;
}
// 데이터 연결 중계 테이블 타입
export interface DataBridge {
bridgeId: number;
relationshipId: number;
fromTableName: string;
fromColumnName: string;
toTableName: string;
toColumnName: string;
connectionType: string;
companyCode: string;
createdAt: string;
createdBy?: string;
updatedAt: string;
updatedBy?: string;
isActive: string;
bridgeData?: Record<string, unknown>;
relationship?: {
relationshipName: string;
relationshipType: string;
connectionType: string;
};
}
// 테이블 데이터 조회 응답 타입
export interface TableDataResponse {
data: Record<string, unknown>[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
// 관계도 정보 인터페이스
export interface DataFlowDiagram {
diagramId: number;
diagramName: string;
connectionType: string;
relationshipType: string;
tableCount: number;
relationshipCount: number;
tables: string[];
companyCode: 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;
}
// 노드 위치 정보 타입
export interface NodePosition {
x: number;
y: number;
}
export interface NodePositions {
[tableName: string]: NodePosition;
}
// 새로운 JSON 기반 타입들
export interface JsonDataFlowDiagram {
diagram_id: number;
diagram_name: string;
relationships: {
relationships: JsonRelationship[];
tables: string[];
};
node_positions?: NodePositions;
company_code: string;
created_at?: string;
updated_at?: string;
created_by?: string;
updated_by?: string;
}
export interface JsonRelationship {
id: string;
relationshipName: 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[];
};
node_positions?: NodePositions;
}
export interface JsonDataFlowDiagramsResponse {
diagrams: JsonDataFlowDiagram[];
pagination: {
page: number;
size: number;
total: number;
totalPages: number;
};
}
// 테이블 간 데이터 관계 설정 API 클래스
export class DataFlowAPI {
/**
*
*/
static async getTables(): Promise<TableInfo[]> {
try {
const response = await apiClient.get<ApiResponse<TableInfo[]>>("/table-management/tables");
if (!response.data.success) {
throw new Error(response.data.message || "테이블 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
try {
const response = await apiClient.get<
ApiResponse<{
columns: ColumnInfo[];
page: number;
total: number;
totalPages: number;
}>
>(`/table-management/tables/${tableName}/columns`);
if (!response.data.success) {
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
}
// 페이지네이션된 응답에서 columns 배열만 추출
return response.data.data?.columns || [];
} catch (error) {
console.error("컬럼 정보 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async getTableWithColumns(tableName: string): Promise<TableDefinition | null> {
try {
const columns = await this.getTableColumns(tableName);
return {
tableName,
displayName: tableName,
description: `${tableName} 테이블`,
columns,
};
} catch (error) {
console.error("테이블 및 컬럼 정보 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async createRelationship(
relationship: any, // 백엔드 API 형식 (camelCase)
): Promise<TableRelationship> {
try {
const response = await apiClient.post<ApiResponse<TableRelationship>>(
"/dataflow/table-relationships",
relationship,
);
if (!response.data.success) {
throw new Error(response.data.message || "관계 생성에 실패했습니다.");
}
return response.data.data!;
} catch (error) {
console.error("관계 생성 오류:", error);
throw error;
}
}
/**
*
*/
static async getRelationshipsByCompany(companyCode: string): Promise<TableRelationship[]> {
try {
const response = await apiClient.get<ApiResponse<TableRelationship[]>>("/dataflow/table-relationships", {
params: { companyCode },
});
if (!response.data.success) {
throw new Error(response.data.message || "관계 목록 조회에 실패했습니다.");
}
return response.data.data || [];
} catch (error) {
console.error("관계 목록 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async updateRelationship(
relationshipId: number,
relationship: Partial<TableRelationship>,
): Promise<TableRelationship> {
try {
const response = await apiClient.put<ApiResponse<TableRelationship>>(
`/dataflow/table-relationships/${relationshipId}`,
relationship,
);
if (!response.data.success) {
throw new Error(response.data.message || "관계 수정에 실패했습니다.");
}
return response.data.data!;
} catch (error) {
console.error("관계 수정 오류:", error);
throw error;
}
}
/**
*
*/
static async deleteRelationship(relationshipId: number): Promise<void> {
try {
const response = await apiClient.delete<ApiResponse<null>>(`/dataflow/table-relationships/${relationshipId}`);
if (!response.data.success) {
throw new Error(response.data.message || "관계 삭제에 실패했습니다.");
}
} catch (error) {
console.error("관계 삭제 오류:", error);
throw error;
}
}
// ==================== 데이터 연결 관리 API ====================
/**
*
*/
static async createDataLink(linkData: {
relationshipId: number;
fromTableName: string;
fromColumnName: string;
toTableName: string;
toColumnName: string;
connectionType: string;
bridgeData?: Record<string, unknown>;
}): Promise<DataBridge> {
try {
const response = await apiClient.post<ApiResponse<DataBridge>>("/dataflow/data-links", linkData);
if (!response.data.success) {
throw new Error(response.data.message || "데이터 연결 생성에 실패했습니다.");
}
return response.data.data as DataBridge;
} catch (error) {
console.error("데이터 연결 생성 오류:", error);
throw error;
}
}
/**
*
*/
static async getLinkedDataByRelationship(relationshipId: number): Promise<DataBridge[]> {
try {
const response = await apiClient.get<ApiResponse<DataBridge[]>>(
`/dataflow/data-links/relationship/${relationshipId}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "연결된 데이터 조회에 실패했습니다.");
}
return response.data.data as DataBridge[];
} catch (error) {
console.error("연결된 데이터 조회 오류:", error);
throw error;
}
}
/**
*
*/
static async deleteDataLink(bridgeId: number): Promise<void> {
try {
const response = await apiClient.delete<ApiResponse<null>>(`/dataflow/data-links/${bridgeId}`);
if (!response.data.success) {
throw new Error(response.data.message || "데이터 연결 삭제에 실패했습니다.");
}
} catch (error) {
console.error("데이터 연결 삭제 오류:", error);
throw error;
}
}
// ==================== 테이블 데이터 조회 API ====================
/**
* ()
*/
static async getTableData(
tableName: string,
page: number = 1,
limit: number = 10,
search: string = "",
searchColumn: string = "",
): Promise<TableDataResponse> {
try {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
...(search && { search }),
...(searchColumn && { searchColumn }),
});
const response = await apiClient.get<ApiResponse<TableDataResponse>>(
`/dataflow/table-data/${tableName}?${params}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "테이블 데이터 조회에 실패했습니다.");
}
return response.data.data as TableDataResponse;
} catch (error) {
console.error("테이블 데이터 조회 오류:", error);
throw error;
}
}
// ==================== 관계도 관리 ====================
// 관계도 목록 조회
static async getDataFlowDiagrams(
page: number = 1,
size: number = 20,
searchTerm: string = "",
): Promise<DataFlowDiagramsResponse> {
try {
const params = new URLSearchParams({
page: page.toString(),
size: size.toString(),
...(searchTerm && { searchTerm }),
});
const response = await apiClient.get<ApiResponse<DataFlowDiagramsResponse>>(`/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<TableRelationship[]> {
try {
const encodedDiagramName = encodeURIComponent(diagramName);
const response = await apiClient.get<ApiResponse<TableRelationship[]>>(
`/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;
}
}
// 관계도 복사
static async copyDiagram(diagramName: string): Promise<string> {
try {
const encodedDiagramName = encodeURIComponent(diagramName);
const response = await apiClient.post<ApiResponse<{ newDiagramName: string }>>(
`/dataflow/diagrams/${encodedDiagramName}/copy`,
);
if (!response.data.success) {
throw new Error(response.data.message || "관계도 복사에 실패했습니다.");
}
return response.data.data?.newDiagramName || "";
} catch (error) {
console.error("관계도 복사 오류:", error);
throw error;
}
}
// 관계도 삭제
static async deleteDiagram(diagramName: string): Promise<number> {
try {
const encodedDiagramName = encodeURIComponent(diagramName);
const response = await apiClient.delete<ApiResponse<{ deletedCount: number }>>(
`/dataflow/diagrams/${encodedDiagramName}`,
);
if (!response.data.success) {
throw new Error(response.data.message || "관계도 삭제에 실패했습니다.");
}
return response.data.data?.deletedCount || 0;
} catch (error) {
console.error("관계도 삭제 오류:", error);
throw error;
}
}
// 특정 관계도의 모든 관계 조회 (diagram_id로) - JSON 기반 시스템
static async getDiagramRelationshipsByDiagramId(
diagramId: number,
companyCode: string = "*",
): Promise<TableRelationship[]> {
try {
// 새로운 JSON 기반 시스템에서 관계도 조회
const jsonDiagram = await this.getJsonDataFlowDiagramById(diagramId, companyCode);
if (!jsonDiagram || !jsonDiagram.relationships) {
return [];
}
// 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.relationshipName || rel.id || "관계", // relationshipName 우선 사용
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<JsonDataFlowDiagramsResponse> {
try {
const params = new URLSearchParams({
page: page.toString(),
size: size.toString(),
companyCode: companyCode,
...(searchTerm && { searchTerm }),
});
const response = await apiClient.get<ApiResponse<JsonDataFlowDiagramsResponse>>(`/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<JsonDataFlowDiagram> {
try {
const params = new URLSearchParams({
companyCode: companyCode,
});
const response = await apiClient.get<ApiResponse<JsonDataFlowDiagram>>(
`/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<JsonDataFlowDiagram> {
try {
const requestWithUserInfo = {
...request,
company_code: companyCode,
created_by: userId,
updated_by: userId,
};
const response = await apiClient.post<ApiResponse<JsonDataFlowDiagram>>(
"/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<CreateDiagramRequest>,
companyCode: string = "*",
userId: string = "SYSTEM",
): Promise<JsonDataFlowDiagram> {
try {
const params = new URLSearchParams({
companyCode: companyCode,
});
const requestWithUserInfo = {
...request,
updated_by: userId,
};
const response = await apiClient.put<ApiResponse<JsonDataFlowDiagram>>(
`/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<void> {
try {
const params = new URLSearchParams({
companyCode: companyCode,
});
const response = await apiClient.delete<ApiResponse<void>>(`/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<JsonDataFlowDiagram> {
try {
const requestData = {
companyCode: companyCode,
userId: userId,
...(newName && { new_name: newName }),
};
const response = await apiClient.post<ApiResponse<JsonDataFlowDiagram>>(
`/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;
}
}
}

View File

@ -31,6 +31,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.8.4",
"@types/react-window": "^1.8.8",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -43,6 +44,7 @@
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-window": "^2.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
@ -2642,6 +2644,55 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3258,6 +3309,38 @@
"win32"
]
},
"node_modules/@xyflow/react": {
"version": "12.8.4",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz",
"integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.68",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz",
"integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -3777,6 +3860,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -3910,6 +3999,111 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -5230,6 +5424,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -7137,6 +7340,23 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -8406,6 +8626,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@ -37,6 +37,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.8.4",
"@types/react-window": "^1.8.8",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -49,6 +50,7 @@
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-window": "^2.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",

View File

@ -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: "관계도 복제 중 오류가 발생했습니다.",
});
}
};

View File

@ -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;

View File

@ -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<DataflowDiagram | null> {
return await prisma.dataflow_diagrams.findFirst({
where: {
diagram_id: diagramId,
company_code: companyCode,
},
});
}
/**
*
*/
async createDataflowDiagram(
data: CreateDataflowDiagramData
): Promise<DataflowDiagram> {
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<DataflowDiagram | null> {
// 먼저 해당 관계도가 존재하는지 확인
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<boolean> {
// 먼저 해당 관계도가 존재하는지 확인
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<DataflowDiagram | null> {
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,
});
}
}