Compare commits
15 Commits
3083ffc0a3
...
affb6899cc
| Author | SHA1 | Date |
|---|---|---|
|
|
affb6899cc | |
|
|
d9270e6307 | |
|
|
699efd25a2 | |
|
|
f15c1fa114 | |
|
|
c557fc5d56 | |
|
|
f0a9c50ca1 | |
|
|
1f67576d5d | |
|
|
da9985cd24 | |
|
|
7297db1730 | |
|
|
e653effac0 | |
|
|
474cc33aee | |
|
|
1436c908a6 | |
|
|
dfda1bcc24 | |
|
|
0258c2a76c | |
|
|
4b28530fec |
|
|
@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes";
|
|||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
import ddlRoutes from "./routes/ddlRoutes";
|
||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||
// import userRoutes from './routes/userRoutes';
|
||||
// import menuRoutes from './routes/menuRoutes';
|
||||
|
|
@ -126,6 +127,7 @@ app.use("/api/screen", screenStandardRoutes);
|
|||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||
app.use("/api/ddl", ddlRoutes);
|
||||
app.use("/api/entity-reference", entityReferenceRoutes);
|
||||
// app.use('/api/users', userRoutes);
|
||||
// app.use('/api/menus', menuRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* DDL 실행 컨트롤러
|
||||
* 테이블/컬럼 생성 API 엔드포인트
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/superAdminMiddleware";
|
||||
import { DDLExecutionService } from "../services/ddlExecutionService";
|
||||
import { DDLAuditLogger } from "../services/ddlAuditLogger";
|
||||
import { CreateTableRequest, AddColumnRequest } from "../types/ddl";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class DDLController {
|
||||
/**
|
||||
* POST /api/ddl/tables - 새 테이블 생성
|
||||
*/
|
||||
static async createTable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columns, description }: CreateTableRequest = req.body;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
|
||||
// 입력값 기본 검증
|
||||
if (!tableName || !columns || columns.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "테이블명과 최소 1개의 컬럼이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("테이블 생성 요청", {
|
||||
tableName,
|
||||
userId,
|
||||
columnCount: columns.length,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
// inputType을 webType으로 변환 (레거시 호환성)
|
||||
const processedColumns = columns.map((col) => ({
|
||||
...col,
|
||||
webType: (col.inputType || col.webType || "text") as any,
|
||||
}));
|
||||
|
||||
// DDL 실행 서비스 호출
|
||||
const ddlService = new DDLExecutionService();
|
||||
const result = await ddlService.createTable(
|
||||
tableName,
|
||||
processedColumns,
|
||||
userCompanyCode,
|
||||
userId,
|
||||
description
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
tableName,
|
||||
columnCount: columns.length,
|
||||
executedQuery: result.executedQuery,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("테이블 생성 컨트롤러 오류:", {
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
userId: req.user?.userId,
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
details: "테이블 생성 중 서버 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ddl/tables/:tableName/columns - 컬럼 추가
|
||||
*/
|
||||
static async addColumn(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { column }: AddColumnRequest = req.body;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
|
||||
// 입력값 기본 검증
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "테이블명이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!column || !column.name || (!column.inputType && !column.webType)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "컬럼명과 입력타입이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("컬럼 추가 요청", {
|
||||
tableName,
|
||||
columnName: column.name,
|
||||
webType: column.webType,
|
||||
userId,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
// inputType을 webType으로 변환 (레거시 호환성)
|
||||
const processedColumn = {
|
||||
...column,
|
||||
webType: (column.inputType || column.webType || "text") as any,
|
||||
};
|
||||
|
||||
// DDL 실행 서비스 호출
|
||||
const ddlService = new DDLExecutionService();
|
||||
const result = await ddlService.addColumn(
|
||||
tableName,
|
||||
processedColumn,
|
||||
userCompanyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
tableName,
|
||||
columnName: column.name,
|
||||
webType: column.webType,
|
||||
executedQuery: result.executedQuery,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("컬럼 추가 컨트롤러 오류:", {
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
userId: req.user?.userId,
|
||||
tableName: req.params.tableName,
|
||||
body: req.body,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
details: "컬럼 추가 중 서버 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ddl/logs - DDL 실행 로그 조회
|
||||
*/
|
||||
static async getDDLLogs(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { limit, userId, ddlType } = req.query;
|
||||
|
||||
const logs = await DDLAuditLogger.getRecentDDLLogs(
|
||||
limit ? parseInt(limit as string) : 50,
|
||||
userId as string,
|
||||
ddlType as string
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs,
|
||||
total: logs.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("DDL 로그 조회 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "LOG_RETRIEVAL_FAILED",
|
||||
details: "DDL 로그 조회 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ddl/statistics - DDL 실행 통계 조회
|
||||
*/
|
||||
static async getDDLStatistics(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { fromDate, toDate } = req.query;
|
||||
|
||||
const statistics = await DDLAuditLogger.getDDLStatistics(
|
||||
fromDate ? new Date(fromDate as string) : undefined,
|
||||
toDate ? new Date(toDate as string) : undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statistics,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("DDL 통계 조회 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "STATISTICS_RETRIEVAL_FAILED",
|
||||
details: "DDL 통계 조회 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ddl/tables/:tableName/info - 생성된 테이블 정보 조회
|
||||
*/
|
||||
static async getTableInfo(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
const ddlService = new DDLExecutionService();
|
||||
const tableInfo = await ddlService.getCreatedTableInfo(tableName);
|
||||
|
||||
if (!tableInfo) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "TABLE_NOT_FOUND",
|
||||
details: `테이블 '${tableName}'을 찾을 수 없습니다.`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tableInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("테이블 정보 조회 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "TABLE_INFO_RETRIEVAL_FAILED",
|
||||
details: "테이블 정보 조회 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ddl/tables/:tableName/history - 테이블 DDL 히스토리 조회
|
||||
*/
|
||||
static async getTableDDLHistory(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
const history = await DDLAuditLogger.getTableDDLHistory(tableName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
tableName,
|
||||
history,
|
||||
total: history.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("테이블 DDL 히스토리 조회 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "HISTORY_RETRIEVAL_FAILED",
|
||||
details: "테이블 DDL 히스토리 조회 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ddl/validate/table - 테이블 생성 사전 검증
|
||||
*/
|
||||
static async validateTableCreation(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columns }: CreateTableRequest = req.body;
|
||||
|
||||
if (!tableName || !columns) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_INPUT",
|
||||
details: "테이블명과 컬럼 정보가 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 검증만 수행 (실제 생성하지 않음)
|
||||
const { DDLSafetyValidator } = await import(
|
||||
"../services/ddlSafetyValidator"
|
||||
);
|
||||
const validationReport = DDLSafetyValidator.generateValidationReport(
|
||||
tableName,
|
||||
columns
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isValid: validationReport.validationResult.isValid,
|
||||
errors: validationReport.validationResult.errors,
|
||||
warnings: validationReport.validationResult.warnings,
|
||||
summary: validationReport.summary,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("테이블 생성 검증 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
details: "테이블 생성 검증 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/logs/cleanup - 오래된 DDL 로그 정리
|
||||
*/
|
||||
static async cleanupOldLogs(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { retentionDays } = req.query;
|
||||
const days = retentionDays ? parseInt(retentionDays as string) : 90;
|
||||
|
||||
const deletedCount = await DDLAuditLogger.cleanupOldLogs(days);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${deletedCount}개의 오래된 DDL 로그가 삭제되었습니다.`,
|
||||
data: {
|
||||
deletedCount,
|
||||
retentionDays: days,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("DDL 로그 정리 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "LOG_CLEANUP_FAILED",
|
||||
details: "DDL 로그 정리 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { Response } from "express";
|
||||
import { dynamicFormService } from "../services/dynamicFormService";
|
||||
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
// 폼 데이터 저장
|
||||
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
|
||||
export const saveFormData = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
|
|
@ -55,6 +56,55 @@ export const saveFormData = async (
|
|||
}
|
||||
};
|
||||
|
||||
// 개선된 폼 데이터 저장 (새 버전)
|
||||
export const saveFormDataEnhanced = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { screenId, tableName, data } = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (screenId === undefined || screenId === null || !tableName || !data) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (screenId, tableName, data)",
|
||||
});
|
||||
}
|
||||
|
||||
// 메타데이터 추가
|
||||
const formDataWithMeta = {
|
||||
...data,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
screen_id: screenId,
|
||||
};
|
||||
|
||||
// company_code 처리
|
||||
if (data.company_code !== undefined) {
|
||||
formDataWithMeta.company_code = data.company_code;
|
||||
} else if (companyCode && companyCode !== "*") {
|
||||
formDataWithMeta.company_code = companyCode;
|
||||
}
|
||||
|
||||
// 개선된 서비스 사용
|
||||
const result = await enhancedDynamicFormService.saveFormData(
|
||||
screenId,
|
||||
tableName,
|
||||
formDataWithMeta
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "데이터 저장에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 데이터 업데이트
|
||||
export const updateFormData = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
|
|
|||
|
|
@ -443,24 +443,24 @@ export async function updateTableLabel(
|
|||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹 타입 설정
|
||||
* 컬럼 입력 타입 설정
|
||||
*/
|
||||
export async function updateColumnWebType(
|
||||
export async function updateColumnInputType(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { webType, detailSettings, inputType } = req.body;
|
||||
const { inputType, detailSettings } = req.body;
|
||||
|
||||
logger.info(
|
||||
`=== 컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===`
|
||||
`=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType} ===`
|
||||
);
|
||||
|
||||
if (!tableName || !columnName || !webType) {
|
||||
if (!tableName || !columnName || !inputType) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블명, 컬럼명, 웹 타입이 모두 필요합니다.",
|
||||
message: "테이블명, 컬럼명, 입력 타입이 모두 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_PARAMETERS",
|
||||
details: "필수 파라미터가 누락되었습니다.",
|
||||
|
|
@ -471,33 +471,32 @@ export async function updateColumnWebType(
|
|||
}
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
await tableManagementService.updateColumnWebType(
|
||||
await tableManagementService.updateColumnInputType(
|
||||
tableName,
|
||||
columnName,
|
||||
webType,
|
||||
detailSettings,
|
||||
inputType
|
||||
inputType,
|
||||
detailSettings
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}`
|
||||
);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "컬럼 웹 타입이 성공적으로 설정되었습니다.",
|
||||
message: "컬럼 입력 타입이 성공적으로 설정되었습니다.",
|
||||
data: null,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("컬럼 웹 타입 설정 중 오류 발생:", error);
|
||||
logger.error("컬럼 입력 타입 설정 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.",
|
||||
message: "컬럼 입력 타입 설정 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "WEB_TYPE_UPDATE_ERROR",
|
||||
code: "INPUT_TYPE_UPDATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
|
@ -735,6 +734,208 @@ export async function editTableData(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
|
||||
*/
|
||||
export async function getTableSchema(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
logger.info(`=== 테이블 스키마 정보 조회 시작: ${tableName} ===`);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_TABLE_NAME",
|
||||
details: "테이블명 파라미터가 누락되었습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
const schema = await tableManagementService.getTableSchema(tableName);
|
||||
|
||||
logger.info(
|
||||
`테이블 스키마 정보 조회 완료: ${tableName}, ${schema.length}개 컬럼`
|
||||
);
|
||||
|
||||
const response: ApiResponse<ColumnTypeInfo[]> = {
|
||||
success: true,
|
||||
message: "테이블 스키마 정보를 성공적으로 조회했습니다.",
|
||||
data: schema,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("테이블 스키마 정보 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블 스키마 정보 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "TABLE_SCHEMA_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
*/
|
||||
export async function checkTableExists(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
logger.info(`=== 테이블 존재 여부 확인 시작: ${tableName} ===`);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_TABLE_NAME",
|
||||
details: "테이블명 파라미터가 누락되었습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
const exists = await tableManagementService.checkTableExists(tableName);
|
||||
|
||||
logger.info(`테이블 존재 여부 확인 완료: ${tableName} = ${exists}`);
|
||||
|
||||
const response: ApiResponse<{ exists: boolean }> = {
|
||||
success: true,
|
||||
message: "테이블 존재 여부를 확인했습니다.",
|
||||
data: { exists },
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("테이블 존재 여부 확인 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블 존재 여부 확인 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "TABLE_EXISTS_CHECK_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹타입 정보 조회 (화면관리 연동용)
|
||||
*/
|
||||
export async function getColumnWebTypes(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
logger.info(`=== 컬럼 웹타입 정보 조회 시작: ${tableName} ===`);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_TABLE_NAME",
|
||||
details: "테이블명 파라미터가 누락되었습니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
const inputTypes =
|
||||
await tableManagementService.getColumnInputTypes(tableName);
|
||||
|
||||
logger.info(
|
||||
`컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼`
|
||||
);
|
||||
|
||||
const response: ApiResponse<ColumnTypeInfo[]> = {
|
||||
success: true,
|
||||
message: "컬럼 입력타입 정보를 성공적으로 조회했습니다.",
|
||||
data: inputTypes,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("컬럼 웹타입 정보 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "컬럼 웹타입 정보 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "COLUMN_WEB_TYPES_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 연결 상태 확인
|
||||
*/
|
||||
export async function checkDatabaseConnection(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 데이터베이스 연결 상태 확인 시작 ===");
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
const connectionStatus =
|
||||
await tableManagementService.checkDatabaseConnection();
|
||||
|
||||
logger.info(
|
||||
`데이터베이스 연결 상태: ${connectionStatus.connected ? "연결됨" : "연결 안됨"}`
|
||||
);
|
||||
|
||||
const response: ApiResponse<{ connected: boolean; message: string }> = {
|
||||
success: true,
|
||||
message: "데이터베이스 연결 상태를 확인했습니다.",
|
||||
data: connectionStatus,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("데이터베이스 연결 상태 확인 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "데이터베이스 연결 상태 확인 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "DATABASE_CONNECTION_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 삭제
|
||||
*/
|
||||
|
|
@ -809,3 +1010,41 @@ export async function deleteTableData(
|
|||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹 타입 설정 (레거시 지원)
|
||||
* @deprecated updateColumnInputType 사용 권장
|
||||
*/
|
||||
export async function updateColumnWebType(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { webType, detailSettings, inputType } = req.body;
|
||||
|
||||
logger.warn(
|
||||
`레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장`
|
||||
);
|
||||
|
||||
// webType을 inputType으로 변환
|
||||
const convertedInputType = inputType || webType || "text";
|
||||
|
||||
// 새로운 메서드 호출
|
||||
req.body = { inputType: convertedInputType, detailSettings };
|
||||
await updateColumnInputType(req, res);
|
||||
} catch (error) {
|
||||
logger.error("레거시 컬럼 웹 타입 설정 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "WEB_TYPE_UPDATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* 슈퍼관리자 권한 검증 미들웨어
|
||||
* 회사코드가 '*'인 최고 관리자만 DDL 실행을 허용
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// DDL 요청 시간 추적을 위한 메모리 저장소
|
||||
const ddlRequestTimes = new Map<string, number>();
|
||||
|
||||
// AuthenticatedRequest 타입 확장
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
userLang?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 슈퍼관리자 권한 확인 미들웨어
|
||||
* 회사코드가 '*'이고 userId가 'plm_admin'인 사용자만 허용
|
||||
*/
|
||||
export const requireSuperAdmin = (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
try {
|
||||
// 인증 여부 확인
|
||||
if (!req.user) {
|
||||
logger.warn("DDL 실행 시도 - 인증되지 않은 사용자", {
|
||||
ip: req.ip,
|
||||
userAgent: req.get("User-Agent"),
|
||||
url: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "AUTHENTICATION_REQUIRED",
|
||||
details: "인증이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 슈퍼관리자 권한 확인 (회사코드가 '*'이고 plm_admin 사용자)
|
||||
if (req.user.companyCode !== "*" || req.user.userId !== "plm_admin") {
|
||||
logger.warn("DDL 실행 시도 - 권한 부족", {
|
||||
userId: req.user.userId,
|
||||
companyCode: req.user.companyCode,
|
||||
ip: req.ip,
|
||||
userAgent: req.get("User-Agent"),
|
||||
url: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "SUPER_ADMIN_REQUIRED",
|
||||
details:
|
||||
"최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 plm_admin 사용자만 가능합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 확인 로깅
|
||||
logger.info("DDL 실행 권한 확인 완료", {
|
||||
userId: req.user.userId,
|
||||
companyCode: req.user.companyCode,
|
||||
ip: req.ip,
|
||||
url: req.originalUrl,
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error("슈퍼관리자 권한 확인 중 오류 발생:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "AUTHORIZATION_ERROR",
|
||||
details: "권한 확인 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DDL 실행 전 추가 보안 검증
|
||||
* 세션 유효성 및 사용자 상태 재확인
|
||||
*/
|
||||
export const validateDDLPermission = (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
try {
|
||||
const user = req.user!; // requireSuperAdmin을 통과했으므로 user 존재 보장
|
||||
|
||||
// 세션 유효성 재확인
|
||||
if (!user.userId || !user.companyCode) {
|
||||
logger.error("DDL 실행 - 세션 데이터 불완전", {
|
||||
userId: user.userId,
|
||||
companyCode: user.companyCode,
|
||||
});
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_SESSION",
|
||||
details: "세션 정보가 불완전합니다. 다시 로그인해주세요.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가 보안 체크 - 메모리 기반 요청 시간 간격 제한
|
||||
const now = Date.now();
|
||||
const minInterval = 5000; // 5초 간격 제한
|
||||
const lastDDLTime = ddlRequestTimes.get(user.userId);
|
||||
|
||||
if (lastDDLTime && now - lastDDLTime < minInterval) {
|
||||
logger.warn("DDL 실행 - 너무 빈번한 요청", {
|
||||
userId: user.userId,
|
||||
timeSinceLastDDL: now - lastDDLTime,
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
details:
|
||||
"DDL 실행 요청이 너무 빈번합니다. 잠시 후 다시 시도해주세요.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 마지막 DDL 실행 시간 기록
|
||||
ddlRequestTimes.set(user.userId, now);
|
||||
|
||||
logger.info("DDL 실행 추가 보안 검증 완료", {
|
||||
userId: user.userId,
|
||||
companyCode: user.companyCode,
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error("DDL 권한 추가 검증 중 오류 발생:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
details: "권한 검증 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수
|
||||
*/
|
||||
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
|
||||
return user?.companyCode === "*" && user?.userId === "plm_admin";
|
||||
};
|
||||
|
||||
/**
|
||||
* DDL 실행 권한 체크 (미들웨어 없이 사용)
|
||||
*/
|
||||
export const checkDDLPermission = (
|
||||
user: AuthenticatedRequest["user"]
|
||||
): {
|
||||
hasPermission: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
} => {
|
||||
if (!user) {
|
||||
return {
|
||||
hasPermission: false,
|
||||
errorCode: "AUTHENTICATION_REQUIRED",
|
||||
errorMessage: "인증이 필요합니다.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isSuperAdmin(user)) {
|
||||
return {
|
||||
hasPermission: false,
|
||||
errorCode: "SUPER_ADMIN_REQUIRED",
|
||||
errorMessage: "최고 관리자 권한이 필요합니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return { hasPermission: true };
|
||||
};
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* DDL 실행 관련 라우터
|
||||
* 테이블/컬럼 생성 API 라우팅
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import { DDLController } from "../controllers/ddlController";
|
||||
import {
|
||||
requireSuperAdmin,
|
||||
validateDDLPermission,
|
||||
} from "../middleware/superAdminMiddleware";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ============================================
|
||||
// DDL 실행 라우터 (최고 관리자 전용)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 테이블 생성
|
||||
* POST /api/ddl/tables
|
||||
*/
|
||||
router.post(
|
||||
"/tables",
|
||||
authenticateToken, // 기본 인증
|
||||
requireSuperAdmin, // 슈퍼관리자 권한 확인
|
||||
validateDDLPermission, // DDL 실행 추가 검증
|
||||
DDLController.createTable
|
||||
);
|
||||
|
||||
/**
|
||||
* 컬럼 추가
|
||||
* POST /api/ddl/tables/:tableName/columns
|
||||
*/
|
||||
router.post(
|
||||
"/tables/:tableName/columns",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
validateDDLPermission,
|
||||
DDLController.addColumn
|
||||
);
|
||||
|
||||
/**
|
||||
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
|
||||
* POST /api/ddl/validate/table
|
||||
*/
|
||||
router.post(
|
||||
"/validate/table",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
DDLController.validateTableCreation
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// DDL 로그 및 모니터링 라우터
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* DDL 실행 로그 조회
|
||||
* GET /api/ddl/logs
|
||||
*/
|
||||
router.get(
|
||||
"/logs",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
DDLController.getDDLLogs
|
||||
);
|
||||
|
||||
/**
|
||||
* DDL 실행 통계 조회
|
||||
* GET /api/ddl/statistics
|
||||
*/
|
||||
router.get(
|
||||
"/statistics",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
DDLController.getDDLStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* 특정 테이블의 DDL 히스토리 조회
|
||||
* GET /api/ddl/tables/:tableName/history
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/history",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
DDLController.getTableDDLHistory
|
||||
);
|
||||
|
||||
/**
|
||||
* 생성된 테이블 정보 조회
|
||||
* GET /api/ddl/tables/:tableName/info
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/info",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
DDLController.getTableInfo
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// DDL 시스템 관리 라우터
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 오래된 DDL 로그 정리
|
||||
* DELETE /api/ddl/logs/cleanup
|
||||
*/
|
||||
router.delete(
|
||||
"/logs/cleanup",
|
||||
authenticateToken,
|
||||
requireSuperAdmin,
|
||||
DDLController.cleanupOldLogs
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 라우터 정보 및 헬스체크
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* DDL 라우터 정보 조회
|
||||
* GET /api/ddl/info
|
||||
*/
|
||||
router.get("/info", authenticateToken, requireSuperAdmin, (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
service: "DDL Execution Service",
|
||||
version: "1.0.0",
|
||||
description: "PostgreSQL 테이블 및 컬럼 동적 생성 서비스",
|
||||
endpoints: {
|
||||
tables: {
|
||||
create: "POST /api/ddl/tables",
|
||||
addColumn: "POST /api/ddl/tables/:tableName/columns",
|
||||
getInfo: "GET /api/ddl/tables/:tableName/info",
|
||||
getHistory: "GET /api/ddl/tables/:tableName/history",
|
||||
},
|
||||
validation: {
|
||||
validateTable: "POST /api/ddl/validate/table",
|
||||
},
|
||||
monitoring: {
|
||||
logs: "GET /api/ddl/logs",
|
||||
statistics: "GET /api/ddl/statistics",
|
||||
cleanup: "DELETE /api/ddl/logs/cleanup",
|
||||
},
|
||||
},
|
||||
requirements: {
|
||||
authentication: "Bearer Token 필요",
|
||||
authorization: "회사코드 '*'인 plm_admin 사용자만 접근 가능",
|
||||
safety: "모든 DDL 실행은 안전성 검증 후 수행",
|
||||
logging: "모든 DDL 실행은 감사 로그에 기록",
|
||||
},
|
||||
supportedWebTypes: [
|
||||
"text",
|
||||
"number",
|
||||
"decimal",
|
||||
"date",
|
||||
"datetime",
|
||||
"boolean",
|
||||
"code",
|
||||
"entity",
|
||||
"textarea",
|
||||
"select",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"file",
|
||||
"email",
|
||||
"tel",
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DDL 서비스 헬스체크
|
||||
* GET /api/ddl/health
|
||||
*/
|
||||
router.get("/health", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 기본적인 데이터베이스 연결 테스트
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
await prisma.$disconnect();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
database: "connected",
|
||||
service: "operational",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
status: "unhealthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
error: {
|
||||
code: "HEALTH_CHECK_FAILED",
|
||||
details: "DDL 서비스 상태 확인 실패",
|
||||
},
|
||||
checks: {
|
||||
database: "disconnected",
|
||||
service: "error",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -2,6 +2,7 @@ import express from "express";
|
|||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
saveFormData,
|
||||
saveFormDataEnhanced,
|
||||
updateFormData,
|
||||
updateFormDataPartial,
|
||||
deleteFormData,
|
||||
|
|
@ -18,7 +19,8 @@ const router = express.Router();
|
|||
router.use(authenticateToken);
|
||||
|
||||
// 폼 데이터 CRUD
|
||||
router.post("/save", saveFormData);
|
||||
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
||||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||
router.put("/:id", updateFormData);
|
||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||
router.delete("/:id", deleteFormData);
|
||||
|
|
|
|||
|
|
@ -262,7 +262,9 @@ router.post(
|
|||
});
|
||||
}
|
||||
|
||||
const result = await ExternalDbConnectionService.testConnectionById(id);
|
||||
// 테스트용 비밀번호가 제공된 경우 사용
|
||||
const testData = req.body.password ? { password: req.body.password } : undefined;
|
||||
const result = await ExternalDbConnectionService.testConnectionById(id, testData);
|
||||
|
||||
return res.status(200).json({
|
||||
success: result.success,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,16 @@ import {
|
|||
getTableLabels,
|
||||
getColumnLabels,
|
||||
updateColumnWebType,
|
||||
updateColumnInputType,
|
||||
updateTableLabel,
|
||||
getTableData,
|
||||
addTableData,
|
||||
editTableData,
|
||||
deleteTableData,
|
||||
getTableSchema,
|
||||
checkTableExists,
|
||||
getColumnWebTypes,
|
||||
checkDatabaseConnection,
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -66,7 +71,7 @@ router.get("/tables/:tableName/labels", getTableLabels);
|
|||
router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels);
|
||||
|
||||
/**
|
||||
* 컬럼 웹 타입 설정
|
||||
* 컬럼 웹 타입 설정 (레거시 지원)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/web-type
|
||||
*/
|
||||
router.put(
|
||||
|
|
@ -74,6 +79,51 @@ router.put(
|
|||
updateColumnWebType
|
||||
);
|
||||
|
||||
/**
|
||||
* 컬럼 입력 타입 설정 (새로운 시스템)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/input-type
|
||||
*/
|
||||
router.put(
|
||||
"/tables/:tableName/columns/:columnName/input-type",
|
||||
updateColumnInputType
|
||||
);
|
||||
|
||||
/**
|
||||
* 개별 컬럼 설정 업데이트 (PUT 방식)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName
|
||||
*/
|
||||
router.put("/tables/:tableName/columns/:columnName", updateColumnSettings);
|
||||
|
||||
/**
|
||||
* 여러 컬럼 설정 일괄 업데이트
|
||||
* PUT /api/table-management/tables/:tableName/columns/batch
|
||||
*/
|
||||
router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
|
||||
|
||||
/**
|
||||
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
|
||||
* GET /api/table-management/tables/:tableName/schema
|
||||
*/
|
||||
router.get("/tables/:tableName/schema", getTableSchema);
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
* GET /api/table-management/tables/:tableName/exists
|
||||
*/
|
||||
router.get("/tables/:tableName/exists", checkTableExists);
|
||||
|
||||
/**
|
||||
* 컬럼 웹타입 정보 조회 (화면관리 연동용)
|
||||
* GET /api/table-management/tables/:tableName/web-types
|
||||
*/
|
||||
router.get("/tables/:tableName/web-types", getColumnWebTypes);
|
||||
|
||||
/**
|
||||
* 데이터베이스 연결 상태 확인
|
||||
* GET /api/table-management/health
|
||||
*/
|
||||
router.get("/health", checkDatabaseConnection);
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
* POST /api/table-management/tables/:tableName/data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* DDL 실행 감사 로깅 서비스
|
||||
* 모든 DDL 실행을 추적하고 기록
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class DDLAuditLogger {
|
||||
/**
|
||||
* DDL 실행 로그 기록
|
||||
*/
|
||||
static async logDDLExecution(
|
||||
userId: string,
|
||||
companyCode: string,
|
||||
ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN",
|
||||
tableName: string,
|
||||
ddlQuery: string,
|
||||
success: boolean,
|
||||
error?: string,
|
||||
additionalInfo?: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// DDL 실행 로그 데이터베이스에 저장
|
||||
const logEntry = await prisma.$executeRaw`
|
||||
INSERT INTO ddl_execution_log (
|
||||
user_id,
|
||||
company_code,
|
||||
ddl_type,
|
||||
table_name,
|
||||
ddl_query,
|
||||
success,
|
||||
error_message,
|
||||
executed_at
|
||||
) VALUES (
|
||||
${userId},
|
||||
${companyCode},
|
||||
${ddlType},
|
||||
${tableName},
|
||||
${ddlQuery},
|
||||
${success},
|
||||
${error || null},
|
||||
NOW()
|
||||
)
|
||||
`;
|
||||
|
||||
// 추가 로깅 (파일 로그)
|
||||
const logData = {
|
||||
userId,
|
||||
companyCode,
|
||||
ddlType,
|
||||
tableName,
|
||||
success,
|
||||
queryLength: ddlQuery.length,
|
||||
error: error || null,
|
||||
additionalInfo: additionalInfo || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (success) {
|
||||
logger.info("DDL 실행 성공", logData);
|
||||
} else {
|
||||
logger.error("DDL 실행 실패", { ...logData, ddlQuery });
|
||||
}
|
||||
|
||||
// 중요한 DDL 실행은 별도 알림 (필요시)
|
||||
if (ddlType === "CREATE_TABLE" || ddlType === "DROP_TABLE") {
|
||||
logger.warn("중요 DDL 실행", {
|
||||
...logData,
|
||||
severity: "HIGH",
|
||||
action: "TABLE_STRUCTURE_CHANGE",
|
||||
});
|
||||
}
|
||||
} catch (logError) {
|
||||
// 로그 기록 실패는 시스템에 영향을 주지 않도록 처리
|
||||
logger.error("DDL 실행 로그 기록 실패:", {
|
||||
originalUserId: userId,
|
||||
originalDdlType: ddlType,
|
||||
originalTableName: tableName,
|
||||
originalSuccess: success,
|
||||
logError: logError,
|
||||
});
|
||||
|
||||
// 로그 기록 실패를 파일 로그로라도 남김
|
||||
console.error("CRITICAL: DDL 로그 기록 실패", {
|
||||
userId,
|
||||
ddlType,
|
||||
tableName,
|
||||
success,
|
||||
logError,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DDL 실행 시작 로그
|
||||
*/
|
||||
static async logDDLStart(
|
||||
userId: string,
|
||||
companyCode: string,
|
||||
ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN",
|
||||
tableName: string,
|
||||
requestData: any
|
||||
): Promise<void> {
|
||||
logger.info("DDL 실행 시작", {
|
||||
userId,
|
||||
companyCode,
|
||||
ddlType,
|
||||
tableName,
|
||||
requestData,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 DDL 실행 로그 조회
|
||||
*/
|
||||
static async getRecentDDLLogs(
|
||||
limit: number = 50,
|
||||
userId?: string,
|
||||
ddlType?: string
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
let whereClause = "WHERE 1=1";
|
||||
const params: any[] = [];
|
||||
|
||||
if (userId) {
|
||||
whereClause += " AND user_id = $" + (params.length + 1);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (ddlType) {
|
||||
whereClause += " AND ddl_type = $" + (params.length + 1);
|
||||
params.push(ddlType);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
company_code,
|
||||
ddl_type,
|
||||
table_name,
|
||||
success,
|
||||
error_message,
|
||||
executed_at,
|
||||
CASE
|
||||
WHEN LENGTH(ddl_query) > 100 THEN SUBSTRING(ddl_query, 1, 100) || '...'
|
||||
ELSE ddl_query
|
||||
END as ddl_query_preview
|
||||
FROM ddl_execution_log
|
||||
${whereClause}
|
||||
ORDER BY executed_at DESC
|
||||
LIMIT $${params.length + 1}
|
||||
`;
|
||||
|
||||
params.push(limit);
|
||||
|
||||
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
||||
return logs as any[];
|
||||
} catch (error) {
|
||||
logger.error("DDL 로그 조회 실패:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DDL 실행 통계 조회
|
||||
*/
|
||||
static async getDDLStatistics(
|
||||
fromDate?: Date,
|
||||
toDate?: Date
|
||||
): Promise<{
|
||||
totalExecutions: number;
|
||||
successfulExecutions: number;
|
||||
failedExecutions: number;
|
||||
byDDLType: Record<string, number>;
|
||||
byUser: Record<string, number>;
|
||||
recentFailures: any[];
|
||||
}> {
|
||||
try {
|
||||
let dateFilter = "";
|
||||
const params: any[] = [];
|
||||
|
||||
if (fromDate) {
|
||||
dateFilter += " AND executed_at >= $" + (params.length + 1);
|
||||
params.push(fromDate);
|
||||
}
|
||||
|
||||
if (toDate) {
|
||||
dateFilter += " AND executed_at <= $" + (params.length + 1);
|
||||
params.push(toDate);
|
||||
}
|
||||
|
||||
// 전체 통계
|
||||
const totalStats = (await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) as total_executions,
|
||||
SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions,
|
||||
SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions
|
||||
FROM ddl_execution_log
|
||||
WHERE 1=1 ${dateFilter}
|
||||
`,
|
||||
...params
|
||||
)) as any[];
|
||||
|
||||
// DDL 타입별 통계
|
||||
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT ddl_type, COUNT(*) as count
|
||||
FROM ddl_execution_log
|
||||
WHERE 1=1 ${dateFilter}
|
||||
GROUP BY ddl_type
|
||||
ORDER BY count DESC
|
||||
`,
|
||||
...params
|
||||
)) as any[];
|
||||
|
||||
// 사용자별 통계
|
||||
const userStats = (await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM ddl_execution_log
|
||||
WHERE 1=1 ${dateFilter}
|
||||
GROUP BY user_id
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
...params
|
||||
)) as any[];
|
||||
|
||||
// 최근 실패 로그
|
||||
const recentFailures = (await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT
|
||||
user_id,
|
||||
ddl_type,
|
||||
table_name,
|
||||
error_message,
|
||||
executed_at
|
||||
FROM ddl_execution_log
|
||||
WHERE success = false ${dateFilter}
|
||||
ORDER BY executed_at DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
...params
|
||||
)) as any[];
|
||||
|
||||
const stats = totalStats[0];
|
||||
|
||||
return {
|
||||
totalExecutions: parseInt(stats.total_executions) || 0,
|
||||
successfulExecutions: parseInt(stats.successful_executions) || 0,
|
||||
failedExecutions: parseInt(stats.failed_executions) || 0,
|
||||
byDDLType: ddlTypeStats.reduce((acc, row) => {
|
||||
acc[row.ddl_type] = parseInt(row.count);
|
||||
return acc;
|
||||
}, {}),
|
||||
byUser: userStats.reduce((acc, row) => {
|
||||
acc[row.user_id] = parseInt(row.count);
|
||||
return acc;
|
||||
}, {}),
|
||||
recentFailures,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("DDL 통계 조회 실패:", error);
|
||||
return {
|
||||
totalExecutions: 0,
|
||||
successfulExecutions: 0,
|
||||
failedExecutions: 0,
|
||||
byDDLType: {},
|
||||
byUser: {},
|
||||
recentFailures: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 DDL 히스토리 조회
|
||||
*/
|
||||
static async getTableDDLHistory(tableName: string): Promise<any[]> {
|
||||
try {
|
||||
const history = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
ddl_type,
|
||||
ddl_query,
|
||||
success,
|
||||
error_message,
|
||||
executed_at
|
||||
FROM ddl_execution_log
|
||||
WHERE table_name = $1
|
||||
ORDER BY executed_at DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
tableName
|
||||
);
|
||||
|
||||
return history as any[];
|
||||
} catch (error) {
|
||||
logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DDL 로그 정리 (오래된 로그 삭제)
|
||||
*/
|
||||
static async cleanupOldLogs(retentionDays: number = 90): Promise<number> {
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
const result = await prisma.$executeRaw`
|
||||
DELETE FROM ddl_execution_log
|
||||
WHERE executed_at < ${cutoffDate}
|
||||
`;
|
||||
|
||||
logger.info(`DDL 로그 정리 완료: ${result}개 레코드 삭제`, {
|
||||
retentionDays,
|
||||
cutoffDate: cutoffDate.toISOString(),
|
||||
});
|
||||
|
||||
return result as number;
|
||||
} catch (error) {
|
||||
logger.error("DDL 로그 정리 실패:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 긴급 상황 로그 (시스템 테이블 접근 시도 등)
|
||||
*/
|
||||
static async logSecurityAlert(
|
||||
userId: string,
|
||||
companyCode: string,
|
||||
alertType:
|
||||
| "SYSTEM_TABLE_ACCESS"
|
||||
| "INVALID_PERMISSION"
|
||||
| "SUSPICIOUS_ACTIVITY",
|
||||
details: string,
|
||||
requestData?: any
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 보안 알림은 별도의 고급 로깅
|
||||
logger.error("DDL 보안 알림", {
|
||||
alertType,
|
||||
userId,
|
||||
companyCode,
|
||||
details,
|
||||
requestData,
|
||||
severity: "CRITICAL",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 필요시 외부 알림 시스템 연동 (이메일, 슬랙 등)
|
||||
// await sendSecurityAlert(alertType, userId, details);
|
||||
} catch (error) {
|
||||
logger.error("보안 알림 기록 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,785 @@
|
|||
/**
|
||||
* DDL 실행 서비스
|
||||
* 실제 PostgreSQL 테이블 및 컬럼 생성을 담당
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
CreateColumnDefinition,
|
||||
DDLExecutionResult,
|
||||
WEB_TYPE_TO_POSTGRES_MAP,
|
||||
WebType,
|
||||
} from "../types/ddl";
|
||||
import { DDLSafetyValidator } from "./ddlSafetyValidator";
|
||||
import { DDLAuditLogger } from "./ddlAuditLogger";
|
||||
import { logger } from "../utils/logger";
|
||||
import { cache, CacheKeys } from "../utils/cache";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export class DDLExecutionService {
|
||||
/**
|
||||
* 새 테이블 생성
|
||||
*/
|
||||
async createTable(
|
||||
tableName: string,
|
||||
columns: CreateColumnDefinition[],
|
||||
userCompanyCode: string,
|
||||
userId: string,
|
||||
description?: string
|
||||
): Promise<DDLExecutionResult> {
|
||||
// DDL 실행 시작 로그
|
||||
await DDLAuditLogger.logDDLStart(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"CREATE_TABLE",
|
||||
tableName,
|
||||
{ columns, description }
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. 권한 검증
|
||||
this.validateSuperAdminPermission(userCompanyCode);
|
||||
|
||||
// 2. 안전성 검증
|
||||
const validation = DDLSafetyValidator.validateTableCreation(
|
||||
tableName,
|
||||
columns
|
||||
);
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = `테이블 생성 검증 실패: ${validation.errors.join(", ")}`;
|
||||
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"CREATE_TABLE",
|
||||
tableName,
|
||||
"VALIDATION_FAILED",
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "VALIDATION_FAILED",
|
||||
details: validation.errors.join(", "),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 테이블 존재 여부 확인
|
||||
const tableExists = await this.checkTableExists(tableName);
|
||||
if (tableExists) {
|
||||
const errorMessage = `테이블 '${tableName}'이 이미 존재합니다.`;
|
||||
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"CREATE_TABLE",
|
||||
tableName,
|
||||
"TABLE_EXISTS",
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "TABLE_EXISTS",
|
||||
details: errorMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 4. DDL 쿼리 생성
|
||||
const ddlQuery = this.generateCreateTableQuery(tableName, columns);
|
||||
|
||||
// 5. 트랜잭션으로 안전하게 실행
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 5-1. 테이블 생성
|
||||
await tx.$executeRawUnsafe(ddlQuery);
|
||||
|
||||
// 5-2. 테이블 메타데이터 저장
|
||||
await this.saveTableMetadata(tx, tableName, description);
|
||||
|
||||
// 5-3. 컬럼 메타데이터 저장
|
||||
await this.saveColumnMetadata(tx, tableName, columns);
|
||||
});
|
||||
|
||||
// 6. 성공 로그 기록
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"CREATE_TABLE",
|
||||
tableName,
|
||||
ddlQuery,
|
||||
true
|
||||
);
|
||||
|
||||
logger.info("테이블 생성 성공", {
|
||||
tableName,
|
||||
userId,
|
||||
columnCount: columns.length,
|
||||
});
|
||||
|
||||
// 테이블 생성 후 관련 캐시 무효화
|
||||
this.invalidateTableCache(tableName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `테이블 '${tableName}'이 성공적으로 생성되었습니다.`,
|
||||
executedQuery: ddlQuery,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = `테이블 생성 실패: ${(error as Error).message}`;
|
||||
|
||||
// 실패 로그 기록
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"CREATE_TABLE",
|
||||
tableName,
|
||||
`FAILED: ${(error as Error).message}`,
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
logger.error("테이블 생성 실패:", {
|
||||
tableName,
|
||||
userId,
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "EXECUTION_FAILED",
|
||||
details: (error as Error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 테이블에 컬럼 추가
|
||||
*/
|
||||
async addColumn(
|
||||
tableName: string,
|
||||
column: CreateColumnDefinition,
|
||||
userCompanyCode: string,
|
||||
userId: string
|
||||
): Promise<DDLExecutionResult> {
|
||||
// DDL 실행 시작 로그
|
||||
await DDLAuditLogger.logDDLStart(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"ADD_COLUMN",
|
||||
tableName,
|
||||
{ column }
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. 권한 검증
|
||||
this.validateSuperAdminPermission(userCompanyCode);
|
||||
|
||||
// 2. 안전성 검증
|
||||
const validation = DDLSafetyValidator.validateColumnAddition(
|
||||
tableName,
|
||||
column
|
||||
);
|
||||
if (!validation.isValid) {
|
||||
const errorMessage = `컬럼 추가 검증 실패: ${validation.errors.join(", ")}`;
|
||||
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"ADD_COLUMN",
|
||||
tableName,
|
||||
"VALIDATION_FAILED",
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "VALIDATION_FAILED",
|
||||
details: validation.errors.join(", "),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 테이블 존재 여부 확인
|
||||
const tableExists = await this.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`;
|
||||
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"ADD_COLUMN",
|
||||
tableName,
|
||||
"TABLE_NOT_EXISTS",
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "TABLE_NOT_EXISTS",
|
||||
details: errorMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 컬럼 존재 여부 확인
|
||||
const columnExists = await this.checkColumnExists(tableName, column.name);
|
||||
if (columnExists) {
|
||||
const errorMessage = `컬럼 '${column.name}'이 이미 존재합니다.`;
|
||||
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"ADD_COLUMN",
|
||||
tableName,
|
||||
"COLUMN_EXISTS",
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "COLUMN_EXISTS",
|
||||
details: errorMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 5. DDL 쿼리 생성
|
||||
const ddlQuery = this.generateAddColumnQuery(tableName, column);
|
||||
|
||||
// 6. 트랜잭션으로 안전하게 실행
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 6-1. 컬럼 추가
|
||||
await tx.$executeRawUnsafe(ddlQuery);
|
||||
|
||||
// 6-2. 컬럼 메타데이터 저장
|
||||
await this.saveColumnMetadata(tx, tableName, [column]);
|
||||
});
|
||||
|
||||
// 7. 성공 로그 기록
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"ADD_COLUMN",
|
||||
tableName,
|
||||
ddlQuery,
|
||||
true
|
||||
);
|
||||
|
||||
logger.info("컬럼 추가 성공", {
|
||||
tableName,
|
||||
columnName: column.name,
|
||||
webType: column.webType,
|
||||
userId,
|
||||
});
|
||||
|
||||
// 컬럼 추가 후 관련 캐시 무효화
|
||||
this.invalidateTableCache(tableName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `컬럼 '${column.name}'이 성공적으로 추가되었습니다.`,
|
||||
executedQuery: ddlQuery,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = `컬럼 추가 실패: ${(error as Error).message}`;
|
||||
|
||||
// 실패 로그 기록
|
||||
await DDLAuditLogger.logDDLExecution(
|
||||
userId,
|
||||
userCompanyCode,
|
||||
"ADD_COLUMN",
|
||||
tableName,
|
||||
`FAILED: ${(error as Error).message}`,
|
||||
false,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
logger.error("컬럼 추가 실패:", {
|
||||
tableName,
|
||||
columnName: column.name,
|
||||
userId,
|
||||
error: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
error: {
|
||||
code: "EXECUTION_FAILED",
|
||||
details: (error as Error).message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CREATE TABLE DDL 쿼리 생성
|
||||
*/
|
||||
private generateCreateTableQuery(
|
||||
tableName: string,
|
||||
columns: CreateColumnDefinition[]
|
||||
): string {
|
||||
// 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 통일
|
||||
const columnDefinitions = columns
|
||||
.map((col) => {
|
||||
// 입력 타입과 관계없이 모든 컬럼을 VARCHAR(500)로 생성
|
||||
let definition = `"${col.name}" varchar(500)`;
|
||||
|
||||
if (!col.nullable) {
|
||||
definition += " NOT NULL";
|
||||
}
|
||||
|
||||
if (col.defaultValue) {
|
||||
definition += ` DEFAULT '${col.defaultValue}'`;
|
||||
}
|
||||
|
||||
return definition;
|
||||
})
|
||||
.join(",\n ");
|
||||
|
||||
// 기본 컬럼들 (날짜는 TIMESTAMP, 나머지는 VARCHAR)
|
||||
const baseColumns = `
|
||||
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(500),
|
||||
"company_code" varchar(500)`;
|
||||
|
||||
// 최종 CREATE TABLE 쿼리
|
||||
return `
|
||||
CREATE TABLE "${tableName}" (${baseColumns},
|
||||
${columnDefinitions}
|
||||
);`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* ALTER TABLE ADD COLUMN DDL 쿼리 생성
|
||||
*/
|
||||
private generateAddColumnQuery(
|
||||
tableName: string,
|
||||
column: CreateColumnDefinition
|
||||
): string {
|
||||
// 새로 추가되는 컬럼도 VARCHAR(500)로 통일
|
||||
let definition = `"${column.name}" varchar(500)`;
|
||||
|
||||
if (!column.nullable) {
|
||||
definition += " NOT NULL";
|
||||
}
|
||||
|
||||
if (column.defaultValue) {
|
||||
definition += ` DEFAULT '${column.defaultValue}'`;
|
||||
}
|
||||
|
||||
return `ALTER TABLE "${tableName}" ADD COLUMN ${definition};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력타입을 PostgreSQL 타입으로 매핑 (날짜는 TIMESTAMP, 나머지는 VARCHAR)
|
||||
* 날짜 타입만 TIMESTAMP로, 나머지는 VARCHAR(500)로 통일
|
||||
*/
|
||||
private mapInputTypeToPostgresType(inputType?: string): string {
|
||||
switch (inputType) {
|
||||
case "date":
|
||||
return "timestamp";
|
||||
default:
|
||||
// 날짜 외의 모든 타입은 VARCHAR(500)로 통일
|
||||
return "varchar(500)";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 지원: 웹타입을 PostgreSQL 타입으로 매핑
|
||||
* @deprecated 새로운 시스템에서는 mapInputTypeToPostgresType 사용
|
||||
*/
|
||||
private mapWebTypeToPostgresType(webType: WebType, length?: number): string {
|
||||
// 레거시 지원을 위해 유지하되, VARCHAR(500)로 통일
|
||||
logger.info(`레거시 웹타입 사용: ${webType} → varchar(500)로 변환`);
|
||||
return "varchar(500)";
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 메타데이터 저장
|
||||
*/
|
||||
private async saveTableMetadata(
|
||||
tx: any,
|
||||
tableName: string,
|
||||
description?: string
|
||||
): Promise<void> {
|
||||
await tx.table_labels.upsert({
|
||||
where: { table_name: tableName },
|
||||
update: {
|
||||
table_label: tableName,
|
||||
description: description || `사용자 생성 테이블: ${tableName}`,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
table_label: tableName,
|
||||
description: description || `사용자 생성 테이블: ${tableName}`,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 메타데이터 저장
|
||||
*/
|
||||
private async saveColumnMetadata(
|
||||
tx: any,
|
||||
tableName: string,
|
||||
columns: CreateColumnDefinition[]
|
||||
): Promise<void> {
|
||||
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
|
||||
await tx.table_labels.upsert({
|
||||
where: {
|
||||
table_name: tableName,
|
||||
},
|
||||
update: {
|
||||
updated_date: new Date(),
|
||||
},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
table_label: tableName,
|
||||
description: `자동 생성된 테이블 메타데이터: ${tableName}`,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
|
||||
const defaultColumns = [
|
||||
{
|
||||
name: "id",
|
||||
label: "ID",
|
||||
inputType: "text",
|
||||
description: "기본키 (자동생성)",
|
||||
order: -5,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: "created_date",
|
||||
label: "생성일시",
|
||||
inputType: "date",
|
||||
description: "레코드 생성일시",
|
||||
order: -4,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: "updated_date",
|
||||
label: "수정일시",
|
||||
inputType: "date",
|
||||
description: "레코드 수정일시",
|
||||
order: -3,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: "writer",
|
||||
label: "작성자",
|
||||
inputType: "text",
|
||||
description: "레코드 작성자",
|
||||
order: -2,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: "company_code",
|
||||
label: "회사코드",
|
||||
inputType: "text",
|
||||
description: "회사 구분 코드",
|
||||
order: -1,
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 기본 컬럼들을 table_type_columns에 등록
|
||||
for (const defaultCol of defaultColumns) {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES (
|
||||
${tableName}, ${defaultCol.name}, ${defaultCol.inputType}, '{}',
|
||||
'Y', ${defaultCol.order}, now(), now()
|
||||
)
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
input_type = ${defaultCol.inputType},
|
||||
display_order = ${defaultCol.order},
|
||||
updated_date = now();
|
||||
`;
|
||||
}
|
||||
|
||||
// 사용자 정의 컬럼들을 table_type_columns에 등록
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const column = columns[i];
|
||||
const inputType = this.convertWebTypeToInputType(
|
||||
column.webType || "text"
|
||||
);
|
||||
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES (
|
||||
${tableName}, ${column.name}, ${inputType}, ${JSON.stringify(column.detailSettings || {})},
|
||||
'Y', ${i}, now(), now()
|
||||
)
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
input_type = ${inputType},
|
||||
detail_settings = ${JSON.stringify(column.detailSettings || {})},
|
||||
display_order = ${i},
|
||||
updated_date = now();
|
||||
`;
|
||||
}
|
||||
|
||||
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
|
||||
// 1. 기본 컬럼들을 column_labels에 등록
|
||||
for (const defaultCol of defaultColumns) {
|
||||
await tx.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: defaultCol.name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
column_label: defaultCol.label,
|
||||
input_type: defaultCol.inputType,
|
||||
detail_settings: JSON.stringify({}),
|
||||
description: defaultCol.description,
|
||||
display_order: defaultCol.order,
|
||||
is_visible: defaultCol.isVisible,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
column_name: defaultCol.name,
|
||||
column_label: defaultCol.label,
|
||||
input_type: defaultCol.inputType,
|
||||
detail_settings: JSON.stringify({}),
|
||||
description: defaultCol.description,
|
||||
display_order: defaultCol.order,
|
||||
is_visible: defaultCol.isVisible,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 사용자 정의 컬럼들을 column_labels에 등록
|
||||
for (const column of columns) {
|
||||
await tx.column_labels.upsert({
|
||||
where: {
|
||||
table_name_column_name: {
|
||||
table_name: tableName,
|
||||
column_name: column.name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
column_label: column.label || column.name,
|
||||
input_type: this.convertWebTypeToInputType(column.webType || "text"),
|
||||
detail_settings: JSON.stringify(column.detailSettings || {}),
|
||||
description: column.description,
|
||||
display_order: column.order || 0,
|
||||
is_visible: true,
|
||||
updated_date: new Date(),
|
||||
},
|
||||
create: {
|
||||
table_name: tableName,
|
||||
column_name: column.name,
|
||||
column_label: column.label || column.name,
|
||||
input_type: this.convertWebTypeToInputType(column.webType || "text"),
|
||||
detail_settings: JSON.stringify(column.detailSettings || {}),
|
||||
description: column.description,
|
||||
display_order: column.order || 0,
|
||||
is_visible: true,
|
||||
created_date: new Date(),
|
||||
updated_date: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹 타입을 입력 타입으로 변환
|
||||
*/
|
||||
private convertWebTypeToInputType(webType: string): string {
|
||||
const webTypeToInputTypeMap: Record<string, string> = {
|
||||
// 텍스트 관련
|
||||
text: "text",
|
||||
textarea: "text",
|
||||
email: "text",
|
||||
tel: "text",
|
||||
url: "text",
|
||||
password: "text",
|
||||
|
||||
// 숫자 관련
|
||||
number: "number",
|
||||
decimal: "number",
|
||||
|
||||
// 날짜 관련
|
||||
date: "date",
|
||||
datetime: "date",
|
||||
time: "date",
|
||||
|
||||
// 선택 관련
|
||||
select: "select",
|
||||
dropdown: "select",
|
||||
checkbox: "checkbox",
|
||||
boolean: "checkbox",
|
||||
radio: "radio",
|
||||
|
||||
// 참조 관련
|
||||
code: "code",
|
||||
entity: "entity",
|
||||
|
||||
// 기타
|
||||
file: "text",
|
||||
button: "text",
|
||||
};
|
||||
|
||||
return webTypeToInputTypeMap[webType] || "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 검증 (슈퍼관리자 확인)
|
||||
*/
|
||||
private validateSuperAdminPermission(userCompanyCode: string): void {
|
||||
if (userCompanyCode !== "*") {
|
||||
throw new Error("최고 관리자 권한이 필요합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
*/
|
||||
private async checkTableExists(tableName: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
);
|
||||
`,
|
||||
tableName
|
||||
);
|
||||
|
||||
return (result as any)[0]?.exists || false;
|
||||
} catch (error) {
|
||||
logger.error("테이블 존재 확인 오류:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 존재 여부 확인
|
||||
*/
|
||||
private async checkColumnExists(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
AND column_name = $2
|
||||
);
|
||||
`,
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
|
||||
return (result as any)[0]?.exists || false;
|
||||
} catch (error) {
|
||||
logger.error("컬럼 존재 확인 오류:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성된 테이블 정보 조회
|
||||
*/
|
||||
async getCreatedTableInfo(tableName: string): Promise<{
|
||||
tableInfo: any;
|
||||
columns: any[];
|
||||
} | null> {
|
||||
try {
|
||||
// 테이블 정보 조회
|
||||
const tableInfo = await prisma.table_labels.findUnique({
|
||||
where: { table_name: tableName },
|
||||
});
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await prisma.column_labels.findMany({
|
||||
where: { table_name: tableName },
|
||||
orderBy: { display_order: "asc" },
|
||||
});
|
||||
|
||||
if (!tableInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tableInfo,
|
||||
columns,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("생성된 테이블 정보 조회 실패:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 관련 캐시 무효화
|
||||
* DDL 작업 후 호출하여 캐시된 데이터를 클리어
|
||||
*/
|
||||
private invalidateTableCache(tableName: string): void {
|
||||
try {
|
||||
// 테이블 컬럼 관련 캐시 무효화
|
||||
const columnCacheDeleted = cache.deleteByPattern(
|
||||
`table_columns:${tableName}`
|
||||
);
|
||||
const countCacheDeleted = cache.deleteByPattern(
|
||||
`table_column_count:${tableName}`
|
||||
);
|
||||
cache.delete("table_list");
|
||||
|
||||
const totalDeleted = columnCacheDeleted + countCacheDeleted + 1;
|
||||
|
||||
logger.info(
|
||||
`테이블 캐시 무효화 완료: ${tableName}, 삭제된 키: ${totalDeleted}개`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(`테이블 캐시 무효화 실패: ${tableName}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
/**
|
||||
* DDL 안전성 검증 서비스
|
||||
* 테이블/컬럼 생성 전 모든 보안 검증을 수행
|
||||
*/
|
||||
|
||||
import {
|
||||
CreateColumnDefinition,
|
||||
ValidationResult,
|
||||
SYSTEM_TABLES,
|
||||
RESERVED_WORDS,
|
||||
RESERVED_COLUMNS,
|
||||
} from "../types/ddl";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class DDLSafetyValidator {
|
||||
/**
|
||||
* 테이블 생성 전 전체 검증
|
||||
*/
|
||||
static validateTableCreation(
|
||||
tableName: string,
|
||||
columns: CreateColumnDefinition[]
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. 테이블명 기본 검증
|
||||
const tableNameValidation = this.validateTableName(tableName);
|
||||
if (!tableNameValidation.isValid) {
|
||||
errors.push(...tableNameValidation.errors);
|
||||
}
|
||||
|
||||
// 2. 컬럼 기본 검증
|
||||
if (columns.length === 0) {
|
||||
errors.push("최소 1개의 컬럼이 필요합니다.");
|
||||
}
|
||||
|
||||
// 3. 컬럼 목록 검증
|
||||
const columnsValidation = this.validateColumnList(columns);
|
||||
if (!columnsValidation.isValid) {
|
||||
errors.push(...columnsValidation.errors);
|
||||
}
|
||||
if (columnsValidation.warnings) {
|
||||
warnings.push(...columnsValidation.warnings);
|
||||
}
|
||||
|
||||
// 4. 컬럼명 중복 검증
|
||||
const duplicateValidation = this.validateColumnDuplication(columns);
|
||||
if (!duplicateValidation.isValid) {
|
||||
errors.push(...duplicateValidation.errors);
|
||||
}
|
||||
|
||||
logger.info("테이블 생성 검증 완료", {
|
||||
tableName,
|
||||
columnCount: columns.length,
|
||||
errorCount: errors.length,
|
||||
warningCount: warnings.length,
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("테이블 생성 검증 중 오류 발생:", error);
|
||||
return {
|
||||
isValid: false,
|
||||
errors: ["테이블 생성 검증 중 내부 오류가 발생했습니다."],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 추가 전 검증
|
||||
*/
|
||||
static validateColumnAddition(
|
||||
tableName: string,
|
||||
column: CreateColumnDefinition
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. 테이블명 검증 (시스템 테이블 확인)
|
||||
if (this.isSystemTable(tableName)) {
|
||||
errors.push(
|
||||
`'${tableName}'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 컬럼 정의 검증
|
||||
const columnValidation = this.validateSingleColumn(column);
|
||||
if (!columnValidation.isValid) {
|
||||
errors.push(...columnValidation.errors);
|
||||
}
|
||||
if (columnValidation.warnings) {
|
||||
warnings.push(...columnValidation.warnings);
|
||||
}
|
||||
|
||||
logger.info("컬럼 추가 검증 완료", {
|
||||
tableName,
|
||||
columnName: column.name,
|
||||
webType: column.webType,
|
||||
errorCount: errors.length,
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("컬럼 추가 검증 중 오류 발생:", error);
|
||||
return {
|
||||
isValid: false,
|
||||
errors: ["컬럼 추가 검증 중 내부 오류가 발생했습니다."],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블명 검증
|
||||
*/
|
||||
private static validateTableName(tableName: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 1. 기본 형식 검증
|
||||
if (!this.isValidTableName(tableName)) {
|
||||
errors.push(
|
||||
"유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다."
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 길이 검증
|
||||
if (tableName.length > 63) {
|
||||
errors.push("테이블명은 63자를 초과할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (tableName.length < 2) {
|
||||
errors.push("테이블명은 최소 2자 이상이어야 합니다.");
|
||||
}
|
||||
|
||||
// 3. 시스템 테이블 보호
|
||||
if (this.isSystemTable(tableName)) {
|
||||
errors.push(
|
||||
`'${tableName}'은 시스템 테이블명으로 사용할 수 없습니다. 다른 이름을 선택해주세요.`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 예약어 검증
|
||||
if (this.isReservedWord(tableName)) {
|
||||
errors.push(
|
||||
`'${tableName}'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 일반적인 네이밍 컨벤션 검증
|
||||
if (tableName.startsWith("_") || tableName.endsWith("_")) {
|
||||
errors.push("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.");
|
||||
}
|
||||
|
||||
if (tableName.includes("__")) {
|
||||
errors.push("테이블명에 연속된 언더스코어는 사용할 수 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 목록 검증
|
||||
*/
|
||||
private static validateColumnList(
|
||||
columns: CreateColumnDefinition[]
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const column = columns[i];
|
||||
const columnValidation = this.validateSingleColumn(column, i + 1);
|
||||
|
||||
if (!columnValidation.isValid) {
|
||||
errors.push(...columnValidation.errors);
|
||||
}
|
||||
|
||||
if (columnValidation.warnings) {
|
||||
warnings.push(...columnValidation.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 컬럼 검증
|
||||
*/
|
||||
private static validateSingleColumn(
|
||||
column: CreateColumnDefinition,
|
||||
position?: number
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const prefix = position
|
||||
? `컬럼 ${position}(${column.name}): `
|
||||
: `컬럼 '${column.name}': `;
|
||||
|
||||
// 1. 컬럼명 기본 검증
|
||||
if (!column.name || column.name.trim() === "") {
|
||||
errors.push(`${prefix}컬럼명은 필수입니다.`);
|
||||
return { isValid: false, errors };
|
||||
}
|
||||
|
||||
if (!this.isValidColumnName(column.name)) {
|
||||
errors.push(
|
||||
`${prefix}유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 길이 검증
|
||||
if (column.name.length > 63) {
|
||||
errors.push(`${prefix}컬럼명은 63자를 초과할 수 없습니다.`);
|
||||
}
|
||||
|
||||
if (column.name.length < 2) {
|
||||
errors.push(`${prefix}컬럼명은 최소 2자 이상이어야 합니다.`);
|
||||
}
|
||||
|
||||
// 3. 예약된 컬럼명 검증
|
||||
if (this.isReservedColumnName(column.name)) {
|
||||
errors.push(
|
||||
`${prefix}'${column.name}'은 예약된 컬럼명입니다. 기본 컬럼(id, created_date, updated_date, company_code)과 중복됩니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. SQL 예약어 검증
|
||||
if (this.isReservedWord(column.name)) {
|
||||
errors.push(
|
||||
`${prefix}'${column.name}'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 웹타입 검증
|
||||
if (!column.webType) {
|
||||
errors.push(`${prefix}웹타입이 지정되지 않았습니다.`);
|
||||
}
|
||||
|
||||
// 6. 길이 설정 검증 (text, code 타입에서만 허용)
|
||||
if (column.length !== undefined) {
|
||||
if (
|
||||
!["text", "code", "email", "tel", "select", "radio"].includes(
|
||||
column.webType || "text"
|
||||
)
|
||||
) {
|
||||
warnings.push(
|
||||
`${prefix}${column.webType || "text"} 타입에서는 길이 설정이 무시됩니다.`
|
||||
);
|
||||
} else if (column.length <= 0 || column.length > 65535) {
|
||||
errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 네이밍 컨벤션 검증
|
||||
if (column.name.startsWith("_") || column.name.endsWith("_")) {
|
||||
warnings.push(
|
||||
`${prefix}컬럼명이 언더스코어로 시작하거나 끝나는 것은 권장하지 않습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
if (column.name.includes("__")) {
|
||||
errors.push(`${prefix}컬럼명에 연속된 언더스코어는 사용할 수 없습니다.`);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼명 중복 검증
|
||||
*/
|
||||
private static validateColumnDuplication(
|
||||
columns: CreateColumnDefinition[]
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const columnNames = columns.map((col) => col.name.toLowerCase());
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
|
||||
for (const name of columnNames) {
|
||||
if (seen.has(name)) {
|
||||
duplicates.add(name);
|
||||
} else {
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicates.size > 0) {
|
||||
errors.push(
|
||||
`중복된 컬럼명이 있습니다: ${Array.from(duplicates).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블명 유효성 검증 (정규식)
|
||||
*/
|
||||
private static isValidTableName(tableName: string): boolean {
|
||||
const tableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
return tableNameRegex.test(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼명 유효성 검증 (정규식)
|
||||
*/
|
||||
private static isValidColumnName(columnName: string): boolean {
|
||||
const columnNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
return columnNameRegex.test(columnName) && columnName.length <= 63;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 테이블 확인
|
||||
*/
|
||||
private static isSystemTable(tableName: string): boolean {
|
||||
return SYSTEM_TABLES.includes(tableName.toLowerCase() as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 예약어 확인
|
||||
*/
|
||||
private static isReservedWord(word: string): boolean {
|
||||
return RESERVED_WORDS.includes(word.toLowerCase() as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약된 컬럼명 확인
|
||||
*/
|
||||
private static isReservedColumnName(columnName: string): boolean {
|
||||
return RESERVED_COLUMNS.includes(columnName.toLowerCase() as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 검증 통계 생성
|
||||
*/
|
||||
static generateValidationReport(
|
||||
tableName: string,
|
||||
columns: CreateColumnDefinition[]
|
||||
): {
|
||||
tableName: string;
|
||||
totalColumns: number;
|
||||
validationResult: ValidationResult;
|
||||
summary: string;
|
||||
} {
|
||||
const validationResult = this.validateTableCreation(tableName, columns);
|
||||
|
||||
let summary = `테이블 '${tableName}' 검증 완료. `;
|
||||
summary += `컬럼 ${columns.length}개 중 `;
|
||||
|
||||
if (validationResult.isValid) {
|
||||
summary += "모든 검증 통과.";
|
||||
} else {
|
||||
summary += `${validationResult.errors.length}개 오류 발견.`;
|
||||
}
|
||||
|
||||
if (validationResult.warnings && validationResult.warnings.length > 0) {
|
||||
summary += ` ${validationResult.warnings.length}개 경고 있음.`;
|
||||
}
|
||||
|
||||
return {
|
||||
tableName,
|
||||
totalColumns: columns.length,
|
||||
validationResult,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,786 @@
|
|||
/**
|
||||
* 개선된 동적 폼 서비스
|
||||
* 타입 안전성과 검증 강화
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
WebType,
|
||||
DynamicWebType,
|
||||
normalizeWebType,
|
||||
isValidWebType,
|
||||
WEB_TYPE_TO_POSTGRES_CONVERTER,
|
||||
WEB_TYPE_VALIDATION_PATTERNS,
|
||||
} from "../types/unified-web-types";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 테이블 컬럼 정보
|
||||
export interface TableColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: any;
|
||||
character_maximum_length?: number;
|
||||
numeric_precision?: number;
|
||||
numeric_scale?: number;
|
||||
}
|
||||
|
||||
// 컬럼 웹타입 정보
|
||||
export interface ColumnWebTypeInfo {
|
||||
columnName: string;
|
||||
webType: WebType;
|
||||
isRequired: boolean;
|
||||
validationRules?: Record<string, any>;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
// 폼 데이터 검증 결과
|
||||
export interface FormValidationResult {
|
||||
isValid: boolean;
|
||||
errors: FormValidationError[];
|
||||
warnings: FormValidationWarning[];
|
||||
transformedData: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface FormValidationError {
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export interface FormValidationWarning {
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// 저장 결과
|
||||
export interface FormDataResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
affectedRows?: number;
|
||||
insertedId?: any;
|
||||
validationResult?: FormValidationResult;
|
||||
}
|
||||
|
||||
export class EnhancedDynamicFormService {
|
||||
private dataflowControlService = new DataflowControlService();
|
||||
private columnCache = new Map<string, TableColumn[]>();
|
||||
private webTypeCache = new Map<string, ColumnWebTypeInfo[]>();
|
||||
|
||||
/**
|
||||
* 폼 데이터 저장 (메인 메서드)
|
||||
*/
|
||||
async saveFormData(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<FormDataResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
console.log(`🚀 개선된 폼 저장 시작: ${tableName}`, {
|
||||
screenId,
|
||||
dataKeys: Object.keys(data),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 1. 테이블 존재 여부 확인
|
||||
const tableExists = await this.validateTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블 '${tableName}'이 존재하지 않습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 스키마 정보 로드
|
||||
const [tableColumns, columnWebTypes] = await Promise.all([
|
||||
this.getTableColumns(tableName),
|
||||
this.getColumnWebTypes(tableName),
|
||||
]);
|
||||
|
||||
// 3. 폼 데이터 검증
|
||||
const validationResult = await this.validateFormData(
|
||||
data,
|
||||
tableColumns,
|
||||
columnWebTypes,
|
||||
tableName
|
||||
);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
console.error("❌ 폼 데이터 검증 실패:", validationResult.errors);
|
||||
return {
|
||||
success: false,
|
||||
message: this.formatValidationErrors(validationResult.errors),
|
||||
validationResult,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 데이터 저장 수행
|
||||
const saveResult = await this.performDataSave(
|
||||
tableName,
|
||||
validationResult.transformedData,
|
||||
tableColumns
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`✅ 폼 저장 완료: ${duration}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "데이터가 성공적으로 저장되었습니다.",
|
||||
data: saveResult.data,
|
||||
affectedRows: saveResult.affectedRows,
|
||||
insertedId: saveResult.insertedId,
|
||||
validationResult,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ 폼 저장 중 오류:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: this.formatErrorMessage(error),
|
||||
data: { error: error.message, stack: error.stack },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
*/
|
||||
private async validateTableExists(tableName: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = $1
|
||||
) as exists
|
||||
`,
|
||||
tableName
|
||||
);
|
||||
|
||||
return (result as any)[0]?.exists || false;
|
||||
} catch (error) {
|
||||
console.error(`❌ 테이블 존재 여부 확인 실패: ${tableName}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회 (캐시 포함)
|
||||
*/
|
||||
private async getTableColumns(tableName: string): Promise<TableColumn[]> {
|
||||
// 캐시 확인
|
||||
const cached = this.columnCache.get(tableName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const columns = (await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length,
|
||||
numeric_precision,
|
||||
numeric_scale
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
ORDER BY ordinal_position
|
||||
`,
|
||||
tableName
|
||||
)) as TableColumn[];
|
||||
|
||||
// 캐시 저장 (10분)
|
||||
this.columnCache.set(tableName, columns);
|
||||
setTimeout(() => this.columnCache.delete(tableName), 10 * 60 * 1000);
|
||||
|
||||
return columns;
|
||||
} catch (error) {
|
||||
console.error(`❌ 테이블 컬럼 정보 조회 실패: ${tableName}`, error);
|
||||
throw new Error(`테이블 컬럼 정보를 조회할 수 없습니다: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹타입 정보 조회
|
||||
*/
|
||||
private async getColumnWebTypes(
|
||||
tableName: string
|
||||
): Promise<ColumnWebTypeInfo[]> {
|
||||
// 캐시 확인
|
||||
const cached = this.webTypeCache.get(tableName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// table_type_columns에서 웹타입 정보 조회
|
||||
const webTypeData = (await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT
|
||||
column_name,
|
||||
web_type,
|
||||
is_nullable,
|
||||
detail_settings
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
`,
|
||||
tableName
|
||||
)) as any[];
|
||||
|
||||
const columnWebTypes: ColumnWebTypeInfo[] = webTypeData.map((row) => ({
|
||||
columnName: row.column_name,
|
||||
webType: normalizeWebType(row.web_type || "text"),
|
||||
isRequired: row.is_nullable === "N",
|
||||
validationRules: this.parseDetailSettings(row.detail_settings),
|
||||
defaultValue: null,
|
||||
}));
|
||||
|
||||
// 캐시 저장 (10분)
|
||||
this.webTypeCache.set(tableName, columnWebTypes);
|
||||
setTimeout(() => this.webTypeCache.delete(tableName), 10 * 60 * 1000);
|
||||
|
||||
return columnWebTypes;
|
||||
} catch (error) {
|
||||
console.error(`❌ 컬럼 웹타입 정보 조회 실패: ${tableName}`, error);
|
||||
// 실패 시 빈 배열 반환 (기본 검증만 수행)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 설정 파싱
|
||||
*/
|
||||
private parseDetailSettings(
|
||||
detailSettings: string | null
|
||||
): Record<string, any> {
|
||||
if (!detailSettings) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(detailSettings);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 검증
|
||||
*/
|
||||
private async validateFormData(
|
||||
data: Record<string, any>,
|
||||
tableColumns: TableColumn[],
|
||||
columnWebTypes: ColumnWebTypeInfo[],
|
||||
tableName: string
|
||||
): Promise<FormValidationResult> {
|
||||
const errors: FormValidationError[] = [];
|
||||
const warnings: FormValidationWarning[] = [];
|
||||
const transformedData: Record<string, any> = {};
|
||||
|
||||
const columnMap = new Map(
|
||||
tableColumns.map((col) => [col.column_name, col])
|
||||
);
|
||||
const webTypeMap = new Map(columnWebTypes.map((wt) => [wt.columnName, wt]));
|
||||
|
||||
console.log(`📋 폼 데이터 검증 시작: ${tableName}`, {
|
||||
inputFields: Object.keys(data).length,
|
||||
tableColumns: tableColumns.length,
|
||||
webTypeColumns: columnWebTypes.length,
|
||||
});
|
||||
|
||||
// 입력된 각 필드 검증
|
||||
for (const [fieldName, value] of Object.entries(data)) {
|
||||
const column = columnMap.get(fieldName);
|
||||
const webTypeInfo = webTypeMap.get(fieldName);
|
||||
|
||||
// 1. 컬럼 존재 여부 확인
|
||||
if (!column) {
|
||||
errors.push({
|
||||
field: fieldName,
|
||||
code: "COLUMN_NOT_EXISTS",
|
||||
message: `테이블 '${tableName}'에 '${fieldName}' 컬럼이 존재하지 않습니다.`,
|
||||
value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 필수값 검증
|
||||
if (webTypeInfo?.isRequired && this.isEmptyValue(value)) {
|
||||
errors.push({
|
||||
field: fieldName,
|
||||
code: "REQUIRED_FIELD",
|
||||
message: `'${fieldName}'은(는) 필수 입력 항목입니다.`,
|
||||
value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 웹타입별 검증 및 변환
|
||||
if (webTypeInfo?.webType) {
|
||||
const validationResult = this.validateFieldByWebType(
|
||||
fieldName,
|
||||
value,
|
||||
webTypeInfo.webType,
|
||||
webTypeInfo.validationRules
|
||||
);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
errors.push(validationResult.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
transformedData[fieldName] = validationResult.transformedValue;
|
||||
} else {
|
||||
// 웹타입 정보가 없는 경우 DB 타입 기반 변환
|
||||
transformedData[fieldName] = this.convertValueForPostgreSQL(
|
||||
value,
|
||||
column.data_type
|
||||
);
|
||||
}
|
||||
|
||||
// 4. DB 제약조건 검증
|
||||
const constraintValidation = this.validateDatabaseConstraints(
|
||||
fieldName,
|
||||
transformedData[fieldName],
|
||||
column
|
||||
);
|
||||
|
||||
if (!constraintValidation.isValid) {
|
||||
errors.push(constraintValidation.error!);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 컬럼 누락 확인
|
||||
const requiredColumns = columnWebTypes.filter((wt) => wt.isRequired);
|
||||
for (const requiredCol of requiredColumns) {
|
||||
if (
|
||||
!(requiredCol.columnName in data) ||
|
||||
this.isEmptyValue(data[requiredCol.columnName])
|
||||
) {
|
||||
if (!errors.some((e) => e.field === requiredCol.columnName)) {
|
||||
errors.push({
|
||||
field: requiredCol.columnName,
|
||||
code: "MISSING_REQUIRED_FIELD",
|
||||
message: `필수 입력 항목 '${requiredCol.columnName}'이 누락되었습니다.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📋 폼 데이터 검증 완료:`, {
|
||||
errors: errors.length,
|
||||
warnings: warnings.length,
|
||||
transformedFields: Object.keys(transformedData).length,
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
transformedData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹타입별 필드 검증
|
||||
*/
|
||||
private validateFieldByWebType(
|
||||
fieldName: string,
|
||||
value: any,
|
||||
webType: WebType,
|
||||
validationRules?: Record<string, any>
|
||||
): { isValid: boolean; error?: FormValidationError; transformedValue?: any } {
|
||||
// 빈 값 처리
|
||||
if (this.isEmptyValue(value)) {
|
||||
return { isValid: true, transformedValue: null };
|
||||
}
|
||||
|
||||
// 웹타입 유효성 확인
|
||||
if (!isValidWebType(webType)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_WEB_TYPE",
|
||||
message: `'${fieldName}'의 웹타입 '${webType}'이 올바르지 않습니다.`,
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 패턴 검증
|
||||
const pattern = WEB_TYPE_VALIDATION_PATTERNS[webType];
|
||||
if (pattern && !pattern.test(String(value))) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_FORMAT",
|
||||
message: `'${fieldName}'의 형식이 올바르지 않습니다.`,
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 값 변환
|
||||
try {
|
||||
const converter = WEB_TYPE_TO_POSTGRES_CONVERTER[webType];
|
||||
const transformedValue = converter ? converter(value) : value;
|
||||
|
||||
return { isValid: true, transformedValue };
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "CONVERSION_ERROR",
|
||||
message: `'${fieldName}' 값 변환 중 오류가 발생했습니다: ${error}`,
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 제약조건 검증
|
||||
*/
|
||||
private validateDatabaseConstraints(
|
||||
fieldName: string,
|
||||
value: any,
|
||||
column: TableColumn
|
||||
): { isValid: boolean; error?: FormValidationError } {
|
||||
// NULL 제약조건
|
||||
if (
|
||||
column.is_nullable === "NO" &&
|
||||
(value === null || value === undefined)
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "NOT_NULL_VIOLATION",
|
||||
message: `'${fieldName}'에는 NULL 값을 입력할 수 없습니다.`,
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 문자열 길이 제약조건
|
||||
if (column.character_maximum_length && typeof value === "string") {
|
||||
if (value.length > column.character_maximum_length) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "STRING_TOO_LONG",
|
||||
message: `'${fieldName}'의 길이는 최대 ${column.character_maximum_length}자까지 입력할 수 있습니다.`,
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 숫자 정밀도 검증
|
||||
if (column.numeric_precision && typeof value === "number") {
|
||||
const totalDigits = Math.abs(value).toString().replace(".", "").length;
|
||||
if (totalDigits > column.numeric_precision) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "NUMERIC_OVERFLOW",
|
||||
message: `'${fieldName}'의 숫자 자릿수가 허용 범위를 초과했습니다.`,
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 값 확인
|
||||
*/
|
||||
private isEmptyValue(value: any): boolean {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 저장 수행
|
||||
*/
|
||||
private async performDataSave(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
tableColumns: TableColumn[]
|
||||
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
|
||||
try {
|
||||
// Primary Key 확인
|
||||
const primaryKeys = await this.getPrimaryKeys(tableName);
|
||||
const hasExistingRecord =
|
||||
primaryKeys.length > 0 &&
|
||||
primaryKeys.every((pk) => data[pk] !== undefined && data[pk] !== null);
|
||||
|
||||
if (hasExistingRecord) {
|
||||
// UPDATE 수행
|
||||
return await this.performUpdate(tableName, data, primaryKeys);
|
||||
} else {
|
||||
// INSERT 수행
|
||||
return await this.performInsert(tableName, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 데이터 저장 실패: ${tableName}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary Key 조회
|
||||
*/
|
||||
private async getPrimaryKeys(tableName: string): Promise<string[]> {
|
||||
try {
|
||||
const result = (await prisma.$queryRawUnsafe(
|
||||
`
|
||||
SELECT column_name
|
||||
FROM information_schema.key_column_usage
|
||||
WHERE table_name = $1
|
||||
AND constraint_name LIKE '%_pkey'
|
||||
`,
|
||||
tableName
|
||||
)) as any[];
|
||||
|
||||
return result.map((row) => row.column_name);
|
||||
} catch (error) {
|
||||
console.error(`❌ Primary Key 조회 실패: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT 수행
|
||||
*/
|
||||
private async performInsert(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO ${tableName} (${columns.join(", ")})
|
||||
VALUES (${placeholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
console.log(`📝 INSERT 쿼리 실행: ${tableName}`, {
|
||||
columns: columns.length,
|
||||
query: insertQuery.replace(/\n\s+/g, " "),
|
||||
});
|
||||
|
||||
const result = (await prisma.$queryRawUnsafe(
|
||||
insertQuery,
|
||||
...values
|
||||
)) as any[];
|
||||
|
||||
return {
|
||||
data: result[0],
|
||||
affectedRows: result.length,
|
||||
insertedId: result[0]?.id || result[0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE 수행
|
||||
*/
|
||||
private async performUpdate(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
primaryKeys: string[]
|
||||
): Promise<{ data?: any; affectedRows: number; insertedId?: any }> {
|
||||
const updateColumns = Object.keys(data).filter(
|
||||
(col) => !primaryKeys.includes(col)
|
||||
);
|
||||
const whereColumns = primaryKeys.filter((pk) => data[pk] !== undefined);
|
||||
|
||||
if (updateColumns.length === 0) {
|
||||
throw new Error("업데이트할 컬럼이 없습니다.");
|
||||
}
|
||||
|
||||
const setClause = updateColumns
|
||||
.map((col, index) => `${col} = $${index + 1}`)
|
||||
.join(", ");
|
||||
|
||||
const whereClause = whereColumns
|
||||
.map((col, index) => `${col} = $${updateColumns.length + index + 1}`)
|
||||
.join(" AND ");
|
||||
|
||||
const updateValues = [
|
||||
...updateColumns.map((col) => data[col]),
|
||||
...whereColumns.map((col) => data[col]),
|
||||
];
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE ${tableName}
|
||||
SET ${setClause}
|
||||
WHERE ${whereClause}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
console.log(`📝 UPDATE 쿼리 실행: ${tableName}`, {
|
||||
updateColumns: updateColumns.length,
|
||||
whereColumns: whereColumns.length,
|
||||
query: updateQuery.replace(/\n\s+/g, " "),
|
||||
});
|
||||
|
||||
const result = (await prisma.$queryRawUnsafe(
|
||||
updateQuery,
|
||||
...updateValues
|
||||
)) as any[];
|
||||
|
||||
return {
|
||||
data: result[0],
|
||||
affectedRows: result.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL 타입 변환 (레거시 지원)
|
||||
*/
|
||||
private convertValueForPostgreSQL(value: any, dataType: string): any {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowerDataType = dataType.toLowerCase();
|
||||
|
||||
// 숫자 타입 처리
|
||||
if (
|
||||
lowerDataType.includes("integer") ||
|
||||
lowerDataType.includes("bigint") ||
|
||||
lowerDataType.includes("serial")
|
||||
) {
|
||||
return parseInt(value) || null;
|
||||
}
|
||||
|
||||
if (
|
||||
lowerDataType.includes("numeric") ||
|
||||
lowerDataType.includes("decimal") ||
|
||||
lowerDataType.includes("real") ||
|
||||
lowerDataType.includes("double")
|
||||
) {
|
||||
return parseFloat(value) || null;
|
||||
}
|
||||
|
||||
// 불린 타입 처리
|
||||
if (lowerDataType.includes("boolean")) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase() === "true" || value === "1";
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
// 날짜/시간 타입 처리
|
||||
if (
|
||||
lowerDataType.includes("timestamp") ||
|
||||
lowerDataType.includes("datetime")
|
||||
) {
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
if (lowerDataType.includes("date")) {
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// JSON 타입 처리
|
||||
if (lowerDataType.includes("json")) {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return value;
|
||||
} catch {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류 메시지 포맷팅
|
||||
*/
|
||||
private formatValidationErrors(errors: FormValidationError[]): string {
|
||||
if (errors.length === 0) return "알 수 없는 오류가 발생했습니다.";
|
||||
if (errors.length === 1) return errors[0].message;
|
||||
|
||||
return `다음 오류들을 수정해주세요:\n• ${errors.map((e) => e.message).join("\n• ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 오류 메시지 포맷팅
|
||||
*/
|
||||
private formatErrorMessage(error: any): string {
|
||||
if (error.code === "23505") {
|
||||
return "중복된 데이터가 이미 존재합니다.";
|
||||
}
|
||||
|
||||
if (error.code === "23503") {
|
||||
return "참조 무결성 제약조건을 위반했습니다.";
|
||||
}
|
||||
|
||||
if (error.code === "23502") {
|
||||
return "필수 입력 항목이 누락되었습니다.";
|
||||
}
|
||||
|
||||
if (
|
||||
error.message?.includes("relation") &&
|
||||
error.message?.includes("does not exist")
|
||||
) {
|
||||
return "지정된 테이블이 존재하지 않습니다.";
|
||||
}
|
||||
|
||||
return `저장 중 오류가 발생했습니다: ${error.message || error}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 클리어
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.columnCache.clear();
|
||||
this.webTypeCache.clear();
|
||||
console.log("🧹 동적 폼 서비스 캐시가 클리어되었습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블별 캐시 클리어
|
||||
*/
|
||||
public clearTableCache(tableName: string): void {
|
||||
this.columnCache.delete(tableName);
|
||||
this.webTypeCache.delete(tableName);
|
||||
console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const enhancedDynamicFormService = new EnhancedDynamicFormService();
|
||||
|
|
@ -239,13 +239,43 @@ export class ExternalDbConnectionService {
|
|||
}
|
||||
}
|
||||
|
||||
// 비밀번호가 변경되는 경우, 연결 테스트 먼저 수행
|
||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||
// 임시 연결 설정으로 테스트
|
||||
const testConfig = {
|
||||
host: data.host || existingConnection.host,
|
||||
port: data.port || existingConnection.port,
|
||||
database: data.database_name || existingConnection.database_name,
|
||||
user: data.username || existingConnection.username,
|
||||
password: data.password, // 새로 입력된 비밀번호로 테스트
|
||||
connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined,
|
||||
ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false
|
||||
};
|
||||
|
||||
// 연결 테스트 수행
|
||||
const testResult = await DbConnectionManager.testConnection(
|
||||
id,
|
||||
existingConnection.db_type,
|
||||
testConfig
|
||||
);
|
||||
|
||||
if (!testResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
|
||||
error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 업데이트 데이터 준비
|
||||
const updateData: any = {
|
||||
...data,
|
||||
updated_date: new Date(),
|
||||
};
|
||||
|
||||
// 비밀번호가 변경된 경우 암호화
|
||||
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
|
||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||
updateData.password = PasswordEncryption.encrypt(data.password);
|
||||
} else {
|
||||
|
|
@ -320,7 +350,8 @@ export class ExternalDbConnectionService {
|
|||
* 데이터베이스 연결 테스트 (ID 기반)
|
||||
*/
|
||||
static async testConnectionById(
|
||||
id: number
|
||||
id: number,
|
||||
testData?: { password?: string }
|
||||
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||
try {
|
||||
// 저장된 연결 정보 조회
|
||||
|
|
@ -339,9 +370,15 @@ export class ExternalDbConnectionService {
|
|||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = await this.getDecryptedPassword(id);
|
||||
if (!decryptedPassword) {
|
||||
// 비밀번호 결정 (테스트용 비밀번호가 제공된 경우 그것을 사용, 아니면 저장된 비밀번호 복호화)
|
||||
let password: string | null;
|
||||
if (testData?.password) {
|
||||
password = testData.password;
|
||||
} else {
|
||||
password = await this.getDecryptedPassword(id);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return {
|
||||
success: false,
|
||||
message: "비밀번호 복호화에 실패했습니다.",
|
||||
|
|
@ -358,7 +395,7 @@ export class ExternalDbConnectionService {
|
|||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
password: password,
|
||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* 입력 타입 처리 서비스
|
||||
* VARCHAR 통일 방식에서 입력 타입별 형변환 및 검증 처리
|
||||
*/
|
||||
|
||||
import { InputType } from "../types/input-types";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
message?: string;
|
||||
convertedValue?: string;
|
||||
}
|
||||
|
||||
export class InputTypeService {
|
||||
/**
|
||||
* 데이터 저장 전 형변환 (화면 입력값 → DB 저장값)
|
||||
* 모든 값을 VARCHAR(500)에 저장하기 위해 문자열로 변환
|
||||
*/
|
||||
static convertForStorage(value: any, inputType: InputType): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
case "select":
|
||||
case "radio":
|
||||
return String(value).trim();
|
||||
|
||||
case "number":
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
return "0";
|
||||
}
|
||||
const num = parseFloat(String(value));
|
||||
return isNaN(num) ? "0" : String(num);
|
||||
|
||||
case "date":
|
||||
if (!value || value === "") {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) {
|
||||
logger.warn(`Invalid date value: ${value}`);
|
||||
return "";
|
||||
}
|
||||
return date.toISOString().split("T")[0]; // YYYY-MM-DD 형식
|
||||
|
||||
case "checkbox":
|
||||
// 다양한 형태의 true 값을 "Y"로, 나머지는 "N"으로 변환
|
||||
const truthyValues = ["true", "1", "Y", "yes", "on", true, 1];
|
||||
return truthyValues.includes(value) ? "Y" : "N";
|
||||
|
||||
case "code":
|
||||
case "entity":
|
||||
return String(value || "").trim();
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error converting value for storage: ${error}`, {
|
||||
value,
|
||||
inputType,
|
||||
});
|
||||
return String(value || "");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 표시용 형변환 (DB 저장값 → 화면 표시값)
|
||||
* VARCHAR에서 읽어온 문자열을 적절한 타입으로 변환
|
||||
*/
|
||||
static convertForDisplay(value: string, inputType: InputType): any {
|
||||
if (!value && value !== "0") {
|
||||
// 빈 값 처리
|
||||
switch (inputType) {
|
||||
case "number":
|
||||
return 0;
|
||||
case "checkbox":
|
||||
return false;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
case "select":
|
||||
case "radio":
|
||||
case "code":
|
||||
case "entity":
|
||||
return value;
|
||||
|
||||
case "number":
|
||||
const num = parseFloat(value);
|
||||
return isNaN(num) ? 0 : num;
|
||||
|
||||
case "date":
|
||||
// YYYY-MM-DD 형식 그대로 반환 (HTML date input 호환)
|
||||
return value;
|
||||
|
||||
case "checkbox":
|
||||
return value === "Y" || value === "true" || value === "1";
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error converting value for display: ${error}`, {
|
||||
value,
|
||||
inputType,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력값 검증
|
||||
* 저장 전에 값이 해당 입력 타입에 적합한지 검증
|
||||
*/
|
||||
static validate(value: any, inputType: InputType): ValidationResult {
|
||||
// 빈 값은 일반적으로 허용 (필수 여부는 별도 검증)
|
||||
if (!value && value !== 0 && value !== false) {
|
||||
return {
|
||||
isValid: true,
|
||||
convertedValue: this.convertForStorage(value, inputType),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
case "select":
|
||||
case "radio":
|
||||
case "code":
|
||||
case "entity":
|
||||
const strValue = String(value).trim();
|
||||
if (strValue.length > 500) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "입력값이 너무 깁니다. (최대 500자)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
convertedValue: this.convertForStorage(value, inputType),
|
||||
};
|
||||
|
||||
case "number":
|
||||
const num = parseFloat(String(value));
|
||||
if (isNaN(num)) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "숫자 형식이 올바르지 않습니다.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
convertedValue: this.convertForStorage(value, inputType),
|
||||
};
|
||||
|
||||
case "date":
|
||||
if (!value) {
|
||||
return { isValid: true, convertedValue: "" };
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: "날짜 형식이 올바르지 않습니다.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
convertedValue: this.convertForStorage(value, inputType),
|
||||
};
|
||||
|
||||
case "checkbox":
|
||||
// 체크박스는 모든 값을 허용 (Y/N으로 변환)
|
||||
return {
|
||||
isValid: true,
|
||||
convertedValue: this.convertForStorage(value, inputType),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
isValid: true,
|
||||
convertedValue: this.convertForStorage(value, inputType),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error validating value: ${error}`, { value, inputType });
|
||||
return {
|
||||
isValid: false,
|
||||
message: "값 검증 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 데이터 변환 (여러 필드를 한번에 처리)
|
||||
*/
|
||||
static convertBatchForStorage(
|
||||
data: Record<string, any>,
|
||||
columnTypes: Record<string, InputType>
|
||||
): Record<string, string> {
|
||||
const converted: Record<string, string> = {};
|
||||
|
||||
for (const [columnName, value] of Object.entries(data)) {
|
||||
const inputType = columnTypes[columnName];
|
||||
if (inputType) {
|
||||
converted[columnName] = this.convertForStorage(value, inputType);
|
||||
} else {
|
||||
// 입력 타입이 정의되지 않은 경우 기본적으로 text로 처리
|
||||
converted[columnName] = this.convertForStorage(value, "text");
|
||||
}
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 데이터 표시용 변환
|
||||
*/
|
||||
static convertBatchForDisplay(
|
||||
data: Record<string, string>,
|
||||
columnTypes: Record<string, InputType>
|
||||
): Record<string, any> {
|
||||
const converted: Record<string, any> = {};
|
||||
|
||||
for (const [columnName, value] of Object.entries(data)) {
|
||||
const inputType = columnTypes[columnName];
|
||||
if (inputType) {
|
||||
converted[columnName] = this.convertForDisplay(value, inputType);
|
||||
} else {
|
||||
// 입력 타입이 정의되지 않은 경우 문자열 그대로 반환
|
||||
converted[columnName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 데이터 검증
|
||||
*/
|
||||
static validateBatch(
|
||||
data: Record<string, any>,
|
||||
columnTypes: Record<string, InputType>
|
||||
): {
|
||||
isValid: boolean;
|
||||
errors: Record<string, string>;
|
||||
convertedData: Record<string, string>;
|
||||
} {
|
||||
const errors: Record<string, string> = {};
|
||||
const convertedData: Record<string, string> = {};
|
||||
|
||||
for (const [columnName, value] of Object.entries(data)) {
|
||||
const inputType = columnTypes[columnName];
|
||||
if (inputType) {
|
||||
const result = this.validate(value, inputType);
|
||||
if (!result.isValid) {
|
||||
errors[columnName] = result.message || "검증 실패";
|
||||
} else {
|
||||
convertedData[columnName] = result.convertedValue || "";
|
||||
}
|
||||
} else {
|
||||
// 입력 타입이 정의되지 않은 경우 기본적으로 text로 처리
|
||||
convertedData[columnName] = this.convertForStorage(value, "text");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
convertedData,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1013,23 +1013,52 @@ export class ScreenManagementService {
|
|||
* 데이터 타입으로부터 웹타입 추론
|
||||
*/
|
||||
private inferWebType(dataType: string): WebType {
|
||||
// 통합 타입 매핑에서 import
|
||||
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
|
||||
|
||||
const lowerType = dataType.toLowerCase();
|
||||
|
||||
if (lowerType.includes("char") || lowerType.includes("text")) {
|
||||
return "text";
|
||||
} else if (
|
||||
lowerType.includes("int") ||
|
||||
lowerType.includes("numeric") ||
|
||||
lowerType.includes("decimal")
|
||||
) {
|
||||
// 정확한 매핑 우선 확인
|
||||
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
|
||||
return DB_TYPE_TO_WEB_TYPE[lowerType];
|
||||
}
|
||||
|
||||
// 부분 문자열 매칭 (더 정교한 규칙)
|
||||
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
|
||||
if (
|
||||
lowerType.includes(dbType.toLowerCase()) ||
|
||||
dbType.toLowerCase().includes(lowerType)
|
||||
) {
|
||||
return webType as WebType;
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 정밀 매핑
|
||||
if (lowerType.includes("int") && !lowerType.includes("point")) {
|
||||
return "number";
|
||||
} else if (lowerType.includes("date") || lowerType.includes("time")) {
|
||||
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
|
||||
return "decimal";
|
||||
} else if (
|
||||
lowerType.includes("timestamp") ||
|
||||
lowerType.includes("datetime")
|
||||
) {
|
||||
return "datetime";
|
||||
} else if (lowerType.includes("date")) {
|
||||
return "date";
|
||||
} else if (lowerType.includes("time")) {
|
||||
return "datetime";
|
||||
} else if (lowerType.includes("bool")) {
|
||||
return "checkbox";
|
||||
} else {
|
||||
return "text";
|
||||
} else if (
|
||||
lowerType.includes("char") ||
|
||||
lowerType.includes("text") ||
|
||||
lowerType.includes("varchar")
|
||||
) {
|
||||
return lowerType.includes("text") ? "textarea" : "text";
|
||||
}
|
||||
|
||||
// 기본값
|
||||
return "text";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
EntityJoinResponse,
|
||||
EntityJoinConfig,
|
||||
} from "../types/tableManagement";
|
||||
import { WebType } from "../types/unified-web-types";
|
||||
import { entityJoinService } from "./entityJoinService";
|
||||
import { referenceCacheService } from "./referenceCacheService";
|
||||
|
||||
|
|
@ -210,6 +211,11 @@ export class TableManagementService {
|
|||
: null,
|
||||
numericScale: column.numericScale ? Number(column.numericScale) : null,
|
||||
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
|
||||
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
|
||||
webType:
|
||||
column.webType === "text"
|
||||
? this.inferWebType(column.dataType)
|
||||
: column.webType,
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
|
@ -323,7 +329,7 @@ export class TableManagementService {
|
|||
},
|
||||
update: {
|
||||
column_label: settings.columnLabel,
|
||||
web_type: settings.webType,
|
||||
input_type: settings.inputType,
|
||||
detail_settings: settings.detailSettings,
|
||||
code_category: settings.codeCategory,
|
||||
code_value: settings.codeValue,
|
||||
|
|
@ -339,7 +345,7 @@ export class TableManagementService {
|
|||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
column_label: settings.columnLabel,
|
||||
web_type: settings.webType,
|
||||
input_type: settings.inputType,
|
||||
detail_settings: settings.detailSettings,
|
||||
code_category: settings.codeCategory,
|
||||
code_value: settings.codeValue,
|
||||
|
|
@ -620,7 +626,123 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 웹 타입별 기본 상세 설정 생성
|
||||
* 컬럼 입력 타입 설정 (새로운 시스템)
|
||||
*/
|
||||
async updateColumnInputType(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
inputType: string,
|
||||
detailSettings?: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}`
|
||||
);
|
||||
|
||||
// 입력 타입별 기본 상세 설정 생성
|
||||
const defaultDetailSettings =
|
||||
this.generateDefaultInputTypeSettings(inputType);
|
||||
|
||||
// 사용자 정의 설정과 기본 설정 병합
|
||||
const finalDetailSettings = {
|
||||
...defaultDetailSettings,
|
||||
...detailSettings,
|
||||
};
|
||||
|
||||
// table_type_columns 테이블에서 업데이트
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO table_type_columns (
|
||||
table_name, column_name, input_type, detail_settings,
|
||||
is_nullable, display_order, created_date, updated_date
|
||||
) VALUES (
|
||||
${tableName}, ${columnName}, ${inputType}, ${JSON.stringify(finalDetailSettings)},
|
||||
'Y', 0, now(), now()
|
||||
)
|
||||
ON CONFLICT (table_name, column_name)
|
||||
DO UPDATE SET
|
||||
input_type = ${inputType},
|
||||
detail_settings = ${JSON.stringify(finalDetailSettings)},
|
||||
updated_date = now();
|
||||
`;
|
||||
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`컬럼 입력 타입 설정 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 타입별 기본 상세 설정 생성
|
||||
*/
|
||||
private generateDefaultInputTypeSettings(
|
||||
inputType: string
|
||||
): Record<string, any> {
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
return {
|
||||
maxLength: 500,
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
};
|
||||
|
||||
case "number":
|
||||
return {
|
||||
min: 0,
|
||||
step: 1,
|
||||
placeholder: "숫자를 입력하세요",
|
||||
};
|
||||
|
||||
case "date":
|
||||
return {
|
||||
format: "YYYY-MM-DD",
|
||||
placeholder: "날짜를 선택하세요",
|
||||
};
|
||||
|
||||
case "code":
|
||||
return {
|
||||
placeholder: "코드를 선택하세요",
|
||||
searchable: true,
|
||||
};
|
||||
|
||||
case "entity":
|
||||
return {
|
||||
placeholder: "항목을 선택하세요",
|
||||
searchable: true,
|
||||
};
|
||||
|
||||
case "select":
|
||||
return {
|
||||
placeholder: "선택하세요",
|
||||
searchable: false,
|
||||
};
|
||||
|
||||
case "checkbox":
|
||||
return {
|
||||
defaultChecked: false,
|
||||
trueValue: "Y",
|
||||
falseValue: "N",
|
||||
};
|
||||
|
||||
case "radio":
|
||||
return {
|
||||
inline: false,
|
||||
};
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹 타입별 기본 상세 설정 생성 (레거시 지원)
|
||||
* @deprecated generateDefaultInputTypeSettings 사용 권장
|
||||
*/
|
||||
private generateDefaultDetailSettings(webType: string): Record<string, any> {
|
||||
switch (webType) {
|
||||
|
|
@ -897,6 +1019,434 @@ export class TableManagementService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고급 검색 조건 구성
|
||||
*/
|
||||
private async buildAdvancedSearchCondition(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
||||
if (
|
||||
value === "__ALL__" ||
|
||||
value === "" ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 컬럼 타입 정보 조회
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
|
||||
if (!columnInfo) {
|
||||
// 컬럼 정보가 없으면 기본 문자열 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const webType = columnInfo.webType;
|
||||
|
||||
// 웹타입별 검색 조건 구성
|
||||
switch (webType) {
|
||||
case "date":
|
||||
case "datetime":
|
||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return this.buildNumberRangeCondition(columnName, value, paramIndex);
|
||||
|
||||
case "code":
|
||||
return await this.buildCodeSearchCondition(
|
||||
tableName,
|
||||
columnName,
|
||||
value,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
case "entity":
|
||||
return await this.buildEntitySearchCondition(
|
||||
tableName,
|
||||
columnName,
|
||||
value,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
default:
|
||||
// 기본 문자열 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`고급 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
// 오류 시 기본 검색으로 폴백
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위 검색 조건 구성
|
||||
*/
|
||||
private buildDateRangeCondition(
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): {
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} {
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (value.from) {
|
||||
conditions.push(`${columnName} >= $${paramIndex + paramCount}`);
|
||||
values.push(value.from);
|
||||
paramCount++;
|
||||
}
|
||||
if (value.to) {
|
||||
conditions.push(`${columnName} <= $${paramIndex + paramCount}`);
|
||||
values.push(value.to);
|
||||
paramCount++;
|
||||
}
|
||||
} else if (typeof value === "string" && value.trim() !== "") {
|
||||
// 단일 날짜 검색 (해당 날짜의 데이터)
|
||||
conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`);
|
||||
values.push(value);
|
||||
paramCount = 1;
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: `(${conditions.join(" AND ")})`,
|
||||
values,
|
||||
paramCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 범위 검색 조건 구성
|
||||
*/
|
||||
private buildNumberRangeCondition(
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): {
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} {
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (value.min !== undefined && value.min !== null && value.min !== "") {
|
||||
conditions.push(
|
||||
`${columnName}::numeric >= $${paramIndex + paramCount}`
|
||||
);
|
||||
values.push(parseFloat(value.min));
|
||||
paramCount++;
|
||||
}
|
||||
if (value.max !== undefined && value.max !== null && value.max !== "") {
|
||||
conditions.push(
|
||||
`${columnName}::numeric <= $${paramIndex + paramCount}`
|
||||
);
|
||||
values.push(parseFloat(value.max));
|
||||
paramCount++;
|
||||
}
|
||||
} else if (typeof value === "string" || typeof value === "number") {
|
||||
// 정확한 값 검색
|
||||
conditions.push(`${columnName}::numeric = $${paramIndex}`);
|
||||
values.push(parseFloat(value.toString()));
|
||||
paramCount = 1;
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: `(${conditions.join(" AND ")})`,
|
||||
values,
|
||||
paramCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 검색 조건 구성
|
||||
*/
|
||||
private async buildCodeSearchCondition(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
}> {
|
||||
try {
|
||||
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
||||
|
||||
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
||||
// 코드 타입이 아니면 기본 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
// 코드값 또는 코드명으로 검색
|
||||
return {
|
||||
whereClause: `(
|
||||
${columnName}::text = $${paramIndex} OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM code_info ci
|
||||
WHERE ci.code_category = $${paramIndex + 1}
|
||||
AND ci.code_value = ${columnName}
|
||||
AND ci.code_name ILIKE $${paramIndex + 2}
|
||||
)
|
||||
)`,
|
||||
values: [value, codeTypeInfo.codeCategory, `%${value}%`],
|
||||
paramCount: 3,
|
||||
};
|
||||
} else {
|
||||
// 정확한 코드값 매칭
|
||||
return {
|
||||
whereClause: `${columnName} = $${paramIndex}`,
|
||||
values: [value],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`코드 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티 검색 조건 구성
|
||||
*/
|
||||
private async buildEntitySearchCondition(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
}> {
|
||||
try {
|
||||
const entityTypeInfo = await this.getEntityTypeInfo(
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
|
||||
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
|
||||
// 엔티티 타입이 아니면 기본 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const displayColumn = entityTypeInfo.displayColumn || "name";
|
||||
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
||||
|
||||
// 참조 테이블의 표시 컬럼으로 검색
|
||||
return {
|
||||
whereClause: `EXISTS (
|
||||
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref
|
||||
WHERE ref.${referenceColumn} = ${columnName}
|
||||
AND ref.${displayColumn} ILIKE $${paramIndex}
|
||||
)`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
} else {
|
||||
// 정확한 참조값 매칭
|
||||
return {
|
||||
whereClause: `${columnName} = $${paramIndex}`,
|
||||
values: [value],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`엔티티 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 불린 검색 조건 구성
|
||||
*/
|
||||
private buildBooleanCondition(
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): {
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} {
|
||||
if (value === "true" || value === true) {
|
||||
return {
|
||||
whereClause: `${columnName} = true`,
|
||||
values: [],
|
||||
paramCount: 0,
|
||||
};
|
||||
} else if (value === "false" || value === false) {
|
||||
return {
|
||||
whereClause: `${columnName} = false`,
|
||||
values: [],
|
||||
paramCount: 0,
|
||||
};
|
||||
} else {
|
||||
// 기본 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹타입 정보 조회
|
||||
*/
|
||||
private async getColumnWebTypeInfo(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<{
|
||||
webType: string;
|
||||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
} | null> {
|
||||
try {
|
||||
const result = await prisma.column_labels.findFirst({
|
||||
where: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
},
|
||||
select: {
|
||||
web_type: true,
|
||||
code_category: true,
|
||||
reference_table: true,
|
||||
reference_column: true,
|
||||
display_column: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
webType: result.web_type || "",
|
||||
codeCategory: result.code_category || undefined,
|
||||
referenceTable: result.reference_table || undefined,
|
||||
referenceColumn: result.reference_column || undefined,
|
||||
displayColumn: result.display_column || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티 타입 정보 조회
|
||||
*/
|
||||
private async getEntityTypeInfo(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<{
|
||||
isEntityType: boolean;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
}> {
|
||||
try {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
|
||||
if (!columnInfo || columnInfo.webType !== "entity") {
|
||||
return { isEntityType: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isEntityType: true,
|
||||
referenceTable: columnInfo.referenceTable,
|
||||
referenceColumn: columnInfo.referenceColumn,
|
||||
displayColumn: columnInfo.displayColumn,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`엔티티 타입 정보 조회 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return { isEntityType: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
*/
|
||||
|
|
@ -949,42 +1499,19 @@ export class TableManagementService {
|
|||
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
||||
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
if (typeof value === "string") {
|
||||
// 🎯 코드 타입 컬럼의 경우 코드값과 코드명 모두로 검색
|
||||
const codeTypeInfo = await this.getCodeTypeInfo(
|
||||
tableName,
|
||||
safeColumn
|
||||
);
|
||||
// 🎯 고급 필터 처리
|
||||
const condition = await this.buildAdvancedSearchCondition(
|
||||
tableName,
|
||||
safeColumn,
|
||||
value,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
if (codeTypeInfo.isCodeType && codeTypeInfo.codeCategory) {
|
||||
// 코드 타입 컬럼: 코드값 또는 코드명으로 검색
|
||||
// 1) 컬럼 값이 직접 검색어와 일치하는 경우
|
||||
// 2) 컬럼 값이 코드값이고, 해당 코드의 코드명이 검색어와 일치하는 경우
|
||||
whereConditions.push(`(
|
||||
${safeColumn}::text ILIKE $${paramIndex} OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM code_info ci
|
||||
WHERE ci.code_category = $${paramIndex + 1}
|
||||
AND ci.code_value = ${safeColumn}
|
||||
AND ci.code_name ILIKE $${paramIndex + 2}
|
||||
)
|
||||
)`);
|
||||
searchValues.push(`%${value}%`); // 직접 값 검색용
|
||||
searchValues.push(codeTypeInfo.codeCategory); // 코드 카테고리
|
||||
searchValues.push(`%${value}%`); // 코드명 검색용
|
||||
paramIndex += 2; // 추가 파라미터로 인해 인덱스 증가
|
||||
} else {
|
||||
// 일반 컬럼: 기존 방식
|
||||
whereConditions.push(
|
||||
`${safeColumn}::text ILIKE $${paramIndex}`
|
||||
);
|
||||
searchValues.push(`%${value}%`);
|
||||
}
|
||||
} else {
|
||||
whereConditions.push(`${safeColumn} = $${paramIndex}`);
|
||||
searchValues.push(value);
|
||||
if (condition) {
|
||||
whereConditions.push(condition.whereClause);
|
||||
searchValues.push(...condition.values);
|
||||
paramIndex += condition.paramCount;
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1576,7 +2103,10 @@ export class TableManagementService {
|
|||
const selectColumns = columns.data.map((col: any) => col.column_name);
|
||||
|
||||
// WHERE 절 구성
|
||||
const whereClause = this.buildWhereClause(options.search);
|
||||
const whereClause = await this.buildWhereClause(
|
||||
tableName,
|
||||
options.search
|
||||
);
|
||||
|
||||
// ORDER BY 절 구성
|
||||
const orderBy = options.sortBy
|
||||
|
|
@ -1939,21 +2469,77 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* WHERE 절 구성
|
||||
* WHERE 절 구성 (고급 검색 지원)
|
||||
*/
|
||||
private buildWhereClause(search?: Record<string, any>): string {
|
||||
private async buildWhereClause(
|
||||
tableName: string,
|
||||
search?: Record<string, any>
|
||||
): Promise<string> {
|
||||
if (!search || Object.keys(search).length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(search)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
for (const [columnName, value] of Object.entries(search)) {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
value === "__ALL__"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 고급 검색 조건 구성
|
||||
const searchCondition = await this.buildAdvancedSearchCondition(
|
||||
tableName,
|
||||
columnName,
|
||||
value,
|
||||
1 // paramIndex는 실제로는 사용되지 않음 (직접 값 삽입)
|
||||
);
|
||||
|
||||
if (searchCondition) {
|
||||
// SQL 인젝션 방지를 위해 값을 직접 삽입하는 대신 안전한 방식 사용
|
||||
let condition = searchCondition.whereClause;
|
||||
|
||||
// 파라미터를 실제 값으로 치환 (안전한 방식)
|
||||
searchCondition.values.forEach((val, index) => {
|
||||
const paramPlaceholder = `$${index + 1}`;
|
||||
if (typeof val === "string") {
|
||||
condition = condition.replace(
|
||||
paramPlaceholder,
|
||||
`'${val.replace(/'/g, "''")}'`
|
||||
);
|
||||
} else if (typeof val === "number") {
|
||||
condition = condition.replace(paramPlaceholder, val.toString());
|
||||
} else {
|
||||
condition = condition.replace(
|
||||
paramPlaceholder,
|
||||
`'${String(val).replace(/'/g, "''")}'`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// main. 접두사 추가 (조인 쿼리용)
|
||||
condition = condition.replace(
|
||||
new RegExp(`\\b${columnName}\\b`, "g"),
|
||||
`main.${columnName}`
|
||||
);
|
||||
conditions.push(condition);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`검색 조건 구성 실패: ${columnName}`, error);
|
||||
// 폴백: 기본 문자열 검색
|
||||
if (typeof value === "string") {
|
||||
conditions.push(`main.${key} ILIKE '%${value}%'`);
|
||||
conditions.push(
|
||||
`main.${columnName}::text ILIKE '%${value.replace(/'/g, "''")}%'`
|
||||
);
|
||||
} else {
|
||||
conditions.push(`main.${key} = '${value}'`);
|
||||
conditions.push(
|
||||
`main.${columnName} = '${String(value).replace(/'/g, "''")}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2267,4 +2853,237 @@ export class TableManagementService {
|
|||
|
||||
return totalHitRate / cacheableJoins.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
|
||||
*/
|
||||
async getTableSchema(tableName: string): Promise<ColumnTypeInfo[]> {
|
||||
try {
|
||||
logger.info(`테이블 스키마 정보 조회: ${tableName}`);
|
||||
|
||||
const rawColumns = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
column_name as "columnName",
|
||||
column_name as "displayName",
|
||||
data_type as "dataType",
|
||||
udt_name as "dbType",
|
||||
is_nullable as "isNullable",
|
||||
column_default as "defaultValue",
|
||||
character_maximum_length as "maxLength",
|
||||
numeric_precision as "numericPrecision",
|
||||
numeric_scale as "numericScale",
|
||||
CASE
|
||||
WHEN column_name IN (
|
||||
SELECT column_name FROM information_schema.key_column_usage
|
||||
WHERE table_name = ${tableName} AND constraint_name LIKE '%_pkey'
|
||||
) THEN true
|
||||
ELSE false
|
||||
END as "isPrimaryKey"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = ${tableName}
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position
|
||||
`;
|
||||
|
||||
const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({
|
||||
tableName: tableName,
|
||||
columnName: col.columnName,
|
||||
displayName: col.displayName,
|
||||
dataType: col.dataType,
|
||||
dbType: col.dbType,
|
||||
webType: "text", // 기본값
|
||||
inputType: "direct",
|
||||
detailSettings: "{}",
|
||||
description: "", // 필수 필드 추가
|
||||
isNullable: col.isNullable,
|
||||
isPrimaryKey: col.isPrimaryKey,
|
||||
defaultValue: col.defaultValue,
|
||||
maxLength: col.maxLength ? Number(col.maxLength) : undefined,
|
||||
numericPrecision: col.numericPrecision
|
||||
? Number(col.numericPrecision)
|
||||
: undefined,
|
||||
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
||||
displayOrder: 0,
|
||||
isVisible: true,
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
`테이블 스키마 조회 완료: ${tableName}, ${columns.length}개 컬럼`
|
||||
);
|
||||
return columns;
|
||||
} catch (error) {
|
||||
logger.error(`테이블 스키마 조회 실패: ${tableName}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
*/
|
||||
async checkTableExists(tableName: string): Promise<boolean> {
|
||||
try {
|
||||
logger.info(`테이블 존재 여부 확인: ${tableName}`);
|
||||
|
||||
const result = await prisma.$queryRaw<any[]>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = ${tableName}
|
||||
AND table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
) as "exists"
|
||||
`;
|
||||
|
||||
const exists = result[0]?.exists || false;
|
||||
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
|
||||
return exists;
|
||||
} catch (error) {
|
||||
logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 입력타입 정보 조회 (화면관리 연동용)
|
||||
*/
|
||||
async getColumnInputTypes(tableName: string): Promise<ColumnTypeInfo[]> {
|
||||
try {
|
||||
logger.info(`컬럼 입력타입 정보 조회: ${tableName}`);
|
||||
|
||||
// table_type_columns에서 입력타입 정보 조회
|
||||
const rawInputTypes = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
ttc.column_name as "columnName",
|
||||
ttc.column_name as "displayName",
|
||||
COALESCE(ttc.input_type, 'text') as "inputType",
|
||||
COALESCE(ttc.detail_settings, '{}') as "detailSettings",
|
||||
ttc.is_nullable as "isNullable",
|
||||
ic.data_type as "dataType"
|
||||
FROM table_type_columns ttc
|
||||
LEFT JOIN information_schema.columns ic
|
||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
||||
WHERE ttc.table_name = ${tableName}
|
||||
ORDER BY ttc.display_order, ttc.column_name
|
||||
`;
|
||||
|
||||
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
|
||||
tableName: tableName,
|
||||
columnName: col.columnName,
|
||||
displayName: col.displayName,
|
||||
dataType: col.dataType || "varchar",
|
||||
inputType: col.inputType,
|
||||
detailSettings: col.detailSettings,
|
||||
description: "", // 필수 필드 추가
|
||||
isNullable: col.isNullable,
|
||||
isPrimaryKey: false,
|
||||
displayOrder: 0,
|
||||
isVisible: true,
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
`컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼`
|
||||
);
|
||||
return inputTypes;
|
||||
} catch (error) {
|
||||
logger.error(`컬럼 입력타입 정보 조회 실패: ${tableName}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 지원: 컬럼 웹타입 정보 조회
|
||||
* @deprecated getColumnInputTypes 사용 권장
|
||||
*/
|
||||
async getColumnWebTypes(tableName: string): Promise<ColumnTypeInfo[]> {
|
||||
logger.warn(
|
||||
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
|
||||
);
|
||||
return this.getColumnInputTypes(tableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 연결 상태 확인
|
||||
*/
|
||||
async checkDatabaseConnection(): Promise<{
|
||||
connected: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
logger.info("데이터베이스 연결 상태 확인");
|
||||
|
||||
// 간단한 쿼리로 연결 테스트
|
||||
const result = await prisma.$queryRaw<any[]>`SELECT 1 as "test"`;
|
||||
|
||||
if (result && result.length > 0) {
|
||||
logger.info("데이터베이스 연결 성공");
|
||||
return {
|
||||
connected: true,
|
||||
message: "데이터베이스에 성공적으로 연결되었습니다.",
|
||||
};
|
||||
} else {
|
||||
logger.warn("데이터베이스 연결 응답 없음");
|
||||
return {
|
||||
connected: false,
|
||||
message: "데이터베이스 연결 응답이 없습니다.",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("데이터베이스 연결 확인 실패:", error);
|
||||
return {
|
||||
connected: false,
|
||||
message: `데이터베이스 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 타입으로부터 웹타입 추론
|
||||
*/
|
||||
private inferWebType(dataType: string): WebType {
|
||||
// 통합 타입 매핑에서 import
|
||||
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
|
||||
|
||||
const lowerType = dataType.toLowerCase();
|
||||
|
||||
// 정확한 매핑 우선 확인
|
||||
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
|
||||
return DB_TYPE_TO_WEB_TYPE[lowerType];
|
||||
}
|
||||
|
||||
// 부분 문자열 매칭 (더 정교한 규칙)
|
||||
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
|
||||
if (
|
||||
lowerType.includes(dbType.toLowerCase()) ||
|
||||
dbType.toLowerCase().includes(lowerType)
|
||||
) {
|
||||
return webType as WebType;
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 정밀 매핑
|
||||
if (lowerType.includes("int") && !lowerType.includes("point")) {
|
||||
return "number";
|
||||
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
|
||||
return "decimal";
|
||||
} else if (
|
||||
lowerType.includes("timestamp") ||
|
||||
lowerType.includes("datetime")
|
||||
) {
|
||||
return "datetime";
|
||||
} else if (lowerType.includes("date")) {
|
||||
return "date";
|
||||
} else if (lowerType.includes("time")) {
|
||||
return "datetime";
|
||||
} else if (lowerType.includes("bool")) {
|
||||
return "checkbox";
|
||||
} else if (
|
||||
lowerType.includes("char") ||
|
||||
lowerType.includes("text") ||
|
||||
lowerType.includes("varchar")
|
||||
) {
|
||||
return lowerType.includes("text") ? "textarea" : "text";
|
||||
}
|
||||
|
||||
// 기본값
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* DDL 실행 관련 타입 정의
|
||||
*/
|
||||
|
||||
// 기본 웹타입
|
||||
export type WebType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "decimal"
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "boolean"
|
||||
| "code"
|
||||
| "entity"
|
||||
| "textarea"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "radio"
|
||||
| "file"
|
||||
| "email"
|
||||
| "tel";
|
||||
|
||||
// 컬럼 정의 인터페이스
|
||||
export interface CreateColumnDefinition {
|
||||
/** 컬럼명 (영문자, 숫자, 언더스코어만 허용) */
|
||||
name: string;
|
||||
/** 컬럼 라벨 (화면 표시용) */
|
||||
label?: string;
|
||||
/** 입력타입 */
|
||||
inputType?: string;
|
||||
/** 웹타입 (레거시 호환용) */
|
||||
webType?: WebType;
|
||||
/** NULL 허용 여부 */
|
||||
nullable?: boolean;
|
||||
/** 컬럼 길이 (text, code 타입에서 사용) */
|
||||
length?: number;
|
||||
/** 기본값 */
|
||||
defaultValue?: string;
|
||||
/** 컬럼 설명 */
|
||||
description?: string;
|
||||
/** 표시 순서 */
|
||||
order?: number;
|
||||
/** 상세 설정 (JSON 형태) */
|
||||
detailSettings?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 테이블 생성 요청 인터페이스
|
||||
export interface CreateTableRequest {
|
||||
/** 테이블명 */
|
||||
tableName: string;
|
||||
/** 테이블 설명 */
|
||||
description?: string;
|
||||
/** 컬럼 정의 목록 */
|
||||
columns: CreateColumnDefinition[];
|
||||
}
|
||||
|
||||
// 컬럼 추가 요청 인터페이스
|
||||
export interface AddColumnRequest {
|
||||
/** 컬럼 정의 */
|
||||
column: CreateColumnDefinition;
|
||||
}
|
||||
|
||||
// DDL 실행 결과 인터페이스
|
||||
export interface DDLExecutionResult {
|
||||
/** 실행 성공 여부 */
|
||||
success: boolean;
|
||||
/** 결과 메시지 */
|
||||
message: string;
|
||||
/** 실행된 DDL 쿼리 */
|
||||
executedQuery?: string;
|
||||
/** 오류 정보 */
|
||||
error?: {
|
||||
code: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 검증 결과 인터페이스
|
||||
export interface ValidationResult {
|
||||
/** 검증 통과 여부 */
|
||||
isValid: boolean;
|
||||
/** 오류 메시지 목록 */
|
||||
errors: string[];
|
||||
/** 경고 메시지 목록 */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
// DDL 실행 로그 인터페이스
|
||||
export interface DDLExecutionLog {
|
||||
/** 로그 ID */
|
||||
id: number;
|
||||
/** 사용자 ID */
|
||||
user_id: string;
|
||||
/** 회사 코드 */
|
||||
company_code: string;
|
||||
/** DDL 유형 */
|
||||
ddl_type: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN";
|
||||
/** 테이블명 */
|
||||
table_name: string;
|
||||
/** 실행된 DDL 쿼리 */
|
||||
ddl_query: string;
|
||||
/** 실행 성공 여부 */
|
||||
success: boolean;
|
||||
/** 오류 메시지 (실패 시) */
|
||||
error_message?: string;
|
||||
/** 실행 시간 */
|
||||
executed_at: Date;
|
||||
}
|
||||
|
||||
// PostgreSQL 타입 매핑
|
||||
export interface PostgreSQLTypeMapping {
|
||||
/** 웹타입 */
|
||||
webType: WebType;
|
||||
/** PostgreSQL 데이터 타입 */
|
||||
postgresType: string;
|
||||
/** 기본 길이 (있는 경우) */
|
||||
defaultLength?: number;
|
||||
/** 길이 지정 가능 여부 */
|
||||
supportsLength: boolean;
|
||||
}
|
||||
|
||||
// 테이블 메타데이터 인터페이스
|
||||
export interface TableMetadata {
|
||||
/** 테이블명 */
|
||||
tableName: string;
|
||||
/** 테이블 라벨 */
|
||||
tableLabel: string;
|
||||
/** 테이블 설명 */
|
||||
description?: string;
|
||||
/** 생성 일시 */
|
||||
createdDate: Date;
|
||||
/** 수정 일시 */
|
||||
updatedDate: Date;
|
||||
}
|
||||
|
||||
// 컬럼 메타데이터 인터페이스
|
||||
export interface ColumnMetadata {
|
||||
/** 테이블명 */
|
||||
tableName: string;
|
||||
/** 컬럼명 */
|
||||
columnName: string;
|
||||
/** 컬럼 라벨 */
|
||||
columnLabel: string;
|
||||
/** 웹타입 */
|
||||
webType: WebType;
|
||||
/** 상세 설정 (JSON 문자열) */
|
||||
detailSettings: string;
|
||||
/** 컬럼 설명 */
|
||||
description?: string;
|
||||
/** 표시 순서 */
|
||||
displayOrder: number;
|
||||
/** 표시 여부 */
|
||||
isVisible: boolean;
|
||||
/** 코드 카테고리 (code 타입용) */
|
||||
codeCategory?: string;
|
||||
/** 코드 값 (code 타입용) */
|
||||
codeValue?: string;
|
||||
/** 참조 테이블 (entity 타입용) */
|
||||
referenceTable?: string;
|
||||
/** 참조 컬럼 (entity 타입용) */
|
||||
referenceColumn?: string;
|
||||
/** 생성 일시 */
|
||||
createdDate: Date;
|
||||
/** 수정 일시 */
|
||||
updatedDate: Date;
|
||||
}
|
||||
|
||||
// 시스템 테이블 목록 (보호 대상)
|
||||
export const SYSTEM_TABLES = [
|
||||
"user_info",
|
||||
"company_mng",
|
||||
"menu_info",
|
||||
"auth_group",
|
||||
"table_labels",
|
||||
"column_labels",
|
||||
"screen_definitions",
|
||||
"screen_layouts",
|
||||
"common_code",
|
||||
"multi_lang_key_master",
|
||||
"multi_lang_text",
|
||||
"button_action_standards",
|
||||
"ddl_execution_log",
|
||||
] as const;
|
||||
|
||||
// 예약어 목록
|
||||
export const RESERVED_WORDS = [
|
||||
"user",
|
||||
"order",
|
||||
"group",
|
||||
"table",
|
||||
"column",
|
||||
"index",
|
||||
"select",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"from",
|
||||
"where",
|
||||
"join",
|
||||
"on",
|
||||
"as",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
"null",
|
||||
"true",
|
||||
"false",
|
||||
"create",
|
||||
"alter",
|
||||
"drop",
|
||||
"primary",
|
||||
"key",
|
||||
"foreign",
|
||||
"references",
|
||||
"constraint",
|
||||
"default",
|
||||
"unique",
|
||||
"check",
|
||||
"view",
|
||||
"procedure",
|
||||
"function",
|
||||
] as const;
|
||||
|
||||
// 예약된 컬럼명 목록 (자동 추가되는 기본 컬럼들)
|
||||
export const RESERVED_COLUMNS = [
|
||||
"id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"company_code",
|
||||
] as const;
|
||||
|
||||
// 웹타입별 PostgreSQL 타입 매핑
|
||||
export const WEB_TYPE_TO_POSTGRES_MAP: Record<WebType, PostgreSQLTypeMapping> =
|
||||
{
|
||||
text: {
|
||||
webType: "text",
|
||||
postgresType: "varchar",
|
||||
defaultLength: 255,
|
||||
supportsLength: true,
|
||||
},
|
||||
number: {
|
||||
webType: "number",
|
||||
postgresType: "integer",
|
||||
supportsLength: false,
|
||||
},
|
||||
decimal: {
|
||||
webType: "decimal",
|
||||
postgresType: "numeric(10,2)",
|
||||
supportsLength: false,
|
||||
},
|
||||
date: {
|
||||
webType: "date",
|
||||
postgresType: "date",
|
||||
supportsLength: false,
|
||||
},
|
||||
datetime: {
|
||||
webType: "datetime",
|
||||
postgresType: "timestamp",
|
||||
supportsLength: false,
|
||||
},
|
||||
boolean: {
|
||||
webType: "boolean",
|
||||
postgresType: "boolean",
|
||||
supportsLength: false,
|
||||
},
|
||||
code: {
|
||||
webType: "code",
|
||||
postgresType: "varchar",
|
||||
defaultLength: 100,
|
||||
supportsLength: true,
|
||||
},
|
||||
entity: {
|
||||
webType: "entity",
|
||||
postgresType: "integer",
|
||||
supportsLength: false,
|
||||
},
|
||||
textarea: {
|
||||
webType: "textarea",
|
||||
postgresType: "text",
|
||||
supportsLength: false,
|
||||
},
|
||||
select: {
|
||||
webType: "select",
|
||||
postgresType: "varchar",
|
||||
defaultLength: 100,
|
||||
supportsLength: true,
|
||||
},
|
||||
checkbox: {
|
||||
webType: "checkbox",
|
||||
postgresType: "boolean",
|
||||
supportsLength: false,
|
||||
},
|
||||
radio: {
|
||||
webType: "radio",
|
||||
postgresType: "varchar",
|
||||
defaultLength: 100,
|
||||
supportsLength: true,
|
||||
},
|
||||
file: {
|
||||
webType: "file",
|
||||
postgresType: "text",
|
||||
supportsLength: false,
|
||||
},
|
||||
email: {
|
||||
webType: "email",
|
||||
postgresType: "varchar",
|
||||
defaultLength: 255,
|
||||
supportsLength: true,
|
||||
},
|
||||
tel: {
|
||||
webType: "tel",
|
||||
postgresType: "varchar",
|
||||
defaultLength: 50,
|
||||
supportsLength: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* 백엔드 입력 타입 정의 (테이블 타입 관리 개선)
|
||||
* 프론트엔드와 동일한 8개 핵심 입력 타입 정의
|
||||
*/
|
||||
|
||||
// 8개 핵심 입력 타입 (프론트엔드와 동일)
|
||||
export type InputType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "date" // 날짜
|
||||
| "code" // 코드
|
||||
| "entity" // 엔티티
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio"; // 라디오버튼
|
||||
|
||||
// 입력 타입 옵션 정의
|
||||
export interface InputTypeOption {
|
||||
value: InputType;
|
||||
label: string;
|
||||
description: string;
|
||||
category: "basic" | "reference" | "selection";
|
||||
}
|
||||
|
||||
// 입력 타입 옵션 목록
|
||||
export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
|
||||
{
|
||||
value: "text",
|
||||
label: "텍스트",
|
||||
description: "일반 텍스트 입력",
|
||||
category: "basic",
|
||||
},
|
||||
{
|
||||
value: "number",
|
||||
label: "숫자",
|
||||
description: "숫자 입력 (정수/소수)",
|
||||
category: "basic",
|
||||
},
|
||||
{ value: "date", label: "날짜", description: "날짜 선택", category: "basic" },
|
||||
{
|
||||
value: "code",
|
||||
label: "코드",
|
||||
description: "공통코드 참조",
|
||||
category: "reference",
|
||||
},
|
||||
{
|
||||
value: "entity",
|
||||
label: "엔티티",
|
||||
description: "다른 테이블 참조",
|
||||
category: "reference",
|
||||
},
|
||||
{
|
||||
value: "select",
|
||||
label: "선택박스",
|
||||
description: "드롭다운 선택",
|
||||
category: "selection",
|
||||
},
|
||||
{
|
||||
value: "checkbox",
|
||||
label: "체크박스",
|
||||
description: "체크박스 입력",
|
||||
category: "selection",
|
||||
},
|
||||
{
|
||||
value: "radio",
|
||||
label: "라디오버튼",
|
||||
description: "단일 선택",
|
||||
category: "selection",
|
||||
},
|
||||
];
|
||||
|
||||
// 입력 타입 검증 함수
|
||||
export const isValidInputType = (inputType: string): inputType is InputType => {
|
||||
return INPUT_TYPE_OPTIONS.some((option) => option.value === inputType);
|
||||
};
|
||||
|
||||
// 레거시 웹 타입 → 입력 타입 매핑
|
||||
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
|
||||
// 텍스트 관련
|
||||
text: "text",
|
||||
textarea: "text",
|
||||
email: "text",
|
||||
tel: "text",
|
||||
url: "text",
|
||||
password: "text",
|
||||
|
||||
// 숫자 관련
|
||||
number: "number",
|
||||
decimal: "number",
|
||||
|
||||
// 날짜 관련
|
||||
date: "date",
|
||||
datetime: "date",
|
||||
time: "date",
|
||||
|
||||
// 선택 관련
|
||||
select: "select",
|
||||
dropdown: "select",
|
||||
checkbox: "checkbox",
|
||||
boolean: "checkbox",
|
||||
radio: "radio",
|
||||
|
||||
// 참조 관련
|
||||
code: "code",
|
||||
entity: "entity",
|
||||
|
||||
// 기타 (기본값: text)
|
||||
file: "text",
|
||||
button: "text",
|
||||
};
|
||||
|
||||
// 입력 타입 변환 함수
|
||||
export const convertWebTypeToInputType = (webType: string): InputType => {
|
||||
return WEB_TYPE_TO_INPUT_TYPE[webType] || "text";
|
||||
};
|
||||
|
|
@ -4,17 +4,8 @@
|
|||
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
|
||||
|
||||
// 웹 타입 정의
|
||||
export type WebType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "date"
|
||||
| "code"
|
||||
| "entity"
|
||||
| "textarea"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "radio"
|
||||
| "file";
|
||||
// WebType은 통합 타입에서 import (중복 정의 제거)
|
||||
export { WebType } from "./unified-web-types";
|
||||
|
||||
// 위치 정보
|
||||
export interface Position {
|
||||
|
|
|
|||
|
|
@ -8,16 +8,15 @@ export interface TableInfo {
|
|||
}
|
||||
|
||||
export interface ColumnTypeInfo {
|
||||
tableName?: string;
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string; // 추가: 데이터 타입 (dbType과 동일하지만 별도 필드)
|
||||
dbType: string;
|
||||
webType: string;
|
||||
inputType?: "direct" | "auto";
|
||||
dataType: string; // DB 데이터 타입 (varchar, integer 등)
|
||||
inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio)
|
||||
detailSettings: string;
|
||||
description: string;
|
||||
isNullable: string;
|
||||
isPrimaryKey: boolean; // 추가: 기본키 여부
|
||||
isPrimaryKey: boolean;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
|
|
@ -34,7 +33,7 @@ export interface ColumnTypeInfo {
|
|||
export interface ColumnSettings {
|
||||
columnName?: string; // 컬럼명 (업데이트 시 필요)
|
||||
columnLabel: string; // 컬럼 표시명
|
||||
webType: string; // 웹 입력 타입 (text, number, date, code, entity)
|
||||
inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio)
|
||||
detailSettings: string; // 상세 설정
|
||||
codeCategory: string; // 코드 카테고리
|
||||
codeValue: string; // 코드 값
|
||||
|
|
|
|||
|
|
@ -0,0 +1,406 @@
|
|||
/**
|
||||
* 백엔드 통합 웹 타입 정의
|
||||
* 프론트엔드와 동일한 웹 타입 정의 유지
|
||||
*/
|
||||
|
||||
// 기본 웹 타입 (프론트엔드와 동일)
|
||||
export type BaseWebType =
|
||||
| "text" // 일반 텍스트
|
||||
| "number" // 숫자 (정수)
|
||||
| "decimal" // 소수점 숫자
|
||||
| "date" // 날짜
|
||||
| "datetime" // 날짜시간
|
||||
| "time" // 시간
|
||||
| "textarea" // 여러줄 텍스트
|
||||
| "select" // 선택박스
|
||||
| "dropdown" // 드롭다운 (select와 동일)
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오버튼
|
||||
| "boolean" // 불린값
|
||||
| "file" // 파일 업로드
|
||||
| "email" // 이메일
|
||||
| "tel" // 전화번호
|
||||
| "url" // URL
|
||||
| "password" // 패스워드
|
||||
| "code" // 공통코드 참조
|
||||
| "entity" // 엔티티 참조
|
||||
| "button"; // 버튼
|
||||
|
||||
// 레거시 지원용
|
||||
export type LegacyWebType = "text_area"; // textarea와 동일
|
||||
|
||||
// 전체 웹 타입
|
||||
export type WebType = BaseWebType | LegacyWebType;
|
||||
|
||||
// 동적 웹 타입 (런타임에 DB에서 로드되는 타입 포함)
|
||||
export type DynamicWebType = WebType | string;
|
||||
|
||||
// 웹 타입 매핑 (레거시 지원)
|
||||
export const WEB_TYPE_MAPPINGS: Record<LegacyWebType, BaseWebType> = {
|
||||
text_area: "textarea",
|
||||
};
|
||||
|
||||
// 웹 타입 정규화 함수
|
||||
export const normalizeWebType = (webType: DynamicWebType): WebType => {
|
||||
if (webType in WEB_TYPE_MAPPINGS) {
|
||||
return WEB_TYPE_MAPPINGS[webType as LegacyWebType];
|
||||
}
|
||||
return webType as WebType;
|
||||
};
|
||||
|
||||
// 웹 타입 검증 함수
|
||||
export const isValidWebType = (webType: string): webType is WebType => {
|
||||
return (
|
||||
[
|
||||
"text",
|
||||
"number",
|
||||
"decimal",
|
||||
"date",
|
||||
"datetime",
|
||||
"time",
|
||||
"textarea",
|
||||
"select",
|
||||
"dropdown",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"boolean",
|
||||
"file",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"password",
|
||||
"code",
|
||||
"entity",
|
||||
"button",
|
||||
"text_area", // 레거시 지원
|
||||
] as string[]
|
||||
).includes(webType);
|
||||
};
|
||||
|
||||
// DB 타입과 웹 타입 매핑
|
||||
export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
|
||||
// 텍스트 타입
|
||||
"character varying": "text",
|
||||
varchar: "text",
|
||||
text: "textarea",
|
||||
char: "text",
|
||||
|
||||
// 숫자 타입
|
||||
integer: "number",
|
||||
bigint: "number",
|
||||
smallint: "number",
|
||||
serial: "number",
|
||||
bigserial: "number",
|
||||
numeric: "decimal",
|
||||
decimal: "decimal",
|
||||
real: "decimal",
|
||||
"double precision": "decimal",
|
||||
|
||||
// 날짜/시간 타입
|
||||
date: "date",
|
||||
timestamp: "datetime",
|
||||
"timestamp with time zone": "datetime",
|
||||
"timestamp without time zone": "datetime",
|
||||
time: "time",
|
||||
"time with time zone": "time",
|
||||
"time without time zone": "time",
|
||||
|
||||
// 불린 타입
|
||||
boolean: "boolean",
|
||||
|
||||
// JSON 타입 (텍스트로 처리)
|
||||
json: "textarea",
|
||||
jsonb: "textarea",
|
||||
|
||||
// 배열 타입 (텍스트로 처리)
|
||||
ARRAY: "textarea",
|
||||
|
||||
// UUID 타입
|
||||
uuid: "text",
|
||||
};
|
||||
|
||||
// 웹 타입별 PostgreSQL 타입 변환 규칙
|
||||
export const WEB_TYPE_TO_POSTGRES_CONVERTER: Record<
|
||||
WebType,
|
||||
(value: any) => any
|
||||
> = {
|
||||
text: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
number: (value) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const num = parseInt(String(value));
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
|
||||
decimal: (value) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const num = parseFloat(String(value));
|
||||
return isNaN(num) ? null : num;
|
||||
},
|
||||
|
||||
date: (value) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
|
||||
},
|
||||
|
||||
datetime: (value) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||
},
|
||||
|
||||
time: (value) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
// 시간 형식 처리 (HH:mm:ss)
|
||||
return String(value);
|
||||
},
|
||||
|
||||
textarea: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
select: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
dropdown: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
checkbox: (value) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase() === "true" || value === "1" || value === "Y";
|
||||
}
|
||||
return Boolean(value);
|
||||
},
|
||||
|
||||
radio: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
boolean: (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase() === "true" || value === "1" || value === "Y";
|
||||
}
|
||||
return Boolean(value);
|
||||
},
|
||||
|
||||
file: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
email: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
tel: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
url: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
password: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
code: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
entity: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
|
||||
button: (value) => null, // 버튼은 저장하지 않음
|
||||
|
||||
// 레거시 지원
|
||||
text_area: (value) =>
|
||||
value === null || value === undefined || value === ""
|
||||
? null
|
||||
: String(value),
|
||||
};
|
||||
|
||||
// 웹 타입별 검증 규칙
|
||||
export const WEB_TYPE_VALIDATION_PATTERNS: Record<WebType, RegExp | null> = {
|
||||
text: null,
|
||||
number: /^-?\d+$/,
|
||||
decimal: /^-?\d+(\.\d+)?$/,
|
||||
date: /^\d{4}-\d{2}-\d{2}$/,
|
||||
datetime: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/,
|
||||
time: /^\d{2}:\d{2}(:\d{2})?$/,
|
||||
textarea: null,
|
||||
select: null,
|
||||
dropdown: null,
|
||||
checkbox: null,
|
||||
radio: null,
|
||||
boolean: null,
|
||||
file: null,
|
||||
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
tel: /^(\d{2,3}-?\d{3,4}-?\d{4}|\d{10,11})$/,
|
||||
url: /^https?:\/\/.+/,
|
||||
password: null,
|
||||
code: null,
|
||||
entity: null,
|
||||
button: null,
|
||||
text_area: null, // 레거시 지원
|
||||
};
|
||||
|
||||
// 업데이트된 웹 타입 옵션 (기존 WEB_TYPE_OPTIONS 대체)
|
||||
export const UNIFIED_WEB_TYPE_OPTIONS = [
|
||||
{
|
||||
value: "text",
|
||||
label: "text",
|
||||
description: "일반 텍스트 입력",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "number",
|
||||
label: "number",
|
||||
description: "숫자 입력 (정수)",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "decimal",
|
||||
label: "decimal",
|
||||
description: "소수점 숫자 입력",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "date",
|
||||
label: "date",
|
||||
description: "날짜 선택기",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "datetime",
|
||||
label: "datetime",
|
||||
description: "날짜시간 선택기",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "time",
|
||||
label: "time",
|
||||
description: "시간 선택기",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "textarea",
|
||||
label: "textarea",
|
||||
description: "여러 줄 텍스트",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "select",
|
||||
label: "select",
|
||||
description: "선택박스",
|
||||
category: "selection",
|
||||
},
|
||||
{
|
||||
value: "dropdown",
|
||||
label: "dropdown",
|
||||
description: "드롭다운",
|
||||
category: "selection",
|
||||
},
|
||||
{
|
||||
value: "checkbox",
|
||||
label: "checkbox",
|
||||
description: "체크박스",
|
||||
category: "selection",
|
||||
},
|
||||
{
|
||||
value: "radio",
|
||||
label: "radio",
|
||||
description: "라디오 버튼",
|
||||
category: "selection",
|
||||
},
|
||||
{
|
||||
value: "boolean",
|
||||
label: "boolean",
|
||||
description: "불린값 (예/아니오)",
|
||||
category: "selection",
|
||||
},
|
||||
{
|
||||
value: "file",
|
||||
label: "file",
|
||||
description: "파일 업로드",
|
||||
category: "upload",
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: "email",
|
||||
description: "이메일 주소",
|
||||
category: "input",
|
||||
},
|
||||
{ value: "tel", label: "tel", description: "전화번호", category: "input" },
|
||||
{
|
||||
value: "url",
|
||||
label: "url",
|
||||
description: "웹사이트 주소",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "password",
|
||||
label: "password",
|
||||
description: "비밀번호",
|
||||
category: "input",
|
||||
},
|
||||
{
|
||||
value: "code",
|
||||
label: "code",
|
||||
description: "코드 선택 (공통코드)",
|
||||
category: "reference",
|
||||
},
|
||||
{
|
||||
value: "entity",
|
||||
label: "entity",
|
||||
description: "엔티티 참조 (참조테이블)",
|
||||
category: "reference",
|
||||
},
|
||||
{ value: "button", label: "button", description: "버튼", category: "action" },
|
||||
] as const;
|
||||
|
||||
// 웹 타입별 기본 설정
|
||||
export const WEB_TYPE_DEFAULT_CONFIGS: Record<WebType, Record<string, any>> = {
|
||||
text: { maxLength: 255, placeholder: "텍스트를 입력하세요" },
|
||||
number: { min: 0, max: 2147483647, step: 1 },
|
||||
decimal: { min: 0, step: 0.01, decimalPlaces: 2 },
|
||||
date: { format: "YYYY-MM-DD" },
|
||||
datetime: { format: "YYYY-MM-DD HH:mm:ss", showTime: true },
|
||||
time: { format: "HH:mm:ss" },
|
||||
textarea: { rows: 4, cols: 50, maxLength: 1000 },
|
||||
select: { placeholder: "선택하세요", searchable: false },
|
||||
dropdown: { placeholder: "선택하세요", searchable: true },
|
||||
checkbox: { defaultChecked: false },
|
||||
radio: { inline: false },
|
||||
boolean: { trueValue: true, falseValue: false },
|
||||
file: { multiple: false, preview: true },
|
||||
email: { placeholder: "이메일을 입력하세요" },
|
||||
tel: { placeholder: "전화번호를 입력하세요" },
|
||||
url: { placeholder: "URL을 입력하세요" },
|
||||
password: { placeholder: "비밀번호를 입력하세요" },
|
||||
code: { placeholder: "코드를 선택하세요", searchable: true },
|
||||
entity: { placeholder: "항목을 선택하세요", searchable: true },
|
||||
button: { variant: "default" },
|
||||
text_area: { rows: 4, cols: 50, maxLength: 1000 }, // 레거시 지원
|
||||
};
|
||||
|
|
@ -7,14 +7,19 @@ import { Button } from "@/components/ui/button";
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Search, Database, RefreshCw, Settings, Menu, X } from "lucide-react";
|
||||
import { Search, Database, RefreshCw, Settings, Menu, X, Plus, Activity } from "lucide-react";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
|
||||
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
// 가상화 스크롤링을 위한 간단한 구현
|
||||
|
||||
interface TableInfo {
|
||||
|
|
@ -27,8 +32,7 @@ interface TableInfo {
|
|||
interface ColumnTypeInfo {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dbType: string;
|
||||
webType: string;
|
||||
inputType: string; // webType → inputType 변경
|
||||
detailSettings: string;
|
||||
description: string;
|
||||
isNullable: string;
|
||||
|
|
@ -45,6 +49,7 @@ interface ColumnTypeInfo {
|
|||
|
||||
export default function TableManagementPage() {
|
||||
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
||||
const { user } = useAuth();
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
|
|
@ -66,6 +71,14 @@ export default function TableManagementPage() {
|
|||
// 🎯 Entity 조인 관련 상태
|
||||
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
||||
|
||||
// DDL 기능 관련 상태
|
||||
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
|
||||
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
||||
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
|
||||
|
||||
// 최고 관리자 여부 확인
|
||||
const isSuperAdmin = user?.companyCode === "*" && user?.userId === "plm_admin";
|
||||
|
||||
// 다국어 텍스트 로드
|
||||
useEffect(() => {
|
||||
const loadTexts = async () => {
|
||||
|
|
@ -135,15 +148,15 @@ export default function TableManagementPage() {
|
|||
[], // 의존성 배열에서 referenceTableColumns 제거
|
||||
);
|
||||
|
||||
// 웹 타입 옵션 (다국어 적용)
|
||||
const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => ({
|
||||
// 입력 타입 옵션 (8개 핵심 타입)
|
||||
const inputTypeOptions = INPUT_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: getTextFromUI(option.labelKey, option.value),
|
||||
description: getTextFromUI(option.descriptionKey, option.value),
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}));
|
||||
|
||||
// 메모이제이션된 웹타입 옵션
|
||||
const memoizedWebTypeOptions = useMemo(() => webTypeOptions, [uiTexts]);
|
||||
// 메모이제이션된 입력타입 옵션
|
||||
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
|
||||
|
||||
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴)
|
||||
const referenceTableOptions = [
|
||||
|
|
@ -218,14 +231,21 @@ export default function TableManagementPage() {
|
|||
// 응답 상태 확인
|
||||
if (response.data.success) {
|
||||
const data = response.data.data;
|
||||
|
||||
// 컬럼 데이터에 기본값 설정
|
||||
const processedColumns = (data.columns || data).map((col: any) => ({
|
||||
...col,
|
||||
inputType: col.inputType || "text", // 기본값: text
|
||||
}));
|
||||
|
||||
if (page === 1) {
|
||||
setColumns(data.columns || data);
|
||||
setOriginalColumns(data.columns || data);
|
||||
setColumns(processedColumns);
|
||||
setOriginalColumns(processedColumns);
|
||||
} else {
|
||||
// 페이지 추가 로드 시 기존 데이터에 추가
|
||||
setColumns((prev) => [...prev, ...(data.columns || data)]);
|
||||
setColumns((prev) => [...prev, ...processedColumns]);
|
||||
}
|
||||
setTotalColumns(data.total || (data.columns || data).length);
|
||||
setTotalColumns(data.total || processedColumns.length);
|
||||
toast.success("컬럼 정보를 성공적으로 로드했습니다.");
|
||||
} else {
|
||||
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
|
||||
|
|
@ -255,24 +275,24 @@ export default function TableManagementPage() {
|
|||
[loadColumnTypes, pageSize, tables],
|
||||
);
|
||||
|
||||
// 웹 타입 변경
|
||||
const handleWebTypeChange = useCallback(
|
||||
(columnName: string, newWebType: string) => {
|
||||
// 입력 타입 변경
|
||||
const handleInputTypeChange = useCallback(
|
||||
(columnName: string, newInputType: string) => {
|
||||
setColumns((prev) =>
|
||||
prev.map((col) => {
|
||||
if (col.columnName === columnName) {
|
||||
const webTypeOption = memoizedWebTypeOptions.find((option) => option.value === newWebType);
|
||||
const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType);
|
||||
return {
|
||||
...col,
|
||||
webType: newWebType,
|
||||
detailSettings: webTypeOption?.description || col.detailSettings,
|
||||
inputType: newInputType,
|
||||
detailSettings: inputTypeOption?.description || col.detailSettings,
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[memoizedWebTypeOptions],
|
||||
[memoizedInputTypeOptions],
|
||||
);
|
||||
|
||||
// 상세 설정 변경 (코드/엔티티 타입용)
|
||||
|
|
@ -382,7 +402,7 @@ export default function TableManagementPage() {
|
|||
const columnSetting = {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
webType: column.webType || "text",
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: column.detailSettings || "",
|
||||
codeCategory: column.codeCategory || "",
|
||||
codeValue: column.codeValue || "",
|
||||
|
|
@ -437,7 +457,7 @@ export default function TableManagementPage() {
|
|||
const columnSettings = columns.map((column) => ({
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
webType: column.webType || "text",
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: column.detailSettings || "",
|
||||
description: column.description || "",
|
||||
codeCategory: column.codeCategory || "",
|
||||
|
|
@ -531,11 +551,44 @@ export default function TableManagementPage() {
|
|||
<p className="mt-2 text-gray-600">
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
|
||||
</p>
|
||||
{isSuperAdmin && (
|
||||
<p className="mt-1 text-sm font-medium text-blue-600">
|
||||
🔧 최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* DDL 기능 버튼들 (최고 관리자만) */}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setCreateTableModalOpen(true)}
|
||||
className="bg-green-600 text-white hover:bg-green-700"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />새 테이블 생성
|
||||
</Button>
|
||||
|
||||
{selectedTable && (
|
||||
<Button onClick={() => setAddColumnModalOpen(true)} variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" size="sm">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
DDL 로그
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2" size="sm">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
|
|
@ -668,8 +721,7 @@ export default function TableManagementPage() {
|
|||
<div className="flex items-center border-b border-gray-200 pb-2 text-sm font-medium text-gray-700">
|
||||
<div className="w-40 px-4">컬럼명</div>
|
||||
<div className="w-48 px-4">라벨</div>
|
||||
<div className="w-40 px-4">DB 타입</div>
|
||||
<div className="w-48 px-4">웹 타입</div>
|
||||
<div className="w-48 px-4">입력 타입</div>
|
||||
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||
상세 설정
|
||||
</div>
|
||||
|
|
@ -703,21 +755,16 @@ export default function TableManagementPage() {
|
|||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40 px-4">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.dbType}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="w-48 px-4">
|
||||
<Select
|
||||
value={column.webType}
|
||||
onValueChange={(value) => handleWebTypeChange(column.columnName, value)}
|
||||
value={column.inputType || "text"}
|
||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
<SelectValue placeholder="입력 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memoizedWebTypeOptions.map((option) => (
|
||||
{memoizedInputTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
|
|
@ -727,7 +774,7 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
|
||||
{column.webType === "code" && (
|
||||
{column.inputType === "code" && (
|
||||
<Select
|
||||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
|
||||
|
|
@ -745,7 +792,7 @@ export default function TableManagementPage() {
|
|||
</Select>
|
||||
)}
|
||||
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.webType === "entity" && (
|
||||
{column.inputType === "entity" && (
|
||||
<div className="space-y-1">
|
||||
{/* 🎯 Entity 타입 설정 - 가로 배치 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
|
||||
|
|
@ -880,7 +927,7 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
)}
|
||||
{/* 다른 웹 타입인 경우 빈 공간 */}
|
||||
{column.webType !== "code" && column.webType !== "entity" && (
|
||||
{column.inputType !== "code" && column.inputType !== "entity" && (
|
||||
<div className="flex h-7 items-center text-xs text-gray-400">-</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -927,6 +974,47 @@ export default function TableManagementPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* DDL 모달 컴포넌트들 */}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<CreateTableModal
|
||||
isOpen={createTableModalOpen}
|
||||
onClose={() => setCreateTableModalOpen(false)}
|
||||
onSuccess={async (result) => {
|
||||
toast.success("테이블이 성공적으로 생성되었습니다!");
|
||||
// 테이블 목록 새로고침
|
||||
await loadTables();
|
||||
// 새로 생성된 테이블 자동 선택 및 컬럼 로드
|
||||
if (result.data?.tableName) {
|
||||
setSelectedTable(result.data.tableName);
|
||||
setCurrentPage(1);
|
||||
setColumns([]);
|
||||
await loadColumnTypes(result.data.tableName, 1, pageSize);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddColumnModal
|
||||
isOpen={addColumnModalOpen}
|
||||
onClose={() => setAddColumnModalOpen(false)}
|
||||
tableName={selectedTable || ""}
|
||||
onSuccess={async (result) => {
|
||||
toast.success("컬럼이 성공적으로 추가되었습니다!");
|
||||
// 테이블 목록 새로고침 (컬럼 수 업데이트)
|
||||
await loadTables();
|
||||
// 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋
|
||||
if (selectedTable) {
|
||||
setCurrentPage(1);
|
||||
setColumns([]); // 기존 컬럼 목록 초기화
|
||||
await loadColumnTypes(selectedTable, 1, pageSize);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,585 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
import { EnhancedInteractiveScreenViewer } from "@/components/screen/EnhancedInteractiveScreenViewer";
|
||||
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||
import { useFormValidation } from "@/hooks/useFormValidation";
|
||||
import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { ComponentData, WidgetComponent, ColumnInfo, ScreenDefinition } from "@/types/screen";
|
||||
import { normalizeWebType } from "@/types/unified-web-types";
|
||||
|
||||
// 테스트용 화면 정의
|
||||
const TEST_SCREEN_DEFINITION: ScreenDefinition = {
|
||||
id: 999,
|
||||
screenName: "validation-demo",
|
||||
tableName: "test_users", // 테스트용 테이블
|
||||
screenResolution: { width: 800, height: 600 },
|
||||
gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 },
|
||||
description: "검증 시스템 데모 화면",
|
||||
};
|
||||
|
||||
// 테스트용 컴포넌트 데이터
|
||||
const TEST_COMPONENTS: ComponentData[] = [
|
||||
{
|
||||
id: "container-1",
|
||||
type: "container",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 600,
|
||||
parentId: null,
|
||||
children: ["widget-1", "widget-2", "widget-3", "widget-4", "widget-5", "widget-6"],
|
||||
},
|
||||
{
|
||||
id: "widget-1",
|
||||
type: "widget",
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 200,
|
||||
height: 40,
|
||||
parentId: "container-1",
|
||||
label: "사용자명",
|
||||
widgetType: "text",
|
||||
columnName: "user_name",
|
||||
required: true,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
{
|
||||
id: "widget-2",
|
||||
type: "widget",
|
||||
x: 20,
|
||||
y: 80,
|
||||
width: 200,
|
||||
height: 40,
|
||||
parentId: "container-1",
|
||||
label: "이메일",
|
||||
widgetType: "email",
|
||||
columnName: "email",
|
||||
required: true,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
{
|
||||
id: "widget-3",
|
||||
type: "widget",
|
||||
x: 20,
|
||||
y: 140,
|
||||
width: 200,
|
||||
height: 40,
|
||||
parentId: "container-1",
|
||||
label: "나이",
|
||||
widgetType: "number",
|
||||
columnName: "age",
|
||||
required: false,
|
||||
webTypeConfig: {
|
||||
min: 0,
|
||||
max: 120,
|
||||
},
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
{
|
||||
id: "widget-4",
|
||||
type: "widget",
|
||||
x: 20,
|
||||
y: 200,
|
||||
width: 200,
|
||||
height: 40,
|
||||
parentId: "container-1",
|
||||
label: "생년월일",
|
||||
widgetType: "date",
|
||||
columnName: "birth_date",
|
||||
required: false,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
{
|
||||
id: "widget-5",
|
||||
type: "widget",
|
||||
x: 20,
|
||||
y: 260,
|
||||
width: 200,
|
||||
height: 40,
|
||||
parentId: "container-1",
|
||||
label: "전화번호",
|
||||
widgetType: "tel",
|
||||
columnName: "phone",
|
||||
required: false,
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
{
|
||||
id: "widget-6",
|
||||
type: "widget",
|
||||
x: 20,
|
||||
y: 320,
|
||||
width: 100,
|
||||
height: 40,
|
||||
parentId: "container-1",
|
||||
label: "저장",
|
||||
widgetType: "button",
|
||||
columnName: "save_button",
|
||||
required: false,
|
||||
webTypeConfig: {
|
||||
actionType: "save",
|
||||
text: "저장하기",
|
||||
},
|
||||
style: {
|
||||
labelFontSize: "14px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
},
|
||||
} as WidgetComponent,
|
||||
];
|
||||
|
||||
// 테스트용 테이블 컬럼 정보
|
||||
const TEST_TABLE_COLUMNS: ColumnInfo[] = [
|
||||
{
|
||||
tableName: "test_users",
|
||||
columnName: "id",
|
||||
columnLabel: "ID",
|
||||
dataType: "integer",
|
||||
webType: "number",
|
||||
widgetType: "number",
|
||||
inputType: "auto",
|
||||
isNullable: "N",
|
||||
required: false,
|
||||
isPrimaryKey: true,
|
||||
isVisible: false,
|
||||
displayOrder: 0,
|
||||
description: "기본키",
|
||||
},
|
||||
{
|
||||
tableName: "test_users",
|
||||
columnName: "user_name",
|
||||
columnLabel: "사용자명",
|
||||
dataType: "character varying",
|
||||
webType: "text",
|
||||
widgetType: "text",
|
||||
inputType: "direct",
|
||||
isNullable: "N",
|
||||
required: true,
|
||||
characterMaximumLength: 50,
|
||||
isVisible: true,
|
||||
displayOrder: 1,
|
||||
description: "사용자 이름",
|
||||
},
|
||||
{
|
||||
tableName: "test_users",
|
||||
columnName: "email",
|
||||
columnLabel: "이메일",
|
||||
dataType: "character varying",
|
||||
webType: "email",
|
||||
widgetType: "email",
|
||||
inputType: "direct",
|
||||
isNullable: "N",
|
||||
required: true,
|
||||
characterMaximumLength: 100,
|
||||
isVisible: true,
|
||||
displayOrder: 2,
|
||||
description: "이메일 주소",
|
||||
},
|
||||
{
|
||||
tableName: "test_users",
|
||||
columnName: "age",
|
||||
columnLabel: "나이",
|
||||
dataType: "integer",
|
||||
webType: "number",
|
||||
widgetType: "number",
|
||||
inputType: "direct",
|
||||
isNullable: "Y",
|
||||
required: false,
|
||||
isVisible: true,
|
||||
displayOrder: 3,
|
||||
description: "나이",
|
||||
},
|
||||
{
|
||||
tableName: "test_users",
|
||||
columnName: "birth_date",
|
||||
columnLabel: "생년월일",
|
||||
dataType: "date",
|
||||
webType: "date",
|
||||
widgetType: "date",
|
||||
inputType: "direct",
|
||||
isNullable: "Y",
|
||||
required: false,
|
||||
isVisible: true,
|
||||
displayOrder: 4,
|
||||
description: "생년월일",
|
||||
},
|
||||
{
|
||||
tableName: "test_users",
|
||||
columnName: "phone",
|
||||
columnLabel: "전화번호",
|
||||
dataType: "character varying",
|
||||
webType: "tel",
|
||||
widgetType: "tel",
|
||||
inputType: "direct",
|
||||
isNullable: "Y",
|
||||
required: false,
|
||||
characterMaximumLength: 20,
|
||||
isVisible: true,
|
||||
displayOrder: 5,
|
||||
description: "전화번호",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ValidationDemoPage() {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [selectedTable, setSelectedTable] = useState<string>("test_users");
|
||||
const [availableTables, setAvailableTables] = useState<string[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>(TEST_TABLE_COLUMNS);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 폼 검증 훅 사용
|
||||
const { validationState, saveState, validateForm, saveForm, canSave, getFieldError, hasFieldError, isFieldValid } =
|
||||
useFormValidation(
|
||||
formData,
|
||||
TEST_COMPONENTS.filter((c) => c.type === "widget") as WidgetComponent[],
|
||||
tableColumns,
|
||||
TEST_SCREEN_DEFINITION,
|
||||
{
|
||||
enableRealTimeValidation: true,
|
||||
validationDelay: 300,
|
||||
enableAutoSave: false,
|
||||
showToastMessages: true,
|
||||
validateOnMount: false,
|
||||
},
|
||||
);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAvailableTables(response.data.map((table) => table.tableName));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
if (selectedTable && selectedTable !== "test_users") {
|
||||
const loadTableColumns = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(selectedTable);
|
||||
if (response.success && response.data) {
|
||||
setTableColumns(response.data.columns || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 컬럼 정보 로드 실패:", error);
|
||||
toast.error("테이블 컬럼 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadTableColumns();
|
||||
} else {
|
||||
setTableColumns(TEST_TABLE_COLUMNS);
|
||||
}
|
||||
}, [selectedTable]);
|
||||
|
||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
const handleTestFormSubmit = async () => {
|
||||
const result = await saveForm();
|
||||
if (result) {
|
||||
toast.success("폼 데이터가 성공적으로 저장되었습니다!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualValidation = async () => {
|
||||
const result = await validateForm();
|
||||
toast.info(
|
||||
`검증 완료: ${result.isValid ? "성공" : "실패"} (오류 ${result.errors.length}개, 경고 ${result.warnings.length}개)`,
|
||||
);
|
||||
};
|
||||
|
||||
const generateTestData = () => {
|
||||
setFormData({
|
||||
user_name: "테스트 사용자",
|
||||
email: "test@example.com",
|
||||
age: 25,
|
||||
birth_date: "1999-01-01",
|
||||
phone: "010-1234-5678",
|
||||
});
|
||||
toast.info("테스트 데이터가 입력되었습니다.");
|
||||
};
|
||||
|
||||
const generateInvalidData = () => {
|
||||
setFormData({
|
||||
user_name: "", // 필수 필드 누락
|
||||
email: "invalid-email", // 잘못된 이메일 형식
|
||||
age: -5, // 음수 나이
|
||||
birth_date: "invalid-date", // 잘못된 날짜
|
||||
phone: "123", // 잘못된 전화번호 형식
|
||||
});
|
||||
toast.info("잘못된 테스트 데이터가 입력되었습니다.");
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
setFormData({});
|
||||
toast.info("폼이 초기화되었습니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">검증 시스템 데모</h1>
|
||||
<p className="text-muted-foreground">개선된 폼 검증 시스템을 테스트해보세요</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">개발 버전</Badge>
|
||||
<Badge variant={validationState.isValid ? "default" : "destructive"}>
|
||||
{validationState.isValid ? "검증 통과" : "검증 실패"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="demo" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="demo">데모 폼</TabsTrigger>
|
||||
<TabsTrigger value="validation">검증 상태</TabsTrigger>
|
||||
<TabsTrigger value="settings">설정</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="demo" className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 폼 영역 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>테스트 폼</CardTitle>
|
||||
<CardDescription>실시간 검증이 적용된 폼입니다. 입력하면서 검증 결과를 확인해보세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative min-h-[400px] rounded-lg border border-dashed border-gray-300 p-4">
|
||||
<EnhancedInteractiveScreenViewer
|
||||
component={TEST_COMPONENTS[0]} // container
|
||||
allComponents={TEST_COMPONENTS}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenInfo={TEST_SCREEN_DEFINITION}
|
||||
tableColumns={tableColumns}
|
||||
validationOptions={{
|
||||
enableRealTimeValidation: true,
|
||||
validationDelay: 300,
|
||||
enableAutoSave: false,
|
||||
showToastMessages: true,
|
||||
}}
|
||||
showValidationPanel={false}
|
||||
compactValidation={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 컨트롤 패널 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>컨트롤 패널</CardTitle>
|
||||
<CardDescription>테스트 기능들을 사용해보세요</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>테스트 데이터</Label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Button onClick={generateTestData} variant="outline" size="sm">
|
||||
✅ 유효한 데이터 입력
|
||||
</Button>
|
||||
<Button onClick={generateInvalidData} variant="outline" size="sm">
|
||||
❌ 잘못된 데이터 입력
|
||||
</Button>
|
||||
<Button onClick={clearForm} variant="outline" size="sm">
|
||||
🧹 폼 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>검증 & 저장</Label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Button onClick={handleManualValidation} variant="outline" size="sm">
|
||||
🔍 수동 검증
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestFormSubmit}
|
||||
disabled={!canSave || saveState.status === "saving"}
|
||||
size="sm"
|
||||
>
|
||||
{saveState.status === "saving" ? "저장 중..." : "💾 폼 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FormValidationIndicator
|
||||
validationState={validationState}
|
||||
saveState={saveState}
|
||||
onSave={handleTestFormSubmit}
|
||||
canSave={canSave}
|
||||
compact={false}
|
||||
showDetails={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="validation" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>검증 상태 상세</CardTitle>
|
||||
<CardDescription>현재 폼의 검증 상태를 자세히 확인할 수 있습니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormValidationIndicator
|
||||
validationState={validationState}
|
||||
saveState={saveState}
|
||||
onSave={handleTestFormSubmit}
|
||||
canSave={canSave}
|
||||
compact={false}
|
||||
showDetails={true}
|
||||
showPerformance={true}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">폼 데이터</h4>
|
||||
<pre className="max-h-60 overflow-auto rounded-md bg-gray-100 p-3 text-sm">
|
||||
{JSON.stringify(formData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">검증 통계</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-md bg-green-50 p-3">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{Object.values(validationState.fieldStates).filter((f) => f.status === "valid").length}
|
||||
</div>
|
||||
<div className="text-sm text-green-700">유효한 필드</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-red-50 p-3">
|
||||
<div className="text-lg font-bold text-red-600">{validationState.errors.length}</div>
|
||||
<div className="text-sm text-red-700">오류 개수</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>테스트 설정</CardTitle>
|
||||
<CardDescription>검증 동작을 조정할 수 있습니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="table-select">테스트 테이블</Label>
|
||||
<Select value={selectedTable} onValueChange={setSelectedTable}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="test_users">test_users (데모용)</SelectItem>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="py-4 text-center">
|
||||
<div className="text-muted-foreground text-sm">테이블 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">테이블 컬럼 정보</h4>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="p-2 text-left">컬럼명</th>
|
||||
<th className="p-2 text-left">타입</th>
|
||||
<th className="p-2 text-left">필수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableColumns.map((column) => (
|
||||
<tr key={column.columnName} className="border-b">
|
||||
<td className="p-2">{column.columnName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{column.webType}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{column.required ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
필수
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
선택
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
|
|||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -364,7 +365,7 @@ export default function ScreenViewPage() {
|
|||
) : (
|
||||
// 빈 화면일 때도 깔끔하게 표시
|
||||
<div
|
||||
className="mx-auto flex items-center justify-center bg-gray-50"
|
||||
className="mx-auto flex items-center justify-center bg-white"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import SimpleTypeSafetyTest from "./simple-test";
|
||||
|
||||
export default function TypeSafetyTestPage() {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
{/* 테스트 네비게이션 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">🧪 타입 안전성 테스트 센터</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
화면관리, 제어관리, 테이블타입관리 시스템의 타입 안전성을 다양한 방법으로 검증합니다.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button variant="outline" className="h-auto flex-col p-4" asChild>
|
||||
<div>
|
||||
<div className="mb-2 text-lg">🧪 기본 테스트</div>
|
||||
<div className="text-muted-foreground text-sm">핵심 타입 안전성 검증</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="h-auto flex-col p-4" asChild>
|
||||
<Link href="/test-type-safety/stress-test">
|
||||
<div className="mb-2 text-lg">🔥 스트레스 테스트</div>
|
||||
<div className="text-muted-foreground text-sm">극한 상황 성능 검증</div>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 테스트 실행 */}
|
||||
<SimpleTypeSafetyTest />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ComponentData,
|
||||
WebType,
|
||||
ButtonActionType,
|
||||
WidgetComponent,
|
||||
isWidgetComponent,
|
||||
isWebType,
|
||||
isButtonActionType,
|
||||
ynToBoolean,
|
||||
booleanToYN,
|
||||
} from "@/types";
|
||||
|
||||
export default function SimpleTypeSafetyTest() {
|
||||
const [testResults, setTestResults] = useState<
|
||||
Array<{ name: string; status: "pending" | "passed" | "failed"; message?: string }>
|
||||
>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const addResult = (name: string, status: "passed" | "failed", message?: string) => {
|
||||
setTestResults((prev) => [...prev, { name, status, message }]);
|
||||
};
|
||||
|
||||
const runSimpleTests = () => {
|
||||
setIsRunning(true);
|
||||
setTestResults([]);
|
||||
|
||||
try {
|
||||
// Test 1: WebType 검증
|
||||
const validWebTypes = ["text", "number", "date", "select", "checkbox"];
|
||||
const invalidWebTypes = ["text_area", "VARCHAR", "submit"];
|
||||
|
||||
let webTypeTestPassed = true;
|
||||
validWebTypes.forEach((type) => {
|
||||
if (!isWebType(type)) webTypeTestPassed = false;
|
||||
});
|
||||
invalidWebTypes.forEach((type) => {
|
||||
if (isWebType(type as any)) webTypeTestPassed = false;
|
||||
});
|
||||
|
||||
addResult(
|
||||
"WebType 타입 검증",
|
||||
webTypeTestPassed ? "passed" : "failed",
|
||||
webTypeTestPassed ? "모든 WebType 검증 통과" : "일부 WebType 검증 실패",
|
||||
);
|
||||
|
||||
// Test 2: ButtonActionType 검증
|
||||
const validActions: ButtonActionType[] = ["save", "cancel", "delete", "edit"];
|
||||
const invalidActions = ["insert", "update", ""];
|
||||
|
||||
let buttonActionTestPassed = true;
|
||||
validActions.forEach((action) => {
|
||||
if (!isButtonActionType(action)) buttonActionTestPassed = false;
|
||||
});
|
||||
invalidActions.forEach((action) => {
|
||||
if (isButtonActionType(action as any)) buttonActionTestPassed = false;
|
||||
});
|
||||
|
||||
addResult(
|
||||
"ButtonActionType 검증",
|
||||
buttonActionTestPassed ? "passed" : "failed",
|
||||
buttonActionTestPassed ? "모든 ButtonActionType 검증 통과" : "일부 ButtonActionType 검증 실패",
|
||||
);
|
||||
|
||||
// Test 3: Y/N ↔ boolean 변환
|
||||
const ynTests = [
|
||||
{ input: "Y", expected: true },
|
||||
{ input: "N", expected: false },
|
||||
{ input: "", expected: false },
|
||||
{ input: undefined, expected: false },
|
||||
];
|
||||
|
||||
let ynTestPassed = true;
|
||||
ynTests.forEach(({ input, expected }) => {
|
||||
if (ynToBoolean(input) !== expected) ynTestPassed = false;
|
||||
});
|
||||
|
||||
if (booleanToYN(true) !== "Y" || booleanToYN(false) !== "N") ynTestPassed = false;
|
||||
|
||||
addResult(
|
||||
"Y/N ↔ boolean 변환",
|
||||
ynTestPassed ? "passed" : "failed",
|
||||
ynTestPassed ? "모든 Y/N 변환 테스트 통과" : "Y/N 변환 테스트 실패",
|
||||
);
|
||||
|
||||
// Test 4: 컴포넌트 타입 가드
|
||||
const testWidget: WidgetComponent = {
|
||||
id: "test-widget",
|
||||
type: "widget",
|
||||
widgetType: "text",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "테스트",
|
||||
webTypeConfig: {},
|
||||
};
|
||||
|
||||
const testContainer = {
|
||||
id: "test-container",
|
||||
type: "container",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 400, height: 300 },
|
||||
children: [],
|
||||
};
|
||||
|
||||
let typeGuardTestPassed = true;
|
||||
if (!isWidgetComponent(testWidget)) typeGuardTestPassed = false;
|
||||
if (isWidgetComponent(testContainer)) typeGuardTestPassed = false;
|
||||
|
||||
addResult(
|
||||
"컴포넌트 타입 가드",
|
||||
typeGuardTestPassed ? "passed" : "failed",
|
||||
typeGuardTestPassed ? "타입 가드 모든 테스트 통과" : "타입 가드 테스트 실패",
|
||||
);
|
||||
|
||||
// Test 5: 폼 데이터 처리 시뮬레이션
|
||||
const formData = {
|
||||
userName: "테스트 사용자",
|
||||
userAge: 25,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const formComponents: WidgetComponent[] = [
|
||||
{
|
||||
id: "userName",
|
||||
type: "widget",
|
||||
widgetType: "text",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "사용자명",
|
||||
columnName: "user_name",
|
||||
webTypeConfig: {},
|
||||
},
|
||||
{
|
||||
id: "isActive",
|
||||
type: "widget",
|
||||
widgetType: "checkbox",
|
||||
position: { x: 0, y: 50 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "활성화",
|
||||
columnName: "is_active",
|
||||
webTypeConfig: {},
|
||||
},
|
||||
];
|
||||
|
||||
const processedData: Record<string, any> = {};
|
||||
formComponents.forEach((component) => {
|
||||
const fieldValue = formData[component.id as keyof typeof formData];
|
||||
if (fieldValue !== undefined && component.columnName) {
|
||||
switch (component.widgetType) {
|
||||
case "text":
|
||||
processedData[component.columnName] = String(fieldValue);
|
||||
break;
|
||||
case "checkbox":
|
||||
processedData[component.columnName] = booleanToYN(Boolean(fieldValue));
|
||||
break;
|
||||
default:
|
||||
processedData[component.columnName] = fieldValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let formProcessingTestPassed = true;
|
||||
if (typeof processedData.user_name !== "string") formProcessingTestPassed = false;
|
||||
if (processedData.is_active !== "Y" && processedData.is_active !== "N") formProcessingTestPassed = false;
|
||||
|
||||
addResult(
|
||||
"폼 데이터 처리",
|
||||
formProcessingTestPassed ? "passed" : "failed",
|
||||
formProcessingTestPassed ? "폼 데이터 타입 안전 처리 성공" : "폼 데이터 처리 실패",
|
||||
);
|
||||
} catch (error) {
|
||||
addResult("전체 테스트", "failed", `테스트 실행 중 오류: ${error}`);
|
||||
}
|
||||
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const passedTests = testResults.filter((test) => test.status === "passed").length;
|
||||
const totalTests = testResults.length;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="text-3xl font-bold">🧪 타입 안전성 간단 테스트</h1>
|
||||
<p className="text-muted-foreground">
|
||||
화면관리, 제어관리, 테이블타입관리 시스템의 핵심 타입 안전성을 검증합니다
|
||||
</p>
|
||||
|
||||
<Button onClick={runSimpleTests} disabled={isRunning} size="lg">
|
||||
{isRunning ? "테스트 실행 중..." : "🚀 간단 테스트 실행"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>📊 테스트 결과</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">
|
||||
{passedTests}/{totalTests} 통과
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
성공률: {totalTests > 0 ? Math.round((passedTests / totalTests) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResults.map((test, index) => (
|
||||
<div key={index} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{test.name}</div>
|
||||
{test.message && <div className="text-muted-foreground mt-1 text-sm">{test.message}</div>}
|
||||
</div>
|
||||
|
||||
<Badge variant={test.status === "passed" ? "default" : "destructive"}>
|
||||
{test.status === "passed" ? "통과" : "실패"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{passedTests === totalTests && totalTests > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div className="font-medium text-green-800">🎉 모든 타입 안전성 테스트가 통과되었습니다!</div>
|
||||
<div className="mt-2 text-sm text-green-600">
|
||||
화면관리, 제어관리, 테이블타입관리 시스템이 안전하게 작동합니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import StressTestSuite from "../../test-scenarios/stress-test-scenarios";
|
||||
|
||||
interface TestResult {
|
||||
testName: string;
|
||||
status: "passed" | "failed" | "warning";
|
||||
duration: number;
|
||||
details: string;
|
||||
metrics?: any;
|
||||
}
|
||||
|
||||
interface StressTestResults {
|
||||
success: boolean;
|
||||
totalTests: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
warningTests: number;
|
||||
totalDuration: number;
|
||||
results: TestResult[];
|
||||
recommendation: string[];
|
||||
}
|
||||
|
||||
export default function StressTestPage() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [currentTest, setCurrentTest] = useState<string>("");
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [testResults, setTestResults] = useState<StressTestResults | null>(null);
|
||||
const [testLogs, setTestLogs] = useState<string[]>([]);
|
||||
|
||||
const runStressTests = async () => {
|
||||
setIsRunning(true);
|
||||
setTestResults(null);
|
||||
setTestLogs([]);
|
||||
setProgress(0);
|
||||
|
||||
// 콘솔 로그를 캡처하기 위한 오버라이드
|
||||
const originalLog = console.log;
|
||||
const capturedLogs: string[] = [];
|
||||
|
||||
console.log = (...args) => {
|
||||
const logMessage = args.join(" ");
|
||||
capturedLogs.push(logMessage);
|
||||
setTestLogs((prev) => [...prev, logMessage]);
|
||||
originalLog(...args);
|
||||
};
|
||||
|
||||
try {
|
||||
// 개별 테스트 진행 상황 모니터링
|
||||
const testNames = [
|
||||
"대량 데이터 처리",
|
||||
"타입 오염 및 손상",
|
||||
"동시 작업 및 경합 상태",
|
||||
"메모리 부하 및 가비지 컬렉션",
|
||||
"API 스트레스 및 네트워크 시뮬레이션",
|
||||
];
|
||||
|
||||
// 각 테스트 시작 시 진행률 업데이트
|
||||
let completedTests = 0;
|
||||
|
||||
const updateProgress = (testName: string) => {
|
||||
setCurrentTest(testName);
|
||||
setProgress((completedTests / testNames.length) * 100);
|
||||
};
|
||||
|
||||
// 테스트 실행 (실제로는 StressTestSuite.runAllStressTests()가 모든 테스트를 순차 실행)
|
||||
updateProgress(testNames[0]);
|
||||
|
||||
// 진행률 시뮬레이션을 위한 간격 업데이트
|
||||
const progressInterval = setInterval(() => {
|
||||
completedTests = Math.min(completedTests + 0.1, testNames.length - 0.1);
|
||||
const currentTestIndex = Math.floor(completedTests);
|
||||
if (currentTestIndex < testNames.length) {
|
||||
setCurrentTest(testNames[currentTestIndex]);
|
||||
}
|
||||
setProgress((completedTests / testNames.length) * 100);
|
||||
}, 200);
|
||||
|
||||
const results = await StressTestSuite.runAllStressTests();
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
setCurrentTest("완료");
|
||||
setTestResults(results as StressTestResults);
|
||||
} catch (error) {
|
||||
console.error("스트레스 테스트 실행 중 오류:", error);
|
||||
setTestResults({
|
||||
success: false,
|
||||
totalTests: 0,
|
||||
passedTests: 0,
|
||||
failedTests: 1,
|
||||
warningTests: 0,
|
||||
totalDuration: 0,
|
||||
results: [],
|
||||
recommendation: [`스트레스 테스트 실행 중 오류가 발생했습니다: ${error}`],
|
||||
});
|
||||
} finally {
|
||||
// 콘솔 로그 복원
|
||||
console.log = originalLog;
|
||||
setIsRunning(false);
|
||||
setCurrentTest("");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "passed":
|
||||
return "default";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
case "warning":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "passed":
|
||||
return "✅";
|
||||
case "failed":
|
||||
return "❌";
|
||||
case "warning":
|
||||
return "⚠️";
|
||||
default:
|
||||
return "❓";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="text-3xl font-bold">🔥 타입 시스템 스트레스 테스트</h1>
|
||||
<p className="text-muted-foreground">극한 상황에서 타입 시스템의 견고함과 성능을 검증합니다</p>
|
||||
<div className="rounded-lg bg-orange-50 p-3 text-sm text-orange-600">
|
||||
⚠️ 주의: 이 테스트는 시스템에 높은 부하를 가할 수 있습니다
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={runStressTests}
|
||||
disabled={isRunning}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600"
|
||||
>
|
||||
{isRunning ? "🔥 스트레스 테스트 실행 중..." : "🚀 스트레스 테스트 시작"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 진행률 표시 */}
|
||||
{isRunning && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">📊 테스트 진행 상황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>현재 테스트: {currentTest}</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 테스트 결과 */}
|
||||
{testResults && (
|
||||
<div className="space-y-6">
|
||||
{/* 요약 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-2xl">
|
||||
📈 스트레스 테스트 결과 요약
|
||||
{testResults.success ? "🎉" : "⚠️"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{testResults.passedTests}</div>
|
||||
<div className="text-muted-foreground text-sm">통과</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{testResults.failedTests}</div>
|
||||
<div className="text-muted-foreground text-sm">실패</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{testResults.warningTests}</div>
|
||||
<div className="text-muted-foreground text-sm">경고</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{Math.round(testResults.totalDuration)}ms</div>
|
||||
<div className="text-muted-foreground text-sm">총 소요시간</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold">
|
||||
성공률:{" "}
|
||||
{testResults.totalTests > 0
|
||||
? Math.round((testResults.passedTests / testResults.totalTests) * 100)
|
||||
: 0}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResults.success ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div className="font-medium text-green-800">🎉 모든 스트레스 테스트 통과!</div>
|
||||
<div className="mt-2 text-sm text-green-600">
|
||||
타입 시스템이 극한 상황에서도 안정적으로 작동합니다.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div className="font-medium text-red-800">⚠️ 일부 스트레스 테스트 실패</div>
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
개선이 필요한 영역이 있습니다. 아래 권장사항을 확인하세요.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 개별 테스트 결과 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">🔍 개별 테스트 상세 결과</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{testResults.results.map((result, index) => (
|
||||
<div key={index} className="rounded-lg border p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{getStatusIcon(result.status)}</span>
|
||||
<h3 className="font-semibold">{result.testName}</h3>
|
||||
</div>
|
||||
<Badge variant={getStatusBadgeVariant(result.status)}>
|
||||
{result.status === "passed" ? "통과" : result.status === "failed" ? "실패" : "경고"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mb-2 text-sm">{result.details}</div>
|
||||
|
||||
<div className="text-muted-foreground text-xs">소요시간: {Math.round(result.duration)}ms</div>
|
||||
|
||||
{/* 메트릭스 표시 */}
|
||||
{result.metrics && (
|
||||
<div className="mt-3 rounded bg-gray-50 p-3 text-xs">
|
||||
<div className="mb-1 font-medium">📊 상세 메트릭스:</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(result.metrics).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span>{key}:</span>
|
||||
<span className="font-mono">
|
||||
{typeof value === "number"
|
||||
? Number.isInteger(value)
|
||||
? value.toLocaleString()
|
||||
: value.toFixed(2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 권장사항 */}
|
||||
{testResults.recommendation.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">💡 개선 권장사항</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{testResults.recommendation.map((rec, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="mt-0.5 text-blue-500">•</span>
|
||||
<span className="text-sm">{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 실시간 로그 (축소된 형태) */}
|
||||
{testLogs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">📋 테스트 로그 (최근 10개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-40 overflow-y-auto rounded bg-black p-4 font-mono text-xs text-green-400">
|
||||
{testLogs.slice(-10).map((log, index) => (
|
||||
<div key={index}>{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import StressTestPage from "../stress-test";
|
||||
|
||||
export default function StressTestRoutePage() {
|
||||
return <StressTestPage />;
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* 컬럼 추가 모달 컴포넌트
|
||||
* 기존 테이블에 새로운 컬럼을 추가하기 위한 모달
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { 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 { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, Plus, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ddlApi } from "../../lib/api/ddl";
|
||||
import {
|
||||
AddColumnModalProps,
|
||||
CreateColumnDefinition,
|
||||
VALIDATION_RULES,
|
||||
RESERVED_WORDS,
|
||||
RESERVED_COLUMNS,
|
||||
} from "../../types/ddl";
|
||||
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
|
||||
|
||||
export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) {
|
||||
const [column, setColumn] = useState<CreateColumnDefinition>({
|
||||
name: "",
|
||||
label: "",
|
||||
inputType: "text",
|
||||
nullable: true,
|
||||
order: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* 모달 리셋
|
||||
*/
|
||||
const resetModal = () => {
|
||||
setColumn({
|
||||
name: "",
|
||||
label: "",
|
||||
inputType: "text",
|
||||
nullable: true,
|
||||
order: 0,
|
||||
});
|
||||
setValidationErrors([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달 열림/닫힘 시 리셋
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
resetModal();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* 컬럼 정보 업데이트
|
||||
*/
|
||||
const updateColumn = (updates: Partial<CreateColumnDefinition>) => {
|
||||
const newColumn = { ...column, ...updates };
|
||||
setColumn(newColumn);
|
||||
|
||||
// 업데이트 후 검증
|
||||
validateColumn(newColumn);
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼 검증
|
||||
*/
|
||||
const validateColumn = (columnData: CreateColumnDefinition) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 컬럼명 검증
|
||||
if (!columnData.name) {
|
||||
errors.push("컬럼명은 필수입니다.");
|
||||
} else {
|
||||
if (!VALIDATION_RULES.columnName.pattern.test(columnData.name)) {
|
||||
errors.push(VALIDATION_RULES.columnName.errorMessage);
|
||||
}
|
||||
|
||||
if (
|
||||
columnData.name.length < VALIDATION_RULES.columnName.minLength ||
|
||||
columnData.name.length > VALIDATION_RULES.columnName.maxLength
|
||||
) {
|
||||
errors.push(
|
||||
`컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 예약어 검증
|
||||
if (RESERVED_WORDS.includes(columnData.name.toLowerCase() as any)) {
|
||||
errors.push("SQL 예약어는 컬럼명으로 사용할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 예약된 컬럼명 검증
|
||||
if (RESERVED_COLUMNS.includes(columnData.name.toLowerCase() as any)) {
|
||||
errors.push("이미 자동 추가되는 기본 컬럼명입니다.");
|
||||
}
|
||||
|
||||
// 네이밍 컨벤션 검증
|
||||
if (columnData.name.startsWith("_") || columnData.name.endsWith("_")) {
|
||||
errors.push("컬럼명은 언더스코어로 시작하거나 끝날 수 없습니다.");
|
||||
}
|
||||
|
||||
if (columnData.name.includes("__")) {
|
||||
errors.push("컬럼명에 연속된 언더스코어는 사용할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 입력타입 검증
|
||||
if (!columnData.inputType) {
|
||||
errors.push("입력타입을 선택해주세요.");
|
||||
}
|
||||
|
||||
// 길이 검증 (길이를 지원하는 타입인 경우)
|
||||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === columnData.inputType);
|
||||
if (inputTypeOption?.supportsLength && columnData.length !== undefined) {
|
||||
if (
|
||||
columnData.length < VALIDATION_RULES.columnLength.min ||
|
||||
columnData.length > VALIDATION_RULES.columnLength.max
|
||||
) {
|
||||
errors.push(VALIDATION_RULES.columnLength.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 입력타입 변경 처리
|
||||
*/
|
||||
const handleInputTypeChange = (inputType: string) => {
|
||||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
|
||||
const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
|
||||
|
||||
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
|
||||
if (inputTypeOption?.supportsLength && !column.length && inputTypeOption.defaultLength) {
|
||||
updates.length = inputTypeOption.defaultLength;
|
||||
}
|
||||
|
||||
// 길이를 지원하지 않는 타입이면 길이 제거
|
||||
if (!inputTypeOption?.supportsLength) {
|
||||
updates.length = undefined;
|
||||
}
|
||||
|
||||
updateColumn(updates);
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼 추가 실행
|
||||
*/
|
||||
const handleAddColumn = async () => {
|
||||
if (!validateColumn(column)) {
|
||||
toast.error("입력값을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await ddlApi.addColumn(tableName, { column });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onSuccess(result);
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(result.error?.details || result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("컬럼 추가 실패:", error);
|
||||
toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 폼 유효성 확인
|
||||
*/
|
||||
const isFormValid = validationErrors.length === 0 && column.name && column.inputType;
|
||||
|
||||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
컬럼 추가 - {tableName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 검증 오류 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index}>• {error}</div>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="columnName">
|
||||
컬럼명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="columnName"
|
||||
value={column.name}
|
||||
onChange={(e) => updateColumn({ name: e.target.value })}
|
||||
placeholder="column_name"
|
||||
disabled={loading}
|
||||
className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-red-300" : ""}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="columnLabel">라벨</Label>
|
||||
<Input
|
||||
id="columnLabel"
|
||||
value={column.label || ""}
|
||||
onChange={(e) => updateColumn({ label: e.target.value })}
|
||||
placeholder="컬럼 라벨"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">화면에 표시될 라벨 (선택사항)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입 및 속성 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
입력타입 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={column.inputType} onValueChange={handleInputTypeChange} disabled={loading}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="입력타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INPUT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="text-muted-foreground text-xs">{option.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="columnLength">길이</Label>
|
||||
<Input
|
||||
id="columnLength"
|
||||
type="number"
|
||||
value={column.length || ""}
|
||||
onChange={(e) =>
|
||||
updateColumn({
|
||||
length: e.target.value ? parseInt(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={inputTypeOption?.defaultLength?.toString() || ""}
|
||||
disabled={loading || !inputTypeOption?.supportsLength}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{inputTypeOption?.supportsLength ? "1-65535 범위에서 설정 가능" : "이 타입은 길이 설정이 불가능합니다"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본값 및 NULL 허용 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultValue">기본값</Label>
|
||||
<Input
|
||||
id="defaultValue"
|
||||
value={column.defaultValue || ""}
|
||||
onChange={(e) => updateColumn({ defaultValue: e.target.value })}
|
||||
placeholder="기본값 (선택사항)"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-6">
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={!column.nullable}
|
||||
onCheckedChange={(checked) => updateColumn({ nullable: !checked })}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="required" className="text-sm font-medium">
|
||||
필수 입력 (NOT NULL)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={column.description || ""}
|
||||
onChange={(e) => updateColumn({ description: e.target.value })}
|
||||
placeholder="컬럼에 대한 설명 (선택사항)"
|
||||
disabled={loading}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 사항 */}
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
컬럼 추가 후에는 해당 컬럼을 삭제하거나 타입을 변경할 수 없습니다. 신중하게 검토 후 추가해주세요.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleAddColumn}
|
||||
disabled={!isFormValid || loading}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
추가 중...
|
||||
</>
|
||||
) : (
|
||||
"컬럼 추가"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* 컬럼 정의 테이블 컴포넌트
|
||||
* 테이블 생성 시 컬럼 정의를 위한 편집 가능한 테이블
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { X, AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
CreateColumnDefinition,
|
||||
ColumnDefinitionTableProps,
|
||||
VALIDATION_RULES,
|
||||
RESERVED_WORDS,
|
||||
RESERVED_COLUMNS,
|
||||
} from "../../types/ddl";
|
||||
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
|
||||
|
||||
export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) {
|
||||
const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({});
|
||||
|
||||
/**
|
||||
* 컬럼 정보 업데이트
|
||||
*/
|
||||
const updateColumn = (index: number, updates: Partial<CreateColumnDefinition>) => {
|
||||
const newColumns = [...columns];
|
||||
newColumns[index] = { ...newColumns[index], ...updates };
|
||||
onChange(newColumns);
|
||||
|
||||
// 업데이트 후 해당 컬럼 검증
|
||||
validateColumn(index, newColumns[index]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼 제거
|
||||
*/
|
||||
const removeColumn = (index: number) => {
|
||||
if (columns.length <= 1) return; // 최소 1개 컬럼 유지
|
||||
|
||||
const newColumns = columns.filter((_, i) => i !== index);
|
||||
onChange(newColumns);
|
||||
|
||||
// 검증 오류도 함께 제거
|
||||
const newErrors = { ...validationErrors };
|
||||
delete newErrors[index];
|
||||
|
||||
// 인덱스 재조정
|
||||
const adjustedErrors: Record<number, string[]> = {};
|
||||
Object.entries(newErrors).forEach(([key, value]) => {
|
||||
const idx = parseInt(key);
|
||||
if (idx > index) {
|
||||
adjustedErrors[idx - 1] = value;
|
||||
} else if (idx < index) {
|
||||
adjustedErrors[idx] = value;
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(adjustedErrors);
|
||||
};
|
||||
|
||||
/**
|
||||
* 개별 컬럼 검증
|
||||
*/
|
||||
const validateColumn = (index: number, column: CreateColumnDefinition) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 컬럼명 검증
|
||||
if (!column.name) {
|
||||
errors.push("컬럼명은 필수입니다");
|
||||
} else {
|
||||
if (!VALIDATION_RULES.columnName.pattern.test(column.name)) {
|
||||
errors.push(VALIDATION_RULES.columnName.errorMessage);
|
||||
}
|
||||
|
||||
if (
|
||||
column.name.length < VALIDATION_RULES.columnName.minLength ||
|
||||
column.name.length > VALIDATION_RULES.columnName.maxLength
|
||||
) {
|
||||
errors.push(
|
||||
`컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다`,
|
||||
);
|
||||
}
|
||||
|
||||
// 예약어 검증
|
||||
if (RESERVED_WORDS.includes(column.name.toLowerCase() as any)) {
|
||||
errors.push("예약어는 컬럼명으로 사용할 수 없습니다");
|
||||
}
|
||||
|
||||
// 예약된 컬럼명 검증
|
||||
if (RESERVED_COLUMNS.includes(column.name.toLowerCase() as any)) {
|
||||
errors.push("이미 자동 추가되는 기본 컬럼명입니다");
|
||||
}
|
||||
|
||||
// 중복 검증
|
||||
const duplicateCount = columns.filter((col) => col.name.toLowerCase() === column.name.toLowerCase()).length;
|
||||
if (duplicateCount > 1) {
|
||||
errors.push("중복된 컬럼명입니다");
|
||||
}
|
||||
}
|
||||
|
||||
// 입력타입 검증
|
||||
if (!column.inputType) {
|
||||
errors.push("입력타입을 선택해주세요");
|
||||
}
|
||||
|
||||
// 길이 검증 (길이를 지원하는 타입인 경우)
|
||||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
||||
if (inputTypeOption?.supportsLength && column.length !== undefined) {
|
||||
if (column.length < VALIDATION_RULES.columnLength.min || column.length > VALIDATION_RULES.columnLength.max) {
|
||||
errors.push(VALIDATION_RULES.columnLength.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// 검증 오류 상태 업데이트
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
[index]: errors,
|
||||
}));
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 입력타입 변경 시 길이 기본값 설정
|
||||
*/
|
||||
const handleInputTypeChange = (index: number, inputType: string) => {
|
||||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
|
||||
const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
|
||||
|
||||
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
|
||||
if (inputTypeOption?.supportsLength && !columns[index].length && inputTypeOption.defaultLength) {
|
||||
updates.length = inputTypeOption.defaultLength;
|
||||
}
|
||||
|
||||
// 길이를 지원하지 않는 타입이면 길이 제거
|
||||
if (!inputTypeOption?.supportsLength) {
|
||||
updates.length = undefined;
|
||||
}
|
||||
|
||||
updateColumn(index, updates);
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 검증 상태 확인
|
||||
*/
|
||||
const hasValidationErrors = Object.values(validationErrors).some((errors) => errors.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검증 오류 요약 */}
|
||||
{hasValidationErrors && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>컬럼 정의에 오류가 있습니다. 각 컬럼의 오류를 확인해주세요.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정의 테이블 */}
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">
|
||||
컬럼명 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">라벨</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
입력타입 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">필수</TableHead>
|
||||
<TableHead className="w-[100px]">길이</TableHead>
|
||||
<TableHead className="w-[120px]">기본값</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{columns.map((column, index) => {
|
||||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
||||
const rowErrors = validationErrors[index] || [];
|
||||
const hasRowError = rowErrors.length > 0;
|
||||
|
||||
return (
|
||||
<TableRow key={index} className={hasRowError ? "bg-red-50" : ""}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={column.name}
|
||||
onChange={(e) => updateColumn(index, { name: e.target.value })}
|
||||
placeholder="column_name"
|
||||
disabled={disabled}
|
||||
className={hasRowError ? "border-red-300" : ""}
|
||||
/>
|
||||
{rowErrors.length > 0 && (
|
||||
<div className="space-y-1 text-xs text-red-600">
|
||||
{rowErrors.map((error, i) => (
|
||||
<div key={i}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Input
|
||||
value={column.label || ""}
|
||||
onChange={(e) => updateColumn(index, { label: e.target.value })}
|
||||
placeholder="컬럼 라벨"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Select
|
||||
value={column.inputType}
|
||||
onValueChange={(value) => handleInputTypeChange(index, value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="입력 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INPUT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="text-muted-foreground text-xs">{option.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={!column.nullable}
|
||||
onCheckedChange={(checked) => updateColumn(index, { nullable: !checked })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={column.length || ""}
|
||||
onChange={(e) =>
|
||||
updateColumn(index, {
|
||||
length: e.target.value ? parseInt(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={inputTypeOption?.defaultLength?.toString() || ""}
|
||||
disabled={disabled || !inputTypeOption?.supportsLength}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Input
|
||||
value={column.defaultValue || ""}
|
||||
onChange={(e) => updateColumn(index, { defaultValue: e.target.value })}
|
||||
placeholder="기본값"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={column.description || ""}
|
||||
onChange={(e) => updateColumn(index, { description: e.target.value })}
|
||||
placeholder="컬럼 설명"
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="min-h-[36px] resize-none"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeColumn(index)}
|
||||
disabled={disabled || columns.length === 1}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="text-muted-foreground space-y-1 text-sm">
|
||||
<div>• 컬럼명은 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.</div>
|
||||
<div>• 다음 컬럼들은 자동으로 추가됩니다: id (PK), created_date, updated_date, company_code</div>
|
||||
<div>• 필수 컬럼은 NOT NULL 제약조건이 추가됩니다.</div>
|
||||
<div>• 길이는 text, code, email, tel 등의 타입에서만 설정 가능합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
/**
|
||||
* 테이블 생성 모달 컴포넌트
|
||||
* 새로운 테이블을 생성하기 위한 모달
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
||||
import { ddlApi } from "../../lib/api/ddl";
|
||||
import {
|
||||
CreateTableModalProps,
|
||||
CreateColumnDefinition,
|
||||
VALIDATION_RULES,
|
||||
SYSTEM_TABLES,
|
||||
RESERVED_WORDS,
|
||||
} from "../../types/ddl";
|
||||
|
||||
export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModalProps) {
|
||||
const [tableName, setTableName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [columns, setColumns] = useState<CreateColumnDefinition[]>([
|
||||
{
|
||||
name: "",
|
||||
label: "",
|
||||
inputType: "text",
|
||||
nullable: true,
|
||||
order: 1,
|
||||
},
|
||||
]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [tableNameError, setTableNameError] = useState("");
|
||||
const [validationResult, setValidationResult] = useState<any>(null);
|
||||
|
||||
/**
|
||||
* 모달 리셋
|
||||
*/
|
||||
const resetModal = () => {
|
||||
setTableName("");
|
||||
setDescription("");
|
||||
setColumns([
|
||||
{
|
||||
name: "",
|
||||
label: "",
|
||||
inputType: "text",
|
||||
nullable: true,
|
||||
order: 1,
|
||||
},
|
||||
]);
|
||||
setTableNameError("");
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달 열림/닫힘 시 리셋
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
resetModal();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* 테이블명 검증
|
||||
*/
|
||||
const validateTableName = (name: string): string => {
|
||||
if (!name) {
|
||||
return "테이블명은 필수입니다.";
|
||||
}
|
||||
|
||||
if (!VALIDATION_RULES.tableName.pattern.test(name)) {
|
||||
return VALIDATION_RULES.tableName.errorMessage;
|
||||
}
|
||||
|
||||
if (name.length < VALIDATION_RULES.tableName.minLength || name.length > VALIDATION_RULES.tableName.maxLength) {
|
||||
return `테이블명은 ${VALIDATION_RULES.tableName.minLength}-${VALIDATION_RULES.tableName.maxLength}자여야 합니다.`;
|
||||
}
|
||||
|
||||
if (SYSTEM_TABLES.includes(name.toLowerCase() as any)) {
|
||||
return "시스템 테이블명으로 사용할 수 없습니다.";
|
||||
}
|
||||
|
||||
if (RESERVED_WORDS.includes(name.toLowerCase() as any)) {
|
||||
return "SQL 예약어는 테이블명으로 사용할 수 없습니다.";
|
||||
}
|
||||
|
||||
if (name.startsWith("_") || name.endsWith("_")) {
|
||||
return "테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.";
|
||||
}
|
||||
|
||||
if (name.includes("__")) {
|
||||
return "테이블명에 연속된 언더스코어는 사용할 수 없습니다.";
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블명 변경 처리
|
||||
*/
|
||||
const handleTableNameChange = (value: string) => {
|
||||
setTableName(value);
|
||||
const error = validateTableName(value);
|
||||
setTableNameError(error);
|
||||
|
||||
// 검증 결과 초기화
|
||||
if (validationResult) {
|
||||
setValidationResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼 추가
|
||||
*/
|
||||
const addColumn = () => {
|
||||
setColumns([
|
||||
...columns,
|
||||
{
|
||||
name: "",
|
||||
label: "",
|
||||
inputType: "text",
|
||||
nullable: true,
|
||||
order: columns.length + 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 생성 사전 검증
|
||||
*/
|
||||
const validateTable = async () => {
|
||||
if (tableNameError || !tableName) {
|
||||
toast.error("테이블명을 올바르게 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validColumns = columns.filter((col) => col.name && col.inputType);
|
||||
if (validColumns.length === 0) {
|
||||
toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidating(true);
|
||||
try {
|
||||
const result = await ddlApi.validateTableCreation({
|
||||
tableName,
|
||||
columns: validColumns,
|
||||
description,
|
||||
});
|
||||
|
||||
setValidationResult(result);
|
||||
|
||||
if (result.isValid) {
|
||||
toast.success("검증 완료! 테이블을 생성할 수 있습니다.");
|
||||
} else {
|
||||
toast.error("검증 실패. 오류를 확인해주세요.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("테이블 검증 실패:", error);
|
||||
toast.error("검증 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 생성 실행
|
||||
*/
|
||||
const handleCreateTable = async () => {
|
||||
if (tableNameError || !tableName) {
|
||||
toast.error("테이블명을 올바르게 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validColumns = columns.filter((col) => col.name && col.inputType);
|
||||
if (validColumns.length === 0) {
|
||||
toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await ddlApi.createTable({
|
||||
tableName,
|
||||
columns: validColumns,
|
||||
description,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onSuccess(result);
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(result.error?.details || result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("테이블 생성 실패:", error);
|
||||
toast.error(error.response?.data?.error?.details || "테이블 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 폼 유효성 확인
|
||||
*/
|
||||
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />새 테이블 생성
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 테이블 기본 정보 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">
|
||||
테이블명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="tableName"
|
||||
value={tableName}
|
||||
onChange={(e) => handleTableNameChange(e.target.value)}
|
||||
placeholder="예: customer_info"
|
||||
className={tableNameError ? "border-red-300" : ""}
|
||||
/>
|
||||
{tableNameError && <p className="text-sm text-red-600">{tableNameError}</p>}
|
||||
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="테이블에 대한 설명"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 정의 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
컬럼 정의 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addColumn} disabled={loading}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||
</div>
|
||||
|
||||
{/* 자동 추가 컬럼 안내 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>자동 추가 컬럼</AlertTitle>
|
||||
<AlertDescription>
|
||||
다음 컬럼들이 자동으로 추가됩니다:
|
||||
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">id</code>(기본키),
|
||||
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">created_date</code>,
|
||||
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">updated_date</code>,
|
||||
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">company_code</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 검증 결과 */}
|
||||
{validationResult && (
|
||||
<Alert variant={validationResult.isValid ? "default" : "destructive"}>
|
||||
{validationResult.isValid ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
|
||||
<AlertTitle>{validationResult.isValid ? "검증 성공" : "검증 실패"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>{validationResult.summary}</div>
|
||||
{validationResult.errors && validationResult.errors.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="font-medium">오류:</div>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{validationResult.errors.map((error: string, index: number) => (
|
||||
<li key={index} className="text-sm">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{validationResult.warnings && validationResult.warnings.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="font-medium">경고:</div>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{validationResult.warnings.map((warning: string, index: number) => (
|
||||
<li key={index} className="text-sm text-orange-600">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={validateTable} disabled={!isFormValid || validating || loading}>
|
||||
{validating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
검증 중...
|
||||
</>
|
||||
) : (
|
||||
"검증하기"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateTable}
|
||||
disabled={!isFormValid || loading || (validationResult && !validationResult.isValid)}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
"테이블 생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
/**
|
||||
* DDL 로그 뷰어 컴포넌트
|
||||
* DDL 실행 로그와 통계를 표시
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Calendar,
|
||||
User,
|
||||
Database,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
BarChart3,
|
||||
Clock,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { ddlApi } from "../../lib/api/ddl";
|
||||
import { DDLLogViewerProps, DDLExecutionLog, DDLStatistics } from "../../types/ddl";
|
||||
|
||||
export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||
const [logs, setLogs] = useState<DDLExecutionLog[]>([]);
|
||||
const [statistics, setStatistics] = useState<DDLStatistics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 필터 상태
|
||||
const [userFilter, setUserFilter] = useState("");
|
||||
const [ddlTypeFilter, setDdlTypeFilter] = useState("all");
|
||||
const [limit, setLimit] = useState(50);
|
||||
|
||||
/**
|
||||
* 로그 및 통계 로드
|
||||
*/
|
||||
const loadData = async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
setRefreshing(true);
|
||||
|
||||
try {
|
||||
// 로그와 통계를 병렬로 로드
|
||||
const [logsResult, statsResult] = await Promise.all([
|
||||
ddlApi.getDDLLogs({
|
||||
limit,
|
||||
userId: userFilter || undefined,
|
||||
ddlType: ddlTypeFilter === "all" ? undefined : ddlTypeFilter,
|
||||
}),
|
||||
ddlApi.getDDLStatistics(),
|
||||
]);
|
||||
|
||||
setLogs(logsResult.logs);
|
||||
setStatistics(statsResult);
|
||||
} catch (error) {
|
||||
console.error("DDL 로그 로드 실패:", error);
|
||||
toast.error("DDL 로그를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 필터 적용
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
loadData(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 필터 초기화
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
setUserFilter("");
|
||||
setDdlTypeFilter("");
|
||||
setLimit(50);
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그 정리
|
||||
*/
|
||||
const cleanupLogs = async () => {
|
||||
if (!confirm("90일 이전의 오래된 로그를 삭제하시겠습니까?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ddlApi.cleanupOldLogs(90);
|
||||
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
|
||||
loadData(false);
|
||||
} catch (error) {
|
||||
console.error("로그 정리 실패:", error);
|
||||
toast.error("로그 정리에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트 마운트 시 데이터 로드
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* DDL 타입 배지 색상
|
||||
*/
|
||||
const getDDLTypeBadgeVariant = (ddlType: string) => {
|
||||
switch (ddlType) {
|
||||
case "CREATE_TABLE":
|
||||
return "default";
|
||||
case "ADD_COLUMN":
|
||||
return "secondary";
|
||||
case "DROP_TABLE":
|
||||
return "destructive";
|
||||
case "DROP_COLUMN":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 성공률 계산
|
||||
*/
|
||||
const getSuccessRate = (stats: DDLStatistics) => {
|
||||
if (stats.totalExecutions === 0) return 0;
|
||||
return Math.round((stats.successfulExecutions / stats.totalExecutions) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
DDL 실행 로그 및 통계
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="logs" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="logs">실행 로그</TabsTrigger>
|
||||
<TabsTrigger value="statistics">통계</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 실행 로그 탭 */}
|
||||
<TabsContent value="logs" className="space-y-4">
|
||||
{/* 필터 및 컨트롤 */}
|
||||
<div className="bg-muted/50 flex flex-wrap items-center gap-2 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<Input
|
||||
placeholder="사용자 ID"
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={ddlTypeFilter} onValueChange={setDdlTypeFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="DDL 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="CREATE_TABLE">테이블 생성</SelectItem>
|
||||
<SelectItem value="ADD_COLUMN">컬럼 추가</SelectItem>
|
||||
<SelectItem value="DROP_TABLE">테이블 삭제</SelectItem>
|
||||
<SelectItem value="DROP_COLUMN">컬럼 삭제</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
<SelectItem value="200">200개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={applyFilters} size="sm">
|
||||
<Search className="mr-1 h-4 w-4" />
|
||||
검색
|
||||
</Button>
|
||||
|
||||
<Button onClick={resetFilters} variant="outline" size="sm">
|
||||
초기화
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button onClick={() => loadData(false)} disabled={refreshing} variant="outline" size="sm">
|
||||
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
|
||||
<Button onClick={cleanupLogs} variant="outline" size="sm">
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
로그 정리
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="mr-2 h-6 w-6 animate-spin" />
|
||||
로그를 불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>실행 시간</TableHead>
|
||||
<TableHead>사용자</TableHead>
|
||||
<TableHead>DDL 타입</TableHead>
|
||||
<TableHead>테이블명</TableHead>
|
||||
<TableHead>결과</TableHead>
|
||||
<TableHead>쿼리 미리보기</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
|
||||
표시할 로그가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
{format(new Date(log.executed_at), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.user_id}</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Badge variant={getDDLTypeBadgeVariant(log.ddl_type)}>{log.ddl_type}</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<code className="bg-muted rounded px-2 py-1 text-sm">{log.table_name}</code>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{log.success ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className={log.success ? "text-green-600" : "text-red-600"}>
|
||||
{log.success ? "성공" : "실패"}
|
||||
</span>
|
||||
</div>
|
||||
{log.error_message && (
|
||||
<div className="mt-1 max-w-xs truncate text-xs text-red-600">{log.error_message}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<code className="text-muted-foreground block max-w-xs truncate text-xs">
|
||||
{log.ddl_query_preview}
|
||||
</code>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 통계 탭 */}
|
||||
<TabsContent value="statistics" className="space-y-4">
|
||||
{statistics && (
|
||||
<div className="grid gap-4">
|
||||
{/* 전체 통계 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">전체 실행</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{statistics.totalExecutions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{statistics.successfulExecutions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{statistics.failedExecutions}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">성공률</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{getSuccessRate(statistics)}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* DDL 타입별 통계 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">DDL 타입별 실행 횟수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{Object.entries(statistics.byDDLType).map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<Badge variant={getDDLTypeBadgeVariant(type)}>{type}</Badge>
|
||||
<span className="font-medium">{count}회</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">사용자별 실행 횟수</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{Object.entries(statistics.byUser).map(([user, count]) => (
|
||||
<div key={user} className="flex items-center justify-between">
|
||||
<Badge variant="outline">{user}</Badge>
|
||||
<span className="font-medium">{count}회</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 최근 실패 로그 */}
|
||||
{statistics.recentFailures.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-red-600">최근 실패 로그</CardTitle>
|
||||
<CardDescription>최근 발생한 DDL 실행 실패 내역입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{statistics.recentFailures.map((failure, index) => (
|
||||
<div key={index} className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
|
||||
<code className="text-sm">{failure.table_name}</code>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-red-600">{failure.error_message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
|||
return;
|
||||
}
|
||||
|
||||
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
|
||||
const result = await ExternalDbConnectionAPI.testConnection(connection.id, formData.password);
|
||||
setTestResult(result);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,378 @@
|
|||
/**
|
||||
* 폼 검증 상태 표시 컴포넌트
|
||||
* 실시간 검증 피드백과 사용자 가이드를 제공
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Info } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { FormValidationState, SaveState, ValidationError, ValidationWarning } from "@/hooks/useFormValidation";
|
||||
|
||||
// Props 타입
|
||||
export interface FormValidationIndicatorProps {
|
||||
validationState: FormValidationState;
|
||||
saveState: SaveState;
|
||||
onValidate?: () => void;
|
||||
onSave?: () => void;
|
||||
canSave?: boolean;
|
||||
compact?: boolean;
|
||||
showDetails?: boolean;
|
||||
showPerformance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 검증 상태 표시 컴포넌트
|
||||
*/
|
||||
export const FormValidationIndicator: React.FC<FormValidationIndicatorProps> = ({
|
||||
validationState,
|
||||
saveState,
|
||||
onValidate,
|
||||
onSave,
|
||||
canSave = false,
|
||||
compact = false,
|
||||
showDetails = true,
|
||||
showPerformance = false,
|
||||
}) => {
|
||||
if (compact) {
|
||||
return (
|
||||
<CompactValidationIndicator
|
||||
validationState={validationState}
|
||||
saveState={saveState}
|
||||
onSave={onSave}
|
||||
canSave={canSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">폼 검증 상태</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<ValidationStatusBadge status={validationState.status} />
|
||||
{validationState.lastValidated && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{validationState.lastValidated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* 검증 요약 */}
|
||||
<ValidationSummary validationState={validationState} />
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onValidate}
|
||||
disabled={validationState.status === "validating"}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{validationState.status === "validating" ? (
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
검증하기
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={!canSave || saveState.status === "saving"}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{saveState.status === "saving" ? (
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
)}
|
||||
저장하기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
{showDetails && (
|
||||
<>
|
||||
<Separator />
|
||||
<ValidationDetails validationState={validationState} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 성능 정보 */}
|
||||
{showPerformance && saveState.result?.performance && (
|
||||
<>
|
||||
<Separator />
|
||||
<PerformanceInfo performance={saveState.result.performance} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 간단한 검증 상태 표시
|
||||
*/
|
||||
const CompactValidationIndicator: React.FC<{
|
||||
validationState: FormValidationState;
|
||||
saveState: SaveState;
|
||||
onSave?: () => void;
|
||||
canSave: boolean;
|
||||
}> = ({ validationState, saveState, onSave, canSave }) => {
|
||||
return (
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded-md p-2">
|
||||
<ValidationStatusBadge status={validationState.status} />
|
||||
|
||||
<div className="flex-1 text-sm">
|
||||
{validationState.errors.length > 0 && (
|
||||
<span className="text-destructive">{validationState.errors.length}개 오류</span>
|
||||
)}
|
||||
{validationState.warnings.length > 0 && (
|
||||
<span className="ml-2 text-orange-600">{validationState.warnings.length}개 경고</span>
|
||||
)}
|
||||
{validationState.isValid && <span className="text-green-600">검증 통과</span>}
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={onSave} disabled={!canSave || saveState.status === "saving"} className="h-8">
|
||||
{saveState.status === "saving" ? "저장중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 검증 상태 배지
|
||||
*/
|
||||
const ValidationStatusBadge: React.FC<{ status: FormValidationState["status"] }> = ({ status }) => {
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case "idle":
|
||||
return {
|
||||
variant: "secondary" as const,
|
||||
icon: Info,
|
||||
text: "대기중",
|
||||
};
|
||||
case "validating":
|
||||
return {
|
||||
variant: "secondary" as const,
|
||||
icon: Clock,
|
||||
text: "검증중",
|
||||
animate: true,
|
||||
};
|
||||
case "valid":
|
||||
return {
|
||||
variant: "default" as const,
|
||||
icon: CheckCircle,
|
||||
text: "유효함",
|
||||
className: "bg-green-500 hover:bg-green-600",
|
||||
};
|
||||
case "invalid":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
icon: AlertCircle,
|
||||
text: "오류",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: "secondary" as const,
|
||||
icon: Info,
|
||||
text: "알 수 없음",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className={config.className}>
|
||||
<IconComponent className={`mr-1 h-3 w-3 ${config.animate ? "animate-spin" : ""}`} />
|
||||
{config.text}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 검증 요약 정보
|
||||
*/
|
||||
const ValidationSummary: React.FC<{ validationState: FormValidationState }> = ({ validationState }) => {
|
||||
const totalFields = Object.keys(validationState.fieldStates).length;
|
||||
const validFields = Object.values(validationState.fieldStates).filter((field) => field.status === "valid").length;
|
||||
|
||||
const progress = totalFields > 0 ? (validFields / totalFields) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 진행률 */}
|
||||
{totalFields > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>검증 진행률</span>
|
||||
<span>
|
||||
{validFields}/{totalFields} 필드
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오류/경고 카운트 */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{validationState.errors.length > 0 && (
|
||||
<div className="text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{validationState.errors.length}개 오류</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationState.warnings.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-orange-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>{validationState.warnings.length}개 경고</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationState.isValid && validationState.errors.length === 0 && validationState.warnings.length === 0 && (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>모든 검증 통과</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 검증 상세 정보
|
||||
*/
|
||||
const ValidationDetails: React.FC<{ validationState: FormValidationState }> = ({ validationState }) => {
|
||||
if (validationState.errors.length === 0 && validationState.warnings.length === 0) {
|
||||
return (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>모든 검증이 성공적으로 완료되었습니다.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-32 w-full">
|
||||
<div className="space-y-2">
|
||||
{/* 오류 목록 */}
|
||||
{validationState.errors.map((error, index) => (
|
||||
<ValidationErrorItem key={`error-${index}`} error={error} />
|
||||
))}
|
||||
|
||||
{/* 경고 목록 */}
|
||||
{validationState.warnings.map((warning, index) => (
|
||||
<ValidationWarningItem key={`warning-${index}`} warning={warning} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 개별 오류 아이템
|
||||
*/
|
||||
const ValidationErrorItem: React.FC<{ error: ValidationError }> = ({ error }) => {
|
||||
return (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<span className="font-medium">{error.field}:</span> {error.message}
|
||||
{error.value !== undefined && (
|
||||
<span className="mt-1 block text-xs opacity-75">입력값: "{String(error.value)}"</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 개별 경고 아이템
|
||||
*/
|
||||
const ValidationWarningItem: React.FC<{ warning: ValidationWarning }> = ({ warning }) => {
|
||||
return (
|
||||
<Alert className="border-orange-200 bg-orange-50 py-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-sm">
|
||||
<span className="font-medium">{warning.field}:</span> {warning.message}
|
||||
{warning.suggestion && <span className="mt-1 block text-xs text-orange-700">💡 {warning.suggestion}</span>}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 성능 정보 표시
|
||||
*/
|
||||
const PerformanceInfo: React.FC<{
|
||||
performance: { validationTime: number; saveTime: number; totalTime: number };
|
||||
}> = ({ performance }) => {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-md p-3">
|
||||
<h4 className="mb-2 text-sm font-medium">성능 정보</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">검증 시간</span>
|
||||
<div className="font-mono">{performance.validationTime.toFixed(2)}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">저장 시간</span>
|
||||
<div className="font-mono">{performance.saveTime.toFixed(2)}ms</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">총 시간</span>
|
||||
<div className="font-mono">{performance.totalTime.toFixed(2)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 필드별 검증 상태 표시 컴포넌트
|
||||
*/
|
||||
export const FieldValidationIndicator: React.FC<{
|
||||
fieldName: string;
|
||||
error?: ValidationError;
|
||||
warning?: ValidationWarning;
|
||||
status?: "idle" | "validating" | "valid" | "invalid";
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}> = ({ fieldName, error, warning, status = "idle", showIcon = true, className }) => {
|
||||
if (status === "idle" && !error && !warning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-xs ${className}`}>
|
||||
{showIcon && (
|
||||
<>
|
||||
{status === "validating" && <Clock className="text-muted-foreground h-3 w-3 animate-spin" />}
|
||||
{status === "valid" && !error && <CheckCircle className="h-3 w-3 text-green-600" />}
|
||||
{error && <AlertCircle className="text-destructive h-3 w-3" />}
|
||||
{warning && !error && <AlertTriangle className="h-3 w-3 text-orange-600" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <span className="text-destructive">{error.message}</span>}
|
||||
|
||||
{warning && !error && <span className="text-orange-600">{warning.message}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -365,7 +365,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-slate-50">
|
||||
<div className="flex h-screen flex-col bg-white">
|
||||
{/* MainHeader 컴포넌트 사용 */}
|
||||
<MainHeader
|
||||
user={user}
|
||||
|
|
@ -428,7 +428,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
</aside>
|
||||
|
||||
{/* 가운데 컨텐츠 영역 */}
|
||||
<main className="bg-background flex-1 p-6">{children}</main>
|
||||
<main className="flex-1 bg-white">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* 프로필 수정 모달 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,441 @@
|
|||
/**
|
||||
* 개선된 대화형 화면 뷰어
|
||||
* 실시간 검증과 개선된 저장 시스템이 통합된 컴포넌트
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { ComponentData, WidgetComponent, DataTableComponent, ScreenDefinition, ColumnInfo } from "@/types/screen";
|
||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
|
||||
import { useFormValidation, UseFormValidationOptions } from "@/hooks/useFormValidation";
|
||||
import { FormValidationIndicator, FieldValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||
import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface EnhancedInteractiveScreenViewerProps {
|
||||
component: ComponentData;
|
||||
allComponents: ComponentData[];
|
||||
screenInfo: ScreenDefinition;
|
||||
tableColumns: ColumnInfo[];
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
hideLabel?: boolean;
|
||||
validationOptions?: UseFormValidationOptions;
|
||||
showValidationPanel?: boolean;
|
||||
compactValidation?: boolean;
|
||||
}
|
||||
|
||||
export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreenViewerProps> = ({
|
||||
component,
|
||||
allComponents,
|
||||
screenInfo,
|
||||
tableColumns,
|
||||
formData: externalFormData = {},
|
||||
onFormDataChange,
|
||||
hideLabel = false,
|
||||
validationOptions = {},
|
||||
showValidationPanel = true,
|
||||
compactValidation = false,
|
||||
}) => {
|
||||
const { userName, user } = useAuth();
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||
|
||||
// 최종 폼 데이터 (외부 + 로컬)
|
||||
const finalFormData = { ...localFormData, ...externalFormData };
|
||||
|
||||
// 폼 검증 훅 사용
|
||||
const {
|
||||
validationState,
|
||||
saveState,
|
||||
validateForm,
|
||||
validateField,
|
||||
saveForm,
|
||||
clearValidation,
|
||||
getFieldError,
|
||||
getFieldWarning,
|
||||
hasFieldError,
|
||||
isFieldValid,
|
||||
canSave,
|
||||
} = useFormValidation(finalFormData, allComponents, tableColumns, screenInfo, {
|
||||
enableRealTimeValidation: true,
|
||||
validationDelay: 300,
|
||||
enableAutoSave: false,
|
||||
showToastMessages: true,
|
||||
validateOnMount: false,
|
||||
...validationOptions,
|
||||
});
|
||||
|
||||
// 자동값 생성 함수
|
||||
const generateAutoValue = useCallback(
|
||||
(autoValueType: string): string => {
|
||||
const now = new Date();
|
||||
switch (autoValueType) {
|
||||
case "current_datetime":
|
||||
return now.toISOString().slice(0, 19).replace("T", " ");
|
||||
case "current_date":
|
||||
return now.toISOString().slice(0, 10);
|
||||
case "current_time":
|
||||
return now.toTimeString().slice(0, 8);
|
||||
case "current_user":
|
||||
return userName || "사용자";
|
||||
case "uuid":
|
||||
return crypto.randomUUID();
|
||||
case "sequence":
|
||||
return `SEQ_${Date.now()}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
[userName],
|
||||
);
|
||||
|
||||
// 폼 데이터 변경 핸들러 (검증 포함)
|
||||
const handleFormDataChange = useCallback(
|
||||
async (fieldName: string, value: any) => {
|
||||
// 로컬 상태 업데이트
|
||||
setLocalFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
|
||||
// 외부 핸들러 호출
|
||||
onFormDataChange?.(fieldName, value);
|
||||
|
||||
// 개별 필드 검증 (debounced)
|
||||
setTimeout(() => {
|
||||
validateField(fieldName, value);
|
||||
}, 100);
|
||||
},
|
||||
[onFormDataChange, validateField],
|
||||
);
|
||||
|
||||
// 자동값 설정
|
||||
useEffect(() => {
|
||||
const widgetComponents = allComponents.filter((c) => c.type === "widget") as WidgetComponent[];
|
||||
const autoValueUpdates: Record<string, any> = {};
|
||||
|
||||
for (const widget of widgetComponents) {
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
const currentValue = finalFormData[fieldName];
|
||||
|
||||
// 자동값이 설정되어 있고 현재 값이 없는 경우
|
||||
if (widget.inputType === "auto" && widget.autoValueType && !currentValue) {
|
||||
const autoValue = generateAutoValue(widget.autoValueType);
|
||||
if (autoValue) {
|
||||
autoValueUpdates[fieldName] = autoValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(autoValueUpdates).length > 0) {
|
||||
setLocalFormData((prev) => ({ ...prev, ...autoValueUpdates }));
|
||||
}
|
||||
}, [allComponents, finalFormData, generateAutoValue]);
|
||||
|
||||
// 향상된 저장 핸들러
|
||||
const handleEnhancedSave = useCallback(async () => {
|
||||
const success = await saveForm();
|
||||
|
||||
if (success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다.", {
|
||||
description: `성능: ${saveState.result?.performance?.totalTime.toFixed(2)}ms`,
|
||||
});
|
||||
}
|
||||
}, [saveForm, saveState.result]);
|
||||
|
||||
// 대화형 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (comp.type === "datatable") {
|
||||
const dataTable = comp as DataTableComponent;
|
||||
return (
|
||||
<div key={comp.id} className="w-full">
|
||||
<InteractiveDataTable
|
||||
component={dataTable}
|
||||
formData={finalFormData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 위젯 컴포넌트가 아닌 경우 일반 컨테이너 렌더링
|
||||
if (comp.type !== "widget") {
|
||||
return renderContainer(comp);
|
||||
}
|
||||
|
||||
const widget = comp as WidgetComponent;
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
const currentValue = finalFormData[fieldName] || "";
|
||||
|
||||
// 필드 검증 상태
|
||||
const fieldError = getFieldError(fieldName);
|
||||
const fieldWarning = getFieldWarning(fieldName);
|
||||
const hasError = hasFieldError(fieldName);
|
||||
const isValid = isFieldValid(fieldName);
|
||||
|
||||
// 스타일 적용
|
||||
const applyStyles = (element: React.ReactElement) => {
|
||||
const style = widget.style || {};
|
||||
const inlineStyle: React.CSSProperties = {
|
||||
width: style.width || "100%",
|
||||
height: style.height || "auto",
|
||||
fontSize: style.fontSize,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
border: style.border,
|
||||
borderRadius: style.borderRadius,
|
||||
padding: style.padding,
|
||||
margin: style.margin,
|
||||
...style,
|
||||
};
|
||||
|
||||
// 검증 상태에 따른 스타일 조정
|
||||
if (hasError) {
|
||||
inlineStyle.borderColor = "#ef4444";
|
||||
inlineStyle.boxShadow = "0 0 0 1px #ef4444";
|
||||
} else if (isValid && finalFormData[fieldName]) {
|
||||
inlineStyle.borderColor = "#22c55e";
|
||||
}
|
||||
|
||||
return React.cloneElement(element, {
|
||||
style: inlineStyle,
|
||||
className:
|
||||
`${element.props.className || ""} ${hasError ? "border-destructive" : ""} ${isValid && finalFormData[fieldName] ? "border-green-500" : ""}`.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
// 라벨 렌더링
|
||||
const renderLabel = () => {
|
||||
if (hideLabel) return null;
|
||||
|
||||
const labelStyle = widget.style || {};
|
||||
const labelElement = (
|
||||
<label
|
||||
className={`mb-1 block text-sm font-medium ${hasError ? "text-destructive" : ""}`}
|
||||
style={{
|
||||
fontSize: labelStyle.labelFontSize || "14px",
|
||||
color: hasError ? "#ef4444" : labelStyle.labelColor || "#374151",
|
||||
fontWeight: labelStyle.labelFontWeight || "500",
|
||||
fontFamily: labelStyle.labelFontFamily,
|
||||
textAlign: labelStyle.labelTextAlign || "left",
|
||||
backgroundColor: labelStyle.labelBackgroundColor,
|
||||
padding: labelStyle.labelPadding,
|
||||
borderRadius: labelStyle.labelBorderRadius,
|
||||
marginBottom: labelStyle.labelMarginBottom || "4px",
|
||||
}}
|
||||
>
|
||||
{widget.label}
|
||||
{widget.required && <span className="ml-1 text-orange-500">*</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
return labelElement;
|
||||
};
|
||||
|
||||
// 필드 검증 표시기
|
||||
const renderFieldValidation = () => {
|
||||
if (!fieldError && !fieldWarning) return null;
|
||||
|
||||
return (
|
||||
<FieldValidationIndicator
|
||||
fieldName={fieldName}
|
||||
error={fieldError}
|
||||
warning={fieldWarning}
|
||||
status={validationState.fieldStates[fieldName]?.status}
|
||||
className="mt-1"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 웹타입별 렌더링
|
||||
const renderByWebType = () => {
|
||||
const widgetType = widget.widgetType;
|
||||
const placeholder = widget.placeholder || `${widget.label}을(를) 입력하세요`;
|
||||
const required = widget.required;
|
||||
const readonly = widget.readonly;
|
||||
|
||||
// DynamicWebTypeRenderer 사용
|
||||
try {
|
||||
const dynamicElement = (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={widgetType || "text"}
|
||||
config={widget.webTypeConfig}
|
||||
props={{
|
||||
component: widget,
|
||||
value: currentValue,
|
||||
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
||||
placeholder,
|
||||
disabled: readonly,
|
||||
required,
|
||||
className: "h-full w-full",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return applyStyles(dynamicElement);
|
||||
} catch (error) {
|
||||
console.warn(`DynamicWebTypeRenderer 오류 (${widgetType}):`, error);
|
||||
|
||||
// 폴백: 기본 input
|
||||
const fallbackElement = (
|
||||
<Input
|
||||
type="text"
|
||||
value={currentValue}
|
||||
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
);
|
||||
return applyStyles(fallbackElement);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={comp.id} className="space-y-1">
|
||||
{renderLabel()}
|
||||
{renderByWebType()}
|
||||
{renderFieldValidation()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 컨테이너 렌더링
|
||||
const renderContainer = (comp: ComponentData) => {
|
||||
const children = allComponents.filter((c) => c.parentId === comp.id);
|
||||
|
||||
return (
|
||||
<div key={comp.id} className="space-y-4">
|
||||
{comp.type === "container" && (comp as any).title && (
|
||||
<h3 className="text-lg font-semibold">{(comp as any).title}</h3>
|
||||
)}
|
||||
{children.map((child) => renderInteractiveWidget(child))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 버튼 렌더링
|
||||
const renderButton = (comp: ComponentData) => {
|
||||
const buttonConfig = (comp as any).webTypeConfig;
|
||||
const actionType = buttonConfig?.actionType || "save";
|
||||
|
||||
const handleButtonClick = async () => {
|
||||
switch (actionType) {
|
||||
case "save":
|
||||
await handleEnhancedSave();
|
||||
break;
|
||||
case "reset":
|
||||
setLocalFormData({});
|
||||
clearValidation();
|
||||
toast.info("폼이 초기화되었습니다.");
|
||||
break;
|
||||
case "validate":
|
||||
await validateForm();
|
||||
break;
|
||||
default:
|
||||
toast.info(`${actionType} 액션이 실행되었습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={comp.id}
|
||||
onClick={handleButtonClick}
|
||||
disabled={actionType === "save" && !canSave}
|
||||
variant={buttonConfig?.variant || "default"}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{saveState.status === "saving" && actionType === "save" && <Clock className="h-4 w-4 animate-spin" />}
|
||||
{validationState.status === "validating" && actionType === "validate" && (
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{comp.label || "버튼"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// 메인 렌더링
|
||||
const renderComponent = () => {
|
||||
if (component.type === "widget") {
|
||||
const widget = component as WidgetComponent;
|
||||
if (widget.widgetType === "button") {
|
||||
return renderButton(component);
|
||||
}
|
||||
return renderInteractiveWidget(component);
|
||||
}
|
||||
|
||||
return renderContainer(component);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검증 상태 패널 */}
|
||||
{showValidationPanel && (
|
||||
<FormValidationIndicator
|
||||
validationState={validationState}
|
||||
saveState={saveState}
|
||||
onValidate={validateForm}
|
||||
onSave={handleEnhancedSave}
|
||||
canSave={canSave}
|
||||
compact={compactValidation}
|
||||
showDetails={!compactValidation}
|
||||
showPerformance={!compactValidation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 메인 컴포넌트 */}
|
||||
<div className="space-y-4">{renderComponent()}</div>
|
||||
|
||||
{/* 개발 정보 (개발 환경에서만 표시) */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<>
|
||||
<Separator />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">개발 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">테이블</Badge>
|
||||
<span className="text-sm">{screenInfo.tableName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">필드</Badge>
|
||||
<span className="text-sm">{Object.keys(finalFormData).length}개</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">검증</Badge>
|
||||
<span className="text-sm">{validationState.validationCount}회</span>
|
||||
</div>
|
||||
{saveState.result?.performance && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">성능</Badge>
|
||||
<span className="text-sm">{saveState.result.performance.totalTime.toFixed(2)}ms</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -41,11 +41,12 @@ import {
|
|||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
||||
|
||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||
interface FileInfo {
|
||||
|
|
@ -332,7 +333,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
// 검색 가능한 컬럼만 필터링
|
||||
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
||||
const searchFilters = component.filters || [];
|
||||
|
||||
// 컬럼의 실제 웹 타입 정보 찾기
|
||||
const getColumnWebType = useCallback(
|
||||
|
|
@ -525,6 +525,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
}, [component.tableName]);
|
||||
|
||||
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
|
||||
const searchFilters = useMemo(() => {
|
||||
return component.filters || [];
|
||||
}, [component.filters]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData(1, searchValues);
|
||||
|
|
@ -1480,115 +1485,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 검색 필터 렌더링
|
||||
const renderSearchFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
||||
switch (filter.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="number"
|
||||
placeholder={`${filter.label} 입력...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "datetime":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
// TODO: 선택 옵션은 추후 구현
|
||||
return (
|
||||
<Select
|
||||
key={filter.columnName}
|
||||
value={value}
|
||||
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
{/* TODO: 동적 옵션 로드 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "code":
|
||||
// 코드 타입은 텍스트 검색으로 처리 (코드명으로 검색 가능)
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색... (코드명 또는 코드값)`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
// 기존 renderSearchFilter 함수는 AdvancedSearchFilters 컴포넌트로 대체됨
|
||||
|
||||
// 파일 다운로드
|
||||
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
||||
|
|
@ -1847,31 +1744,22 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
{searchFilters.length > 0 && (
|
||||
{/* 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
|
||||
{tableColumns && tableColumns.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: searchFilters
|
||||
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{searchFilters.map((filter: DataTableFilter) => (
|
||||
<div key={filter.columnName} className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
{renderSearchFilter(filter)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AdvancedSearchFilters
|
||||
filters={searchFilters.length > 0 ? searchFilters : []}
|
||||
searchValues={searchValues}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onSearch={handleSearch}
|
||||
onClearFilters={() => {
|
||||
setSearchValues({});
|
||||
loadData(1, {});
|
||||
}}
|
||||
tableColumns={tableColumns}
|
||||
tableName={component.tableName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,13 +31,17 @@ import {
|
|||
CodeTypeConfig,
|
||||
EntityTypeConfig,
|
||||
ButtonTypeConfig,
|
||||
} from "@/types/screen";
|
||||
} from "@/types";
|
||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||
import { FileUpload } from "./widgets/FileUpload";
|
||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||
import { useParams } from "next/navigation";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
|
||||
import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
||||
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||
import { useFormValidation } from "@/hooks/useFormValidation";
|
||||
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
|
||||
|
||||
interface InteractiveScreenViewerProps {
|
||||
component: ComponentData;
|
||||
|
|
@ -49,6 +53,16 @@ interface InteractiveScreenViewerProps {
|
|||
id: number;
|
||||
tableName?: string;
|
||||
};
|
||||
// 새로운 검증 관련 옵션들
|
||||
enableEnhancedValidation?: boolean;
|
||||
tableColumns?: ColumnInfo[];
|
||||
showValidationPanel?: boolean;
|
||||
validationOptions?: {
|
||||
enableRealTimeValidation?: boolean;
|
||||
validationDelay?: number;
|
||||
enableAutoSave?: boolean;
|
||||
showToastMessages?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -58,6 +72,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
onFormDataChange,
|
||||
hideLabel = false,
|
||||
screenInfo,
|
||||
enableEnhancedValidation = false,
|
||||
tableColumns = [],
|
||||
showValidationPanel = false,
|
||||
validationOptions = {},
|
||||
}) => {
|
||||
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
|
|
@ -79,6 +97,33 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
// 팝업 전용 formData 상태
|
||||
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 통합된 폼 데이터
|
||||
const finalFormData = { ...localFormData, ...externalFormData };
|
||||
|
||||
// 개선된 검증 시스템 (선택적 활성화)
|
||||
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
||||
? useFormValidation(
|
||||
finalFormData,
|
||||
allComponents.filter(c => c.type === 'widget') as WidgetComponent[],
|
||||
tableColumns,
|
||||
{
|
||||
id: screenInfo.id,
|
||||
screenName: screenInfo.tableName || "unknown",
|
||||
tableName: screenInfo.tableName,
|
||||
screenResolution: { width: 800, height: 600 },
|
||||
gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 },
|
||||
description: "동적 화면"
|
||||
},
|
||||
{
|
||||
enableRealTimeValidation: true,
|
||||
validationDelay: 300,
|
||||
enableAutoSave: false,
|
||||
showToastMessages: true,
|
||||
...validationOptions,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
|
||||
// 자동값 생성 함수
|
||||
const generateAutoValue = useCallback((autoValueType: string): string => {
|
||||
const now = new Date();
|
||||
|
|
@ -1104,20 +1149,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
};
|
||||
|
||||
// 저장 액션
|
||||
// 저장 액션 (개선된 버전)
|
||||
const handleSaveAction = async () => {
|
||||
// 저장 시점에서 최신 formData 구성
|
||||
console.log("💾 저장 시작");
|
||||
|
||||
// 개선된 검증 시스템이 활성화된 경우
|
||||
if (enhancedValidation) {
|
||||
console.log("🔍 개선된 검증 시스템 사용");
|
||||
const success = await enhancedValidation.saveForm();
|
||||
if (success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 방식 (레거시 지원)
|
||||
const currentFormData = { ...localFormData, ...externalFormData };
|
||||
console.log("💾 저장 시작 - currentFormData:", currentFormData);
|
||||
console.log("💾 저장 시점 formData 상세:", {
|
||||
local: localFormData,
|
||||
external: externalFormData,
|
||||
merged: currentFormData
|
||||
});
|
||||
console.log("💾 currentFormData 키-값 상세:");
|
||||
Object.entries(currentFormData).forEach(([key, value]) => {
|
||||
console.log(` ${key}: "${value}" (타입: ${typeof value})`);
|
||||
});
|
||||
console.log("💾 기존 방식으로 저장 - currentFormData:", currentFormData);
|
||||
|
||||
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
|
||||
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
|
||||
|
|
@ -1684,6 +1732,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
|
||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||
{showValidationPanel && enhancedValidation && (
|
||||
<div className="absolute bottom-4 right-4 z-50">
|
||||
<FormValidationIndicator
|
||||
validationState={enhancedValidation.validationState}
|
||||
saveState={enhancedValidation.saveState}
|
||||
onSave={async () => {
|
||||
const success = await enhancedValidation.saveForm();
|
||||
if (success) {
|
||||
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||
}
|
||||
}}
|
||||
canSave={enhancedValidation.canSave}
|
||||
compact={true}
|
||||
showDetails={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 화면 */}
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||
setPopupScreen(null);
|
||||
|
|
|
|||
|
|
@ -467,8 +467,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
<div className="h-full w-full">
|
||||
{/* 라벨 표시 */}
|
||||
{!hideLabel && component.label && (
|
||||
{/* 라벨 표시 - 컴포넌트 내부에서 라벨을 처리하므로 외부에서는 표시하지 않음 */}
|
||||
{!hideLabel && component.label && component.style?.labelDisplay === false && (
|
||||
<div className="mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{component.label}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen";
|
||||
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -56,20 +56,12 @@ interface RealtimePreviewProps {
|
|||
}
|
||||
|
||||
// 영역 레이아웃에 따른 아이콘 반환
|
||||
const getAreaIcon = (layoutType: AreaLayoutType) => {
|
||||
switch (layoutType) {
|
||||
case "flex-row":
|
||||
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
|
||||
switch (layoutDirection) {
|
||||
case "horizontal":
|
||||
return <Layout className="h-4 w-4 text-blue-600" />;
|
||||
case "grid":
|
||||
return <Grid3x3 className="h-4 w-4 text-green-600" />;
|
||||
case "flex-column":
|
||||
case "vertical":
|
||||
return <Columns className="h-4 w-4 text-purple-600" />;
|
||||
case "panel":
|
||||
return <Rows className="h-4 w-4 text-orange-600" />;
|
||||
case "sidebar":
|
||||
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
|
||||
case "tabs":
|
||||
return <Folder className="h-4 w-4 text-pink-600" />;
|
||||
default:
|
||||
return <Square className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
|
|
@ -77,14 +69,17 @@ const getAreaIcon = (layoutType: AreaLayoutType) => {
|
|||
|
||||
// 영역 렌더링
|
||||
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||
const area = component as AreaComponent;
|
||||
const { layoutType, title } = area;
|
||||
if (!isContainerComponent(component) || component.type !== "area") {
|
||||
return null;
|
||||
}
|
||||
const area = component;
|
||||
const { layoutDirection, label } = area;
|
||||
|
||||
const renderPlaceholder = () => (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
{getAreaIcon(layoutType)}
|
||||
<p className="mt-2 text-sm text-gray-600">{title || `${layoutType} 영역`}</p>
|
||||
{getAreaIcon(layoutDirection)}
|
||||
<p className="mt-2 text-sm text-gray-600">{label || `${layoutDirection || "기본"} 영역`}</p>
|
||||
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,11 +97,11 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
|||
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
||||
const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => {
|
||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||
if (component.type !== "widget") {
|
||||
if (!isWidgetComponent(component)) {
|
||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
||||
}
|
||||
|
||||
const widget = component as WidgetComponent;
|
||||
const widget = component;
|
||||
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||
|
||||
// 디버깅: 실제 widgetType 값 확인
|
||||
|
|
@ -180,7 +175,6 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
|
|||
case "dropdown":
|
||||
return <List className="h-4 w-4 text-orange-600" />;
|
||||
case "textarea":
|
||||
case "text_area":
|
||||
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
|
|
@ -327,8 +321,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
<div className="absolute -top-6 left-0 rounded bg-blue-600 px-2 py-1 text-xs text-white">
|
||||
{type === "widget" && (
|
||||
<div className="flex items-center gap-1">
|
||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
||||
{(component as WidgetComponent).widgetType || "widget"}
|
||||
{getWidgetIcon(isWidgetComponent(component) ? (component.widgetType as WebType) : undefined)}
|
||||
{isWidgetComponent(component) ? component.widgetType || "widget" : component.type}
|
||||
</div>
|
||||
)}
|
||||
{type !== "widget" && type}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Monitor, Maximize2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useContainerSize } from "@/hooks/useViewportSize";
|
||||
|
||||
interface ResponsiveDesignerContainerProps {
|
||||
children: React.ReactNode;
|
||||
designWidth: number;
|
||||
designHeight: number;
|
||||
screenName?: string;
|
||||
onScaleChange?: (scale: number) => void;
|
||||
}
|
||||
|
||||
type DesignerViewMode = "fit" | "original" | "custom";
|
||||
|
||||
/**
|
||||
* 화면 디자이너용 반응형 컨테이너
|
||||
* 편집 작업을 위해 원본 크기 유지하면서도 뷰포트에 맞춰 조정 가능
|
||||
*/
|
||||
export const ResponsiveDesignerContainer: React.FC<ResponsiveDesignerContainerProps> = ({
|
||||
children,
|
||||
designWidth,
|
||||
designHeight,
|
||||
screenName,
|
||||
onScaleChange,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewMode, setViewMode] = useState<DesignerViewMode>("original");
|
||||
const [customScale, setCustomScale] = useState(1);
|
||||
const containerSize = useContainerSize(containerRef);
|
||||
|
||||
// 스케일 계산
|
||||
const calculateScale = (): number => {
|
||||
if (containerSize.width === 0 || containerSize.height === 0) return 1;
|
||||
|
||||
switch (viewMode) {
|
||||
case "fit":
|
||||
// 컨테이너에 맞춰 비율 유지하며 조정 (여백 허용)
|
||||
const scaleX = (containerSize.width - 40) / designWidth;
|
||||
const scaleY = (containerSize.height - 40) / designHeight;
|
||||
return Math.min(scaleX, scaleY, 2); // 최대 2배까지 허용
|
||||
|
||||
case "custom":
|
||||
return customScale;
|
||||
|
||||
case "original":
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const scale = calculateScale();
|
||||
|
||||
// 스케일 변경 시 콜백 호출
|
||||
React.useEffect(() => {
|
||||
onScaleChange?.(scale);
|
||||
}, [scale, onScaleChange]);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const newScale = Math.min(customScale * 1.1, 3);
|
||||
setCustomScale(newScale);
|
||||
setViewMode("custom");
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
const newScale = Math.max(customScale * 0.9, 0.1);
|
||||
setCustomScale(newScale);
|
||||
setViewMode("custom");
|
||||
};
|
||||
|
||||
const getViewModeInfo = (mode: DesignerViewMode) => {
|
||||
switch (mode) {
|
||||
case "fit":
|
||||
return {
|
||||
label: "화면 맞춤",
|
||||
description: "뷰포트에 맞춰 자동 조정",
|
||||
icon: <Monitor className="h-4 w-4" />,
|
||||
};
|
||||
case "original":
|
||||
return {
|
||||
label: "원본 크기",
|
||||
description: "설계 해상도 100% 표시",
|
||||
icon: <Maximize2 className="h-4 w-4" />,
|
||||
};
|
||||
case "custom":
|
||||
return {
|
||||
label: `사용자 정의 (${Math.round(customScale * 100)}%)`,
|
||||
description: "사용자가 조정한 배율",
|
||||
icon: <ZoomIn className="h-4 w-4" />,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const screenStyle = {
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
transition: "transform 0.3s ease-in-out",
|
||||
};
|
||||
|
||||
const wrapperStyle = {
|
||||
width: `${designWidth * scale}px`,
|
||||
height: `${designHeight * scale}px`,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-gray-100">
|
||||
{/* 상단 컨트롤 바 */}
|
||||
<div className="flex items-center justify-between border-b bg-white px-4 py-2 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{screenName && `${screenName} - `}
|
||||
{designWidth} × {designHeight}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
(배율: {Math.round(scale * 100)}% | 컨테이너: {containerSize.width}×{containerSize.height})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomOut} className="h-8 w-8 p-0" title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="min-w-[60px] px-2 text-center text-xs text-gray-600">{Math.round(scale * 100)}%</span>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomIn} className="h-8 w-8 p-0" title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 뷰 모드 버튼 */}
|
||||
{(["fit", "original"] as DesignerViewMode[]).map((mode) => {
|
||||
const info = getViewModeInfo(mode);
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={viewMode === mode ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode(mode)}
|
||||
className="h-8 text-xs"
|
||||
title={info.description}
|
||||
>
|
||||
{info.icon}
|
||||
<span className="ml-1 hidden sm:inline">{info.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 디자인 영역 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto p-8"
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<div style={wrapperStyle}>
|
||||
<div style={screenStyle}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponsiveDesignerContainer;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useRef } from "react";
|
||||
|
||||
interface ResponsiveScreenContainerProps {
|
||||
children: React.ReactNode;
|
||||
designWidth: number;
|
||||
designHeight: number;
|
||||
screenName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨테이너
|
||||
* 설계된 원본 크기 그대로 화면을 표시합니다.
|
||||
*/
|
||||
export const ResponsiveScreenContainer: React.FC<ResponsiveScreenContainerProps> = ({
|
||||
children,
|
||||
designWidth,
|
||||
designHeight,
|
||||
screenName,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const screenStyle = {
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
};
|
||||
|
||||
const wrapperStyle = {
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
overflow: "auto",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-gray-50">
|
||||
<div style={wrapperStyle}>
|
||||
<div style={screenStyle}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponsiveScreenContainer;
|
||||
|
|
@ -1884,7 +1884,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
codeCategory: column.codeCategory,
|
||||
}),
|
||||
style: {
|
||||
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
|
||||
labelFontSize: "12px",
|
||||
labelColor: "#374151",
|
||||
labelFontWeight: "500",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { ModernDatePicker } from "./ModernDatePicker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { EntityReferenceAPI } from "@/lib/api/entityReference";
|
||||
import type { DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
|
||||
interface AdvancedSearchFiltersProps {
|
||||
filters: DataTableFilter[];
|
||||
searchValues: Record<string, any>;
|
||||
onSearchValueChange: (columnName: string, value: any) => void;
|
||||
onSearch: () => void;
|
||||
onClearFilters: () => void;
|
||||
className?: string;
|
||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||
tableName?: string; // 테이블명
|
||||
}
|
||||
|
||||
interface DateRangeValue {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
interface CodeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EntityOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||
filters,
|
||||
searchValues,
|
||||
onSearchValueChange,
|
||||
onSearch,
|
||||
onClearFilters,
|
||||
className = "",
|
||||
tableColumns = [],
|
||||
tableName = "",
|
||||
}) => {
|
||||
// 코드 옵션 캐시
|
||||
const [codeOptions, setCodeOptions] = useState<Record<string, CodeOption[]>>({});
|
||||
// 엔티티 옵션 캐시
|
||||
const [entityOptions, setEntityOptions] = useState<Record<string, EntityOption[]>>({});
|
||||
// 로딩 상태
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 자동 필터 생성 (설정된 필터가 없을 때 테이블 컬럼 기반으로 생성)
|
||||
const autoGeneratedFilters = useMemo(() => {
|
||||
if (filters.length > 0 || !tableColumns || tableColumns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 필터 가능한 웹타입들
|
||||
const filterableWebTypes = ["text", "email", "tel", "number", "decimal", "date", "datetime", "code", "entity"];
|
||||
|
||||
return tableColumns
|
||||
.filter((col) => {
|
||||
const webType = col.webType || col.web_type;
|
||||
return filterableWebTypes.includes(webType) && col.isVisible !== false;
|
||||
})
|
||||
.slice(0, 6) // 최대 6개까지만 자동 생성
|
||||
.map((col) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
widgetType: col.webType || col.web_type,
|
||||
label: col.displayName || col.column_label || col.columnName || col.column_name,
|
||||
gridColumns: 3,
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
referenceTable: col.referenceTable || col.reference_table,
|
||||
referenceColumn: col.referenceColumn || col.reference_column,
|
||||
displayColumn: col.displayColumn || col.display_column,
|
||||
}));
|
||||
}, [filters, tableColumns]);
|
||||
|
||||
// 실제 사용할 필터 (설정된 필터가 있으면 우선, 없으면 자동 생성)
|
||||
const effectiveFilters = useMemo(() => {
|
||||
return filters.length > 0 ? filters : autoGeneratedFilters;
|
||||
}, [filters, autoGeneratedFilters]);
|
||||
|
||||
// 코드 데이터 로드
|
||||
const loadCodeOptions = useCallback(
|
||||
async (codeCategory: string) => {
|
||||
if (codeOptions[codeCategory] || loadingStates[codeCategory]) return;
|
||||
|
||||
setLoadingStates((prev) => ({ ...prev, [codeCategory]: true }));
|
||||
|
||||
try {
|
||||
const response = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 1000 });
|
||||
const options = response.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
|
||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: options }));
|
||||
} catch (error) {
|
||||
console.error(`코드 카테고리 ${codeCategory} 로드 실패:`, error);
|
||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: [] }));
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [codeCategory]: false }));
|
||||
}
|
||||
},
|
||||
[codeOptions, loadingStates],
|
||||
);
|
||||
|
||||
// 엔티티 데이터 로드
|
||||
const loadEntityOptions = useCallback(
|
||||
async (tableName: string, columnName: string) => {
|
||||
const key = `${tableName}.${columnName}`;
|
||||
if (entityOptions[key] || loadingStates[key]) return;
|
||||
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
try {
|
||||
const response = await EntityReferenceAPI.getEntityReferenceData(tableName, columnName, { limit: 1000 });
|
||||
const options = response.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
|
||||
setEntityOptions((prev) => ({ ...prev, [key]: options }));
|
||||
} catch (error) {
|
||||
console.error(`엔티티 ${tableName}.${columnName} 로드 실패:`, error);
|
||||
setEntityOptions((prev) => ({ ...prev, [key]: [] }));
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
},
|
||||
[entityOptions, loadingStates],
|
||||
);
|
||||
|
||||
// 즉시 검색을 위한 onChange 핸들러
|
||||
const handleChange = useCallback(
|
||||
(columnName: string, newValue: any) => {
|
||||
onSearchValueChange(columnName, newValue);
|
||||
// 즉시 검색 실행 (디바운싱 제거)
|
||||
onSearch();
|
||||
},
|
||||
[onSearchValueChange, onSearch],
|
||||
);
|
||||
|
||||
// 텍스트 입력용 핸들러 (Enter 키 또는 blur 시에만 검색)
|
||||
const handleTextChange = useCallback(
|
||||
(columnName: string, newValue: string) => {
|
||||
onSearchValueChange(columnName, newValue);
|
||||
// 텍스트는 즉시 검색하지 않고 상태만 업데이트
|
||||
},
|
||||
[onSearchValueChange],
|
||||
);
|
||||
|
||||
// Enter 키 또는 blur 시 검색 실행
|
||||
const handleTextSearch = useCallback(() => {
|
||||
onSearch();
|
||||
}, [onSearch]);
|
||||
|
||||
// 필터별 렌더링 함수
|
||||
const renderFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
||||
switch (filter.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleTextSearch();
|
||||
}
|
||||
}}
|
||||
onBlur={handleTextSearch}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
// 숫자 필터 모드에 따라 다른 UI 렌더링
|
||||
if (filter.numberFilterMode === "exact") {
|
||||
// 정확한 값 검색
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="number"
|
||||
placeholder={`${filter.label} 입력...`}
|
||||
value={value || ""}
|
||||
onChange={(e) => handleChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// 범위 검색 (기본값)
|
||||
return (
|
||||
<div key={filter.columnName} className="flex space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`최소값`}
|
||||
value={value?.min || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(filter.columnName, {
|
||||
...value,
|
||||
min: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`최대값`}
|
||||
value={value?.max || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(filter.columnName, {
|
||||
...value,
|
||||
max: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "date":
|
||||
case "datetime":
|
||||
return (
|
||||
<ModernDatePicker
|
||||
key={filter.columnName}
|
||||
label={filter.label}
|
||||
value={value || {}}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
includeTime={filter.widgetType === "datetime"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "code":
|
||||
console.log("🔍 코드 필터 렌더링:", {
|
||||
columnName: filter.columnName,
|
||||
codeCategory: filter.codeCategory,
|
||||
options: codeOptions[filter.codeCategory || ""],
|
||||
loading: loadingStates[filter.codeCategory || ""],
|
||||
});
|
||||
return (
|
||||
<CodeFilter
|
||||
key={filter.columnName}
|
||||
filter={filter}
|
||||
value={value}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
options={codeOptions[filter.codeCategory || ""] || []}
|
||||
loading={loadingStates[filter.codeCategory || ""]}
|
||||
onLoadOptions={() => filter.codeCategory && loadCodeOptions(filter.codeCategory)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "entity":
|
||||
return (
|
||||
<EntityFilter
|
||||
key={filter.columnName}
|
||||
filter={filter}
|
||||
value={value}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
options={entityOptions[`${filter.referenceTable}.${filter.columnName}`] || []}
|
||||
loading={loadingStates[`${filter.referenceTable}.${filter.columnName}`]}
|
||||
onLoadOptions={() => filter.referenceTable && loadEntityOptions(filter.referenceTable, filter.columnName)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
return (
|
||||
<Select
|
||||
key={filter.columnName}
|
||||
value={value}
|
||||
onValueChange={(newValue) => onSearchValueChange(filter.columnName, newValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{/* TODO: 동적 옵션 로드 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleTextSearch();
|
||||
}
|
||||
}}
|
||||
onBlur={handleTextSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 활성 필터 개수 계산
|
||||
const activeFiltersCount = Object.values(searchValues).filter((value) => {
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
|
||||
}
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* 필터 헤더 */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
{autoGeneratedFilters.length > 0 && <span className="text-xs text-blue-600">(자동 생성)</span>}
|
||||
</div>
|
||||
|
||||
{/* 필터 그리드 - 적절한 너비로 조정 */}
|
||||
{effectiveFilters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{effectiveFilters.map((filter: DataTableFilter) => {
|
||||
// 필터 개수에 따라 적절한 너비 계산
|
||||
const getFilterWidth = () => {
|
||||
const filterCount = effectiveFilters.length;
|
||||
if (filterCount === 1) return "w-80"; // 1개일 때는 적당한 크기
|
||||
if (filterCount === 2) return "w-64"; // 2개일 때는 조금 작게
|
||||
if (filterCount === 3) return "w-52"; // 3개일 때는 더 작게
|
||||
return "w-48"; // 4개 이상일 때는 가장 작게
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={filter.columnName} className={`space-y-0.5 ${getFilterWidth()}`}>
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
{renderFilter(filter)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 상태 및 초기화 버튼 */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">{activeFiltersCount}개 필터 적용 중</div>
|
||||
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2">
|
||||
<X className="h-3 w-3" />
|
||||
필터 초기화
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 코드 필터 컴포넌트
|
||||
const CodeFilter: React.FC<{
|
||||
filter: DataTableFilter;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: CodeOption[];
|
||||
loading: boolean;
|
||||
onLoadOptions: () => void;
|
||||
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
|
||||
useEffect(() => {
|
||||
if (filter.codeCategory && options.length === 0 && !loading) {
|
||||
onLoadOptions();
|
||||
}
|
||||
}, [filter.codeCategory, options.length, loading, onLoadOptions]);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
// 엔티티 필터 컴포넌트
|
||||
const EntityFilter: React.FC<{
|
||||
filter: DataTableFilter;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: EntityOption[];
|
||||
loading: boolean;
|
||||
onLoadOptions: () => void;
|
||||
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
|
||||
useEffect(() => {
|
||||
if (filter.referenceTable && options.length === 0 && !loading) {
|
||||
onLoadOptions();
|
||||
}
|
||||
}, [filter.referenceTable, options.length, loading, onLoadOptions]);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateRangeValue {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
interface ModernDatePickerProps {
|
||||
label: string;
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
includeTime?: boolean;
|
||||
}
|
||||
|
||||
export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value, onChange, includeTime = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) return "";
|
||||
if (includeTime) {
|
||||
return format(date, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||
}
|
||||
return format(date, "yyyy-MM-dd", { locale: ko });
|
||||
};
|
||||
|
||||
const displayValue = () => {
|
||||
if (value?.from && value?.to) {
|
||||
return `${formatDate(value.from)} ~ ${formatDate(value.to)}`;
|
||||
}
|
||||
if (value?.from) {
|
||||
return `${formatDate(value.from)} ~`;
|
||||
}
|
||||
if (value?.to) {
|
||||
return `~ ${formatDate(value.to)}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
if (selectingType === "from") {
|
||||
const newValue = { ...value, from: date };
|
||||
onChange(newValue);
|
||||
setSelectingType("to");
|
||||
} else {
|
||||
const newValue = { ...value, to: date };
|
||||
onChange(newValue);
|
||||
setSelectingType("from");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange({});
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
// 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
// 달력 시작을 월요일로 맞추기 위해 앞의 빈 칸들 계산
|
||||
const startDate = new Date(monthStart);
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 일요일(0)이면 6개, 나머지는 -1
|
||||
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
const isInRange = (date: Date) => {
|
||||
if (!value.from || !value.to) return false;
|
||||
return date >= value.from && date <= value.to;
|
||||
};
|
||||
|
||||
const isRangeStart = (date: Date) => {
|
||||
return value.from && isSameDay(date, value.from);
|
||||
};
|
||||
|
||||
const isRangeEnd = (date: Date) => {
|
||||
return value.to && isSameDay(date, value.to);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value?.from && !value?.to && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{displayValue() || `${label} 기간 선택...`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">기간 선택</h3>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{selectingType === "from" ? "시작일을 선택하세요" : "종료일을 선택하세요"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={index} className="p-2" />;
|
||||
}
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = isRangeStart(date) || isRangeEnd(date);
|
||||
const isInRangeDate = isInRange(date);
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isInRangeDate && !isSelected && "bg-muted",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
selectingType === "from" && "hover:bg-primary/20",
|
||||
selectingType === "to" && "hover:bg-secondary/20",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선택된 범위 표시 */}
|
||||
{(value.from || value.to) && (
|
||||
<div className="bg-muted mb-4 rounded-md p-2">
|
||||
<div className="text-muted-foreground mb-1 text-xs">선택된 기간</div>
|
||||
<div className="text-sm">
|
||||
{value.from && <span className="font-medium">시작: {formatDate(value.from)}</span>}
|
||||
{value.from && value.to && <span className="mx-2">~</span>}
|
||||
{value.to && <span className="font-medium">종료: {formatDate(value.to)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" size="sm" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -320,18 +320,8 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
columnsCount: table.columns.length,
|
||||
});
|
||||
|
||||
// 테이블의 모든 컬럼을 기본 설정으로 추가
|
||||
const defaultColumns: DataTableColumn[] = table.columns.map((col, index) => ({
|
||||
id: generateComponentId(),
|
||||
columnName: col.columnName,
|
||||
label: col.columnLabel || col.columnName,
|
||||
widgetType: getWidgetTypeFromColumn(col),
|
||||
gridColumns: 2, // 기본 2칸
|
||||
visible: index < 6, // 처음 6개만 기본으로 표시
|
||||
filterable: isFilterableWebType(getWidgetTypeFromColumn(col)),
|
||||
sortable: true,
|
||||
searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)),
|
||||
}));
|
||||
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
|
||||
const defaultColumns: DataTableColumn[] = [];
|
||||
|
||||
console.log("✅ 생성된 컬럼 설정:", {
|
||||
defaultColumnsCount: defaultColumns.length,
|
||||
|
|
@ -351,71 +341,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
[tables, onUpdateComponent, localValues.tableName],
|
||||
);
|
||||
|
||||
// 컬럼 타입 추론
|
||||
// 컬럼 타입 추론 (통합 매핑 시스템 사용)
|
||||
const getWidgetTypeFromColumn = (column: ColumnInfo): WebType => {
|
||||
const type = column.dataType?.toLowerCase() || "";
|
||||
const name = column.columnName.toLowerCase();
|
||||
// 통합 자동 매핑 유틸리티 사용
|
||||
const { inferWebTypeFromColumn } = require("@/lib/utils/dbTypeMapping");
|
||||
|
||||
console.log("🔍 웹타입 추론:", {
|
||||
columnName: column.columnName,
|
||||
dataType: column.dataType,
|
||||
type,
|
||||
name,
|
||||
});
|
||||
|
||||
// 숫자 타입
|
||||
if (type.includes("int") || type.includes("integer") || type.includes("bigint") || type.includes("smallint")) {
|
||||
return "number";
|
||||
}
|
||||
if (
|
||||
type.includes("decimal") ||
|
||||
type.includes("numeric") ||
|
||||
type.includes("float") ||
|
||||
type.includes("double") ||
|
||||
type.includes("real")
|
||||
) {
|
||||
return "decimal";
|
||||
}
|
||||
|
||||
// 날짜/시간 타입
|
||||
if (type.includes("timestamp") || type.includes("datetime")) {
|
||||
return "datetime";
|
||||
}
|
||||
if (type.includes("date")) {
|
||||
return "date";
|
||||
}
|
||||
if (type.includes("time")) {
|
||||
return "datetime";
|
||||
}
|
||||
|
||||
// 불린 타입
|
||||
if (type.includes("bool") || type.includes("boolean")) {
|
||||
return "checkbox";
|
||||
}
|
||||
|
||||
// 컬럼명 기반 추론
|
||||
if (name.includes("email") || name.includes("mail")) return "email";
|
||||
if (name.includes("phone") || name.includes("tel") || name.includes("mobile")) return "tel";
|
||||
if (name.includes("url") || name.includes("link")) return "text";
|
||||
if (name.includes("password") || name.includes("pwd")) return "text";
|
||||
|
||||
// 파일 타입 추론
|
||||
if (
|
||||
name.includes("file") ||
|
||||
name.includes("attach") ||
|
||||
name.includes("upload") ||
|
||||
name.includes("document") ||
|
||||
name.includes("docs") ||
|
||||
name.includes("image") ||
|
||||
name.includes("photo") ||
|
||||
name.includes("picture") ||
|
||||
name.includes("media")
|
||||
) {
|
||||
return "file";
|
||||
}
|
||||
|
||||
// 텍스트 타입 (기본값)
|
||||
return "text";
|
||||
return inferWebTypeFromColumn(column.dataType || "text", column.columnName);
|
||||
};
|
||||
|
||||
// 컬럼 업데이트
|
||||
|
|
@ -844,6 +775,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
widgetType,
|
||||
label: targetColumn.columnLabel || targetColumn.columnName,
|
||||
gridColumns: 3,
|
||||
// 웹타입별 추가 정보 설정
|
||||
codeCategory: targetColumn.codeCategory,
|
||||
referenceTable: targetColumn.referenceTable,
|
||||
referenceColumn: targetColumn.referenceColumn,
|
||||
displayColumn: targetColumn.displayColumn,
|
||||
};
|
||||
|
||||
console.log("➕ 필터 추가 시작:", {
|
||||
|
|
|
|||
|
|
@ -121,25 +121,9 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
|||
className = "",
|
||||
isPreview = true,
|
||||
}) => {
|
||||
// 미리보기용 기본 컬럼 데이터
|
||||
// 설정된 컬럼만 사용 (자동 생성 안함)
|
||||
const defaultColumns = React.useMemo(() => {
|
||||
if (columns.length > 0) return columns;
|
||||
|
||||
return [
|
||||
{ id: "id", label: "ID", type: "number", visible: true, sortable: true, filterable: false, width: 80 },
|
||||
{ id: "name", label: "이름", type: "text", visible: true, sortable: true, filterable: true, width: 150 },
|
||||
{ id: "email", label: "이메일", type: "email", visible: true, sortable: true, filterable: true, width: 200 },
|
||||
{ id: "status", label: "상태", type: "select", visible: true, sortable: true, filterable: true, width: 100 },
|
||||
{
|
||||
id: "created_date",
|
||||
label: "생성일",
|
||||
type: "date",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
return columns || [];
|
||||
}, [columns]);
|
||||
|
||||
// 미리보기용 샘플 데이터
|
||||
|
|
|
|||
|
|
@ -1,31 +1,25 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("bg-secondary relative h-4 w-full overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
|
|
|||
|
|
@ -48,6 +48,20 @@ export const TABLE_MANAGEMENT_KEYS = {
|
|||
WEB_TYPE_RADIO_DESC: "table.management.web.type.radio.description",
|
||||
WEB_TYPE_FILE: "table.management.web.type.file",
|
||||
WEB_TYPE_FILE_DESC: "table.management.web.type.file.description",
|
||||
WEB_TYPE_DECIMAL: "table.management.web.type.decimal",
|
||||
WEB_TYPE_DECIMAL_DESC: "table.management.web.type.decimal.description",
|
||||
WEB_TYPE_DATETIME: "table.management.web.type.datetime",
|
||||
WEB_TYPE_DATETIME_DESC: "table.management.web.type.datetime.description",
|
||||
WEB_TYPE_BOOLEAN: "table.management.web.type.boolean",
|
||||
WEB_TYPE_BOOLEAN_DESC: "table.management.web.type.boolean.description",
|
||||
WEB_TYPE_EMAIL: "table.management.web.type.email",
|
||||
WEB_TYPE_EMAIL_DESC: "table.management.web.type.email.description",
|
||||
WEB_TYPE_TEL: "table.management.web.type.tel",
|
||||
WEB_TYPE_TEL_DESC: "table.management.web.type.tel.description",
|
||||
WEB_TYPE_URL: "table.management.web.type.url",
|
||||
WEB_TYPE_URL_DESC: "table.management.web.type.url.description",
|
||||
WEB_TYPE_DROPDOWN: "table.management.web.type.dropdown",
|
||||
WEB_TYPE_DROPDOWN_DESC: "table.management.web.type.dropdown.description",
|
||||
|
||||
// 공통 UI 요소
|
||||
BUTTON_REFRESH: "table.management.button.refresh",
|
||||
|
|
@ -135,4 +149,39 @@ export const WEB_TYPE_OPTIONS_WITH_KEYS = [
|
|||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE_DESC,
|
||||
},
|
||||
{
|
||||
value: "decimal",
|
||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DECIMAL,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DECIMAL_DESC,
|
||||
},
|
||||
{
|
||||
value: "datetime",
|
||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DATETIME,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DATETIME_DESC,
|
||||
},
|
||||
{
|
||||
value: "boolean",
|
||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_BOOLEAN,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_BOOLEAN_DESC,
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_EMAIL,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_EMAIL_DESC,
|
||||
},
|
||||
{
|
||||
value: "tel",
|
||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEL,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEL_DESC,
|
||||
},
|
||||
{
|
||||
value: "url",
|
||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_URL,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_URL_DESC,
|
||||
},
|
||||
{
|
||||
value: "dropdown",
|
||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DROPDOWN,
|
||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DROPDOWN_DESC,
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1,89 +1,472 @@
|
|||
import { useState } from "react";
|
||||
/**
|
||||
* 폼 검증 상태 관리 훅
|
||||
* 실시간 검증과 사용자 피드백을 위한 커스텀 훅
|
||||
*/
|
||||
|
||||
export interface ValidationState {
|
||||
enabled: boolean;
|
||||
value: string;
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { ComponentData, ColumnInfo, ScreenDefinition } from "@/types/screen";
|
||||
import { validateFormData, ValidationResult, ValidationError, ValidationWarning } from "@/lib/utils/formValidation";
|
||||
import { enhancedFormService, SaveContext, EnhancedSaveResult } from "@/lib/services/enhancedFormService";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// 검증 상태
|
||||
export type ValidationStatus = "idle" | "validating" | "valid" | "invalid";
|
||||
|
||||
// 필드별 검증 상태
|
||||
export interface FieldValidationState {
|
||||
status: ValidationStatus;
|
||||
error?: ValidationError;
|
||||
warning?: ValidationWarning;
|
||||
lastValidated?: Date;
|
||||
}
|
||||
|
||||
export interface ValidationStates {
|
||||
[fieldName: string]: ValidationState;
|
||||
// 폼 검증 상태
|
||||
export interface FormValidationState {
|
||||
status: ValidationStatus;
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
fieldStates: Record<string, FieldValidationState>;
|
||||
lastValidated?: Date;
|
||||
validationCount: number;
|
||||
}
|
||||
|
||||
export interface UseFormValidationProps {
|
||||
fields: string[];
|
||||
initialStates?: Partial<ValidationStates>;
|
||||
// 저장 상태
|
||||
export interface SaveState {
|
||||
status: "idle" | "saving" | "success" | "error";
|
||||
message?: string;
|
||||
result?: EnhancedSaveResult;
|
||||
lastSaved?: Date;
|
||||
}
|
||||
|
||||
export function useFormValidation({ fields, initialStates = {} }: UseFormValidationProps) {
|
||||
// 검증 상태 초기화
|
||||
const initValidationStates = (): ValidationStates => {
|
||||
const states: ValidationStates = {};
|
||||
fields.forEach((field) => {
|
||||
states[field] = initialStates[field] || { enabled: false, value: "" };
|
||||
});
|
||||
return states;
|
||||
};
|
||||
// 훅 옵션
|
||||
export interface UseFormValidationOptions {
|
||||
enableRealTimeValidation?: boolean;
|
||||
validationDelay?: number; // debounce 지연시간 (ms)
|
||||
enableAutoSave?: boolean;
|
||||
autoSaveDelay?: number; // 자동저장 지연시간 (ms)
|
||||
showToastMessages?: boolean;
|
||||
validateOnMount?: boolean;
|
||||
}
|
||||
|
||||
const [validationStates, setValidationStates] = useState<ValidationStates>(initValidationStates);
|
||||
// 훅 반환값
|
||||
export interface UseFormValidationReturn {
|
||||
// 상태
|
||||
validationState: FormValidationState;
|
||||
saveState: SaveState;
|
||||
|
||||
// 특정 필드의 검증 상태 업데이트
|
||||
const updateFieldValidation = (fieldName: string, value: string) => {
|
||||
setValidationStates((prev) => ({
|
||||
// 액션
|
||||
validateForm: () => Promise<ValidationResult>;
|
||||
validateField: (fieldName: string, value: any) => Promise<void>;
|
||||
saveForm: () => Promise<boolean>;
|
||||
clearValidation: () => void;
|
||||
|
||||
// 유틸리티
|
||||
getFieldError: (fieldName: string) => ValidationError | undefined;
|
||||
getFieldWarning: (fieldName: string) => ValidationWarning | undefined;
|
||||
hasFieldError: (fieldName: string) => boolean;
|
||||
isFieldValid: (fieldName: string) => boolean;
|
||||
canSave: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 검증 관리 훅
|
||||
*/
|
||||
export const useFormValidation = (
|
||||
formData: Record<string, any>,
|
||||
components: ComponentData[],
|
||||
tableColumns: ColumnInfo[],
|
||||
screenInfo: ScreenDefinition,
|
||||
options: UseFormValidationOptions = {},
|
||||
): UseFormValidationReturn => {
|
||||
const {
|
||||
enableRealTimeValidation = true,
|
||||
validationDelay = 500,
|
||||
enableAutoSave = false,
|
||||
autoSaveDelay = 2000,
|
||||
showToastMessages = true,
|
||||
validateOnMount = false,
|
||||
} = options;
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// 상태
|
||||
const [validationState, setValidationState] = useState<FormValidationState>({
|
||||
status: "idle",
|
||||
isValid: false,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
fieldStates: {},
|
||||
validationCount: 0,
|
||||
});
|
||||
|
||||
const [saveState, setSaveState] = useState<SaveState>({
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
// 타이머 참조
|
||||
const validationTimer = useRef<NodeJS.Timeout>();
|
||||
const autoSaveTimer = useRef<NodeJS.Timeout>();
|
||||
const lastValidationData = useRef<string>("");
|
||||
|
||||
/**
|
||||
* 전체 폼 검증
|
||||
*/
|
||||
const validateForm = useCallback(async (): Promise<ValidationResult> => {
|
||||
if (!screenInfo?.tableName) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
field: "form",
|
||||
code: "NO_TABLE",
|
||||
message: "테이블명이 설정되지 않았습니다.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
setValidationState((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: { enabled: true, value: value.trim() },
|
||||
status: "validating",
|
||||
}));
|
||||
};
|
||||
|
||||
// onBlur 핸들러 생성
|
||||
const createBlurHandler =
|
||||
(fieldName: string) => (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const value = event.target.value.trim();
|
||||
if (value) {
|
||||
updateFieldValidation(fieldName, value);
|
||||
try {
|
||||
const result = await validateFormData(formData, components, tableColumns, screenInfo.tableName);
|
||||
|
||||
// 필드별 상태 업데이트
|
||||
const fieldStates: Record<string, FieldValidationState> = {};
|
||||
|
||||
// 기존 필드 상태 초기화
|
||||
Object.keys(formData).forEach((fieldName) => {
|
||||
fieldStates[fieldName] = {
|
||||
status: "valid",
|
||||
lastValidated: new Date(),
|
||||
};
|
||||
});
|
||||
|
||||
// 오류가 있는 필드 업데이트
|
||||
result.errors.forEach((error) => {
|
||||
fieldStates[error.field] = {
|
||||
status: "invalid",
|
||||
error,
|
||||
lastValidated: new Date(),
|
||||
};
|
||||
});
|
||||
|
||||
// 경고가 있는 필드 업데이트
|
||||
result.warnings.forEach((warning) => {
|
||||
if (fieldStates[warning.field]) {
|
||||
fieldStates[warning.field].warning = warning;
|
||||
} else {
|
||||
fieldStates[warning.field] = {
|
||||
status: "valid",
|
||||
warning,
|
||||
lastValidated: new Date(),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
setValidationState((prev) => ({
|
||||
status: result.isValid ? "valid" : "invalid",
|
||||
isValid: result.isValid,
|
||||
errors: result.errors,
|
||||
warnings: result.warnings,
|
||||
fieldStates,
|
||||
lastValidated: new Date(),
|
||||
validationCount: prev.validationCount + 1,
|
||||
}));
|
||||
|
||||
if (showToastMessages) {
|
||||
if (result.isValid && result.warnings.length > 0) {
|
||||
toast({
|
||||
title: "검증 완료",
|
||||
description: `${result.warnings.length}개의 경고가 있습니다.`,
|
||||
variant: "default",
|
||||
});
|
||||
} else if (!result.isValid) {
|
||||
toast({
|
||||
title: "검증 실패",
|
||||
description: `${result.errors.length}개의 오류를 수정해주세요.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ 폼 검증 중 오류:", error);
|
||||
|
||||
const errorResult: ValidationResult = {
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
field: "form",
|
||||
code: "VALIDATION_ERROR",
|
||||
message: `검증 중 오류가 발생했습니다: ${error}`,
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
setValidationState((prev) => ({
|
||||
...prev,
|
||||
status: "invalid",
|
||||
isValid: false,
|
||||
errors: errorResult.errors,
|
||||
warnings: [],
|
||||
lastValidated: new Date(),
|
||||
validationCount: prev.validationCount + 1,
|
||||
}));
|
||||
|
||||
return errorResult;
|
||||
}
|
||||
}, [formData, components, tableColumns, screenInfo, showToastMessages, toast]);
|
||||
|
||||
/**
|
||||
* 개별 필드 검증
|
||||
*/
|
||||
const validateField = useCallback(
|
||||
async (fieldName: string, value: any): Promise<void> => {
|
||||
const component = components.find((c) => (c as any).columnName === fieldName || c.id === fieldName);
|
||||
|
||||
if (!component || component.type !== "widget") return;
|
||||
|
||||
setValidationState((prev) => ({
|
||||
...prev,
|
||||
fieldStates: {
|
||||
...prev.fieldStates,
|
||||
[fieldName]: {
|
||||
...prev.fieldStates[fieldName],
|
||||
status: "validating",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 개별 필드 검증 로직
|
||||
// (실제 구현에서는 validateFieldValue 함수 사용)
|
||||
|
||||
setValidationState((prev) => ({
|
||||
...prev,
|
||||
fieldStates: {
|
||||
...prev.fieldStates,
|
||||
[fieldName]: {
|
||||
status: "valid",
|
||||
lastValidated: new Date(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[components],
|
||||
);
|
||||
|
||||
/**
|
||||
* 폼 저장
|
||||
*/
|
||||
const saveForm = useCallback(async (): Promise<boolean> => {
|
||||
if (!validationState.isValid) {
|
||||
if (showToastMessages) {
|
||||
toast({
|
||||
title: "저장 실패",
|
||||
description: "검증 오류를 먼저 수정해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setSaveState({ status: "saving" });
|
||||
|
||||
try {
|
||||
const saveContext: SaveContext = {
|
||||
tableName: screenInfo.tableName,
|
||||
screenInfo,
|
||||
components,
|
||||
formData,
|
||||
options: {
|
||||
transformData: true,
|
||||
showProgress: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await enhancedFormService.saveFormData(saveContext);
|
||||
|
||||
setSaveState({
|
||||
status: result.success ? "success" : "error",
|
||||
message: result.message,
|
||||
result,
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
|
||||
if (showToastMessages) {
|
||||
toast({
|
||||
title: result.success ? "저장 성공" : "저장 실패",
|
||||
description: result.message,
|
||||
variant: result.success ? "default" : "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error("❌ 폼 저장 중 오류:", error);
|
||||
|
||||
setSaveState({
|
||||
status: "error",
|
||||
message: `저장 중 오류가 발생했습니다: ${error}`,
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
|
||||
if (showToastMessages) {
|
||||
toast({
|
||||
title: "저장 실패",
|
||||
description: "저장 중 오류가 발생했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [validationState.isValid, screenInfo, components, formData, showToastMessages, toast]);
|
||||
|
||||
/**
|
||||
* 검증 상태 초기화
|
||||
*/
|
||||
const clearValidation = useCallback(() => {
|
||||
setValidationState({
|
||||
status: "idle",
|
||||
isValid: false,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
fieldStates: {},
|
||||
validationCount: 0,
|
||||
});
|
||||
|
||||
setSaveState({ status: "idle" });
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 오류 조회
|
||||
*/
|
||||
const getFieldError = useCallback(
|
||||
(fieldName: string): ValidationError | undefined => {
|
||||
return validationState.fieldStates[fieldName]?.error;
|
||||
},
|
||||
[validationState.fieldStates],
|
||||
);
|
||||
|
||||
/**
|
||||
* 필드 경고 조회
|
||||
*/
|
||||
const getFieldWarning = useCallback(
|
||||
(fieldName: string): ValidationWarning | undefined => {
|
||||
return validationState.fieldStates[fieldName]?.warning;
|
||||
},
|
||||
[validationState.fieldStates],
|
||||
);
|
||||
|
||||
/**
|
||||
* 필드 오류 여부 확인
|
||||
*/
|
||||
const hasFieldError = useCallback(
|
||||
(fieldName: string): boolean => {
|
||||
return validationState.fieldStates[fieldName]?.status === "invalid";
|
||||
},
|
||||
[validationState.fieldStates],
|
||||
);
|
||||
|
||||
/**
|
||||
* 필드 유효성 확인
|
||||
*/
|
||||
const isFieldValid = useCallback(
|
||||
(fieldName: string): boolean => {
|
||||
const fieldState = validationState.fieldStates[fieldName];
|
||||
return fieldState?.status === "valid" || !fieldState;
|
||||
},
|
||||
[validationState.fieldStates],
|
||||
);
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = validationState.isValid && saveState.status !== "saving" && Object.keys(formData).length > 0;
|
||||
|
||||
// 실시간 검증 (debounced)
|
||||
useEffect(() => {
|
||||
if (!enableRealTimeValidation) return;
|
||||
|
||||
const currentDataString = JSON.stringify(formData);
|
||||
if (currentDataString === lastValidationData.current) return;
|
||||
|
||||
// 이전 타이머 클리어
|
||||
if (validationTimer.current) {
|
||||
clearTimeout(validationTimer.current);
|
||||
}
|
||||
|
||||
// 새 타이머 설정
|
||||
validationTimer.current = setTimeout(() => {
|
||||
lastValidationData.current = currentDataString;
|
||||
validateForm();
|
||||
}, validationDelay);
|
||||
|
||||
return () => {
|
||||
if (validationTimer.current) {
|
||||
clearTimeout(validationTimer.current);
|
||||
}
|
||||
};
|
||||
}, [formData, enableRealTimeValidation, validationDelay, validateForm]);
|
||||
|
||||
// 모든 필수 필드가 검증되었는지 확인
|
||||
const areAllFieldsValidated = (requiredFields?: string[]) => {
|
||||
const fieldsToCheck = requiredFields || fields;
|
||||
return fieldsToCheck.every((field) => validationStates[field]?.enabled);
|
||||
};
|
||||
// 자동 저장
|
||||
useEffect(() => {
|
||||
if (!enableAutoSave || !validationState.isValid) return;
|
||||
|
||||
// 검증 상태 초기화
|
||||
const resetValidation = (newStates?: Partial<ValidationStates>) => {
|
||||
if (newStates) {
|
||||
setValidationStates((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.entries(newStates).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
updated[key] = value;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
setValidationStates(initValidationStates());
|
||||
// 이전 타이머 클리어
|
||||
if (autoSaveTimer.current) {
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 필드 검증 상태 확인
|
||||
const isFieldValidated = (fieldName: string) => validationStates[fieldName]?.enabled || false;
|
||||
// 새 타이머 설정
|
||||
autoSaveTimer.current = setTimeout(() => {
|
||||
saveForm();
|
||||
}, autoSaveDelay);
|
||||
|
||||
// 필드 값 가져오기
|
||||
const getFieldValue = (fieldName: string) => validationStates[fieldName]?.value || "";
|
||||
return () => {
|
||||
if (autoSaveTimer.current) {
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
}
|
||||
};
|
||||
}, [validationState.isValid, enableAutoSave, autoSaveDelay, saveForm]);
|
||||
|
||||
// 마운트 시 검증
|
||||
useEffect(() => {
|
||||
if (validateOnMount && Object.keys(formData).length > 0) {
|
||||
validateForm();
|
||||
}
|
||||
}, [validateOnMount]); // formData는 의존성에서 제외 (무한 루프 방지)
|
||||
|
||||
// 클린업
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (validationTimer.current) {
|
||||
clearTimeout(validationTimer.current);
|
||||
}
|
||||
if (autoSaveTimer.current) {
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
validationStates,
|
||||
|
||||
// 액션
|
||||
updateFieldValidation,
|
||||
resetValidation,
|
||||
|
||||
// 유틸리티
|
||||
createBlurHandler,
|
||||
areAllFieldsValidated,
|
||||
isFieldValidated,
|
||||
getFieldValue,
|
||||
validationState,
|
||||
saveState,
|
||||
validateForm,
|
||||
validateField,
|
||||
saveForm,
|
||||
clearValidation,
|
||||
getFieldError,
|
||||
getFieldWarning,
|
||||
hasFieldError,
|
||||
isFieldValid,
|
||||
canSave,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
availableWidth: number;
|
||||
availableHeight: number;
|
||||
sidebarWidth: number;
|
||||
headerHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 뷰포트 크기와 레이아웃 요소들을 고려한 사용 가능한 공간을 계산하는 훅
|
||||
*/
|
||||
export const useViewportSize = () => {
|
||||
const [viewportSize, setViewportSize] = useState<ViewportSize>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
availableWidth: 0,
|
||||
availableHeight: 0,
|
||||
sidebarWidth: 0,
|
||||
headerHeight: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportSize = () => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// 레이아웃 요소 크기 계산
|
||||
const isDesktop = width >= 1024; // lg 브레이크포인트
|
||||
const sidebarWidth = isDesktop ? 256 : 0; // w-64 = 256px
|
||||
const headerHeight = 56; // 대략적인 헤더 높이 (h-14 = 56px)
|
||||
|
||||
// 사용 가능한 컨텐츠 영역 계산
|
||||
const availableWidth = width - sidebarWidth;
|
||||
const availableHeight = height - headerHeight;
|
||||
|
||||
setViewportSize({
|
||||
width,
|
||||
height,
|
||||
availableWidth: Math.max(availableWidth, 300), // 최소 300px
|
||||
availableHeight: Math.max(availableHeight, 200), // 최소 200px
|
||||
sidebarWidth,
|
||||
headerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
updateViewportSize();
|
||||
window.addEventListener("resize", updateViewportSize);
|
||||
return () => window.removeEventListener("resize", updateViewportSize);
|
||||
}, []);
|
||||
|
||||
return viewportSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 컨테이너의 실제 사용 가능한 크기를 계산
|
||||
*/
|
||||
export const useContainerSize = (containerRef: React.RefObject<HTMLElement>) => {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const viewportSize = useViewportSize();
|
||||
|
||||
useEffect(() => {
|
||||
const updateContainerSize = () => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
// 컨테이너 내부 여백 고려
|
||||
const padding = 32; // p-4 * 2 = 32px
|
||||
const controlBarHeight = 48; // 컨트롤 바 높이
|
||||
|
||||
const availableWidth = Math.max(rect.width - padding, 300);
|
||||
const availableHeight = Math.max(rect.height - controlBarHeight - padding, 200);
|
||||
|
||||
setContainerSize({
|
||||
width: availableWidth,
|
||||
height: availableHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateContainerSize();
|
||||
window.addEventListener("resize", updateContainerSize);
|
||||
return () => window.removeEventListener("resize", updateContainerSize);
|
||||
}, [containerRef, viewportSize]);
|
||||
|
||||
return containerSize;
|
||||
};
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* DDL 실행 API 클라이언트
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import {
|
||||
CreateTableRequest,
|
||||
AddColumnRequest,
|
||||
DDLExecutionResult,
|
||||
ValidationResult,
|
||||
DDLExecutionLog,
|
||||
DDLStatistics,
|
||||
} from "../../types/ddl";
|
||||
|
||||
export const ddlApi = {
|
||||
/**
|
||||
* 새 테이블 생성
|
||||
*/
|
||||
createTable: async (request: CreateTableRequest): Promise<DDLExecutionResult> => {
|
||||
const response = await apiClient.post("/ddl/tables", request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 기존 테이블에 컬럼 추가
|
||||
*/
|
||||
addColumn: async (tableName: string, request: AddColumnRequest): Promise<DDLExecutionResult> => {
|
||||
const response = await apiClient.post(`/ddl/tables/${tableName}/columns`, request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 테이블 생성 사전 검증 (실제 생성하지 않고 검증만)
|
||||
*/
|
||||
validateTableCreation: async (request: CreateTableRequest): Promise<ValidationResult> => {
|
||||
const response = await apiClient.post("/ddl/validate/table", request);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* DDL 실행 로그 조회
|
||||
*/
|
||||
getDDLLogs: async (params?: {
|
||||
limit?: number;
|
||||
userId?: string;
|
||||
ddlType?: string;
|
||||
}): Promise<{
|
||||
logs: DDLExecutionLog[];
|
||||
total: number;
|
||||
}> => {
|
||||
const response = await apiClient.get("/ddl/logs", { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* DDL 실행 통계 조회
|
||||
*/
|
||||
getDDLStatistics: async (params?: { fromDate?: string; toDate?: string }): Promise<DDLStatistics> => {
|
||||
const response = await apiClient.get("/ddl/statistics", { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 테이블의 DDL 히스토리 조회
|
||||
*/
|
||||
getTableDDLHistory: async (
|
||||
tableName: string,
|
||||
): Promise<{
|
||||
tableName: string;
|
||||
history: DDLExecutionLog[];
|
||||
total: number;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/ddl/tables/${tableName}/history`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 생성된 테이블 정보 조회
|
||||
*/
|
||||
getTableInfo: async (
|
||||
tableName: string,
|
||||
): Promise<{
|
||||
tableInfo: any;
|
||||
columns: any[];
|
||||
}> => {
|
||||
const response = await apiClient.get(`/ddl/tables/${tableName}/info`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 오래된 DDL 로그 정리
|
||||
*/
|
||||
cleanupOldLogs: async (
|
||||
retentionDays?: number,
|
||||
): Promise<{
|
||||
deletedCount: number;
|
||||
retentionDays: number;
|
||||
}> => {
|
||||
const response = await apiClient.delete("/ddl/logs/cleanup", {
|
||||
params: { retentionDays },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* DDL 서비스 정보 조회
|
||||
*/
|
||||
getServiceInfo: async (): Promise<any> => {
|
||||
const response = await apiClient.get("/ddl/info");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* DDL 서비스 헬스체크
|
||||
*/
|
||||
healthCheck: async (): Promise<{
|
||||
status: string;
|
||||
timestamp: string;
|
||||
checks: {
|
||||
database: string;
|
||||
service: string;
|
||||
};
|
||||
}> => {
|
||||
const response = await apiClient.get("/ddl/health");
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -28,7 +28,7 @@ export interface FormDataResponse {
|
|||
// 동적 폼 API 클래스
|
||||
export class DynamicFormApi {
|
||||
/**
|
||||
* 폼 데이터 저장
|
||||
* 폼 데이터 저장 (기존 버전 - 레거시 지원)
|
||||
* @param formData 저장할 폼 데이터
|
||||
* @returns 저장 결과
|
||||
*/
|
||||
|
|
@ -57,6 +57,38 @@ export class DynamicFormApi {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 저장 (개선된 버전)
|
||||
* @param formData 저장할 폼 데이터
|
||||
* @returns 저장 결과 (상세한 검증 정보 포함)
|
||||
*/
|
||||
static async saveData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
|
||||
try {
|
||||
console.log("🚀 개선된 폼 데이터 저장 요청:", formData);
|
||||
|
||||
const response = await apiClient.post("/dynamic-form/save-enhanced", formData);
|
||||
|
||||
console.log("✅ 개선된 폼 데이터 저장 성공:", response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
|
||||
|
||||
// 개선된 오류 처리
|
||||
const errorResponse = error.response?.data;
|
||||
if (errorResponse && !errorResponse.success) {
|
||||
return errorResponse; // 서버에서 온 구조화된 오류 응답 그대로 반환
|
||||
}
|
||||
|
||||
const errorMessage = error.response?.data?.message || error.message || "데이터 저장 중 오류가 발생했습니다.";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 업데이트
|
||||
* @param id 레코드 ID
|
||||
|
|
|
|||
|
|
@ -205,10 +205,11 @@ export class ExternalDbConnectionAPI {
|
|||
/**
|
||||
* 데이터베이스 연결 테스트 (ID 기반)
|
||||
*/
|
||||
static async testConnection(connectionId: number): Promise<ConnectionTestResult> {
|
||||
static async testConnection(connectionId: number, password?: string): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(
|
||||
`${this.BASE_PATH}/${connectionId}/test`
|
||||
`${this.BASE_PATH}/${connectionId}/test`,
|
||||
password ? { password } : undefined
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* 테이블 관리 API
|
||||
* 테이블 컬럼 정보 조회 및 관리 기능
|
||||
*/
|
||||
|
||||
import { apiClient, ApiResponse } from "./client";
|
||||
|
||||
// 컬럼 정보 타입 (백엔드와 일치)
|
||||
export interface ColumnTypeInfo {
|
||||
tableName?: string;
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
dbType: string;
|
||||
webType: string;
|
||||
inputType?: "direct" | "auto";
|
||||
detailSettings: string;
|
||||
description?: string;
|
||||
isNullable: string;
|
||||
isPrimaryKey: boolean;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
codeCategory?: string;
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
displayOrder?: number;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// 테이블 정보 타입
|
||||
export interface TableInfo {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
// 컬럼 설정 타입
|
||||
export interface ColumnSettings {
|
||||
columnName?: string;
|
||||
columnLabel: string;
|
||||
webType: string;
|
||||
detailSettings: string;
|
||||
codeCategory: string;
|
||||
codeValue: string;
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
displayColumn?: string;
|
||||
displayOrder?: number;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// API 응답 타입들
|
||||
export interface TableListResponse extends ApiResponse<TableInfo[]> {}
|
||||
export interface ColumnListResponse extends ApiResponse<ColumnTypeInfo[]> {}
|
||||
export interface ColumnSettingsResponse extends ApiResponse<void> {}
|
||||
|
||||
/**
|
||||
* 테이블 관리 API 클래스
|
||||
*/
|
||||
class TableManagementApi {
|
||||
private readonly basePath = "/table-management";
|
||||
|
||||
/**
|
||||
* 테이블 목록 조회
|
||||
*/
|
||||
async getTableList(): Promise<TableListResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.basePath}/tables`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "테이블 목록을 조회할 수 없습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
async getColumnList(tableName: string): Promise<ColumnListResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/columns`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 테이블 '${tableName}' 컬럼 목록 조회 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error.response?.data?.message || error.message || `테이블 '${tableName}'의 컬럼 정보를 조회할 수 없습니다.`,
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 타입 설정 저장
|
||||
*/
|
||||
async updateColumnSettings(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
settings: ColumnSettings,
|
||||
): Promise<ColumnSettingsResponse> {
|
||||
try {
|
||||
const response = await apiClient.put(`${this.basePath}/tables/${tableName}/columns/${columnName}`, settings);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 컬럼 '${tableName}.${columnName}' 설정 저장 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "컬럼 설정을 저장할 수 없습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 컬럼 설정 일괄 저장
|
||||
*/
|
||||
async updateMultipleColumnSettings(
|
||||
tableName: string,
|
||||
settingsArray: Array<{ columnName: string; settings: ColumnSettings }>,
|
||||
): Promise<ColumnSettingsResponse> {
|
||||
try {
|
||||
const response = await apiClient.put(`${this.basePath}/tables/${tableName}/columns/batch`, {
|
||||
settings: settingsArray,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 테이블 '${tableName}' 컬럼 설정 일괄 저장 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "컬럼 설정을 일괄 저장할 수 없습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
|
||||
*/
|
||||
async getTableSchema(tableName: string): Promise<ApiResponse<ColumnTypeInfo[]>> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/schema`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 테이블 '${tableName}' 스키마 조회 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error.response?.data?.message || error.message || `테이블 '${tableName}'의 스키마 정보를 조회할 수 없습니다.`,
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
*/
|
||||
async checkTableExists(tableName: string): Promise<ApiResponse<{ exists: boolean }>> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/exists`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 테이블 '${tableName}' 존재 여부 확인 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "테이블 존재 여부를 확인할 수 없습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹타입 정보 조회 (화면관리 연동용)
|
||||
*/
|
||||
async getColumnWebTypes(tableName: string): Promise<ApiResponse<ColumnTypeInfo[]>> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/web-types`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 테이블 '${tableName}' 웹타입 정보 조회 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "웹타입 정보를 조회할 수 없습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 연결 상태 확인
|
||||
*/
|
||||
async checkDatabaseConnection(): Promise<ApiResponse<{ connected: boolean; message: string }>> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.basePath}/health`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 데이터베이스 연결 상태 확인 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "데이터베이스 연결 상태를 확인할 수 없습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성
|
||||
export const tableManagementApi = new TableManagementApi();
|
||||
|
||||
// 편의 함수들
|
||||
export const getTableColumns = (tableName: string) => tableManagementApi.getColumnList(tableName);
|
||||
export const updateColumnType = (tableName: string, columnName: string, settings: ColumnSettings) =>
|
||||
tableManagementApi.updateColumnSettings(tableName, columnName, settings);
|
||||
export const checkTableExists = (tableName: string) => tableManagementApi.checkTableExists(tableName);
|
||||
|
|
@ -77,7 +77,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -92,11 +92,13 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -104,7 +106,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
)}
|
||||
|
||||
<label
|
||||
style={{display: "flex",
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
|
|
@ -112,7 +115,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
height: "100%",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -122,20 +126,28 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
checked={component.value === true || component.value === "true"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{width: "16px",
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),}}>{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{componentConfig.checkboxLabel || component.text || "체크박스"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -303,15 +303,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -91,15 +91,23 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{width: "100%",
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px dashed #d1d5db",
|
||||
borderRadius: "8px",
|
||||
|
|
@ -110,9 +118,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
cursor: "pointer",
|
||||
backgroundColor: "#f9fafb",
|
||||
position: "relative",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -139,22 +147,42 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{textAlign: "center", color: "#6b7280", fontSize: "14px",
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
<div style={{fontSize: "24px", marginBottom: "8px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>📁</div>
|
||||
<div style={{fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>파일을 선택하거나 드래그하세요</div>
|
||||
<div style={{fontSize: "12px", marginTop: "4px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginBottom: "8px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
📁
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "500",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
파일을 선택하거나 드래그하세요
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "4px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{componentConfig.accept && `지원 형식: ${componentConfig.accept}`}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
|
|||
|
|
@ -96,15 +96,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -91,23 +91,31 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{width: "100%",
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: componentConfig.direction === "horizontal" ? "row" : "column",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -115,14 +123,15 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
{(componentConfig.options || []).map((option, index) => (
|
||||
<label
|
||||
key={index}
|
||||
style={{display: "flex",
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
|
|
@ -131,53 +140,79 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
checked={component.value === option.value}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{width: "16px",
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>{option.label}</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{(!componentConfig.options || componentConfig.options.length === 0) && (
|
||||
<>
|
||||
<label style={{display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value="option1"
|
||||
style={{width: "16px", height: "16px", accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
<span>옵션 1</span>
|
||||
</label>
|
||||
<label style={{display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={component.id || "radio-group"}
|
||||
value="option2"
|
||||
style={{width: "16px", height: "16px", accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
style={{
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
accentColor: "#3b82f6",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
<span>옵션 2</span>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const loadGlobalTableCodeCategory = async (tableName: string, columnName: string
|
|||
try {
|
||||
await globalState.activeRequests.get(`table_${key}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ 테이블 설정 로딩 대기 중 오류:`, error);
|
||||
console.error("❌ 테이블 설정 로딩 대기 중 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ const loadGlobalCodeOptions = async (codeCategory: string): Promise<Option[]> =>
|
|||
try {
|
||||
await globalState.activeRequests.get(`code_${codeCategory}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ 코드 옵션 로딩 대기 중 오류:`, error);
|
||||
console.error("❌ 코드 옵션 로딩 대기 중 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -281,7 +281,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
|
||||
if (newValue !== selectedValue) {
|
||||
console.log(`🔄 SelectBasicComponent value 업데이트: "${selectedValue}" → "${newValue}"`);
|
||||
console.log(`🔍 업데이트 조건 분석:`, {
|
||||
console.log("🔍 업데이트 조건 분석:", {
|
||||
externalValue,
|
||||
componentConfigValue: componentConfig?.value,
|
||||
configValue: config?.value,
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -91,23 +91,31 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{width: "100%",
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "8px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
@ -120,7 +128,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
value={component.value || componentConfig.min || 0}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{width: "70%",
|
||||
style={{
|
||||
width: "70%",
|
||||
height: "6px",
|
||||
outline: "none",
|
||||
borderRadius: "3px",
|
||||
|
|
@ -136,7 +145,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
|
|||
}}
|
||||
/>
|
||||
<span
|
||||
style={{width: "30%",
|
||||
style={{
|
||||
width: "30%",
|
||||
textAlign: "center",
|
||||
fontSize: "14px",
|
||||
color: "#374151",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ColumnConfig } from "./types";
|
||||
|
||||
interface SingleTableWithStickyProps {
|
||||
visibleColumns: ColumnConfig[];
|
||||
data: Record<string, any>[];
|
||||
columnLabels: Record<string, string>;
|
||||
sortColumn: string | null;
|
||||
sortDirection: "asc" | "desc";
|
||||
tableConfig: any;
|
||||
isDesignMode: boolean;
|
||||
isAllSelected: boolean;
|
||||
handleSort: (columnName: string) => void;
|
||||
handleSelectAll: (checked: boolean) => void;
|
||||
handleRowClick: (row: any) => void;
|
||||
renderCheckboxCell: (row: any, index: number) => React.ReactNode;
|
||||
formatCellValue: (value: any, format?: string, columnName?: string) => string;
|
||||
getColumnWidth: (column: ColumnConfig) => number;
|
||||
}
|
||||
|
||||
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
visibleColumns,
|
||||
data,
|
||||
columnLabels,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
tableConfig,
|
||||
isDesignMode,
|
||||
isAllSelected,
|
||||
handleSort,
|
||||
handleSelectAll,
|
||||
handleRowClick,
|
||||
renderCheckboxCell,
|
||||
formatCellValue,
|
||||
getColumnWidth,
|
||||
}) => {
|
||||
const checkboxConfig = tableConfig.checkbox || {};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<Table className="w-full">
|
||||
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = visibleColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-10 border-b px-4 py-2 text-center align-middle"
|
||||
: "h-10 cursor-pointer border-b px-4 py-2 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-gray-50",
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "sticky z-10 border-r bg-white shadow-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l bg-white shadow-sm",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: getColumnWidth(column),
|
||||
maxWidth: getColumnWidth(column),
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
checkboxConfig.selectAll && (
|
||||
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate">
|
||||
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||
</span>
|
||||
{column.sortable && (
|
||||
<span className="ml-1">
|
||||
{sortColumn === column.columnName ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3 text-blue-600" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3 text-blue-600" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="py-8 text-center text-gray-500">
|
||||
데이터가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<TableRow
|
||||
key={`row-${index}`}
|
||||
className={cn(
|
||||
"h-10 cursor-pointer border-b leading-none",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/30",
|
||||
)}
|
||||
style={{ minHeight: "40px", height: "40px", lineHeight: "1" }}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
const leftFixedWidth = visibleColumns
|
||||
.slice(0, colIndex)
|
||||
.filter((col) => col.fixed === "left")
|
||||
.reduce((sum, col) => sum + getColumnWidth(col), 0);
|
||||
|
||||
// 오른쪽 고정 컬럼들의 누적 너비 계산
|
||||
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
|
||||
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
|
||||
const rightFixedWidth =
|
||||
rightFixedIndex >= 0
|
||||
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={`cell-${column.columnName}`}
|
||||
className={cn(
|
||||
"h-10 px-4 py-2 align-middle text-sm whitespace-nowrap",
|
||||
`text-${column.align}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "sticky z-10 border-r bg-white",
|
||||
column.fixed === "right" && "sticky z-10 border-l bg-white",
|
||||
)}
|
||||
style={{
|
||||
minHeight: "40px",
|
||||
height: "40px",
|
||||
verticalAlign: "middle",
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
}}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
? renderCheckboxCell(row, index)
|
||||
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,14 +7,12 @@ import { entityJoinApi } from "@/lib/api/entityJoin";
|
|||
import { codeCache } from "@/lib/caching/codeCache";
|
||||
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
|
|
@ -23,6 +21,9 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||
|
||||
export interface TableListComponentProps {
|
||||
component: any;
|
||||
|
|
@ -96,10 +97,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||
const [tableLabel, setTableLabel] = useState<string>("");
|
||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); // 로컬 페이지 크기 상태
|
||||
const [selectedSearchColumn, setSelectedSearchColumn] = useState<string>(""); // 선택된 검색 컬럼
|
||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
|
||||
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
|
||||
|
||||
// 고급 필터 관련 state
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
|
||||
// 체크박스 상태 관리
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); // 선택된 행들의 키 집합
|
||||
const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태
|
||||
|
|
@ -111,39 +114,67 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
maxBatchSize: 5,
|
||||
});
|
||||
|
||||
// 높이 계산 함수
|
||||
const calculateOptimalHeight = () => {
|
||||
// 50개 이상일 때는 20개 기준으로 높이 고정
|
||||
const displayPageSize = localPageSize >= 50 ? 20 : localPageSize;
|
||||
const headerHeight = 48; // 테이블 헤더
|
||||
const rowHeight = 40; // 각 행 높이 (normal)
|
||||
const searchHeight = tableConfig.filter?.enabled ? 48 : 0; // 검색 영역
|
||||
const footerHeight = tableConfig.showFooter ? 56 : 0; // 페이지네이션
|
||||
const padding = 8; // 여백
|
||||
// 높이 계산 함수 (메모이제이션)
|
||||
const optimalHeight = useMemo(() => {
|
||||
// 50개 이상일 때는 20개 기준으로 높이 고정하고 스크롤 생성
|
||||
// 50개 미만일 때는 실제 데이터 개수에 맞춰서 스크롤 없이 표시
|
||||
const actualDataCount = Math.min(data.length, localPageSize);
|
||||
const displayPageSize = localPageSize >= 50 ? 20 : Math.max(actualDataCount, 5);
|
||||
|
||||
return headerHeight + displayPageSize * rowHeight + searchHeight + footerHeight + padding;
|
||||
};
|
||||
const headerHeight = 50; // 테이블 헤더
|
||||
const rowHeight = 42; // 각 행 높이
|
||||
const searchHeight = tableConfig.filter?.enabled ? 80 : 0; // 검색 영역
|
||||
const footerHeight = tableConfig.showFooter ? 60 : 0; // 페이지네이션
|
||||
const titleHeight = tableConfig.showHeader ? 60 : 0; // 제목 영역
|
||||
const padding = 40; // 여백
|
||||
|
||||
const calculatedHeight =
|
||||
titleHeight + searchHeight + headerHeight + displayPageSize * rowHeight + footerHeight + padding;
|
||||
|
||||
console.log("🔍 테이블 높이 계산:", {
|
||||
actualDataCount,
|
||||
localPageSize,
|
||||
displayPageSize,
|
||||
willHaveScroll: localPageSize >= 50,
|
||||
titleHeight,
|
||||
searchHeight,
|
||||
headerHeight,
|
||||
rowHeight,
|
||||
footerHeight,
|
||||
padding,
|
||||
calculatedHeight,
|
||||
finalHeight: `${calculatedHeight}px`,
|
||||
});
|
||||
|
||||
// 추가 디버깅: 실제 데이터 상황
|
||||
console.log("🔍 실제 데이터 상황:", {
|
||||
actualDataLength: data.length,
|
||||
localPageSize,
|
||||
currentPage,
|
||||
totalItems,
|
||||
totalPages,
|
||||
});
|
||||
|
||||
return calculatedHeight;
|
||||
}, [data.length, localPageSize, tableConfig.filter?.enabled, tableConfig.showFooter, tableConfig.showHeader]);
|
||||
|
||||
// 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height:
|
||||
tableConfig.height === "fixed"
|
||||
? `${tableConfig.fixedHeight || calculateOptimalHeight()}px`
|
||||
: tableConfig.height === "auto"
|
||||
? `${calculateOptimalHeight()}px`
|
||||
: "100%",
|
||||
height: `${optimalHeight}px`, // 20개 데이터를 모두 보여주는 높이
|
||||
minHeight: `${optimalHeight}px`, // 최소 높이 보장
|
||||
...component.style,
|
||||
...style,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxSizing: "border-box", // 패딩/보더 포함한 크기 계산
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
componentStyle.minHeight = "200px";
|
||||
// minHeight 제거 - 실제 데이터에 맞는 높이 사용
|
||||
}
|
||||
|
||||
// 컬럼 라벨 정보 가져오기
|
||||
|
|
@ -234,56 +265,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page: currentPage,
|
||||
size: localPageSize,
|
||||
search: searchTerm?.trim()
|
||||
? (() => {
|
||||
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
|
||||
let searchColumn = selectedSearchColumn || sortColumn; // 사용자 선택 컬럼 최우선, 그 다음 정렬된 컬럼
|
||||
search: (() => {
|
||||
// 고급 필터 값이 있으면 우선 사용
|
||||
const hasAdvancedFilters = Object.values(searchValues).some((value) => {
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
|
||||
}
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
});
|
||||
|
||||
if (!searchColumn) {
|
||||
// 1순위: name 관련 컬럼 (가장 검색에 적합)
|
||||
const nameColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("name") ||
|
||||
col.columnName.toLowerCase().includes("title") ||
|
||||
col.columnName.toLowerCase().includes("subject"),
|
||||
);
|
||||
if (hasAdvancedFilters) {
|
||||
console.log("🔍 고급 검색 필터 사용:", searchValues);
|
||||
console.log("🔍 고급 검색 필터 상세:", JSON.stringify(searchValues, null, 2));
|
||||
return searchValues;
|
||||
}
|
||||
|
||||
// 2순위: text/varchar 타입 컬럼
|
||||
const textColumns = visibleColumns.filter(
|
||||
(col) => col.dataType === "text" || col.dataType === "varchar",
|
||||
);
|
||||
// 고급 필터가 없으면 기존 단순 검색 사용
|
||||
if (searchTerm?.trim()) {
|
||||
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
|
||||
let searchColumn = sortColumn; // 정렬된 컬럼 우선
|
||||
|
||||
// 3순위: description 관련 컬럼
|
||||
const descColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("desc") ||
|
||||
col.columnName.toLowerCase().includes("comment") ||
|
||||
col.columnName.toLowerCase().includes("memo"),
|
||||
);
|
||||
|
||||
// 우선순위에 따라 선택
|
||||
if (nameColumns.length > 0) {
|
||||
searchColumn = nameColumns[0].columnName;
|
||||
} else if (textColumns.length > 0) {
|
||||
searchColumn = textColumns[0].columnName;
|
||||
} else if (descColumns.length > 0) {
|
||||
searchColumn = descColumns[0].columnName;
|
||||
} else {
|
||||
// 마지막 대안: 첫 번째 컬럼
|
||||
searchColumn = visibleColumns[0]?.columnName || "id";
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔍 선택된 검색 컬럼:", searchColumn);
|
||||
console.log("🔍 검색어:", searchTerm);
|
||||
console.log(
|
||||
"🔍 사용 가능한 컬럼들:",
|
||||
visibleColumns.map((col) => `${col.columnName}(${col.dataType || "unknown"})`),
|
||||
if (!searchColumn) {
|
||||
// 1순위: name 관련 컬럼 (가장 검색에 적합)
|
||||
const nameColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("name") ||
|
||||
col.columnName.toLowerCase().includes("title") ||
|
||||
col.columnName.toLowerCase().includes("subject"),
|
||||
);
|
||||
|
||||
return { [searchColumn]: searchTerm };
|
||||
})()
|
||||
: undefined,
|
||||
// 2순위: text/varchar 타입 컬럼
|
||||
const textColumns = visibleColumns.filter((col) => col.dataType === "text" || col.dataType === "varchar");
|
||||
|
||||
// 3순위: description 관련 컬럼
|
||||
const descColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("desc") ||
|
||||
col.columnName.toLowerCase().includes("comment") ||
|
||||
col.columnName.toLowerCase().includes("memo"),
|
||||
);
|
||||
|
||||
// 우선순위에 따라 선택
|
||||
if (nameColumns.length > 0) {
|
||||
searchColumn = nameColumns[0].columnName;
|
||||
} else if (textColumns.length > 0) {
|
||||
searchColumn = textColumns[0].columnName;
|
||||
} else if (descColumns.length > 0) {
|
||||
searchColumn = descColumns[0].columnName;
|
||||
} else {
|
||||
// 마지막 대안: 첫 번째 컬럼
|
||||
searchColumn = visibleColumns[0]?.columnName || "id";
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔍 기존 검색 방식 사용:", { [searchColumn]: searchTerm });
|
||||
return { [searchColumn]: searchTerm };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})(),
|
||||
sortBy: sortColumn || undefined,
|
||||
sortOrder: sortDirection,
|
||||
enableEntityJoin: true, // 🎯 Entity 조인 활성화
|
||||
|
|
@ -410,10 +451,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||
// 고급 필터 핸들러
|
||||
const handleSearchValueChange = (columnName: string, value: any) => {
|
||||
setSearchValues((prev) => ({
|
||||
...prev,
|
||||
[columnName]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchTableData();
|
||||
};
|
||||
|
||||
const handleClearAdvancedFilters = () => {
|
||||
setSearchValues({});
|
||||
setCurrentPage(1);
|
||||
fetchTableData();
|
||||
};
|
||||
|
||||
// 새로고침
|
||||
|
|
@ -522,7 +576,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (tableConfig.autoLoad && !isDesignMode) {
|
||||
fetchTableData();
|
||||
}
|
||||
}, [tableConfig.selectedTable, localPageSize, currentPage, searchTerm, sortColumn, sortDirection, columnLabels]);
|
||||
}, [
|
||||
tableConfig.selectedTable,
|
||||
localPageSize,
|
||||
currentPage,
|
||||
searchTerm,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
columnLabels,
|
||||
searchValues,
|
||||
]);
|
||||
|
||||
// refreshKey 변경 시 테이블 데이터 새로고침
|
||||
useEffect(() => {
|
||||
|
|
@ -577,8 +640,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// 체크박스가 활성화된 경우 체크박스 컬럼을 추가
|
||||
if (checkboxConfig.enabled) {
|
||||
// 체크박스가 활성화되고 실제 데이터 컬럼이 있는 경우에만 체크박스 컬럼을 추가
|
||||
if (checkboxConfig.enabled && columns.length > 0) {
|
||||
const checkboxColumn: ColumnConfig = {
|
||||
columnName: "__checkbox__",
|
||||
displayName: "",
|
||||
|
|
@ -603,28 +666,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return columns;
|
||||
}, [displayColumns, tableConfig.columns, tableConfig.checkbox]);
|
||||
|
||||
// 컬럼을 고정 위치별로 분류
|
||||
const columnsByPosition = useMemo(() => {
|
||||
const leftFixed: ColumnConfig[] = [];
|
||||
const rightFixed: ColumnConfig[] = [];
|
||||
const normal: ColumnConfig[] = [];
|
||||
|
||||
visibleColumns.forEach((col) => {
|
||||
if (col.fixed === "left") {
|
||||
leftFixed.push(col);
|
||||
} else if (col.fixed === "right") {
|
||||
rightFixed.push(col);
|
||||
} else {
|
||||
normal.push(col);
|
||||
}
|
||||
});
|
||||
|
||||
// 고정 컬럼들은 fixedOrder로 정렬
|
||||
leftFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
|
||||
rightFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
|
||||
|
||||
return { leftFixed, rightFixed, normal };
|
||||
}, [visibleColumns]);
|
||||
// columnsByPosition은 SingleTableWithSticky에서 사용하지 않으므로 제거
|
||||
// 기존 테이블에서만 필요한 경우 다시 추가 가능
|
||||
|
||||
// 가로 스크롤이 필요한지 계산
|
||||
const needsHorizontalScroll = useMemo(() => {
|
||||
|
|
@ -819,8 +862,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 */}
|
||||
{tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
|
||||
{/* 검색 - 기존 방식은 주석처리 */}
|
||||
{/* {tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
|
|
@ -831,7 +874,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="w-64 pl-8"
|
||||
/>
|
||||
</div>
|
||||
{/* 검색 컬럼 선택 드롭다운 */}
|
||||
{tableConfig.filter?.showColumnSelector && (
|
||||
<select
|
||||
value={selectedSearchColumn}
|
||||
|
|
@ -847,7 +889,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* 새로고침 */}
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
|
||||
|
|
@ -857,8 +899,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
|
||||
{tableConfig.filter?.enabled && visibleColumns && visibleColumns.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<AdvancedSearchFilters
|
||||
filters={tableConfig.filter?.filters || []} // 설정된 필터 사용, 없으면 자동 생성
|
||||
searchValues={searchValues}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onSearch={handleAdvancedSearch}
|
||||
onClearFilters={handleClearAdvancedFilters}
|
||||
tableColumns={visibleColumns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
webType: columnMeta[col.columnName]?.webType || "text",
|
||||
displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||
codeCategory: columnMeta[col.columnName]?.codeCategory,
|
||||
isVisible: col.visible,
|
||||
// 추가 메타데이터 전달 (필터 자동 생성용)
|
||||
web_type: columnMeta[col.columnName]?.webType || "text",
|
||||
column_name: col.columnName,
|
||||
column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||
code_category: columnMeta[col.columnName]?.codeCategory,
|
||||
}))}
|
||||
tableName={tableConfig.selectedTable}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 테이블 컨텐츠 */}
|
||||
<div className={`flex-1 ${localPageSize >= 50 ? "overflow-auto" : "overflow-hidden"}`}>
|
||||
<div className={`w-full ${localPageSize >= 50 ? "flex-1 overflow-auto" : ""}`}>
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -874,317 +943,43 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
) : needsHorizontalScroll ? (
|
||||
// 가로 스크롤이 필요한 경우 - 고정 컬럼 지원 테이블
|
||||
<div className="relative flex h-full">
|
||||
{/* 왼쪽 고정 컬럼 */}
|
||||
{columnsByPosition.leftFixed.length > 0 && (
|
||||
<div className="flex-shrink-0 border-r bg-gray-50/50">
|
||||
<table
|
||||
className="table-fixed-layout table-auto"
|
||||
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
|
||||
>
|
||||
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
|
||||
<tr>
|
||||
{columnsByPosition.leftFixed.map((column) => (
|
||||
<th
|
||||
key={`fixed-left-${column.columnName}`}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-12 border-b px-4 py-3 text-center align-middle"
|
||||
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-gray-50",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||
)}
|
||||
style={{
|
||||
minWidth: `${getColumnWidth(column)}px`,
|
||||
minHeight: "48px",
|
||||
height: "48px",
|
||||
verticalAlign: "middle",
|
||||
lineHeight: "1",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxHeader()
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && (
|
||||
<div className="flex flex-col">
|
||||
{sortColumn === column.columnName ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columnsByPosition.leftFixed.length} className="py-8 text-center text-gray-500">
|
||||
데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<tr
|
||||
key={`fixed-left-row-${index}`}
|
||||
className={cn(
|
||||
"h-12 cursor-pointer border-b leading-none",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
|
||||
)}
|
||||
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{columnsByPosition.leftFixed.map((column) => (
|
||||
<td
|
||||
key={`fixed-left-cell-${column.columnName}`}
|
||||
className={cn(
|
||||
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
|
||||
`text-${column.align}`,
|
||||
)}
|
||||
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
? renderCheckboxCell(row, index)
|
||||
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스크롤 가능한 중앙 컬럼들 */}
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<table
|
||||
className="table-fixed-layout w-full table-auto"
|
||||
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
|
||||
>
|
||||
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
|
||||
<tr>
|
||||
{columnsByPosition.normal.map((column) => (
|
||||
<th
|
||||
key={`normal-${column.columnName}`}
|
||||
style={{
|
||||
minWidth: `${getColumnWidth(column)}px`,
|
||||
minHeight: "48px",
|
||||
height: "48px",
|
||||
verticalAlign: "middle",
|
||||
lineHeight: "1",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-12 border-b px-4 py-3 text-center align-middle"
|
||||
: "cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-gray-50",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||
)}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxHeader()
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && (
|
||||
<div className="flex flex-col">
|
||||
{sortColumn === column.columnName ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columnsByPosition.normal.length} className="py-8 text-center text-gray-500">
|
||||
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.rightFixed.length === 0
|
||||
? "데이터가 없습니다"
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<tr
|
||||
key={`normal-row-${index}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-b",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
|
||||
)}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{columnsByPosition.normal.map((column) => (
|
||||
<td
|
||||
key={`normal-cell-${column.columnName}`}
|
||||
className={cn(
|
||||
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
|
||||
`text-${column.align}`,
|
||||
)}
|
||||
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
? renderCheckboxCell(row, index)
|
||||
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 고정 컬럼 */}
|
||||
{columnsByPosition.rightFixed.length > 0 && (
|
||||
<div className="flex-shrink-0 border-l bg-gray-50/50">
|
||||
<table
|
||||
className="table-fixed-layout table-auto"
|
||||
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
|
||||
>
|
||||
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
|
||||
<tr>
|
||||
{columnsByPosition.rightFixed.map((column) => (
|
||||
<th
|
||||
key={`fixed-right-${column.columnName}`}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-12 border-b px-4 py-3 text-center align-middle"
|
||||
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-gray-50",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||
)}
|
||||
style={{
|
||||
minWidth: `${getColumnWidth(column)}px`,
|
||||
minHeight: "48px",
|
||||
height: "48px",
|
||||
verticalAlign: "middle",
|
||||
lineHeight: "1",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
>
|
||||
{column.columnName === "__checkbox__" ? (
|
||||
renderCheckboxHeader()
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && (
|
||||
<div className="flex flex-col">
|
||||
{sortColumn === column.columnName ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columnsByPosition.rightFixed.length} className="py-8 text-center text-gray-500">
|
||||
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.normal.length === 0
|
||||
? "데이터가 없습니다"
|
||||
: ""}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<tr
|
||||
key={`fixed-right-row-${index}`}
|
||||
className={cn(
|
||||
"h-12 cursor-pointer border-b leading-none",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
|
||||
)}
|
||||
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{columnsByPosition.rightFixed.map((column) => (
|
||||
<td
|
||||
key={`fixed-right-cell-${column.columnName}`}
|
||||
className={cn(
|
||||
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
|
||||
`text-${column.align}`,
|
||||
)}
|
||||
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
? renderCheckboxCell(row, index)
|
||||
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
// 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용
|
||||
<SingleTableWithSticky
|
||||
visibleColumns={visibleColumns}
|
||||
data={data}
|
||||
columnLabels={columnLabels}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
tableConfig={tableConfig}
|
||||
isDesignMode={isDesignMode}
|
||||
isAllSelected={isAllSelected}
|
||||
handleSort={handleSort}
|
||||
handleSelectAll={handleSelectAll}
|
||||
handleRowClick={handleRowClick}
|
||||
renderCheckboxCell={renderCheckboxCell}
|
||||
formatCellValue={formatCellValue}
|
||||
getColumnWidth={getColumnWidth}
|
||||
/>
|
||||
) : (
|
||||
// 기존 테이블 (가로 스크롤이 필요 없는 경우)
|
||||
<Table>
|
||||
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
|
||||
<TableRow style={{ minHeight: "48px !important", height: "48px !important", lineHeight: "1" }}>
|
||||
<TableRow style={{ minHeight: "40px !important", height: "40px !important", lineHeight: "1" }}>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableHead
|
||||
key={column.columnName}
|
||||
style={{
|
||||
width: column.width ? `${column.width}px` : undefined,
|
||||
minHeight: "48px !important",
|
||||
height: "48px !important",
|
||||
minHeight: "40px !important",
|
||||
height: "40px !important",
|
||||
verticalAlign: "middle",
|
||||
lineHeight: "1",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-12 text-center align-middle"
|
||||
: "h-12 cursor-pointer align-middle whitespace-nowrap select-none",
|
||||
? "h-10 text-center align-middle"
|
||||
: "h-10 cursor-pointer align-middle whitespace-nowrap select-none",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-gray-50",
|
||||
)}
|
||||
|
|
@ -1226,18 +1021,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<TableRow
|
||||
key={index}
|
||||
className={cn(
|
||||
"h-12 cursor-pointer leading-none",
|
||||
"h-10 cursor-pointer leading-none",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
|
||||
)}
|
||||
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
|
||||
style={{ minHeight: "40px", height: "40px", lineHeight: "1" }}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableCell
|
||||
key={column.columnName}
|
||||
className={cn("h-12 align-middle whitespace-nowrap", `text-${column.align}`)}
|
||||
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
|
||||
className={cn("h-10 align-middle whitespace-nowrap", `text-${column.align}`)}
|
||||
style={{ minHeight: "40px", height: "40px", verticalAlign: "middle" }}
|
||||
>
|
||||
{column.columnName === "__checkbox__"
|
||||
? renderCheckboxCell(row, index)
|
||||
|
|
|
|||
|
|
@ -95,20 +95,33 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
fetchTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 설정 (tableColumns prop 우선 사용)
|
||||
// 선택된 테이블의 컬럼 목록 설정
|
||||
useEffect(() => {
|
||||
console.log("🔍 useEffect 실행됨 - tableColumns:", tableColumns, "length:", tableColumns?.length);
|
||||
if (tableColumns && tableColumns.length > 0) {
|
||||
// tableColumns prop이 있으면 사용
|
||||
console.log(
|
||||
"🔍 useEffect 실행됨 - config.selectedTable:",
|
||||
config.selectedTable,
|
||||
"screenTableName:",
|
||||
screenTableName,
|
||||
);
|
||||
|
||||
// 컴포넌트에 명시적으로 테이블이 선택되었거나, 화면에 연결된 테이블이 있는 경우에만 컬럼 목록 표시
|
||||
const shouldShowColumns = config.selectedTable || (screenTableName && config.columns && config.columns.length > 0);
|
||||
|
||||
if (!shouldShowColumns) {
|
||||
console.log("🔧 컬럼 목록 숨김 - 명시적 테이블 선택 또는 설정된 컬럼이 없음");
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// tableColumns prop을 우선 사용하되, 컴포넌트가 명시적으로 설정되었을 때만
|
||||
if (tableColumns && tableColumns.length > 0 && (config.selectedTable || config.columns?.length > 0)) {
|
||||
console.log("🔧 tableColumns prop 사용:", tableColumns);
|
||||
console.log("🔧 첫 번째 컬럼 상세:", tableColumns[0]);
|
||||
const mappedColumns = tableColumns.map((column: any) => ({
|
||||
columnName: column.columnName || column.name,
|
||||
dataType: column.dataType || column.type || "text",
|
||||
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
|
||||
}));
|
||||
console.log("🏷️ availableColumns 설정됨:", mappedColumns);
|
||||
console.log("🏷️ 첫 번째 mappedColumn:", mappedColumns[0]);
|
||||
setAvailableColumns(mappedColumns);
|
||||
} else if (config.selectedTable || screenTableName) {
|
||||
// API에서 컬럼 정보 가져오기
|
||||
|
|
@ -144,7 +157,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
} else {
|
||||
setAvailableColumns([]);
|
||||
}
|
||||
}, [config.selectedTable, screenTableName, tableColumns]);
|
||||
}, [config.selectedTable, screenTableName, tableColumns, config.columns]);
|
||||
|
||||
// Entity 조인 컬럼 정보 가져오기
|
||||
useEffect(() => {
|
||||
|
|
@ -274,6 +287,72 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
handleChange("columns", columns);
|
||||
};
|
||||
|
||||
// 필터 추가
|
||||
const addFilter = (columnName: string) => {
|
||||
const existingFilter = config.filter?.filters?.find((f) => f.columnName === columnName);
|
||||
if (existingFilter) return;
|
||||
|
||||
const column = availableColumns.find((col) => col.columnName === columnName);
|
||||
if (!column) return;
|
||||
|
||||
// tableColumns에서 해당 컬럼의 메타정보 찾기
|
||||
const tableColumn = tableColumns?.find((tc) => tc.columnName === columnName || tc.column_name === columnName);
|
||||
|
||||
// 컬럼의 데이터 타입과 웹타입에 따라 위젯 타입 결정
|
||||
const inferWidgetType = (dataType: string, webType?: string): string => {
|
||||
// 웹타입이 있으면 우선 사용
|
||||
if (webType) {
|
||||
return webType;
|
||||
}
|
||||
|
||||
// 데이터 타입으로 추론
|
||||
const type = dataType.toLowerCase();
|
||||
if (type.includes("int") || type.includes("numeric") || type.includes("decimal")) return "number";
|
||||
if (type.includes("date") || type.includes("timestamp")) return "date";
|
||||
if (type.includes("bool")) return "boolean";
|
||||
return "text";
|
||||
};
|
||||
|
||||
const widgetType = inferWidgetType(column.dataType, tableColumn?.webType || tableColumn?.web_type);
|
||||
|
||||
const newFilter = {
|
||||
columnName,
|
||||
widgetType,
|
||||
label: column.label || column.columnName,
|
||||
gridColumns: 3,
|
||||
numberFilterMode: "range" as const,
|
||||
// 코드 타입인 경우 코드 카테고리 추가
|
||||
...(widgetType === "code" && {
|
||||
codeCategory: tableColumn?.codeCategory || tableColumn?.code_category,
|
||||
}),
|
||||
// 엔티티 타입인 경우 참조 정보 추가
|
||||
...(widgetType === "entity" && {
|
||||
referenceTable: tableColumn?.referenceTable || tableColumn?.reference_table,
|
||||
referenceColumn: tableColumn?.referenceColumn || tableColumn?.reference_column,
|
||||
displayColumn: tableColumn?.displayColumn || tableColumn?.display_column,
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("🔍 필터 추가:", newFilter);
|
||||
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
handleNestedChange("filter", "filters", [...currentFilters, newFilter]);
|
||||
};
|
||||
|
||||
// 필터 제거
|
||||
const removeFilter = (index: number) => {
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
const updatedFilters = currentFilters.filter((_, i) => i !== index);
|
||||
handleNestedChange("filter", "filters", updatedFilters);
|
||||
};
|
||||
|
||||
// 필터 업데이트
|
||||
const updateFilter = (index: number, key: string, value: any) => {
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
const updatedFilters = currentFilters.map((filter, i) => (i === index ? { ...filter, [key]: value } : filter));
|
||||
handleNestedChange("filter", "filters", updatedFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">테이블 리스트 설정</div>
|
||||
|
|
@ -620,6 +699,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : availableColumns.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>컬럼을 추가하려면 먼저 컴포넌트에 테이블을 명시적으로 선택하거나</p>
|
||||
<p className="text-sm">기본 설정 탭에서 테이블을 설정해주세요.</p>
|
||||
<p className="mt-2 text-xs text-blue-600">현재 화면 테이블: {screenTableName}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
|
|
@ -990,54 +1079,141 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
{/* 필터 설정 탭 */}
|
||||
<TabsContent value="filter" className="space-y-4">
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
{/* 필터 기능 활성화 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검색 및 필터</CardTitle>
|
||||
<CardTitle className="text-base">필터 설정</CardTitle>
|
||||
<CardDescription>테이블에서 사용할 검색 필터를 설정하세요</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="filterEnabled"
|
||||
checked={config.filter?.enabled}
|
||||
checked={config.filter?.enabled || false}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "enabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="filterEnabled">필터 기능 사용</Label>
|
||||
</div>
|
||||
|
||||
{config.filter?.enabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="quickSearch"
|
||||
checked={config.filter?.quickSearch}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "quickSearch", checked)}
|
||||
/>
|
||||
<Label htmlFor="quickSearch">빠른 검색</Label>
|
||||
</div>
|
||||
|
||||
{config.filter?.quickSearch && (
|
||||
<div className="ml-6 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showColumnSelector"
|
||||
checked={config.filter?.showColumnSelector}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "showColumnSelector", checked)}
|
||||
/>
|
||||
<Label htmlFor="showColumnSelector">검색 컬럼 선택기 표시</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="advancedFilter"
|
||||
checked={config.filter?.advancedFilter}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "advancedFilter", checked)}
|
||||
/>
|
||||
<Label htmlFor="advancedFilter">고급 필터</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 필터 목록 */}
|
||||
{config.filter?.enabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">사용할 필터</CardTitle>
|
||||
<CardDescription>검색에 사용할 컬럼 필터를 추가하고 설정하세요</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 필터 추가 버튼 */}
|
||||
{availableColumns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableColumns
|
||||
.filter((col) => !config.filter?.filters?.find((f) => f.columnName === col.columnName))
|
||||
.map((column) => (
|
||||
<Button
|
||||
key={column.columnName}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addFilter(column.columnName)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{column.label || column.columnName}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{column.dataType}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정된 필터 목록 */}
|
||||
{config.filter?.filters && config.filter.filters.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">설정된 필터</h4>
|
||||
{config.filter.filters.map((filter, index) => (
|
||||
<div key={filter.columnName} className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{filter.widgetType}</Badge>
|
||||
<span className="font-medium">{filter.label}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeFilter(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">표시명</Label>
|
||||
<Input
|
||||
value={filter.label}
|
||||
onChange={(e) => updateFilter(index, "label", e.target.value)}
|
||||
placeholder="필터 라벨"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">그리드 컬럼</Label>
|
||||
<Select
|
||||
value={filter.gridColumns.toString()}
|
||||
onValueChange={(value) => updateFilter(index, "gridColumns", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2칸</SelectItem>
|
||||
<SelectItem value="3">3칸</SelectItem>
|
||||
<SelectItem value="4">4칸</SelectItem>
|
||||
<SelectItem value="6">6칸</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 숫자 타입인 경우 검색 모드 선택 */}
|
||||
{(filter.widgetType === "number" || filter.widgetType === "decimal") && (
|
||||
<div>
|
||||
<Label className="text-xs">검색 모드</Label>
|
||||
<Select
|
||||
value={filter.numberFilterMode || "range"}
|
||||
onValueChange={(value) => updateFilter(index, "numberFilterMode", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exact">정확한 값</SelectItem>
|
||||
<SelectItem value="range">범위 검색</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 코드 타입인 경우 코드 카테고리 */}
|
||||
{filter.widgetType === "code" && (
|
||||
<div>
|
||||
<Label className="text-xs">코드 카테고리</Label>
|
||||
<Input
|
||||
value={filter.codeCategory || ""}
|
||||
onChange={(e) => updateFilter(index, "codeCategory", e.target.value)}
|
||||
placeholder="코드 카테고리"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -59,10 +59,7 @@ export const TableListDefinition = createComponentDefinition({
|
|||
// 필터 설정
|
||||
filter: {
|
||||
enabled: true,
|
||||
quickSearch: true,
|
||||
showColumnSelector: true, // 검색컬럼 선택기 표시 기본값
|
||||
advancedFilter: false,
|
||||
filterableColumns: [],
|
||||
filters: [], // 사용자가 설정할 필터 목록
|
||||
},
|
||||
|
||||
// 액션 설정
|
||||
|
|
|
|||
|
|
@ -71,14 +71,17 @@ export interface ColumnConfig {
|
|||
*/
|
||||
export interface FilterConfig {
|
||||
enabled: boolean;
|
||||
quickSearch: boolean;
|
||||
showColumnSelector?: boolean; // 검색 컬럼 선택기 표시 여부
|
||||
advancedFilter: boolean;
|
||||
filterableColumns: string[];
|
||||
defaultFilters?: Array<{
|
||||
column: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||
value: any;
|
||||
// 사용할 필터 목록 (DataTableFilter 타입 사용)
|
||||
filters: Array<{
|
||||
columnName: string;
|
||||
widgetType: string;
|
||||
label: string;
|
||||
gridColumns: number;
|
||||
numberFilterMode?: "exact" | "range"; // 숫자 필터 모드
|
||||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,10 +71,10 @@ export const TestInputComponent: React.FC<TestInputComponentProps> = ({
|
|||
...domProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -89,7 +89,7 @@ export const TestInputComponent: React.FC<TestInputComponentProps> = ({
|
|||
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={component.value || ""}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -91,10 +91,17 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
|
@ -105,7 +112,8 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
rows={componentConfig.rows || 3}
|
||||
style={{width: "100%",
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
|
|
@ -113,9 +121,9 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
fontSize: "14px",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
return (
|
||||
<div style={componentStyle} className={className} {...domProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && (
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -91,30 +91,39 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{component.label}
|
||||
{component.required && <span style={{color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>*</span>}
|
||||
{component.required && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ef4444",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label
|
||||
style={{display: "flex",
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div
|
||||
style={{position: "relative",
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "48px",
|
||||
height: "24px",
|
||||
backgroundColor: component.value === true ? "#3b82f6" : "#d1d5db",
|
||||
|
|
@ -132,14 +141,14 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: "absolute",
|
||||
opacity: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: "pointer",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (component.onChange) {
|
||||
component.onChange(e.target.checked);
|
||||
|
|
@ -148,7 +157,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: component.value === true ? "26px" : "2px",
|
||||
width: "20px",
|
||||
|
|
@ -157,15 +166,20 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
borderRadius: "50%",
|
||||
transition: "left 0.2s",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{color: "#374151",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}>{componentConfig.toggleLabel || (component.value ? "켜짐" : "꺼짐")}</span>
|
||||
>
|
||||
{componentConfig.toggleLabel || (component.value ? "켜짐" : "꺼짐")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,480 @@
|
|||
/**
|
||||
* 개선된 폼 데이터 저장 서비스
|
||||
* 클라이언트 측 사전 검증과 서버 측 검증을 조합한 안전한 저장 로직
|
||||
*/
|
||||
|
||||
import { ComponentData, ColumnInfo, ScreenDefinition } from "@/types/screen";
|
||||
import { validateFormData, ValidationResult } from "@/lib/utils/formValidation";
|
||||
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
// 저장 결과 타입
|
||||
export interface EnhancedSaveResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
validationResult?: ValidationResult;
|
||||
performance?: {
|
||||
validationTime: number;
|
||||
saveTime: number;
|
||||
totalTime: number;
|
||||
};
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
// 저장 옵션
|
||||
export interface SaveOptions {
|
||||
skipClientValidation?: boolean;
|
||||
skipServerValidation?: boolean;
|
||||
transformData?: boolean;
|
||||
showProgress?: boolean;
|
||||
retryOnError?: boolean;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
// 저장 컨텍스트
|
||||
export interface SaveContext {
|
||||
tableName: string;
|
||||
screenInfo: ScreenDefinition;
|
||||
components: ComponentData[];
|
||||
formData: Record<string, any>;
|
||||
options?: SaveOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 향상된 폼 데이터 저장 클래스
|
||||
*/
|
||||
export class EnhancedFormService {
|
||||
private static instance: EnhancedFormService;
|
||||
private columnCache = new Map<string, ColumnInfo[]>();
|
||||
private validationCache = new Map<string, ValidationResult>();
|
||||
|
||||
public static getInstance(): EnhancedFormService {
|
||||
if (!EnhancedFormService.instance) {
|
||||
EnhancedFormService.instance = new EnhancedFormService();
|
||||
}
|
||||
return EnhancedFormService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 저장 메서드
|
||||
*/
|
||||
async saveFormData(context: SaveContext): Promise<EnhancedSaveResult> {
|
||||
const startTime = performance.now();
|
||||
let validationTime = 0;
|
||||
let saveTime = 0;
|
||||
|
||||
try {
|
||||
const { tableName, screenInfo, components, formData, options = {} } = context;
|
||||
|
||||
console.log("🚀 향상된 폼 저장 시작:", {
|
||||
tableName,
|
||||
screenId: screenInfo.screenId,
|
||||
dataKeys: Object.keys(formData),
|
||||
componentsCount: components.length,
|
||||
});
|
||||
|
||||
// 1. 사전 검증 수행
|
||||
let validationResult: ValidationResult | undefined;
|
||||
if (!options.skipClientValidation) {
|
||||
const validationStart = performance.now();
|
||||
validationResult = await this.performClientValidation(formData, components, tableName);
|
||||
validationTime = performance.now() - validationStart;
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
console.error("❌ 클라이언트 검증 실패:", validationResult.errors);
|
||||
return {
|
||||
success: false,
|
||||
message: this.formatValidationMessage(validationResult),
|
||||
validationResult,
|
||||
performance: {
|
||||
validationTime,
|
||||
saveTime: 0,
|
||||
totalTime: performance.now() - startTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 데이터 변환 및 정제
|
||||
let processedData = formData;
|
||||
if (options.transformData !== false) {
|
||||
processedData = await this.transformFormData(formData, components, tableName);
|
||||
}
|
||||
|
||||
// 3. 서버 저장 수행
|
||||
const saveStart = performance.now();
|
||||
const saveResult = await this.performServerSave(screenInfo.screenId, tableName, processedData, options);
|
||||
saveTime = performance.now() - saveStart;
|
||||
|
||||
if (!saveResult.success) {
|
||||
console.error("❌ 서버 저장 실패:", saveResult.message);
|
||||
return {
|
||||
success: false,
|
||||
message: saveResult.message || "저장 중 서버 오류가 발생했습니다.",
|
||||
data: saveResult.data,
|
||||
validationResult,
|
||||
performance: {
|
||||
validationTime,
|
||||
saveTime,
|
||||
totalTime: performance.now() - startTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.log("✅ 폼 저장 성공:", {
|
||||
validationTime: `${validationTime.toFixed(2)}ms`,
|
||||
saveTime: `${saveTime.toFixed(2)}ms`,
|
||||
totalTime: `${(performance.now() - startTime).toFixed(2)}ms`,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "데이터가 성공적으로 저장되었습니다.",
|
||||
data: saveResult.data,
|
||||
validationResult,
|
||||
performance: {
|
||||
validationTime,
|
||||
saveTime,
|
||||
totalTime: performance.now() - startTime,
|
||||
},
|
||||
warnings: validationResult?.warnings.map((w) => w.message),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ 폼 저장 중 예외 발생:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `저장 중 오류가 발생했습니다: ${error.message || error}`,
|
||||
performance: {
|
||||
validationTime,
|
||||
saveTime,
|
||||
totalTime: performance.now() - startTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 측 검증 수행
|
||||
*/
|
||||
private async performClientValidation(
|
||||
formData: Record<string, any>,
|
||||
components: ComponentData[],
|
||||
tableName: string,
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
// 캐시된 검증 결과 확인
|
||||
const cacheKey = this.generateValidationCacheKey(formData, components, tableName);
|
||||
const cached = this.validationCache.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log("📋 캐시된 검증 결과 사용");
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 테이블 컬럼 정보 조회 (캐시 사용)
|
||||
const tableColumns = await this.getTableColumns(tableName);
|
||||
|
||||
// 폼 데이터 검증 수행
|
||||
const validationResult = await validateFormData(formData, components, tableColumns, tableName);
|
||||
|
||||
// 결과 캐시 저장 (5분간)
|
||||
setTimeout(
|
||||
() => {
|
||||
this.validationCache.delete(cacheKey);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
this.validationCache.set(cacheKey, validationResult);
|
||||
return validationResult;
|
||||
} catch (error) {
|
||||
console.error("❌ 클라이언트 검증 중 오류:", error);
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
field: "validation",
|
||||
code: "VALIDATION_ERROR",
|
||||
message: `검증 중 오류가 발생했습니다: ${error}`,
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회 (캐시 포함)
|
||||
*/
|
||||
private async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
||||
// 캐시 확인
|
||||
const cached = this.columnCache.get(tableName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data) {
|
||||
const columns = response.data.map((col) => ({
|
||||
tableName: col.tableName || tableName,
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName,
|
||||
dataType: col.dataType,
|
||||
webType: col.webType,
|
||||
inputType: col.inputType,
|
||||
isNullable: col.isNullable,
|
||||
required: col.isNullable === "N",
|
||||
detailSettings: col.detailSettings,
|
||||
codeCategory: col.codeCategory,
|
||||
referenceTable: col.referenceTable,
|
||||
referenceColumn: col.referenceColumn,
|
||||
displayColumn: col.displayColumn,
|
||||
isVisible: col.isVisible,
|
||||
displayOrder: col.displayOrder,
|
||||
description: col.description,
|
||||
})) as ColumnInfo[];
|
||||
|
||||
// 캐시 저장 (10분간)
|
||||
this.columnCache.set(tableName, columns);
|
||||
setTimeout(
|
||||
() => {
|
||||
this.columnCache.delete(tableName);
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
return columns;
|
||||
} else {
|
||||
throw new Error(response.message || "컬럼 정보 조회 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 정보 조회 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 변환 및 정제
|
||||
*/
|
||||
private async transformFormData(
|
||||
formData: Record<string, any>,
|
||||
components: ComponentData[],
|
||||
tableName: string,
|
||||
): Promise<Record<string, any>> {
|
||||
const transformed = { ...formData };
|
||||
const tableColumns = await this.getTableColumns(tableName);
|
||||
const columnMap = new Map(tableColumns.map((col) => [col.columnName, col]));
|
||||
|
||||
for (const [key, value] of Object.entries(transformed)) {
|
||||
const column = columnMap.get(key);
|
||||
if (!column) continue;
|
||||
|
||||
// 빈 문자열을 null로 변환 (nullable 컬럼인 경우)
|
||||
if (value === "" && column.isNullable === "Y") {
|
||||
transformed[key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 데이터 타입별 변환
|
||||
if (value !== null && value !== undefined) {
|
||||
transformed[key] = this.convertValueByDataType(value, column.dataType);
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 필드 자동 추가
|
||||
const now = new Date().toISOString();
|
||||
if (!transformed.created_date && tableColumns.some((col) => col.columnName === "created_date")) {
|
||||
transformed.created_date = now;
|
||||
}
|
||||
if (!transformed.updated_date && tableColumns.some((col) => col.columnName === "updated_date")) {
|
||||
transformed.updated_date = now;
|
||||
}
|
||||
|
||||
console.log("🔄 데이터 변환 완료:", {
|
||||
original: Object.keys(formData).length,
|
||||
transformed: Object.keys(transformed).length,
|
||||
changes: Object.keys(transformed).filter((key) => transformed[key] !== formData[key]),
|
||||
});
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 타입별 값 변환
|
||||
*/
|
||||
private convertValueByDataType(value: any, dataType: string): any {
|
||||
const lowerDataType = dataType.toLowerCase();
|
||||
|
||||
// 숫자 타입
|
||||
if (lowerDataType.includes("integer") || lowerDataType.includes("bigint") || lowerDataType.includes("serial")) {
|
||||
const num = parseInt(value);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
if (
|
||||
lowerDataType.includes("numeric") ||
|
||||
lowerDataType.includes("decimal") ||
|
||||
lowerDataType.includes("real") ||
|
||||
lowerDataType.includes("double")
|
||||
) {
|
||||
const num = parseFloat(value);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
// 불린 타입
|
||||
if (lowerDataType.includes("boolean")) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase() === "true" || value === "1" || value === "Y";
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
// 날짜/시간 타입
|
||||
if (lowerDataType.includes("timestamp") || lowerDataType.includes("datetime")) {
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
if (lowerDataType.includes("date")) {
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
if (lowerDataType.includes("time")) {
|
||||
// 시간 형식 변환 로직
|
||||
return value;
|
||||
}
|
||||
|
||||
// JSON 타입
|
||||
if (lowerDataType.includes("json")) {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return value;
|
||||
} catch {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// 기본값: 문자열
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 저장 수행
|
||||
*/
|
||||
private async performServerSave(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
formData: Record<string, any>,
|
||||
options: SaveOptions,
|
||||
): Promise<{ success: boolean; message?: string; data?: any }> {
|
||||
try {
|
||||
const result = await dynamicFormApi.saveData({
|
||||
screenId,
|
||||
tableName,
|
||||
data: formData,
|
||||
});
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
message: result.message || "저장이 완료되었습니다.",
|
||||
data: result.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ 서버 저장 오류:", error);
|
||||
|
||||
// 에러 타입별 처리
|
||||
if (error.response?.status === 400) {
|
||||
return {
|
||||
success: false,
|
||||
message: "잘못된 요청: " + (error.response.data?.message || "데이터 형식을 확인해주세요."),
|
||||
};
|
||||
}
|
||||
|
||||
if (error.response?.status === 500) {
|
||||
return {
|
||||
success: false,
|
||||
message: "서버 오류: " + (error.response.data?.message || "잠시 후 다시 시도해주세요."),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "저장 중 알 수 없는 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 캐시 키 생성
|
||||
*/
|
||||
private generateValidationCacheKey(
|
||||
formData: Record<string, any>,
|
||||
components: ComponentData[],
|
||||
tableName: string,
|
||||
): string {
|
||||
const dataHash = JSON.stringify(formData);
|
||||
const componentsHash = JSON.stringify(
|
||||
components.map((c) => ({ id: c.id, type: c.type, columnName: (c as any).columnName })),
|
||||
);
|
||||
return `${tableName}:${btoa(dataHash + componentsHash).substring(0, 32)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 메시지 포맷팅
|
||||
*/
|
||||
private formatValidationMessage(validationResult: ValidationResult): string {
|
||||
const errorMessages = validationResult.errors.filter((e) => e.severity === "error").map((e) => e.message);
|
||||
|
||||
if (errorMessages.length === 0) {
|
||||
return "알 수 없는 검증 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
if (errorMessages.length === 1) {
|
||||
return errorMessages[0];
|
||||
}
|
||||
|
||||
return `다음 오류들을 수정해주세요:\n• ${errorMessages.join("\n• ")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 클리어
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.columnCache.clear();
|
||||
this.validationCache.clear();
|
||||
console.log("🧹 폼 서비스 캐시가 클리어되었습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블별 캐시 클리어
|
||||
*/
|
||||
public clearTableCache(tableName: string): void {
|
||||
this.columnCache.delete(tableName);
|
||||
console.log(`🧹 테이블 '${tableName}' 캐시가 클리어되었습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 내보내기
|
||||
export const enhancedFormService = EnhancedFormService.getInstance();
|
||||
|
||||
// 편의 함수들
|
||||
export const saveFormDataEnhanced = (context: SaveContext): Promise<EnhancedSaveResult> => {
|
||||
return enhancedFormService.saveFormData(context);
|
||||
};
|
||||
|
||||
export const clearFormCache = (): void => {
|
||||
enhancedFormService.clearCache();
|
||||
};
|
||||
|
||||
export const clearTableFormCache = (tableName: string): void => {
|
||||
enhancedFormService.clearTableCache(tableName);
|
||||
};
|
||||
|
|
@ -7,11 +7,11 @@
|
|||
|
||||
import {
|
||||
ButtonActionType,
|
||||
ButtonTypeConfig,
|
||||
ExtendedButtonTypeConfig,
|
||||
ButtonDataflowConfig,
|
||||
DataflowExecutionResult,
|
||||
DataflowCondition,
|
||||
} from "@/types/screen";
|
||||
} from "@/types";
|
||||
import { dataflowConfigCache } from "./dataflowCache";
|
||||
import { dataflowJobQueue, JobPriority } from "./dataflowJobQueue";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -72,7 +72,7 @@ export class OptimizedButtonDataflowService {
|
|||
static async executeButtonWithDataflow(
|
||||
buttonId: string,
|
||||
actionType: ButtonActionType,
|
||||
buttonConfig: ButtonTypeConfig,
|
||||
buttonConfig: ExtendedExtendedButtonTypeConfig,
|
||||
contextData: Record<string, any>,
|
||||
companyCode: string,
|
||||
): Promise<OptimizedExecutionResult> {
|
||||
|
|
@ -115,7 +115,7 @@ export class OptimizedButtonDataflowService {
|
|||
private static async executeAfterTiming(
|
||||
buttonId: string,
|
||||
actionType: ButtonActionType,
|
||||
buttonConfig: ButtonTypeConfig,
|
||||
buttonConfig: ExtendedButtonTypeConfig,
|
||||
contextData: Record<string, any>,
|
||||
companyCode: string,
|
||||
): Promise<OptimizedExecutionResult> {
|
||||
|
|
@ -155,7 +155,7 @@ export class OptimizedButtonDataflowService {
|
|||
private static async executeBeforeTiming(
|
||||
buttonId: string,
|
||||
actionType: ButtonActionType,
|
||||
buttonConfig: ButtonTypeConfig,
|
||||
buttonConfig: ExtendedButtonTypeConfig,
|
||||
contextData: Record<string, any>,
|
||||
companyCode: string,
|
||||
): Promise<OptimizedExecutionResult> {
|
||||
|
|
@ -228,7 +228,7 @@ export class OptimizedButtonDataflowService {
|
|||
private static async executeReplaceTiming(
|
||||
buttonId: string,
|
||||
actionType: ButtonActionType,
|
||||
buttonConfig: ButtonTypeConfig,
|
||||
buttonConfig: ExtendedButtonTypeConfig,
|
||||
contextData: Record<string, any>,
|
||||
companyCode: string,
|
||||
): Promise<OptimizedExecutionResult> {
|
||||
|
|
@ -623,7 +623,7 @@ export class OptimizedButtonDataflowService {
|
|||
*/
|
||||
private static async executeOriginalAction(
|
||||
actionType: ButtonActionType,
|
||||
buttonConfig: ButtonTypeConfig,
|
||||
buttonConfig: ExtendedButtonTypeConfig,
|
||||
contextData: Record<string, any>,
|
||||
): Promise<any> {
|
||||
const startTime = performance.now();
|
||||
|
|
@ -677,42 +677,42 @@ export class OptimizedButtonDataflowService {
|
|||
/**
|
||||
* 개별 액션 구현들
|
||||
*/
|
||||
private static async executeSaveAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeSaveAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
// TODO: 실제 저장 로직 구현
|
||||
return { success: true, message: "저장되었습니다." };
|
||||
}
|
||||
|
||||
private static async executeDeleteAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeDeleteAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
// TODO: 실제 삭제 로직 구현
|
||||
return { success: true, message: "삭제되었습니다." };
|
||||
}
|
||||
|
||||
private static async executeSearchAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeSearchAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
// TODO: 실제 검색 로직 구현
|
||||
return { success: true, message: "검색되었습니다.", data: [] };
|
||||
}
|
||||
|
||||
private static async executeEditAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeEditAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
return { success: true, message: "수정 모드로 전환되었습니다." };
|
||||
}
|
||||
|
||||
private static async executeAddAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeAddAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
return { success: true, message: "추가 모드로 전환되었습니다." };
|
||||
}
|
||||
|
||||
private static async executeResetAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeResetAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
return { success: true, message: "초기화되었습니다." };
|
||||
}
|
||||
|
||||
private static async executeSubmitAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeSubmitAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
return { success: true, message: "제출되었습니다." };
|
||||
}
|
||||
|
||||
private static async executeCloseAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeCloseAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
return { success: true, message: "닫기 액션이 실행되었습니다." };
|
||||
}
|
||||
|
||||
private static async executePopupAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executePopupAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
return {
|
||||
success: true,
|
||||
message: "팝업이 열렸습니다.",
|
||||
|
|
@ -721,7 +721,7 @@ export class OptimizedButtonDataflowService {
|
|||
};
|
||||
}
|
||||
|
||||
private static async executeNavigateAction(config: ButtonTypeConfig, data: Record<string, any>) {
|
||||
private static async executeNavigateAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
|
||||
return {
|
||||
success: true,
|
||||
message: "페이지 이동이 실행되었습니다.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
/**
|
||||
* DB 타입 → 웹 타입 자동 매핑 유틸리티
|
||||
*
|
||||
* 백엔드의 unified-web-types.ts와 동기화된 매핑 로직을 제공합니다.
|
||||
* 테이블 정보를 불러올 때 DB 타입을 적절한 웹 타입으로 자동 변환합니다.
|
||||
*/
|
||||
|
||||
import { WebType } from "@/types";
|
||||
|
||||
/**
|
||||
* PostgreSQL DB 타입 → 웹 타입 매핑 테이블
|
||||
* (백엔드 unified-web-types.ts와 동기화됨)
|
||||
*/
|
||||
export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
|
||||
// 텍스트 타입
|
||||
"character varying": "text",
|
||||
varchar: "text",
|
||||
text: "textarea",
|
||||
char: "text",
|
||||
|
||||
// 숫자 타입
|
||||
integer: "number",
|
||||
bigint: "number",
|
||||
smallint: "number",
|
||||
serial: "number",
|
||||
bigserial: "number",
|
||||
numeric: "decimal",
|
||||
decimal: "decimal",
|
||||
real: "decimal",
|
||||
"double precision": "decimal",
|
||||
float: "decimal",
|
||||
double: "decimal",
|
||||
|
||||
// 날짜/시간 타입
|
||||
date: "date",
|
||||
timestamp: "datetime",
|
||||
"timestamp with time zone": "datetime",
|
||||
"timestamp without time zone": "datetime",
|
||||
datetime: "datetime",
|
||||
time: "datetime",
|
||||
"time with time zone": "datetime",
|
||||
"time without time zone": "datetime",
|
||||
|
||||
// 불린 타입
|
||||
boolean: "checkbox",
|
||||
bool: "checkbox",
|
||||
|
||||
// JSON 타입 (텍스트로 처리)
|
||||
json: "textarea",
|
||||
jsonb: "textarea",
|
||||
|
||||
// 배열 타입 (텍스트로 처리)
|
||||
ARRAY: "textarea",
|
||||
|
||||
// UUID 타입
|
||||
uuid: "text",
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼명 기반 스마트 웹 타입 추론 규칙
|
||||
*/
|
||||
export const COLUMN_NAME_TO_WEB_TYPE: Record<string, WebType> = {
|
||||
// 이메일 관련
|
||||
email: "email",
|
||||
mail: "email",
|
||||
e_mail: "email",
|
||||
|
||||
// 전화번호 관련
|
||||
phone: "tel",
|
||||
tel: "tel",
|
||||
telephone: "tel",
|
||||
mobile: "tel",
|
||||
cellphone: "tel",
|
||||
|
||||
// URL 관련
|
||||
url: "text",
|
||||
link: "text",
|
||||
website: "text",
|
||||
homepage: "text",
|
||||
|
||||
// 비밀번호 관련
|
||||
password: "text",
|
||||
pwd: "text",
|
||||
pass: "text",
|
||||
passwd: "text",
|
||||
|
||||
// 파일 관련
|
||||
file: "file",
|
||||
attach: "file",
|
||||
attachment: "file",
|
||||
upload: "file",
|
||||
document: "file",
|
||||
doc: "file",
|
||||
docs: "file",
|
||||
image: "file",
|
||||
img: "file",
|
||||
photo: "file",
|
||||
picture: "file",
|
||||
media: "file",
|
||||
|
||||
// 코드 관련 (선택박스로 처리)
|
||||
code: "code",
|
||||
status: "code",
|
||||
state: "code",
|
||||
category: "code",
|
||||
type: "code",
|
||||
|
||||
// 긴 텍스트 관련
|
||||
description: "textarea",
|
||||
desc: "textarea",
|
||||
content: "textarea",
|
||||
comment: "textarea",
|
||||
note: "textarea",
|
||||
memo: "textarea",
|
||||
remark: "textarea",
|
||||
detail: "textarea",
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼 정보를 기반으로 최적의 웹 타입을 자동 추론합니다.
|
||||
*
|
||||
* @param dataType PostgreSQL 데이터 타입 (예: "integer", "varchar", "timestamp")
|
||||
* @param columnName 컬럼명 (스마트 추론용)
|
||||
* @returns 추론된 웹 타입
|
||||
*/
|
||||
export function inferWebTypeFromColumn(dataType: string, columnName: string = ""): WebType {
|
||||
const type = dataType.toLowerCase();
|
||||
const name = columnName.toLowerCase();
|
||||
|
||||
console.log("🔍 웹타입 자동 추론:", {
|
||||
dataType,
|
||||
columnName,
|
||||
normalizedType: type,
|
||||
normalizedName: name,
|
||||
});
|
||||
|
||||
// 1. 정확한 DB 타입 매칭 우선
|
||||
if (DB_TYPE_TO_WEB_TYPE[type]) {
|
||||
console.log(`✅ 정확한 매핑: ${type} → ${DB_TYPE_TO_WEB_TYPE[type]}`);
|
||||
return DB_TYPE_TO_WEB_TYPE[type];
|
||||
}
|
||||
|
||||
// 2. 부분 문자열 매칭 (PostgreSQL 타입 변형 대응)
|
||||
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
|
||||
if (type.includes(dbType.toLowerCase()) || dbType.toLowerCase().includes(type)) {
|
||||
console.log(`✅ 부분 매핑: ${type} → ${webType} (기준: ${dbType})`);
|
||||
return webType;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 추가 정밀 매핑 (일반적인 패턴)
|
||||
if (type.includes("int") && !type.includes("point")) {
|
||||
console.log(`✅ 패턴 매핑: ${type} → number (정수 패턴)`);
|
||||
return "number";
|
||||
}
|
||||
if (type.includes("numeric") || type.includes("decimal")) {
|
||||
console.log(`✅ 패턴 매핑: ${type} → decimal (실수 패턴)`);
|
||||
return "decimal";
|
||||
}
|
||||
if (type.includes("timestamp") || type.includes("datetime")) {
|
||||
console.log(`✅ 패턴 매핑: ${type} → datetime (타임스탬프 패턴)`);
|
||||
return "datetime";
|
||||
}
|
||||
if (type.includes("date")) {
|
||||
console.log(`✅ 패턴 매핑: ${type} → date (날짜 패턴)`);
|
||||
return "date";
|
||||
}
|
||||
if (type.includes("time")) {
|
||||
console.log(`✅ 패턴 매핑: ${type} → datetime (시간 패턴)`);
|
||||
return "datetime";
|
||||
}
|
||||
if (type.includes("bool")) {
|
||||
console.log(`✅ 패턴 매핑: ${type} → checkbox (불린 패턴)`);
|
||||
return "checkbox";
|
||||
}
|
||||
|
||||
// 4. 컬럼명 기반 스마트 추론
|
||||
for (const [namePattern, webType] of Object.entries(COLUMN_NAME_TO_WEB_TYPE)) {
|
||||
if (name.includes(namePattern)) {
|
||||
console.log(`✅ 컬럼명 기반 매핑: ${columnName} → ${webType} (패턴: ${namePattern})`);
|
||||
return webType;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 텍스트 타입 세분화
|
||||
if (type.includes("char") || type.includes("varchar") || type.includes("text")) {
|
||||
const webType = type.includes("text") ? "textarea" : "text";
|
||||
console.log(`✅ 텍스트 타입 매핑: ${type} → ${webType}`);
|
||||
return webType;
|
||||
}
|
||||
|
||||
// 6. 최종 기본값
|
||||
console.log(`⚠️ 기본값 사용: ${type} → text (매핑 규칙 없음)`);
|
||||
return "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹 타입이 DB 타입과 호환되는지 확인합니다.
|
||||
*
|
||||
* @param webType 설정된 웹 타입
|
||||
* @param dbType DB 데이터 타입
|
||||
* @returns 호환성 검사 결과
|
||||
*/
|
||||
export function checkWebTypeCompatibility(
|
||||
webType: WebType,
|
||||
dbType: string,
|
||||
): {
|
||||
compatible: boolean;
|
||||
risk: "none" | "low" | "medium" | "high";
|
||||
warning?: string;
|
||||
} {
|
||||
const normalizedDbType = dbType.toLowerCase();
|
||||
const recommendedWebType = inferWebTypeFromColumn(dbType);
|
||||
|
||||
// 완전 호환
|
||||
if (webType === recommendedWebType) {
|
||||
return { compatible: true, risk: "none" };
|
||||
}
|
||||
|
||||
// 위험도별 분류
|
||||
const incompatiblePairs: Record<string, { risk: "low" | "medium" | "high"; warning: string }> = {
|
||||
// 높은 위험: 데이터 손실 가능성 높음
|
||||
"text-integer": {
|
||||
risk: "high",
|
||||
warning: "텍스트 입력에서 정수 DB로 저장 시 숫자가 아닌 데이터는 손실됩니다.",
|
||||
},
|
||||
"text-numeric": {
|
||||
risk: "high",
|
||||
warning: "텍스트 입력에서 숫자 DB로 저장 시 숫자가 아닌 데이터는 손실됩니다.",
|
||||
},
|
||||
"text-boolean": {
|
||||
risk: "high",
|
||||
warning: "텍스트 입력에서 불린 DB로 저장 시 예상치 못한 변환이 발생할 수 있습니다.",
|
||||
},
|
||||
|
||||
// 중간 위험: 일부 데이터 손실 또는 형식 문제
|
||||
"number-varchar": {
|
||||
risk: "medium",
|
||||
warning: "숫자 입력이 텍스트로 저장되어 숫자 연산에 제한이 있을 수 있습니다.",
|
||||
},
|
||||
"date-varchar": {
|
||||
risk: "medium",
|
||||
warning: "날짜 입력이 텍스트로 저장되어 날짜 연산에 제한이 있을 수 있습니다.",
|
||||
},
|
||||
|
||||
// 낮은 위험: 호환 가능하지만 최적이 아님
|
||||
"textarea-varchar": {
|
||||
risk: "low",
|
||||
warning: "긴 텍스트 입력이 짧은 텍스트 필드에 저장될 수 있습니다.",
|
||||
},
|
||||
"text-varchar": {
|
||||
risk: "low",
|
||||
warning: "일반적으로 호환되지만 길이 제한에 주의하세요.",
|
||||
},
|
||||
};
|
||||
|
||||
// DB 타입 정규화
|
||||
let normalizedPair = "";
|
||||
if (normalizedDbType.includes("int") || normalizedDbType.includes("serial")) {
|
||||
normalizedPair = `${webType}-integer`;
|
||||
} else if (normalizedDbType.includes("numeric") || normalizedDbType.includes("decimal")) {
|
||||
normalizedPair = `${webType}-numeric`;
|
||||
} else if (normalizedDbType.includes("bool")) {
|
||||
normalizedPair = `${webType}-boolean`;
|
||||
} else if (normalizedDbType.includes("varchar") || normalizedDbType.includes("char")) {
|
||||
normalizedPair = `${webType}-varchar`;
|
||||
} else if (normalizedDbType.includes("text")) {
|
||||
normalizedPair = `${webType}-text`;
|
||||
} else if (normalizedDbType.includes("date") || normalizedDbType.includes("timestamp")) {
|
||||
normalizedPair = `${webType}-date`;
|
||||
}
|
||||
|
||||
const incompatibility = incompatiblePairs[normalizedPair];
|
||||
if (incompatibility) {
|
||||
return {
|
||||
compatible: false,
|
||||
risk: incompatibility.risk,
|
||||
warning: incompatibility.warning,
|
||||
};
|
||||
}
|
||||
|
||||
// 알려지지 않은 조합은 중간 위험으로 처리
|
||||
return {
|
||||
compatible: false,
|
||||
risk: "medium",
|
||||
warning: `웹 타입 '${webType}'와 DB 타입 '${dbType}'의 호환성을 확인해주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 모든 컬럼에 대해 자동 웹 타입 매핑을 수행합니다.
|
||||
*/
|
||||
export function autoMapTableColumns(
|
||||
columns: Array<{ columnName: string; dataType: string }>,
|
||||
): Array<{ columnName: string; dataType: string; recommendedWebType: WebType; confidence: "high" | "medium" | "low" }> {
|
||||
return columns.map((column) => {
|
||||
const recommendedWebType = inferWebTypeFromColumn(column.dataType, column.columnName);
|
||||
|
||||
// 신뢰도 계산
|
||||
let confidence: "high" | "medium" | "low" = "medium";
|
||||
|
||||
// 정확한 매핑이 있으면 높은 신뢰도
|
||||
if (DB_TYPE_TO_WEB_TYPE[column.dataType.toLowerCase()]) {
|
||||
confidence = "high";
|
||||
}
|
||||
// 컬럼명 기반 매핑이 있으면 높은 신뢰도
|
||||
else if (
|
||||
Object.keys(COLUMN_NAME_TO_WEB_TYPE).some((pattern) => column.columnName.toLowerCase().includes(pattern))
|
||||
) {
|
||||
confidence = "high";
|
||||
}
|
||||
// 기본값을 사용한 경우 낮은 신뢰도
|
||||
else if (recommendedWebType === "text") {
|
||||
confidence = "low";
|
||||
}
|
||||
|
||||
return {
|
||||
...column,
|
||||
recommendedWebType,
|
||||
confidence,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,663 @@
|
|||
/**
|
||||
* 화면관리 폼 데이터 검증 유틸리티
|
||||
* 클라이언트 측에서 사전 검증을 수행하여 사용자 경험 향상
|
||||
*/
|
||||
|
||||
import { WebType, DynamicWebType, isValidWebType, normalizeWebType } from "@/types/unified-web-types";
|
||||
import { ColumnInfo, ComponentData, WidgetComponent } from "@/types/screen";
|
||||
|
||||
// 검증 결과 타입
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// 필드 검증 결과
|
||||
export interface FieldValidationResult {
|
||||
isValid: boolean;
|
||||
error?: ValidationError;
|
||||
transformedValue?: any;
|
||||
}
|
||||
|
||||
// 스키마 검증 결과
|
||||
export interface SchemaValidationResult {
|
||||
isValid: boolean;
|
||||
missingColumns: string[];
|
||||
invalidTypes: { field: string; expected: WebType; actual: string }[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 전체 검증
|
||||
*/
|
||||
export const validateFormData = async (
|
||||
formData: Record<string, any>,
|
||||
components: ComponentData[],
|
||||
tableColumns: ColumnInfo[],
|
||||
tableName: string,
|
||||
): Promise<ValidationResult> => {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
|
||||
try {
|
||||
// 1. 스키마 검증 (컬럼 존재 여부, 타입 일치)
|
||||
const schemaValidation = validateFormSchema(formData, components, tableColumns);
|
||||
if (!schemaValidation.isValid) {
|
||||
errors.push(
|
||||
...schemaValidation.missingColumns.map((col) => ({
|
||||
field: col,
|
||||
code: "COLUMN_NOT_EXISTS",
|
||||
message: `테이블 '${tableName}'에 '${col}' 컬럼이 존재하지 않습니다.`,
|
||||
severity: "error" as const,
|
||||
})),
|
||||
);
|
||||
|
||||
errors.push(
|
||||
...schemaValidation.invalidTypes.map((type) => ({
|
||||
field: type.field,
|
||||
code: "INVALID_WEB_TYPE",
|
||||
message: `필드 '${type.field}'의 웹타입이 올바르지 않습니다. 예상: ${type.expected}, 실제: ${type.actual}`,
|
||||
severity: "error" as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 필수 필드 검증
|
||||
const requiredValidation = validateRequiredFields(formData, components);
|
||||
errors.push(...requiredValidation);
|
||||
|
||||
// 3. 데이터 타입 검증 및 변환
|
||||
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
|
||||
|
||||
for (const component of widgetComponents) {
|
||||
const fieldName = component.columnName || component.id;
|
||||
const value = formData[fieldName];
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const fieldValidation = validateFieldValue(
|
||||
fieldName,
|
||||
value,
|
||||
component.widgetType,
|
||||
component.webTypeConfig,
|
||||
component.validationRules,
|
||||
);
|
||||
|
||||
if (!fieldValidation.isValid && fieldValidation.error) {
|
||||
errors.push(fieldValidation.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 비즈니스 로직 검증 (커스텀 규칙)
|
||||
const businessValidation = await validateBusinessRules(formData, tableName, components);
|
||||
errors.push(...businessValidation.errors);
|
||||
warnings.push(...businessValidation.warnings);
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
field: "form",
|
||||
code: "VALIDATION_ERROR",
|
||||
message: `검증 중 오류가 발생했습니다: ${error}`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.filter((e) => e.severity === "error").length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 스키마 검증 (컬럼 존재 여부, 타입 일치)
|
||||
*/
|
||||
export const validateFormSchema = (
|
||||
formData: Record<string, any>,
|
||||
components: ComponentData[],
|
||||
tableColumns: ColumnInfo[],
|
||||
): SchemaValidationResult => {
|
||||
const missingColumns: string[] = [];
|
||||
const invalidTypes: { field: string; expected: WebType; actual: string }[] = [];
|
||||
const suggestions: string[] = [];
|
||||
|
||||
const columnMap = new Map(tableColumns.map((col) => [col.columnName, col]));
|
||||
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
|
||||
|
||||
for (const component of widgetComponents) {
|
||||
const fieldName = component.columnName;
|
||||
if (!fieldName) continue;
|
||||
|
||||
// 컬럼 존재 여부 확인
|
||||
const columnInfo = columnMap.get(fieldName);
|
||||
if (!columnInfo) {
|
||||
missingColumns.push(fieldName);
|
||||
|
||||
// 유사한 컬럼명 제안
|
||||
const similar = findSimilarColumns(fieldName, tableColumns);
|
||||
if (similar.length > 0) {
|
||||
suggestions.push(`'${fieldName}' 대신 '${similar.join("', '")}'을 사용하시겠습니까?`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 웹타입 일치 여부 확인
|
||||
const componentWebType = normalizeWebType(component.widgetType);
|
||||
const columnWebType = columnInfo.webType ? normalizeWebType(columnInfo.webType) : null;
|
||||
|
||||
if (columnWebType && componentWebType !== columnWebType) {
|
||||
invalidTypes.push({
|
||||
field: fieldName,
|
||||
expected: columnWebType,
|
||||
actual: componentWebType,
|
||||
});
|
||||
}
|
||||
|
||||
// 웹타입 유효성 확인
|
||||
if (!isValidWebType(component.widgetType)) {
|
||||
invalidTypes.push({
|
||||
field: fieldName,
|
||||
expected: "text", // 기본값
|
||||
actual: component.widgetType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: missingColumns.length === 0 && invalidTypes.length === 0,
|
||||
missingColumns,
|
||||
invalidTypes,
|
||||
suggestions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 필수 필드 검증
|
||||
*/
|
||||
export const validateRequiredFields = (
|
||||
formData: Record<string, any>,
|
||||
components: ComponentData[],
|
||||
): ValidationError[] => {
|
||||
const errors: ValidationError[] = [];
|
||||
const widgetComponents = components.filter((c) => c.type === "widget") as WidgetComponent[];
|
||||
|
||||
for (const component of widgetComponents) {
|
||||
if (!component.required) continue;
|
||||
|
||||
const fieldName = component.columnName || component.id;
|
||||
const value = formData[fieldName];
|
||||
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === "string" && value.trim() === "") ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
) {
|
||||
errors.push({
|
||||
field: fieldName,
|
||||
code: "REQUIRED_FIELD",
|
||||
message: `'${component.label || fieldName}'은(는) 필수 입력 항목입니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* 개별 필드 값 검증
|
||||
*/
|
||||
export const validateFieldValue = (
|
||||
fieldName: string,
|
||||
value: any,
|
||||
webType: DynamicWebType,
|
||||
config?: Record<string, any>,
|
||||
rules?: any[],
|
||||
): FieldValidationResult => {
|
||||
try {
|
||||
const normalizedWebType = normalizeWebType(webType);
|
||||
|
||||
// 타입별 검증
|
||||
switch (normalizedWebType) {
|
||||
case "number":
|
||||
return validateNumberField(fieldName, value, config);
|
||||
|
||||
case "decimal":
|
||||
return validateDecimalField(fieldName, value, config);
|
||||
|
||||
case "date":
|
||||
return validateDateField(fieldName, value, config);
|
||||
|
||||
case "datetime":
|
||||
return validateDateTimeField(fieldName, value, config);
|
||||
|
||||
case "email":
|
||||
return validateEmailField(fieldName, value, config);
|
||||
|
||||
case "tel":
|
||||
return validateTelField(fieldName, value, config);
|
||||
|
||||
case "url":
|
||||
return validateUrlField(fieldName, value, config);
|
||||
|
||||
case "text":
|
||||
case "textarea":
|
||||
return validateTextField(fieldName, value, config);
|
||||
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return validateBooleanField(fieldName, value, config);
|
||||
|
||||
default:
|
||||
// 기본 문자열 검증
|
||||
return validateTextField(fieldName, value, config);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "VALIDATION_ERROR",
|
||||
message: `필드 '${fieldName}' 검증 중 오류: ${error}`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 숫자 필드 검증
|
||||
*/
|
||||
const validateNumberField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
const numValue = Number(value);
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_NUMBER",
|
||||
message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(numValue)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "NOT_INTEGER",
|
||||
message: `'${fieldName}'에는 정수만 입력할 수 있습니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 범위 검증
|
||||
if (config?.min !== undefined && numValue < config.min) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "VALUE_TOO_SMALL",
|
||||
message: `'${fieldName}'의 값은 ${config.min} 이상이어야 합니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (config?.max !== undefined && numValue > config.max) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "VALUE_TOO_LARGE",
|
||||
message: `'${fieldName}'의 값은 ${config.max} 이하여야 합니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: numValue };
|
||||
};
|
||||
|
||||
/**
|
||||
* 소수 필드 검증
|
||||
*/
|
||||
const validateDecimalField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
const numValue = Number(value);
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_DECIMAL",
|
||||
message: `'${fieldName}'에는 숫자만 입력할 수 있습니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 소수점 자릿수 검증
|
||||
if (config?.decimalPlaces !== undefined) {
|
||||
const decimalPart = value.toString().split(".")[1];
|
||||
if (decimalPart && decimalPart.length > config.decimalPlaces) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "TOO_MANY_DECIMAL_PLACES",
|
||||
message: `'${fieldName}'의 소수점은 ${config.decimalPlaces}자리까지만 입력할 수 있습니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: numValue };
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 필드 검증
|
||||
*/
|
||||
const validateDateField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
const dateValue = new Date(value);
|
||||
|
||||
if (isNaN(dateValue.getTime())) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_DATE",
|
||||
message: `'${fieldName}'에는 올바른 날짜를 입력해주세요.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 날짜 범위 검증
|
||||
if (config?.minDate) {
|
||||
const minDate = new Date(config.minDate);
|
||||
if (dateValue < minDate) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "DATE_TOO_EARLY",
|
||||
message: `'${fieldName}'의 날짜는 ${config.minDate} 이후여야 합니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (config?.maxDate) {
|
||||
const maxDate = new Date(config.maxDate);
|
||||
if (dateValue > maxDate) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "DATE_TOO_LATE",
|
||||
message: `'${fieldName}'의 날짜는 ${config.maxDate} 이전이어야 합니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: dateValue.toISOString().split("T")[0] };
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜시간 필드 검증
|
||||
*/
|
||||
const validateDateTimeField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
const dateValue = new Date(value);
|
||||
|
||||
if (isNaN(dateValue.getTime())) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_DATETIME",
|
||||
message: `'${fieldName}'에는 올바른 날짜시간을 입력해주세요.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: dateValue.toISOString() };
|
||||
};
|
||||
|
||||
/**
|
||||
* 이메일 필드 검증
|
||||
*/
|
||||
const validateEmailField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
if (!emailRegex.test(value)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_EMAIL",
|
||||
message: `'${fieldName}'에는 올바른 이메일 주소를 입력해주세요.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: value };
|
||||
};
|
||||
|
||||
/**
|
||||
* 전화번호 필드 검증
|
||||
*/
|
||||
const validateTelField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
// 기본 전화번호 형식 검증 (한국)
|
||||
const telRegex = /^(\d{2,3}-?\d{3,4}-?\d{4}|\d{10,11})$/;
|
||||
|
||||
if (!telRegex.test(value.replace(/\s/g, ""))) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_TEL",
|
||||
message: `'${fieldName}'에는 올바른 전화번호를 입력해주세요.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: value };
|
||||
};
|
||||
|
||||
/**
|
||||
* URL 필드 검증
|
||||
*/
|
||||
const validateUrlField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
try {
|
||||
new URL(value);
|
||||
return { isValid: true, transformedValue: value };
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "INVALID_URL",
|
||||
message: `'${fieldName}'에는 올바른 URL을 입력해주세요.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 텍스트 필드 검증
|
||||
*/
|
||||
const validateTextField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
const strValue = String(value);
|
||||
|
||||
// 길이 검증
|
||||
if (config?.minLength && strValue.length < config.minLength) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "TOO_SHORT",
|
||||
message: `'${fieldName}'은(는) 최소 ${config.minLength}자 이상이어야 합니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (config?.maxLength && strValue.length > config.maxLength) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "TOO_LONG",
|
||||
message: `'${fieldName}'은(는) 최대 ${config.maxLength}자까지만 입력할 수 있습니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 패턴 검증
|
||||
if (config?.pattern) {
|
||||
const regex = new RegExp(config.pattern);
|
||||
if (!regex.test(strValue)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "PATTERN_MISMATCH",
|
||||
message: `'${fieldName}'의 형식이 올바르지 않습니다.`,
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: strValue };
|
||||
};
|
||||
|
||||
/**
|
||||
* 불린 필드 검증
|
||||
*/
|
||||
const validateBooleanField = (fieldName: string, value: any, config?: Record<string, any>): FieldValidationResult => {
|
||||
let boolValue: boolean;
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
boolValue = value;
|
||||
} else if (typeof value === "string") {
|
||||
boolValue = value.toLowerCase() === "true" || value === "1";
|
||||
} else if (typeof value === "number") {
|
||||
boolValue = value === 1;
|
||||
} else {
|
||||
boolValue = Boolean(value);
|
||||
}
|
||||
|
||||
return { isValid: true, transformedValue: boolValue };
|
||||
};
|
||||
|
||||
/**
|
||||
* 비즈니스 로직 검증 (커스텀)
|
||||
*/
|
||||
const validateBusinessRules = async (
|
||||
formData: Record<string, any>,
|
||||
tableName: string,
|
||||
components: ComponentData[],
|
||||
): Promise<{ errors: ValidationError[]; warnings: ValidationWarning[] }> => {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
|
||||
// 여기에 테이블별 비즈니스 로직 검증 추가
|
||||
// 예: 중복 체크, 외래키 제약조건, 커스텀 규칙 등
|
||||
|
||||
return { errors, warnings };
|
||||
};
|
||||
|
||||
/**
|
||||
* 유사한 컬럼명 찾기 (오타 제안용)
|
||||
*/
|
||||
const findSimilarColumns = (targetColumn: string, columns: ColumnInfo[], threshold: number = 0.6): string[] => {
|
||||
const similar: string[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
const similarity = calculateStringSimilarity(targetColumn, column.columnName);
|
||||
if (similarity >= threshold) {
|
||||
similar.push(column.columnName);
|
||||
}
|
||||
}
|
||||
|
||||
return similar.slice(0, 3); // 최대 3개까지
|
||||
};
|
||||
|
||||
/**
|
||||
* 문자열 유사도 계산 (Levenshtein distance 기반)
|
||||
*/
|
||||
const calculateStringSimilarity = (str1: string, str2: string): number => {
|
||||
const len1 = str1.length;
|
||||
const len2 = str2.length;
|
||||
|
||||
const matrix: number[][] = [];
|
||||
|
||||
for (let i = 0; i <= len1; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
|
||||
for (let j = 0; j <= len2; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= len1; i++) {
|
||||
for (let j = 1; j <= len2; j++) {
|
||||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
||||
}
|
||||
}
|
||||
|
||||
const distance = matrix[len1][len2];
|
||||
const maxLen = Math.max(len1, len2);
|
||||
|
||||
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
|
||||
};
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
/**
|
||||
* 🌐 API 연동 타입 안전성 테스트
|
||||
*
|
||||
* 실제 백엔드 API와 연동하여 타입 안전성을 검증합니다.
|
||||
*/
|
||||
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import {
|
||||
ComponentData,
|
||||
WidgetComponent,
|
||||
ScreenDefinition,
|
||||
LayoutData,
|
||||
TableInfo,
|
||||
UnifiedColumnInfo,
|
||||
ColumnTypeInfo,
|
||||
ButtonActionType,
|
||||
WebType,
|
||||
isWebType,
|
||||
isButtonActionType,
|
||||
ynToBoolean,
|
||||
booleanToYN,
|
||||
} from "@/types";
|
||||
|
||||
export class APIIntegrationTestSuite {
|
||||
/**
|
||||
* 🧪 Test 1: 테이블 정보 API 타입 안전성
|
||||
*/
|
||||
static async testTableInfoAPI() {
|
||||
console.log("🧪 API Test 1: 테이블 정보 API 타입 안전성");
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const response = await apiClient.get("/api/admin/table-management/tables", {
|
||||
params: { companyCode: "COMPANY_1" },
|
||||
});
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
const tables = response.data as TableInfo[];
|
||||
|
||||
tables.forEach((table, index) => {
|
||||
// 필수 필드 검증
|
||||
console.assert(typeof table.tableName === "string", `테이블 ${index}: tableName이 문자열이 아님`);
|
||||
console.assert(typeof table.tableLabel === "string", `테이블 ${index}: tableLabel이 문자열이 아님`);
|
||||
|
||||
if (table.columns && Array.isArray(table.columns)) {
|
||||
table.columns.forEach((column, colIndex) => {
|
||||
// 컬럼 타입 검증
|
||||
console.assert(
|
||||
typeof column.columnName === "string",
|
||||
`테이블 ${index}, 컬럼 ${colIndex}: columnName이 문자열이 아님`,
|
||||
);
|
||||
|
||||
// WebType 안전성 검증
|
||||
if (column.webType) {
|
||||
const isValidWebType = isWebType(column.webType);
|
||||
if (!isValidWebType) {
|
||||
console.warn(
|
||||
`테이블 ${table.tableName}, 컬럼 ${column.columnName}: 유효하지 않은 webType: ${column.webType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 테이블 정보 API: ${tables.length}개 테이블 검증 완료`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 정보 API 테스트 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 2: 컬럼 타입 정보 API 호환성
|
||||
*/
|
||||
static async testColumnTypeAPI() {
|
||||
console.log("🧪 API Test 2: 컬럼 타입 정보 API 호환성");
|
||||
|
||||
try {
|
||||
const response = await apiClient.get("/api/admin/table-management/columns", {
|
||||
params: {
|
||||
tableName: "user_info",
|
||||
companyCode: "COMPANY_1",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
const columns = response.data as ColumnTypeInfo[];
|
||||
|
||||
// 백엔드 타입을 프론트엔드 통합 타입으로 변환 테스트
|
||||
const unifiedColumns: UnifiedColumnInfo[] = columns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
displayName: col.displayName,
|
||||
dataType: col.dataType,
|
||||
dbType: col.dbType,
|
||||
webType: isWebType(col.webType) ? (col.webType as WebType) : "text",
|
||||
inputType: col.inputType || "direct",
|
||||
detailSettings: col.detailSettings ? JSON.parse(col.detailSettings) : {},
|
||||
description: col.description || "",
|
||||
isNullable: ynToBoolean(col.isNullable),
|
||||
isPrimaryKey: col.isPrimaryKey,
|
||||
defaultValue: col.defaultValue,
|
||||
maxLength: col.maxLength,
|
||||
companyCode: col.companyCode,
|
||||
}));
|
||||
|
||||
// 변환 검증
|
||||
unifiedColumns.forEach((unifiedCol, index) => {
|
||||
const originalCol = columns[index];
|
||||
|
||||
// WebType 변환 검증
|
||||
console.assert(isWebType(unifiedCol.webType), `컬럼 ${unifiedCol.columnName}: WebType 변환 실패`);
|
||||
|
||||
// Y/N → boolean 변환 검증
|
||||
console.assert(
|
||||
typeof unifiedCol.isNullable === "boolean",
|
||||
`컬럼 ${unifiedCol.columnName}: isNullable boolean 변환 실패`,
|
||||
);
|
||||
|
||||
// JSON 파싱 검증
|
||||
console.assert(
|
||||
typeof unifiedCol.detailSettings === "object",
|
||||
`컬럼 ${unifiedCol.columnName}: detailSettings 객체 변환 실패`,
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`✅ 컬럼 타입 API: ${unifiedColumns.length}개 컬럼 변환 완료`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 타입 API 테스트 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 3: 화면 정의 저장/불러오기 API
|
||||
*/
|
||||
static async testScreenDefinitionAPI() {
|
||||
console.log("🧪 API Test 3: 화면 정의 저장/불러오기 API");
|
||||
|
||||
try {
|
||||
// 테스트용 화면 정의 생성
|
||||
const testScreenDefinition: ScreenDefinition = {
|
||||
screenId: 9999, // 테스트용 임시 ID
|
||||
screenName: "API 테스트 화면",
|
||||
screenCode: "API_TEST_SCREEN",
|
||||
tableName: "test_table",
|
||||
tableLabel: "테스트 테이블",
|
||||
description: "API 타입 안전성 테스트용 화면",
|
||||
isActive: "Y",
|
||||
layoutData: {
|
||||
screenId: 9999,
|
||||
components: [
|
||||
{
|
||||
id: "testWidget",
|
||||
type: "widget",
|
||||
widgetType: "text",
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "테스트 입력",
|
||||
columnName: "test_column",
|
||||
required: true,
|
||||
webTypeConfig: { maxLength: 100 },
|
||||
} as WidgetComponent,
|
||||
],
|
||||
gridSettings: {
|
||||
enabled: true,
|
||||
size: 10,
|
||||
snapToGrid: true,
|
||||
showGrid: true,
|
||||
color: "#e0e0e0",
|
||||
opacity: 0.5,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 화면 정의 저장 시도
|
||||
const saveResponse = await apiClient.post("/api/admin/screen-management/screens", testScreenDefinition);
|
||||
|
||||
if (saveResponse.status === 200 || saveResponse.status === 201) {
|
||||
console.log("✅ 화면 정의 저장 성공");
|
||||
|
||||
// 저장된 화면 불러오기 시도
|
||||
const loadResponse = await apiClient.get(
|
||||
`/api/admin/screen-management/screens/${testScreenDefinition.screenId}`,
|
||||
);
|
||||
|
||||
if (loadResponse.data) {
|
||||
const loadedScreen = loadResponse.data as ScreenDefinition;
|
||||
|
||||
// 데이터 무결성 검증
|
||||
console.assert(loadedScreen.screenName === testScreenDefinition.screenName, "화면명 불일치");
|
||||
console.assert(loadedScreen.layoutData.components.length > 0, "컴포넌트 데이터 손실");
|
||||
|
||||
// 컴포넌트 타입 안전성 검증
|
||||
loadedScreen.layoutData.components.forEach((component) => {
|
||||
if (component.type === "widget") {
|
||||
const widget = component as WidgetComponent;
|
||||
console.assert(isWebType(widget.widgetType), `위젯 타입 검증 실패: ${widget.widgetType}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ 화면 정의 불러오기 및 검증 완료");
|
||||
}
|
||||
|
||||
// 테스트 데이터 정리
|
||||
try {
|
||||
await apiClient.delete(`/api/admin/screen-management/screens/${testScreenDefinition.screenId}`);
|
||||
console.log("✅ 테스트 데이터 정리 완료");
|
||||
} catch (cleanupError) {
|
||||
console.warn("⚠️ 테스트 데이터 정리 실패 (정상적일 수 있음):", cleanupError);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 정의 API 테스트 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 4: 폼 데이터 저장 API 타입 안전성
|
||||
*/
|
||||
static async testFormDataSaveAPI() {
|
||||
console.log("🧪 API Test 4: 폼 데이터 저장 API 타입 안전성");
|
||||
|
||||
try {
|
||||
// 다양한 웹타입의 폼 데이터 준비
|
||||
const formData = {
|
||||
textField: "테스트 텍스트",
|
||||
numberField: 123,
|
||||
booleanField: true,
|
||||
dateField: "2024-01-01",
|
||||
selectField: "option1",
|
||||
emailField: "test@example.com",
|
||||
};
|
||||
|
||||
// 컴포넌트 정의 (폼 구조)
|
||||
const formComponents: WidgetComponent[] = [
|
||||
{
|
||||
id: "textField",
|
||||
type: "widget",
|
||||
widgetType: "text",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "텍스트",
|
||||
columnName: "text_column",
|
||||
webTypeConfig: {},
|
||||
},
|
||||
{
|
||||
id: "numberField",
|
||||
type: "widget",
|
||||
widgetType: "number",
|
||||
position: { x: 0, y: 50 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "숫자",
|
||||
columnName: "number_column",
|
||||
webTypeConfig: {},
|
||||
},
|
||||
{
|
||||
id: "booleanField",
|
||||
type: "widget",
|
||||
widgetType: "checkbox",
|
||||
position: { x: 0, y: 100 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "체크박스",
|
||||
columnName: "boolean_column",
|
||||
webTypeConfig: {},
|
||||
},
|
||||
];
|
||||
|
||||
// 타입 안전한 데이터 변환
|
||||
const processedData: Record<string, any> = {};
|
||||
|
||||
formComponents.forEach((component) => {
|
||||
const fieldValue = formData[component.id as keyof typeof formData];
|
||||
|
||||
if (fieldValue !== undefined && component.columnName) {
|
||||
switch (component.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
processedData[component.columnName] = String(fieldValue);
|
||||
break;
|
||||
|
||||
case "number":
|
||||
processedData[component.columnName] = Number(fieldValue);
|
||||
break;
|
||||
|
||||
case "checkbox":
|
||||
case "boolean":
|
||||
processedData[component.columnName] = booleanToYN(Boolean(fieldValue));
|
||||
break;
|
||||
|
||||
case "date":
|
||||
processedData[component.columnName] = fieldValue ? String(fieldValue) : null;
|
||||
break;
|
||||
|
||||
default:
|
||||
processedData[component.columnName] = fieldValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 실제 API 호출 시뮬레이션 (일반적인 폼 저장 엔드포인트)
|
||||
console.log("📤 처리된 폼 데이터:", processedData);
|
||||
|
||||
// 타입 검증
|
||||
console.assert(typeof processedData.text_column === "string", "텍스트 필드 타입 오류");
|
||||
console.assert(typeof processedData.number_column === "number", "숫자 필드 타입 오류");
|
||||
console.assert(
|
||||
processedData.boolean_column === "Y" || processedData.boolean_column === "N",
|
||||
"불린 필드 Y/N 변환 오류",
|
||||
);
|
||||
|
||||
console.log("✅ 폼 데이터 저장 타입 안전성 검증 완료");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 폼 데이터 저장 API 테스트 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 5: 버튼 액션 실행 API 타입 안전성
|
||||
*/
|
||||
static async testButtonActionAPI() {
|
||||
console.log("🧪 API Test 5: 버튼 액션 실행 API 타입 안전성");
|
||||
|
||||
try {
|
||||
const buttonActions: ButtonActionType[] = [
|
||||
"save",
|
||||
"cancel",
|
||||
"delete",
|
||||
"edit",
|
||||
"add",
|
||||
"search",
|
||||
"reset",
|
||||
"submit",
|
||||
"close",
|
||||
"popup",
|
||||
"modal",
|
||||
"navigate",
|
||||
"control",
|
||||
];
|
||||
|
||||
// 각 버튼 액션 타입 검증
|
||||
buttonActions.forEach((action) => {
|
||||
console.assert(isButtonActionType(action), `유효하지 않은 버튼 액션: ${action}`);
|
||||
});
|
||||
|
||||
// 잘못된 액션 타입들 검증
|
||||
const invalidActions = ["insert", "update", "remove", ""];
|
||||
invalidActions.forEach((action) => {
|
||||
console.assert(!isButtonActionType(action), `잘못된 액션이 허용됨: ${action}`);
|
||||
});
|
||||
|
||||
console.log("✅ 버튼 액션 타입 안전성 검증 완료");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 버튼 액션 API 테스트 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 모든 API 연동 테스트 실행
|
||||
*/
|
||||
static async runAllAPITests() {
|
||||
console.log("🎯 API 연동 타입 안전성 테스트 시작\n");
|
||||
|
||||
const results = {
|
||||
tableInfoAPI: false,
|
||||
columnTypeAPI: false,
|
||||
screenDefinitionAPI: false,
|
||||
formDataSaveAPI: false,
|
||||
buttonActionAPI: false,
|
||||
};
|
||||
|
||||
try {
|
||||
results.tableInfoAPI = await this.testTableInfoAPI();
|
||||
results.columnTypeAPI = await this.testColumnTypeAPI();
|
||||
results.screenDefinitionAPI = await this.testScreenDefinitionAPI();
|
||||
results.formDataSaveAPI = await this.testFormDataSaveAPI();
|
||||
results.buttonActionAPI = await this.testButtonActionAPI();
|
||||
|
||||
const passedTests = Object.values(results).filter(Boolean).length;
|
||||
const totalTests = Object.keys(results).length;
|
||||
|
||||
console.log(`\n🎉 API 연동 테스트 완료: ${passedTests}/${totalTests} 통과`);
|
||||
|
||||
if (passedTests === totalTests) {
|
||||
console.log("✅ 모든 API 연동 타입 안전성 테스트 통과!");
|
||||
} else {
|
||||
console.log("⚠️ 일부 API 연동 테스트 실패");
|
||||
}
|
||||
|
||||
return {
|
||||
success: passedTests === totalTests,
|
||||
passedTests,
|
||||
totalTests,
|
||||
results,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ API 연동 테스트 실행 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
passedTests: 0,
|
||||
totalTests: Object.keys(results).length,
|
||||
results,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default APIIntegrationTestSuite;
|
||||
|
|
@ -0,0 +1,800 @@
|
|||
/**
|
||||
* 🔥 스트레스 테스트 시나리오
|
||||
*
|
||||
* 타입 시스템의 견고함을 검증하기 위한 극한 상황 테스트
|
||||
*/
|
||||
|
||||
import {
|
||||
ComponentData,
|
||||
WidgetComponent,
|
||||
WebType,
|
||||
ScreenDefinition,
|
||||
LayoutData,
|
||||
|
||||
// 타입 가드 및 유틸리티
|
||||
isWebType,
|
||||
isButtonActionType,
|
||||
isWidgetComponent,
|
||||
asWidgetComponent,
|
||||
ynToBoolean,
|
||||
booleanToYN,
|
||||
} from "@/types";
|
||||
|
||||
// 스트레스 테스트용 확장된 화면 정의
|
||||
interface TestScreenDefinition extends ScreenDefinition {
|
||||
layoutData?: LayoutData;
|
||||
}
|
||||
|
||||
export class StressTestSuite {
|
||||
private static results: Array<{
|
||||
testName: string;
|
||||
status: "passed" | "failed" | "warning";
|
||||
duration: number;
|
||||
details: string;
|
||||
metrics?: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* 🔥 Test 1: 대량 데이터 처리 스트레스 테스트
|
||||
*/
|
||||
static async testMassiveDataProcessing() {
|
||||
console.log("🔥 스트레스 테스트 1: 대량 데이터 처리");
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 10,000개의 컴포넌트 생성 및 타입 검증
|
||||
const componentCount = 10000;
|
||||
const components: ComponentData[] = [];
|
||||
const webTypes: WebType[] = ["text", "number", "date", "select", "checkbox", "textarea", "email", "decimal"];
|
||||
|
||||
console.log(`📊 ${componentCount}개의 컴포넌트 생성 중...`);
|
||||
|
||||
for (let i = 0; i < componentCount; i++) {
|
||||
const randomWebType = webTypes[i % webTypes.length];
|
||||
const component: WidgetComponent = {
|
||||
id: `stress-widget-${i}`,
|
||||
type: "widget",
|
||||
widgetType: randomWebType,
|
||||
position: { x: Math.random() * 1000, y: Math.random() * 1000 },
|
||||
size: { width: 100 + Math.random() * 200, height: 30 + Math.random() * 50 },
|
||||
label: `스트레스 테스트 컴포넌트 ${i}`,
|
||||
columnName: `stress_column_${i}`,
|
||||
required: Math.random() > 0.5,
|
||||
readonly: Math.random() > 0.7,
|
||||
webTypeConfig: {
|
||||
maxLength: Math.floor(Math.random() * 500),
|
||||
placeholder: `테스트 플레이스홀더 ${i}`,
|
||||
},
|
||||
};
|
||||
components.push(component);
|
||||
}
|
||||
|
||||
console.log("🔍 타입 검증 시작...");
|
||||
let validComponents = 0;
|
||||
let invalidComponents = 0;
|
||||
|
||||
// 모든 컴포넌트 타입 검증
|
||||
for (const component of components) {
|
||||
if (isWidgetComponent(component)) {
|
||||
const widget = asWidgetComponent(component);
|
||||
if (widget && isWebType(widget.widgetType)) {
|
||||
validComponents++;
|
||||
} else {
|
||||
invalidComponents++;
|
||||
}
|
||||
} else {
|
||||
invalidComponents++;
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
const metrics = {
|
||||
totalComponents: componentCount,
|
||||
validComponents,
|
||||
invalidComponents,
|
||||
processingTimeMs: duration,
|
||||
componentsPerSecond: Math.round(componentCount / (duration / 1000)),
|
||||
};
|
||||
|
||||
console.log("📈 대량 데이터 처리 결과:", metrics);
|
||||
|
||||
this.results.push({
|
||||
testName: "대량 데이터 처리",
|
||||
status: invalidComponents === 0 ? "passed" : "failed",
|
||||
duration,
|
||||
details: `${validComponents}/${componentCount} 컴포넌트 검증 성공`,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
this.results.push({
|
||||
testName: "대량 데이터 처리",
|
||||
status: "failed",
|
||||
duration: endTime - startTime,
|
||||
details: `오류 발생: ${error}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Test 2: 타입 오염 및 손상 시나리오
|
||||
*/
|
||||
static async testTypeCorruption() {
|
||||
console.log("🔥 스트레스 테스트 2: 타입 오염 및 손상");
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 다양한 잘못된 데이터들로 타입 시스템 공격
|
||||
const corruptedInputs = [
|
||||
// 잘못된 WebType들
|
||||
{ webType: null, expected: false },
|
||||
{ webType: undefined, expected: false },
|
||||
{ webType: "", expected: false },
|
||||
{ webType: "invalid_type", expected: false },
|
||||
{ webType: "VARCHAR(255)", expected: false },
|
||||
{ webType: "submit", expected: false }, // ButtonActionType과 혼동
|
||||
{ webType: "widget", expected: false }, // ComponentType과 혼동
|
||||
{ webType: 123, expected: false },
|
||||
{ webType: {}, expected: false },
|
||||
{ webType: [], expected: false },
|
||||
{ webType: "text", expected: true }, // 유일한 올바른 값
|
||||
|
||||
// 잘못된 ButtonActionType들
|
||||
{ buttonAction: "insert", expected: false },
|
||||
{ buttonAction: "update", expected: false },
|
||||
{ buttonAction: "remove", expected: false },
|
||||
{ buttonAction: "text", expected: false }, // WebType과 혼동
|
||||
{ buttonAction: null, expected: false },
|
||||
{ buttonAction: 456, expected: false },
|
||||
{ buttonAction: "save", expected: true }, // 올바른 값
|
||||
];
|
||||
|
||||
let passedChecks = 0;
|
||||
let failedChecks = 0;
|
||||
|
||||
console.log("🦠 타입 오염 데이터 검증 중...");
|
||||
|
||||
corruptedInputs.forEach((input, index) => {
|
||||
if ("webType" in input) {
|
||||
const isValid = isWebType(input.webType as unknown);
|
||||
if (isValid === input.expected) {
|
||||
passedChecks++;
|
||||
} else {
|
||||
failedChecks++;
|
||||
console.warn(
|
||||
`❌ WebType 검증 실패 #${index}:`,
|
||||
input.webType,
|
||||
"expected:",
|
||||
input.expected,
|
||||
"got:",
|
||||
isValid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("buttonAction" in input) {
|
||||
const isValid = isButtonActionType(input.buttonAction as unknown);
|
||||
if (isValid === input.expected) {
|
||||
passedChecks++;
|
||||
} else {
|
||||
failedChecks++;
|
||||
console.warn(
|
||||
`❌ ButtonActionType 검증 실패 #${index}:`,
|
||||
input.buttonAction,
|
||||
"expected:",
|
||||
input.expected,
|
||||
"got:",
|
||||
isValid,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 극한 메모리 오염 시뮬레이션
|
||||
const memoryCorruptionTest = () => {
|
||||
const largeString = "x".repeat(1000000); // 1MB 문자열
|
||||
const corruptedComponent = {
|
||||
id: largeString,
|
||||
type: "widget", // 올바른 타입이지만
|
||||
widgetType: largeString, // 잘못된 웹타입 (매우 긴 문자열)
|
||||
position: { x: Infinity, y: -Infinity }, // 잘못된 위치값
|
||||
size: { width: NaN, height: -1 }, // 잘못된 크기값
|
||||
label: null, // null 라벨
|
||||
// 필수 필드들이 누락됨
|
||||
};
|
||||
|
||||
// 더 엄격한 검증을 위해 실제 WidgetComponent 인터페이스와 비교
|
||||
const isValidWidget = isWidgetComponent(corruptedComponent as unknown);
|
||||
|
||||
// 추가 검증: widgetType이 유효한 WebType인지 확인
|
||||
const hasValidWebType = corruptedComponent.widgetType && isWebType(corruptedComponent.widgetType);
|
||||
|
||||
// 추가 검증: 필수 필드들이 존재하고 유효한지 확인
|
||||
const hasValidStructure =
|
||||
corruptedComponent.position &&
|
||||
typeof corruptedComponent.position.x === "number" &&
|
||||
typeof corruptedComponent.position.y === "number" &&
|
||||
!isNaN(corruptedComponent.position.x) &&
|
||||
!isNaN(corruptedComponent.position.y) &&
|
||||
corruptedComponent.size &&
|
||||
typeof corruptedComponent.size.width === "number" &&
|
||||
typeof corruptedComponent.size.height === "number" &&
|
||||
!isNaN(corruptedComponent.size.width) &&
|
||||
!isNaN(corruptedComponent.size.height) &&
|
||||
corruptedComponent.size.width > 0 &&
|
||||
corruptedComponent.size.height > 0;
|
||||
|
||||
// 모든 검증이 통과해야 true 반환 (실제로는 모두 실패해야 함)
|
||||
return isValidWidget && hasValidWebType && hasValidStructure;
|
||||
};
|
||||
|
||||
const memoryTestResult = memoryCorruptionTest();
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
const metrics = {
|
||||
totalChecks: corruptedInputs.length,
|
||||
passedChecks,
|
||||
failedChecks,
|
||||
memoryCorruptionHandled: !memoryTestResult, // 오염된 컴포넌트는 거부되어야 함
|
||||
duration,
|
||||
};
|
||||
|
||||
console.log("🦠 타입 오염 테스트 결과:", metrics);
|
||||
|
||||
this.results.push({
|
||||
testName: "타입 오염 및 손상",
|
||||
status: failedChecks === 0 && metrics.memoryCorruptionHandled ? "passed" : "failed",
|
||||
duration,
|
||||
details: `${passedChecks}/${corruptedInputs.length} 오염 데이터 차단 성공`,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
this.results.push({
|
||||
testName: "타입 오염 및 손상",
|
||||
status: "failed",
|
||||
duration: endTime - startTime,
|
||||
details: `오류 발생: ${error}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Test 3: 동시 작업 및 경합 상태 테스트
|
||||
*/
|
||||
static async testConcurrentOperations() {
|
||||
console.log("🔥 스트레스 테스트 3: 동시 작업 및 경합 상태");
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const concurrentTasks = 100;
|
||||
const operationsPerTask = 100;
|
||||
|
||||
console.log(`⚡ ${concurrentTasks}개의 동시 작업 시작 (각각 ${operationsPerTask}개 연산)...`);
|
||||
|
||||
// 동시에 실행될 작업들
|
||||
const concurrentPromises = Array.from({ length: concurrentTasks }, async (_, taskIndex) => {
|
||||
const taskResults = {
|
||||
taskIndex,
|
||||
successfulOperations: 0,
|
||||
failedOperations: 0,
|
||||
typeGuardCalls: 0,
|
||||
conversionCalls: 0,
|
||||
};
|
||||
|
||||
for (let i = 0; i < operationsPerTask; i++) {
|
||||
try {
|
||||
// 타입 가드 테스트
|
||||
const randomWebType = ["text", "number", "invalid"][Math.floor(Math.random() * 3)];
|
||||
isWebType(randomWebType as unknown);
|
||||
taskResults.typeGuardCalls++;
|
||||
|
||||
// Y/N 변환 테스트
|
||||
const randomBoolean = Math.random() > 0.5;
|
||||
const ynValue = booleanToYN(randomBoolean);
|
||||
const backToBoolean = ynToBoolean(ynValue);
|
||||
taskResults.conversionCalls++;
|
||||
|
||||
if (backToBoolean === randomBoolean) {
|
||||
taskResults.successfulOperations++;
|
||||
} else {
|
||||
taskResults.failedOperations++;
|
||||
}
|
||||
|
||||
// 컴포넌트 생성 및 타입 가드 테스트
|
||||
const component: WidgetComponent = {
|
||||
id: `concurrent-${taskIndex}-${i}`,
|
||||
type: "widget",
|
||||
widgetType: "text",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 30 },
|
||||
label: `Concurrent ${taskIndex}-${i}`,
|
||||
webTypeConfig: {},
|
||||
};
|
||||
|
||||
if (isWidgetComponent(component)) {
|
||||
taskResults.successfulOperations++;
|
||||
} else {
|
||||
taskResults.failedOperations++;
|
||||
}
|
||||
} catch {
|
||||
taskResults.failedOperations++;
|
||||
}
|
||||
}
|
||||
|
||||
return taskResults;
|
||||
});
|
||||
|
||||
// 모든 동시 작업 완료 대기
|
||||
const allResults = await Promise.all(concurrentPromises);
|
||||
|
||||
const aggregatedResults = allResults.reduce(
|
||||
(acc, result) => ({
|
||||
totalTasks: acc.totalTasks + 1,
|
||||
totalSuccessfulOperations: acc.totalSuccessfulOperations + result.successfulOperations,
|
||||
totalFailedOperations: acc.totalFailedOperations + result.failedOperations,
|
||||
totalTypeGuardCalls: acc.totalTypeGuardCalls + result.typeGuardCalls,
|
||||
totalConversionCalls: acc.totalConversionCalls + result.conversionCalls,
|
||||
}),
|
||||
{
|
||||
totalTasks: 0,
|
||||
totalSuccessfulOperations: 0,
|
||||
totalFailedOperations: 0,
|
||||
totalTypeGuardCalls: 0,
|
||||
totalConversionCalls: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
const metrics = {
|
||||
...aggregatedResults,
|
||||
concurrentTasks,
|
||||
operationsPerTask,
|
||||
totalOperations: concurrentTasks * operationsPerTask * 3, // 각 루프에서 3개 연산
|
||||
duration,
|
||||
operationsPerSecond: Math.round(
|
||||
(aggregatedResults.totalSuccessfulOperations + aggregatedResults.totalFailedOperations) / (duration / 1000),
|
||||
),
|
||||
successRate:
|
||||
(aggregatedResults.totalSuccessfulOperations /
|
||||
(aggregatedResults.totalSuccessfulOperations + aggregatedResults.totalFailedOperations)) *
|
||||
100,
|
||||
};
|
||||
|
||||
console.log("⚡ 동시 작업 테스트 결과:", metrics);
|
||||
|
||||
this.results.push({
|
||||
testName: "동시 작업 및 경합 상태",
|
||||
status: metrics.successRate > 95 ? "passed" : "failed",
|
||||
duration,
|
||||
details: `${metrics.successRate.toFixed(2)}% 성공률`,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
this.results.push({
|
||||
testName: "동시 작업 및 경합 상태",
|
||||
status: "failed",
|
||||
duration: endTime - startTime,
|
||||
details: `오류 발생: ${error}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Test 4: 메모리 부하 및 가비지 컬렉션 스트레스
|
||||
*/
|
||||
static async testMemoryStress() {
|
||||
console.log("🔥 스트레스 테스트 4: 메모리 부하 및 가비지 컬렉션");
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const iterations = 1000;
|
||||
const objectsPerIteration = 1000;
|
||||
|
||||
console.log(`🧠 메모리 스트레스 테스트: ${iterations}회 반복, 매회 ${objectsPerIteration}개 객체 생성`);
|
||||
|
||||
let totalObjectsCreated = 0;
|
||||
let gcTriggered = 0;
|
||||
|
||||
// 메모리 사용량 모니터링 (가능한 경우)
|
||||
const initialMemory =
|
||||
(performance as unknown as { memory?: { usedJSHeapSize: number } }).memory?.usedJSHeapSize || 0;
|
||||
|
||||
for (let iteration = 0; iteration < iterations; iteration++) {
|
||||
// 대량의 객체 생성
|
||||
const tempObjects: ComponentData[] = [];
|
||||
|
||||
for (let i = 0; i < objectsPerIteration; i++) {
|
||||
const largeComponent: WidgetComponent = {
|
||||
id: `memory-stress-${iteration}-${i}`,
|
||||
type: "widget",
|
||||
widgetType: "textarea",
|
||||
position: { x: Math.random() * 10000, y: Math.random() * 10000 },
|
||||
size: { width: Math.random() * 1000, height: Math.random() * 1000 },
|
||||
label: "메모리 스트레스 테스트 컴포넌트 ".repeat(10), // 긴 문자열
|
||||
columnName: `stress_test_column_with_very_long_name_${iteration}_${i}`,
|
||||
placeholder: "매우 긴 플레이스홀더 텍스트 ".repeat(20),
|
||||
webTypeConfig: {
|
||||
maxLength: 10000,
|
||||
rows: 50,
|
||||
placeholder: "대용량 텍스트 영역 ".repeat(50),
|
||||
validation: {
|
||||
pattern: "매우 복잡한 정규식 패턴 ".repeat(10),
|
||||
errorMessage: "복잡한 오류 메시지 ".repeat(10),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 타입 검증
|
||||
if (isWidgetComponent(largeComponent)) {
|
||||
tempObjects.push(largeComponent);
|
||||
totalObjectsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
// 더 적극적인 메모리 해제 (가비지 컬렉션 유도)
|
||||
if (iteration % 50 === 0) {
|
||||
// 더 자주 정리 (100 → 50)
|
||||
tempObjects.length = 0; // 배열 초기화
|
||||
|
||||
// 강제적인 가비지 컬렉션 힌트 제공
|
||||
if (typeof global !== "undefined" && (global as unknown as { gc?: () => void }).gc) {
|
||||
(global as unknown as { gc: () => void }).gc();
|
||||
gcTriggered++;
|
||||
}
|
||||
|
||||
// 추가적인 메모리 정리 시뮬레이션
|
||||
// 큰 객체들을 null로 설정하여 참조 해제
|
||||
for (let cleanupIndex = 0; cleanupIndex < 10; cleanupIndex++) {
|
||||
const dummyCleanup = new Array(1000).fill(null);
|
||||
dummyCleanup.length = 0;
|
||||
}
|
||||
|
||||
console.log(`🗑️ 가비지 컬렉션 시뮬레이션 (반복 ${iteration}/${iterations})`);
|
||||
}
|
||||
}
|
||||
|
||||
const finalMemory =
|
||||
(performance as unknown as { memory?: { usedJSHeapSize: number } }).memory?.usedJSHeapSize || 0;
|
||||
const memoryDelta = finalMemory - initialMemory;
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
const metrics = {
|
||||
iterations,
|
||||
objectsPerIteration,
|
||||
totalObjectsCreated,
|
||||
gcTriggered,
|
||||
initialMemoryBytes: initialMemory,
|
||||
finalMemoryBytes: finalMemory,
|
||||
memoryDeltaBytes: memoryDelta,
|
||||
memoryDeltaMB: Math.round((memoryDelta / (1024 * 1024)) * 100) / 100,
|
||||
duration,
|
||||
objectsPerSecond: Math.round(totalObjectsCreated / (duration / 1000)),
|
||||
};
|
||||
|
||||
console.log("🧠 메모리 스트레스 테스트 결과:", metrics);
|
||||
|
||||
// 메모리 누수 체크 (매우 단순한 휴리스틱)
|
||||
const suspectedMemoryLeak = metrics.memoryDeltaMB > 100; // 100MB 이상 증가 시 의심
|
||||
|
||||
this.results.push({
|
||||
testName: "메모리 부하 및 가비지 컬렉션",
|
||||
status: suspectedMemoryLeak ? "warning" : "passed",
|
||||
duration,
|
||||
details: `${metrics.totalObjectsCreated}개 객체 생성, 메모리 변화: ${metrics.memoryDeltaMB}MB`,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
this.results.push({
|
||||
testName: "메모리 부하 및 가비지 컬렉션",
|
||||
status: "failed",
|
||||
duration: endTime - startTime,
|
||||
details: `오류 발생: ${error}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Test 5: API 스트레스 및 네트워크 시뮬레이션
|
||||
*/
|
||||
static async testAPIStress() {
|
||||
console.log("🔥 스트레스 테스트 5: API 스트레스 및 네트워크 시뮬레이션");
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 대량의 API 요청 시뮬레이션
|
||||
const apiCalls = 100;
|
||||
const batchSize = 10;
|
||||
|
||||
console.log(`🌐 ${apiCalls}개의 API 호출을 ${batchSize}개씩 배치로 처리...`);
|
||||
|
||||
let successfulCalls = 0;
|
||||
let failedCalls = 0;
|
||||
const responseTimes: number[] = [];
|
||||
|
||||
// 배치별로 API 호출 시뮬레이션
|
||||
for (let batch = 0; batch < Math.ceil(apiCalls / batchSize); batch++) {
|
||||
const batchPromises = [];
|
||||
|
||||
for (let i = 0; i < batchSize && batch * batchSize + i < apiCalls; i++) {
|
||||
const callIndex = batch * batchSize + i;
|
||||
|
||||
// API 호출 시뮬레이션 (실제로는 타입 처리 로직)
|
||||
const apiCallSimulation = async () => {
|
||||
const callStart = performance.now();
|
||||
|
||||
try {
|
||||
// 복잡한 데이터 구조 생성 및 검증
|
||||
const components: ComponentData[] = Array.from(
|
||||
{ length: 50 },
|
||||
(_, idx) =>
|
||||
({
|
||||
id: `stress-component-${callIndex}-${idx}`,
|
||||
type: "widget" as const,
|
||||
widgetType: "text" as WebType,
|
||||
position: { x: idx * 10, y: idx * 5 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: `컴포넌트 ${idx}`,
|
||||
webTypeConfig: {},
|
||||
}) as WidgetComponent,
|
||||
);
|
||||
|
||||
const complexScreenData: TestScreenDefinition = {
|
||||
screenId: callIndex,
|
||||
screenName: `스트레스 테스트 화면 ${callIndex}`,
|
||||
screenCode: `STRESS_SCREEN_${callIndex}`,
|
||||
tableName: `stress_table_${callIndex}`,
|
||||
tableLabel: `스트레스 테이블 ${callIndex}`,
|
||||
companyCode: "COMPANY_1",
|
||||
description: `API 스트레스 테스트용 화면 ${callIndex}`,
|
||||
isActive: Math.random() > 0.5 ? "Y" : "N",
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
layoutData: {
|
||||
screenId: callIndex,
|
||||
components,
|
||||
gridSettings: {
|
||||
enabled: true,
|
||||
size: 10,
|
||||
color: "#e0e0e0",
|
||||
opacity: 0.5,
|
||||
snapToGrid: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 모든 컴포넌트 타입 검증
|
||||
let validComponents = 0;
|
||||
if (complexScreenData.layoutData?.components) {
|
||||
for (const component of complexScreenData.layoutData.components) {
|
||||
if (isWidgetComponent(component)) {
|
||||
validComponents++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const callEnd = performance.now();
|
||||
const responseTime = callEnd - callStart;
|
||||
responseTimes.push(responseTime);
|
||||
|
||||
const totalComponents = complexScreenData.layoutData?.components?.length || 0;
|
||||
if (validComponents === totalComponents) {
|
||||
successfulCalls++;
|
||||
return { success: true, responseTime, validComponents };
|
||||
} else {
|
||||
failedCalls++;
|
||||
return { success: false, responseTime, validComponents };
|
||||
}
|
||||
} catch (error) {
|
||||
const callEnd = performance.now();
|
||||
const responseTime = callEnd - callStart;
|
||||
responseTimes.push(responseTime);
|
||||
failedCalls++;
|
||||
return { success: false, responseTime, error };
|
||||
}
|
||||
};
|
||||
|
||||
batchPromises.push(apiCallSimulation());
|
||||
}
|
||||
|
||||
// 배치 완료 대기
|
||||
await Promise.all(batchPromises);
|
||||
|
||||
// 배치 간 짧은 대기 (실제 네트워크 지연 시뮬레이션)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// 응답 시간 통계
|
||||
const avgResponseTime = responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length;
|
||||
const maxResponseTime = Math.max(...responseTimes);
|
||||
const minResponseTime = Math.min(...responseTimes);
|
||||
|
||||
const metrics = {
|
||||
totalAPICalls: apiCalls,
|
||||
successfulCalls,
|
||||
failedCalls,
|
||||
successRate: (successfulCalls / apiCalls) * 100,
|
||||
avgResponseTimeMs: Math.round(avgResponseTime * 100) / 100,
|
||||
maxResponseTimeMs: Math.round(maxResponseTime * 100) / 100,
|
||||
minResponseTimeMs: Math.round(minResponseTime * 100) / 100,
|
||||
totalDuration: duration,
|
||||
callsPerSecond: Math.round(apiCalls / (duration / 1000)),
|
||||
};
|
||||
|
||||
console.log("🌐 API 스트레스 테스트 결과:", metrics);
|
||||
|
||||
this.results.push({
|
||||
testName: "API 스트레스 및 네트워크 시뮬레이션",
|
||||
status: metrics.successRate > 95 ? "passed" : "failed",
|
||||
duration,
|
||||
details: `${metrics.successRate.toFixed(2)}% 성공률, 평균 응답시간: ${metrics.avgResponseTimeMs}ms`,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
this.results.push({
|
||||
testName: "API 스트레스 및 네트워크 시뮬레이션",
|
||||
status: "failed",
|
||||
duration: endTime - startTime,
|
||||
details: `오류 발생: ${error}`,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 모든 스트레스 테스트 실행
|
||||
*/
|
||||
static async runAllStressTests() {
|
||||
console.log("🎯 스트레스 테스트 스위트 시작");
|
||||
console.log("⚠️ 시스템에 높은 부하를 가할 예정입니다...\n");
|
||||
|
||||
const overallStart = performance.now();
|
||||
this.results = []; // 결과 초기화
|
||||
|
||||
try {
|
||||
// 1. 대량 데이터 처리
|
||||
console.log("=".repeat(60));
|
||||
await this.testMassiveDataProcessing();
|
||||
|
||||
// 2. 타입 오염 및 손상
|
||||
console.log("=".repeat(60));
|
||||
await this.testTypeCorruption();
|
||||
|
||||
// 3. 동시 작업 및 경합 상태
|
||||
console.log("=".repeat(60));
|
||||
await this.testConcurrentOperations();
|
||||
|
||||
// 4. 메모리 부하
|
||||
console.log("=".repeat(60));
|
||||
await this.testMemoryStress();
|
||||
|
||||
// 5. API 스트레스
|
||||
console.log("=".repeat(60));
|
||||
await this.testAPIStress();
|
||||
|
||||
const overallEnd = performance.now();
|
||||
const totalDuration = overallEnd - overallStart;
|
||||
|
||||
// 결과 분석
|
||||
const passedTests = this.results.filter((r) => r.status === "passed").length;
|
||||
const failedTests = this.results.filter((r) => r.status === "failed").length;
|
||||
const warningTests = this.results.filter((r) => r.status === "warning").length;
|
||||
|
||||
console.log("\n" + "=".repeat(60));
|
||||
console.log("🎉 스트레스 테스트 완료!");
|
||||
console.log("=".repeat(60));
|
||||
console.log(`📊 총 테스트: ${this.results.length}`);
|
||||
console.log(`✅ 통과: ${passedTests}`);
|
||||
console.log(`❌ 실패: ${failedTests}`);
|
||||
console.log(`⚠️ 경고: ${warningTests}`);
|
||||
console.log(`⏱️ 총 소요시간: ${Math.round(totalDuration)}ms`);
|
||||
console.log("");
|
||||
|
||||
// 개별 테스트 결과 출력
|
||||
this.results.forEach((result, index) => {
|
||||
const statusIcon = result.status === "passed" ? "✅" : result.status === "failed" ? "❌" : "⚠️";
|
||||
console.log(`${statusIcon} ${index + 1}. ${result.testName}`);
|
||||
console.log(` └─ ${result.details} (${Math.round(result.duration)}ms)`);
|
||||
});
|
||||
|
||||
return {
|
||||
success: failedTests === 0,
|
||||
totalTests: this.results.length,
|
||||
passedTests,
|
||||
failedTests,
|
||||
warningTests,
|
||||
totalDuration,
|
||||
results: this.results,
|
||||
recommendation: this.generateRecommendations(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ 스트레스 테스트 실행 중 치명적 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
results: this.results,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 테스트 결과 기반 권장사항 생성
|
||||
*/
|
||||
private static generateRecommendations(): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
this.results.forEach((result) => {
|
||||
if (result.status === "failed") {
|
||||
recommendations.push(`🔧 ${result.testName}: 실패 원인을 분석하고 타입 시스템을 강화하세요.`);
|
||||
}
|
||||
|
||||
if (result.status === "warning") {
|
||||
recommendations.push(`⚠️ ${result.testName}: 잠재적 문제가 감지되었습니다. 모니터링을 강화하세요.`);
|
||||
}
|
||||
|
||||
if (result.metrics) {
|
||||
// 성능 기반 권장사항
|
||||
if (result.metrics.operationsPerSecond && result.metrics.operationsPerSecond < 1000) {
|
||||
recommendations.push(
|
||||
`⚡ ${result.testName}: 성능 최적화를 고려하세요 (${result.metrics.operationsPerSecond} ops/sec).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.metrics.memoryDeltaMB && result.metrics.memoryDeltaMB > 50) {
|
||||
recommendations.push(
|
||||
`🧠 ${result.testName}: 메모리 사용량 최적화를 권장합니다 (${result.metrics.memoryDeltaMB}MB 증가).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.metrics.successRate && result.metrics.successRate < 99) {
|
||||
recommendations.push(
|
||||
`🎯 ${result.testName}: 성공률 개선이 필요합니다 (${result.metrics.successRate.toFixed(2)}%).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push("🎉 모든 스트레스 테스트를 성공적으로 통과했습니다! 타입 시스템이 매우 견고합니다.");
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 테스트 결과 반환 (외부에서 접근 가능)
|
||||
*/
|
||||
static getResults() {
|
||||
return this.results;
|
||||
}
|
||||
}
|
||||
|
||||
export default StressTestSuite;
|
||||
|
|
@ -0,0 +1,541 @@
|
|||
/**
|
||||
* 🧪 타입 안전성 종합 테스트 시나리오
|
||||
*
|
||||
* 화면관리, 제어관리, 테이블 타입관리 시스템의 타입 안전성을 검증합니다.
|
||||
* 실제 사용자 시나리오에서 발생할 수 있는 모든 타입 오류 상황을 테스트합니다.
|
||||
*/
|
||||
|
||||
import {
|
||||
ComponentData,
|
||||
WebType,
|
||||
ButtonActionType,
|
||||
WidgetComponent,
|
||||
ContainerComponent,
|
||||
|
||||
// 타입 가드 함수들
|
||||
isWidgetComponent,
|
||||
isContainerComponent,
|
||||
isWebType,
|
||||
isButtonActionType,
|
||||
|
||||
// 안전한 캐스팅 함수들
|
||||
asWidgetComponent,
|
||||
asContainerComponent,
|
||||
|
||||
// 변환 함수들
|
||||
ynToBoolean,
|
||||
booleanToYN,
|
||||
|
||||
// 테이블 관련
|
||||
UnifiedColumnInfo,
|
||||
ColumnTypeInfo,
|
||||
|
||||
// 제어 관련
|
||||
ExtendedButtonTypeConfig,
|
||||
|
||||
// 화면 관련
|
||||
ScreenDefinition,
|
||||
GroupState,
|
||||
} from "@/types";
|
||||
|
||||
// ===== 1단계: 기본 타입 검증 테스트 =====
|
||||
|
||||
export class TypeSafetyTestSuite {
|
||||
/**
|
||||
* 🧪 Test 1: WebType 타입 안전성 검증
|
||||
*/
|
||||
static testWebTypeValidation() {
|
||||
console.log("🧪 Test 1: WebType 타입 안전성 검증");
|
||||
|
||||
// 유효한 WebType들
|
||||
const validWebTypes = [
|
||||
"text",
|
||||
"number",
|
||||
"decimal",
|
||||
"date",
|
||||
"datetime",
|
||||
"select",
|
||||
"dropdown",
|
||||
"radio",
|
||||
"checkbox",
|
||||
"boolean",
|
||||
"textarea",
|
||||
"code",
|
||||
"entity",
|
||||
"file",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"button",
|
||||
];
|
||||
|
||||
// 무효한 타입들 (기존 시스템에서 문제가 되었던 것들)
|
||||
const invalidWebTypes = [
|
||||
"text_area", // 기존에 사용되던 잘못된 타입
|
||||
"VARCHAR", // DB 타입과 혼동
|
||||
"submit", // ButtonActionType과 혼동
|
||||
"container", // ComponentType과 혼동
|
||||
"",
|
||||
null,
|
||||
undefined,
|
||||
];
|
||||
|
||||
validWebTypes.forEach((type) => {
|
||||
const isValid = isWebType(type);
|
||||
console.assert(isValid, `유효한 WebType이 거부됨: ${type}`);
|
||||
if (isValid) {
|
||||
console.log(`✅ Valid WebType: ${type}`);
|
||||
}
|
||||
});
|
||||
|
||||
invalidWebTypes.forEach((type) => {
|
||||
const isValid = isWebType(type as any);
|
||||
console.assert(!isValid, `무효한 WebType이 허용됨: ${type}`);
|
||||
if (!isValid) {
|
||||
console.log(`❌ Invalid WebType rejected: ${type}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 2: ComponentData 타입 가드 안전성
|
||||
*/
|
||||
static testComponentTypeGuards() {
|
||||
console.log("\n🧪 Test 2: ComponentData 타입 가드 안전성");
|
||||
|
||||
// 올바른 컴포넌트 생성
|
||||
const widgetComponent: WidgetComponent = {
|
||||
id: "widget-1",
|
||||
type: "widget",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 200, height: 40 },
|
||||
widgetType: "text",
|
||||
label: "테스트 텍스트",
|
||||
placeholder: "입력하세요",
|
||||
required: false,
|
||||
readonly: false,
|
||||
webTypeConfig: {},
|
||||
};
|
||||
|
||||
const containerComponent: ContainerComponent = {
|
||||
id: "container-1",
|
||||
type: "container",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 400, height: 300 },
|
||||
label: "컨테이너",
|
||||
children: [],
|
||||
layoutDirection: "vertical",
|
||||
};
|
||||
|
||||
// 타입 가드 테스트
|
||||
console.assert(isWidgetComponent(widgetComponent), "WidgetComponent 타입 가드 실패");
|
||||
console.assert(isContainerComponent(containerComponent), "ContainerComponent 타입 가드 실패");
|
||||
console.assert(!isWidgetComponent(containerComponent), "잘못된 타입이 통과됨");
|
||||
console.assert(!isContainerComponent(widgetComponent), "잘못된 타입이 통과됨");
|
||||
|
||||
// 안전한 캐스팅 테스트
|
||||
const safeWidget = asWidgetComponent(widgetComponent);
|
||||
const safeContainer = asContainerComponent(containerComponent);
|
||||
|
||||
console.assert(safeWidget !== null, "안전한 위젯 캐스팅 실패");
|
||||
console.assert(safeContainer !== null, "안전한 컨테이너 캐스팅 실패");
|
||||
console.assert(asWidgetComponent(containerComponent) === null, "잘못된 캐스팅이 허용됨");
|
||||
|
||||
console.log("✅ Component 타입 가드 모든 테스트 통과");
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 3: DB 호환성 (Y/N ↔ boolean) 변환 테스트
|
||||
*/
|
||||
static testYNBooleanConversion() {
|
||||
console.log("\n🧪 Test 3: DB 호환성 Y/N ↔ boolean 변환 테스트");
|
||||
|
||||
// Y/N → boolean 변환
|
||||
console.assert(ynToBoolean("Y") === true, "Y → true 변환 실패");
|
||||
console.assert(ynToBoolean("N") === false, "N → false 변환 실패");
|
||||
console.assert(ynToBoolean("") === false, "빈 문자열 → false 변환 실패");
|
||||
console.assert(ynToBoolean(undefined) === false, "undefined → false 변환 실패");
|
||||
|
||||
// boolean → Y/N 변환
|
||||
console.assert(booleanToYN(true) === "Y", "true → Y 변환 실패");
|
||||
console.assert(booleanToYN(false) === "N", "false → N 변환 실패");
|
||||
|
||||
// 실제 DB 시나리오 시뮬레이션
|
||||
const dbColumnData = {
|
||||
isActive: "Y",
|
||||
isVisible: "N",
|
||||
isPrimaryKey: "Y",
|
||||
isNullable: "N",
|
||||
};
|
||||
|
||||
const convertedData = {
|
||||
isActive: ynToBoolean(dbColumnData.isActive),
|
||||
isVisible: ynToBoolean(dbColumnData.isVisible),
|
||||
isPrimaryKey: ynToBoolean(dbColumnData.isPrimaryKey),
|
||||
isNullable: ynToBoolean(dbColumnData.isNullable),
|
||||
};
|
||||
|
||||
console.assert(convertedData.isActive === true, "DB isActive 변환 실패");
|
||||
console.assert(convertedData.isVisible === false, "DB isVisible 변환 실패");
|
||||
|
||||
console.log("✅ Y/N ↔ boolean 변환 모든 테스트 통과");
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 4: 실제 폼 저장 시나리오 시뮬레이션
|
||||
*/
|
||||
static async testFormSaveScenarios() {
|
||||
console.log("\n🧪 Test 4: 실제 폼 저장 시나리오 시뮬레이션");
|
||||
|
||||
// 시나리오 1: 혼합 웹타입 폼 데이터
|
||||
const formData = {
|
||||
userName: "홍길동",
|
||||
userAge: 25,
|
||||
userEmail: "hong@example.com",
|
||||
isActive: true,
|
||||
birthDate: "1999-01-01",
|
||||
userRole: "admin",
|
||||
description: "테스트 사용자입니다.",
|
||||
};
|
||||
|
||||
// 시나리오 2: 컴포넌트별 웹타입 매핑
|
||||
const formComponents: ComponentData[] = [
|
||||
{
|
||||
id: "userName",
|
||||
type: "widget",
|
||||
widgetType: "text",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "사용자명",
|
||||
columnName: "user_name",
|
||||
webTypeConfig: {},
|
||||
} as WidgetComponent,
|
||||
{
|
||||
id: "userAge",
|
||||
type: "widget",
|
||||
widgetType: "number",
|
||||
position: { x: 0, y: 50 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "나이",
|
||||
columnName: "user_age",
|
||||
webTypeConfig: { min: 0, max: 120 },
|
||||
} as WidgetComponent,
|
||||
{
|
||||
id: "isActive",
|
||||
type: "widget",
|
||||
widgetType: "checkbox",
|
||||
position: { x: 0, y: 100 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "활성화",
|
||||
columnName: "is_active",
|
||||
webTypeConfig: {},
|
||||
} as WidgetComponent,
|
||||
];
|
||||
|
||||
// 타입 안전한 데이터 처리
|
||||
const processedData: Record<string, any> = {};
|
||||
|
||||
for (const component of formComponents) {
|
||||
if (isWidgetComponent(component)) {
|
||||
const { columnName, widgetType } = component;
|
||||
|
||||
if (columnName && widgetType && formData.hasOwnProperty(component.id)) {
|
||||
const rawValue = formData[component.id as keyof typeof formData];
|
||||
|
||||
// 웹타입별 안전한 변환
|
||||
switch (widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "textarea":
|
||||
processedData[columnName] = String(rawValue);
|
||||
break;
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
processedData[columnName] = Number(rawValue);
|
||||
break;
|
||||
|
||||
case "checkbox":
|
||||
case "boolean":
|
||||
processedData[columnName] = booleanToYN(Boolean(rawValue));
|
||||
break;
|
||||
|
||||
case "date":
|
||||
case "datetime":
|
||||
processedData[columnName] = rawValue ? String(rawValue) : null;
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`처리되지 않은 웹타입: ${widgetType}`);
|
||||
processedData[columnName] = rawValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 폼 데이터 타입 안전 변환:", processedData);
|
||||
|
||||
// 검증: 모든 값이 올바른 타입으로 변환되었는지 확인
|
||||
console.assert(typeof processedData.user_name === "string", "사용자명 타입 변환 실패");
|
||||
console.assert(typeof processedData.user_age === "number", "나이 타입 변환 실패");
|
||||
console.assert(processedData.is_active === "Y" || processedData.is_active === "N", "활성화 상태 변환 실패");
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 5: 버튼 제어관리 타입 안전성 테스트
|
||||
*/
|
||||
static testButtonControlTypesSafety() {
|
||||
console.log("\n🧪 Test 5: 버튼 제어관리 타입 안전성 테스트");
|
||||
|
||||
// ButtonActionType 안전성 검증
|
||||
const validActions: ButtonActionType[] = [
|
||||
"save",
|
||||
"cancel",
|
||||
"delete",
|
||||
"edit",
|
||||
"add",
|
||||
"search",
|
||||
"reset",
|
||||
"submit",
|
||||
"close",
|
||||
"popup",
|
||||
"modal",
|
||||
"navigate",
|
||||
"control",
|
||||
];
|
||||
|
||||
validActions.forEach((action) => {
|
||||
console.assert(isButtonActionType(action), `유효한 ButtonActionType 거부: ${action}`);
|
||||
});
|
||||
|
||||
// 무효한 액션 타입들
|
||||
const invalidActions = ["insert", "update", "remove", ""];
|
||||
invalidActions.forEach((action) => {
|
||||
console.assert(!isButtonActionType(action), `무효한 ButtonActionType 허용: ${action}`);
|
||||
});
|
||||
|
||||
console.log("✅ 버튼 제어관리 타입 안전성 테스트 통과");
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 6: 테이블 컬럼 정보 타입 호환성 테스트
|
||||
*/
|
||||
static testTableColumnTypeCompatibility() {
|
||||
console.log("\n🧪 Test 6: 테이블 컬럼 정보 타입 호환성 테스트");
|
||||
|
||||
// 백엔드에서 받은 원시 컬럼 정보 (ColumnTypeInfo)
|
||||
const backendColumnInfo: ColumnTypeInfo = {
|
||||
columnName: "user_name",
|
||||
displayName: "사용자명",
|
||||
dataType: "varchar",
|
||||
dbType: "character varying(100)",
|
||||
webType: "text", // string 타입 (백엔드)
|
||||
inputType: "direct",
|
||||
detailSettings: JSON.stringify({ maxLength: 100 }),
|
||||
description: "사용자의 이름을 저장하는 컬럼",
|
||||
isNullable: "N", // Y/N 문자열
|
||||
isPrimaryKey: false,
|
||||
defaultValue: "",
|
||||
maxLength: 100,
|
||||
};
|
||||
|
||||
// 프론트엔드 통합 컬럼 정보로 변환 (UnifiedColumnInfo)
|
||||
const unifiedColumnInfo: UnifiedColumnInfo = {
|
||||
columnName: backendColumnInfo.columnName,
|
||||
displayName: backendColumnInfo.displayName,
|
||||
dataType: backendColumnInfo.dataType,
|
||||
dbType: backendColumnInfo.dbType,
|
||||
webType: isWebType(backendColumnInfo.webType) ? (backendColumnInfo.webType as WebType) : "text", // 안전한 타입 변환
|
||||
inputType: backendColumnInfo.inputType,
|
||||
detailSettings: JSON.parse(backendColumnInfo.detailSettings || "{}"),
|
||||
description: backendColumnInfo.description,
|
||||
isNullable: ynToBoolean(backendColumnInfo.isNullable), // Y/N → boolean
|
||||
isPrimaryKey: backendColumnInfo.isPrimaryKey,
|
||||
defaultValue: backendColumnInfo.defaultValue,
|
||||
maxLength: backendColumnInfo.maxLength,
|
||||
companyCode: backendColumnInfo.companyCode,
|
||||
};
|
||||
|
||||
// 검증
|
||||
console.assert(isWebType(unifiedColumnInfo.webType), "WebType 변환 실패");
|
||||
console.assert(typeof unifiedColumnInfo.isNullable === "boolean", "isNullable 타입 변환 실패");
|
||||
console.assert(typeof unifiedColumnInfo.detailSettings === "object", "detailSettings JSON 파싱 실패");
|
||||
|
||||
console.log("✅ 테이블 컬럼 타입 호환성 테스트 통과");
|
||||
console.log("변환된 컬럼 정보:", unifiedColumnInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧪 Test 7: 복합 시나리오 - 화면 설계 + 데이터 저장 + 제어 실행
|
||||
*/
|
||||
static async testComplexScenario() {
|
||||
console.log("\n🧪 Test 7: 복합 시나리오 - 화면 설계 + 데이터 저장 + 제어 실행");
|
||||
|
||||
try {
|
||||
// Step 1: 화면 정의 생성 (단순화된 버전)
|
||||
const screenDefinition: ScreenDefinition = {
|
||||
screenId: 1001,
|
||||
screenName: "사용자 관리",
|
||||
screenCode: "USER_MANAGEMENT",
|
||||
tableName: "user_info",
|
||||
tableLabel: "사용자 정보",
|
||||
description: "사용자 정보를 관리하는 화면",
|
||||
isActive: "Y",
|
||||
};
|
||||
|
||||
// 개별 컴포넌트 생성
|
||||
const components: ComponentData[] = [
|
||||
{
|
||||
id: "userName",
|
||||
type: "widget",
|
||||
widgetType: "text",
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: "사용자명",
|
||||
columnName: "user_name",
|
||||
required: true,
|
||||
webTypeConfig: { maxLength: 50 },
|
||||
} as WidgetComponent,
|
||||
];
|
||||
|
||||
// Step 2: 컴포넌트 타입 안전성 검증
|
||||
components.forEach((component) => {
|
||||
if (isWidgetComponent(component)) {
|
||||
console.assert(isWebType(component.widgetType), `잘못된 위젯타입: ${component.widgetType}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 3: 그룹 상태 시뮬레이션
|
||||
const groupState: GroupState = {
|
||||
isGrouping: true,
|
||||
selectedComponents: ["userName", "saveButton"],
|
||||
groupTarget: "userForm",
|
||||
groupMode: "create",
|
||||
groupTitle: "사용자 입력 폼",
|
||||
};
|
||||
|
||||
// Step 4: 실제 저장 시뮬레이션
|
||||
const formData = {
|
||||
userName: "테스트 사용자",
|
||||
userEmail: "test@example.com",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
console.log("✅ 복합 시나리오 모든 단계 성공");
|
||||
console.log("- 화면 정의 생성: ✓");
|
||||
console.log("- 컴포넌트 타입 검증: ✓");
|
||||
console.log("- 그룹 상태 관리: ✓");
|
||||
console.log("- 데이터 저장 시뮬레이션: ✓");
|
||||
} catch (error) {
|
||||
console.error("❌ 복합 시나리오 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 모든 테스트 실행
|
||||
*/
|
||||
static async runAllTests() {
|
||||
console.log("🎯 타입 안전성 종합 테스트 시작\n");
|
||||
|
||||
try {
|
||||
this.testWebTypeValidation();
|
||||
this.testComponentTypeGuards();
|
||||
this.testYNBooleanConversion();
|
||||
await this.testFormSaveScenarios();
|
||||
this.testButtonControlTypesSafety();
|
||||
this.testTableColumnTypeCompatibility();
|
||||
await this.testComplexScenario();
|
||||
|
||||
console.log("\n🎉 모든 타입 안전성 테스트 통과!");
|
||||
console.log("✅ 화면관리, 제어관리, 테이블타입관리 시스템의 타입 안전성이 보장됩니다.");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
passedTests: 7,
|
||||
failedTests: 0,
|
||||
message: "모든 타입 안전성 테스트 통과",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ 타입 안전성 테스트 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
passedTests: 0,
|
||||
failedTests: 1,
|
||||
message: `테스트 실패: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 스트레스 테스트 시나리오
|
||||
export class StressTestScenarios {
|
||||
/**
|
||||
* 🔥 극한 상황 테스트: 잘못된 타입들의 혼재
|
||||
*/
|
||||
static testMixedInvalidTypes() {
|
||||
console.log("🔥 극한 상황 테스트: 잘못된 타입들의 혼재");
|
||||
|
||||
// API로부터 받을 수 있는 다양한 잘못된 데이터들
|
||||
const corruptedData = [
|
||||
{ webType: "text_area", expected: false }, // 기존 잘못된 타입
|
||||
{ webType: "VARCHAR(255)", expected: false }, // DB 타입 혼입
|
||||
{ webType: "submit", expected: false }, // ButtonActionType 혼입
|
||||
{ webType: "", expected: false }, // 빈 문자열
|
||||
{ webType: null, expected: false }, // null
|
||||
{ webType: undefined, expected: false }, // undefined
|
||||
{ webType: 123, expected: false }, // 숫자
|
||||
{ webType: {}, expected: false }, // 객체
|
||||
{ webType: "text", expected: true }, // 올바른 타입
|
||||
];
|
||||
|
||||
corruptedData.forEach(({ webType, expected }) => {
|
||||
const result = isWebType(webType as any);
|
||||
console.assert(
|
||||
result === expected,
|
||||
`타입 검증 실패: ${JSON.stringify(webType)} → expected: ${expected}, got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
console.log("✅ 극한 상황 타입 검증 테스트 통과");
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 대량 데이터 처리 시나리오
|
||||
*/
|
||||
static testBulkDataProcessing() {
|
||||
console.log("🔥 대량 데이터 처리 시나리오");
|
||||
|
||||
// 1000개의 컴포넌트 생성 및 타입 검증
|
||||
const components: ComponentData[] = [];
|
||||
const webTypes: WebType[] = ["text", "number", "date", "select", "checkbox"];
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const randomWebType = webTypes[i % webTypes.length];
|
||||
const component: WidgetComponent = {
|
||||
id: `widget-${i}`,
|
||||
type: "widget",
|
||||
widgetType: randomWebType,
|
||||
position: { x: i % 100, y: Math.floor(i / 100) * 50 },
|
||||
size: { width: 200, height: 40 },
|
||||
label: `Component ${i}`,
|
||||
webTypeConfig: {},
|
||||
};
|
||||
components.push(component);
|
||||
}
|
||||
|
||||
// 모든 컴포넌트 타입 검증
|
||||
let validCount = 0;
|
||||
components.forEach((component) => {
|
||||
if (isWidgetComponent(component) && isWebType(component.widgetType)) {
|
||||
validCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.assert(validCount === 1000, `대량 데이터 검증 실패: ${validCount}/1000`);
|
||||
console.log(`✅ 대량 데이터 처리 성공: ${validCount}/1000 컴포넌트 검증 완료`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in tests
|
||||
export default TypeSafetyTestSuite;
|
||||
|
|
@ -0,0 +1,514 @@
|
|||
/**
|
||||
* 🎮 제어관리 시스템 전용 타입 정의
|
||||
*
|
||||
* 버튼 액션, 데이터플로우, 조건부 실행, 트랜잭션 처리 등 제어관리에서만 사용하는 타입들
|
||||
*/
|
||||
|
||||
import {
|
||||
ButtonActionType,
|
||||
ConditionOperator,
|
||||
CompanyCode,
|
||||
ActiveStatus,
|
||||
TimestampFields,
|
||||
BaseApiResponse,
|
||||
} from "./unified-core";
|
||||
|
||||
// ===== 버튼 제어 관련 =====
|
||||
|
||||
/**
|
||||
* 확장된 버튼 설정 (화면관리의 ButtonTypeConfig 확장)
|
||||
*/
|
||||
export interface ExtendedButtonTypeConfig {
|
||||
// 기본 버튼 설정
|
||||
actionType: ButtonActionType;
|
||||
text?: string;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
size?: "sm" | "md" | "lg";
|
||||
icon?: string;
|
||||
|
||||
// 확인 및 검증
|
||||
confirmMessage?: string;
|
||||
requiresConfirmation?: boolean;
|
||||
|
||||
// 모달 관련 설정
|
||||
popupTitle?: string;
|
||||
popupContent?: string;
|
||||
popupScreenId?: number;
|
||||
|
||||
// 네비게이션 관련 설정
|
||||
navigateType?: "url" | "screen";
|
||||
navigateUrl?: string;
|
||||
navigateScreenId?: number;
|
||||
navigateTarget?: "_self" | "_blank";
|
||||
|
||||
// 커스텀 액션 설정
|
||||
customAction?: string;
|
||||
|
||||
// 🎯 제어관리 기능
|
||||
enableDataflowControl?: boolean;
|
||||
dataflowConfig?: ButtonDataflowConfig;
|
||||
dataflowTiming?: "before" | "after" | "replace";
|
||||
|
||||
// 스타일 설정
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
borderColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 데이터플로우 설정
|
||||
*/
|
||||
export interface ButtonDataflowConfig {
|
||||
// 제어 방식 선택
|
||||
controlMode: "simple" | "advanced";
|
||||
|
||||
// 관계도 방식 (diagram 기반)
|
||||
selectedDiagramId?: number;
|
||||
selectedRelationshipId?: number;
|
||||
|
||||
// 직접 설정 방식
|
||||
directControl?: DirectControlConfig;
|
||||
|
||||
// 제어 데이터 소스
|
||||
controlDataSource?: ControlDataSource;
|
||||
|
||||
// 실행 옵션
|
||||
executionOptions?: ExecutionOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 데이터 소스 타입
|
||||
*/
|
||||
export type ControlDataSource = "form" | "table-selection" | "both";
|
||||
|
||||
/**
|
||||
* 직접 제어 설정
|
||||
*/
|
||||
export interface DirectControlConfig {
|
||||
conditions: DataflowCondition[];
|
||||
actions: DataflowAction[];
|
||||
logic?: "AND" | "OR" | "CUSTOM";
|
||||
customLogic?: string; // "(A AND B) OR (C AND D)"
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 옵션
|
||||
*/
|
||||
export interface ExecutionOptions {
|
||||
timeout?: number; // ms
|
||||
retryCount?: number;
|
||||
parallelExecution?: boolean;
|
||||
continueOnError?: boolean;
|
||||
}
|
||||
|
||||
// ===== 데이터플로우 조건 및 액션 =====
|
||||
|
||||
/**
|
||||
* 데이터플로우 조건
|
||||
*/
|
||||
export interface DataflowCondition {
|
||||
id: string;
|
||||
type: "condition" | "group";
|
||||
|
||||
// 단일 조건
|
||||
field?: string;
|
||||
operator?: ConditionOperator;
|
||||
value?: unknown;
|
||||
dataSource?: ControlDataSource;
|
||||
|
||||
// 그룹 조건
|
||||
conditions?: DataflowCondition[];
|
||||
logic?: "AND" | "OR";
|
||||
|
||||
// 메타데이터
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터플로우 액션
|
||||
*/
|
||||
export interface DataflowAction {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ActionType;
|
||||
|
||||
// 데이터베이스 액션
|
||||
tableName?: string;
|
||||
operation?: DatabaseOperation;
|
||||
fields?: ActionField[];
|
||||
conditions?: DataflowCondition[];
|
||||
|
||||
// API 액션
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
|
||||
// 알림 액션
|
||||
notificationType?: NotificationType;
|
||||
message?: string;
|
||||
recipients?: string[];
|
||||
|
||||
// 리다이렉트 액션
|
||||
redirectUrl?: string;
|
||||
redirectTarget?: "_self" | "_blank";
|
||||
|
||||
// 실행 옵션
|
||||
timeout?: number;
|
||||
retryCount?: number;
|
||||
rollbackable?: boolean;
|
||||
|
||||
// 메타데이터
|
||||
description?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 타입
|
||||
*/
|
||||
export type ActionType = "database" | "api" | "notification" | "redirect" | "custom";
|
||||
|
||||
/**
|
||||
* 데이터베이스 작업 타입
|
||||
*/
|
||||
export type DatabaseOperation = "INSERT" | "UPDATE" | "DELETE" | "SELECT";
|
||||
|
||||
/**
|
||||
* 액션 필드
|
||||
*/
|
||||
export interface ActionField {
|
||||
name: string;
|
||||
value: unknown;
|
||||
type?: "static" | "dynamic" | "computed";
|
||||
source?: string; // 동적 값의 소스 (form field, selected row 등)
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 타입
|
||||
*/
|
||||
export type NotificationType = "success" | "error" | "warning" | "info" | "toast" | "modal" | "email";
|
||||
|
||||
// ===== 트랜잭션 관리 =====
|
||||
|
||||
/**
|
||||
* 트랜잭션 그룹
|
||||
*/
|
||||
export interface TransactionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
actions: DataflowAction[];
|
||||
rollbackStrategy: RollbackStrategy;
|
||||
executionMode: "sequential" | "parallel";
|
||||
onFailure: FailureHandling;
|
||||
}
|
||||
|
||||
/**
|
||||
* 롤백 전략
|
||||
*/
|
||||
export type RollbackStrategy =
|
||||
| "none" // 롤백 안함
|
||||
| "partial" // 실패한 액션만 롤백
|
||||
| "complete"; // 전체 트랜잭션 롤백
|
||||
|
||||
/**
|
||||
* 실패 처리 방식
|
||||
*/
|
||||
export type FailureHandling =
|
||||
| "stop" // 실패 시 중단
|
||||
| "continue" // 실패해도 계속 진행
|
||||
| "alternative"; // 대안 액션 실행
|
||||
|
||||
/**
|
||||
* 조건부 실행 계획
|
||||
*/
|
||||
export interface ConditionalExecutionPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
conditions: ExecutionCondition[];
|
||||
logic: "AND" | "OR" | "CUSTOM";
|
||||
customLogic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 조건
|
||||
*/
|
||||
export interface ExecutionCondition {
|
||||
id: string;
|
||||
type: "action_group" | "validation" | "data_check";
|
||||
|
||||
// 액션 그룹 조건
|
||||
actionGroup?: TransactionGroup;
|
||||
|
||||
// 검증 조건
|
||||
validation?: {
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
// 성공/실패 조건
|
||||
expectedResult: "success" | "failure" | "any";
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 액션 그룹
|
||||
*/
|
||||
export interface ConditionalActionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
// 실행 조건
|
||||
executionCondition: {
|
||||
type: "always" | "conditional" | "fallback";
|
||||
conditions?: DataflowCondition[];
|
||||
logic?: "AND" | "OR";
|
||||
};
|
||||
|
||||
// 액션들
|
||||
actions: DataflowAction[];
|
||||
|
||||
// 성공/실패 조건 정의
|
||||
successCriteria: {
|
||||
type: "all_success" | "any_success" | "custom";
|
||||
customLogic?: string;
|
||||
};
|
||||
|
||||
// 다음 단계 정의
|
||||
onSuccess?: {
|
||||
nextGroup?: string;
|
||||
completeTransaction?: boolean;
|
||||
};
|
||||
|
||||
onFailure?: {
|
||||
retryCount?: number;
|
||||
fallbackGroup?: string;
|
||||
rollbackStrategy?: RollbackStrategy;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 실행 결과 및 상태 =====
|
||||
|
||||
/**
|
||||
* 액션 실행 결과
|
||||
*/
|
||||
export interface ActionExecutionResult {
|
||||
actionId: string;
|
||||
transactionId?: string;
|
||||
status: "pending" | "running" | "success" | "failed" | "rolled_back";
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
rollbackData?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랜잭션 실행 상태
|
||||
*/
|
||||
export interface TransactionExecutionState {
|
||||
transactionId: string;
|
||||
status: "pending" | "running" | "success" | "failed" | "rolling_back" | "rolled_back";
|
||||
actions: ActionExecutionResult[];
|
||||
rollbackActions?: ActionExecutionResult[];
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랜잭션 실행 결과
|
||||
*/
|
||||
export interface TransactionExecutionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
requiresRollback: boolean;
|
||||
results: [string, boolean][];
|
||||
transactionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터플로우 실행 결과
|
||||
*/
|
||||
export interface DataflowExecutionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
executedActions?: ActionExecutionResult[];
|
||||
failedActions?: ActionExecutionResult[];
|
||||
totalActions?: number;
|
||||
executionTime?: number;
|
||||
}
|
||||
|
||||
// ===== 제어 컨텍스트 =====
|
||||
|
||||
/**
|
||||
* 확장된 제어 컨텍스트
|
||||
*/
|
||||
export interface ExtendedControlContext {
|
||||
// 기존 폼 데이터
|
||||
formData: Record<string, unknown>;
|
||||
|
||||
// 테이블 선택 데이터
|
||||
selectedRows?: unknown[];
|
||||
selectedRowsData?: Record<string, unknown>[];
|
||||
|
||||
// 제어 데이터 소스 타입
|
||||
controlDataSource: ControlDataSource;
|
||||
|
||||
// 기타 컨텍스트
|
||||
buttonId: string;
|
||||
componentData?: unknown;
|
||||
timestamp: string;
|
||||
clickCount?: number;
|
||||
|
||||
// 사용자 정보
|
||||
userId?: string;
|
||||
companyCode?: CompanyCode;
|
||||
|
||||
// 화면 정보
|
||||
screenId?: number;
|
||||
screenCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빠른 검증 결과
|
||||
*/
|
||||
export interface QuickValidationResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
canExecuteImmediately: boolean;
|
||||
actions?: DataflowAction[];
|
||||
}
|
||||
|
||||
// ===== 버튼 액션 표준 (DB 기반) =====
|
||||
|
||||
/**
|
||||
* 버튼 액션 표준 정의 (DB의 button_action_standards 테이블)
|
||||
*/
|
||||
export interface ButtonActionStandard extends TimestampFields {
|
||||
action_type: string;
|
||||
action_name: string;
|
||||
action_name_eng?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
default_text?: string;
|
||||
default_text_eng?: string;
|
||||
default_icon?: string;
|
||||
default_color?: string;
|
||||
default_variant?: string;
|
||||
confirmation_required: boolean;
|
||||
confirmation_message?: string;
|
||||
validation_rules?: unknown;
|
||||
action_config?: unknown;
|
||||
sort_order?: number;
|
||||
is_active: ActiveStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 액션 생성/수정 요청
|
||||
*/
|
||||
export interface ButtonActionFormData {
|
||||
action_type: string;
|
||||
action_name: string;
|
||||
action_name_eng?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
default_text?: string;
|
||||
default_text_eng?: string;
|
||||
default_icon?: string;
|
||||
default_color?: string;
|
||||
default_variant?: string;
|
||||
confirmation_required: boolean;
|
||||
confirmation_message?: string;
|
||||
validation_rules?: unknown;
|
||||
action_config?: unknown;
|
||||
sort_order?: number;
|
||||
is_active: ActiveStatus;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입들 =====
|
||||
|
||||
/**
|
||||
* 버튼 액션 목록 응답
|
||||
*/
|
||||
export interface ButtonActionListResponse extends BaseApiResponse<ButtonActionStandard[]> {}
|
||||
|
||||
/**
|
||||
* 데이터플로우 실행 응답
|
||||
*/
|
||||
export interface DataflowExecutionResponse extends BaseApiResponse<DataflowExecutionResult> {}
|
||||
|
||||
/**
|
||||
* 트랜잭션 실행 응답
|
||||
*/
|
||||
export interface TransactionExecutionResponse extends BaseApiResponse<TransactionExecutionResult> {}
|
||||
|
||||
// ===== 유틸리티 타입들 =====
|
||||
|
||||
/**
|
||||
* 롤백 핸들러
|
||||
*/
|
||||
export interface RollbackHandler {
|
||||
actionId: string;
|
||||
rollbackFn: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 최적화된 실행 결과
|
||||
*/
|
||||
export interface OptimizedExecutionResult {
|
||||
jobId: string;
|
||||
immediateResult?: unknown;
|
||||
isBackground?: boolean;
|
||||
timing?: "before" | "after" | "replace";
|
||||
}
|
||||
|
||||
// ===== 타입 가드 및 유틸리티 함수들 =====
|
||||
|
||||
/**
|
||||
* DataflowCondition이 단일 조건인지 확인
|
||||
*/
|
||||
export const isSingleCondition = (condition: DataflowCondition): boolean => {
|
||||
return condition.type === "condition" && !!condition.field;
|
||||
};
|
||||
|
||||
/**
|
||||
* DataflowCondition이 그룹 조건인지 확인
|
||||
*/
|
||||
export const isGroupCondition = (condition: DataflowCondition): boolean => {
|
||||
return condition.type === "group" && !!condition.conditions?.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* DataflowAction이 데이터베이스 액션인지 확인
|
||||
*/
|
||||
export const isDatabaseAction = (action: DataflowAction): boolean => {
|
||||
return action.type === "database" && !!action.tableName;
|
||||
};
|
||||
|
||||
/**
|
||||
* DataflowAction이 API 액션인지 확인
|
||||
*/
|
||||
export const isApiAction = (action: DataflowAction): boolean => {
|
||||
return action.type === "api" && !!action.endpoint;
|
||||
};
|
||||
|
||||
/**
|
||||
* 액션 실행 결과가 성공인지 확인
|
||||
*/
|
||||
export const isActionSuccess = (result: ActionExecutionResult): boolean => {
|
||||
return result.status === "success";
|
||||
};
|
||||
|
||||
/**
|
||||
* 트랜잭션이 완료되었는지 확인 (성공 또는 실패)
|
||||
*/
|
||||
export const isTransactionCompleted = (state: TransactionExecutionState): boolean => {
|
||||
return ["success", "failed", "rolled_back"].includes(state.status);
|
||||
};
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* DDL 실행 관련 타입 정의 (프론트엔드)
|
||||
*/
|
||||
|
||||
// 기본 웹타입
|
||||
export type WebType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "decimal"
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "boolean"
|
||||
| "code"
|
||||
| "entity"
|
||||
| "textarea"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "radio"
|
||||
| "file"
|
||||
| "email"
|
||||
| "tel";
|
||||
|
||||
// 컬럼 정의 인터페이스
|
||||
export interface CreateColumnDefinition {
|
||||
/** 컬럼명 (영문자, 숫자, 언더스코어만 허용) */
|
||||
name: string;
|
||||
/** 컬럼 라벨 (화면 표시용) */
|
||||
label?: string;
|
||||
/** 입력타입 */
|
||||
inputType: string;
|
||||
/** 웹타입 (레거시 호환용) */
|
||||
webType?: WebType;
|
||||
/** NULL 허용 여부 */
|
||||
nullable?: boolean;
|
||||
/** 컬럼 길이 (text, code 타입에서 사용) */
|
||||
length?: number;
|
||||
/** 기본값 */
|
||||
defaultValue?: string;
|
||||
/** 컬럼 설명 */
|
||||
description?: string;
|
||||
/** 표시 순서 */
|
||||
order?: number;
|
||||
/** 상세 설정 (JSON 형태) */
|
||||
detailSettings?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 테이블 생성 요청 인터페이스
|
||||
export interface CreateTableRequest {
|
||||
/** 테이블명 */
|
||||
tableName: string;
|
||||
/** 테이블 설명 */
|
||||
description?: string;
|
||||
/** 컬럼 정의 목록 */
|
||||
columns: CreateColumnDefinition[];
|
||||
}
|
||||
|
||||
// 컬럼 추가 요청 인터페이스
|
||||
export interface AddColumnRequest {
|
||||
/** 컬럼 정의 */
|
||||
column: CreateColumnDefinition;
|
||||
}
|
||||
|
||||
// DDL 실행 결과 인터페이스
|
||||
export interface DDLExecutionResult {
|
||||
/** 실행 성공 여부 */
|
||||
success: boolean;
|
||||
/** 결과 메시지 */
|
||||
message: string;
|
||||
/** 결과 데이터 */
|
||||
data?: any;
|
||||
/** 오류 정보 */
|
||||
error?: {
|
||||
code: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 검증 결과 인터페이스
|
||||
export interface ValidationResult {
|
||||
/** 검증 통과 여부 */
|
||||
isValid: boolean;
|
||||
/** 오류 메시지 목록 */
|
||||
errors: string[];
|
||||
/** 경고 메시지 목록 */
|
||||
warnings?: string[];
|
||||
/** 검증 요약 */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// DDL 실행 로그 인터페이스
|
||||
export interface DDLExecutionLog {
|
||||
/** 로그 ID */
|
||||
id: number;
|
||||
/** 사용자 ID */
|
||||
user_id: string;
|
||||
/** 회사 코드 */
|
||||
company_code: string;
|
||||
/** DDL 유형 */
|
||||
ddl_type: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN";
|
||||
/** 테이블명 */
|
||||
table_name: string;
|
||||
/** 실행된 DDL 쿼리 (미리보기용) */
|
||||
ddl_query_preview?: string;
|
||||
/** 실행 성공 여부 */
|
||||
success: boolean;
|
||||
/** 오류 메시지 (실패 시) */
|
||||
error_message?: string;
|
||||
/** 실행 시간 */
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
// DDL 통계 인터페이스
|
||||
export interface DDLStatistics {
|
||||
/** 전체 실행 횟수 */
|
||||
totalExecutions: number;
|
||||
/** 성공 횟수 */
|
||||
successfulExecutions: number;
|
||||
/** 실패 횟수 */
|
||||
failedExecutions: number;
|
||||
/** DDL 타입별 통계 */
|
||||
byDDLType: Record<string, number>;
|
||||
/** 사용자별 통계 */
|
||||
byUser: Record<string, number>;
|
||||
/** 최근 실패 로그 */
|
||||
recentFailures: any[];
|
||||
}
|
||||
|
||||
// 웹타입 옵션 (UI용)
|
||||
export interface WebTypeOption {
|
||||
value: WebType;
|
||||
label: string;
|
||||
description?: string;
|
||||
supportsLength: boolean;
|
||||
defaultLength?: number;
|
||||
}
|
||||
|
||||
// 웹타입 옵션 목록
|
||||
export const WEB_TYPE_OPTIONS: WebTypeOption[] = [
|
||||
{
|
||||
value: "text",
|
||||
label: "텍스트",
|
||||
description: "일반 텍스트 입력",
|
||||
supportsLength: true,
|
||||
defaultLength: 255,
|
||||
},
|
||||
{
|
||||
value: "number",
|
||||
label: "숫자",
|
||||
description: "정수 입력",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "decimal",
|
||||
label: "소수",
|
||||
description: "소수점 숫자 입력",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "date",
|
||||
label: "날짜",
|
||||
description: "날짜 선택",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "datetime",
|
||||
label: "날짜시간",
|
||||
description: "날짜와 시간 선택",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "boolean",
|
||||
label: "불린",
|
||||
description: "참/거짓 값",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "code",
|
||||
label: "코드",
|
||||
description: "공통코드 선택",
|
||||
supportsLength: true,
|
||||
defaultLength: 100,
|
||||
},
|
||||
{
|
||||
value: "entity",
|
||||
label: "엔티티",
|
||||
description: "다른 테이블 참조",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "textarea",
|
||||
label: "긴 텍스트",
|
||||
description: "여러 줄 텍스트 입력",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "select",
|
||||
label: "선택박스",
|
||||
description: "드롭다운 선택",
|
||||
supportsLength: true,
|
||||
defaultLength: 100,
|
||||
},
|
||||
{
|
||||
value: "checkbox",
|
||||
label: "체크박스",
|
||||
description: "체크박스 입력",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "radio",
|
||||
label: "라디오버튼",
|
||||
description: "단일 선택 라디오버튼",
|
||||
supportsLength: true,
|
||||
defaultLength: 100,
|
||||
},
|
||||
{
|
||||
value: "file",
|
||||
label: "파일",
|
||||
description: "파일 업로드",
|
||||
supportsLength: false,
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: "이메일",
|
||||
description: "이메일 주소 입력",
|
||||
supportsLength: true,
|
||||
defaultLength: 255,
|
||||
},
|
||||
{
|
||||
value: "tel",
|
||||
label: "전화번호",
|
||||
description: "전화번호 입력",
|
||||
supportsLength: true,
|
||||
defaultLength: 50,
|
||||
},
|
||||
];
|
||||
|
||||
// 컬럼 정의 테이블 속성
|
||||
export interface ColumnDefinitionTableProps {
|
||||
columns: CreateColumnDefinition[];
|
||||
onChange: (columns: CreateColumnDefinition[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// 테이블 생성 모달 속성
|
||||
export interface CreateTableModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (result: DDLExecutionResult) => void;
|
||||
}
|
||||
|
||||
// 컬럼 추가 모달 속성
|
||||
export interface AddColumnModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tableName: string;
|
||||
onSuccess: (result: DDLExecutionResult) => void;
|
||||
}
|
||||
|
||||
// DDL 로그 뷰어 속성
|
||||
export interface DDLLogViewerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 유효성 검사 규칙
|
||||
export const VALIDATION_RULES = {
|
||||
tableName: {
|
||||
pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
|
||||
minLength: 2,
|
||||
maxLength: 63,
|
||||
errorMessage: "테이블명은 영문자로 시작하고, 영문자/숫자/언더스코어만 사용 가능합니다 (2-63자)",
|
||||
},
|
||||
columnName: {
|
||||
pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
|
||||
minLength: 2,
|
||||
maxLength: 63,
|
||||
errorMessage: "컬럼명은 영문자로 시작하고, 영문자/숫자/언더스코어만 사용 가능합니다 (2-63자)",
|
||||
},
|
||||
columnLength: {
|
||||
min: 1,
|
||||
max: 65535,
|
||||
errorMessage: "컬럼 길이는 1 이상 65535 이하여야 합니다",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// 시스템 테이블 목록 (보호 대상)
|
||||
export const SYSTEM_TABLES = [
|
||||
"user_info",
|
||||
"company_mng",
|
||||
"menu_info",
|
||||
"auth_group",
|
||||
"table_labels",
|
||||
"column_labels",
|
||||
"screen_definitions",
|
||||
"screen_layouts",
|
||||
"common_code",
|
||||
"multi_lang_key_master",
|
||||
"multi_lang_text",
|
||||
"button_action_standards",
|
||||
"ddl_execution_log",
|
||||
] as const;
|
||||
|
||||
// 예약어 목록
|
||||
export const RESERVED_WORDS = [
|
||||
"user",
|
||||
"order",
|
||||
"group",
|
||||
"table",
|
||||
"column",
|
||||
"index",
|
||||
"select",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"from",
|
||||
"where",
|
||||
"join",
|
||||
"on",
|
||||
"as",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
"null",
|
||||
"true",
|
||||
"false",
|
||||
"create",
|
||||
"alter",
|
||||
"drop",
|
||||
"primary",
|
||||
"key",
|
||||
"foreign",
|
||||
"references",
|
||||
"constraint",
|
||||
"default",
|
||||
"unique",
|
||||
"check",
|
||||
"view",
|
||||
"procedure",
|
||||
"function",
|
||||
] as const;
|
||||
|
||||
// 예약된 컬럼명 목록 (자동 추가되는 기본 컬럼들)
|
||||
export const RESERVED_COLUMNS = ["id", "created_date", "updated_date", "company_code"] as const;
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* 🎯 통합 타입 시스템 Index
|
||||
*
|
||||
* 모든 타입 정의를 중앙에서 관리하고 re-export합니다.
|
||||
* 이 파일을 통해 모든 타입에 일관성 있게 접근할 수 있습니다.
|
||||
*/
|
||||
|
||||
// ===== 핵심 공통 타입들 =====
|
||||
export * from "./unified-core";
|
||||
|
||||
// ===== 시스템별 전용 타입들 =====
|
||||
export * from "./screen-management";
|
||||
export * from "./control-management";
|
||||
export * from "./table-management";
|
||||
|
||||
// ===== 기존 호환성을 위한 re-export =====
|
||||
|
||||
// unified-core에서 제공하는 주요 타입들을 직접 export
|
||||
export type {
|
||||
// 핵심 타입들
|
||||
WebType,
|
||||
DynamicWebType,
|
||||
ButtonActionType,
|
||||
ComponentType,
|
||||
Position,
|
||||
Size,
|
||||
CommonStyle,
|
||||
ValidationRule,
|
||||
ConditionOperator,
|
||||
|
||||
// API 관련
|
||||
BaseApiResponse,
|
||||
PaginatedResponse,
|
||||
|
||||
// 공통 필드들
|
||||
CompanyCode,
|
||||
ActiveStatus,
|
||||
TimestampFields,
|
||||
AuditFields,
|
||||
|
||||
// 이벤트 타입들
|
||||
WebTypeEvent,
|
||||
ComponentEvent,
|
||||
} from "./unified-core";
|
||||
|
||||
// screen-management에서 제공하는 주요 타입들
|
||||
export type {
|
||||
// 컴포넌트 타입들
|
||||
ComponentData,
|
||||
BaseComponent,
|
||||
WidgetComponent,
|
||||
ContainerComponent,
|
||||
GroupComponent,
|
||||
DataTableComponent,
|
||||
FileComponent,
|
||||
|
||||
// 웹타입 설정들
|
||||
WebTypeConfig,
|
||||
DateTypeConfig,
|
||||
NumberTypeConfig,
|
||||
SelectTypeConfig,
|
||||
TextTypeConfig,
|
||||
FileTypeConfig,
|
||||
EntityTypeConfig,
|
||||
ButtonTypeConfig,
|
||||
|
||||
// 화면 관련
|
||||
ScreenDefinition,
|
||||
CreateScreenRequest,
|
||||
UpdateScreenRequest,
|
||||
LayoutData,
|
||||
GridSettings,
|
||||
ScreenTemplate,
|
||||
ScreenResolution,
|
||||
GroupState,
|
||||
|
||||
// 화면 해상도 상수
|
||||
SCREEN_RESOLUTIONS,
|
||||
|
||||
// 데이터 테이블
|
||||
DataTableColumn,
|
||||
DataTableFilter,
|
||||
|
||||
// 파일 업로드
|
||||
UploadedFile,
|
||||
} from "./screen-management";
|
||||
|
||||
// control-management에서 제공하는 주요 타입들
|
||||
export type {
|
||||
// 버튼 제어
|
||||
ExtendedButtonTypeConfig,
|
||||
ButtonDataflowConfig,
|
||||
ControlDataSource,
|
||||
|
||||
// 데이터플로우
|
||||
DataflowCondition,
|
||||
DataflowAction,
|
||||
ActionType,
|
||||
DatabaseOperation,
|
||||
NotificationType,
|
||||
|
||||
// 트랜잭션 관리
|
||||
TransactionGroup,
|
||||
RollbackStrategy,
|
||||
FailureHandling,
|
||||
ConditionalExecutionPlan,
|
||||
ExecutionCondition,
|
||||
|
||||
// 실행 결과
|
||||
ActionExecutionResult,
|
||||
TransactionExecutionState,
|
||||
DataflowExecutionResult,
|
||||
|
||||
// 컨텍스트
|
||||
ExtendedControlContext,
|
||||
QuickValidationResult,
|
||||
|
||||
// 버튼 액션 표준
|
||||
ButtonActionStandard,
|
||||
ButtonActionFormData,
|
||||
} from "./control-management";
|
||||
|
||||
// table-management에서 제공하는 주요 타입들
|
||||
export type {
|
||||
// 테이블 정보
|
||||
TableInfo,
|
||||
UnifiedColumnInfo,
|
||||
ColumnTypeInfo,
|
||||
ColumnSettings,
|
||||
|
||||
// 웹타입 표준
|
||||
WebTypeStandard,
|
||||
WebTypeDefinition,
|
||||
|
||||
// 라벨 관리
|
||||
TableLabels,
|
||||
ColumnLabels,
|
||||
|
||||
// 엔티티 조인
|
||||
EntityJoinConfig,
|
||||
EntityJoinResponse,
|
||||
BatchLookupRequest,
|
||||
BatchLookupResponse,
|
||||
|
||||
// 테이블 관계
|
||||
TableRelationship,
|
||||
DataRelationshipBridge,
|
||||
|
||||
// 컬럼 웹타입 설정
|
||||
ColumnWebTypeSetting,
|
||||
|
||||
// API 응답들
|
||||
TableListResponse,
|
||||
ColumnListResponse,
|
||||
ColumnTypeInfoResponse,
|
||||
WebTypeStandardListResponse,
|
||||
TableDataResponse,
|
||||
} from "./table-management";
|
||||
|
||||
// ===== 타입 가드 함수들 통합 export =====
|
||||
|
||||
// unified-core 타입 가드들
|
||||
export { isWebType, isButtonActionType, isComponentType, ynToBoolean, booleanToYN } from "./unified-core";
|
||||
|
||||
// screen-management 타입 가드들
|
||||
export {
|
||||
isWidgetComponent,
|
||||
isContainerComponent,
|
||||
isGroupComponent,
|
||||
isDataTableComponent,
|
||||
isFileComponent,
|
||||
asWidgetComponent,
|
||||
asContainerComponent,
|
||||
asGroupComponent,
|
||||
asDataTableComponent,
|
||||
asFileComponent,
|
||||
} from "./screen-management";
|
||||
|
||||
// control-management 타입 가드들
|
||||
export {
|
||||
isSingleCondition,
|
||||
isGroupCondition,
|
||||
isDatabaseAction,
|
||||
isApiAction,
|
||||
isActionSuccess,
|
||||
isTransactionCompleted,
|
||||
} from "./control-management";
|
||||
|
||||
// table-management 타입 가드들
|
||||
export {
|
||||
isReferenceWebType,
|
||||
isNumericWebType,
|
||||
isDateWebType,
|
||||
isSelectWebType,
|
||||
isRequiredColumn,
|
||||
isSystemColumn,
|
||||
mapWebTypeStandardToDefinition,
|
||||
mapColumnTypeInfoToUnified,
|
||||
mapUnifiedToColumnTypeInfo,
|
||||
} from "./table-management";
|
||||
|
||||
// ===== 상수들 통합 export =====
|
||||
|
||||
// table-management 상수들
|
||||
export { WEB_TYPE_OPTIONS } from "./table-management";
|
||||
|
||||
// ===== 타입 별칭 (기존 호환성) =====
|
||||
|
||||
/**
|
||||
* @deprecated screen.ts에서 이전하세요. unified-core.ts의 WebType을 사용하세요.
|
||||
*/
|
||||
export type LegacyWebType = WebType;
|
||||
|
||||
/**
|
||||
* @deprecated screen.ts에서 이전하세요. unified-core.ts의 ButtonActionType을 사용하세요.
|
||||
*/
|
||||
export type LegacyButtonActionType = ButtonActionType;
|
||||
|
||||
/**
|
||||
* @deprecated screen.ts에서 이전하세요. screen-management.ts의 ComponentData를 사용하세요.
|
||||
*/
|
||||
export type LegacyComponentData = ComponentData;
|
||||
|
||||
// ===== 유틸리티 타입들 =====
|
||||
|
||||
/**
|
||||
* 컴포넌트 업데이트를 위한 부분 타입
|
||||
*/
|
||||
export type ComponentUpdate<T extends ComponentData> = Partial<Omit<T, "id" | "type">> & {
|
||||
id: string;
|
||||
type: T["type"];
|
||||
};
|
||||
|
||||
/**
|
||||
* API 요청을 위한 기본 파라미터
|
||||
*/
|
||||
export interface BaseRequestParams {
|
||||
companyCode?: CompanyCode;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
sortDirection?: "asc" | "desc";
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 타입 (모든 시스템에서 공통 사용)
|
||||
*/
|
||||
export type FormData = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 선택된 행 데이터 타입
|
||||
*/
|
||||
export type SelectedRowData = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 테이블 데이터 타입
|
||||
*/
|
||||
export type TableData = Record<string, unknown>[];
|
||||
|
||||
// ===== 마이그레이션 도우미 =====
|
||||
|
||||
/**
|
||||
* 기존 screen.ts 타입을 새로운 통합 타입으로 마이그레이션하는 도우미
|
||||
*/
|
||||
export namespace Migration {
|
||||
/**
|
||||
* 기존 screen.ts의 WebType을 새로운 WebType으로 변환
|
||||
*/
|
||||
export const migrateWebType = (oldWebType: string): WebType => {
|
||||
// 기존 타입이 새로운 WebType에 포함되어 있는지 확인
|
||||
if (isWebType(oldWebType)) {
|
||||
return oldWebType as WebType;
|
||||
}
|
||||
|
||||
// 호환되지 않는 타입의 경우 기본값 반환
|
||||
console.warn(`Unknown WebType: ${oldWebType}, defaulting to 'text'`);
|
||||
return "text";
|
||||
};
|
||||
|
||||
/**
|
||||
* 기존 ButtonActionType을 새로운 ButtonActionType으로 변환
|
||||
*/
|
||||
export const migrateButtonActionType = (oldActionType: string): ButtonActionType => {
|
||||
if (isButtonActionType(oldActionType)) {
|
||||
return oldActionType as ButtonActionType;
|
||||
}
|
||||
|
||||
console.warn(`Unknown ButtonActionType: ${oldActionType}, defaulting to 'submit'`);
|
||||
return "submit";
|
||||
};
|
||||
|
||||
/**
|
||||
* Y/N 문자열을 boolean으로 변환 (DB 호환성)
|
||||
*/
|
||||
export const migrateYNToBoolean = (value: string | undefined): boolean => {
|
||||
return value === "Y";
|
||||
};
|
||||
|
||||
/**
|
||||
* boolean을 Y/N 문자열로 변환 (DB 호환성)
|
||||
*/
|
||||
export const migrateBooleanToYN = (value: boolean): string => {
|
||||
return value ? "Y" : "N";
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 타입 검증 도우미 =====
|
||||
|
||||
/**
|
||||
* 런타임에서 타입 안전성을 보장하는 검증 함수들
|
||||
*/
|
||||
export namespace TypeValidation {
|
||||
/**
|
||||
* 객체가 BaseComponent 인터페이스를 만족하는지 검증
|
||||
*/
|
||||
export const validateBaseComponent = (obj: unknown): obj is BaseComponent => {
|
||||
if (typeof obj !== "object" || obj === null) return false;
|
||||
|
||||
const component = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof component.id === "string" &&
|
||||
typeof component.type === "string" &&
|
||||
isComponentType(component.type as string) &&
|
||||
typeof component.position === "object" &&
|
||||
typeof component.size === "object"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 객체가 WebTypeConfig를 만족하는지 검증
|
||||
*/
|
||||
export const validateWebTypeConfig = (obj: unknown): obj is WebTypeConfig => {
|
||||
return typeof obj === "object" && obj !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 문자열이 유효한 CompanyCode인지 검증
|
||||
*/
|
||||
export const validateCompanyCode = (code: unknown): code is CompanyCode => {
|
||||
return typeof code === "string" && code.length > 0;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* 입력 타입 정의 (테이블 타입 관리 개선)
|
||||
* 기존 웹 타입을 8개 핵심 입력 타입으로 단순화
|
||||
*
|
||||
* 주의: 이 파일을 수정할 때는 반드시 백엔드 타입도 함께 업데이트 해야 합니다.
|
||||
*/
|
||||
|
||||
// 8개 핵심 입력 타입
|
||||
export type InputType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "date" // 날짜
|
||||
| "code" // 코드
|
||||
| "entity" // 엔티티
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio"; // 라디오버튼
|
||||
|
||||
// 입력 타입 옵션 정의
|
||||
export interface InputTypeOption {
|
||||
value: InputType;
|
||||
label: string;
|
||||
description: string;
|
||||
category: InputTypeCategory;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// 입력 타입 카테고리
|
||||
export type InputTypeCategory =
|
||||
| "basic" // 기본 입력 (text, number, date)
|
||||
| "reference" // 참조 입력 (code, entity)
|
||||
| "selection"; // 선택 입력 (select, checkbox, radio)
|
||||
|
||||
// 입력 타입 옵션 목록
|
||||
export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
|
||||
{
|
||||
value: "text",
|
||||
label: "텍스트",
|
||||
description: "일반 텍스트 입력",
|
||||
category: "basic",
|
||||
icon: "Type",
|
||||
},
|
||||
{
|
||||
value: "number",
|
||||
label: "숫자",
|
||||
description: "숫자 입력 (정수/소수)",
|
||||
category: "basic",
|
||||
icon: "Hash",
|
||||
},
|
||||
{
|
||||
value: "date",
|
||||
label: "날짜",
|
||||
description: "날짜 선택",
|
||||
category: "basic",
|
||||
icon: "Calendar",
|
||||
},
|
||||
{
|
||||
value: "code",
|
||||
label: "코드",
|
||||
description: "공통코드 참조",
|
||||
category: "reference",
|
||||
icon: "Code",
|
||||
},
|
||||
{
|
||||
value: "entity",
|
||||
label: "엔티티",
|
||||
description: "다른 테이블 참조",
|
||||
category: "reference",
|
||||
icon: "Database",
|
||||
},
|
||||
{
|
||||
value: "select",
|
||||
label: "선택박스",
|
||||
description: "드롭다운 선택",
|
||||
category: "selection",
|
||||
icon: "ChevronDown",
|
||||
},
|
||||
{
|
||||
value: "checkbox",
|
||||
label: "체크박스",
|
||||
description: "체크박스 입력",
|
||||
category: "selection",
|
||||
icon: "CheckSquare",
|
||||
},
|
||||
{
|
||||
value: "radio",
|
||||
label: "라디오버튼",
|
||||
description: "단일 선택",
|
||||
category: "selection",
|
||||
icon: "Circle",
|
||||
},
|
||||
];
|
||||
|
||||
// 카테고리별 입력 타입 그룹화
|
||||
export const INPUT_TYPE_GROUPS = {
|
||||
basic: INPUT_TYPE_OPTIONS.filter((option) => option.category === "basic"),
|
||||
reference: INPUT_TYPE_OPTIONS.filter((option) => option.category === "reference"),
|
||||
selection: INPUT_TYPE_OPTIONS.filter((option) => option.category === "selection"),
|
||||
};
|
||||
|
||||
// 입력 타입 검증 함수
|
||||
export const isValidInputType = (inputType: string): inputType is InputType => {
|
||||
return INPUT_TYPE_OPTIONS.some((option) => option.value === inputType);
|
||||
};
|
||||
|
||||
// 입력 타입 정보 조회
|
||||
export const getInputTypeInfo = (inputType: InputType): InputTypeOption | undefined => {
|
||||
return INPUT_TYPE_OPTIONS.find((option) => option.value === inputType);
|
||||
};
|
||||
|
||||
// 입력 타입별 기본 설정
|
||||
export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>> = {
|
||||
text: {
|
||||
maxLength: 500,
|
||||
placeholder: "텍스트를 입력하세요",
|
||||
},
|
||||
number: {
|
||||
min: 0,
|
||||
step: 1,
|
||||
placeholder: "숫자를 입력하세요",
|
||||
},
|
||||
date: {
|
||||
format: "YYYY-MM-DD",
|
||||
placeholder: "날짜를 선택하세요",
|
||||
},
|
||||
code: {
|
||||
placeholder: "코드를 선택하세요",
|
||||
searchable: true,
|
||||
},
|
||||
entity: {
|
||||
placeholder: "항목을 선택하세요",
|
||||
searchable: true,
|
||||
},
|
||||
select: {
|
||||
placeholder: "선택하세요",
|
||||
searchable: false,
|
||||
},
|
||||
checkbox: {
|
||||
defaultChecked: false,
|
||||
trueValue: "Y",
|
||||
falseValue: "N",
|
||||
},
|
||||
radio: {
|
||||
inline: false,
|
||||
},
|
||||
};
|
||||
|
||||
// 레거시 웹 타입 → 입력 타입 매핑
|
||||
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
|
||||
// 텍스트 관련
|
||||
text: "text",
|
||||
textarea: "text",
|
||||
email: "text",
|
||||
tel: "text",
|
||||
url: "text",
|
||||
password: "text",
|
||||
|
||||
// 숫자 관련
|
||||
number: "number",
|
||||
decimal: "number",
|
||||
|
||||
// 날짜 관련
|
||||
date: "date",
|
||||
datetime: "date",
|
||||
time: "date",
|
||||
|
||||
// 선택 관련
|
||||
select: "select",
|
||||
dropdown: "select",
|
||||
checkbox: "checkbox",
|
||||
boolean: "checkbox",
|
||||
radio: "radio",
|
||||
|
||||
// 참조 관련
|
||||
code: "code",
|
||||
entity: "entity",
|
||||
|
||||
// 기타 (기본값: text)
|
||||
file: "text",
|
||||
button: "text",
|
||||
};
|
||||
|
||||
// 입력 타입 → 웹 타입 역매핑 (화면관리 시스템 호환용)
|
||||
export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
|
||||
text: "text",
|
||||
number: "number",
|
||||
date: "date",
|
||||
code: "code",
|
||||
entity: "entity",
|
||||
select: "select",
|
||||
checkbox: "checkbox",
|
||||
radio: "radio",
|
||||
};
|
||||
|
||||
// 입력 타입 변환 함수
|
||||
export const convertWebTypeToInputType = (webType: string): InputType => {
|
||||
return WEB_TYPE_TO_INPUT_TYPE[webType] || "text";
|
||||
};
|
||||
|
||||
// 입력 타입별 검증 규칙
|
||||
export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>> = {
|
||||
text: {
|
||||
type: "string",
|
||||
trim: true,
|
||||
maxLength: 500,
|
||||
},
|
||||
number: {
|
||||
type: "number",
|
||||
allowFloat: true,
|
||||
},
|
||||
date: {
|
||||
type: "date",
|
||||
format: "YYYY-MM-DD",
|
||||
},
|
||||
code: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
entity: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
select: {
|
||||
type: "string",
|
||||
options: true,
|
||||
},
|
||||
checkbox: {
|
||||
type: "boolean",
|
||||
values: ["Y", "N"],
|
||||
},
|
||||
radio: {
|
||||
type: "string",
|
||||
options: true,
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,583 @@
|
|||
/**
|
||||
* 🖥️ 화면관리 시스템 전용 타입 정의
|
||||
*
|
||||
* 화면 설계, 컴포넌트 관리, 레이아웃 등 화면관리 시스템에서만 사용하는 타입들
|
||||
*/
|
||||
|
||||
import {
|
||||
ComponentType,
|
||||
WebType,
|
||||
DynamicWebType,
|
||||
Position,
|
||||
Size,
|
||||
CommonStyle,
|
||||
ValidationRule,
|
||||
TimestampFields,
|
||||
CompanyCode,
|
||||
ActiveStatus,
|
||||
isWebType,
|
||||
} from "./unified-core";
|
||||
|
||||
// ===== 기본 컴포넌트 인터페이스 =====
|
||||
|
||||
/**
|
||||
* 모든 컴포넌트의 기본 인터페이스
|
||||
*/
|
||||
export interface BaseComponent {
|
||||
id: string;
|
||||
type: ComponentType;
|
||||
position: Position;
|
||||
size: Size;
|
||||
parentId?: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
style?: ComponentStyle;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면관리용 확장 스타일 (CommonStyle 기반)
|
||||
*/
|
||||
export interface ComponentStyle extends CommonStyle {
|
||||
// 화면관리 전용 스타일 확장 가능
|
||||
}
|
||||
|
||||
/**
|
||||
* 위젯 컴포넌트 (입력 요소)
|
||||
*/
|
||||
export interface WidgetComponent extends BaseComponent {
|
||||
type: "widget";
|
||||
widgetType: DynamicWebType;
|
||||
placeholder?: string;
|
||||
columnName?: string;
|
||||
webTypeConfig?: WebTypeConfig;
|
||||
validationRules?: ValidationRule[];
|
||||
|
||||
// 웹타입별 추가 설정
|
||||
dateConfig?: DateTypeConfig;
|
||||
numberConfig?: NumberTypeConfig;
|
||||
selectConfig?: SelectTypeConfig;
|
||||
textConfig?: TextTypeConfig;
|
||||
fileConfig?: FileTypeConfig;
|
||||
entityConfig?: EntityTypeConfig;
|
||||
buttonConfig?: ButtonTypeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨테이너 컴포넌트 (레이아웃)
|
||||
*/
|
||||
export interface ContainerComponent extends BaseComponent {
|
||||
type: "container" | "row" | "column" | "area";
|
||||
children?: string[]; // 자식 컴포넌트 ID 배열
|
||||
layoutDirection?: "horizontal" | "vertical";
|
||||
justifyContent?: "start" | "center" | "end" | "space-between" | "space-around";
|
||||
alignItems?: "start" | "center" | "end" | "stretch";
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 컴포넌트 (논리적 그룹핑)
|
||||
*/
|
||||
export interface GroupComponent extends BaseComponent {
|
||||
type: "group";
|
||||
groupName: string;
|
||||
children: string[]; // 그룹에 속한 컴포넌트 ID 배열
|
||||
isCollapsible?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 테이블 컴포넌트
|
||||
*/
|
||||
export interface DataTableComponent extends BaseComponent {
|
||||
type: "datatable";
|
||||
tableName?: string;
|
||||
columns: DataTableColumn[];
|
||||
pagination?: boolean;
|
||||
pageSize?: number;
|
||||
searchable?: boolean;
|
||||
sortable?: boolean;
|
||||
filters?: DataTableFilter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 컴포넌트
|
||||
*/
|
||||
export interface FileComponent extends BaseComponent {
|
||||
type: "file";
|
||||
fileConfig: FileTypeConfig;
|
||||
uploadedFiles?: UploadedFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 컴포넌트 데이터 타입
|
||||
*/
|
||||
export type ComponentData = WidgetComponent | ContainerComponent | GroupComponent | DataTableComponent | FileComponent;
|
||||
|
||||
// ===== 웹타입별 설정 인터페이스 =====
|
||||
|
||||
/**
|
||||
* 기본 웹타입 설정
|
||||
*/
|
||||
export interface WebTypeConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜/시간 타입 설정
|
||||
*/
|
||||
export interface DateTypeConfig {
|
||||
format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
|
||||
showTime: boolean;
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 타입 설정
|
||||
*/
|
||||
export interface NumberTypeConfig {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
format?: "integer" | "decimal" | "currency" | "percentage";
|
||||
decimalPlaces?: number;
|
||||
thousandSeparator?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택박스 타입 설정
|
||||
*/
|
||||
export interface SelectTypeConfig {
|
||||
options: Array<{ label: string; value: string }>;
|
||||
multiple?: boolean;
|
||||
searchable?: boolean;
|
||||
placeholder?: string;
|
||||
allowCustomValue?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 타입 설정
|
||||
*/
|
||||
export interface TextTypeConfig {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
format?: "none" | "email" | "phone" | "url" | "korean" | "english";
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 타입 설정
|
||||
*/
|
||||
export interface FileTypeConfig {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // bytes
|
||||
maxFiles?: number;
|
||||
preview?: boolean;
|
||||
docType?: string;
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티 타입 설정
|
||||
*/
|
||||
export interface EntityTypeConfig {
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
displayColumn: string;
|
||||
searchColumns?: string[];
|
||||
filters?: Record<string, unknown>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버튼 타입 설정
|
||||
*/
|
||||
export interface ButtonTypeConfig {
|
||||
text?: string;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
size?: "sm" | "md" | "lg";
|
||||
icon?: string;
|
||||
// ButtonActionType과 관련된 설정은 control-management.ts에서 정의
|
||||
}
|
||||
|
||||
// ===== 데이터 테이블 관련 =====
|
||||
|
||||
/**
|
||||
* 데이터 테이블 컬럼
|
||||
*/
|
||||
export interface DataTableColumn {
|
||||
id: string;
|
||||
columnName: string;
|
||||
label: string;
|
||||
dataType?: string;
|
||||
widgetType?: DynamicWebType;
|
||||
width?: number;
|
||||
sortable?: boolean;
|
||||
searchable?: boolean;
|
||||
visible: boolean;
|
||||
frozen?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 테이블 필터
|
||||
*/
|
||||
export interface DataTableFilter {
|
||||
id: string;
|
||||
columnName: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||
value: unknown;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
}
|
||||
|
||||
// ===== 파일 업로드 관련 =====
|
||||
|
||||
/**
|
||||
* 업로드된 파일 정보
|
||||
*/
|
||||
export interface UploadedFile {
|
||||
objid: string;
|
||||
realFileName: string;
|
||||
savedFileName: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
filePath: string;
|
||||
docType?: string;
|
||||
docTypeName?: string;
|
||||
writer?: string;
|
||||
regdate?: string;
|
||||
status?: "uploading" | "completed" | "error";
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
// ===== 화면 정의 관련 =====
|
||||
|
||||
/**
|
||||
* 화면 정의
|
||||
*/
|
||||
export interface ScreenDefinition {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
companyCode: CompanyCode;
|
||||
description?: string;
|
||||
isActive: ActiveStatus;
|
||||
createdDate: Date;
|
||||
updatedDate: Date;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 생성 요청
|
||||
*/
|
||||
export interface CreateScreenRequest {
|
||||
screenName: string;
|
||||
screenCode?: string;
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
companyCode: CompanyCode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 수정 요청
|
||||
*/
|
||||
export interface UpdateScreenRequest {
|
||||
screenName?: string;
|
||||
screenCode?: string;
|
||||
tableName?: string;
|
||||
tableLabel?: string;
|
||||
description?: string;
|
||||
isActive?: ActiveStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 해상도 설정
|
||||
*/
|
||||
export interface ScreenResolution {
|
||||
width: number;
|
||||
height: number;
|
||||
name: string;
|
||||
category: "desktop" | "tablet" | "mobile" | "custom";
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리 정의된 해상도 프리셋
|
||||
*/
|
||||
export const SCREEN_RESOLUTIONS: ScreenResolution[] = [
|
||||
// Desktop
|
||||
{ width: 1920, height: 1080, name: "Full HD (1920×1080)", category: "desktop" },
|
||||
{ width: 1366, height: 768, name: "HD (1366×768)", category: "desktop" },
|
||||
{ width: 1440, height: 900, name: "WXGA+ (1440×900)", category: "desktop" },
|
||||
{ width: 1280, height: 1024, name: "SXGA (1280×1024)", category: "desktop" },
|
||||
|
||||
// Tablet
|
||||
{ width: 1024, height: 768, name: "iPad Landscape (1024×768)", category: "tablet" },
|
||||
{ width: 768, height: 1024, name: "iPad Portrait (768×1024)", category: "tablet" },
|
||||
{ width: 1112, height: 834, name: 'iPad Pro 10.5" Landscape', category: "tablet" },
|
||||
{ width: 834, height: 1112, name: 'iPad Pro 10.5" Portrait', category: "tablet" },
|
||||
|
||||
// Mobile
|
||||
{ width: 375, height: 667, name: "iPhone 8 (375×667)", category: "mobile" },
|
||||
{ width: 414, height: 896, name: "iPhone 11 (414×896)", category: "mobile" },
|
||||
{ width: 390, height: 844, name: "iPhone 12/13 (390×844)", category: "mobile" },
|
||||
{ width: 360, height: 640, name: "Android Medium (360×640)", category: "mobile" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 그룹화 상태
|
||||
*/
|
||||
export interface GroupState {
|
||||
isGrouping: boolean;
|
||||
selectedComponents: string[];
|
||||
groupTarget?: string | null;
|
||||
groupMode?: "create" | "add" | "remove" | "ungroup";
|
||||
groupTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 데이터
|
||||
*/
|
||||
export interface LayoutData {
|
||||
screenId: number;
|
||||
components: ComponentData[];
|
||||
gridSettings?: GridSettings;
|
||||
metadata?: LayoutMetadata;
|
||||
screenResolution?: ScreenResolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자 설정
|
||||
*/
|
||||
export interface GridSettings {
|
||||
enabled: boolean;
|
||||
size: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
snapToGrid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 메타데이터
|
||||
*/
|
||||
export interface LayoutMetadata {
|
||||
version: string;
|
||||
lastModified: Date;
|
||||
modifiedBy: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// ===== 템플릿 관련 =====
|
||||
|
||||
/**
|
||||
* 화면 템플릿
|
||||
*/
|
||||
export interface ScreenTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
components: ComponentData[];
|
||||
previewImage?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 컴포넌트 (템플릿 패널에서 사용)
|
||||
*/
|
||||
export interface TemplateComponent {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category: string;
|
||||
defaultProps: Partial<ComponentData>;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
defaultProps: Partial<ComponentData>;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== 타입 가드 함수들 =====
|
||||
|
||||
/**
|
||||
* WidgetComponent 타입 가드 (강화된 검증)
|
||||
*/
|
||||
export const isWidgetComponent = (component: ComponentData): component is WidgetComponent => {
|
||||
if (!component || typeof component !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 기본 타입 체크
|
||||
if (component.type !== "widget") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 필수 필드 존재 여부 체크
|
||||
if (!component.id || typeof component.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// widgetType이 유효한 WebType인지 체크
|
||||
if (!component.widgetType || !isWebType(component.widgetType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// position 검증
|
||||
if (
|
||||
!component.position ||
|
||||
typeof component.position.x !== "number" ||
|
||||
typeof component.position.y !== "number" ||
|
||||
!Number.isFinite(component.position.x) ||
|
||||
!Number.isFinite(component.position.y)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// size 검증
|
||||
if (
|
||||
!component.size ||
|
||||
typeof component.size.width !== "number" ||
|
||||
typeof component.size.height !== "number" ||
|
||||
!Number.isFinite(component.size.width) ||
|
||||
!Number.isFinite(component.size.height) ||
|
||||
component.size.width <= 0 ||
|
||||
component.size.height <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* ContainerComponent 타입 가드 (강화된 검증)
|
||||
*/
|
||||
export const isContainerComponent = (component: ComponentData): component is ContainerComponent => {
|
||||
if (!component || typeof component !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 기본 타입 체크
|
||||
if (!["container", "row", "column", "area"].includes(component.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 필수 필드 존재 여부 체크
|
||||
if (!component.id || typeof component.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// position 검증
|
||||
if (
|
||||
!component.position ||
|
||||
typeof component.position.x !== "number" ||
|
||||
typeof component.position.y !== "number" ||
|
||||
!Number.isFinite(component.position.x) ||
|
||||
!Number.isFinite(component.position.y)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// size 검증
|
||||
if (
|
||||
!component.size ||
|
||||
typeof component.size.width !== "number" ||
|
||||
typeof component.size.height !== "number" ||
|
||||
!Number.isFinite(component.size.width) ||
|
||||
!Number.isFinite(component.size.height) ||
|
||||
component.size.width <= 0 ||
|
||||
component.size.height <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* GroupComponent 타입 가드
|
||||
*/
|
||||
export const isGroupComponent = (component: ComponentData): component is GroupComponent => {
|
||||
return component.type === "group";
|
||||
};
|
||||
|
||||
/**
|
||||
* DataTableComponent 타입 가드
|
||||
*/
|
||||
export const isDataTableComponent = (component: ComponentData): component is DataTableComponent => {
|
||||
return component.type === "datatable";
|
||||
};
|
||||
|
||||
/**
|
||||
* FileComponent 타입 가드
|
||||
*/
|
||||
export const isFileComponent = (component: ComponentData): component is FileComponent => {
|
||||
return component.type === "file";
|
||||
};
|
||||
|
||||
// ===== 안전한 타입 캐스팅 유틸리티 =====
|
||||
|
||||
/**
|
||||
* ComponentData를 WidgetComponent로 안전하게 캐스팅
|
||||
*/
|
||||
export const asWidgetComponent = (component: ComponentData): WidgetComponent => {
|
||||
if (!isWidgetComponent(component)) {
|
||||
throw new Error(`Expected WidgetComponent, got ${component.type}`);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
/**
|
||||
* ComponentData를 ContainerComponent로 안전하게 캐스팅
|
||||
*/
|
||||
export const asContainerComponent = (component: ComponentData): ContainerComponent => {
|
||||
if (!isContainerComponent(component)) {
|
||||
throw new Error(`Expected ContainerComponent, got ${component.type}`);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
/**
|
||||
* ComponentData를 GroupComponent로 안전하게 캐스팅
|
||||
*/
|
||||
export const asGroupComponent = (component: ComponentData): GroupComponent => {
|
||||
if (!isGroupComponent(component)) {
|
||||
throw new Error(`Expected GroupComponent, got ${component.type}`);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
/**
|
||||
* ComponentData를 DataTableComponent로 안전하게 캐스팅
|
||||
*/
|
||||
export const asDataTableComponent = (component: ComponentData): DataTableComponent => {
|
||||
if (!isDataTableComponent(component)) {
|
||||
throw new Error(`Expected DataTableComponent, got ${component.type}`);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
/**
|
||||
* ComponentData를 FileComponent로 안전하게 캐스팅
|
||||
*/
|
||||
export const asFileComponent = (component: ComponentData): FileComponent => {
|
||||
if (!isFileComponent(component)) {
|
||||
throw new Error(`Expected FileComponent, got ${component.type}`);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,505 @@
|
|||
/**
|
||||
* 🗄️ 테이블 타입관리 시스템 전용 타입 정의
|
||||
*
|
||||
* 데이터베이스 테이블 스키마, 컬럼 타입, 웹타입 매핑 등 테이블 관리에서만 사용하는 타입들
|
||||
*/
|
||||
|
||||
import {
|
||||
DynamicWebType,
|
||||
CompanyCode,
|
||||
ActiveStatus,
|
||||
TimestampFields,
|
||||
BaseApiResponse,
|
||||
PaginatedResponse,
|
||||
ConditionOperator,
|
||||
} from "./unified-core";
|
||||
|
||||
// ===== 기본 테이블 정보 =====
|
||||
|
||||
/**
|
||||
* 테이블 정보
|
||||
*/
|
||||
export interface TableInfo {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columnCount: number;
|
||||
companyCode?: CompanyCode;
|
||||
isActive?: ActiveStatus;
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합된 컬럼 정보 (프론트엔드/백엔드 호환)
|
||||
*/
|
||||
export interface UnifiedColumnInfo {
|
||||
// 기본 정보
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
|
||||
// 데이터 타입
|
||||
dataType: string; // DB 데이터 타입 (varchar, integer, timestamp 등)
|
||||
dbType: string; // DB 내부 타입
|
||||
webType: DynamicWebType; // 웹 입력 타입 (text, number, date 등)
|
||||
|
||||
// 입력 설정
|
||||
inputType: "direct" | "auto";
|
||||
detailSettings?: Record<string, unknown>; // JSON 파싱된 객체
|
||||
description?: string;
|
||||
|
||||
// 제약 조건
|
||||
isNullable: boolean; // Y/N → boolean 변환
|
||||
isPrimaryKey: boolean;
|
||||
defaultValue?: string;
|
||||
|
||||
// 크기 제한
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
|
||||
// 표시 옵션
|
||||
isVisible?: boolean;
|
||||
displayOrder?: number;
|
||||
|
||||
// 참조 관계
|
||||
codeCategory?: string;
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
|
||||
// 메타데이터
|
||||
companyCode?: CompanyCode;
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 백엔드 호환용 컬럼 타입 정보 (기존 ColumnTypeInfo)
|
||||
*/
|
||||
export interface ColumnTypeInfo {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
dbType: string;
|
||||
webType: string; // string 타입 (백엔드 호환)
|
||||
inputType?: "direct" | "auto";
|
||||
detailSettings: string; // JSON 문자열
|
||||
description: string; // 필수 필드
|
||||
isNullable: string; // Y/N 문자열
|
||||
isPrimaryKey: boolean;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
codeCategory?: string;
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
displayOrder?: number;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 설정 (업데이트용)
|
||||
*/
|
||||
export interface ColumnSettings {
|
||||
columnName?: string; // 컬럼명 (업데이트 시 필요)
|
||||
columnLabel: string; // 컬럼 표시명
|
||||
webType: string; // 웹 입력 타입
|
||||
detailSettings: string; // 상세 설정 (JSON 문자열)
|
||||
codeCategory: string; // 코드 카테고리
|
||||
codeValue: string; // 코드 값
|
||||
referenceTable: string; // 참조 테이블
|
||||
referenceColumn: string; // 참조 컬럼
|
||||
displayColumn?: string; // 표시할 컬럼명
|
||||
displayOrder?: number; // 표시 순서
|
||||
isVisible?: boolean; // 표시 여부
|
||||
}
|
||||
|
||||
// ===== 웹타입 표준 정의 =====
|
||||
|
||||
/**
|
||||
* 웹타입 표준 정보 (DB의 web_type_standards 테이블)
|
||||
*/
|
||||
export interface WebTypeStandard extends TimestampFields {
|
||||
web_type: string;
|
||||
type_name: string;
|
||||
type_name_eng?: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
default_config?: unknown; // JSON
|
||||
validation_rules?: unknown; // JSON
|
||||
default_style?: unknown; // JSON
|
||||
input_properties?: unknown; // JSON
|
||||
sort_order?: number;
|
||||
is_active: ActiveStatus;
|
||||
component_name?: string;
|
||||
config_panel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프론트엔드용 웹타입 정의 (WebTypeStandard 변환)
|
||||
*/
|
||||
export interface WebTypeDefinition {
|
||||
webType: string; // web_type 필드
|
||||
typeName: string; // type_name 필드
|
||||
typeNameEng?: string; // type_name_eng 필드
|
||||
description?: string;
|
||||
category: string;
|
||||
defaultConfig: Record<string, unknown>; // JSON 타입 매핑
|
||||
validationRules?: Record<string, unknown>; // JSON 타입 매핑
|
||||
defaultStyle?: Record<string, unknown>; // JSON 타입 매핑
|
||||
inputProperties?: Record<string, unknown>; // JSON 타입 매핑
|
||||
componentName?: string; // component_name 필드
|
||||
configPanel?: string; // config_panel 필드
|
||||
sortOrder?: number; // sort_order 필드
|
||||
isActive: boolean; // is_active Y/N → boolean 변환
|
||||
}
|
||||
|
||||
// ===== 테이블 라벨 관리 =====
|
||||
|
||||
/**
|
||||
* 테이블 라벨
|
||||
*/
|
||||
export interface TableLabels extends TimestampFields {
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
description?: string;
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 라벨
|
||||
*/
|
||||
export interface ColumnLabels extends TimestampFields {
|
||||
id?: number;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
webType?: string;
|
||||
detailSettings?: string;
|
||||
description?: string;
|
||||
displayOrder?: number;
|
||||
isVisible?: boolean;
|
||||
codeCategory?: string;
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
// ===== 엔티티 조인 관리 =====
|
||||
|
||||
/**
|
||||
* 엔티티 조인 설정
|
||||
*/
|
||||
export interface EntityJoinConfig {
|
||||
sourceTable: string; // 원본 테이블 (예: companies)
|
||||
sourceColumn: string; // 원본 컬럼 (예: writer)
|
||||
referenceTable: string; // 참조 테이블 (예: user_info)
|
||||
referenceColumn: string; // 조인 키 (예: user_id)
|
||||
displayColumn: string; // 표시할 값 (예: user_name)
|
||||
aliasColumn: string; // 결과 컬럼명 (예: writer_name)
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티 조인 응답
|
||||
*/
|
||||
export interface EntityJoinResponse {
|
||||
data: Record<string, unknown>[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
entityJoinInfo?: {
|
||||
joinConfigs: EntityJoinConfig[];
|
||||
strategy: "full_join" | "cache_lookup" | "hybrid";
|
||||
performance: {
|
||||
queryTime: number;
|
||||
cacheHitRate?: number;
|
||||
hybridBreakdown?: {
|
||||
dbJoins: number;
|
||||
cacheJoins: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 조회 요청
|
||||
*/
|
||||
export interface BatchLookupRequest {
|
||||
table: string;
|
||||
key: string;
|
||||
displayColumn: string;
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 조회 응답
|
||||
*/
|
||||
export interface BatchLookupResponse {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// ===== 테이블 관계 관리 =====
|
||||
|
||||
/**
|
||||
* 테이블 관계 정의
|
||||
*/
|
||||
export interface TableRelationship extends TimestampFields {
|
||||
relationship_id?: number;
|
||||
relationship_name?: string;
|
||||
from_table_name?: string;
|
||||
from_column_name?: string;
|
||||
to_table_name?: string;
|
||||
to_column_name?: string;
|
||||
relationship_type?: string;
|
||||
connection_type?: string;
|
||||
company_code?: CompanyCode;
|
||||
settings?: unknown; // JSON
|
||||
is_active?: ActiveStatus;
|
||||
diagram_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 관계 브릿지
|
||||
*/
|
||||
export interface DataRelationshipBridge extends TimestampFields {
|
||||
bridge_id?: number;
|
||||
relationship_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
connection_type: string;
|
||||
company_code: CompanyCode;
|
||||
is_active?: ActiveStatus;
|
||||
bridge_data?: unknown; // JSON
|
||||
from_key_value?: string;
|
||||
from_record_id?: string;
|
||||
to_key_value?: string;
|
||||
to_record_id?: string;
|
||||
}
|
||||
|
||||
// ===== 컬럼 웹타입 설정 =====
|
||||
|
||||
/**
|
||||
* 컬럼 웹타입 설정
|
||||
*/
|
||||
export interface ColumnWebTypeSetting {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
webType: DynamicWebType;
|
||||
detailSettings?: Record<string, unknown>;
|
||||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입들 =====
|
||||
|
||||
/**
|
||||
* 테이블 목록 응답
|
||||
*/
|
||||
export interface TableListResponse extends BaseApiResponse<TableInfo[]> {}
|
||||
|
||||
/**
|
||||
* 컬럼 목록 응답
|
||||
*/
|
||||
export interface ColumnListResponse extends BaseApiResponse<UnifiedColumnInfo[]> {}
|
||||
|
||||
/**
|
||||
* 컬럼 타입 정보 응답 (백엔드 호환)
|
||||
*/
|
||||
export interface ColumnTypeInfoResponse extends BaseApiResponse<ColumnTypeInfo[]> {}
|
||||
|
||||
/**
|
||||
* 컬럼 설정 응답
|
||||
*/
|
||||
export interface ColumnSettingsResponse extends BaseApiResponse<void> {}
|
||||
|
||||
/**
|
||||
* 웹타입 표준 목록 응답
|
||||
*/
|
||||
export interface WebTypeStandardListResponse extends BaseApiResponse<WebTypeStandard[]> {}
|
||||
|
||||
/**
|
||||
* 웹타입 정의 목록 응답
|
||||
*/
|
||||
export interface WebTypeDefinitionListResponse extends BaseApiResponse<WebTypeDefinition[]> {}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 응답
|
||||
*/
|
||||
export interface TableDataResponse extends PaginatedResponse<Record<string, unknown>> {}
|
||||
|
||||
// ===== 웹타입 옵션 상수 =====
|
||||
|
||||
/**
|
||||
* 웹타입 옵션 (기존 호환성 유지)
|
||||
*/
|
||||
export const WEB_TYPE_OPTIONS = [
|
||||
{ value: "text", label: "text", description: "일반 텍스트 입력" },
|
||||
{ value: "number", label: "number", description: "숫자 입력" },
|
||||
{ value: "decimal", label: "decimal", description: "소수 입력" },
|
||||
{ value: "date", label: "date", description: "날짜 선택기" },
|
||||
{ value: "datetime", label: "datetime", description: "날짜시간 선택기" },
|
||||
{ value: "code", label: "code", description: "코드 선택 (공통코드 지정)" },
|
||||
{ value: "entity", label: "entity", description: "엔티티 참조 (참조테이블 지정)" },
|
||||
{ value: "textarea", label: "textarea", description: "여러 줄 텍스트" },
|
||||
{ value: "select", label: "select", description: "드롭다운 선택" },
|
||||
{ value: "dropdown", label: "dropdown", description: "드롭다운 선택" },
|
||||
{ value: "checkbox", label: "checkbox", description: "체크박스" },
|
||||
{ value: "boolean", label: "boolean", description: "참/거짓" },
|
||||
{ value: "radio", label: "radio", description: "라디오 버튼" },
|
||||
{ value: "file", label: "file", description: "파일 업로드" },
|
||||
{ value: "email", label: "email", description: "이메일 입력" },
|
||||
{ value: "tel", label: "tel", description: "전화번호 입력" },
|
||||
{ value: "url", label: "url", description: "URL 입력" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 웹타입 (기존 호환성)
|
||||
*/
|
||||
export type WebType = (typeof WEB_TYPE_OPTIONS)[number]["value"];
|
||||
|
||||
// ===== 변환 유틸리티 함수들 =====
|
||||
|
||||
/**
|
||||
* WebTypeStandard를 WebTypeDefinition으로 변환
|
||||
*/
|
||||
export const mapWebTypeStandardToDefinition = (standard: WebTypeStandard): WebTypeDefinition => ({
|
||||
webType: standard.web_type,
|
||||
typeName: standard.type_name,
|
||||
typeNameEng: standard.type_name_eng || undefined,
|
||||
description: standard.description || undefined,
|
||||
category: standard.category || "input",
|
||||
defaultConfig: (standard.default_config as Record<string, unknown>) || {},
|
||||
validationRules: (standard.validation_rules as Record<string, unknown>) || undefined,
|
||||
defaultStyle: (standard.default_style as Record<string, unknown>) || undefined,
|
||||
inputProperties: (standard.input_properties as Record<string, unknown>) || undefined,
|
||||
componentName: standard.component_name || undefined,
|
||||
configPanel: standard.config_panel || undefined,
|
||||
sortOrder: standard.sort_order || 0,
|
||||
isActive: standard.is_active === "Y",
|
||||
});
|
||||
|
||||
/**
|
||||
* ColumnTypeInfo를 UnifiedColumnInfo로 변환
|
||||
*/
|
||||
export const mapColumnTypeInfoToUnified = (columnInfo: ColumnTypeInfo): UnifiedColumnInfo => ({
|
||||
tableName: columnInfo.tableName || "",
|
||||
columnName: columnInfo.columnName,
|
||||
displayName: columnInfo.displayName,
|
||||
dataType: columnInfo.dataType,
|
||||
dbType: columnInfo.dbType,
|
||||
webType: columnInfo.webType,
|
||||
inputType: columnInfo.inputType || "direct",
|
||||
detailSettings: columnInfo.detailSettings ? JSON.parse(columnInfo.detailSettings) : undefined,
|
||||
description: columnInfo.description,
|
||||
isNullable: columnInfo.isNullable === "Y",
|
||||
isPrimaryKey: columnInfo.isPrimaryKey,
|
||||
defaultValue: columnInfo.defaultValue,
|
||||
maxLength: columnInfo.maxLength,
|
||||
numericPrecision: columnInfo.numericPrecision,
|
||||
numericScale: columnInfo.numericScale,
|
||||
isVisible: columnInfo.isVisible,
|
||||
displayOrder: columnInfo.displayOrder,
|
||||
codeCategory: columnInfo.codeCategory,
|
||||
codeValue: columnInfo.codeValue,
|
||||
referenceTable: columnInfo.referenceTable,
|
||||
referenceColumn: columnInfo.referenceColumn,
|
||||
displayColumn: columnInfo.displayColumn,
|
||||
});
|
||||
|
||||
/**
|
||||
* UnifiedColumnInfo를 ColumnTypeInfo로 변환
|
||||
*/
|
||||
export const mapUnifiedToColumnTypeInfo = (unified: UnifiedColumnInfo): ColumnTypeInfo => ({
|
||||
tableName: unified.tableName,
|
||||
columnName: unified.columnName,
|
||||
displayName: unified.displayName,
|
||||
dataType: unified.dataType,
|
||||
dbType: unified.dbType,
|
||||
webType: unified.webType,
|
||||
inputType: unified.inputType,
|
||||
detailSettings: unified.detailSettings ? JSON.stringify(unified.detailSettings) : "{}",
|
||||
description: unified.description || "",
|
||||
isNullable: unified.isNullable ? "Y" : "N",
|
||||
isPrimaryKey: unified.isPrimaryKey,
|
||||
defaultValue: unified.defaultValue,
|
||||
maxLength: unified.maxLength,
|
||||
numericPrecision: unified.numericPrecision,
|
||||
numericScale: unified.numericScale,
|
||||
isVisible: unified.isVisible,
|
||||
displayOrder: unified.displayOrder,
|
||||
codeCategory: unified.codeCategory,
|
||||
codeValue: unified.codeValue,
|
||||
referenceTable: unified.referenceTable,
|
||||
referenceColumn: unified.referenceColumn,
|
||||
displayColumn: unified.displayColumn,
|
||||
});
|
||||
|
||||
// ===== 타입 가드 함수들 =====
|
||||
|
||||
/**
|
||||
* 웹타입이 참조 타입인지 확인
|
||||
*/
|
||||
export const isReferenceWebType = (webType: string): boolean => {
|
||||
return ["code", "entity"].includes(webType);
|
||||
};
|
||||
|
||||
/**
|
||||
* 웹타입이 숫자 타입인지 확인
|
||||
*/
|
||||
export const isNumericWebType = (webType: string): boolean => {
|
||||
return ["number", "decimal"].includes(webType);
|
||||
};
|
||||
|
||||
/**
|
||||
* 웹타입이 날짜 타입인지 확인
|
||||
*/
|
||||
export const isDateWebType = (webType: string): boolean => {
|
||||
return ["date", "datetime"].includes(webType);
|
||||
};
|
||||
|
||||
/**
|
||||
* 웹타입이 선택 타입인지 확인
|
||||
*/
|
||||
export const isSelectWebType = (webType: string): boolean => {
|
||||
return ["select", "dropdown", "radio", "checkbox", "boolean"].includes(webType);
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼이 필수 필드인지 확인
|
||||
*/
|
||||
export const isRequiredColumn = (column: UnifiedColumnInfo): boolean => {
|
||||
return !column.isNullable || column.isPrimaryKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컬럼이 시스템 컬럼인지 확인
|
||||
*/
|
||||
export const isSystemColumn = (columnName: string): boolean => {
|
||||
const systemColumns = [
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"is_active",
|
||||
"company_code",
|
||||
"version",
|
||||
"id",
|
||||
];
|
||||
return systemColumns.includes(columnName.toLowerCase());
|
||||
};
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
/**
|
||||
* 🎯 통합 핵심 타입 정의
|
||||
*
|
||||
* 모든 시스템에서 공통으로 사용하는 핵심 타입들을 중앙집중식으로 관리합니다.
|
||||
* - 화면관리 시스템
|
||||
* - 제어관리 시스템
|
||||
* - 테이블 타입관리 시스템
|
||||
*/
|
||||
|
||||
// ===== 핵심 공통 타입들 =====
|
||||
|
||||
/**
|
||||
* 통합 WebType 정의
|
||||
* 모든 시스템에서 사용하는 웹 입력 타입의 표준 정의
|
||||
*/
|
||||
export type WebType =
|
||||
// 기본 텍스트 입력
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "email"
|
||||
| "tel"
|
||||
| "url"
|
||||
// 숫자 입력
|
||||
| "number"
|
||||
| "decimal"
|
||||
// 날짜/시간 입력
|
||||
| "date"
|
||||
| "datetime"
|
||||
// 선택 입력
|
||||
| "select"
|
||||
| "dropdown"
|
||||
| "radio"
|
||||
| "checkbox"
|
||||
| "boolean"
|
||||
// 특수 입력
|
||||
| "code" // 공통코드 참조
|
||||
| "entity" // 엔티티 참조
|
||||
| "file" // 파일 업로드
|
||||
| "button"; // 버튼 컴포넌트
|
||||
|
||||
/**
|
||||
* 동적 WebType 지원
|
||||
* DB에서 동적으로 로드되는 웹타입도 지원
|
||||
*/
|
||||
export type DynamicWebType = WebType | string;
|
||||
|
||||
/**
|
||||
* 통합 ButtonActionType 정의
|
||||
* 모든 버튼 액션 타입의 표준 정의
|
||||
*/
|
||||
export type ButtonActionType =
|
||||
// 데이터 조작
|
||||
| "save"
|
||||
| "cancel"
|
||||
| "delete"
|
||||
| "edit"
|
||||
| "add"
|
||||
// 검색 및 초기화
|
||||
| "search"
|
||||
| "reset"
|
||||
| "submit"
|
||||
// UI 제어
|
||||
| "close"
|
||||
| "popup"
|
||||
| "modal"
|
||||
// 네비게이션
|
||||
| "navigate"
|
||||
| "newWindow"
|
||||
// 제어관리 전용
|
||||
| "control";
|
||||
|
||||
/**
|
||||
* 컴포넌트 타입 정의
|
||||
*/
|
||||
export type ComponentType =
|
||||
| "container"
|
||||
| "row"
|
||||
| "column"
|
||||
| "widget"
|
||||
| "group"
|
||||
| "datatable"
|
||||
| "file"
|
||||
| "area"
|
||||
| "layout";
|
||||
|
||||
/**
|
||||
* 기본 위치 정보
|
||||
*/
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 크기 정보
|
||||
*/
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 스타일 속성
|
||||
*/
|
||||
export interface CommonStyle {
|
||||
// 여백
|
||||
margin?: string;
|
||||
marginTop?: string;
|
||||
marginRight?: string;
|
||||
marginBottom?: string;
|
||||
marginLeft?: string;
|
||||
padding?: string;
|
||||
paddingTop?: string;
|
||||
paddingRight?: string;
|
||||
paddingBottom?: string;
|
||||
paddingLeft?: string;
|
||||
|
||||
// 테두리
|
||||
border?: string;
|
||||
borderWidth?: string;
|
||||
borderStyle?: string;
|
||||
borderColor?: string;
|
||||
borderRadius?: string;
|
||||
|
||||
// 배경
|
||||
backgroundColor?: string;
|
||||
backgroundImage?: string;
|
||||
|
||||
// 텍스트
|
||||
color?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
fontFamily?: string;
|
||||
textAlign?: "left" | "center" | "right" | "justify";
|
||||
lineHeight?: string;
|
||||
|
||||
// 라벨 스타일
|
||||
labelFontSize?: string;
|
||||
labelColor?: string;
|
||||
labelFontWeight?: string;
|
||||
labelMarginBottom?: string;
|
||||
|
||||
// 레이아웃
|
||||
display?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
minWidth?: string;
|
||||
minHeight?: string;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
|
||||
// 기타
|
||||
opacity?: string;
|
||||
zIndex?: string;
|
||||
overflow?: "visible" | "hidden" | "scroll" | "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙
|
||||
*/
|
||||
export interface ValidationRule {
|
||||
type: "required" | "minLength" | "maxLength" | "pattern" | "min" | "max" | "email" | "url";
|
||||
value?: unknown;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 연산자
|
||||
*/
|
||||
export type ConditionOperator =
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| "<"
|
||||
| ">="
|
||||
| "<="
|
||||
| "LIKE"
|
||||
| "IN"
|
||||
| "NOT IN"
|
||||
| "IS NULL"
|
||||
| "IS NOT NULL";
|
||||
|
||||
/**
|
||||
* 기본 API 응답 형태
|
||||
*/
|
||||
export interface BaseApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지네이션 응답
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 코드 (모든 데이터에 공통)
|
||||
*/
|
||||
export type CompanyCode = string;
|
||||
|
||||
/**
|
||||
* 활성 상태 (DB의 Y/N을 boolean으로 변환)
|
||||
*/
|
||||
export type ActiveStatus = "Y" | "N";
|
||||
|
||||
/**
|
||||
* boolean으로 변환하는 유틸리티 타입
|
||||
*/
|
||||
export type BooleanFromYN<T extends ActiveStatus> = T extends "Y" ? true : false;
|
||||
|
||||
// ===== 공통 유틸리티 타입들 =====
|
||||
|
||||
/**
|
||||
* 선택적 ID (신규 생성 시에는 없고, 기존 데이터는 있음)
|
||||
*/
|
||||
export type OptionalId<T> = Omit<T, "id"> & { id?: string | number };
|
||||
|
||||
/**
|
||||
* 타임스탬프 필드
|
||||
*/
|
||||
export interface TimestampFields {
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 감사 필드 (DB 표준)
|
||||
*/
|
||||
export interface AuditFields {
|
||||
created_date?: Date;
|
||||
updated_date?: Date;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
is_active?: ActiveStatus;
|
||||
}
|
||||
|
||||
// ===== 이벤트 타입들 =====
|
||||
|
||||
/**
|
||||
* 웹타입 이벤트
|
||||
*/
|
||||
export interface WebTypeEvent {
|
||||
type: "change" | "blur" | "focus" | "click" | "submit";
|
||||
value: unknown;
|
||||
field?: string;
|
||||
component?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 이벤트
|
||||
*/
|
||||
export interface ComponentEvent {
|
||||
type: "select" | "drag" | "drop" | "resize" | "delete" | "update";
|
||||
componentId: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
// ===== 타입 가드용 유틸리티 =====
|
||||
|
||||
/**
|
||||
* 문자열이 WebType인지 확인
|
||||
*/
|
||||
export const isWebType = (value: string): value is WebType => {
|
||||
const webTypes: WebType[] = [
|
||||
"text",
|
||||
"textarea",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"number",
|
||||
"decimal",
|
||||
"date",
|
||||
"datetime",
|
||||
"select",
|
||||
"dropdown",
|
||||
"radio",
|
||||
"checkbox",
|
||||
"boolean",
|
||||
"code",
|
||||
"entity",
|
||||
"file",
|
||||
"button",
|
||||
];
|
||||
return webTypes.includes(value as WebType);
|
||||
};
|
||||
|
||||
/**
|
||||
* 문자열이 ButtonActionType인지 확인
|
||||
*/
|
||||
export const isButtonActionType = (value: string): value is ButtonActionType => {
|
||||
const actionTypes: ButtonActionType[] = [
|
||||
"save",
|
||||
"cancel",
|
||||
"delete",
|
||||
"edit",
|
||||
"add",
|
||||
"search",
|
||||
"reset",
|
||||
"submit",
|
||||
"close",
|
||||
"popup",
|
||||
"modal",
|
||||
"navigate",
|
||||
"newWindow",
|
||||
"control",
|
||||
];
|
||||
return actionTypes.includes(value as ButtonActionType);
|
||||
};
|
||||
|
||||
/**
|
||||
* 문자열이 ComponentType인지 확인
|
||||
*/
|
||||
export const isComponentType = (value: string): value is ComponentType => {
|
||||
const componentTypes: ComponentType[] = [
|
||||
"container",
|
||||
"row",
|
||||
"column",
|
||||
"widget",
|
||||
"group",
|
||||
"datatable",
|
||||
"file",
|
||||
"area",
|
||||
"layout",
|
||||
];
|
||||
return componentTypes.includes(value as ComponentType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Y/N 문자열을 boolean으로 변환
|
||||
*/
|
||||
export const ynToBoolean = (value: ActiveStatus | string | undefined): boolean => {
|
||||
return value === "Y";
|
||||
};
|
||||
|
||||
/**
|
||||
* boolean을 Y/N 문자열로 변환
|
||||
*/
|
||||
export const booleanToYN = (value: boolean): ActiveStatus => {
|
||||
return value ? "Y" : "N";
|
||||
};
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* 통합 웹 타입 정의
|
||||
* 프론트엔드와 백엔드에서 공통으로 사용하는 웹 타입 정의
|
||||
*
|
||||
* 주의: 이 파일을 수정할 때는 반드시 백엔드 타입도 함께 업데이트 해야 합니다.
|
||||
*/
|
||||
|
||||
// 기본 웹 타입 (DB web_type_standards와 동기화)
|
||||
export type BaseWebType =
|
||||
| "text" // 일반 텍스트
|
||||
| "number" // 숫자 (정수)
|
||||
| "decimal" // 소수점 숫자
|
||||
| "date" // 날짜
|
||||
| "datetime" // 날짜시간
|
||||
| "time" // 시간
|
||||
| "textarea" // 여러줄 텍스트
|
||||
| "select" // 선택박스
|
||||
| "dropdown" // 드롭다운 (select와 동일)
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오버튼
|
||||
| "boolean" // 불린값
|
||||
| "file" // 파일 업로드
|
||||
| "email" // 이메일
|
||||
| "tel" // 전화번호
|
||||
| "url" // URL
|
||||
| "password" // 패스워드
|
||||
| "code" // 공통코드 참조
|
||||
| "entity" // 엔티티 참조
|
||||
| "button"; // 버튼
|
||||
|
||||
// 레거시 지원용 (기존 시스템과의 호환성)
|
||||
export type LegacyWebType = "text_area"; // textarea와 동일
|
||||
|
||||
// 전체 웹 타입 (DB 동적 로딩 지원)
|
||||
export type WebType = BaseWebType | LegacyWebType;
|
||||
|
||||
// 동적 웹 타입 (런타임에 DB에서 로드되는 타입 포함)
|
||||
export type DynamicWebType = WebType | string;
|
||||
|
||||
// 웹 타입 카테고리
|
||||
export type WebTypeCategory =
|
||||
| "input" // 입력 컴포넌트
|
||||
| "selection" // 선택 컴포넌트
|
||||
| "display" // 표시 컴포넌트
|
||||
| "action" // 액션 컴포넌트
|
||||
| "upload" // 업로드 컴포넌트
|
||||
| "reference"; // 참조 컴포넌트
|
||||
|
||||
// 웹 타입 정보
|
||||
export interface WebTypeInfo {
|
||||
webType: WebType;
|
||||
typeName: string;
|
||||
typeNameEng?: string;
|
||||
description?: string;
|
||||
category: WebTypeCategory;
|
||||
defaultConfig?: Record<string, any>;
|
||||
validationRules?: Record<string, any>;
|
||||
componentName?: string;
|
||||
configPanel?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// 웹 타입 매핑 (레거시 지원)
|
||||
export const WEB_TYPE_MAPPINGS: Record<LegacyWebType, BaseWebType> = {
|
||||
text_area: "textarea",
|
||||
};
|
||||
|
||||
// 웹 타입 정규화 함수
|
||||
export const normalizeWebType = (webType: DynamicWebType): WebType => {
|
||||
if (webType in WEB_TYPE_MAPPINGS) {
|
||||
return WEB_TYPE_MAPPINGS[webType as LegacyWebType];
|
||||
}
|
||||
return webType as WebType;
|
||||
};
|
||||
|
||||
// 웹 타입 검증 함수
|
||||
export const isValidWebType = (webType: string): webType is WebType => {
|
||||
return (
|
||||
[
|
||||
"text",
|
||||
"number",
|
||||
"decimal",
|
||||
"date",
|
||||
"datetime",
|
||||
"time",
|
||||
"textarea",
|
||||
"select",
|
||||
"dropdown",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"boolean",
|
||||
"file",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"password",
|
||||
"code",
|
||||
"entity",
|
||||
"button",
|
||||
"text_area", // 레거시 지원
|
||||
] as string[]
|
||||
).includes(webType);
|
||||
};
|
||||
|
||||
// DB 타입과 웹 타입 매핑
|
||||
export const DB_TYPE_TO_WEB_TYPE: Record<string, WebType> = {
|
||||
// 텍스트 타입
|
||||
"character varying": "text",
|
||||
varchar: "text",
|
||||
text: "textarea",
|
||||
char: "text",
|
||||
|
||||
// 숫자 타입
|
||||
integer: "number",
|
||||
bigint: "number",
|
||||
smallint: "number",
|
||||
serial: "number",
|
||||
bigserial: "number",
|
||||
numeric: "decimal",
|
||||
decimal: "decimal",
|
||||
real: "decimal",
|
||||
"double precision": "decimal",
|
||||
|
||||
// 날짜/시간 타입
|
||||
date: "date",
|
||||
timestamp: "datetime",
|
||||
"timestamp with time zone": "datetime",
|
||||
"timestamp without time zone": "datetime",
|
||||
time: "time",
|
||||
"time with time zone": "time",
|
||||
"time without time zone": "time",
|
||||
|
||||
// 불린 타입
|
||||
boolean: "boolean",
|
||||
|
||||
// JSON 타입 (텍스트로 처리)
|
||||
json: "textarea",
|
||||
jsonb: "textarea",
|
||||
|
||||
// 배열 타입 (텍스트로 처리)
|
||||
ARRAY: "textarea",
|
||||
|
||||
// UUID 타입
|
||||
uuid: "text",
|
||||
};
|
||||
|
||||
// 웹 타입별 기본 설정
|
||||
export const WEB_TYPE_DEFAULT_CONFIGS: Record<WebType, Record<string, any>> = {
|
||||
text: { maxLength: 255, placeholder: "텍스트를 입력하세요" },
|
||||
number: { min: 0, max: 2147483647, step: 1 },
|
||||
decimal: { min: 0, step: 0.01, decimalPlaces: 2 },
|
||||
date: { format: "YYYY-MM-DD" },
|
||||
datetime: { format: "YYYY-MM-DD HH:mm:ss", showTime: true },
|
||||
time: { format: "HH:mm:ss" },
|
||||
textarea: { rows: 4, cols: 50, maxLength: 1000 },
|
||||
select: { placeholder: "선택하세요", searchable: false },
|
||||
dropdown: { placeholder: "선택하세요", searchable: true },
|
||||
checkbox: { defaultChecked: false },
|
||||
radio: { inline: false },
|
||||
boolean: { trueValue: true, falseValue: false },
|
||||
file: { multiple: false, preview: true },
|
||||
email: { placeholder: "이메일을 입력하세요" },
|
||||
tel: { placeholder: "전화번호를 입력하세요" },
|
||||
url: { placeholder: "URL을 입력하세요" },
|
||||
password: { placeholder: "비밀번호를 입력하세요" },
|
||||
code: { placeholder: "코드를 선택하세요", searchable: true },
|
||||
entity: { placeholder: "항목을 선택하세요", searchable: true },
|
||||
button: { variant: "default" },
|
||||
text_area: { rows: 4, cols: 50, maxLength: 1000 }, // 레거시 지원
|
||||
};
|
||||
|
||||
// 웹 타입별 검증 규칙
|
||||
export const WEB_TYPE_VALIDATION_RULES: Record<WebType, Record<string, any>> = {
|
||||
text: { type: "string", trim: true },
|
||||
number: { type: "number", integer: true },
|
||||
decimal: { type: "number", float: true },
|
||||
date: { type: "date", format: "YYYY-MM-DD" },
|
||||
datetime: { type: "datetime" },
|
||||
time: { type: "time" },
|
||||
textarea: { type: "string", multiline: true },
|
||||
select: { type: "string", options: true },
|
||||
dropdown: { type: "string", options: true },
|
||||
checkbox: { type: "boolean" },
|
||||
radio: { type: "string", options: true },
|
||||
boolean: { type: "boolean" },
|
||||
file: { type: "file" },
|
||||
email: { type: "email" },
|
||||
tel: { type: "tel" },
|
||||
url: { type: "url" },
|
||||
password: { type: "string", password: true },
|
||||
code: { type: "string", code: true },
|
||||
entity: { type: "string", entity: true },
|
||||
button: { type: "action" },
|
||||
text_area: { type: "string", multiline: true }, // 레거시 지원
|
||||
};
|
||||
|
|
@ -0,0 +1,852 @@
|
|||
# 제어관리 시스템 트랜잭션 및 조건부 실행 개선방안
|
||||
|
||||
## 🚨 현재 문제점 분석
|
||||
|
||||
### 1. 트랜잭션 처리 부재
|
||||
|
||||
**문제**: 여러 액션 중 하나가 실패해도 이전 액션들이 그대로 유지됨
|
||||
|
||||
#### 현재 상황:
|
||||
|
||||
```
|
||||
저장액션1 (성공) → 저장액션2 (실패)
|
||||
결과: 저장액션1의 데이터는 DB에 그대로 남아있음 (데이터 불일치)
|
||||
```
|
||||
|
||||
#### 예시 시나리오:
|
||||
|
||||
1. **고객정보 저장** (성공)
|
||||
2. **주문정보 저장** (실패)
|
||||
3. **결제정보 저장** (실행되지 않음)
|
||||
|
||||
→ 고객정보만 저장되어 데이터 정합성 깨짐
|
||||
|
||||
### 2. 조건부 실행 로직 부재
|
||||
|
||||
**문제**: AND/OR 조건에 따른 유연한 액션 실행이 불가능
|
||||
|
||||
#### 현재 한계:
|
||||
|
||||
- 모든 액션이 순차적으로 실행됨
|
||||
- 하나 실패하면 전체 중단
|
||||
- 대안 액션 실행 불가
|
||||
|
||||
#### 원하는 동작:
|
||||
|
||||
```
|
||||
액션그룹1: (저장액션1 AND 저장액션2) OR 저장액션3
|
||||
→ 저장액션1,2가 모두 성공하면 완료
|
||||
→ 둘 중 하나라도 실패하면 저장액션3 실행
|
||||
```
|
||||
|
||||
## 🎯 해결방안 설계
|
||||
|
||||
## Phase 1: 트랜잭션 관리 시스템 구축
|
||||
|
||||
### 1.1 트랜잭션 단위 정의
|
||||
|
||||
```typescript
|
||||
// frontend/types/control-management.ts
|
||||
|
||||
export interface TransactionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
actions: DataflowAction[];
|
||||
rollbackStrategy: RollbackStrategy;
|
||||
executionMode: "sequential" | "parallel";
|
||||
onFailure: FailureHandling;
|
||||
}
|
||||
|
||||
export type RollbackStrategy =
|
||||
| "none" // 롤백 안함 (현재 방식)
|
||||
| "partial" // 실패한 액션만 롤백
|
||||
| "complete"; // 전체 트랜잭션 롤백
|
||||
|
||||
export type FailureHandling =
|
||||
| "stop" // 실패 시 중단 (현재 방식)
|
||||
| "continue" // 실패해도 계속 진행
|
||||
| "alternative"; // 대안 액션 실행
|
||||
```
|
||||
|
||||
### 1.2 조건부 실행 로직 구조
|
||||
|
||||
```typescript
|
||||
export interface ConditionalExecutionPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
conditions: ExecutionCondition[];
|
||||
logic: "AND" | "OR" | "CUSTOM";
|
||||
customLogic?: string; // "(A AND B) OR (C AND D)"
|
||||
}
|
||||
|
||||
export interface ExecutionCondition {
|
||||
id: string;
|
||||
type: "action_group" | "validation" | "data_check";
|
||||
|
||||
// 액션 그룹 조건
|
||||
actionGroup?: TransactionGroup;
|
||||
|
||||
// 검증 조건
|
||||
validation?: {
|
||||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
// 성공/실패 조건
|
||||
expectedResult: "success" | "failure" | "any";
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 액션 실행 결과 추적
|
||||
|
||||
```typescript
|
||||
export interface ActionExecutionResult {
|
||||
actionId: string;
|
||||
transactionId: string;
|
||||
status: "pending" | "running" | "success" | "failed" | "rolled_back";
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
rollbackData?: unknown; // 롤백을 위한 데이터
|
||||
}
|
||||
|
||||
export interface TransactionExecutionState {
|
||||
transactionId: string;
|
||||
status:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "rolling_back"
|
||||
| "rolled_back";
|
||||
actions: ActionExecutionResult[];
|
||||
rollbackActions?: ActionExecutionResult[];
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 2: 고급 조건부 실행 시스템
|
||||
|
||||
### 2.1 조건부 액션 그룹 정의
|
||||
|
||||
```typescript
|
||||
export interface ConditionalActionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
// 실행 조건
|
||||
executionCondition: {
|
||||
type: "always" | "conditional" | "fallback";
|
||||
conditions?: DataflowCondition[];
|
||||
logic?: "AND" | "OR";
|
||||
};
|
||||
|
||||
// 액션들
|
||||
actions: DataflowAction[];
|
||||
|
||||
// 성공/실패 조건 정의
|
||||
successCriteria: {
|
||||
type: "all_success" | "any_success" | "custom";
|
||||
customLogic?: string; // "action1 AND (action2 OR action3)"
|
||||
};
|
||||
|
||||
// 다음 단계 정의
|
||||
onSuccess?: {
|
||||
nextGroup?: string;
|
||||
completeTransaction?: boolean;
|
||||
};
|
||||
|
||||
onFailure?: {
|
||||
retryCount?: number;
|
||||
fallbackGroup?: string;
|
||||
rollbackStrategy?: RollbackStrategy;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 복잡한 실행 계획 예시
|
||||
|
||||
```typescript
|
||||
// 예시: 주문 처리 시스템
|
||||
const orderProcessingPlan: ConditionalExecutionPlan = {
|
||||
id: "order_processing",
|
||||
name: "주문 처리",
|
||||
conditions: [
|
||||
{
|
||||
id: "primary_payment",
|
||||
type: "action_group",
|
||||
actionGroup: {
|
||||
id: "payment_group_1",
|
||||
name: "주결제 수단",
|
||||
actions: [
|
||||
{
|
||||
type: "database",
|
||||
operation: "UPDATE",
|
||||
tableName: "customer" /* ... */,
|
||||
},
|
||||
{
|
||||
type: "database",
|
||||
operation: "INSERT",
|
||||
tableName: "orders" /* ... */,
|
||||
},
|
||||
{ type: "api", endpoint: "/payment/card" /* ... */ },
|
||||
],
|
||||
rollbackStrategy: "complete",
|
||||
executionMode: "sequential",
|
||||
},
|
||||
expectedResult: "success",
|
||||
},
|
||||
{
|
||||
id: "alternative_payment",
|
||||
type: "action_group",
|
||||
actionGroup: {
|
||||
id: "payment_group_2",
|
||||
name: "대안 결제 수단",
|
||||
actions: [
|
||||
{ type: "api", endpoint: "/payment/bank" /* ... */ },
|
||||
{
|
||||
type: "database",
|
||||
operation: "UPDATE",
|
||||
tableName: "orders" /* ... */,
|
||||
},
|
||||
],
|
||||
rollbackStrategy: "complete",
|
||||
executionMode: "sequential",
|
||||
},
|
||||
expectedResult: "success",
|
||||
},
|
||||
],
|
||||
logic: "OR", // primary_payment OR alternative_payment
|
||||
customLogic: "primary_payment OR alternative_payment",
|
||||
};
|
||||
```
|
||||
|
||||
## Phase 3: 트랜잭션 실행 엔진 구현
|
||||
|
||||
### 3.1 트랜잭션 매니저 클래스
|
||||
|
||||
```typescript
|
||||
// frontend/lib/services/transactionManager.ts
|
||||
|
||||
export class TransactionManager {
|
||||
private activeTransactions: Map<string, TransactionExecutionState> =
|
||||
new Map();
|
||||
private rollbackHandlers: Map<string, RollbackHandler[]> = new Map();
|
||||
|
||||
/**
|
||||
* 트랜잭션 실행
|
||||
*/
|
||||
async executeTransaction(
|
||||
plan: ConditionalExecutionPlan,
|
||||
context: ExtendedControlContext
|
||||
): Promise<TransactionExecutionResult> {
|
||||
const transactionId = this.generateTransactionId();
|
||||
const state: TransactionExecutionState = {
|
||||
transactionId,
|
||||
status: "pending",
|
||||
actions: [],
|
||||
startTime: new Date(),
|
||||
};
|
||||
|
||||
this.activeTransactions.set(transactionId, state);
|
||||
|
||||
try {
|
||||
state.status = "running";
|
||||
|
||||
// 조건부 실행 로직 평가
|
||||
const executionResult = await this.evaluateExecutionPlan(
|
||||
plan,
|
||||
context,
|
||||
transactionId
|
||||
);
|
||||
|
||||
if (executionResult.success) {
|
||||
state.status = "success";
|
||||
} else {
|
||||
state.status = "failed";
|
||||
|
||||
// 실패 시 롤백 처리
|
||||
if (executionResult.requiresRollback) {
|
||||
await this.rollbackTransaction(transactionId);
|
||||
}
|
||||
}
|
||||
|
||||
state.endTime = new Date();
|
||||
return executionResult;
|
||||
} catch (error) {
|
||||
state.status = "failed";
|
||||
state.endTime = new Date();
|
||||
|
||||
await this.rollbackTransaction(transactionId);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
// 트랜잭션 정리 (일정 시간 후)
|
||||
setTimeout(() => this.cleanupTransaction(transactionId), 300000); // 5분 후
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 계획 평가
|
||||
*/
|
||||
private async evaluateExecutionPlan(
|
||||
plan: ConditionalExecutionPlan,
|
||||
context: ExtendedControlContext,
|
||||
transactionId: string
|
||||
): Promise<TransactionExecutionResult> {
|
||||
const results: Map<string, boolean> = new Map();
|
||||
|
||||
// 각 조건별로 실행
|
||||
for (const condition of plan.conditions) {
|
||||
const result = await this.executeCondition(
|
||||
condition,
|
||||
context,
|
||||
transactionId
|
||||
);
|
||||
results.set(condition.id, result.success);
|
||||
|
||||
// 실패 시 즉시 중단할지 결정
|
||||
if (!result.success && this.shouldStopOnFailure(plan, condition)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `조건 ${condition.id} 실행 실패`,
|
||||
requiresRollback: true,
|
||||
results: Array.from(results.entries()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 로직 평가
|
||||
const overallSuccess = this.evaluateLogic(
|
||||
plan.logic,
|
||||
plan.customLogic,
|
||||
results
|
||||
);
|
||||
|
||||
return {
|
||||
success: overallSuccess,
|
||||
message: overallSuccess ? "모든 조건 실행 성공" : "조건 실행 실패",
|
||||
requiresRollback: !overallSuccess,
|
||||
results: Array.from(results.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 조건 실행
|
||||
*/
|
||||
private async executeCondition(
|
||||
condition: ExecutionCondition,
|
||||
context: ExtendedControlContext,
|
||||
transactionId: string
|
||||
): Promise<{ success: boolean; result?: unknown }> {
|
||||
if (condition.type === "action_group" && condition.actionGroup) {
|
||||
return await this.executeActionGroup(
|
||||
condition.actionGroup,
|
||||
context,
|
||||
transactionId
|
||||
);
|
||||
}
|
||||
|
||||
// 다른 조건 타입들 처리...
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 그룹 실행
|
||||
*/
|
||||
private async executeActionGroup(
|
||||
group: TransactionGroup,
|
||||
context: ExtendedControlContext,
|
||||
transactionId: string
|
||||
): Promise<{ success: boolean; result?: unknown }> {
|
||||
const state = this.activeTransactions.get(transactionId)!;
|
||||
const groupResults: ActionExecutionResult[] = [];
|
||||
|
||||
try {
|
||||
if (group.executionMode === "sequential") {
|
||||
// 순차 실행
|
||||
for (const action of group.actions) {
|
||||
const result = await this.executeAction(
|
||||
action,
|
||||
context,
|
||||
transactionId
|
||||
);
|
||||
groupResults.push(result);
|
||||
state.actions.push(result);
|
||||
|
||||
if (result.status === "failed" && group.onFailure === "stop") {
|
||||
throw new Error(
|
||||
`액션 ${action.id} 실행 실패: ${result.error?.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 병렬 실행
|
||||
const promises = group.actions.map((action) =>
|
||||
this.executeAction(action, context, transactionId)
|
||||
);
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const actionResult: ActionExecutionResult = {
|
||||
actionId: group.actions[index].id,
|
||||
transactionId,
|
||||
status:
|
||||
result.status === "fulfilled" && result.value.status === "success"
|
||||
? "success"
|
||||
: "failed",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result:
|
||||
result.status === "fulfilled" ? result.value.result : undefined,
|
||||
error:
|
||||
result.status === "rejected"
|
||||
? { code: "EXECUTION_ERROR", message: result.reason }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
groupResults.push(actionResult);
|
||||
state.actions.push(actionResult);
|
||||
});
|
||||
}
|
||||
|
||||
// 성공 기준 평가
|
||||
const success = this.evaluateSuccessCriteria(
|
||||
group.successCriteria,
|
||||
groupResults
|
||||
);
|
||||
|
||||
if (!success && group.rollbackStrategy === "complete") {
|
||||
// 그룹 내 모든 액션 롤백
|
||||
await this.rollbackActionGroup(group, groupResults, transactionId);
|
||||
}
|
||||
|
||||
return { success, result: groupResults };
|
||||
} catch (error) {
|
||||
// 오류 발생 시 롤백
|
||||
if (group.rollbackStrategy !== "none") {
|
||||
await this.rollbackActionGroup(group, groupResults, transactionId);
|
||||
}
|
||||
|
||||
return { success: false, result: error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 액션 실행
|
||||
*/
|
||||
private async executeAction(
|
||||
action: DataflowAction,
|
||||
context: ExtendedControlContext,
|
||||
transactionId: string
|
||||
): Promise<ActionExecutionResult> {
|
||||
const result: ActionExecutionResult = {
|
||||
actionId: action.id,
|
||||
transactionId,
|
||||
status: "running",
|
||||
startTime: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
// 액션 타입별 실행
|
||||
let executionResult: unknown;
|
||||
|
||||
switch (action.type) {
|
||||
case "database":
|
||||
executionResult = await this.executeDatabaseAction(action, context);
|
||||
|
||||
// 롤백 데이터 저장 (UPDATE/DELETE의 경우)
|
||||
if (action.operation === "UPDATE" || action.operation === "DELETE") {
|
||||
result.rollbackData = await this.captureRollbackData(
|
||||
action,
|
||||
context
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "api":
|
||||
executionResult = await this.executeApiAction(action, context);
|
||||
break;
|
||||
|
||||
case "notification":
|
||||
executionResult = await this.executeNotificationAction(
|
||||
action,
|
||||
context
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported action type: ${action.type}`);
|
||||
}
|
||||
|
||||
result.status = "success";
|
||||
result.result = executionResult;
|
||||
result.endTime = new Date();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.status = "failed";
|
||||
result.error = {
|
||||
code: "ACTION_EXECUTION_ERROR",
|
||||
message: error.message,
|
||||
details: error,
|
||||
};
|
||||
result.endTime = new Date();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랜잭션 롤백
|
||||
*/
|
||||
private async rollbackTransaction(transactionId: string): Promise<void> {
|
||||
const state = this.activeTransactions.get(transactionId);
|
||||
if (!state) return;
|
||||
|
||||
state.status = "rolling_back";
|
||||
|
||||
// 성공한 액션들을 역순으로 롤백
|
||||
const successfulActions = state.actions
|
||||
.filter((action) => action.status === "success")
|
||||
.reverse();
|
||||
|
||||
const rollbackResults: ActionExecutionResult[] = [];
|
||||
|
||||
for (const action of successfulActions) {
|
||||
try {
|
||||
const rollbackResult = await this.rollbackAction(action);
|
||||
rollbackResults.push(rollbackResult);
|
||||
} catch (error) {
|
||||
console.error(`롤백 실패: ${action.actionId}`, error);
|
||||
// 롤백 실패는 로그만 남기고 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
state.rollbackActions = rollbackResults;
|
||||
state.status = "rolled_back";
|
||||
state.endTime = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 액션 롤백
|
||||
*/
|
||||
private async rollbackAction(
|
||||
action: ActionExecutionResult
|
||||
): Promise<ActionExecutionResult> {
|
||||
// 롤백 액션 실행
|
||||
// 이 부분은 액션 타입별로 구체적인 롤백 로직 구현 필요
|
||||
|
||||
return {
|
||||
actionId: `rollback_${action.actionId}`,
|
||||
transactionId: action.transactionId,
|
||||
status: "success",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
result: "롤백 완료",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 로직 평가 (AND/OR/CUSTOM)
|
||||
*/
|
||||
private evaluateLogic(
|
||||
logic: "AND" | "OR" | "CUSTOM",
|
||||
customLogic: string | undefined,
|
||||
results: Map<string, boolean>
|
||||
): boolean {
|
||||
switch (logic) {
|
||||
case "AND":
|
||||
return Array.from(results.values()).every((result) => result);
|
||||
|
||||
case "OR":
|
||||
return Array.from(results.values()).some((result) => result);
|
||||
|
||||
case "CUSTOM":
|
||||
if (!customLogic) return false;
|
||||
return this.evaluateCustomLogic(customLogic, results);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 로직 평가
|
||||
*/
|
||||
private evaluateCustomLogic(
|
||||
logic: string,
|
||||
results: Map<string, boolean>
|
||||
): boolean {
|
||||
// "(A AND B) OR (C AND D)" 형태의 로직 파싱 및 평가
|
||||
let expression = logic;
|
||||
|
||||
// 변수를 실제 결과값으로 치환
|
||||
for (const [id, result] of results) {
|
||||
expression = expression.replace(
|
||||
new RegExp(`\\b${id}\\b`, "g"),
|
||||
result.toString()
|
||||
);
|
||||
}
|
||||
|
||||
// AND/OR를 JavaScript 연산자로 변환
|
||||
expression = expression
|
||||
.replace(/\bAND\b/g, "&&")
|
||||
.replace(/\bOR\b/g, "||")
|
||||
.replace(/\btrue\b/g, "true")
|
||||
.replace(/\bfalse\b/g, "false");
|
||||
|
||||
try {
|
||||
// 안전한 평가를 위해 Function 생성자 사용
|
||||
return new Function(`return ${expression}`)();
|
||||
} catch (error) {
|
||||
console.error("커스텀 로직 평가 오류:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ... 기타 헬퍼 메서드들
|
||||
}
|
||||
|
||||
export interface TransactionExecutionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
requiresRollback: boolean;
|
||||
results: [string, boolean][];
|
||||
}
|
||||
|
||||
export interface RollbackHandler {
|
||||
actionId: string;
|
||||
rollbackFn: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 데이터베이스 액션 실행기
|
||||
|
||||
```typescript
|
||||
// frontend/lib/services/databaseActionExecutor.ts
|
||||
|
||||
export class DatabaseActionExecutor {
|
||||
/**
|
||||
* 데이터베이스 액션 실행
|
||||
*/
|
||||
static async executeAction(
|
||||
action: DataflowAction,
|
||||
context: ExtendedControlContext
|
||||
): Promise<unknown> {
|
||||
const { tableName, operation, fields, conditions } = action;
|
||||
|
||||
switch (operation) {
|
||||
case "INSERT":
|
||||
return await this.executeInsert(tableName!, fields!, context);
|
||||
|
||||
case "UPDATE":
|
||||
return await this.executeUpdate(
|
||||
tableName!,
|
||||
fields!,
|
||||
conditions!,
|
||||
context
|
||||
);
|
||||
|
||||
case "DELETE":
|
||||
return await this.executeDelete(tableName!, conditions!, context);
|
||||
|
||||
case "SELECT":
|
||||
return await this.executeSelect(
|
||||
tableName!,
|
||||
fields!,
|
||||
conditions!,
|
||||
context
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported database operation: ${operation}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 롤백 데이터 캡처
|
||||
*/
|
||||
static async captureRollbackData(
|
||||
action: DataflowAction,
|
||||
context: ExtendedControlContext
|
||||
): Promise<unknown> {
|
||||
const { tableName, conditions } = action;
|
||||
|
||||
if (action.operation === "UPDATE" || action.operation === "DELETE") {
|
||||
// 변경 전 데이터를 조회하여 저장
|
||||
return await this.executeSelect(tableName!, ["*"], conditions!, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 롤백 실행
|
||||
*/
|
||||
static async executeRollback(
|
||||
originalAction: ActionExecutionResult,
|
||||
rollbackData: unknown
|
||||
): Promise<void> {
|
||||
// 원본 액션의 반대 작업 수행
|
||||
// INSERT -> DELETE
|
||||
// UPDATE -> UPDATE (원본 데이터로)
|
||||
// DELETE -> INSERT (원본 데이터로)
|
||||
// 구체적인 롤백 로직 구현...
|
||||
}
|
||||
|
||||
// ... 개별 operation 구현 메서드들
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 4: 사용자 인터페이스 개선
|
||||
|
||||
### 4.1 조건부 실행 설정 UI
|
||||
|
||||
```typescript
|
||||
// frontend/components/screen/config-panels/ConditionalExecutionPanel.tsx
|
||||
|
||||
export const ConditionalExecutionPanel: React.FC<{
|
||||
config: ButtonDataflowConfig;
|
||||
onConfigChange: (config: ButtonDataflowConfig) => void;
|
||||
}> = ({ config, onConfigChange }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 실행 모드 선택 */}
|
||||
<div>
|
||||
<Label>실행 모드</Label>
|
||||
<Select>
|
||||
<SelectItem value="simple">단순 실행</SelectItem>
|
||||
<SelectItem value="conditional">조건부 실행</SelectItem>
|
||||
<SelectItem value="transaction">트랜잭션 실행</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 트랜잭션 설정 */}
|
||||
<div>
|
||||
<Label>트랜잭션 롤백 전략</Label>
|
||||
<Select>
|
||||
<SelectItem value="none">롤백 안함</SelectItem>
|
||||
<SelectItem value="partial">부분 롤백</SelectItem>
|
||||
<SelectItem value="complete">전체 롤백</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 액션 그룹 설정 */}
|
||||
<div>
|
||||
<Label>액션 그룹</Label>
|
||||
<ActionGroupEditor />
|
||||
</div>
|
||||
|
||||
{/* 조건부 로직 설정 */}
|
||||
<div>
|
||||
<Label>실행 조건</Label>
|
||||
<ConditionalLogicEditor />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 트랜잭션 모니터링 UI
|
||||
|
||||
```typescript
|
||||
// frontend/components/screen/TransactionMonitor.tsx
|
||||
|
||||
export const TransactionMonitor: React.FC = () => {
|
||||
const [transactions, setTransactions] = useState<TransactionExecutionState[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3>트랜잭션 실행 현황</h3>
|
||||
|
||||
{transactions.map((transaction) => (
|
||||
<Card key={transaction.transactionId}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between">
|
||||
<span>트랜잭션 {transaction.transactionId}</span>
|
||||
<Badge variant={getStatusVariant(transaction.status)}>
|
||||
{transaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{transaction.actions.map((action) => (
|
||||
<div key={action.actionId} className="flex justify-between">
|
||||
<span>{action.actionId}</span>
|
||||
<Badge variant={getStatusVariant(action.status)}>
|
||||
{action.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{transaction.status === "failed" && (
|
||||
<Button
|
||||
onClick={() => retryTransaction(transaction.transactionId)}
|
||||
>
|
||||
재시도
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 📋 구현 우선순위
|
||||
|
||||
### 🔥 즉시 구현 (Critical)
|
||||
|
||||
1. **TransactionManager 기본 구조** - 트랜잭션 단위 실행
|
||||
2. **롤백 메커니즘** - 실패 시 이전 상태 복구
|
||||
3. **AND/OR 조건부 실행** - 기본적인 조건부 로직
|
||||
|
||||
### ⚡ 단기 구현 (High)
|
||||
|
||||
4. **데이터베이스 액션 실행기** - 실제 DB 작업 처리
|
||||
5. **에러 핸들링 강화** - 상세한 오류 정보 제공
|
||||
6. **트랜잭션 상태 추적** - 실행 과정 모니터링
|
||||
|
||||
### 📅 중장기 구현 (Medium)
|
||||
|
||||
7. **복잡한 조건부 로직** - 커스텀 로직 지원
|
||||
8. **병렬 실행 지원** - 성능 최적화
|
||||
9. **모니터링 UI** - 실시간 트랜잭션 추적
|
||||
|
||||
## 💡 기대 효과
|
||||
|
||||
### 데이터 일관성 보장
|
||||
|
||||
- 트랜잭션 롤백으로 부분 실행 방지
|
||||
- All-or-Nothing 원칙 적용
|
||||
- 데이터 정합성 확보
|
||||
|
||||
### 유연한 비즈니스 로직
|
||||
|
||||
- 복잡한 조건부 실행 지원
|
||||
- 대안 액션 자동 실행
|
||||
- 비즈니스 요구사항 정확한 반영
|
||||
|
||||
### 시스템 안정성 향상
|
||||
|
||||
- 실패 시 자동 복구
|
||||
- 상세한 실행 로그
|
||||
- 문제 상황 신속 파악
|
||||
|
||||
이 개선방안에 대한 의견이나 우선순위 조정이 필요한 부분이 있으시면 말씀해 주세요!
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,301 @@
|
|||
# 테이블 타입 관리 개선 사용 가이드
|
||||
|
||||
## 🎯 개선 내용 요약
|
||||
|
||||
테이블 타입 관리 시스템이 다음과 같이 개선되었습니다:
|
||||
|
||||
### 주요 변경사항
|
||||
|
||||
- **용어 통일**: 웹 타입 → **입력 타입**
|
||||
- **타입 단순화**: 20개 → **8개 핵심 타입**
|
||||
- **DB 타입 제거**: dbType 필드 완전 삭제
|
||||
- **저장 방식**: 모든 사용자 정의 컬럼을 **VARCHAR(500)로 통일**
|
||||
- **형변환**: 애플리케이션 레벨에서 입력 타입별 처리
|
||||
|
||||
## 📋 8개 핵심 입력 타입
|
||||
|
||||
| 번호 | 입력 타입 | 설명 | 예시 | 저장 형태 |
|
||||
| ---- | ---------- | ---------- | -------------------- | ------------ |
|
||||
| 1 | `text` | 텍스트 | 이름, 제목, 설명 | "홍길동" |
|
||||
| 2 | `number` | 숫자 | 수량, 건수, 순번 | "15000.50" |
|
||||
| 3 | `date` | 날짜 | 생성일, 완료일, 기한 | "2024-03-15" |
|
||||
| 4 | `code` | 코드 | 상태코드, 유형코드 | "ACTIVE" |
|
||||
| 5 | `entity` | 엔티티 | 고객선택, 제품선택 | "123" |
|
||||
| 6 | `select` | 선택박스 | 드롭다운 목록 | "option1" |
|
||||
| 7 | `checkbox` | 체크박스 | Y/N, 사용여부 | "Y" / "N" |
|
||||
| 8 | `radio` | 라디오버튼 | 단일선택 옵션 | "high" |
|
||||
|
||||
## 🚀 마이그레이션 실행
|
||||
|
||||
### 1. 데이터베이스 마이그레이션
|
||||
|
||||
```bash
|
||||
# 백엔드 디렉토리에서 실행
|
||||
cd backend-node
|
||||
node scripts/migrate-to-input-types.js
|
||||
```
|
||||
|
||||
**실행 결과:**
|
||||
|
||||
- `web_type` → `input_type` 컬럼명 변경
|
||||
- `db_type` 컬럼 제거
|
||||
- 기존 웹 타입을 8개 입력 타입으로 자동 변환
|
||||
- 기존 데이터 백업 (`table_type_columns_backup`)
|
||||
|
||||
### 2. 서비스 재시작
|
||||
|
||||
```bash
|
||||
# 백엔드 서비스 재시작
|
||||
npm run dev
|
||||
|
||||
# 프론트엔드 서비스 재시작 (별도 터미널)
|
||||
cd ../frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 💻 사용법
|
||||
|
||||
### 1. 테이블 생성
|
||||
|
||||
새로운 테이블 생성 시 자동으로 기본 컬럼이 추가됩니다:
|
||||
|
||||
```sql
|
||||
CREATE TABLE "products" (
|
||||
-- 기본 컬럼 (자동 추가)
|
||||
"id" serial PRIMARY KEY,
|
||||
"created_date" timestamp DEFAULT now(),
|
||||
"updated_date" timestamp DEFAULT now(),
|
||||
"writer" varchar(100),
|
||||
"company_code" varchar(50) DEFAULT '*',
|
||||
|
||||
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
|
||||
"product_name" varchar(500), -- 입력타입: text
|
||||
"price" varchar(500), -- 입력타입: number
|
||||
"launch_date" varchar(500), -- 입력타입: date
|
||||
"is_active" varchar(500) -- 입력타입: checkbox
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 데이터 입력/조회
|
||||
|
||||
#### 백엔드에서 데이터 처리
|
||||
|
||||
```typescript
|
||||
import { InputTypeService } from "./services/inputTypeService";
|
||||
|
||||
// 데이터 저장 시
|
||||
const inputData = {
|
||||
product_name: "테스트 제품",
|
||||
price: 15000.5,
|
||||
launch_date: new Date("2024-03-15"),
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
const columnTypes = {
|
||||
product_name: "text",
|
||||
price: "number",
|
||||
launch_date: "date",
|
||||
is_active: "checkbox",
|
||||
};
|
||||
|
||||
// 저장용 변환 (모든 값을 문자열로)
|
||||
const convertedData = InputTypeService.convertBatchForStorage(
|
||||
inputData,
|
||||
columnTypes
|
||||
);
|
||||
// 결과: { product_name: "테스트 제품", price: "15000.5", launch_date: "2024-03-15", is_active: "Y" }
|
||||
|
||||
// 데이터베이스에 저장
|
||||
await prisma.products.create({ data: convertedData });
|
||||
```
|
||||
|
||||
#### 데이터 조회 시
|
||||
|
||||
```typescript
|
||||
// 데이터베이스에서 조회 (모든 값이 문자열)
|
||||
const rawData = await prisma.products.findFirst();
|
||||
// 결과: { product_name: "테스트 제품", price: "15000.5", launch_date: "2024-03-15", is_active: "Y" }
|
||||
|
||||
// 표시용 변환 (적절한 타입으로)
|
||||
const displayData = InputTypeService.convertBatchForDisplay(
|
||||
rawData,
|
||||
columnTypes
|
||||
);
|
||||
// 결과: { product_name: "테스트 제품", price: 15000.5, launch_date: "2024-03-15", is_active: true }
|
||||
```
|
||||
|
||||
### 3. 프론트엔드에서 사용
|
||||
|
||||
#### 테이블 관리 화면
|
||||
|
||||
1. **관리자 > 테이블 관리** 메뉴 접속
|
||||
2. 테이블 선택 후 컬럼 목록 확인
|
||||
3. **입력 타입** 컬럼에서 8개 타입 중 선택
|
||||
4. **DB 타입** 컬럼은 제거됨 (더 이상 표시되지 않음)
|
||||
|
||||
#### 화면 관리 시스템 연동
|
||||
|
||||
```typescript
|
||||
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
|
||||
|
||||
// 입력 타입 옵션 사용
|
||||
const inputTypeSelect = (
|
||||
<Select>
|
||||
{INPUT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label} - {option.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
```
|
||||
|
||||
## 🧪 테스트
|
||||
|
||||
### 전체 시스템 테스트
|
||||
|
||||
```bash
|
||||
cd backend-node
|
||||
node scripts/test-input-type-system.js
|
||||
```
|
||||
|
||||
**테스트 내용:**
|
||||
|
||||
- ✅ 테이블 생성 (기본 컬럼 자동 추가)
|
||||
- ✅ VARCHAR(500) 통일 저장
|
||||
- ✅ 입력 타입별 형변환
|
||||
- ✅ 배치 데이터 처리
|
||||
- ✅ 입력값 검증
|
||||
- ✅ 성능 테스트
|
||||
|
||||
## 🔧 개발자 가이드
|
||||
|
||||
### 새로운 입력 타입 추가
|
||||
|
||||
1. **타입 정의 업데이트**
|
||||
|
||||
```typescript
|
||||
// frontend/types/input-types.ts & backend-node/src/types/input-types.ts
|
||||
export type InputType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "date"
|
||||
| "code"
|
||||
| "entity"
|
||||
| "select"
|
||||
| "checkbox"
|
||||
| "radio"
|
||||
| "new_type"; // 새 타입 추가
|
||||
```
|
||||
|
||||
2. **변환 로직 추가**
|
||||
|
||||
```typescript
|
||||
// backend-node/src/services/inputTypeService.ts
|
||||
case "new_type":
|
||||
return processNewType(value);
|
||||
```
|
||||
|
||||
3. **UI 옵션 추가**
|
||||
|
||||
```typescript
|
||||
// frontend/types/input-types.ts
|
||||
export const INPUT_TYPE_OPTIONS = [
|
||||
// ... 기존 옵션들
|
||||
{
|
||||
value: "new_type",
|
||||
label: "새 타입",
|
||||
description: "새로운 입력 타입",
|
||||
category: "basic",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
#### 입력 타입 변경
|
||||
|
||||
```http
|
||||
PUT /api/table-management/tables/{tableName}/columns/{columnName}/input-type
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"inputType": "number",
|
||||
"detailSettings": "{}"
|
||||
}
|
||||
```
|
||||
|
||||
#### 컬럼 입력 타입 조회
|
||||
|
||||
```http
|
||||
GET /api/table-management/tables/{tableName}/input-types
|
||||
```
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 1. 데이터 검증 강화 필요
|
||||
|
||||
VARCHAR 통일 방식에서는 애플리케이션 레벨 검증이 중요합니다:
|
||||
|
||||
```typescript
|
||||
// 반드시 검증 후 저장
|
||||
const validation = InputTypeService.validate(value, inputType);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 기존 데이터 호환성
|
||||
|
||||
- **기존 테이블**: 현재 타입 구조 유지
|
||||
- **신규 테이블**: 새로운 입력 타입 체계 적용
|
||||
- **점진적 전환**: 필요에 따라 기존 테이블도 단계적 전환
|
||||
|
||||
### 3. 성능 고려사항
|
||||
|
||||
- 대용량 데이터 처리 시 배치 변환 사용
|
||||
- 자주 사용되는 변환 결과는 캐싱 고려
|
||||
- 복잡한 검증 로직은 비동기 처리
|
||||
|
||||
## 🆘 문제 해결
|
||||
|
||||
### 마이그레이션 실패 시 롤백
|
||||
|
||||
```sql
|
||||
-- 1. 기존 테이블 삭제
|
||||
DROP TABLE table_type_columns;
|
||||
|
||||
-- 2. 백업에서 복원
|
||||
ALTER TABLE table_type_columns_backup RENAME TO table_type_columns;
|
||||
```
|
||||
|
||||
### 입력 타입 변환 오류
|
||||
|
||||
```typescript
|
||||
// 안전한 변환을 위한 try-catch 사용
|
||||
try {
|
||||
const converted = InputTypeService.convertForStorage(value, inputType);
|
||||
return converted;
|
||||
} catch (error) {
|
||||
logger.error("변환 실패", { value, inputType, error });
|
||||
return String(value); // 기본값으로 문자열 반환
|
||||
}
|
||||
```
|
||||
|
||||
### UI에서 입력 타입이 표시되지 않는 경우
|
||||
|
||||
1. 브라우저 캐시 클리어
|
||||
2. 프론트엔드 서비스 재시작
|
||||
3. `INPUT_TYPE_OPTIONS` import 확인
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하거나 추가 기능이 필요한 경우:
|
||||
|
||||
1. **로그 확인**: 백엔드 콘솔에서 상세 로그 확인
|
||||
2. **테스트 실행**: `test-input-type-system.js`로 시스템 상태 점검
|
||||
3. **데이터 백업**: 중요한 변경 전 항상 백업 실행
|
||||
|
||||
---
|
||||
|
||||
**🎉 테이블 타입 관리 개선으로 더욱 유연하고 안정적인 시스템을 경험하세요!**
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
# 화면관리 검증 시스템 사용 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
이 문서는 화면관리에서 입력 폼 저장 시 발생하던 타입 불일치와 컬럼 오류 문제를 해결하기 위해 개발된 **개선된 검증 시스템**의 사용 방법을 안내합니다.
|
||||
|
||||
## 🚀 주요 개선 사항
|
||||
|
||||
### ✅ 해결된 문제점
|
||||
|
||||
- **타입 불일치 오류**: WebType 정의 통합으로 프론트엔드-백엔드 타입 일관성 확보
|
||||
- **없는 컬럼 참조**: 클라이언트 사전 검증으로 존재하지 않는 컬럼 접근 방지
|
||||
- **불명확한 오류 메시지**: 사용자 친화적인 상세 오류 메시지 제공
|
||||
- **느린 저장 성능**: 캐싱 및 사전 검증으로 불필요한 서버 호출 최소화
|
||||
|
||||
### 🎯 새로운 기능
|
||||
|
||||
- **실시간 폼 검증**: 입력과 동시에 유효성 검사
|
||||
- **스마트 오류 제안**: 오타나 잘못된 컬럼명에 대한 추천
|
||||
- **향상된 타입 변환**: PostgreSQL 타입에 맞는 안전한 데이터 변환
|
||||
- **성능 최적화**: 테이블 컬럼 정보 캐싱으로 빠른 응답
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
- **TypeScript**: 통합 타입 정의 (`unified-web-types.ts`)
|
||||
- **React Hooks**: 실시간 검증 (`useFormValidation`)
|
||||
- **Validation Utils**: 클라이언트 검증 로직 (`formValidation.ts`)
|
||||
- **Enhanced Service**: 통합 폼 서비스 (`enhancedFormService.ts`)
|
||||
|
||||
### 백엔드
|
||||
|
||||
- **Enhanced Service**: 개선된 동적 폼 서비스 (`enhancedDynamicFormService.ts`)
|
||||
- **Table Management API**: 테이블 관리 API (`tableManagementController.ts`)
|
||||
- **Type Safety**: 통합 웹타입 정의 (`unified-web-types.ts`)
|
||||
|
||||
## 🎮 사용 방법
|
||||
|
||||
### 1. 데모 페이지에서 테스트
|
||||
|
||||
개선된 검증 시스템을 직접 체험할 수 있는 데모 페이지가 제공됩니다:
|
||||
|
||||
```
|
||||
http://localhost:9771/admin/validation-demo
|
||||
```
|
||||
|
||||
#### 데모 기능
|
||||
|
||||
- **유효한 데이터 입력**: 모든 검증을 통과하는 테스트 데이터
|
||||
- **잘못된 데이터 입력**: 다양한 검증 오류를 확인할 수 있는 데이터
|
||||
- **실시간 검증**: 입력과 동시에 검증 결과 확인
|
||||
- **상세 검증 상태**: 필드별 검증 상태 및 오류 메시지
|
||||
|
||||
### 2. 기존 화면에서 검증 기능 활성화
|
||||
|
||||
기존 `InteractiveScreenViewer` 컴포넌트에 검증 기능을 추가할 수 있습니다:
|
||||
|
||||
```tsx
|
||||
<InteractiveScreenViewer
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
// 검증 기능 활성화
|
||||
enableEnhancedValidation={true}
|
||||
tableColumns={tableColumns}
|
||||
showValidationPanel={true}
|
||||
validationOptions={{
|
||||
enableRealTimeValidation: true,
|
||||
validationDelay: 300,
|
||||
enableAutoSave: false,
|
||||
showToastMessages: true,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. 새로운 Enhanced 컴포넌트 사용
|
||||
|
||||
완전히 새로운 검증 기능을 위해서는 `EnhancedInteractiveScreenViewer`를 사용하세요:
|
||||
|
||||
```tsx
|
||||
import { EnhancedInteractiveScreenViewer } from "@/components/screen/EnhancedInteractiveScreenViewer";
|
||||
|
||||
<EnhancedInteractiveScreenViewer
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
tableColumns={tableColumns}
|
||||
showValidationPanel={true}
|
||||
showDeveloperInfo={false} // 개발 모드에서만 true
|
||||
/>;
|
||||
```
|
||||
|
||||
## ⚙️ 설정 옵션
|
||||
|
||||
### 검증 옵션 (`validationOptions`)
|
||||
|
||||
```typescript
|
||||
interface ValidationOptions {
|
||||
enableRealTimeValidation?: boolean; // 실시간 검증 활성화 (기본: true)
|
||||
validationDelay?: number; // 검증 지연 시간 ms (기본: 300)
|
||||
enableAutoSave?: boolean; // 자동 저장 (기본: false)
|
||||
autoSaveDelay?: number; // 자동 저장 지연 시간 ms (기본: 2000)
|
||||
showToastMessages?: boolean; // 토스트 메시지 표시 (기본: true)
|
||||
validateOnMount?: boolean; // 마운트 시 검증 (기본: false)
|
||||
}
|
||||
```
|
||||
|
||||
### 표시 옵션
|
||||
|
||||
```typescript
|
||||
interface DisplayOptions {
|
||||
showValidationPanel?: boolean; // 검증 패널 표시 (기본: false)
|
||||
compactValidation?: boolean; // 간소화된 검증 UI (기본: false)
|
||||
showDeveloperInfo?: boolean; // 개발자 정보 표시 (기본: false)
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 개발자 가이드
|
||||
|
||||
### 1. 새로운 WebType 추가
|
||||
|
||||
새로운 웹타입을 추가하려면 양쪽 모두 업데이트해야 합니다:
|
||||
|
||||
**프론트엔드** (`frontend/types/unified-web-types.ts`):
|
||||
|
||||
```typescript
|
||||
export type BaseWebType =
|
||||
| "text"
|
||||
| "number"
|
||||
// ... 기존 타입들
|
||||
| "new-type"; // 새 타입 추가
|
||||
```
|
||||
|
||||
**백엔드** (`backend-node/src/types/unified-web-types.ts`):
|
||||
|
||||
```typescript
|
||||
export type BaseWebType =
|
||||
| "text"
|
||||
| "number"
|
||||
// ... 기존 타입들
|
||||
| "new-type"; // 동일하게 추가
|
||||
```
|
||||
|
||||
### 2. 커스텀 검증 규칙 추가
|
||||
|
||||
`formValidation.ts`에서 새로운 검증 로직을 추가할 수 있습니다:
|
||||
|
||||
```typescript
|
||||
const validateCustomField = (
|
||||
fieldName: string,
|
||||
value: any,
|
||||
config?: Record<string, any>
|
||||
): FieldValidationResult => {
|
||||
// 커스텀 검증 로직
|
||||
if (customValidationFails) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: {
|
||||
field: fieldName,
|
||||
code: "CUSTOM_ERROR",
|
||||
message: "커스텀 오류 메시지",
|
||||
severity: "error",
|
||||
value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 테이블 컬럼 정보 캐싱
|
||||
|
||||
시스템은 자동으로 테이블 컬럼 정보를 캐싱합니다:
|
||||
|
||||
- **클라이언트**: 10분간 캐싱
|
||||
- **서버**: 10분간 캐싱
|
||||
- **수동 캐시 클리어**: `enhancedFormService.clearTableCache(tableName)`
|
||||
|
||||
## 🚨 트러블슈팅
|
||||
|
||||
### 자주 발생하는 문제
|
||||
|
||||
#### 1. "테이블 정보를 찾을 수 없습니다"
|
||||
|
||||
```
|
||||
해결책:
|
||||
1. 테이블명이 정확한지 확인
|
||||
2. table_type_columns 테이블에 해당 테이블 정보가 있는지 확인
|
||||
3. 데이터베이스 연결 상태 확인 (http://localhost:8080/api/table-management/health)
|
||||
```
|
||||
|
||||
#### 2. "컬럼이 존재하지 않습니다"
|
||||
|
||||
```
|
||||
해결책:
|
||||
1. WidgetComponent의 columnName 속성 확인
|
||||
2. 데이터베이스 스키마와 일치하는지 확인
|
||||
3. 제안된 유사한 컬럼명 사용 고려
|
||||
```
|
||||
|
||||
#### 3. "타입 변환 오류"
|
||||
|
||||
```
|
||||
해결책:
|
||||
1. webType과 PostgreSQL 데이터 타입 호환성 확인
|
||||
2. detailSettings의 제약조건 검토
|
||||
3. 입력값 형식 확인 (예: 날짜는 YYYY-MM-DD)
|
||||
```
|
||||
|
||||
#### 4. 검증이 작동하지 않음
|
||||
|
||||
```
|
||||
해결책:
|
||||
1. enableEnhancedValidation={true} 설정 확인
|
||||
2. tableColumns 배열이 올바르게 전달되었는지 확인
|
||||
3. screenInfo에 id와 tableName이 있는지 확인
|
||||
```
|
||||
|
||||
### 로그 분석
|
||||
|
||||
개발 모드에서는 상세한 로그가 제공됩니다:
|
||||
|
||||
```javascript
|
||||
// 클라이언트 로그
|
||||
console.log("🔍 개선된 검증 시스템 사용");
|
||||
console.log("💾 폼 데이터 저장 요청:", formData);
|
||||
|
||||
// 서버 로그
|
||||
logger.info("개선된 폼 저장 시작: tableName");
|
||||
logger.debug("Data after validation:", transformedData);
|
||||
```
|
||||
|
||||
## 📊 성능 모니터링
|
||||
|
||||
### 성능 지표
|
||||
|
||||
시스템은 다음 성능 지표를 추적합니다:
|
||||
|
||||
- **검증 시간**: 클라이언트 검증 수행 시간
|
||||
- **저장 시간**: 서버 저장 처리 시간
|
||||
- **전체 시간**: 요청부터 응답까지 총 시간
|
||||
- **캐시 히트율**: 테이블 컬럼 정보 캐시 사용률
|
||||
|
||||
### 성능 최적화 팁
|
||||
|
||||
1. **테이블 컬럼 정보 사전 로드**:
|
||||
|
||||
```typescript
|
||||
// 화면 로드 시 테이블 정보 미리 캐싱
|
||||
await enhancedFormService.getTableColumns(tableName);
|
||||
```
|
||||
|
||||
2. **검증 지연 시간 조정**:
|
||||
|
||||
```typescript
|
||||
// 빠른 응답이 필요한 경우
|
||||
validationOptions={{ validationDelay: 100 }}
|
||||
|
||||
// 서버 부하를 줄이려는 경우
|
||||
validationOptions={{ validationDelay: 1000 }}
|
||||
```
|
||||
|
||||
3. **불필요한 실시간 검증 비활성화**:
|
||||
```typescript
|
||||
// 단순한 폼의 경우
|
||||
validationOptions={{ enableRealTimeValidation: false }}
|
||||
```
|
||||
|
||||
## 🔄 마이그레이션 가이드
|
||||
|
||||
### 기존 코드에서 새 시스템으로
|
||||
|
||||
1. **단계별 마이그레이션**:
|
||||
|
||||
```typescript
|
||||
// 1단계: 기존 코드 유지하면서 검증만 추가
|
||||
<InteractiveScreenViewer
|
||||
{...existingProps}
|
||||
enableEnhancedValidation={true}
|
||||
tableColumns={tableColumns}
|
||||
/>
|
||||
|
||||
// 2단계: 검증 패널 추가
|
||||
<InteractiveScreenViewer
|
||||
{...existingProps}
|
||||
enableEnhancedValidation={true}
|
||||
tableColumns={tableColumns}
|
||||
showValidationPanel={true}
|
||||
/>
|
||||
|
||||
// 3단계: 완전 마이그레이션
|
||||
<EnhancedInteractiveScreenViewer
|
||||
{...existingProps}
|
||||
tableColumns={tableColumns}
|
||||
showValidationPanel={true}
|
||||
/>
|
||||
```
|
||||
|
||||
2. **API 호출 변경**:
|
||||
|
||||
```typescript
|
||||
// 기존 방식
|
||||
await dynamicFormApi.saveFormData(formData);
|
||||
|
||||
// 새로운 방식 (권장)
|
||||
await dynamicFormApi.saveData(formData);
|
||||
```
|
||||
|
||||
## 📞 지원 및 문의
|
||||
|
||||
### 개발 지원
|
||||
|
||||
- **데모 페이지**: `/admin/validation-demo`
|
||||
- **API 상태 확인**: `/api/table-management/health`
|
||||
- **로그 레벨**: 개발 환경에서 DEBUG 로그 활성화
|
||||
|
||||
### 추가 개발 계획
|
||||
|
||||
1. **Phase 2**: 웹타입별 상세 설정 UI
|
||||
2. **Phase 3**: 고급 검증 규칙 (정규식, 범위 등)
|
||||
3. **Phase 4**: 조건부 필드 및 계산 필드
|
||||
4. **Phase 5**: 실시간 협업 기능
|
||||
|
||||
---
|
||||
|
||||
이 가이드는 화면관리 검증 시스템의 핵심 기능과 사용법을 다룹니다. 추가 질문이나 개선 제안은 개발팀에 문의해주세요! 🚀
|
||||
Loading…
Reference in New Issue