Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
c4bf8b727a
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 기반 시각적 설계**: 복잡한 테이블 관계를 직관적인 노드와 엣지로 설계
|
||||
- **인터랙티브 캔버스**: 줌, 팬, 미니맵 등 고급 시각화 기능 제공
|
||||
- **회사별 관리**: 각 회사별로 독립적인 테이블 관계 관리
|
||||
- **다양한 연결 타입**: 업무 요구사항에 맞는 다양한 연결 방식
|
||||
- **자동화**: 중계 테이블 자동 생성 및 외부 시스템 연동
|
||||
- **확장성**: 새로운 연결 타입과 관계 유형 쉽게 추가
|
||||
- **사용자 친화적**: 드래그앤드롭 기반의 직관적인 사용자 인터페이스
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
“{selectedDiagramForAction?.diagramName}” 관계도를 복사하시겠습니까?
|
||||
<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>
|
||||
“{selectedDiagramForAction?.diagramName}” 관계도를 완전히 삭제하시겠습니까?
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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" />;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "관계도 복제 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue