타입 관리 개선 및 화면 비율조정 중간커밋
This commit is contained in:
parent
baa656dee5
commit
4b28530fec
|
|
@ -1,8 +1,9 @@
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { dynamicFormService } from "../services/dynamicFormService";
|
import { dynamicFormService } from "../services/dynamicFormService";
|
||||||
|
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
// 폼 데이터 저장
|
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
|
||||||
export const saveFormData = async (
|
export const saveFormData = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response
|
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 (
|
export const updateFormData = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -735,6 +735,207 @@ 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 webTypes = await tableManagementService.getColumnWebTypes(tableName);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`컬럼 웹타입 정보 조회 완료: ${tableName}, ${webTypes.length}개 컬럼`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse<ColumnTypeInfo[]> = {
|
||||||
|
success: true,
|
||||||
|
message: "컬럼 웹타입 정보를 성공적으로 조회했습니다.",
|
||||||
|
data: webTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 데이터 삭제
|
* 테이블 데이터 삭제
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import express from "express";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import {
|
import {
|
||||||
saveFormData,
|
saveFormData,
|
||||||
|
saveFormDataEnhanced,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
updateFormDataPartial,
|
updateFormDataPartial,
|
||||||
deleteFormData,
|
deleteFormData,
|
||||||
|
|
@ -18,7 +19,8 @@ const router = express.Router();
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// 폼 데이터 CRUD
|
// 폼 데이터 CRUD
|
||||||
router.post("/save", saveFormData);
|
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
||||||
|
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||||
router.put("/:id", updateFormData);
|
router.put("/:id", updateFormData);
|
||||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||||
router.delete("/:id", deleteFormData);
|
router.delete("/:id", deleteFormData);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ import {
|
||||||
addTableData,
|
addTableData,
|
||||||
editTableData,
|
editTableData,
|
||||||
deleteTableData,
|
deleteTableData,
|
||||||
|
getTableSchema,
|
||||||
|
checkTableExists,
|
||||||
|
getColumnWebTypes,
|
||||||
|
checkDatabaseConnection,
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -74,6 +78,42 @@ router.put(
|
||||||
updateColumnWebType
|
updateColumnWebType
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 컬럼 설정 업데이트 (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
|
* POST /api/table-management/tables/:tableName/data
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -1013,23 +1013,52 @@ export class ScreenManagementService {
|
||||||
* 데이터 타입으로부터 웹타입 추론
|
* 데이터 타입으로부터 웹타입 추론
|
||||||
*/
|
*/
|
||||||
private inferWebType(dataType: string): WebType {
|
private inferWebType(dataType: string): WebType {
|
||||||
|
// 통합 타입 매핑에서 import
|
||||||
|
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
|
||||||
|
|
||||||
const lowerType = dataType.toLowerCase();
|
const lowerType = dataType.toLowerCase();
|
||||||
|
|
||||||
if (lowerType.includes("char") || lowerType.includes("text")) {
|
// 정확한 매핑 우선 확인
|
||||||
return "text";
|
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
|
||||||
} else if (
|
return DB_TYPE_TO_WEB_TYPE[lowerType];
|
||||||
lowerType.includes("int") ||
|
}
|
||||||
lowerType.includes("numeric") ||
|
|
||||||
lowerType.includes("decimal")
|
// 부분 문자열 매칭 (더 정교한 규칙)
|
||||||
|
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";
|
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";
|
return "date";
|
||||||
|
} else if (lowerType.includes("time")) {
|
||||||
|
return "datetime";
|
||||||
} else if (lowerType.includes("bool")) {
|
} else if (lowerType.includes("bool")) {
|
||||||
return "checkbox";
|
return "checkbox";
|
||||||
} else {
|
} else if (
|
||||||
return "text";
|
lowerType.includes("char") ||
|
||||||
|
lowerType.includes("text") ||
|
||||||
|
lowerType.includes("varchar")
|
||||||
|
) {
|
||||||
|
return lowerType.includes("text") ? "textarea" : "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
EntityJoinResponse,
|
EntityJoinResponse,
|
||||||
EntityJoinConfig,
|
EntityJoinConfig,
|
||||||
} from "../types/tableManagement";
|
} from "../types/tableManagement";
|
||||||
|
import { WebType } from "../types/unified-web-types";
|
||||||
import { entityJoinService } from "./entityJoinService";
|
import { entityJoinService } from "./entityJoinService";
|
||||||
import { referenceCacheService } from "./referenceCacheService";
|
import { referenceCacheService } from "./referenceCacheService";
|
||||||
|
|
||||||
|
|
@ -210,6 +211,11 @@ export class TableManagementService {
|
||||||
: null,
|
: null,
|
||||||
numericScale: column.numericScale ? Number(column.numericScale) : null,
|
numericScale: column.numericScale ? Number(column.numericScale) : null,
|
||||||
displayOrder: column.displayOrder ? Number(column.displayOrder) : 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);
|
const totalPages = Math.ceil(total / size);
|
||||||
|
|
@ -2267,4 +2273,229 @@ export class TableManagementService {
|
||||||
|
|
||||||
return totalHitRate / cacheableJoins.length;
|
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 getColumnWebTypes(tableName: string): Promise<ColumnTypeInfo[]> {
|
||||||
|
try {
|
||||||
|
logger.info(`컬럼 웹타입 정보 조회: ${tableName}`);
|
||||||
|
|
||||||
|
// table_type_columns에서 웹타입 정보 조회
|
||||||
|
const rawWebTypes = await prisma.$queryRaw<any[]>`
|
||||||
|
SELECT
|
||||||
|
ttc.column_name as "columnName",
|
||||||
|
ttc.column_name as "displayName",
|
||||||
|
COALESCE(ttc.web_type, 'text') as "webType",
|
||||||
|
COALESCE(ttc.detail_settings, '{}') as "detailSettings",
|
||||||
|
ttc.is_nullable as "isNullable",
|
||||||
|
ic.data_type as "dataType",
|
||||||
|
ic.udt_name as "dbType"
|
||||||
|
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 webTypes: ColumnTypeInfo[] = rawWebTypes.map((col) => ({
|
||||||
|
tableName: tableName,
|
||||||
|
columnName: col.columnName,
|
||||||
|
displayName: col.displayName,
|
||||||
|
dataType: col.dataType || "text",
|
||||||
|
dbType: col.dbType || "text",
|
||||||
|
webType: col.webType,
|
||||||
|
inputType: "direct",
|
||||||
|
detailSettings: col.detailSettings,
|
||||||
|
description: "", // 필수 필드 추가
|
||||||
|
isNullable: col.isNullable,
|
||||||
|
isPrimaryKey: false,
|
||||||
|
displayOrder: 0,
|
||||||
|
isVisible: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`컬럼 웹타입 정보 조회 완료: ${tableName}, ${webTypes.length}개 컬럼`
|
||||||
|
);
|
||||||
|
return webTypes;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`컬럼 웹타입 정보 조회 실패: ${tableName}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 연결 상태 확인
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,8 @@
|
||||||
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
|
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
|
||||||
|
|
||||||
// 웹 타입 정의
|
// 웹 타입 정의
|
||||||
export type WebType =
|
// WebType은 통합 타입에서 import (중복 정의 제거)
|
||||||
| "text"
|
export { WebType } from "./unified-web-types";
|
||||||
| "number"
|
|
||||||
| "date"
|
|
||||||
| "code"
|
|
||||||
| "entity"
|
|
||||||
| "textarea"
|
|
||||||
| "select"
|
|
||||||
| "checkbox"
|
|
||||||
| "radio"
|
|
||||||
| "file";
|
|
||||||
|
|
||||||
// 위치 정보
|
// 위치 정보
|
||||||
export interface Position {
|
export interface Position {
|
||||||
|
|
|
||||||
|
|
@ -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 }, // 레거시 지원
|
||||||
|
};
|
||||||
|
|
@ -135,12 +135,35 @@ export default function TableManagementPage() {
|
||||||
[], // 의존성 배열에서 referenceTableColumns 제거
|
[], // 의존성 배열에서 referenceTableColumns 제거
|
||||||
);
|
);
|
||||||
|
|
||||||
// 웹 타입 옵션 (다국어 적용)
|
// 웹 타입 옵션 (한글 직접 표시)
|
||||||
const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => ({
|
const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => {
|
||||||
|
// 한국어 라벨 직접 매핑 (다국어 키값 대신)
|
||||||
|
const koreanLabels: Record<string, string> = {
|
||||||
|
text: "텍스트",
|
||||||
|
number: "숫자",
|
||||||
|
date: "날짜",
|
||||||
|
code: "코드",
|
||||||
|
entity: "엔티티",
|
||||||
|
textarea: "텍스트 영역",
|
||||||
|
select: "선택박스",
|
||||||
|
checkbox: "체크박스",
|
||||||
|
radio: "라디오버튼",
|
||||||
|
file: "파일",
|
||||||
|
decimal: "소수",
|
||||||
|
datetime: "날짜시간",
|
||||||
|
boolean: "불린",
|
||||||
|
email: "이메일",
|
||||||
|
tel: "전화번호",
|
||||||
|
url: "URL",
|
||||||
|
dropdown: "드롭다운",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
value: option.value,
|
value: option.value,
|
||||||
label: getTextFromUI(option.labelKey, option.value),
|
label: koreanLabels[option.value] || option.value,
|
||||||
description: getTextFromUI(option.descriptionKey, option.value),
|
description: koreanLabels[option.value] || option.value,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 메모이제이션된 웹타입 옵션
|
// 메모이제이션된 웹타입 옵션
|
||||||
const memoizedWebTypeOptions = useMemo(() => webTypeOptions, [uiTexts]);
|
const memoizedWebTypeOptions = useMemo(() => webTypeOptions, [uiTexts]);
|
||||||
|
|
|
||||||
|
|
@ -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 { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
import { EditModal } from "@/components/screen/EditModal";
|
import { EditModal } from "@/components/screen/EditModal";
|
||||||
|
import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer";
|
||||||
|
|
||||||
export default function ScreenViewPage() {
|
export default function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -146,7 +147,7 @@ export default function ScreenViewPage() {
|
||||||
const screenHeight = layout?.screenResolution?.height || 800;
|
const screenHeight = layout?.screenResolution?.height || 800;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-auto bg-white">
|
<ResponsiveScreenContainer designWidth={screenWidth} designHeight={screenHeight} screenName={screen?.screenName}>
|
||||||
{layout && layout.components.length > 0 ? (
|
{layout && layout.components.length > 0 ? (
|
||||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||||
<div
|
<div
|
||||||
|
|
@ -406,6 +407,6 @@ export default function ScreenViewPage() {
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ResponsiveScreenContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -420,7 +420,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 가운데 컨텐츠 영역 */}
|
{/* 가운데 컨텐츠 영역 */}
|
||||||
<main className="bg-background flex-1 p-6">{children}</main>
|
<main className="bg-background flex-1">{children}</main>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -31,13 +31,17 @@ import {
|
||||||
CodeTypeConfig,
|
CodeTypeConfig,
|
||||||
EntityTypeConfig,
|
EntityTypeConfig,
|
||||||
ButtonTypeConfig,
|
ButtonTypeConfig,
|
||||||
} from "@/types/screen";
|
} from "@/types";
|
||||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||||
import { FileUpload } from "./widgets/FileUpload";
|
import { FileUpload } from "./widgets/FileUpload";
|
||||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
|
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 {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -49,6 +53,16 @@ interface InteractiveScreenViewerProps {
|
||||||
id: number;
|
id: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
};
|
};
|
||||||
|
// 새로운 검증 관련 옵션들
|
||||||
|
enableEnhancedValidation?: boolean;
|
||||||
|
tableColumns?: ColumnInfo[];
|
||||||
|
showValidationPanel?: boolean;
|
||||||
|
validationOptions?: {
|
||||||
|
enableRealTimeValidation?: boolean;
|
||||||
|
validationDelay?: number;
|
||||||
|
enableAutoSave?: boolean;
|
||||||
|
showToastMessages?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
|
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
|
||||||
|
|
@ -58,6 +72,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
screenInfo,
|
screenInfo,
|
||||||
|
enableEnhancedValidation = false,
|
||||||
|
tableColumns = [],
|
||||||
|
showValidationPanel = false,
|
||||||
|
validationOptions = {},
|
||||||
}) => {
|
}) => {
|
||||||
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
||||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||||
|
|
@ -79,6 +97,33 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
// 팝업 전용 formData 상태
|
// 팝업 전용 formData 상태
|
||||||
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
|
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 generateAutoValue = useCallback((autoValueType: string): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -1104,20 +1149,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 저장 액션
|
// 저장 액션 (개선된 버전)
|
||||||
const handleSaveAction = async () => {
|
const handleSaveAction = async () => {
|
||||||
// 저장 시점에서 최신 formData 구성
|
console.log("💾 저장 시작");
|
||||||
|
|
||||||
|
// 개선된 검증 시스템이 활성화된 경우
|
||||||
|
if (enhancedValidation) {
|
||||||
|
console.log("🔍 개선된 검증 시스템 사용");
|
||||||
|
const success = await enhancedValidation.saveForm();
|
||||||
|
if (success) {
|
||||||
|
toast.success("데이터가 성공적으로 저장되었습니다!");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 방식 (레거시 지원)
|
||||||
const currentFormData = { ...localFormData, ...externalFormData };
|
const currentFormData = { ...localFormData, ...externalFormData };
|
||||||
console.log("💾 저장 시작 - currentFormData:", currentFormData);
|
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})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
|
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
|
||||||
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
|
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 className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||||
</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={() => {
|
<Dialog open={!!popupScreen} onOpenChange={() => {
|
||||||
setPopupScreen(null);
|
setPopupScreen(null);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -56,20 +56,12 @@ interface RealtimePreviewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영역 레이아웃에 따른 아이콘 반환
|
// 영역 레이아웃에 따른 아이콘 반환
|
||||||
const getAreaIcon = (layoutType: AreaLayoutType) => {
|
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
|
||||||
switch (layoutType) {
|
switch (layoutDirection) {
|
||||||
case "flex-row":
|
case "horizontal":
|
||||||
return <Layout className="h-4 w-4 text-blue-600" />;
|
return <Layout className="h-4 w-4 text-blue-600" />;
|
||||||
case "grid":
|
case "vertical":
|
||||||
return <Grid3x3 className="h-4 w-4 text-green-600" />;
|
|
||||||
case "flex-column":
|
|
||||||
return <Columns className="h-4 w-4 text-purple-600" />;
|
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:
|
default:
|
||||||
return <Square className="h-4 w-4 text-gray-500" />;
|
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 renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||||
const area = component as AreaComponent;
|
if (!isContainerComponent(component) || component.type !== "area") {
|
||||||
const { layoutType, title } = area;
|
return null;
|
||||||
|
}
|
||||||
|
const area = component;
|
||||||
|
const { layoutDirection, label } = area;
|
||||||
|
|
||||||
const renderPlaceholder = () => (
|
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="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{getAreaIcon(layoutType)}
|
{getAreaIcon(layoutDirection)}
|
||||||
<p className="mt-2 text-sm text-gray-600">{title || `${layoutType} 영역`}</p>
|
<p className="mt-2 text-sm text-gray-600">{label || `${layoutDirection || "기본"} 영역`}</p>
|
||||||
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,11 +97,11 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||||
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
// 동적 웹 타입 위젯 렌더링 컴포넌트
|
||||||
const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => {
|
const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => {
|
||||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||||
if (component.type !== "widget") {
|
if (!isWidgetComponent(component)) {
|
||||||
return <div className="text-xs text-gray-500">위젯이 아닙니다</div>;
|
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;
|
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
|
||||||
|
|
||||||
// 디버깅: 실제 widgetType 값 확인
|
// 디버깅: 실제 widgetType 값 확인
|
||||||
|
|
@ -180,7 +175,6 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
|
||||||
case "dropdown":
|
case "dropdown":
|
||||||
return <List className="h-4 w-4 text-orange-600" />;
|
return <List className="h-4 w-4 text-orange-600" />;
|
||||||
case "textarea":
|
case "textarea":
|
||||||
case "text_area":
|
|
||||||
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
||||||
case "boolean":
|
case "boolean":
|
||||||
case "checkbox":
|
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">
|
<div className="absolute -top-6 left-0 rounded bg-blue-600 px-2 py-1 text-xs text-white">
|
||||||
{type === "widget" && (
|
{type === "widget" && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
{getWidgetIcon(isWidgetComponent(component) ? (component.widgetType as WebType) : undefined)}
|
||||||
{(component as WidgetComponent).widgetType || "widget"}
|
{isWidgetComponent(component) ? component.widgetType || "widget" : component.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{type !== "widget" && type}
|
{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>("fit");
|
||||||
|
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,162 @@
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Monitor, Smartphone, Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import { useContainerSize } from "@/hooks/useViewportSize";
|
||||||
|
|
||||||
|
interface ResponsiveScreenContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
designWidth: number;
|
||||||
|
designHeight: number;
|
||||||
|
screenName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = "fit" | "scale" | "original" | "fullwidth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 반응형 화면 컨테이너
|
||||||
|
* 다양한 모니터 크기에 맞춰 화면을 자동 조정합니다.
|
||||||
|
*/
|
||||||
|
export const ResponsiveScreenContainer: React.FC<ResponsiveScreenContainerProps> = ({
|
||||||
|
children,
|
||||||
|
designWidth,
|
||||||
|
designHeight,
|
||||||
|
screenName,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("fit");
|
||||||
|
const containerSize = useContainerSize(containerRef);
|
||||||
|
|
||||||
|
// 스케일 계산 (실시간 계산으로 변경)
|
||||||
|
const calculateScale = (): number => {
|
||||||
|
if (containerSize.width === 0 || containerSize.height === 0) return 1;
|
||||||
|
|
||||||
|
let newScale = 1;
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case "fit":
|
||||||
|
// 컨테이너에 맞춰 비율 유지하며 조정 (여백 허용)
|
||||||
|
const scaleX = (containerSize.width - 40) / designWidth; // 20px 여백
|
||||||
|
const scaleY = (containerSize.height - 40) / designHeight; // 20px 여백
|
||||||
|
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배까지만
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "scale":
|
||||||
|
// 컨테이너를 가득 채우도록 조정 (비율 유지)
|
||||||
|
const fillScaleX = containerSize.width / designWidth;
|
||||||
|
const fillScaleY = containerSize.height / designHeight;
|
||||||
|
newScale = Math.min(fillScaleX, fillScaleY);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fullwidth":
|
||||||
|
// 가로폭을 컨테이너에 맞춤 (세로는 비율 유지)
|
||||||
|
newScale = containerSize.width / designWidth;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "original":
|
||||||
|
default:
|
||||||
|
// 원본 크기 유지
|
||||||
|
newScale = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(newScale, 0.1); // 최소 0.1배
|
||||||
|
};
|
||||||
|
|
||||||
|
const scale = calculateScale();
|
||||||
|
|
||||||
|
const getViewModeInfo = (mode: ViewMode) => {
|
||||||
|
switch (mode) {
|
||||||
|
case "fit":
|
||||||
|
return {
|
||||||
|
label: "화면 맞춤",
|
||||||
|
description: "모니터 크기에 맞춰 비율 유지하며 조정",
|
||||||
|
icon: <Monitor className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
case "scale":
|
||||||
|
return {
|
||||||
|
label: "전체 채움",
|
||||||
|
description: "화면을 가득 채우도록 조정",
|
||||||
|
icon: <Maximize2 className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
case "fullwidth":
|
||||||
|
return {
|
||||||
|
label: "가로 맞춤",
|
||||||
|
description: "가로폭을 화면에 맞춤",
|
||||||
|
icon: <Smartphone className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
case "original":
|
||||||
|
return {
|
||||||
|
label: "원본 크기",
|
||||||
|
description: "설계된 원본 크기로 표시",
|
||||||
|
icon: <Minimize2 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: viewMode === "original" ? "auto" : "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-gray-50">
|
||||||
|
{/* 상단 컨트롤 바 */}
|
||||||
|
<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">
|
||||||
|
{(["fit", "scale", "fullwidth", "original"] as ViewMode[]).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-4"
|
||||||
|
style={{
|
||||||
|
justifyContent: viewMode === "original" ? "flex-start" : "center",
|
||||||
|
alignItems: viewMode === "original" ? "flex-start" : "center",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={wrapperStyle}>
|
||||||
|
<div style={screenStyle}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResponsiveScreenContainer;
|
||||||
|
|
@ -351,71 +351,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||||
[tables, onUpdateComponent, localValues.tableName],
|
[tables, onUpdateComponent, localValues.tableName],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컬럼 타입 추론
|
// 컬럼 타입 추론 (통합 매핑 시스템 사용)
|
||||||
const getWidgetTypeFromColumn = (column: ColumnInfo): WebType => {
|
const getWidgetTypeFromColumn = (column: ColumnInfo): WebType => {
|
||||||
const type = column.dataType?.toLowerCase() || "";
|
// 통합 자동 매핑 유틸리티 사용
|
||||||
const name = column.columnName.toLowerCase();
|
const { inferWebTypeFromColumn } = require("@/lib/utils/dbTypeMapping");
|
||||||
|
|
||||||
console.log("🔍 웹타입 추론:", {
|
return inferWebTypeFromColumn(column.dataType || "text", column.columnName);
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 업데이트
|
// 컬럼 업데이트
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,25 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Progress({
|
const Progress = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
value,
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
...props
|
>(({ className, value, ...props }, ref) => (
|
||||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<ProgressPrimitive.Root
|
<ProgressPrimitive.Root
|
||||||
data-slot="progress"
|
ref={ref}
|
||||||
className={cn(
|
className={cn("bg-secondary relative h-4 w-full overflow-hidden rounded-full", className)}
|
||||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
data-slot="progress-indicator"
|
|
||||||
className="bg-primary h-full w-full flex-1 transition-all"
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</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_RADIO_DESC: "table.management.web.type.radio.description",
|
||||||
WEB_TYPE_FILE: "table.management.web.type.file",
|
WEB_TYPE_FILE: "table.management.web.type.file",
|
||||||
WEB_TYPE_FILE_DESC: "table.management.web.type.file.description",
|
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 요소
|
// 공통 UI 요소
|
||||||
BUTTON_REFRESH: "table.management.button.refresh",
|
BUTTON_REFRESH: "table.management.button.refresh",
|
||||||
|
|
@ -135,4 +149,39 @@ export const WEB_TYPE_OPTIONS_WITH_KEYS = [
|
||||||
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE,
|
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE,
|
||||||
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE_DESC,
|
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;
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,472 @@
|
||||||
import { useState } from "react";
|
/**
|
||||||
|
* 폼 검증 상태 관리 훅
|
||||||
|
* 실시간 검증과 사용자 피드백을 위한 커스텀 훅
|
||||||
|
*/
|
||||||
|
|
||||||
export interface ValidationState {
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
enabled: boolean;
|
import { ComponentData, ColumnInfo, ScreenDefinition } from "@/types/screen";
|
||||||
value: string;
|
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[];
|
export interface SaveState {
|
||||||
initialStates?: Partial<ValidationStates>;
|
status: "idle" | "saving" | "success" | "error";
|
||||||
|
message?: string;
|
||||||
|
result?: EnhancedSaveResult;
|
||||||
|
lastSaved?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFormValidation({ fields, initialStates = {} }: UseFormValidationProps) {
|
// 훅 옵션
|
||||||
// 검증 상태 초기화
|
export interface UseFormValidationOptions {
|
||||||
const initValidationStates = (): ValidationStates => {
|
enableRealTimeValidation?: boolean;
|
||||||
const states: ValidationStates = {};
|
validationDelay?: number; // debounce 지연시간 (ms)
|
||||||
fields.forEach((field) => {
|
enableAutoSave?: boolean;
|
||||||
states[field] = initialStates[field] || { enabled: false, value: "" };
|
autoSaveDelay?: number; // 자동저장 지연시간 (ms)
|
||||||
});
|
showToastMessages?: boolean;
|
||||||
return states;
|
validateOnMount?: boolean;
|
||||||
};
|
|
||||||
|
|
||||||
const [validationStates, setValidationStates] = useState<ValidationStates>(initValidationStates);
|
|
||||||
|
|
||||||
// 특정 필드의 검증 상태 업데이트
|
|
||||||
const updateFieldValidation = (fieldName: string, value: string) => {
|
|
||||||
setValidationStates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[fieldName]: { enabled: true, value: value.trim() },
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// onBlur 핸들러 생성
|
|
||||||
const createBlurHandler =
|
|
||||||
(fieldName: string) => (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
||||||
const value = event.target.value.trim();
|
|
||||||
if (value) {
|
|
||||||
updateFieldValidation(fieldName, value);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// 모든 필수 필드가 검증되었는지 확인
|
// 훅 반환값
|
||||||
const areAllFieldsValidated = (requiredFields?: string[]) => {
|
export interface UseFormValidationReturn {
|
||||||
const fieldsToCheck = requiredFields || fields;
|
|
||||||
return fieldsToCheck.every((field) => validationStates[field]?.enabled);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검증 상태 초기화
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 특정 필드 검증 상태 확인
|
|
||||||
const isFieldValidated = (fieldName: string) => validationStates[fieldName]?.enabled || false;
|
|
||||||
|
|
||||||
// 필드 값 가져오기
|
|
||||||
const getFieldValue = (fieldName: string) => validationStates[fieldName]?.value || "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 상태
|
// 상태
|
||||||
validationStates,
|
validationState: FormValidationState;
|
||||||
|
saveState: SaveState;
|
||||||
|
|
||||||
// 액션
|
// 액션
|
||||||
updateFieldValidation,
|
validateForm: () => Promise<ValidationResult>;
|
||||||
resetValidation,
|
validateField: (fieldName: string, value: any) => Promise<void>;
|
||||||
|
saveForm: () => Promise<boolean>;
|
||||||
|
clearValidation: () => void;
|
||||||
|
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
createBlurHandler,
|
getFieldError: (fieldName: string) => ValidationError | undefined;
|
||||||
areAllFieldsValidated,
|
getFieldWarning: (fieldName: string) => ValidationWarning | undefined;
|
||||||
isFieldValidated,
|
hasFieldError: (fieldName: string) => boolean;
|
||||||
getFieldValue,
|
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,
|
||||||
|
status: "validating",
|
||||||
|
}));
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 자동 저장
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableAutoSave || !validationState.isValid) return;
|
||||||
|
|
||||||
|
// 이전 타이머 클리어
|
||||||
|
if (autoSaveTimer.current) {
|
||||||
|
clearTimeout(autoSaveTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 타이머 설정
|
||||||
|
autoSaveTimer.current = setTimeout(() => {
|
||||||
|
saveForm();
|
||||||
|
}, autoSaveDelay);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -28,7 +28,7 @@ export interface FormDataResponse {
|
||||||
// 동적 폼 API 클래스
|
// 동적 폼 API 클래스
|
||||||
export class DynamicFormApi {
|
export class DynamicFormApi {
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 저장
|
* 폼 데이터 저장 (기존 버전 - 레거시 지원)
|
||||||
* @param formData 저장할 폼 데이터
|
* @param formData 저장할 폼 데이터
|
||||||
* @returns 저장 결과
|
* @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
|
* @param id 레코드 ID
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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 {
|
import {
|
||||||
ButtonActionType,
|
ButtonActionType,
|
||||||
ButtonTypeConfig,
|
ExtendedButtonTypeConfig,
|
||||||
ButtonDataflowConfig,
|
ButtonDataflowConfig,
|
||||||
DataflowExecutionResult,
|
DataflowExecutionResult,
|
||||||
DataflowCondition,
|
DataflowCondition,
|
||||||
} from "@/types/screen";
|
} from "@/types";
|
||||||
import { dataflowConfigCache } from "./dataflowCache";
|
import { dataflowConfigCache } from "./dataflowCache";
|
||||||
import { dataflowJobQueue, JobPriority } from "./dataflowJobQueue";
|
import { dataflowJobQueue, JobPriority } from "./dataflowJobQueue";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -72,7 +72,7 @@ export class OptimizedButtonDataflowService {
|
||||||
static async executeButtonWithDataflow(
|
static async executeButtonWithDataflow(
|
||||||
buttonId: string,
|
buttonId: string,
|
||||||
actionType: ButtonActionType,
|
actionType: ButtonActionType,
|
||||||
buttonConfig: ButtonTypeConfig,
|
buttonConfig: ExtendedExtendedButtonTypeConfig,
|
||||||
contextData: Record<string, any>,
|
contextData: Record<string, any>,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<OptimizedExecutionResult> {
|
): Promise<OptimizedExecutionResult> {
|
||||||
|
|
@ -115,7 +115,7 @@ export class OptimizedButtonDataflowService {
|
||||||
private static async executeAfterTiming(
|
private static async executeAfterTiming(
|
||||||
buttonId: string,
|
buttonId: string,
|
||||||
actionType: ButtonActionType,
|
actionType: ButtonActionType,
|
||||||
buttonConfig: ButtonTypeConfig,
|
buttonConfig: ExtendedButtonTypeConfig,
|
||||||
contextData: Record<string, any>,
|
contextData: Record<string, any>,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<OptimizedExecutionResult> {
|
): Promise<OptimizedExecutionResult> {
|
||||||
|
|
@ -155,7 +155,7 @@ export class OptimizedButtonDataflowService {
|
||||||
private static async executeBeforeTiming(
|
private static async executeBeforeTiming(
|
||||||
buttonId: string,
|
buttonId: string,
|
||||||
actionType: ButtonActionType,
|
actionType: ButtonActionType,
|
||||||
buttonConfig: ButtonTypeConfig,
|
buttonConfig: ExtendedButtonTypeConfig,
|
||||||
contextData: Record<string, any>,
|
contextData: Record<string, any>,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<OptimizedExecutionResult> {
|
): Promise<OptimizedExecutionResult> {
|
||||||
|
|
@ -228,7 +228,7 @@ export class OptimizedButtonDataflowService {
|
||||||
private static async executeReplaceTiming(
|
private static async executeReplaceTiming(
|
||||||
buttonId: string,
|
buttonId: string,
|
||||||
actionType: ButtonActionType,
|
actionType: ButtonActionType,
|
||||||
buttonConfig: ButtonTypeConfig,
|
buttonConfig: ExtendedButtonTypeConfig,
|
||||||
contextData: Record<string, any>,
|
contextData: Record<string, any>,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<OptimizedExecutionResult> {
|
): Promise<OptimizedExecutionResult> {
|
||||||
|
|
@ -623,7 +623,7 @@ export class OptimizedButtonDataflowService {
|
||||||
*/
|
*/
|
||||||
private static async executeOriginalAction(
|
private static async executeOriginalAction(
|
||||||
actionType: ButtonActionType,
|
actionType: ButtonActionType,
|
||||||
buttonConfig: ButtonTypeConfig,
|
buttonConfig: ExtendedButtonTypeConfig,
|
||||||
contextData: Record<string, any>,
|
contextData: Record<string, any>,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const startTime = performance.now();
|
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: 실제 저장 로직 구현
|
// TODO: 실제 저장 로직 구현
|
||||||
return { success: true, message: "저장되었습니다." };
|
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: 실제 삭제 로직 구현
|
// TODO: 실제 삭제 로직 구현
|
||||||
return { success: true, message: "삭제되었습니다." };
|
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: 실제 검색 로직 구현
|
// TODO: 실제 검색 로직 구현
|
||||||
return { success: true, message: "검색되었습니다.", data: [] };
|
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: "수정 모드로 전환되었습니다." };
|
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: "추가 모드로 전환되었습니다." };
|
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: "초기화되었습니다." };
|
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: "제출되었습니다." };
|
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: "닫기 액션이 실행되었습니다." };
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "팝업이 열렸습니다.",
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "페이지 이동이 실행되었습니다.",
|
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 @@
|
||||||
|
/**
|
||||||
|
* 🎯 통합 타입 시스템 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
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 원칙 적용
|
||||||
|
- 데이터 정합성 확보
|
||||||
|
|
||||||
|
### 유연한 비즈니스 로직
|
||||||
|
|
||||||
|
- 복잡한 조건부 실행 지원
|
||||||
|
- 대안 액션 자동 실행
|
||||||
|
- 비즈니스 요구사항 정확한 반영
|
||||||
|
|
||||||
|
### 시스템 안정성 향상
|
||||||
|
|
||||||
|
- 실패 시 자동 복구
|
||||||
|
- 상세한 실행 로그
|
||||||
|
- 문제 상황 신속 파악
|
||||||
|
|
||||||
|
이 개선방안에 대한 의견이나 우선순위 조정이 필요한 부분이 있으시면 말씀해 주세요!
|
||||||
|
|
@ -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