lhj #50

Merged
hjlee merged 14 commits from lhj into dev 2025-09-23 16:07:59 +09:00
94 changed files with 22053 additions and 2103 deletions

View File

@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@ -126,6 +127,7 @@ app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@ -0,0 +1,419 @@
/**
* DDL
* / API
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/superAdminMiddleware";
import { DDLExecutionService } from "../services/ddlExecutionService";
import { DDLAuditLogger } from "../services/ddlAuditLogger";
import { CreateTableRequest, AddColumnRequest } from "../types/ddl";
import { logger } from "../utils/logger";
export class DDLController {
/**
* POST /api/ddl/tables -
*/
static async createTable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columns, description }: CreateTableRequest = req.body;
const userId = req.user!.userId;
const userCompanyCode = req.user!.companyCode;
// 입력값 기본 검증
if (!tableName || !columns || columns.length === 0) {
res.status(400).json({
success: false,
error: {
code: "INVALID_INPUT",
details: "테이블명과 최소 1개의 컬럼이 필요합니다.",
},
});
return;
}
logger.info("테이블 생성 요청", {
tableName,
userId,
columnCount: columns.length,
ip: req.ip,
});
// inputType을 webType으로 변환 (레거시 호환성)
const processedColumns = columns.map((col) => ({
...col,
webType: (col.inputType || col.webType || "text") as any,
}));
// DDL 실행 서비스 호출
const ddlService = new DDLExecutionService();
const result = await ddlService.createTable(
tableName,
processedColumns,
userCompanyCode,
userId,
description
);
if (result.success) {
res.status(200).json({
success: true,
message: result.message,
data: {
tableName,
columnCount: columns.length,
executedQuery: result.executedQuery,
},
});
} else {
res.status(400).json({
success: false,
message: result.message,
error: result.error,
});
}
} catch (error) {
logger.error("테이블 생성 컨트롤러 오류:", {
error: (error as Error).message,
stack: (error as Error).stack,
userId: req.user?.userId,
body: req.body,
});
res.status(500).json({
success: false,
error: {
code: "INTERNAL_SERVER_ERROR",
details: "테이블 생성 중 서버 오류가 발생했습니다.",
},
});
}
}
/**
* POST /api/ddl/tables/:tableName/columns -
*/
static async addColumn(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { column }: AddColumnRequest = req.body;
const userId = req.user!.userId;
const userCompanyCode = req.user!.companyCode;
// 입력값 기본 검증
if (!tableName) {
res.status(400).json({
success: false,
error: {
code: "INVALID_INPUT",
details: "테이블명이 필요합니다.",
},
});
return;
}
if (!column || !column.name || (!column.inputType && !column.webType)) {
res.status(400).json({
success: false,
error: {
code: "INVALID_INPUT",
details: "컬럼명과 입력타입이 필요합니다.",
},
});
return;
}
logger.info("컬럼 추가 요청", {
tableName,
columnName: column.name,
webType: column.webType,
userId,
ip: req.ip,
});
// inputType을 webType으로 변환 (레거시 호환성)
const processedColumn = {
...column,
webType: (column.inputType || column.webType || "text") as any,
};
// DDL 실행 서비스 호출
const ddlService = new DDLExecutionService();
const result = await ddlService.addColumn(
tableName,
processedColumn,
userCompanyCode,
userId
);
if (result.success) {
res.status(200).json({
success: true,
message: result.message,
data: {
tableName,
columnName: column.name,
webType: column.webType,
executedQuery: result.executedQuery,
},
});
} else {
res.status(400).json({
success: false,
message: result.message,
error: result.error,
});
}
} catch (error) {
logger.error("컬럼 추가 컨트롤러 오류:", {
error: (error as Error).message,
stack: (error as Error).stack,
userId: req.user?.userId,
tableName: req.params.tableName,
body: req.body,
});
res.status(500).json({
success: false,
error: {
code: "INTERNAL_SERVER_ERROR",
details: "컬럼 추가 중 서버 오류가 발생했습니다.",
},
});
}
}
/**
* GET /api/ddl/logs - DDL
*/
static async getDDLLogs(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { limit, userId, ddlType } = req.query;
const logs = await DDLAuditLogger.getRecentDDLLogs(
limit ? parseInt(limit as string) : 50,
userId as string,
ddlType as string
);
res.json({
success: true,
data: {
logs,
total: logs.length,
},
});
} catch (error) {
logger.error("DDL 로그 조회 오류:", error);
res.status(500).json({
success: false,
error: {
code: "LOG_RETRIEVAL_FAILED",
details: "DDL 로그 조회 중 오류가 발생했습니다.",
},
});
}
}
/**
* GET /api/ddl/statistics - DDL
*/
static async getDDLStatistics(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { fromDate, toDate } = req.query;
const statistics = await DDLAuditLogger.getDDLStatistics(
fromDate ? new Date(fromDate as string) : undefined,
toDate ? new Date(toDate as string) : undefined
);
res.json({
success: true,
data: statistics,
});
} catch (error) {
logger.error("DDL 통계 조회 오류:", error);
res.status(500).json({
success: false,
error: {
code: "STATISTICS_RETRIEVAL_FAILED",
details: "DDL 통계 조회 중 오류가 발생했습니다.",
},
});
}
}
/**
* GET /api/ddl/tables/:tableName/info -
*/
static async getTableInfo(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const ddlService = new DDLExecutionService();
const tableInfo = await ddlService.getCreatedTableInfo(tableName);
if (!tableInfo) {
res.status(404).json({
success: false,
error: {
code: "TABLE_NOT_FOUND",
details: `테이블 '${tableName}'을 찾을 수 없습니다.`,
},
});
return;
}
res.json({
success: true,
data: tableInfo,
});
} catch (error) {
logger.error("테이블 정보 조회 오류:", error);
res.status(500).json({
success: false,
error: {
code: "TABLE_INFO_RETRIEVAL_FAILED",
details: "테이블 정보 조회 중 오류가 발생했습니다.",
},
});
}
}
/**
* GET /api/ddl/tables/:tableName/history - DDL
*/
static async getTableDDLHistory(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const history = await DDLAuditLogger.getTableDDLHistory(tableName);
res.json({
success: true,
data: {
tableName,
history,
total: history.length,
},
});
} catch (error) {
logger.error("테이블 DDL 히스토리 조회 오류:", error);
res.status(500).json({
success: false,
error: {
code: "HISTORY_RETRIEVAL_FAILED",
details: "테이블 DDL 히스토리 조회 중 오류가 발생했습니다.",
},
});
}
}
/**
* POST /api/ddl/validate/table -
*/
static async validateTableCreation(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columns }: CreateTableRequest = req.body;
if (!tableName || !columns) {
res.status(400).json({
success: false,
error: {
code: "INVALID_INPUT",
details: "테이블명과 컬럼 정보가 필요합니다.",
},
});
return;
}
// 검증만 수행 (실제 생성하지 않음)
const { DDLSafetyValidator } = await import(
"../services/ddlSafetyValidator"
);
const validationReport = DDLSafetyValidator.generateValidationReport(
tableName,
columns
);
res.json({
success: true,
data: {
isValid: validationReport.validationResult.isValid,
errors: validationReport.validationResult.errors,
warnings: validationReport.validationResult.warnings,
summary: validationReport.summary,
},
});
} catch (error) {
logger.error("테이블 생성 검증 오류:", error);
res.status(500).json({
success: false,
error: {
code: "VALIDATION_ERROR",
details: "테이블 생성 검증 중 오류가 발생했습니다.",
},
});
}
}
/**
* DELETE /api/ddl/logs/cleanup - DDL
*/
static async cleanupOldLogs(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { retentionDays } = req.query;
const days = retentionDays ? parseInt(retentionDays as string) : 90;
const deletedCount = await DDLAuditLogger.cleanupOldLogs(days);
res.json({
success: true,
message: `${deletedCount}개의 오래된 DDL 로그가 삭제되었습니다.`,
data: {
deletedCount,
retentionDays: days,
},
});
} catch (error) {
logger.error("DDL 로그 정리 오류:", error);
res.status(500).json({
success: false,
error: {
code: "LOG_CLEANUP_FAILED",
details: "DDL 로그 정리 중 오류가 발생했습니다.",
},
});
}
}
}

View File

@ -1,8 +1,9 @@
import { Response } from "express";
import { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { AuthenticatedRequest } from "../types/auth";
// 폼 데이터 저장
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
export const saveFormData = async (
req: AuthenticatedRequest,
res: Response
@ -55,6 +56,55 @@ export const saveFormData = async (
}
};
// 개선된 폼 데이터 저장 (새 버전)
export const saveFormDataEnhanced = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { screenId, tableName, data } = req.body;
// 필수 필드 검증
if (screenId === undefined || screenId === null || !tableName || !data) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (screenId, tableName, data)",
});
}
// 메타데이터 추가
const formDataWithMeta = {
...data,
created_by: userId,
updated_by: userId,
screen_id: screenId,
};
// company_code 처리
if (data.company_code !== undefined) {
formDataWithMeta.company_code = data.company_code;
} else if (companyCode && companyCode !== "*") {
formDataWithMeta.company_code = companyCode;
}
// 개선된 서비스 사용
const result = await enhancedDynamicFormService.saveFormData(
screenId,
tableName,
formDataWithMeta
);
res.json(result);
} catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
res.status(500).json({
success: false,
message: error.message || "데이터 저장에 실패했습니다.",
});
}
};
// 폼 데이터 업데이트
export const updateFormData = async (
req: AuthenticatedRequest,

View File

@ -443,24 +443,24 @@ export async function updateTableLabel(
}
/**
*
*
*/
export async function updateColumnWebType(
export async function updateColumnInputType(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { webType, detailSettings, inputType } = req.body;
const { inputType, detailSettings } = req.body;
logger.info(
`=== 컬럼 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===`
`=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType} ===`
);
if (!tableName || !columnName || !webType) {
if (!tableName || !columnName || !inputType) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명, 컬럼명, 타입이 모두 필요합니다.",
message: "테이블명, 컬럼명, 입력 타입이 모두 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "필수 파라미터가 누락되었습니다.",
@ -471,33 +471,32 @@ export async function updateColumnWebType(
}
const tableManagementService = new TableManagementService();
await tableManagementService.updateColumnWebType(
await tableManagementService.updateColumnInputType(
tableName,
columnName,
webType,
detailSettings,
inputType
inputType,
detailSettings
);
logger.info(
`컬럼 타입 설정 완료: ${tableName}.${columnName} = ${webType}`
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}`
);
const response: ApiResponse<null> = {
success: true,
message: "컬럼 타입이 성공적으로 설정되었습니다.",
message: "컬럼 입력 타입이 성공적으로 설정되었습니다.",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 타입 설정 중 오류 발생:", error);
logger.error("컬럼 입력 타입 설정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 타입 설정 중 오류가 발생했습니다.",
message: "컬럼 입력 타입 설정 중 오류가 발생했습니다.",
error: {
code: "WEB_TYPE_UPDATE_ERROR",
code: "INPUT_TYPE_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
@ -735,6 +734,208 @@ export async function editTableData(
}
}
/**
* ( )
*/
export async function getTableSchema(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 테이블 스키마 정보 조회 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const schema = await tableManagementService.getTableSchema(tableName);
logger.info(
`테이블 스키마 정보 조회 완료: ${tableName}, ${schema.length}개 컬럼`
);
const response: ApiResponse<ColumnTypeInfo[]> = {
success: true,
message: "테이블 스키마 정보를 성공적으로 조회했습니다.",
data: schema,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 스키마 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 스키마 정보 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_SCHEMA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function checkTableExists(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 테이블 존재 여부 확인 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const exists = await tableManagementService.checkTableExists(tableName);
logger.info(`테이블 존재 여부 확인 완료: ${tableName} = ${exists}`);
const response: ApiResponse<{ exists: boolean }> = {
success: true,
message: "테이블 존재 여부를 확인했습니다.",
data: { exists },
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 존재 여부 확인 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 존재 여부 확인 중 오류가 발생했습니다.",
error: {
code: "TABLE_EXISTS_CHECK_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* ( )
*/
export async function getColumnWebTypes(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 컬럼 웹타입 정보 조회 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const inputTypes =
await tableManagementService.getColumnInputTypes(tableName);
logger.info(
`컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼`
);
const response: ApiResponse<ColumnTypeInfo[]> = {
success: true,
message: "컬럼 입력타입 정보를 성공적으로 조회했습니다.",
data: inputTypes,
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 웹타입 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 웹타입 정보 조회 중 오류가 발생했습니다.",
error: {
code: "COLUMN_WEB_TYPES_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function checkDatabaseConnection(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 데이터베이스 연결 상태 확인 시작 ===");
const tableManagementService = new TableManagementService();
const connectionStatus =
await tableManagementService.checkDatabaseConnection();
logger.info(
`데이터베이스 연결 상태: ${connectionStatus.connected ? "연결됨" : "연결 안됨"}`
);
const response: ApiResponse<{ connected: boolean; message: string }> = {
success: true,
message: "데이터베이스 연결 상태를 확인했습니다.",
data: connectionStatus,
};
res.status(200).json(response);
} catch (error) {
logger.error("데이터베이스 연결 상태 확인 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "데이터베이스 연결 상태 확인 중 오류가 발생했습니다.",
error: {
code: "DATABASE_CONNECTION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
@ -809,3 +1010,41 @@ export async function deleteTableData(
res.status(500).json(response);
}
}
/**
* ( )
* @deprecated updateColumnInputType
*/
export async function updateColumnWebType(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { webType, detailSettings, inputType } = req.body;
logger.warn(
`레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장`
);
// webType을 inputType으로 변환
const convertedInputType = inputType || webType || "text";
// 새로운 메서드 호출
req.body = { inputType: convertedInputType, detailSettings };
await updateColumnInputType(req, res);
} catch (error) {
logger.error("레거시 컬럼 웹 타입 설정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.",
error: {
code: "WEB_TYPE_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -0,0 +1,200 @@
/**
*
* '*' DDL
*/
import { Request, Response, NextFunction } from "express";
import { logger } from "../utils/logger";
// DDL 요청 시간 추적을 위한 메모리 저장소
const ddlRequestTimes = new Map<string, number>();
// AuthenticatedRequest 타입 확장
export interface AuthenticatedRequest extends Request {
user?: {
userId: string;
userName: string;
companyCode: string;
userLang?: string;
};
}
/**
*
* '*' userId가 'plm_admin'
*/
export const requireSuperAdmin = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
// 인증 여부 확인
if (!req.user) {
logger.warn("DDL 실행 시도 - 인증되지 않은 사용자", {
ip: req.ip,
userAgent: req.get("User-Agent"),
url: req.originalUrl,
});
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
// 슈퍼관리자 권한 확인 (회사코드가 '*'이고 plm_admin 사용자)
if (req.user.companyCode !== "*" || req.user.userId !== "plm_admin") {
logger.warn("DDL 실행 시도 - 권한 부족", {
userId: req.user.userId,
companyCode: req.user.companyCode,
ip: req.ip,
userAgent: req.get("User-Agent"),
url: req.originalUrl,
});
res.status(403).json({
success: false,
error: {
code: "SUPER_ADMIN_REQUIRED",
details:
"최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 plm_admin 사용자만 가능합니다.",
},
});
return;
}
// 권한 확인 로깅
logger.info("DDL 실행 권한 확인 완료", {
userId: req.user.userId,
companyCode: req.user.companyCode,
ip: req.ip,
url: req.originalUrl,
});
next();
} catch (error) {
logger.error("슈퍼관리자 권한 확인 중 오류 발생:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};
/**
* DDL
*
*/
export const validateDDLPermission = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
const user = req.user!; // requireSuperAdmin을 통과했으므로 user 존재 보장
// 세션 유효성 재확인
if (!user.userId || !user.companyCode) {
logger.error("DDL 실행 - 세션 데이터 불완전", {
userId: user.userId,
companyCode: user.companyCode,
});
res.status(401).json({
success: false,
error: {
code: "INVALID_SESSION",
details: "세션 정보가 불완전합니다. 다시 로그인해주세요.",
},
});
return;
}
// 추가 보안 체크 - 메모리 기반 요청 시간 간격 제한
const now = Date.now();
const minInterval = 5000; // 5초 간격 제한
const lastDDLTime = ddlRequestTimes.get(user.userId);
if (lastDDLTime && now - lastDDLTime < minInterval) {
logger.warn("DDL 실행 - 너무 빈번한 요청", {
userId: user.userId,
timeSinceLastDDL: now - lastDDLTime,
});
res.status(429).json({
success: false,
error: {
code: "TOO_MANY_REQUESTS",
details:
"DDL 실행 요청이 너무 빈번합니다. 잠시 후 다시 시도해주세요.",
},
});
return;
}
// 마지막 DDL 실행 시간 기록
ddlRequestTimes.set(user.userId, now);
logger.info("DDL 실행 추가 보안 검증 완료", {
userId: user.userId,
companyCode: user.companyCode,
});
next();
} catch (error) {
logger.error("DDL 권한 추가 검증 중 오류 발생:", error);
res.status(500).json({
success: false,
error: {
code: "VALIDATION_ERROR",
details: "권한 검증 중 오류가 발생했습니다.",
},
});
}
};
/**
*
*/
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
return user?.companyCode === "*" && user?.userId === "plm_admin";
};
/**
* DDL ( )
*/
export const checkDDLPermission = (
user: AuthenticatedRequest["user"]
): {
hasPermission: boolean;
errorCode?: string;
errorMessage?: string;
} => {
if (!user) {
return {
hasPermission: false,
errorCode: "AUTHENTICATION_REQUIRED",
errorMessage: "인증이 필요합니다.",
};
}
if (!isSuperAdmin(user)) {
return {
hasPermission: false,
errorCode: "SUPER_ADMIN_REQUIRED",
errorMessage: "최고 관리자 권한이 필요합니다.",
};
}
return { hasPermission: true };
};

View File

@ -0,0 +1,215 @@
/**
* DDL
* / API
*/
import express from "express";
import { DDLController } from "../controllers/ddlController";
import {
requireSuperAdmin,
validateDDLPermission,
} from "../middleware/superAdminMiddleware";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// ============================================
// DDL 실행 라우터 (최고 관리자 전용)
// ============================================
/**
*
* POST /api/ddl/tables
*/
router.post(
"/tables",
authenticateToken, // 기본 인증
requireSuperAdmin, // 슈퍼관리자 권한 확인
validateDDLPermission, // DDL 실행 추가 검증
DDLController.createTable
);
/**
*
* POST /api/ddl/tables/:tableName/columns
*/
router.post(
"/tables/:tableName/columns",
authenticateToken,
requireSuperAdmin,
validateDDLPermission,
DDLController.addColumn
);
/**
* ( )
* POST /api/ddl/validate/table
*/
router.post(
"/validate/table",
authenticateToken,
requireSuperAdmin,
DDLController.validateTableCreation
);
// ============================================
// DDL 로그 및 모니터링 라우터
// ============================================
/**
* DDL
* GET /api/ddl/logs
*/
router.get(
"/logs",
authenticateToken,
requireSuperAdmin,
DDLController.getDDLLogs
);
/**
* DDL
* GET /api/ddl/statistics
*/
router.get(
"/statistics",
authenticateToken,
requireSuperAdmin,
DDLController.getDDLStatistics
);
/**
* DDL
* GET /api/ddl/tables/:tableName/history
*/
router.get(
"/tables/:tableName/history",
authenticateToken,
requireSuperAdmin,
DDLController.getTableDDLHistory
);
/**
*
* GET /api/ddl/tables/:tableName/info
*/
router.get(
"/tables/:tableName/info",
authenticateToken,
requireSuperAdmin,
DDLController.getTableInfo
);
// ============================================
// DDL 시스템 관리 라우터
// ============================================
/**
* DDL
* DELETE /api/ddl/logs/cleanup
*/
router.delete(
"/logs/cleanup",
authenticateToken,
requireSuperAdmin,
DDLController.cleanupOldLogs
);
// ============================================
// 라우터 정보 및 헬스체크
// ============================================
/**
* DDL
* GET /api/ddl/info
*/
router.get("/info", authenticateToken, requireSuperAdmin, (req, res) => {
res.json({
success: true,
data: {
service: "DDL Execution Service",
version: "1.0.0",
description: "PostgreSQL 테이블 및 컬럼 동적 생성 서비스",
endpoints: {
tables: {
create: "POST /api/ddl/tables",
addColumn: "POST /api/ddl/tables/:tableName/columns",
getInfo: "GET /api/ddl/tables/:tableName/info",
getHistory: "GET /api/ddl/tables/:tableName/history",
},
validation: {
validateTable: "POST /api/ddl/validate/table",
},
monitoring: {
logs: "GET /api/ddl/logs",
statistics: "GET /api/ddl/statistics",
cleanup: "DELETE /api/ddl/logs/cleanup",
},
},
requirements: {
authentication: "Bearer Token 필요",
authorization: "회사코드 '*'인 plm_admin 사용자만 접근 가능",
safety: "모든 DDL 실행은 안전성 검증 후 수행",
logging: "모든 DDL 실행은 감사 로그에 기록",
},
supportedWebTypes: [
"text",
"number",
"decimal",
"date",
"datetime",
"boolean",
"code",
"entity",
"textarea",
"select",
"checkbox",
"radio",
"file",
"email",
"tel",
],
},
});
});
/**
* DDL
* GET /api/ddl/health
*/
router.get("/health", authenticateToken, async (req, res) => {
try {
// 기본적인 데이터베이스 연결 테스트
const { PrismaClient } = await import("@prisma/client");
const prisma = new PrismaClient();
await prisma.$queryRaw`SELECT 1`;
await prisma.$disconnect();
res.json({
success: true,
status: "healthy",
timestamp: new Date().toISOString(),
checks: {
database: "connected",
service: "operational",
},
});
} catch (error) {
res.status(503).json({
success: false,
status: "unhealthy",
timestamp: new Date().toISOString(),
error: {
code: "HEALTH_CHECK_FAILED",
details: "DDL 서비스 상태 확인 실패",
},
checks: {
database: "disconnected",
service: "error",
},
});
}
});
export default router;

View File

@ -2,6 +2,7 @@ import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
saveFormData,
saveFormDataEnhanced,
updateFormData,
updateFormDataPartial,
deleteFormData,
@ -18,7 +19,8 @@ const router = express.Router();
router.use(authenticateToken);
// 폼 데이터 CRUD
router.post("/save", saveFormData);
router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.delete("/:id", deleteFormData);

View File

@ -262,7 +262,9 @@ router.post(
});
}
const result = await ExternalDbConnectionService.testConnectionById(id);
// 테스트용 비밀번호가 제공된 경우 사용
const testData = req.body.password ? { password: req.body.password } : undefined;
const result = await ExternalDbConnectionService.testConnectionById(id, testData);
return res.status(200).json({
success: result.success,

View File

@ -8,11 +8,16 @@ import {
getTableLabels,
getColumnLabels,
updateColumnWebType,
updateColumnInputType,
updateTableLabel,
getTableData,
addTableData,
editTableData,
deleteTableData,
getTableSchema,
checkTableExists,
getColumnWebTypes,
checkDatabaseConnection,
} from "../controllers/tableManagementController";
const router = express.Router();
@ -66,7 +71,7 @@ router.get("/tables/:tableName/labels", getTableLabels);
router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels);
/**
*
* ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/web-type
*/
router.put(
@ -74,6 +79,51 @@ router.put(
updateColumnWebType
);
/**
* ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/input-type
*/
router.put(
"/tables/:tableName/columns/:columnName/input-type",
updateColumnInputType
);
/**
* (PUT )
* PUT /api/table-management/tables/:tableName/columns/:columnName
*/
router.put("/tables/:tableName/columns/:columnName", updateColumnSettings);
/**
*
* PUT /api/table-management/tables/:tableName/columns/batch
*/
router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
/**
* ( )
* GET /api/table-management/tables/:tableName/schema
*/
router.get("/tables/:tableName/schema", getTableSchema);
/**
*
* GET /api/table-management/tables/:tableName/exists
*/
router.get("/tables/:tableName/exists", checkTableExists);
/**
* ( )
* GET /api/table-management/tables/:tableName/web-types
*/
router.get("/tables/:tableName/web-types", getColumnWebTypes);
/**
*
* GET /api/table-management/health
*/
router.get("/health", checkDatabaseConnection);
/**
* ( + )
* POST /api/table-management/tables/:tableName/data

View File

@ -0,0 +1,368 @@
/**
* DDL
* DDL
*/
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
export class DDLAuditLogger {
/**
* DDL
*/
static async logDDLExecution(
userId: string,
companyCode: string,
ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN",
tableName: string,
ddlQuery: string,
success: boolean,
error?: string,
additionalInfo?: Record<string, any>
): Promise<void> {
try {
// DDL 실행 로그 데이터베이스에 저장
const logEntry = await prisma.$executeRaw`
INSERT INTO ddl_execution_log (
user_id,
company_code,
ddl_type,
table_name,
ddl_query,
success,
error_message,
executed_at
) VALUES (
${userId},
${companyCode},
${ddlType},
${tableName},
${ddlQuery},
${success},
${error || null},
NOW()
)
`;
// 추가 로깅 (파일 로그)
const logData = {
userId,
companyCode,
ddlType,
tableName,
success,
queryLength: ddlQuery.length,
error: error || null,
additionalInfo: additionalInfo || null,
timestamp: new Date().toISOString(),
};
if (success) {
logger.info("DDL 실행 성공", logData);
} else {
logger.error("DDL 실행 실패", { ...logData, ddlQuery });
}
// 중요한 DDL 실행은 별도 알림 (필요시)
if (ddlType === "CREATE_TABLE" || ddlType === "DROP_TABLE") {
logger.warn("중요 DDL 실행", {
...logData,
severity: "HIGH",
action: "TABLE_STRUCTURE_CHANGE",
});
}
} catch (logError) {
// 로그 기록 실패는 시스템에 영향을 주지 않도록 처리
logger.error("DDL 실행 로그 기록 실패:", {
originalUserId: userId,
originalDdlType: ddlType,
originalTableName: tableName,
originalSuccess: success,
logError: logError,
});
// 로그 기록 실패를 파일 로그로라도 남김
console.error("CRITICAL: DDL 로그 기록 실패", {
userId,
ddlType,
tableName,
success,
logError,
timestamp: new Date().toISOString(),
});
}
}
/**
* DDL
*/
static async logDDLStart(
userId: string,
companyCode: string,
ddlType: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN",
tableName: string,
requestData: any
): Promise<void> {
logger.info("DDL 실행 시작", {
userId,
companyCode,
ddlType,
tableName,
requestData,
timestamp: new Date().toISOString(),
});
}
/**
* DDL
*/
static async getRecentDDLLogs(
limit: number = 50,
userId?: string,
ddlType?: string
): Promise<any[]> {
try {
let whereClause = "WHERE 1=1";
const params: any[] = [];
if (userId) {
whereClause += " AND user_id = $" + (params.length + 1);
params.push(userId);
}
if (ddlType) {
whereClause += " AND ddl_type = $" + (params.length + 1);
params.push(ddlType);
}
const query = `
SELECT
id,
user_id,
company_code,
ddl_type,
table_name,
success,
error_message,
executed_at,
CASE
WHEN LENGTH(ddl_query) > 100 THEN SUBSTRING(ddl_query, 1, 100) || '...'
ELSE ddl_query
END as ddl_query_preview
FROM ddl_execution_log
${whereClause}
ORDER BY executed_at DESC
LIMIT $${params.length + 1}
`;
params.push(limit);
const logs = await prisma.$queryRawUnsafe(query, ...params);
return logs as any[];
} catch (error) {
logger.error("DDL 로그 조회 실패:", error);
return [];
}
}
/**
* DDL
*/
static async getDDLStatistics(
fromDate?: Date,
toDate?: Date
): Promise<{
totalExecutions: number;
successfulExecutions: number;
failedExecutions: number;
byDDLType: Record<string, number>;
byUser: Record<string, number>;
recentFailures: any[];
}> {
try {
let dateFilter = "";
const params: any[] = [];
if (fromDate) {
dateFilter += " AND executed_at >= $" + (params.length + 1);
params.push(fromDate);
}
if (toDate) {
dateFilter += " AND executed_at <= $" + (params.length + 1);
params.push(toDate);
}
// 전체 통계
const totalStats = (await prisma.$queryRawUnsafe(
`
SELECT
COUNT(*) as total_executions,
SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions,
SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions
FROM ddl_execution_log
WHERE 1=1 ${dateFilter}
`,
...params
)) as any[];
// DDL 타입별 통계
const ddlTypeStats = (await prisma.$queryRawUnsafe(
`
SELECT ddl_type, COUNT(*) as count
FROM ddl_execution_log
WHERE 1=1 ${dateFilter}
GROUP BY ddl_type
ORDER BY count DESC
`,
...params
)) as any[];
// 사용자별 통계
const userStats = (await prisma.$queryRawUnsafe(
`
SELECT user_id, COUNT(*) as count
FROM ddl_execution_log
WHERE 1=1 ${dateFilter}
GROUP BY user_id
ORDER BY count DESC
LIMIT 10
`,
...params
)) as any[];
// 최근 실패 로그
const recentFailures = (await prisma.$queryRawUnsafe(
`
SELECT
user_id,
ddl_type,
table_name,
error_message,
executed_at
FROM ddl_execution_log
WHERE success = false ${dateFilter}
ORDER BY executed_at DESC
LIMIT 10
`,
...params
)) as any[];
const stats = totalStats[0];
return {
totalExecutions: parseInt(stats.total_executions) || 0,
successfulExecutions: parseInt(stats.successful_executions) || 0,
failedExecutions: parseInt(stats.failed_executions) || 0,
byDDLType: ddlTypeStats.reduce((acc, row) => {
acc[row.ddl_type] = parseInt(row.count);
return acc;
}, {}),
byUser: userStats.reduce((acc, row) => {
acc[row.user_id] = parseInt(row.count);
return acc;
}, {}),
recentFailures,
};
} catch (error) {
logger.error("DDL 통계 조회 실패:", error);
return {
totalExecutions: 0,
successfulExecutions: 0,
failedExecutions: 0,
byDDLType: {},
byUser: {},
recentFailures: [],
};
}
}
/**
* DDL
*/
static async getTableDDLHistory(tableName: string): Promise<any[]> {
try {
const history = await prisma.$queryRawUnsafe(
`
SELECT
id,
user_id,
ddl_type,
ddl_query,
success,
error_message,
executed_at
FROM ddl_execution_log
WHERE table_name = $1
ORDER BY executed_at DESC
LIMIT 20
`,
tableName
);
return history as any[];
} catch (error) {
logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error);
return [];
}
}
/**
* DDL ( )
*/
static async cleanupOldLogs(retentionDays: number = 90): Promise<number> {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const result = await prisma.$executeRaw`
DELETE FROM ddl_execution_log
WHERE executed_at < ${cutoffDate}
`;
logger.info(`DDL 로그 정리 완료: ${result}개 레코드 삭제`, {
retentionDays,
cutoffDate: cutoffDate.toISOString(),
});
return result as number;
} catch (error) {
logger.error("DDL 로그 정리 실패:", error);
return 0;
}
}
/**
* ( )
*/
static async logSecurityAlert(
userId: string,
companyCode: string,
alertType:
| "SYSTEM_TABLE_ACCESS"
| "INVALID_PERMISSION"
| "SUSPICIOUS_ACTIVITY",
details: string,
requestData?: any
): Promise<void> {
try {
// 보안 알림은 별도의 고급 로깅
logger.error("DDL 보안 알림", {
alertType,
userId,
companyCode,
details,
requestData,
severity: "CRITICAL",
timestamp: new Date().toISOString(),
});
// 필요시 외부 알림 시스템 연동 (이메일, 슬랙 등)
// await sendSecurityAlert(alertType, userId, details);
} catch (error) {
logger.error("보안 알림 기록 실패:", error);
}
}
}

View File

@ -0,0 +1,785 @@
/**
* DDL
* PostgreSQL
*/
import { PrismaClient } from "@prisma/client";
import {
CreateColumnDefinition,
DDLExecutionResult,
WEB_TYPE_TO_POSTGRES_MAP,
WebType,
} from "../types/ddl";
import { DDLSafetyValidator } from "./ddlSafetyValidator";
import { DDLAuditLogger } from "./ddlAuditLogger";
import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache";
const prisma = new PrismaClient();
export class DDLExecutionService {
/**
*
*/
async createTable(
tableName: string,
columns: CreateColumnDefinition[],
userCompanyCode: string,
userId: string,
description?: string
): Promise<DDLExecutionResult> {
// DDL 실행 시작 로그
await DDLAuditLogger.logDDLStart(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
{ columns, description }
);
try {
// 1. 권한 검증
this.validateSuperAdminPermission(userCompanyCode);
// 2. 안전성 검증
const validation = DDLSafetyValidator.validateTableCreation(
tableName,
columns
);
if (!validation.isValid) {
const errorMessage = `테이블 생성 검증 실패: ${validation.errors.join(", ")}`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
"VALIDATION_FAILED",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "VALIDATION_FAILED",
details: validation.errors.join(", "),
},
};
}
// 3. 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (tableExists) {
const errorMessage = `테이블 '${tableName}'이 이미 존재합니다.`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
"TABLE_EXISTS",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "TABLE_EXISTS",
details: errorMessage,
},
};
}
// 4. DDL 쿼리 생성
const ddlQuery = this.generateCreateTableQuery(tableName, columns);
// 5. 트랜잭션으로 안전하게 실행
await prisma.$transaction(async (tx) => {
// 5-1. 테이블 생성
await tx.$executeRawUnsafe(ddlQuery);
// 5-2. 테이블 메타데이터 저장
await this.saveTableMetadata(tx, tableName, description);
// 5-3. 컬럼 메타데이터 저장
await this.saveColumnMetadata(tx, tableName, columns);
});
// 6. 성공 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
ddlQuery,
true
);
logger.info("테이블 생성 성공", {
tableName,
userId,
columnCount: columns.length,
});
// 테이블 생성 후 관련 캐시 무효화
this.invalidateTableCache(tableName);
return {
success: true,
message: `테이블 '${tableName}'이 성공적으로 생성되었습니다.`,
executedQuery: ddlQuery,
};
} catch (error) {
const errorMessage = `테이블 생성 실패: ${(error as Error).message}`;
// 실패 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"CREATE_TABLE",
tableName,
`FAILED: ${(error as Error).message}`,
false,
errorMessage
);
logger.error("테이블 생성 실패:", {
tableName,
userId,
error: (error as Error).message,
stack: (error as Error).stack,
});
return {
success: false,
message: errorMessage,
error: {
code: "EXECUTION_FAILED",
details: (error as Error).message,
},
};
}
}
/**
*
*/
async addColumn(
tableName: string,
column: CreateColumnDefinition,
userCompanyCode: string,
userId: string
): Promise<DDLExecutionResult> {
// DDL 실행 시작 로그
await DDLAuditLogger.logDDLStart(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
{ column }
);
try {
// 1. 권한 검증
this.validateSuperAdminPermission(userCompanyCode);
// 2. 안전성 검증
const validation = DDLSafetyValidator.validateColumnAddition(
tableName,
column
);
if (!validation.isValid) {
const errorMessage = `컬럼 추가 검증 실패: ${validation.errors.join(", ")}`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
"VALIDATION_FAILED",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "VALIDATION_FAILED",
details: validation.errors.join(", "),
},
};
}
// 3. 테이블 존재 여부 확인
const tableExists = await this.checkTableExists(tableName);
if (!tableExists) {
const errorMessage = `테이블 '${tableName}'이 존재하지 않습니다.`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
"TABLE_NOT_EXISTS",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "TABLE_NOT_EXISTS",
details: errorMessage,
},
};
}
// 4. 컬럼 존재 여부 확인
const columnExists = await this.checkColumnExists(tableName, column.name);
if (columnExists) {
const errorMessage = `컬럼 '${column.name}'이 이미 존재합니다.`;
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
"COLUMN_EXISTS",
false,
errorMessage
);
return {
success: false,
message: errorMessage,
error: {
code: "COLUMN_EXISTS",
details: errorMessage,
},
};
}
// 5. DDL 쿼리 생성
const ddlQuery = this.generateAddColumnQuery(tableName, column);
// 6. 트랜잭션으로 안전하게 실행
await prisma.$transaction(async (tx) => {
// 6-1. 컬럼 추가
await tx.$executeRawUnsafe(ddlQuery);
// 6-2. 컬럼 메타데이터 저장
await this.saveColumnMetadata(tx, tableName, [column]);
});
// 7. 성공 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
ddlQuery,
true
);
logger.info("컬럼 추가 성공", {
tableName,
columnName: column.name,
webType: column.webType,
userId,
});
// 컬럼 추가 후 관련 캐시 무효화
this.invalidateTableCache(tableName);
return {
success: true,
message: `컬럼 '${column.name}'이 성공적으로 추가되었습니다.`,
executedQuery: ddlQuery,
};
} catch (error) {
const errorMessage = `컬럼 추가 실패: ${(error as Error).message}`;
// 실패 로그 기록
await DDLAuditLogger.logDDLExecution(
userId,
userCompanyCode,
"ADD_COLUMN",
tableName,
`FAILED: ${(error as Error).message}`,
false,
errorMessage
);
logger.error("컬럼 추가 실패:", {
tableName,
columnName: column.name,
userId,
error: (error as Error).message,
stack: (error as Error).stack,
});
return {
success: false,
message: errorMessage,
error: {
code: "EXECUTION_FAILED",
details: (error as Error).message,
},
};
}
}
/**
* CREATE TABLE DDL
*/
private generateCreateTableQuery(
tableName: string,
columns: CreateColumnDefinition[]
): string {
// 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 통일
const columnDefinitions = columns
.map((col) => {
// 입력 타입과 관계없이 모든 컬럼을 VARCHAR(500)로 생성
let definition = `"${col.name}" varchar(500)`;
if (!col.nullable) {
definition += " NOT NULL";
}
if (col.defaultValue) {
definition += ` DEFAULT '${col.defaultValue}'`;
}
return definition;
})
.join(",\n ");
// 기본 컬럼들 (날짜는 TIMESTAMP, 나머지는 VARCHAR)
const baseColumns = `
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(500),
"company_code" varchar(500)`;
// 최종 CREATE TABLE 쿼리
return `
CREATE TABLE "${tableName}" (${baseColumns},
${columnDefinitions}
);`.trim();
}
/**
* ALTER TABLE ADD COLUMN DDL
*/
private generateAddColumnQuery(
tableName: string,
column: CreateColumnDefinition
): string {
// 새로 추가되는 컬럼도 VARCHAR(500)로 통일
let definition = `"${column.name}" varchar(500)`;
if (!column.nullable) {
definition += " NOT NULL";
}
if (column.defaultValue) {
definition += ` DEFAULT '${column.defaultValue}'`;
}
return `ALTER TABLE "${tableName}" ADD COLUMN ${definition};`;
}
/**
* PostgreSQL ( TIMESTAMP, VARCHAR)
* TIMESTAMP로, VARCHAR(500)
*/
private mapInputTypeToPostgresType(inputType?: string): string {
switch (inputType) {
case "date":
return "timestamp";
default:
// 날짜 외의 모든 타입은 VARCHAR(500)로 통일
return "varchar(500)";
}
}
/**
* 지원: 웹타입을 PostgreSQL
* @deprecated mapInputTypeToPostgresType
*/
private mapWebTypeToPostgresType(webType: WebType, length?: number): string {
// 레거시 지원을 위해 유지하되, VARCHAR(500)로 통일
logger.info(`레거시 웹타입 사용: ${webType} → varchar(500)로 변환`);
return "varchar(500)";
}
/**
*
*/
private async saveTableMetadata(
tx: any,
tableName: string,
description?: string
): Promise<void> {
await tx.table_labels.upsert({
where: { table_name: tableName },
update: {
table_label: tableName,
description: description || `사용자 생성 테이블: ${tableName}`,
updated_date: new Date(),
},
create: {
table_name: tableName,
table_label: tableName,
description: description || `사용자 생성 테이블: ${tableName}`,
created_date: new Date(),
updated_date: new Date(),
},
});
}
/**
*
*/
private async saveColumnMetadata(
tx: any,
tableName: string,
columns: CreateColumnDefinition[]
): Promise<void> {
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
await tx.table_labels.upsert({
where: {
table_name: tableName,
},
update: {
updated_date: new Date(),
},
create: {
table_name: tableName,
table_label: tableName,
description: `자동 생성된 테이블 메타데이터: ${tableName}`,
created_date: new Date(),
updated_date: new Date(),
},
});
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
const defaultColumns = [
{
name: "id",
label: "ID",
inputType: "text",
description: "기본키 (자동생성)",
order: -5,
isVisible: true,
},
{
name: "created_date",
label: "생성일시",
inputType: "date",
description: "레코드 생성일시",
order: -4,
isVisible: true,
},
{
name: "updated_date",
label: "수정일시",
inputType: "date",
description: "레코드 수정일시",
order: -3,
isVisible: true,
},
{
name: "writer",
label: "작성자",
inputType: "text",
description: "레코드 작성자",
order: -2,
isVisible: true,
},
{
name: "company_code",
label: "회사코드",
inputType: "text",
description: "회사 구분 코드",
order: -1,
isVisible: true,
},
];
// 기본 컬럼들을 table_type_columns에 등록
for (const defaultCol of defaultColumns) {
await tx.$executeRaw`
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
${tableName}, ${defaultCol.name}, ${defaultCol.inputType}, '{}',
'Y', ${defaultCol.order}, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
input_type = ${defaultCol.inputType},
display_order = ${defaultCol.order},
updated_date = now();
`;
}
// 사용자 정의 컬럼들을 table_type_columns에 등록
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
const inputType = this.convertWebTypeToInputType(
column.webType || "text"
);
await tx.$executeRaw`
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
${tableName}, ${column.name}, ${inputType}, ${JSON.stringify(column.detailSettings || {})},
'Y', ${i}, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
input_type = ${inputType},
detail_settings = ${JSON.stringify(column.detailSettings || {})},
display_order = ${i},
updated_date = now();
`;
}
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
// 1. 기본 컬럼들을 column_labels에 등록
for (const defaultCol of defaultColumns) {
await tx.column_labels.upsert({
where: {
table_name_column_name: {
table_name: tableName,
column_name: defaultCol.name,
},
},
update: {
column_label: defaultCol.label,
input_type: defaultCol.inputType,
detail_settings: JSON.stringify({}),
description: defaultCol.description,
display_order: defaultCol.order,
is_visible: defaultCol.isVisible,
updated_date: new Date(),
},
create: {
table_name: tableName,
column_name: defaultCol.name,
column_label: defaultCol.label,
input_type: defaultCol.inputType,
detail_settings: JSON.stringify({}),
description: defaultCol.description,
display_order: defaultCol.order,
is_visible: defaultCol.isVisible,
created_date: new Date(),
updated_date: new Date(),
},
});
}
// 2. 사용자 정의 컬럼들을 column_labels에 등록
for (const column of columns) {
await tx.column_labels.upsert({
where: {
table_name_column_name: {
table_name: tableName,
column_name: column.name,
},
},
update: {
column_label: column.label || column.name,
input_type: this.convertWebTypeToInputType(column.webType || "text"),
detail_settings: JSON.stringify(column.detailSettings || {}),
description: column.description,
display_order: column.order || 0,
is_visible: true,
updated_date: new Date(),
},
create: {
table_name: tableName,
column_name: column.name,
column_label: column.label || column.name,
input_type: this.convertWebTypeToInputType(column.webType || "text"),
detail_settings: JSON.stringify(column.detailSettings || {}),
description: column.description,
display_order: column.order || 0,
is_visible: true,
created_date: new Date(),
updated_date: new Date(),
},
});
}
}
/**
*
*/
private convertWebTypeToInputType(webType: string): string {
const webTypeToInputTypeMap: Record<string, string> = {
// 텍스트 관련
text: "text",
textarea: "text",
email: "text",
tel: "text",
url: "text",
password: "text",
// 숫자 관련
number: "number",
decimal: "number",
// 날짜 관련
date: "date",
datetime: "date",
time: "date",
// 선택 관련
select: "select",
dropdown: "select",
checkbox: "checkbox",
boolean: "checkbox",
radio: "radio",
// 참조 관련
code: "code",
entity: "entity",
// 기타
file: "text",
button: "text",
};
return webTypeToInputTypeMap[webType] || "text";
}
/**
* ( )
*/
private validateSuperAdminPermission(userCompanyCode: string): void {
if (userCompanyCode !== "*") {
throw new Error("최고 관리자 권한이 필요합니다.");
}
}
/**
*
*/
private async checkTableExists(tableName: string): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
);
`,
tableName
);
return (result as any)[0]?.exists || false;
} catch (error) {
logger.error("테이블 존재 확인 오류:", error);
return false;
}
}
/**
*
*/
private async checkColumnExists(
tableName: string,
columnName: string
): Promise<boolean> {
try {
const result = await prisma.$queryRawUnsafe(
`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
);
`,
tableName,
columnName
);
return (result as any)[0]?.exists || false;
} catch (error) {
logger.error("컬럼 존재 확인 오류:", error);
return false;
}
}
/**
*
*/
async getCreatedTableInfo(tableName: string): Promise<{
tableInfo: any;
columns: any[];
} | null> {
try {
// 테이블 정보 조회
const tableInfo = await prisma.table_labels.findUnique({
where: { table_name: tableName },
});
// 컬럼 정보 조회
const columns = await prisma.column_labels.findMany({
where: { table_name: tableName },
orderBy: { display_order: "asc" },
});
if (!tableInfo) {
return null;
}
return {
tableInfo,
columns,
};
} catch (error) {
logger.error("생성된 테이블 정보 조회 실패:", error);
return null;
}
}
/**
*
* DDL
*/
private invalidateTableCache(tableName: string): void {
try {
// 테이블 컬럼 관련 캐시 무효화
const columnCacheDeleted = cache.deleteByPattern(
`table_columns:${tableName}`
);
const countCacheDeleted = cache.deleteByPattern(
`table_column_count:${tableName}`
);
cache.delete("table_list");
const totalDeleted = columnCacheDeleted + countCacheDeleted + 1;
logger.info(
`테이블 캐시 무효화 완료: ${tableName}, 삭제된 키: ${totalDeleted}`
);
} catch (error) {
logger.warn(`테이블 캐시 무효화 실패: ${tableName}`, error);
}
}
}

View File

@ -0,0 +1,390 @@
/**
* DDL
* /
*/
import {
CreateColumnDefinition,
ValidationResult,
SYSTEM_TABLES,
RESERVED_WORDS,
RESERVED_COLUMNS,
} from "../types/ddl";
import { logger } from "../utils/logger";
export class DDLSafetyValidator {
/**
*
*/
static validateTableCreation(
tableName: string,
columns: CreateColumnDefinition[]
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
try {
// 1. 테이블명 기본 검증
const tableNameValidation = this.validateTableName(tableName);
if (!tableNameValidation.isValid) {
errors.push(...tableNameValidation.errors);
}
// 2. 컬럼 기본 검증
if (columns.length === 0) {
errors.push("최소 1개의 컬럼이 필요합니다.");
}
// 3. 컬럼 목록 검증
const columnsValidation = this.validateColumnList(columns);
if (!columnsValidation.isValid) {
errors.push(...columnsValidation.errors);
}
if (columnsValidation.warnings) {
warnings.push(...columnsValidation.warnings);
}
// 4. 컬럼명 중복 검증
const duplicateValidation = this.validateColumnDuplication(columns);
if (!duplicateValidation.isValid) {
errors.push(...duplicateValidation.errors);
}
logger.info("테이블 생성 검증 완료", {
tableName,
columnCount: columns.length,
errorCount: errors.length,
warningCount: warnings.length,
});
return {
isValid: errors.length === 0,
errors,
warnings,
};
} catch (error) {
logger.error("테이블 생성 검증 중 오류 발생:", error);
return {
isValid: false,
errors: ["테이블 생성 검증 중 내부 오류가 발생했습니다."],
};
}
}
/**
*
*/
static validateColumnAddition(
tableName: string,
column: CreateColumnDefinition
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
try {
// 1. 테이블명 검증 (시스템 테이블 확인)
if (this.isSystemTable(tableName)) {
errors.push(
`'${tableName}'은 시스템 테이블이므로 컬럼을 추가할 수 없습니다.`
);
}
// 2. 컬럼 정의 검증
const columnValidation = this.validateSingleColumn(column);
if (!columnValidation.isValid) {
errors.push(...columnValidation.errors);
}
if (columnValidation.warnings) {
warnings.push(...columnValidation.warnings);
}
logger.info("컬럼 추가 검증 완료", {
tableName,
columnName: column.name,
webType: column.webType,
errorCount: errors.length,
});
return {
isValid: errors.length === 0,
errors,
warnings,
};
} catch (error) {
logger.error("컬럼 추가 검증 중 오류 발생:", error);
return {
isValid: false,
errors: ["컬럼 추가 검증 중 내부 오류가 발생했습니다."],
};
}
}
/**
*
*/
private static validateTableName(tableName: string): ValidationResult {
const errors: string[] = [];
// 1. 기본 형식 검증
if (!this.isValidTableName(tableName)) {
errors.push(
"유효하지 않은 테이블명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다."
);
}
// 2. 길이 검증
if (tableName.length > 63) {
errors.push("테이블명은 63자를 초과할 수 없습니다.");
}
if (tableName.length < 2) {
errors.push("테이블명은 최소 2자 이상이어야 합니다.");
}
// 3. 시스템 테이블 보호
if (this.isSystemTable(tableName)) {
errors.push(
`'${tableName}'은 시스템 테이블명으로 사용할 수 없습니다. 다른 이름을 선택해주세요.`
);
}
// 4. 예약어 검증
if (this.isReservedWord(tableName)) {
errors.push(
`'${tableName}'은 SQL 예약어이므로 테이블명으로 사용할 수 없습니다.`
);
}
// 5. 일반적인 네이밍 컨벤션 검증
if (tableName.startsWith("_") || tableName.endsWith("_")) {
errors.push("테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.");
}
if (tableName.includes("__")) {
errors.push("테이블명에 연속된 언더스코어는 사용할 수 없습니다.");
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
*
*/
private static validateColumnList(
columns: CreateColumnDefinition[]
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
const columnValidation = this.validateSingleColumn(column, i + 1);
if (!columnValidation.isValid) {
errors.push(...columnValidation.errors);
}
if (columnValidation.warnings) {
warnings.push(...columnValidation.warnings);
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
*
*/
private static validateSingleColumn(
column: CreateColumnDefinition,
position?: number
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
const prefix = position
? `컬럼 ${position}(${column.name}): `
: `컬럼 '${column.name}': `;
// 1. 컬럼명 기본 검증
if (!column.name || column.name.trim() === "") {
errors.push(`${prefix}컬럼명은 필수입니다.`);
return { isValid: false, errors };
}
if (!this.isValidColumnName(column.name)) {
errors.push(
`${prefix}유효하지 않은 컬럼명입니다. 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용 가능합니다.`
);
}
// 2. 길이 검증
if (column.name.length > 63) {
errors.push(`${prefix}컬럼명은 63자를 초과할 수 없습니다.`);
}
if (column.name.length < 2) {
errors.push(`${prefix}컬럼명은 최소 2자 이상이어야 합니다.`);
}
// 3. 예약된 컬럼명 검증
if (this.isReservedColumnName(column.name)) {
errors.push(
`${prefix}'${column.name}'은 예약된 컬럼명입니다. 기본 컬럼(id, created_date, updated_date, company_code)과 중복됩니다.`
);
}
// 4. SQL 예약어 검증
if (this.isReservedWord(column.name)) {
errors.push(
`${prefix}'${column.name}'은 SQL 예약어이므로 컬럼명으로 사용할 수 없습니다.`
);
}
// 5. 웹타입 검증
if (!column.webType) {
errors.push(`${prefix}웹타입이 지정되지 않았습니다.`);
}
// 6. 길이 설정 검증 (text, code 타입에서만 허용)
if (column.length !== undefined) {
if (
!["text", "code", "email", "tel", "select", "radio"].includes(
column.webType || "text"
)
) {
warnings.push(
`${prefix}${column.webType || "text"} 타입에서는 길이 설정이 무시됩니다.`
);
} else if (column.length <= 0 || column.length > 65535) {
errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`);
}
}
// 7. 네이밍 컨벤션 검증
if (column.name.startsWith("_") || column.name.endsWith("_")) {
warnings.push(
`${prefix}컬럼명이 언더스코어로 시작하거나 끝나는 것은 권장하지 않습니다.`
);
}
if (column.name.includes("__")) {
errors.push(`${prefix}컬럼명에 연속된 언더스코어는 사용할 수 없습니다.`);
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
*
*/
private static validateColumnDuplication(
columns: CreateColumnDefinition[]
): ValidationResult {
const errors: string[] = [];
const columnNames = columns.map((col) => col.name.toLowerCase());
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of columnNames) {
if (seen.has(name)) {
duplicates.add(name);
} else {
seen.add(name);
}
}
if (duplicates.size > 0) {
errors.push(
`중복된 컬럼명이 있습니다: ${Array.from(duplicates).join(", ")}`
);
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* ()
*/
private static isValidTableName(tableName: string): boolean {
const tableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
return tableNameRegex.test(tableName);
}
/**
* ()
*/
private static isValidColumnName(columnName: string): boolean {
const columnNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
return columnNameRegex.test(columnName) && columnName.length <= 63;
}
/**
*
*/
private static isSystemTable(tableName: string): boolean {
return SYSTEM_TABLES.includes(tableName.toLowerCase() as any);
}
/**
* SQL
*/
private static isReservedWord(word: string): boolean {
return RESERVED_WORDS.includes(word.toLowerCase() as any);
}
/**
*
*/
private static isReservedColumnName(columnName: string): boolean {
return RESERVED_COLUMNS.includes(columnName.toLowerCase() as any);
}
/**
*
*/
static generateValidationReport(
tableName: string,
columns: CreateColumnDefinition[]
): {
tableName: string;
totalColumns: number;
validationResult: ValidationResult;
summary: string;
} {
const validationResult = this.validateTableCreation(tableName, columns);
let summary = `테이블 '${tableName}' 검증 완료. `;
summary += `컬럼 ${columns.length}개 중 `;
if (validationResult.isValid) {
summary += "모든 검증 통과.";
} else {
summary += `${validationResult.errors.length}개 오류 발견.`;
}
if (validationResult.warnings && validationResult.warnings.length > 0) {
summary += ` ${validationResult.warnings.length}개 경고 있음.`;
}
return {
tableName,
totalColumns: columns.length,
validationResult,
summary,
};
}
}

View File

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

View File

@ -239,13 +239,43 @@ export class ExternalDbConnectionService {
}
}
// 비밀번호가 변경되는 경우, 연결 테스트 먼저 수행
if (data.password && data.password !== "***ENCRYPTED***") {
// 임시 연결 설정으로 테스트
const testConfig = {
host: data.host || existingConnection.host,
port: data.port || existingConnection.port,
database: data.database_name || existingConnection.database_name,
user: data.username || existingConnection.username,
password: data.password, // 새로 입력된 비밀번호로 테스트
connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined,
queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined,
ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false
};
// 연결 테스트 수행
const testResult = await DbConnectionManager.testConnection(
id,
existingConnection.db_type,
testConfig
);
if (!testResult.success) {
return {
success: false,
message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined
};
}
}
// 업데이트 데이터 준비
const updateData: any = {
...data,
updated_date: new Date(),
};
// 비밀번호가 변경된 경우 암호화
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
if (data.password && data.password !== "***ENCRYPTED***") {
updateData.password = PasswordEncryption.encrypt(data.password);
} else {
@ -320,7 +350,8 @@ export class ExternalDbConnectionService {
* (ID )
*/
static async testConnectionById(
id: number
id: number,
testData?: { password?: string }
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
try {
// 저장된 연결 정보 조회
@ -339,9 +370,15 @@ export class ExternalDbConnectionService {
};
}
// 비밀번호 복호화
const decryptedPassword = await this.getDecryptedPassword(id);
if (!decryptedPassword) {
// 비밀번호 결정 (테스트용 비밀번호가 제공된 경우 그것을 사용, 아니면 저장된 비밀번호 복호화)
let password: string | null;
if (testData?.password) {
password = testData.password;
} else {
password = await this.getDecryptedPassword(id);
}
if (!password) {
return {
success: false,
message: "비밀번호 복호화에 실패했습니다.",
@ -358,7 +395,7 @@ export class ExternalDbConnectionService {
port: connection.port,
database: connection.database_name,
user: connection.username,
password: decryptedPassword,
password: password,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false

View File

@ -0,0 +1,282 @@
/**
*
* VARCHAR
*/
import { InputType } from "../types/input-types";
import { logger } from "../utils/logger";
export interface ValidationResult {
isValid: boolean;
message?: string;
convertedValue?: string;
}
export class InputTypeService {
/**
* ( DB )
* VARCHAR(500)
*/
static convertForStorage(value: any, inputType: InputType): string {
if (value === null || value === undefined) {
return "";
}
try {
switch (inputType) {
case "text":
case "select":
case "radio":
return String(value).trim();
case "number":
if (value === "" || value === null || value === undefined) {
return "0";
}
const num = parseFloat(String(value));
return isNaN(num) ? "0" : String(num);
case "date":
if (!value || value === "") {
return "";
}
const date = new Date(value);
if (isNaN(date.getTime())) {
logger.warn(`Invalid date value: ${value}`);
return "";
}
return date.toISOString().split("T")[0]; // YYYY-MM-DD 형식
case "checkbox":
// 다양한 형태의 true 값을 "Y"로, 나머지는 "N"으로 변환
const truthyValues = ["true", "1", "Y", "yes", "on", true, 1];
return truthyValues.includes(value) ? "Y" : "N";
case "code":
case "entity":
return String(value || "").trim();
default:
return String(value);
}
} catch (error) {
logger.error(`Error converting value for storage: ${error}`, {
value,
inputType,
});
return String(value || "");
}
}
/**
* (DB )
* VARCHAR에서
*/
static convertForDisplay(value: string, inputType: InputType): any {
if (!value && value !== "0") {
// 빈 값 처리
switch (inputType) {
case "number":
return 0;
case "checkbox":
return false;
default:
return "";
}
}
try {
switch (inputType) {
case "text":
case "select":
case "radio":
case "code":
case "entity":
return value;
case "number":
const num = parseFloat(value);
return isNaN(num) ? 0 : num;
case "date":
// YYYY-MM-DD 형식 그대로 반환 (HTML date input 호환)
return value;
case "checkbox":
return value === "Y" || value === "true" || value === "1";
default:
return value;
}
} catch (error) {
logger.error(`Error converting value for display: ${error}`, {
value,
inputType,
});
return value;
}
}
/**
*
*
*/
static validate(value: any, inputType: InputType): ValidationResult {
// 빈 값은 일반적으로 허용 (필수 여부는 별도 검증)
if (!value && value !== 0 && value !== false) {
return {
isValid: true,
convertedValue: this.convertForStorage(value, inputType),
};
}
try {
switch (inputType) {
case "text":
case "select":
case "radio":
case "code":
case "entity":
const strValue = String(value).trim();
if (strValue.length > 500) {
return {
isValid: false,
message: "입력값이 너무 깁니다. (최대 500자)",
};
}
return {
isValid: true,
convertedValue: this.convertForStorage(value, inputType),
};
case "number":
const num = parseFloat(String(value));
if (isNaN(num)) {
return {
isValid: false,
message: "숫자 형식이 올바르지 않습니다.",
};
}
return {
isValid: true,
convertedValue: this.convertForStorage(value, inputType),
};
case "date":
if (!value) {
return { isValid: true, convertedValue: "" };
}
const date = new Date(value);
if (isNaN(date.getTime())) {
return {
isValid: false,
message: "날짜 형식이 올바르지 않습니다.",
};
}
return {
isValid: true,
convertedValue: this.convertForStorage(value, inputType),
};
case "checkbox":
// 체크박스는 모든 값을 허용 (Y/N으로 변환)
return {
isValid: true,
convertedValue: this.convertForStorage(value, inputType),
};
default:
return {
isValid: true,
convertedValue: this.convertForStorage(value, inputType),
};
}
} catch (error) {
logger.error(`Error validating value: ${error}`, { value, inputType });
return {
isValid: false,
message: "값 검증 중 오류가 발생했습니다.",
};
}
}
/**
* ( )
*/
static convertBatchForStorage(
data: Record<string, any>,
columnTypes: Record<string, InputType>
): Record<string, string> {
const converted: Record<string, string> = {};
for (const [columnName, value] of Object.entries(data)) {
const inputType = columnTypes[columnName];
if (inputType) {
converted[columnName] = this.convertForStorage(value, inputType);
} else {
// 입력 타입이 정의되지 않은 경우 기본적으로 text로 처리
converted[columnName] = this.convertForStorage(value, "text");
}
}
return converted;
}
/**
*
*/
static convertBatchForDisplay(
data: Record<string, string>,
columnTypes: Record<string, InputType>
): Record<string, any> {
const converted: Record<string, any> = {};
for (const [columnName, value] of Object.entries(data)) {
const inputType = columnTypes[columnName];
if (inputType) {
converted[columnName] = this.convertForDisplay(value, inputType);
} else {
// 입력 타입이 정의되지 않은 경우 문자열 그대로 반환
converted[columnName] = value;
}
}
return converted;
}
/**
*
*/
static validateBatch(
data: Record<string, any>,
columnTypes: Record<string, InputType>
): {
isValid: boolean;
errors: Record<string, string>;
convertedData: Record<string, string>;
} {
const errors: Record<string, string> = {};
const convertedData: Record<string, string> = {};
for (const [columnName, value] of Object.entries(data)) {
const inputType = columnTypes[columnName];
if (inputType) {
const result = this.validate(value, inputType);
if (!result.isValid) {
errors[columnName] = result.message || "검증 실패";
} else {
convertedData[columnName] = result.convertedValue || "";
}
} else {
// 입력 타입이 정의되지 않은 경우 기본적으로 text로 처리
convertedData[columnName] = this.convertForStorage(value, "text");
}
}
return {
isValid: Object.keys(errors).length === 0,
errors,
convertedData,
};
}
}

View File

@ -1013,23 +1013,52 @@ export class ScreenManagementService {
*
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
const lowerType = dataType.toLowerCase();
if (lowerType.includes("char") || lowerType.includes("text")) {
return "text";
} else if (
lowerType.includes("int") ||
lowerType.includes("numeric") ||
lowerType.includes("decimal")
) {
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("date") || lowerType.includes("time")) {
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else {
return "text";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
// ========================================

View File

@ -10,6 +10,7 @@ import {
EntityJoinResponse,
EntityJoinConfig,
} from "../types/tableManagement";
import { WebType } from "../types/unified-web-types";
import { entityJoinService } from "./entityJoinService";
import { referenceCacheService } from "./referenceCacheService";
@ -210,6 +211,11 @@ export class TableManagementService {
: null,
numericScale: column.numericScale ? Number(column.numericScale) : null,
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론
webType:
column.webType === "text"
? this.inferWebType(column.dataType)
: column.webType,
}));
const totalPages = Math.ceil(total / size);
@ -323,7 +329,7 @@ export class TableManagementService {
},
update: {
column_label: settings.columnLabel,
web_type: settings.webType,
input_type: settings.inputType,
detail_settings: settings.detailSettings,
code_category: settings.codeCategory,
code_value: settings.codeValue,
@ -339,7 +345,7 @@ export class TableManagementService {
table_name: tableName,
column_name: columnName,
column_label: settings.columnLabel,
web_type: settings.webType,
input_type: settings.inputType,
detail_settings: settings.detailSettings,
code_category: settings.codeCategory,
code_value: settings.codeValue,
@ -620,7 +626,123 @@ export class TableManagementService {
}
/**
*
* ( )
*/
async updateColumnInputType(
tableName: string,
columnName: string,
inputType: string,
detailSettings?: Record<string, any>
): Promise<void> {
try {
logger.info(
`컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}`
);
// 입력 타입별 기본 상세 설정 생성
const defaultDetailSettings =
this.generateDefaultInputTypeSettings(inputType);
// 사용자 정의 설정과 기본 설정 병합
const finalDetailSettings = {
...defaultDetailSettings,
...detailSettings,
};
// table_type_columns 테이블에서 업데이트
await prisma.$executeRaw`
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
${tableName}, ${columnName}, ${inputType}, ${JSON.stringify(finalDetailSettings)},
'Y', 0, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
input_type = ${inputType},
detail_settings = ${JSON.stringify(finalDetailSettings)},
updated_date = now();
`;
logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}`
);
} catch (error) {
logger.error(
`컬럼 입력 타입 설정 실패: ${tableName}.${columnName}`,
error
);
throw new Error(
`컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
*
*/
private generateDefaultInputTypeSettings(
inputType: string
): Record<string, any> {
switch (inputType) {
case "text":
return {
maxLength: 500,
placeholder: "텍스트를 입력하세요",
};
case "number":
return {
min: 0,
step: 1,
placeholder: "숫자를 입력하세요",
};
case "date":
return {
format: "YYYY-MM-DD",
placeholder: "날짜를 선택하세요",
};
case "code":
return {
placeholder: "코드를 선택하세요",
searchable: true,
};
case "entity":
return {
placeholder: "항목을 선택하세요",
searchable: true,
};
case "select":
return {
placeholder: "선택하세요",
searchable: false,
};
case "checkbox":
return {
defaultChecked: false,
trueValue: "Y",
falseValue: "N",
};
case "radio":
return {
inline: false,
};
default:
return {};
}
}
/**
* ( )
* @deprecated generateDefaultInputTypeSettings
*/
private generateDefaultDetailSettings(webType: string): Record<string, any> {
switch (webType) {
@ -897,6 +1019,434 @@ export class TableManagementService {
}
}
/**
*
*/
private async buildAdvancedSearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
} | null> {
try {
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
if (
value === "__ALL__" ||
value === "" ||
value === null ||
value === undefined
) {
return null;
}
// 컬럼 타입 정보 조회
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (!columnInfo) {
// 컬럼 정보가 없으면 기본 문자열 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
const webType = columnInfo.webType;
// 웹타입별 검색 조건 구성
switch (webType) {
case "date":
case "datetime":
return this.buildDateRangeCondition(columnName, value, paramIndex);
case "number":
case "decimal":
return this.buildNumberRangeCondition(columnName, value, paramIndex);
case "code":
return await this.buildCodeSearchCondition(
tableName,
columnName,
value,
paramIndex
);
case "entity":
return await this.buildEntitySearchCondition(
tableName,
columnName,
value,
paramIndex
);
default:
// 기본 문자열 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`고급 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
// 오류 시 기본 검색으로 폴백
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
*
*/
private buildDateRangeCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
const conditions: string[] = [];
const values: any[] = [];
let paramCount = 0;
if (typeof value === "object" && value !== null) {
if (value.from) {
conditions.push(`${columnName} >= $${paramIndex + paramCount}`);
values.push(value.from);
paramCount++;
}
if (value.to) {
conditions.push(`${columnName} <= $${paramIndex + paramCount}`);
values.push(value.to);
paramCount++;
}
} else if (typeof value === "string" && value.trim() !== "") {
// 단일 날짜 검색 (해당 날짜의 데이터)
conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`);
values.push(value);
paramCount = 1;
}
if (conditions.length === 0) {
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
return {
whereClause: `(${conditions.join(" AND ")})`,
values,
paramCount,
};
}
/**
*
*/
private buildNumberRangeCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
const conditions: string[] = [];
const values: any[] = [];
let paramCount = 0;
if (typeof value === "object" && value !== null) {
if (value.min !== undefined && value.min !== null && value.min !== "") {
conditions.push(
`${columnName}::numeric >= $${paramIndex + paramCount}`
);
values.push(parseFloat(value.min));
paramCount++;
}
if (value.max !== undefined && value.max !== null && value.max !== "") {
conditions.push(
`${columnName}::numeric <= $${paramIndex + paramCount}`
);
values.push(parseFloat(value.max));
paramCount++;
}
} else if (typeof value === "string" || typeof value === "number") {
// 정확한 값 검색
conditions.push(`${columnName}::numeric = $${paramIndex}`);
values.push(parseFloat(value.toString()));
paramCount = 1;
}
if (conditions.length === 0) {
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
return {
whereClause: `(${conditions.join(" AND ")})`,
values,
paramCount,
};
}
/**
*
*/
private async buildCodeSearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
}> {
try {
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
// 코드 타입이 아니면 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// 코드값 또는 코드명으로 검색
return {
whereClause: `(
${columnName}::text = $${paramIndex} OR
EXISTS (
SELECT 1 FROM code_info ci
WHERE ci.code_category = $${paramIndex + 1}
AND ci.code_value = ${columnName}
AND ci.code_name ILIKE $${paramIndex + 2}
)
)`,
values: [value, codeTypeInfo.codeCategory, `%${value}%`],
paramCount: 3,
};
} else {
// 정확한 코드값 매칭
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`코드 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
*
*/
private async buildEntitySearchCondition(
tableName: string,
columnName: string,
value: any,
paramIndex: number
): Promise<{
whereClause: string;
values: any[];
paramCount: number;
}> {
try {
const entityTypeInfo = await this.getEntityTypeInfo(
tableName,
columnName
);
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
// 엔티티 타입이 아니면 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
const displayColumn = entityTypeInfo.displayColumn || "name";
const referenceColumn = entityTypeInfo.referenceColumn || "id";
// 참조 테이블의 표시 컬럼으로 검색
return {
whereClause: `EXISTS (
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref
WHERE ref.${referenceColumn} = ${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex}
)`,
values: [`%${value}%`],
paramCount: 1,
};
} else {
// 정확한 참조값 매칭
return {
whereClause: `${columnName} = $${paramIndex}`,
values: [value],
paramCount: 1,
};
}
} catch (error) {
logger.error(
`엔티티 검색 조건 구성 실패: ${tableName}.${columnName}`,
error
);
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
*
*/
private buildBooleanCondition(
columnName: string,
value: any,
paramIndex: number
): {
whereClause: string;
values: any[];
paramCount: number;
} {
if (value === "true" || value === true) {
return {
whereClause: `${columnName} = true`,
values: [],
paramCount: 0,
};
} else if (value === "false" || value === false) {
return {
whereClause: `${columnName} = false`,
values: [],
paramCount: 0,
};
} else {
// 기본 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
}
}
/**
*
*/
private async getColumnWebTypeInfo(
tableName: string,
columnName: string
): Promise<{
webType: string;
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
} | null> {
try {
const result = await prisma.column_labels.findFirst({
where: {
table_name: tableName,
column_name: columnName,
},
select: {
web_type: true,
code_category: true,
reference_table: true,
reference_column: true,
display_column: true,
},
});
if (!result) {
return null;
}
return {
webType: result.web_type || "",
codeCategory: result.code_category || undefined,
referenceTable: result.reference_table || undefined,
referenceColumn: result.reference_column || undefined,
displayColumn: result.display_column || undefined,
};
} catch (error) {
logger.error(
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,
error
);
return null;
}
}
/**
*
*/
private async getEntityTypeInfo(
tableName: string,
columnName: string
): Promise<{
isEntityType: boolean;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}> {
try {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (!columnInfo || columnInfo.webType !== "entity") {
return { isEntityType: false };
}
return {
isEntityType: true,
referenceTable: columnInfo.referenceTable,
referenceColumn: columnInfo.referenceColumn,
displayColumn: columnInfo.displayColumn,
};
} catch (error) {
logger.error(
`엔티티 타입 정보 조회 실패: ${tableName}.${columnName}`,
error
);
return { isEntityType: false };
}
}
/**
* ( + )
*/
@ -949,42 +1499,19 @@ export class TableManagementService {
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
if (typeof value === "string") {
// 🎯 코드 타입 컬럼의 경우 코드값과 코드명 모두로 검색
const codeTypeInfo = await this.getCodeTypeInfo(
tableName,
safeColumn
);
// 🎯 고급 필터 처리
const condition = await this.buildAdvancedSearchCondition(
tableName,
safeColumn,
value,
paramIndex
);
if (codeTypeInfo.isCodeType && codeTypeInfo.codeCategory) {
// 코드 타입 컬럼: 코드값 또는 코드명으로 검색
// 1) 컬럼 값이 직접 검색어와 일치하는 경우
// 2) 컬럼 값이 코드값이고, 해당 코드의 코드명이 검색어와 일치하는 경우
whereConditions.push(`(
${safeColumn}::text ILIKE $${paramIndex} OR
EXISTS (
SELECT 1 FROM code_info ci
WHERE ci.code_category = $${paramIndex + 1}
AND ci.code_value = ${safeColumn}
AND ci.code_name ILIKE $${paramIndex + 2}
)
)`);
searchValues.push(`%${value}%`); // 직접 값 검색용
searchValues.push(codeTypeInfo.codeCategory); // 코드 카테고리
searchValues.push(`%${value}%`); // 코드명 검색용
paramIndex += 2; // 추가 파라미터로 인해 인덱스 증가
} else {
// 일반 컬럼: 기존 방식
whereConditions.push(
`${safeColumn}::text ILIKE $${paramIndex}`
);
searchValues.push(`%${value}%`);
}
} else {
whereConditions.push(`${safeColumn} = $${paramIndex}`);
searchValues.push(value);
if (condition) {
whereConditions.push(condition.whereClause);
searchValues.push(...condition.values);
paramIndex += condition.paramCount;
}
paramIndex++;
}
}
}
@ -1576,7 +2103,10 @@ export class TableManagementService {
const selectColumns = columns.data.map((col: any) => col.column_name);
// WHERE 절 구성
const whereClause = this.buildWhereClause(options.search);
const whereClause = await this.buildWhereClause(
tableName,
options.search
);
// ORDER BY 절 구성
const orderBy = options.sortBy
@ -1939,21 +2469,77 @@ export class TableManagementService {
}
/**
* WHERE
* WHERE ( )
*/
private buildWhereClause(search?: Record<string, any>): string {
private async buildWhereClause(
tableName: string,
search?: Record<string, any>
): Promise<string> {
if (!search || Object.keys(search).length === 0) {
return "";
}
const conditions: string[] = [];
for (const [key, value] of Object.entries(search)) {
if (value !== undefined && value !== null && value !== "") {
for (const [columnName, value] of Object.entries(search)) {
if (
value === undefined ||
value === null ||
value === "" ||
value === "__ALL__"
) {
continue;
}
try {
// 고급 검색 조건 구성
const searchCondition = await this.buildAdvancedSearchCondition(
tableName,
columnName,
value,
1 // paramIndex는 실제로는 사용되지 않음 (직접 값 삽입)
);
if (searchCondition) {
// SQL 인젝션 방지를 위해 값을 직접 삽입하는 대신 안전한 방식 사용
let condition = searchCondition.whereClause;
// 파라미터를 실제 값으로 치환 (안전한 방식)
searchCondition.values.forEach((val, index) => {
const paramPlaceholder = `$${index + 1}`;
if (typeof val === "string") {
condition = condition.replace(
paramPlaceholder,
`'${val.replace(/'/g, "''")}'`
);
} else if (typeof val === "number") {
condition = condition.replace(paramPlaceholder, val.toString());
} else {
condition = condition.replace(
paramPlaceholder,
`'${String(val).replace(/'/g, "''")}'`
);
}
});
// main. 접두사 추가 (조인 쿼리용)
condition = condition.replace(
new RegExp(`\\b${columnName}\\b`, "g"),
`main.${columnName}`
);
conditions.push(condition);
}
} catch (error) {
logger.warn(`검색 조건 구성 실패: ${columnName}`, error);
// 폴백: 기본 문자열 검색
if (typeof value === "string") {
conditions.push(`main.${key} ILIKE '%${value}%'`);
conditions.push(
`main.${columnName}::text ILIKE '%${value.replace(/'/g, "''")}%'`
);
} else {
conditions.push(`main.${key} = '${value}'`);
conditions.push(
`main.${columnName} = '${String(value).replace(/'/g, "''")}'`
);
}
}
}
@ -2267,4 +2853,237 @@ export class TableManagementService {
return totalHitRate / cacheableJoins.length;
}
/**
* ( )
*/
async getTableSchema(tableName: string): Promise<ColumnTypeInfo[]> {
try {
logger.info(`테이블 스키마 정보 조회: ${tableName}`);
const rawColumns = await prisma.$queryRaw<any[]>`
SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = ${tableName} AND constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = ${tableName}
AND table_schema = 'public'
ORDER BY ordinal_position
`;
const columns: ColumnTypeInfo[] = rawColumns.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength ? Number(col.maxLength) : undefined,
numericPrecision: col.numericPrecision
? Number(col.numericPrecision)
: undefined,
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
}));
logger.info(
`테이블 스키마 조회 완료: ${tableName}, ${columns.length}개 컬럼`
);
return columns;
} catch (error) {
logger.error(`테이블 스키마 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
*
*/
async checkTableExists(tableName: string): Promise<boolean> {
try {
logger.info(`테이블 존재 여부 확인: ${tableName}`);
const result = await prisma.$queryRaw<any[]>`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = ${tableName}
AND table_schema = 'public'
AND table_type = 'BASE TABLE'
) as "exists"
`;
const exists = result[0]?.exists || false;
logger.info(`테이블 존재 여부: ${tableName} = ${exists}`);
return exists;
} catch (error) {
logger.error(`테이블 존재 여부 확인 실패: ${tableName}`, error);
throw error;
}
}
/**
* ( )
*/
async getColumnInputTypes(tableName: string): Promise<ColumnTypeInfo[]> {
try {
logger.info(`컬럼 입력타입 정보 조회: ${tableName}`);
// table_type_columns에서 입력타입 정보 조회
const rawInputTypes = await prisma.$queryRaw<any[]>`
SELECT
ttc.column_name as "columnName",
ttc.column_name as "displayName",
COALESCE(ttc.input_type, 'text') as "inputType",
COALESCE(ttc.detail_settings, '{}') as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType"
FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = ${tableName}
ORDER BY ttc.display_order, ttc.column_name
`;
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable,
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
}));
logger.info(
`컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼`
);
return inputTypes;
} catch (error) {
logger.error(`컬럼 입력타입 정보 조회 실패: ${tableName}`, error);
throw error;
}
}
/**
* 지원: 컬럼
* @deprecated getColumnInputTypes
*/
async getColumnWebTypes(tableName: string): Promise<ColumnTypeInfo[]> {
logger.warn(
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
);
return this.getColumnInputTypes(tableName);
}
/**
*
*/
async checkDatabaseConnection(): Promise<{
connected: boolean;
message: string;
}> {
try {
logger.info("데이터베이스 연결 상태 확인");
// 간단한 쿼리로 연결 테스트
const result = await prisma.$queryRaw<any[]>`SELECT 1 as "test"`;
if (result && result.length > 0) {
logger.info("데이터베이스 연결 성공");
return {
connected: true,
message: "데이터베이스에 성공적으로 연결되었습니다.",
};
} else {
logger.warn("데이터베이스 연결 응답 없음");
return {
connected: false,
message: "데이터베이스 연결 응답이 없습니다.",
};
}
} catch (error) {
logger.error("데이터베이스 연결 확인 실패:", error);
return {
connected: false,
message: `데이터베이스 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
};
}
}
/**
*
*/
private inferWebType(dataType: string): WebType {
// 통합 타입 매핑에서 import
const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types");
const lowerType = dataType.toLowerCase();
// 정확한 매핑 우선 확인
if (DB_TYPE_TO_WEB_TYPE[lowerType]) {
return DB_TYPE_TO_WEB_TYPE[lowerType];
}
// 부분 문자열 매칭 (더 정교한 규칙)
for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) {
if (
lowerType.includes(dbType.toLowerCase()) ||
dbType.toLowerCase().includes(lowerType)
) {
return webType as WebType;
}
}
// 추가 정밀 매핑
if (lowerType.includes("int") && !lowerType.includes("point")) {
return "number";
} else if (lowerType.includes("numeric") || lowerType.includes("decimal")) {
return "decimal";
} else if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime")
) {
return "datetime";
} else if (lowerType.includes("date")) {
return "date";
} else if (lowerType.includes("time")) {
return "datetime";
} else if (lowerType.includes("bool")) {
return "checkbox";
} else if (
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("varchar")
) {
return lowerType.includes("text") ? "textarea" : "text";
}
// 기본값
return "text";
}
}

View File

@ -0,0 +1,316 @@
/**
* DDL
*/
// 기본 웹타입
export type WebType =
| "text"
| "number"
| "decimal"
| "date"
| "datetime"
| "boolean"
| "code"
| "entity"
| "textarea"
| "select"
| "checkbox"
| "radio"
| "file"
| "email"
| "tel";
// 컬럼 정의 인터페이스
export interface CreateColumnDefinition {
/** 컬럼명 (영문자, 숫자, 언더스코어만 허용) */
name: string;
/** 컬럼 라벨 (화면 표시용) */
label?: string;
/** 입력타입 */
inputType?: string;
/** 웹타입 (레거시 호환용) */
webType?: WebType;
/** NULL 허용 여부 */
nullable?: boolean;
/** 컬럼 길이 (text, code 타입에서 사용) */
length?: number;
/** 기본값 */
defaultValue?: string;
/** 컬럼 설명 */
description?: string;
/** 표시 순서 */
order?: number;
/** 상세 설정 (JSON 형태) */
detailSettings?: Record<string, any>;
}
// 테이블 생성 요청 인터페이스
export interface CreateTableRequest {
/** 테이블명 */
tableName: string;
/** 테이블 설명 */
description?: string;
/** 컬럼 정의 목록 */
columns: CreateColumnDefinition[];
}
// 컬럼 추가 요청 인터페이스
export interface AddColumnRequest {
/** 컬럼 정의 */
column: CreateColumnDefinition;
}
// DDL 실행 결과 인터페이스
export interface DDLExecutionResult {
/** 실행 성공 여부 */
success: boolean;
/** 결과 메시지 */
message: string;
/** 실행된 DDL 쿼리 */
executedQuery?: string;
/** 오류 정보 */
error?: {
code: string;
details: string;
};
}
// 검증 결과 인터페이스
export interface ValidationResult {
/** 검증 통과 여부 */
isValid: boolean;
/** 오류 메시지 목록 */
errors: string[];
/** 경고 메시지 목록 */
warnings?: string[];
}
// DDL 실행 로그 인터페이스
export interface DDLExecutionLog {
/** 로그 ID */
id: number;
/** 사용자 ID */
user_id: string;
/** 회사 코드 */
company_code: string;
/** DDL 유형 */
ddl_type: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN";
/** 테이블명 */
table_name: string;
/** 실행된 DDL 쿼리 */
ddl_query: string;
/** 실행 성공 여부 */
success: boolean;
/** 오류 메시지 (실패 시) */
error_message?: string;
/** 실행 시간 */
executed_at: Date;
}
// PostgreSQL 타입 매핑
export interface PostgreSQLTypeMapping {
/** 웹타입 */
webType: WebType;
/** PostgreSQL 데이터 타입 */
postgresType: string;
/** 기본 길이 (있는 경우) */
defaultLength?: number;
/** 길이 지정 가능 여부 */
supportsLength: boolean;
}
// 테이블 메타데이터 인터페이스
export interface TableMetadata {
/** 테이블명 */
tableName: string;
/** 테이블 라벨 */
tableLabel: string;
/** 테이블 설명 */
description?: string;
/** 생성 일시 */
createdDate: Date;
/** 수정 일시 */
updatedDate: Date;
}
// 컬럼 메타데이터 인터페이스
export interface ColumnMetadata {
/** 테이블명 */
tableName: string;
/** 컬럼명 */
columnName: string;
/** 컬럼 라벨 */
columnLabel: string;
/** 웹타입 */
webType: WebType;
/** 상세 설정 (JSON 문자열) */
detailSettings: string;
/** 컬럼 설명 */
description?: string;
/** 표시 순서 */
displayOrder: number;
/** 표시 여부 */
isVisible: boolean;
/** 코드 카테고리 (code 타입용) */
codeCategory?: string;
/** 코드 값 (code 타입용) */
codeValue?: string;
/** 참조 테이블 (entity 타입용) */
referenceTable?: string;
/** 참조 컬럼 (entity 타입용) */
referenceColumn?: string;
/** 생성 일시 */
createdDate: Date;
/** 수정 일시 */
updatedDate: Date;
}
// 시스템 테이블 목록 (보호 대상)
export const SYSTEM_TABLES = [
"user_info",
"company_mng",
"menu_info",
"auth_group",
"table_labels",
"column_labels",
"screen_definitions",
"screen_layouts",
"common_code",
"multi_lang_key_master",
"multi_lang_text",
"button_action_standards",
"ddl_execution_log",
] as const;
// 예약어 목록
export const RESERVED_WORDS = [
"user",
"order",
"group",
"table",
"column",
"index",
"select",
"insert",
"update",
"delete",
"from",
"where",
"join",
"on",
"as",
"and",
"or",
"not",
"null",
"true",
"false",
"create",
"alter",
"drop",
"primary",
"key",
"foreign",
"references",
"constraint",
"default",
"unique",
"check",
"view",
"procedure",
"function",
] as const;
// 예약된 컬럼명 목록 (자동 추가되는 기본 컬럼들)
export const RESERVED_COLUMNS = [
"id",
"created_date",
"updated_date",
"company_code",
] as const;
// 웹타입별 PostgreSQL 타입 매핑
export const WEB_TYPE_TO_POSTGRES_MAP: Record<WebType, PostgreSQLTypeMapping> =
{
text: {
webType: "text",
postgresType: "varchar",
defaultLength: 255,
supportsLength: true,
},
number: {
webType: "number",
postgresType: "integer",
supportsLength: false,
},
decimal: {
webType: "decimal",
postgresType: "numeric(10,2)",
supportsLength: false,
},
date: {
webType: "date",
postgresType: "date",
supportsLength: false,
},
datetime: {
webType: "datetime",
postgresType: "timestamp",
supportsLength: false,
},
boolean: {
webType: "boolean",
postgresType: "boolean",
supportsLength: false,
},
code: {
webType: "code",
postgresType: "varchar",
defaultLength: 100,
supportsLength: true,
},
entity: {
webType: "entity",
postgresType: "integer",
supportsLength: false,
},
textarea: {
webType: "textarea",
postgresType: "text",
supportsLength: false,
},
select: {
webType: "select",
postgresType: "varchar",
defaultLength: 100,
supportsLength: true,
},
checkbox: {
webType: "checkbox",
postgresType: "boolean",
supportsLength: false,
},
radio: {
webType: "radio",
postgresType: "varchar",
defaultLength: 100,
supportsLength: true,
},
file: {
webType: "file",
postgresType: "text",
supportsLength: false,
},
email: {
webType: "email",
postgresType: "varchar",
defaultLength: 255,
supportsLength: true,
},
tel: {
webType: "tel",
postgresType: "varchar",
defaultLength: 50,
supportsLength: true,
},
};

View File

@ -0,0 +1,115 @@
/**
* ( )
* 8
*/
// 8개 핵심 입력 타입 (프론트엔드와 동일)
export type InputType =
| "text" // 텍스트
| "number" // 숫자
| "date" // 날짜
| "code" // 코드
| "entity" // 엔티티
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio"; // 라디오버튼
// 입력 타입 옵션 정의
export interface InputTypeOption {
value: InputType;
label: string;
description: string;
category: "basic" | "reference" | "selection";
}
// 입력 타입 옵션 목록
export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
{
value: "text",
label: "텍스트",
description: "일반 텍스트 입력",
category: "basic",
},
{
value: "number",
label: "숫자",
description: "숫자 입력 (정수/소수)",
category: "basic",
},
{ value: "date", label: "날짜", description: "날짜 선택", category: "basic" },
{
value: "code",
label: "코드",
description: "공통코드 참조",
category: "reference",
},
{
value: "entity",
label: "엔티티",
description: "다른 테이블 참조",
category: "reference",
},
{
value: "select",
label: "선택박스",
description: "드롭다운 선택",
category: "selection",
},
{
value: "checkbox",
label: "체크박스",
description: "체크박스 입력",
category: "selection",
},
{
value: "radio",
label: "라디오버튼",
description: "단일 선택",
category: "selection",
},
];
// 입력 타입 검증 함수
export const isValidInputType = (inputType: string): inputType is InputType => {
return INPUT_TYPE_OPTIONS.some((option) => option.value === inputType);
};
// 레거시 웹 타입 → 입력 타입 매핑
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
// 텍스트 관련
text: "text",
textarea: "text",
email: "text",
tel: "text",
url: "text",
password: "text",
// 숫자 관련
number: "number",
decimal: "number",
// 날짜 관련
date: "date",
datetime: "date",
time: "date",
// 선택 관련
select: "select",
dropdown: "select",
checkbox: "checkbox",
boolean: "checkbox",
radio: "radio",
// 참조 관련
code: "code",
entity: "entity",
// 기타 (기본값: text)
file: "text",
button: "text",
};
// 입력 타입 변환 함수
export const convertWebTypeToInputType = (webType: string): InputType => {
return WEB_TYPE_TO_INPUT_TYPE[webType] || "text";
};

View File

@ -4,17 +4,8 @@
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
// 웹 타입 정의
export type WebType =
| "text"
| "number"
| "date"
| "code"
| "entity"
| "textarea"
| "select"
| "checkbox"
| "radio"
| "file";
// WebType은 통합 타입에서 import (중복 정의 제거)
export { WebType } from "./unified-web-types";
// 위치 정보
export interface Position {

View File

@ -8,16 +8,15 @@ export interface TableInfo {
}
export interface ColumnTypeInfo {
tableName?: string;
columnName: string;
displayName: string;
dataType: string; // 추가: 데이터 타입 (dbType과 동일하지만 별도 필드)
dbType: string;
webType: string;
inputType?: "direct" | "auto";
dataType: string; // DB 데이터 타입 (varchar, integer 등)
inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio)
detailSettings: string;
description: string;
isNullable: string;
isPrimaryKey: boolean; // 추가: 기본키 여부
isPrimaryKey: boolean;
defaultValue?: string;
maxLength?: number;
numericPrecision?: number;
@ -34,7 +33,7 @@ export interface ColumnTypeInfo {
export interface ColumnSettings {
columnName?: string; // 컬럼명 (업데이트 시 필요)
columnLabel: string; // 컬럼 표시명
webType: string; // 웹 입력 타입 (text, number, date, code, entity)
inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio)
detailSettings: string; // 상세 설정
codeCategory: string; // 코드 카테고리
codeValue: string; // 코드 값

View File

@ -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 }, // 레거시 지원
};

View File

@ -7,14 +7,19 @@ import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Database, RefreshCw, Settings, Menu, X } from "lucide-react";
import { Search, Database, RefreshCw, Settings, Menu, X, Plus, Activity } from "lucide-react";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang";
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement";
import { useAuth } from "@/hooks/useAuth";
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
// 가상화 스크롤링을 위한 간단한 구현
interface TableInfo {
@ -27,8 +32,7 @@ interface TableInfo {
interface ColumnTypeInfo {
columnName: string;
displayName: string;
dbType: string;
webType: string;
inputType: string; // webType → inputType 변경
detailSettings: string;
description: string;
isNullable: string;
@ -45,6 +49,7 @@ interface ColumnTypeInfo {
export default function TableManagementPage() {
const { userLang, getText } = useMultiLang({ companyCode: "*" });
const { user } = useAuth();
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(null);
@ -66,6 +71,14 @@ export default function TableManagementPage() {
// 🎯 Entity 조인 관련 상태
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
// DDL 기능 관련 상태
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
// 최고 관리자 여부 확인
const isSuperAdmin = user?.companyCode === "*" && user?.userId === "plm_admin";
// 다국어 텍스트 로드
useEffect(() => {
const loadTexts = async () => {
@ -135,15 +148,15 @@ export default function TableManagementPage() {
[], // 의존성 배열에서 referenceTableColumns 제거
);
// 웹 타입 옵션 (다국어 적용)
const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => ({
// 입력 타입 옵션 (8개 핵심 타입)
const inputTypeOptions = INPUT_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: getTextFromUI(option.labelKey, option.value),
description: getTextFromUI(option.descriptionKey, option.value),
label: option.label,
description: option.description,
}));
// 메모이제이션된 타입 옵션
const memoizedWebTypeOptions = useMemo(() => webTypeOptions, [uiTexts]);
// 메모이제이션된 입력타입 옵션
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴)
const referenceTableOptions = [
@ -218,14 +231,21 @@ export default function TableManagementPage() {
// 응답 상태 확인
if (response.data.success) {
const data = response.data.data;
// 컬럼 데이터에 기본값 설정
const processedColumns = (data.columns || data).map((col: any) => ({
...col,
inputType: col.inputType || "text", // 기본값: text
}));
if (page === 1) {
setColumns(data.columns || data);
setOriginalColumns(data.columns || data);
setColumns(processedColumns);
setOriginalColumns(processedColumns);
} else {
// 페이지 추가 로드 시 기존 데이터에 추가
setColumns((prev) => [...prev, ...(data.columns || data)]);
setColumns((prev) => [...prev, ...processedColumns]);
}
setTotalColumns(data.total || (data.columns || data).length);
setTotalColumns(data.total || processedColumns.length);
toast.success("컬럼 정보를 성공적으로 로드했습니다.");
} else {
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
@ -255,24 +275,24 @@ export default function TableManagementPage() {
[loadColumnTypes, pageSize, tables],
);
// 타입 변경
const handleWebTypeChange = useCallback(
(columnName: string, newWebType: string) => {
// 입력 타입 변경
const handleInputTypeChange = useCallback(
(columnName: string, newInputType: string) => {
setColumns((prev) =>
prev.map((col) => {
if (col.columnName === columnName) {
const webTypeOption = memoizedWebTypeOptions.find((option) => option.value === newWebType);
const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType);
return {
...col,
webType: newWebType,
detailSettings: webTypeOption?.description || col.detailSettings,
inputType: newInputType,
detailSettings: inputTypeOption?.description || col.detailSettings,
};
}
return col;
}),
);
},
[memoizedWebTypeOptions],
[memoizedInputTypeOptions],
);
// 상세 설정 변경 (코드/엔티티 타입용)
@ -382,7 +402,7 @@ export default function TableManagementPage() {
const columnSetting = {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명
webType: column.webType || "text",
inputType: column.inputType || "text",
detailSettings: column.detailSettings || "",
codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "",
@ -437,7 +457,7 @@ export default function TableManagementPage() {
const columnSettings = columns.map((column) => ({
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명
webType: column.webType || "text",
inputType: column.inputType || "text",
detailSettings: column.detailSettings || "",
description: column.description || "",
codeCategory: column.codeCategory || "",
@ -531,11 +551,44 @@ export default function TableManagementPage() {
<p className="mt-2 text-gray-600">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
</p>
{isSuperAdmin && (
<p className="mt-1 text-sm font-medium text-blue-600">
🔧
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* DDL 기능 버튼들 (최고 관리자만) */}
{isSuperAdmin && (
<>
<Button
onClick={() => setCreateTableModalOpen(true)}
className="bg-green-600 text-white hover:bg-green-700"
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
{selectedTable && (
<Button onClick={() => setAddColumnModalOpen(true)} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
<Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" size="sm">
<Activity className="mr-2 h-4 w-4" />
DDL
</Button>
</>
)}
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2" size="sm">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button>
</div>
<Button onClick={loadTables} disabled={loading} className="flex items-center gap-2">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
@ -668,8 +721,7 @@ export default function TableManagementPage() {
<div className="flex items-center border-b border-gray-200 pb-2 text-sm font-medium text-gray-700">
<div className="w-40 px-4"></div>
<div className="w-48 px-4"></div>
<div className="w-40 px-4">DB </div>
<div className="w-48 px-4"> </div>
<div className="w-48 px-4"> </div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
</div>
@ -703,21 +755,16 @@ export default function TableManagementPage() {
className="h-7 text-xs"
/>
</div>
<div className="w-40 px-4">
<Badge variant="outline" className="text-xs">
{column.dbType}
</Badge>
</div>
<div className="w-48 px-4">
<Select
value={column.webType}
onValueChange={(value) => handleWebTypeChange(column.columnName, value)}
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{memoizedWebTypeOptions.map((option) => (
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -727,7 +774,7 @@ export default function TableManagementPage() {
</div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
{column.webType === "code" && (
{column.inputType === "code" && (
<Select
value={column.codeCategory || "none"}
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
@ -745,7 +792,7 @@ export default function TableManagementPage() {
</Select>
)}
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.webType === "entity" && (
{column.inputType === "entity" && (
<div className="space-y-1">
{/* 🎯 Entity 타입 설정 - 가로 배치 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
@ -880,7 +927,7 @@ export default function TableManagementPage() {
</div>
)}
{/* 다른 웹 타입인 경우 빈 공간 */}
{column.webType !== "code" && column.webType !== "entity" && (
{column.inputType !== "code" && column.inputType !== "entity" && (
<div className="flex h-7 items-center text-xs text-gray-400">-</div>
)}
</div>
@ -927,6 +974,47 @@ export default function TableManagementPage() {
</CardContent>
</Card>
</div>
{/* DDL 모달 컴포넌트들 */}
{isSuperAdmin && (
<>
<CreateTableModal
isOpen={createTableModalOpen}
onClose={() => setCreateTableModalOpen(false)}
onSuccess={async (result) => {
toast.success("테이블이 성공적으로 생성되었습니다!");
// 테이블 목록 새로고침
await loadTables();
// 새로 생성된 테이블 자동 선택 및 컬럼 로드
if (result.data?.tableName) {
setSelectedTable(result.data.tableName);
setCurrentPage(1);
setColumns([]);
await loadColumnTypes(result.data.tableName, 1, pageSize);
}
}}
/>
<AddColumnModal
isOpen={addColumnModalOpen}
onClose={() => setAddColumnModalOpen(false)}
tableName={selectedTable || ""}
onSuccess={async (result) => {
toast.success("컬럼이 성공적으로 추가되었습니다!");
// 테이블 목록 새로고침 (컬럼 수 업데이트)
await loadTables();
// 선택된 테이블의 컬럼 목록 새로고침 - 페이지 리셋
if (selectedTable) {
setCurrentPage(1);
setColumns([]); // 기존 컬럼 목록 초기화
await loadColumnTypes(selectedTable, 1, pageSize);
}
}}
/>
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
</>
)}
</div>
);
}

View File

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

View File

@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
import { EditModal } from "@/components/screen/EditModal";
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
export default function ScreenViewPage() {
const params = useParams();
@ -364,7 +365,7 @@ export default function ScreenViewPage() {
) : (
// 빈 화면일 때도 깔끔하게 표시
<div
className="mx-auto flex items-center justify-center bg-gray-50"
className="mx-auto flex items-center justify-center bg-white"
style={{
width: `${screenWidth}px`,
height: `${screenHeight}px`,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import StressTestPage from "../stress-test";
export default function StressTestRoutePage() {
return <StressTestPage />;
}

View File

@ -0,0 +1,365 @@
/**
*
*
*/
"use client";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Plus, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { ddlApi } from "../../lib/api/ddl";
import {
AddColumnModalProps,
CreateColumnDefinition,
VALIDATION_RULES,
RESERVED_WORDS,
RESERVED_COLUMNS,
} from "../../types/ddl";
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) {
const [column, setColumn] = useState<CreateColumnDefinition>({
name: "",
label: "",
inputType: "text",
nullable: true,
order: 0,
});
const [loading, setLoading] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
/**
*
*/
const resetModal = () => {
setColumn({
name: "",
label: "",
inputType: "text",
nullable: true,
order: 0,
});
setValidationErrors([]);
};
/**
* /
*/
useEffect(() => {
if (isOpen) {
resetModal();
}
}, [isOpen]);
/**
*
*/
const updateColumn = (updates: Partial<CreateColumnDefinition>) => {
const newColumn = { ...column, ...updates };
setColumn(newColumn);
// 업데이트 후 검증
validateColumn(newColumn);
};
/**
*
*/
const validateColumn = (columnData: CreateColumnDefinition) => {
const errors: string[] = [];
// 컬럼명 검증
if (!columnData.name) {
errors.push("컬럼명은 필수입니다.");
} else {
if (!VALIDATION_RULES.columnName.pattern.test(columnData.name)) {
errors.push(VALIDATION_RULES.columnName.errorMessage);
}
if (
columnData.name.length < VALIDATION_RULES.columnName.minLength ||
columnData.name.length > VALIDATION_RULES.columnName.maxLength
) {
errors.push(
`컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다.`,
);
}
// 예약어 검증
if (RESERVED_WORDS.includes(columnData.name.toLowerCase() as any)) {
errors.push("SQL 예약어는 컬럼명으로 사용할 수 없습니다.");
}
// 예약된 컬럼명 검증
if (RESERVED_COLUMNS.includes(columnData.name.toLowerCase() as any)) {
errors.push("이미 자동 추가되는 기본 컬럼명입니다.");
}
// 네이밍 컨벤션 검증
if (columnData.name.startsWith("_") || columnData.name.endsWith("_")) {
errors.push("컬럼명은 언더스코어로 시작하거나 끝날 수 없습니다.");
}
if (columnData.name.includes("__")) {
errors.push("컬럼명에 연속된 언더스코어는 사용할 수 없습니다.");
}
}
// 입력타입 검증
if (!columnData.inputType) {
errors.push("입력타입을 선택해주세요.");
}
// 길이 검증 (길이를 지원하는 타입인 경우)
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === columnData.inputType);
if (inputTypeOption?.supportsLength && columnData.length !== undefined) {
if (
columnData.length < VALIDATION_RULES.columnLength.min ||
columnData.length > VALIDATION_RULES.columnLength.max
) {
errors.push(VALIDATION_RULES.columnLength.errorMessage);
}
}
setValidationErrors(errors);
return errors.length === 0;
};
/**
*
*/
const handleInputTypeChange = (inputType: string) => {
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
if (inputTypeOption?.supportsLength && !column.length && inputTypeOption.defaultLength) {
updates.length = inputTypeOption.defaultLength;
}
// 길이를 지원하지 않는 타입이면 길이 제거
if (!inputTypeOption?.supportsLength) {
updates.length = undefined;
}
updateColumn(updates);
};
/**
*
*/
const handleAddColumn = async () => {
if (!validateColumn(column)) {
toast.error("입력값을 확인해주세요.");
return;
}
setLoading(true);
try {
const result = await ddlApi.addColumn(tableName, { column });
if (result.success) {
toast.success(result.message);
onSuccess(result);
onClose();
} else {
toast.error(result.error?.details || result.message);
}
} catch (error: any) {
console.error("컬럼 추가 실패:", error);
toast.error(error.response?.data?.error?.details || "컬럼 추가에 실패했습니다.");
} finally {
setLoading(false);
}
};
/**
*
*/
const isFormValid = validationErrors.length === 0 && column.name && column.inputType;
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
- {tableName}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 검증 오류 표시 */}
{validationErrors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
{validationErrors.map((error, index) => (
<div key={index}> {error}</div>
))}
</div>
</AlertDescription>
</Alert>
)}
{/* 기본 정보 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="columnName">
<span className="text-red-500">*</span>
</Label>
<Input
id="columnName"
value={column.name}
onChange={(e) => updateColumn({ name: e.target.value })}
placeholder="column_name"
disabled={loading}
className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-red-300" : ""}
/>
<p className="text-muted-foreground text-xs"> , // </p>
</div>
<div className="space-y-2">
<Label htmlFor="columnLabel"></Label>
<Input
id="columnLabel"
value={column.label || ""}
onChange={(e) => updateColumn({ label: e.target.value })}
placeholder="컬럼 라벨"
disabled={loading}
/>
<p className="text-muted-foreground text-xs"> ()</p>
</div>
</div>
{/* 타입 및 속성 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Select value={column.inputType} onValueChange={handleInputTypeChange} disabled={loading}>
<SelectTrigger>
<SelectValue placeholder="입력타입 선택" />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div>
<div className="font-medium">{option.label}</div>
{option.description && (
<div className="text-muted-foreground text-xs">{option.description}</div>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="columnLength"></Label>
<Input
id="columnLength"
type="number"
value={column.length || ""}
onChange={(e) =>
updateColumn({
length: e.target.value ? parseInt(e.target.value) : undefined,
})
}
placeholder={inputTypeOption?.defaultLength?.toString() || ""}
disabled={loading || !inputTypeOption?.supportsLength}
min={1}
max={65535}
/>
<p className="text-muted-foreground text-xs">
{inputTypeOption?.supportsLength ? "1-65535 범위에서 설정 가능" : "이 타입은 길이 설정이 불가능합니다"}
</p>
</div>
</div>
{/* 기본값 및 NULL 허용 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="defaultValue"></Label>
<Input
id="defaultValue"
value={column.defaultValue || ""}
onChange={(e) => updateColumn({ defaultValue: e.target.value })}
placeholder="기본값 (선택사항)"
disabled={loading}
/>
</div>
<div className="flex items-center space-x-2 pt-6">
<Checkbox
id="required"
checked={!column.nullable}
onCheckedChange={(checked) => updateColumn({ nullable: !checked })}
disabled={loading}
/>
<Label htmlFor="required" className="text-sm font-medium">
(NOT NULL)
</Label>
</div>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={column.description || ""}
onChange={(e) => updateColumn({ description: e.target.value })}
placeholder="컬럼에 대한 설명 (선택사항)"
disabled={loading}
rows={3}
/>
</div>
{/* 안내 사항 */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
. .
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button
onClick={handleAddColumn}
disabled={!isFormValid || loading}
className="bg-blue-600 hover:bg-blue-700"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"컬럼 추가"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,318 @@
/**
*
*
*/
"use client";
import { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { X, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
CreateColumnDefinition,
ColumnDefinitionTableProps,
VALIDATION_RULES,
RESERVED_WORDS,
RESERVED_COLUMNS,
} from "../../types/ddl";
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) {
const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({});
/**
*
*/
const updateColumn = (index: number, updates: Partial<CreateColumnDefinition>) => {
const newColumns = [...columns];
newColumns[index] = { ...newColumns[index], ...updates };
onChange(newColumns);
// 업데이트 후 해당 컬럼 검증
validateColumn(index, newColumns[index]);
};
/**
*
*/
const removeColumn = (index: number) => {
if (columns.length <= 1) return; // 최소 1개 컬럼 유지
const newColumns = columns.filter((_, i) => i !== index);
onChange(newColumns);
// 검증 오류도 함께 제거
const newErrors = { ...validationErrors };
delete newErrors[index];
// 인덱스 재조정
const adjustedErrors: Record<number, string[]> = {};
Object.entries(newErrors).forEach(([key, value]) => {
const idx = parseInt(key);
if (idx > index) {
adjustedErrors[idx - 1] = value;
} else if (idx < index) {
adjustedErrors[idx] = value;
}
});
setValidationErrors(adjustedErrors);
};
/**
*
*/
const validateColumn = (index: number, column: CreateColumnDefinition) => {
const errors: string[] = [];
// 컬럼명 검증
if (!column.name) {
errors.push("컬럼명은 필수입니다");
} else {
if (!VALIDATION_RULES.columnName.pattern.test(column.name)) {
errors.push(VALIDATION_RULES.columnName.errorMessage);
}
if (
column.name.length < VALIDATION_RULES.columnName.minLength ||
column.name.length > VALIDATION_RULES.columnName.maxLength
) {
errors.push(
`컬럼명은 ${VALIDATION_RULES.columnName.minLength}-${VALIDATION_RULES.columnName.maxLength}자여야 합니다`,
);
}
// 예약어 검증
if (RESERVED_WORDS.includes(column.name.toLowerCase() as any)) {
errors.push("예약어는 컬럼명으로 사용할 수 없습니다");
}
// 예약된 컬럼명 검증
if (RESERVED_COLUMNS.includes(column.name.toLowerCase() as any)) {
errors.push("이미 자동 추가되는 기본 컬럼명입니다");
}
// 중복 검증
const duplicateCount = columns.filter((col) => col.name.toLowerCase() === column.name.toLowerCase()).length;
if (duplicateCount > 1) {
errors.push("중복된 컬럼명입니다");
}
}
// 입력타입 검증
if (!column.inputType) {
errors.push("입력타입을 선택해주세요");
}
// 길이 검증 (길이를 지원하는 타입인 경우)
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
if (inputTypeOption?.supportsLength && column.length !== undefined) {
if (column.length < VALIDATION_RULES.columnLength.min || column.length > VALIDATION_RULES.columnLength.max) {
errors.push(VALIDATION_RULES.columnLength.errorMessage);
}
}
// 검증 오류 상태 업데이트
setValidationErrors((prev) => ({
...prev,
[index]: errors,
}));
return errors.length === 0;
};
/**
*
*/
const handleInputTypeChange = (index: number, inputType: string) => {
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
if (inputTypeOption?.supportsLength && !columns[index].length && inputTypeOption.defaultLength) {
updates.length = inputTypeOption.defaultLength;
}
// 길이를 지원하지 않는 타입이면 길이 제거
if (!inputTypeOption?.supportsLength) {
updates.length = undefined;
}
updateColumn(index, updates);
};
/**
*
*/
const hasValidationErrors = Object.values(validationErrors).some((errors) => errors.length > 0);
return (
<div className="space-y-4">
{/* 검증 오류 요약 */}
{hasValidationErrors && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription> . .</AlertDescription>
</Alert>
)}
{/* 컬럼 정의 테이블 */}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column, index) => {
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
const rowErrors = validationErrors[index] || [];
const hasRowError = rowErrors.length > 0;
return (
<TableRow key={index} className={hasRowError ? "bg-red-50" : ""}>
<TableCell>
<div className="space-y-1">
<Input
value={column.name}
onChange={(e) => updateColumn(index, { name: e.target.value })}
placeholder="column_name"
disabled={disabled}
className={hasRowError ? "border-red-300" : ""}
/>
{rowErrors.length > 0 && (
<div className="space-y-1 text-xs text-red-600">
{rowErrors.map((error, i) => (
<div key={i}>{error}</div>
))}
</div>
)}
</div>
</TableCell>
<TableCell>
<Input
value={column.label || ""}
onChange={(e) => updateColumn(index, { label: e.target.value })}
placeholder="컬럼 라벨"
disabled={disabled}
/>
</TableCell>
<TableCell>
<Select
value={column.inputType}
onValueChange={(value) => handleInputTypeChange(index, value)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div>
<div className="font-medium">{option.label}</div>
{option.description && (
<div className="text-muted-foreground text-xs">{option.description}</div>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Checkbox
checked={!column.nullable}
onCheckedChange={(checked) => updateColumn(index, { nullable: !checked })}
disabled={disabled}
/>
</div>
</TableCell>
<TableCell>
<Input
type="number"
value={column.length || ""}
onChange={(e) =>
updateColumn(index, {
length: e.target.value ? parseInt(e.target.value) : undefined,
})
}
placeholder={inputTypeOption?.defaultLength?.toString() || ""}
disabled={disabled || !inputTypeOption?.supportsLength}
min={1}
max={65535}
/>
</TableCell>
<TableCell>
<Input
value={column.defaultValue || ""}
onChange={(e) => updateColumn(index, { defaultValue: e.target.value })}
placeholder="기본값"
disabled={disabled}
/>
</TableCell>
<TableCell>
<Textarea
value={column.description || ""}
onChange={(e) => updateColumn(index, { description: e.target.value })}
placeholder="컬럼 설명"
disabled={disabled}
rows={1}
className="min-h-[36px] resize-none"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeColumn(index)}
disabled={disabled || columns.length === 1}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 도움말 */}
<div className="text-muted-foreground space-y-1 text-sm">
<div> , , .</div>
<div> 추가됩니다: id (PK), created_date, updated_date, company_code</div>
<div> NOT NULL .</div>
<div> text, code, email, tel .</div>
</div>
</div>
);
}

View File

@ -0,0 +1,364 @@
/**
*
*
*/
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
import { toast } from "sonner";
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
import { ddlApi } from "../../lib/api/ddl";
import {
CreateTableModalProps,
CreateColumnDefinition,
VALIDATION_RULES,
SYSTEM_TABLES,
RESERVED_WORDS,
} from "../../types/ddl";
export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModalProps) {
const [tableName, setTableName] = useState("");
const [description, setDescription] = useState("");
const [columns, setColumns] = useState<CreateColumnDefinition[]>([
{
name: "",
label: "",
inputType: "text",
nullable: true,
order: 1,
},
]);
const [loading, setLoading] = useState(false);
const [validating, setValidating] = useState(false);
const [tableNameError, setTableNameError] = useState("");
const [validationResult, setValidationResult] = useState<any>(null);
/**
*
*/
const resetModal = () => {
setTableName("");
setDescription("");
setColumns([
{
name: "",
label: "",
inputType: "text",
nullable: true,
order: 1,
},
]);
setTableNameError("");
setValidationResult(null);
};
/**
* /
*/
useEffect(() => {
if (isOpen) {
resetModal();
}
}, [isOpen]);
/**
*
*/
const validateTableName = (name: string): string => {
if (!name) {
return "테이블명은 필수입니다.";
}
if (!VALIDATION_RULES.tableName.pattern.test(name)) {
return VALIDATION_RULES.tableName.errorMessage;
}
if (name.length < VALIDATION_RULES.tableName.minLength || name.length > VALIDATION_RULES.tableName.maxLength) {
return `테이블명은 ${VALIDATION_RULES.tableName.minLength}-${VALIDATION_RULES.tableName.maxLength}자여야 합니다.`;
}
if (SYSTEM_TABLES.includes(name.toLowerCase() as any)) {
return "시스템 테이블명으로 사용할 수 없습니다.";
}
if (RESERVED_WORDS.includes(name.toLowerCase() as any)) {
return "SQL 예약어는 테이블명으로 사용할 수 없습니다.";
}
if (name.startsWith("_") || name.endsWith("_")) {
return "테이블명은 언더스코어로 시작하거나 끝날 수 없습니다.";
}
if (name.includes("__")) {
return "테이블명에 연속된 언더스코어는 사용할 수 없습니다.";
}
return "";
};
/**
*
*/
const handleTableNameChange = (value: string) => {
setTableName(value);
const error = validateTableName(value);
setTableNameError(error);
// 검증 결과 초기화
if (validationResult) {
setValidationResult(null);
}
};
/**
*
*/
const addColumn = () => {
setColumns([
...columns,
{
name: "",
label: "",
inputType: "text",
nullable: true,
order: columns.length + 1,
},
]);
};
/**
*
*/
const validateTable = async () => {
if (tableNameError || !tableName) {
toast.error("테이블명을 올바르게 입력해주세요.");
return;
}
const validColumns = columns.filter((col) => col.name && col.inputType);
if (validColumns.length === 0) {
toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
return;
}
setValidating(true);
try {
const result = await ddlApi.validateTableCreation({
tableName,
columns: validColumns,
description,
});
setValidationResult(result);
if (result.isValid) {
toast.success("검증 완료! 테이블을 생성할 수 있습니다.");
} else {
toast.error("검증 실패. 오류를 확인해주세요.");
}
} catch (error: any) {
console.error("테이블 검증 실패:", error);
toast.error("검증 중 오류가 발생했습니다.");
} finally {
setValidating(false);
}
};
/**
*
*/
const handleCreateTable = async () => {
if (tableNameError || !tableName) {
toast.error("테이블명을 올바르게 입력해주세요.");
return;
}
const validColumns = columns.filter((col) => col.name && col.inputType);
if (validColumns.length === 0) {
toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
return;
}
setLoading(true);
try {
const result = await ddlApi.createTable({
tableName,
columns: validColumns,
description,
});
if (result.success) {
toast.success(result.message);
onSuccess(result);
onClose();
} else {
toast.error(result.error?.details || result.message);
}
} catch (error: any) {
console.error("테이블 생성 실패:", error);
toast.error(error.response?.data?.error?.details || "테이블 생성에 실패했습니다.");
} finally {
setLoading(false);
}
};
/**
*
*/
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 테이블 기본 정보 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tableName">
<span className="text-red-500">*</span>
</Label>
<Input
id="tableName"
value={tableName}
onChange={(e) => handleTableNameChange(e.target.value)}
placeholder="예: customer_info"
className={tableNameError ? "border-red-300" : ""}
/>
{tableNameError && <p className="text-sm text-red-600">{tableNameError}</p>}
<p className="text-muted-foreground text-xs"> , // </p>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="테이블에 대한 설명"
/>
</div>
</div>
{/* 컬럼 정의 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>
<span className="text-red-500">*</span>
</Label>
<Button type="button" variant="outline" size="sm" onClick={addColumn} disabled={loading}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
</div>
{/* 자동 추가 컬럼 안내 */}
<Alert>
<Info className="h-4 w-4" />
<AlertTitle> </AlertTitle>
<AlertDescription>
:
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">id</code>(),
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">created_date</code>,
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">updated_date</code>,
<code className="bg-muted mx-1 rounded px-1 py-0.5 text-sm">company_code</code>
</AlertDescription>
</Alert>
{/* 검증 결과 */}
{validationResult && (
<Alert variant={validationResult.isValid ? "default" : "destructive"}>
{validationResult.isValid ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
<AlertTitle>{validationResult.isValid ? "검증 성공" : "검증 실패"}</AlertTitle>
<AlertDescription>
<div>{validationResult.summary}</div>
{validationResult.errors && validationResult.errors.length > 0 && (
<div className="mt-2 space-y-1">
<div className="font-medium">:</div>
<ul className="list-inside list-disc space-y-1">
{validationResult.errors.map((error: string, index: number) => (
<li key={index} className="text-sm">
{error}
</li>
))}
</ul>
</div>
)}
{validationResult.warnings && validationResult.warnings.length > 0 && (
<div className="mt-2 space-y-1">
<div className="font-medium">:</div>
<ul className="list-inside list-disc space-y-1">
{validationResult.warnings.map((warning: string, index: number) => (
<li key={index} className="text-sm text-orange-600">
{warning}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button variant="secondary" onClick={validateTable} disabled={!isFormValid || validating || loading}>
{validating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"검증하기"
)}
</Button>
<Button
onClick={handleCreateTable}
disabled={!isFormValid || loading || (validationResult && !validationResult.isValid)}
className="bg-green-600 hover:bg-green-700"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"테이블 생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,407 @@
/**
* DDL
* DDL
*/
"use client";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
RefreshCw,
Search,
Calendar,
User,
Database,
CheckCircle2,
XCircle,
BarChart3,
Clock,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { ddlApi } from "../../lib/api/ddl";
import { DDLLogViewerProps, DDLExecutionLog, DDLStatistics } from "../../types/ddl";
export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
const [logs, setLogs] = useState<DDLExecutionLog[]>([]);
const [statistics, setStatistics] = useState<DDLStatistics | null>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// 필터 상태
const [userFilter, setUserFilter] = useState("");
const [ddlTypeFilter, setDdlTypeFilter] = useState("all");
const [limit, setLimit] = useState(50);
/**
*
*/
const loadData = async (showLoading = true) => {
if (showLoading) setLoading(true);
setRefreshing(true);
try {
// 로그와 통계를 병렬로 로드
const [logsResult, statsResult] = await Promise.all([
ddlApi.getDDLLogs({
limit,
userId: userFilter || undefined,
ddlType: ddlTypeFilter === "all" ? undefined : ddlTypeFilter,
}),
ddlApi.getDDLStatistics(),
]);
setLogs(logsResult.logs);
setStatistics(statsResult);
} catch (error) {
console.error("DDL 로그 로드 실패:", error);
toast.error("DDL 로그를 불러오는데 실패했습니다.");
} finally {
if (showLoading) setLoading(false);
setRefreshing(false);
}
};
/**
*
*/
const applyFilters = () => {
loadData(false);
};
/**
*
*/
const resetFilters = () => {
setUserFilter("");
setDdlTypeFilter("");
setLimit(50);
};
/**
*
*/
const cleanupLogs = async () => {
if (!confirm("90일 이전의 오래된 로그를 삭제하시겠습니까?")) {
return;
}
try {
const result = await ddlApi.cleanupOldLogs(90);
toast.success(`${result.deletedCount}개의 오래된 로그가 삭제되었습니다.`);
loadData(false);
} catch (error) {
console.error("로그 정리 실패:", error);
toast.error("로그 정리에 실패했습니다.");
}
};
/**
*
*/
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen]);
/**
* DDL
*/
const getDDLTypeBadgeVariant = (ddlType: string) => {
switch (ddlType) {
case "CREATE_TABLE":
return "default";
case "ADD_COLUMN":
return "secondary";
case "DROP_TABLE":
return "destructive";
case "DROP_COLUMN":
return "outline";
default:
return "outline";
}
};
/**
*
*/
const getSuccessRate = (stats: DDLStatistics) => {
if (stats.totalExecutions === 0) return 0;
return Math.round((stats.successfulExecutions / stats.totalExecutions) * 100);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
DDL
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="logs" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="logs"> </TabsTrigger>
<TabsTrigger value="statistics"></TabsTrigger>
</TabsList>
{/* 실행 로그 탭 */}
<TabsContent value="logs" className="space-y-4">
{/* 필터 및 컨트롤 */}
<div className="bg-muted/50 flex flex-wrap items-center gap-2 rounded-lg p-4">
<div className="flex items-center gap-2">
<User className="h-4 w-4" />
<Input
placeholder="사용자 ID"
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
className="w-32"
/>
</div>
<Select value={ddlTypeFilter} onValueChange={setDdlTypeFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="DDL 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="CREATE_TABLE"> </SelectItem>
<SelectItem value="ADD_COLUMN"> </SelectItem>
<SelectItem value="DROP_TABLE"> </SelectItem>
<SelectItem value="DROP_COLUMN"> </SelectItem>
</SelectContent>
</Select>
<Select value={limit.toString()} onValueChange={(value) => setLimit(parseInt(value))}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
<Button onClick={applyFilters} size="sm">
<Search className="mr-1 h-4 w-4" />
</Button>
<Button onClick={resetFilters} variant="outline" size="sm">
</Button>
<div className="flex-1" />
<Button onClick={() => loadData(false)} disabled={refreshing} variant="outline" size="sm">
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<Button onClick={cleanupLogs} variant="outline" size="sm">
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 로그 테이블 */}
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="mr-2 h-6 w-6 animate-spin" />
...
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead>DDL </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
.
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id}>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<Clock className="h-3 w-3" />
{format(new Date(log.executed_at), "yyyy-MM-dd HH:mm:ss", { locale: ko })}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{log.user_id}</Badge>
</TableCell>
<TableCell>
<Badge variant={getDDLTypeBadgeVariant(log.ddl_type)}>{log.ddl_type}</Badge>
</TableCell>
<TableCell>
<code className="bg-muted rounded px-2 py-1 text-sm">{log.table_name}</code>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{log.success ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className={log.success ? "text-green-600" : "text-red-600"}>
{log.success ? "성공" : "실패"}
</span>
</div>
{log.error_message && (
<div className="mt-1 max-w-xs truncate text-xs text-red-600">{log.error_message}</div>
)}
</TableCell>
<TableCell>
<code className="text-muted-foreground block max-w-xs truncate text-xs">
{log.ddl_query_preview}
</code>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* 통계 탭 */}
<TabsContent value="statistics" className="space-y-4">
{statistics && (
<div className="grid gap-4">
{/* 전체 통계 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{statistics.totalExecutions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{statistics.successfulExecutions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{statistics.failedExecutions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{getSuccessRate(statistics)}%</div>
</CardContent>
</Card>
</div>
{/* DDL 타입별 통계 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">DDL </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{Object.entries(statistics.byDDLType).map(([type, count]) => (
<div key={type} className="flex items-center justify-between">
<Badge variant={getDDLTypeBadgeVariant(type)}>{type}</Badge>
<span className="font-medium">{count}</span>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{Object.entries(statistics.byUser).map(([user, count]) => (
<div key={user} className="flex items-center justify-between">
<Badge variant="outline">{user}</Badge>
<span className="font-medium">{count}</span>
</div>
))}
</CardContent>
</Card>
</div>
{/* 최근 실패 로그 */}
{statistics.recentFailures.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base text-red-600"> </CardTitle>
<CardDescription> DDL .</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{statistics.recentFailures.map((failure, index) => (
<div key={index} className="rounded-lg border border-red-200 bg-red-50 p-3">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
<code className="text-sm">{failure.table_name}</code>
</div>
<span className="text-muted-foreground text-xs">
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
</span>
</div>
<div className="text-sm text-red-600">{failure.error_message}</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@ -221,7 +221,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
return;
}
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
const result = await ExternalDbConnectionAPI.testConnection(connection.id, formData.password);
setTestResult(result);
if (result.success) {

View File

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

View File

@ -365,7 +365,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
return (
<div className="flex h-screen flex-col bg-slate-50">
<div className="flex h-screen flex-col bg-white">
{/* MainHeader 컴포넌트 사용 */}
<MainHeader
user={user}
@ -428,7 +428,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</aside>
{/* 가운데 컨텐츠 영역 */}
<main className="bg-background flex-1 p-6">{children}</main>
<main className="flex-1 bg-white">{children}</main>
</div>
{/* 프로필 수정 모달 */}

View File

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

View File

@ -41,11 +41,12 @@ import {
import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCurrentUser, UserInfo } from "@/lib/api/client";
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
import { cn } from "@/lib/utils";
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
import { toast } from "sonner";
import { FileUpload } from "@/components/screen/widgets/FileUpload";
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
interface FileInfo {
@ -332,7 +333,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 검색 가능한 컬럼만 필터링
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
const searchFilters = component.filters || [];
// 컬럼의 실제 웹 타입 정보 찾기
const getColumnWebType = useCallback(
@ -525,6 +525,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
}, [component.tableName]);
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
const searchFilters = useMemo(() => {
return component.filters || [];
}, [component.filters]);
// 초기 데이터 로드
useEffect(() => {
loadData(1, searchValues);
@ -1480,115 +1485,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
};
// 검색 필터 렌더링
const renderSearchFilter = (filter: DataTableFilter) => {
const value = searchValues[filter.columnName] || "";
switch (filter.widgetType) {
case "text":
case "email":
case "tel":
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
case "number":
case "decimal":
return (
<Input
key={filter.columnName}
type="number"
placeholder={`${filter.label} 입력...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
case "date":
return (
<Input
key={filter.columnName}
type="date"
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
/>
);
case "datetime":
return (
<Input
key={filter.columnName}
type="datetime-local"
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
/>
);
case "select":
// TODO: 선택 옵션은 추후 구현
return (
<Select
key={filter.columnName}
value={value}
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
>
<SelectTrigger>
<SelectValue placeholder={`${filter.label} 선택...`} />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{/* TODO: 동적 옵션 로드 */}
</SelectContent>
</Select>
);
case "code":
// 코드 타입은 텍스트 검색으로 처리 (코드명으로 검색 가능)
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색... (코드명 또는 코드값)`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
default:
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
/>
);
}
};
// 기존 renderSearchFilter 함수는 AdvancedSearchFilters 컴포넌트로 대체됨
// 파일 다운로드
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
@ -1847,31 +1744,22 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div>
</div>
{/* 검색 필터 */}
{searchFilters.length > 0 && (
{/* 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
{tableColumns && tableColumns.length > 0 && (
<>
<Separator className="my-2" />
<div className="space-y-3">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Search className="h-3 w-3" />
</div>
<div
className="grid gap-3"
style={{
gridTemplateColumns: searchFilters
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
.join(" "),
}}
>
{searchFilters.map((filter: DataTableFilter) => (
<div key={filter.columnName} className="space-y-1">
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
{renderSearchFilter(filter)}
</div>
))}
</div>
</div>
<AdvancedSearchFilters
filters={searchFilters.length > 0 ? searchFilters : []}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleSearch}
onClearFilters={() => {
setSearchValues({});
loadData(1, {});
}}
tableColumns={tableColumns}
tableName={component.tableName}
/>
</>
)}
</div>

View File

@ -31,13 +31,17 @@ import {
CodeTypeConfig,
EntityTypeConfig,
ButtonTypeConfig,
} from "@/types/screen";
} from "@/types";
import { InteractiveDataTable } from "./InteractiveDataTable";
import { FileUpload } from "./widgets/FileUpload";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen";
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
import { enhancedFormService } from "@/lib/services/enhancedFormService";
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
import { useFormValidation } from "@/hooks/useFormValidation";
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
interface InteractiveScreenViewerProps {
component: ComponentData;
@ -49,6 +53,16 @@ interface InteractiveScreenViewerProps {
id: number;
tableName?: string;
};
// 새로운 검증 관련 옵션들
enableEnhancedValidation?: boolean;
tableColumns?: ColumnInfo[];
showValidationPanel?: boolean;
validationOptions?: {
enableRealTimeValidation?: boolean;
validationDelay?: number;
enableAutoSave?: boolean;
showToastMessages?: boolean;
};
}
export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = ({
@ -58,6 +72,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
onFormDataChange,
hideLabel = false,
screenInfo,
enableEnhancedValidation = false,
tableColumns = [],
showValidationPanel = false,
validationOptions = {},
}) => {
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
@ -79,6 +97,33 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 통합된 폼 데이터
const finalFormData = { ...localFormData, ...externalFormData };
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
? useFormValidation(
finalFormData,
allComponents.filter(c => c.type === 'widget') as WidgetComponent[],
tableColumns,
{
id: screenInfo.id,
screenName: screenInfo.tableName || "unknown",
tableName: screenInfo.tableName,
screenResolution: { width: 800, height: 600 },
gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 },
description: "동적 화면"
},
{
enableRealTimeValidation: true,
validationDelay: 300,
enableAutoSave: false,
showToastMessages: true,
...validationOptions,
}
)
: null;
// 자동값 생성 함수
const generateAutoValue = useCallback((autoValueType: string): string => {
const now = new Date();
@ -1104,20 +1149,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
};
// 저장 액션
// 저장 액션 (개선된 버전)
const handleSaveAction = async () => {
// 저장 시점에서 최신 formData 구성
console.log("💾 저장 시작");
// 개선된 검증 시스템이 활성화된 경우
if (enhancedValidation) {
console.log("🔍 개선된 검증 시스템 사용");
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
return;
}
// 기존 방식 (레거시 지원)
const currentFormData = { ...localFormData, ...externalFormData };
console.log("💾 저장 시작 - currentFormData:", currentFormData);
console.log("💾 저장 시점 formData 상세:", {
local: localFormData,
external: externalFormData,
merged: currentFormData
});
console.log("💾 currentFormData 키-값 상세:");
Object.entries(currentFormData).forEach(([key, value]) => {
console.log(` ${key}: "${value}" (타입: ${typeof value})`);
});
console.log("💾 기존 방식으로 저장 - currentFormData:", currentFormData);
// formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행)
const hasWidgets = allComponents.some(comp => comp.type === 'widget');
@ -1684,6 +1732,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">
<FormValidationIndicator
validationState={enhancedValidation.validationState}
saveState={enhancedValidation.saveState}
onSave={async () => {
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
}}
canSave={enhancedValidation.canSave}
compact={true}
showDetails={false}
/>
</div>
)}
{/* 모달 화면 */}
<Dialog open={!!popupScreen} onOpenChange={() => {
setPopupScreen(null);

View File

@ -467,8 +467,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
<>
<div className="absolute" style={componentStyle}>
<div className="h-full w-full">
{/* 라벨 표시 */}
{!hideLabel && component.label && (
{/* 라벨 표시 - 컴포넌트 내부에서 라벨을 처리하므로 외부에서는 표시하지 않음 */}
{!hideLabel && component.label && component.style?.labelDisplay === false && (
<div className="mb-1">
<label className="text-sm font-medium text-gray-700">
{component.label}

View File

@ -1,7 +1,7 @@
"use client";
import React from "react";
import { ComponentData, WebType, WidgetComponent, FileComponent, AreaComponent, AreaLayoutType } from "@/types/screen";
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -56,20 +56,12 @@ interface RealtimePreviewProps {
}
// 영역 레이아웃에 따른 아이콘 반환
const getAreaIcon = (layoutType: AreaLayoutType) => {
switch (layoutType) {
case "flex-row":
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
switch (layoutDirection) {
case "horizontal":
return <Layout className="h-4 w-4 text-blue-600" />;
case "grid":
return <Grid3x3 className="h-4 w-4 text-green-600" />;
case "flex-column":
case "vertical":
return <Columns className="h-4 w-4 text-purple-600" />;
case "panel":
return <Rows className="h-4 w-4 text-orange-600" />;
case "sidebar":
return <SidebarOpen className="h-4 w-4 text-indigo-600" />;
case "tabs":
return <Folder className="h-4 w-4 text-pink-600" />;
default:
return <Square className="h-4 w-4 text-gray-500" />;
}
@ -77,14 +69,17 @@ const getAreaIcon = (layoutType: AreaLayoutType) => {
// 영역 렌더링
const renderArea = (component: ComponentData, children?: React.ReactNode) => {
const area = component as AreaComponent;
const { layoutType, title } = area;
if (!isContainerComponent(component) || component.type !== "area") {
return null;
}
const area = component;
const { layoutDirection, label } = area;
const renderPlaceholder = () => (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<div className="text-center">
{getAreaIcon(layoutType)}
<p className="mt-2 text-sm text-gray-600">{title || `${layoutType} 영역`}</p>
{getAreaIcon(layoutDirection)}
<p className="mt-2 text-sm text-gray-600">{label || `${layoutDirection || "기본"} 영역`}</p>
<p className="text-xs text-gray-400"> </p>
</div>
</div>
@ -102,11 +97,11 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
// 동적 웹 타입 위젯 렌더링 컴포넌트
const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) => {
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
if (component.type !== "widget") {
if (!isWidgetComponent(component)) {
return <div className="text-xs text-gray-500"> </div>;
}
const widget = component as WidgetComponent;
const widget = component;
const { widgetType, label, placeholder, required, readonly, columnName, style } = widget;
// 디버깅: 실제 widgetType 값 확인
@ -180,7 +175,6 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
case "dropdown":
return <List className="h-4 w-4 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
case "boolean":
case "checkbox":
@ -327,8 +321,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
<div className="absolute -top-6 left-0 rounded bg-blue-600 px-2 py-1 text-xs text-white">
{type === "widget" && (
<div className="flex items-center gap-1">
{getWidgetIcon((component as WidgetComponent).widgetType)}
{(component as WidgetComponent).widgetType || "widget"}
{getWidgetIcon(isWidgetComponent(component) ? (component.widgetType as WebType) : undefined)}
{isWidgetComponent(component) ? component.widgetType || "widget" : component.type}
</div>
)}
{type !== "widget" && type}

View File

@ -0,0 +1,172 @@
import React, { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Monitor, Maximize2, ZoomIn, ZoomOut } from "lucide-react";
import { useContainerSize } from "@/hooks/useViewportSize";
interface ResponsiveDesignerContainerProps {
children: React.ReactNode;
designWidth: number;
designHeight: number;
screenName?: string;
onScaleChange?: (scale: number) => void;
}
type DesignerViewMode = "fit" | "original" | "custom";
/**
*
*
*/
export const ResponsiveDesignerContainer: React.FC<ResponsiveDesignerContainerProps> = ({
children,
designWidth,
designHeight,
screenName,
onScaleChange,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [viewMode, setViewMode] = useState<DesignerViewMode>("original");
const [customScale, setCustomScale] = useState(1);
const containerSize = useContainerSize(containerRef);
// 스케일 계산
const calculateScale = (): number => {
if (containerSize.width === 0 || containerSize.height === 0) return 1;
switch (viewMode) {
case "fit":
// 컨테이너에 맞춰 비율 유지하며 조정 (여백 허용)
const scaleX = (containerSize.width - 40) / designWidth;
const scaleY = (containerSize.height - 40) / designHeight;
return Math.min(scaleX, scaleY, 2); // 최대 2배까지 허용
case "custom":
return customScale;
case "original":
default:
return 1;
}
};
const scale = calculateScale();
// 스케일 변경 시 콜백 호출
React.useEffect(() => {
onScaleChange?.(scale);
}, [scale, onScaleChange]);
const handleZoomIn = () => {
const newScale = Math.min(customScale * 1.1, 3);
setCustomScale(newScale);
setViewMode("custom");
};
const handleZoomOut = () => {
const newScale = Math.max(customScale * 0.9, 0.1);
setCustomScale(newScale);
setViewMode("custom");
};
const getViewModeInfo = (mode: DesignerViewMode) => {
switch (mode) {
case "fit":
return {
label: "화면 맞춤",
description: "뷰포트에 맞춰 자동 조정",
icon: <Monitor className="h-4 w-4" />,
};
case "original":
return {
label: "원본 크기",
description: "설계 해상도 100% 표시",
icon: <Maximize2 className="h-4 w-4" />,
};
case "custom":
return {
label: `사용자 정의 (${Math.round(customScale * 100)}%)`,
description: "사용자가 조정한 배율",
icon: <ZoomIn className="h-4 w-4" />,
};
}
};
const screenStyle = {
width: `${designWidth}px`,
height: `${designHeight}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
transition: "transform 0.3s ease-in-out",
};
const wrapperStyle = {
width: `${designWidth * scale}px`,
height: `${designHeight * scale}px`,
overflow: "hidden",
};
return (
<div className="flex h-full w-full flex-col bg-gray-100">
{/* 상단 컨트롤 바 */}
<div className="flex items-center justify-between border-b bg-white px-4 py-2 shadow-sm">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-700">
{screenName && `${screenName} - `}
{designWidth} × {designHeight}
</span>
<span className="text-xs text-gray-500">
(: {Math.round(scale * 100)}% | : {containerSize.width}×{containerSize.height})
</span>
</div>
<div className="flex items-center space-x-1">
{/* 줌 컨트롤 */}
<Button variant="ghost" size="sm" onClick={handleZoomOut} className="h-8 w-8 p-0" title="축소">
<ZoomOut className="h-4 w-4" />
</Button>
<span className="min-w-[60px] px-2 text-center text-xs text-gray-600">{Math.round(scale * 100)}%</span>
<Button variant="ghost" size="sm" onClick={handleZoomIn} className="h-8 w-8 p-0" title="확대">
<ZoomIn className="h-4 w-4" />
</Button>
{/* 뷰 모드 버튼 */}
{(["fit", "original"] as DesignerViewMode[]).map((mode) => {
const info = getViewModeInfo(mode);
return (
<Button
key={mode}
variant={viewMode === mode ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode(mode)}
className="h-8 text-xs"
title={info.description}
>
{info.icon}
<span className="ml-1 hidden sm:inline">{info.label}</span>
</Button>
);
})}
</div>
</div>
{/* 디자인 영역 */}
<div
ref={containerRef}
className="flex-1 overflow-auto p-8"
style={{
justifyContent: "center",
alignItems: "flex-start",
display: "flex",
}}
>
<div style={wrapperStyle}>
<div style={screenStyle}>{children}</div>
</div>
</div>
</div>
);
};
export default ResponsiveDesignerContainer;

View File

@ -0,0 +1,42 @@
import React, { useRef } from "react";
interface ResponsiveScreenContainerProps {
children: React.ReactNode;
designWidth: number;
designHeight: number;
screenName?: string;
}
/**
*
* .
*/
export const ResponsiveScreenContainer: React.FC<ResponsiveScreenContainerProps> = ({
children,
designWidth,
designHeight,
screenName,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const screenStyle = {
width: `${designWidth}px`,
height: `${designHeight}px`,
};
const wrapperStyle = {
width: `${designWidth}px`,
height: `${designHeight}px`,
overflow: "auto",
};
return (
<div className="h-full w-full overflow-auto bg-gray-50">
<div style={wrapperStyle}>
<div style={screenStyle}>{children}</div>
</div>
</div>
);
};
export default ResponsiveScreenContainer;

View File

@ -1884,7 +1884,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
codeCategory: column.codeCategory,
}),
style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
labelFontSize: "12px",
labelColor: "#374151",
labelFontWeight: "500",

View File

@ -0,0 +1,427 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Search, X } from "lucide-react";
import { ModernDatePicker } from "./ModernDatePicker";
import { cn } from "@/lib/utils";
import { commonCodeApi } from "@/lib/api/commonCode";
import { EntityReferenceAPI } from "@/lib/api/entityReference";
import type { DataTableFilter } from "@/types/screen-legacy-backup";
import type { CodeInfo } from "@/types/commonCode";
interface AdvancedSearchFiltersProps {
filters: DataTableFilter[];
searchValues: Record<string, any>;
onSearchValueChange: (columnName: string, value: any) => void;
onSearch: () => void;
onClearFilters: () => void;
className?: string;
tableColumns?: any[]; // 테이블 컬럼 정보
tableName?: string; // 테이블명
}
interface DateRangeValue {
from?: Date;
to?: Date;
}
interface CodeOption {
value: string;
label: string;
}
interface EntityOption {
value: string;
label: string;
}
export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
filters,
searchValues,
onSearchValueChange,
onSearch,
onClearFilters,
className = "",
tableColumns = [],
tableName = "",
}) => {
// 코드 옵션 캐시
const [codeOptions, setCodeOptions] = useState<Record<string, CodeOption[]>>({});
// 엔티티 옵션 캐시
const [entityOptions, setEntityOptions] = useState<Record<string, EntityOption[]>>({});
// 로딩 상태
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
// 자동 필터 생성 (설정된 필터가 없을 때 테이블 컬럼 기반으로 생성)
const autoGeneratedFilters = useMemo(() => {
if (filters.length > 0 || !tableColumns || tableColumns.length === 0) {
return [];
}
// 필터 가능한 웹타입들
const filterableWebTypes = ["text", "email", "tel", "number", "decimal", "date", "datetime", "code", "entity"];
return tableColumns
.filter((col) => {
const webType = col.webType || col.web_type;
return filterableWebTypes.includes(webType) && col.isVisible !== false;
})
.slice(0, 6) // 최대 6개까지만 자동 생성
.map((col) => ({
columnName: col.columnName || col.column_name,
widgetType: col.webType || col.web_type,
label: col.displayName || col.column_label || col.columnName || col.column_name,
gridColumns: 3,
codeCategory: col.codeCategory || col.code_category,
referenceTable: col.referenceTable || col.reference_table,
referenceColumn: col.referenceColumn || col.reference_column,
displayColumn: col.displayColumn || col.display_column,
}));
}, [filters, tableColumns]);
// 실제 사용할 필터 (설정된 필터가 있으면 우선, 없으면 자동 생성)
const effectiveFilters = useMemo(() => {
return filters.length > 0 ? filters : autoGeneratedFilters;
}, [filters, autoGeneratedFilters]);
// 코드 데이터 로드
const loadCodeOptions = useCallback(
async (codeCategory: string) => {
if (codeOptions[codeCategory] || loadingStates[codeCategory]) return;
setLoadingStates((prev) => ({ ...prev, [codeCategory]: true }));
try {
const response = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 1000 });
const options = response.options.map((option) => ({
value: option.value,
label: option.label,
}));
setCodeOptions((prev) => ({ ...prev, [codeCategory]: options }));
} catch (error) {
console.error(`코드 카테고리 ${codeCategory} 로드 실패:`, error);
setCodeOptions((prev) => ({ ...prev, [codeCategory]: [] }));
} finally {
setLoadingStates((prev) => ({ ...prev, [codeCategory]: false }));
}
},
[codeOptions, loadingStates],
);
// 엔티티 데이터 로드
const loadEntityOptions = useCallback(
async (tableName: string, columnName: string) => {
const key = `${tableName}.${columnName}`;
if (entityOptions[key] || loadingStates[key]) return;
setLoadingStates((prev) => ({ ...prev, [key]: true }));
try {
const response = await EntityReferenceAPI.getEntityReferenceData(tableName, columnName, { limit: 1000 });
const options = response.options.map((option) => ({
value: option.value,
label: option.label,
}));
setEntityOptions((prev) => ({ ...prev, [key]: options }));
} catch (error) {
console.error(`엔티티 ${tableName}.${columnName} 로드 실패:`, error);
setEntityOptions((prev) => ({ ...prev, [key]: [] }));
} finally {
setLoadingStates((prev) => ({ ...prev, [key]: false }));
}
},
[entityOptions, loadingStates],
);
// 즉시 검색을 위한 onChange 핸들러
const handleChange = useCallback(
(columnName: string, newValue: any) => {
onSearchValueChange(columnName, newValue);
// 즉시 검색 실행 (디바운싱 제거)
onSearch();
},
[onSearchValueChange, onSearch],
);
// 텍스트 입력용 핸들러 (Enter 키 또는 blur 시에만 검색)
const handleTextChange = useCallback(
(columnName: string, newValue: string) => {
onSearchValueChange(columnName, newValue);
// 텍스트는 즉시 검색하지 않고 상태만 업데이트
},
[onSearchValueChange],
);
// Enter 키 또는 blur 시 검색 실행
const handleTextSearch = useCallback(() => {
onSearch();
}, [onSearch]);
// 필터별 렌더링 함수
const renderFilter = (filter: DataTableFilter) => {
const value = searchValues[filter.columnName] || "";
switch (filter.widgetType) {
case "text":
case "email":
case "tel":
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleTextSearch();
}
}}
onBlur={handleTextSearch}
/>
);
case "number":
case "decimal":
// 숫자 필터 모드에 따라 다른 UI 렌더링
if (filter.numberFilterMode === "exact") {
// 정확한 값 검색
return (
<Input
key={filter.columnName}
type="number"
placeholder={`${filter.label} 입력...`}
value={value || ""}
onChange={(e) => handleChange(filter.columnName, e.target.value)}
/>
);
} else {
// 범위 검색 (기본값)
return (
<div key={filter.columnName} className="flex space-x-2">
<Input
type="number"
placeholder={`최소값`}
value={value?.min || ""}
onChange={(e) =>
handleChange(filter.columnName, {
...value,
min: e.target.value,
})
}
/>
<Input
type="number"
placeholder={`최대값`}
value={value?.max || ""}
onChange={(e) =>
handleChange(filter.columnName, {
...value,
max: e.target.value,
})
}
/>
</div>
);
}
case "date":
case "datetime":
return (
<ModernDatePicker
key={filter.columnName}
label={filter.label}
value={value || {}}
onChange={(newValue) => handleChange(filter.columnName, newValue)}
includeTime={filter.widgetType === "datetime"}
/>
);
case "code":
console.log("🔍 코드 필터 렌더링:", {
columnName: filter.columnName,
codeCategory: filter.codeCategory,
options: codeOptions[filter.codeCategory || ""],
loading: loadingStates[filter.codeCategory || ""],
});
return (
<CodeFilter
key={filter.columnName}
filter={filter}
value={value}
onChange={(newValue) => handleChange(filter.columnName, newValue)}
options={codeOptions[filter.codeCategory || ""] || []}
loading={loadingStates[filter.codeCategory || ""]}
onLoadOptions={() => filter.codeCategory && loadCodeOptions(filter.codeCategory)}
/>
);
case "entity":
return (
<EntityFilter
key={filter.columnName}
filter={filter}
value={value}
onChange={(newValue) => handleChange(filter.columnName, newValue)}
options={entityOptions[`${filter.referenceTable}.${filter.columnName}`] || []}
loading={loadingStates[`${filter.referenceTable}.${filter.columnName}`]}
onLoadOptions={() => filter.referenceTable && loadEntityOptions(filter.referenceTable, filter.columnName)}
/>
);
case "select":
case "dropdown":
return (
<Select
key={filter.columnName}
value={value}
onValueChange={(newValue) => onSearchValueChange(filter.columnName, newValue)}
>
<SelectTrigger>
<SelectValue placeholder={`${filter.label} 선택...`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ALL__"></SelectItem>
{/* TODO: 동적 옵션 로드 */}
</SelectContent>
</Select>
);
default:
return (
<Input
key={filter.columnName}
placeholder={`${filter.label} 검색...`}
value={value}
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleTextSearch();
}
}}
onBlur={handleTextSearch}
/>
);
}
};
// 활성 필터 개수 계산
const activeFiltersCount = Object.values(searchValues).filter((value) => {
if (typeof value === "string") return value.trim() !== "";
if (typeof value === "object" && value !== null) {
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
}
return value !== null && value !== undefined && value !== "";
}).length;
return (
<div className={cn("space-y-2", className)}>
{/* 필터 헤더 */}
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Search className="h-3 w-3" />
{autoGeneratedFilters.length > 0 && <span className="text-xs text-blue-600">( )</span>}
</div>
{/* 필터 그리드 - 적절한 너비로 조정 */}
{effectiveFilters.length > 0 && (
<div className="flex flex-wrap gap-3">
{effectiveFilters.map((filter: DataTableFilter) => {
// 필터 개수에 따라 적절한 너비 계산
const getFilterWidth = () => {
const filterCount = effectiveFilters.length;
if (filterCount === 1) return "w-80"; // 1개일 때는 적당한 크기
if (filterCount === 2) return "w-64"; // 2개일 때는 조금 작게
if (filterCount === 3) return "w-52"; // 3개일 때는 더 작게
return "w-48"; // 4개 이상일 때는 가장 작게
};
return (
<div key={filter.columnName} className={`space-y-0.5 ${getFilterWidth()}`}>
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
{renderFilter(filter)}
</div>
);
})}
</div>
)}
{/* 필터 상태 및 초기화 버튼 */}
{activeFiltersCount > 0 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">{activeFiltersCount} </div>
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2">
<X className="h-3 w-3" />
</Button>
</div>
)}
</div>
);
};
// 코드 필터 컴포넌트
const CodeFilter: React.FC<{
filter: DataTableFilter;
value: string;
onChange: (value: string) => void;
options: CodeOption[];
loading: boolean;
onLoadOptions: () => void;
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
useEffect(() => {
if (filter.codeCategory && options.length === 0 && !loading) {
onLoadOptions();
}
}, [filter.codeCategory, options.length, loading, onLoadOptions]);
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ALL__"></SelectItem>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
// 엔티티 필터 컴포넌트
const EntityFilter: React.FC<{
filter: DataTableFilter;
value: string;
onChange: (value: string) => void;
options: EntityOption[];
loading: boolean;
onLoadOptions: () => void;
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
useEffect(() => {
if (filter.referenceTable && options.length === 0 && !loading) {
onLoadOptions();
}
}, [filter.referenceTable, options.length, loading, onLoadOptions]);
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__ALL__"></SelectItem>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@ -0,0 +1,215 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
} from "date-fns";
import { ko } from "date-fns/locale";
import { cn } from "@/lib/utils";
interface DateRangeValue {
from?: Date;
to?: Date;
}
interface ModernDatePickerProps {
label: string;
value: DateRangeValue;
onChange: (value: DateRangeValue) => void;
includeTime?: boolean;
}
export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value, onChange, includeTime = false }) => {
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
const formatDate = (date: Date | undefined) => {
if (!date) return "";
if (includeTime) {
return format(date, "yyyy-MM-dd HH:mm", { locale: ko });
}
return format(date, "yyyy-MM-dd", { locale: ko });
};
const displayValue = () => {
if (value?.from && value?.to) {
return `${formatDate(value.from)} ~ ${formatDate(value.to)}`;
}
if (value?.from) {
return `${formatDate(value.from)} ~`;
}
if (value?.to) {
return `~ ${formatDate(value.to)}`;
}
return "";
};
const handleDateClick = (date: Date) => {
if (selectingType === "from") {
const newValue = { ...value, from: date };
onChange(newValue);
setSelectingType("to");
} else {
const newValue = { ...value, to: date };
onChange(newValue);
setSelectingType("from");
}
};
const handleClear = () => {
onChange({});
setSelectingType("from");
};
const handleConfirm = () => {
setIsOpen(false);
setSelectingType("from");
// 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
};
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
// 달력 시작을 월요일로 맞추기 위해 앞의 빈 칸들 계산
const startDate = new Date(monthStart);
const dayOfWeek = startDate.getDay();
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 일요일(0)이면 6개, 나머지는 -1
const allDays = [...Array(paddingDays).fill(null), ...days];
const isInRange = (date: Date) => {
if (!value.from || !value.to) return false;
return date >= value.from && date <= value.to;
};
const isRangeStart = (date: Date) => {
return value.from && isSameDay(date, value.from);
};
const isRangeEnd = (date: Date) => {
return value.to && isSameDay(date, value.to);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!value?.from && !value?.to && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{displayValue() || `${label} 기간 선택...`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium"> </h3>
<div className="text-muted-foreground text-xs">
{selectingType === "from" ? "시작일을 선택하세요" : "종료일을 선택하세요"}
</div>
</div>
{/* 월 네비게이션 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 요일 헤더 */}
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) {
return <div key={index} className="p-2" />;
}
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = isRangeStart(date) || isRangeEnd(date);
const isInRangeDate = isInRange(date);
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isInRangeDate && !isSelected && "bg-muted",
isTodayDate && !isSelected && "border-primary border",
selectingType === "from" && "hover:bg-primary/20",
selectingType === "to" && "hover:bg-secondary/20",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
</Button>
);
})}
</div>
{/* 선택된 범위 표시 */}
{(value.from || value.to) && (
<div className="bg-muted mb-4 rounded-md p-2">
<div className="text-muted-foreground mb-1 text-xs"> </div>
<div className="text-sm">
{value.from && <span className="font-medium">: {formatDate(value.from)}</span>}
{value.from && value.to && <span className="mx-2">~</span>}
{value.to && <span className="font-medium">: {formatDate(value.to)}</span>}
</div>
</div>
)}
{/* 액션 버튼 */}
<div className="flex justify-between">
<Button variant="outline" size="sm" onClick={handleClear}>
</Button>
<div className="space-x-2">
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
</Button>
<Button size="sm" onClick={handleConfirm}>
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -320,18 +320,8 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
columnsCount: table.columns.length,
});
// 테이블의 모든 컬럼을 기본 설정으로 추가
const defaultColumns: DataTableColumn[] = table.columns.map((col, index) => ({
id: generateComponentId(),
columnName: col.columnName,
label: col.columnLabel || col.columnName,
widgetType: getWidgetTypeFromColumn(col),
gridColumns: 2, // 기본 2칸
visible: index < 6, // 처음 6개만 기본으로 표시
filterable: isFilterableWebType(getWidgetTypeFromColumn(col)),
sortable: true,
searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)),
}));
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
const defaultColumns: DataTableColumn[] = [];
console.log("✅ 생성된 컬럼 설정:", {
defaultColumnsCount: defaultColumns.length,
@ -351,71 +341,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
[tables, onUpdateComponent, localValues.tableName],
);
// 컬럼 타입 추론
// 컬럼 타입 추론 (통합 매핑 시스템 사용)
const getWidgetTypeFromColumn = (column: ColumnInfo): WebType => {
const type = column.dataType?.toLowerCase() || "";
const name = column.columnName.toLowerCase();
// 통합 자동 매핑 유틸리티 사용
const { inferWebTypeFromColumn } = require("@/lib/utils/dbTypeMapping");
console.log("🔍 웹타입 추론:", {
columnName: column.columnName,
dataType: column.dataType,
type,
name,
});
// 숫자 타입
if (type.includes("int") || type.includes("integer") || type.includes("bigint") || type.includes("smallint")) {
return "number";
}
if (
type.includes("decimal") ||
type.includes("numeric") ||
type.includes("float") ||
type.includes("double") ||
type.includes("real")
) {
return "decimal";
}
// 날짜/시간 타입
if (type.includes("timestamp") || type.includes("datetime")) {
return "datetime";
}
if (type.includes("date")) {
return "date";
}
if (type.includes("time")) {
return "datetime";
}
// 불린 타입
if (type.includes("bool") || type.includes("boolean")) {
return "checkbox";
}
// 컬럼명 기반 추론
if (name.includes("email") || name.includes("mail")) return "email";
if (name.includes("phone") || name.includes("tel") || name.includes("mobile")) return "tel";
if (name.includes("url") || name.includes("link")) return "text";
if (name.includes("password") || name.includes("pwd")) return "text";
// 파일 타입 추론
if (
name.includes("file") ||
name.includes("attach") ||
name.includes("upload") ||
name.includes("document") ||
name.includes("docs") ||
name.includes("image") ||
name.includes("photo") ||
name.includes("picture") ||
name.includes("media")
) {
return "file";
}
// 텍스트 타입 (기본값)
return "text";
return inferWebTypeFromColumn(column.dataType || "text", column.columnName);
};
// 컬럼 업데이트
@ -844,6 +775,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
widgetType,
label: targetColumn.columnLabel || targetColumn.columnName,
gridColumns: 3,
// 웹타입별 추가 정보 설정
codeCategory: targetColumn.codeCategory,
referenceTable: targetColumn.referenceTable,
referenceColumn: targetColumn.referenceColumn,
displayColumn: targetColumn.displayColumn,
};
console.log(" 필터 추가 시작:", {

View File

@ -121,25 +121,9 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
className = "",
isPreview = true,
}) => {
// 미리보기용 기본 컬럼 데이터
// 설정된 컬럼만 사용 (자동 생성 안함)
const defaultColumns = React.useMemo(() => {
if (columns.length > 0) return columns;
return [
{ id: "id", label: "ID", type: "number", visible: true, sortable: true, filterable: false, width: 80 },
{ id: "name", label: "이름", type: "text", visible: true, sortable: true, filterable: true, width: 150 },
{ id: "email", label: "이메일", type: "email", visible: true, sortable: true, filterable: true, width: 200 },
{ id: "status", label: "상태", type: "select", visible: true, sortable: true, filterable: true, width: 100 },
{
id: "created_date",
label: "생성일",
type: "date",
visible: true,
sortable: true,
filterable: true,
width: 120,
},
];
return columns || [];
}, [columns]);
// 미리보기용 샘플 데이터

View File

@ -1,31 +1,25 @@
"use client"
"use client";
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("bg-secondary relative h-4 w-full overflow-hidden rounded-full", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress }
export { Progress };

View File

@ -48,6 +48,20 @@ export const TABLE_MANAGEMENT_KEYS = {
WEB_TYPE_RADIO_DESC: "table.management.web.type.radio.description",
WEB_TYPE_FILE: "table.management.web.type.file",
WEB_TYPE_FILE_DESC: "table.management.web.type.file.description",
WEB_TYPE_DECIMAL: "table.management.web.type.decimal",
WEB_TYPE_DECIMAL_DESC: "table.management.web.type.decimal.description",
WEB_TYPE_DATETIME: "table.management.web.type.datetime",
WEB_TYPE_DATETIME_DESC: "table.management.web.type.datetime.description",
WEB_TYPE_BOOLEAN: "table.management.web.type.boolean",
WEB_TYPE_BOOLEAN_DESC: "table.management.web.type.boolean.description",
WEB_TYPE_EMAIL: "table.management.web.type.email",
WEB_TYPE_EMAIL_DESC: "table.management.web.type.email.description",
WEB_TYPE_TEL: "table.management.web.type.tel",
WEB_TYPE_TEL_DESC: "table.management.web.type.tel.description",
WEB_TYPE_URL: "table.management.web.type.url",
WEB_TYPE_URL_DESC: "table.management.web.type.url.description",
WEB_TYPE_DROPDOWN: "table.management.web.type.dropdown",
WEB_TYPE_DROPDOWN_DESC: "table.management.web.type.dropdown.description",
// 공통 UI 요소
BUTTON_REFRESH: "table.management.button.refresh",
@ -135,4 +149,39 @@ export const WEB_TYPE_OPTIONS_WITH_KEYS = [
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_FILE_DESC,
},
{
value: "decimal",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DECIMAL,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DECIMAL_DESC,
},
{
value: "datetime",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DATETIME,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DATETIME_DESC,
},
{
value: "boolean",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_BOOLEAN,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_BOOLEAN_DESC,
},
{
value: "email",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_EMAIL,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_EMAIL_DESC,
},
{
value: "tel",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEL,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_TEL_DESC,
},
{
value: "url",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_URL,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_URL_DESC,
},
{
value: "dropdown",
labelKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DROPDOWN,
descriptionKey: TABLE_MANAGEMENT_KEYS.WEB_TYPE_DROPDOWN_DESC,
},
] as const;

View File

@ -1,89 +1,472 @@
import { useState } from "react";
/**
*
*
*/
export interface ValidationState {
enabled: boolean;
value: string;
import { useState, useCallback, useEffect, useRef } from "react";
import { ComponentData, ColumnInfo, ScreenDefinition } from "@/types/screen";
import { validateFormData, ValidationResult, ValidationError, ValidationWarning } from "@/lib/utils/formValidation";
import { enhancedFormService, SaveContext, EnhancedSaveResult } from "@/lib/services/enhancedFormService";
import { useToast } from "@/hooks/use-toast";
// 검증 상태
export type ValidationStatus = "idle" | "validating" | "valid" | "invalid";
// 필드별 검증 상태
export interface FieldValidationState {
status: ValidationStatus;
error?: ValidationError;
warning?: ValidationWarning;
lastValidated?: Date;
}
export interface ValidationStates {
[fieldName: string]: ValidationState;
// 폼 검증 상태
export interface FormValidationState {
status: ValidationStatus;
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
fieldStates: Record<string, FieldValidationState>;
lastValidated?: Date;
validationCount: number;
}
export interface UseFormValidationProps {
fields: string[];
initialStates?: Partial<ValidationStates>;
// 저장 상태
export interface SaveState {
status: "idle" | "saving" | "success" | "error";
message?: string;
result?: EnhancedSaveResult;
lastSaved?: Date;
}
export function useFormValidation({ fields, initialStates = {} }: UseFormValidationProps) {
// 검증 상태 초기화
const initValidationStates = (): ValidationStates => {
const states: ValidationStates = {};
fields.forEach((field) => {
states[field] = initialStates[field] || { enabled: false, value: "" };
});
return states;
};
// 훅 옵션
export interface UseFormValidationOptions {
enableRealTimeValidation?: boolean;
validationDelay?: number; // debounce 지연시간 (ms)
enableAutoSave?: boolean;
autoSaveDelay?: number; // 자동저장 지연시간 (ms)
showToastMessages?: boolean;
validateOnMount?: boolean;
}
const [validationStates, setValidationStates] = useState<ValidationStates>(initValidationStates);
// 훅 반환값
export interface UseFormValidationReturn {
// 상태
validationState: FormValidationState;
saveState: SaveState;
// 특정 필드의 검증 상태 업데이트
const updateFieldValidation = (fieldName: string, value: string) => {
setValidationStates((prev) => ({
// 액션
validateForm: () => Promise<ValidationResult>;
validateField: (fieldName: string, value: any) => Promise<void>;
saveForm: () => Promise<boolean>;
clearValidation: () => void;
// 유틸리티
getFieldError: (fieldName: string) => ValidationError | undefined;
getFieldWarning: (fieldName: string) => ValidationWarning | undefined;
hasFieldError: (fieldName: string) => boolean;
isFieldValid: (fieldName: string) => boolean;
canSave: boolean;
}
/**
*
*/
export const useFormValidation = (
formData: Record<string, any>,
components: ComponentData[],
tableColumns: ColumnInfo[],
screenInfo: ScreenDefinition,
options: UseFormValidationOptions = {},
): UseFormValidationReturn => {
const {
enableRealTimeValidation = true,
validationDelay = 500,
enableAutoSave = false,
autoSaveDelay = 2000,
showToastMessages = true,
validateOnMount = false,
} = options;
const { toast } = useToast();
// 상태
const [validationState, setValidationState] = useState<FormValidationState>({
status: "idle",
isValid: false,
errors: [],
warnings: [],
fieldStates: {},
validationCount: 0,
});
const [saveState, setSaveState] = useState<SaveState>({
status: "idle",
});
// 타이머 참조
const validationTimer = useRef<NodeJS.Timeout>();
const autoSaveTimer = useRef<NodeJS.Timeout>();
const lastValidationData = useRef<string>("");
/**
*
*/
const validateForm = useCallback(async (): Promise<ValidationResult> => {
if (!screenInfo?.tableName) {
return {
isValid: false,
errors: [
{
field: "form",
code: "NO_TABLE",
message: "테이블명이 설정되지 않았습니다.",
severity: "error",
},
],
warnings: [],
};
}
setValidationState((prev) => ({
...prev,
[fieldName]: { enabled: true, value: value.trim() },
status: "validating",
}));
};
// onBlur 핸들러 생성
const createBlurHandler =
(fieldName: string) => (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = event.target.value.trim();
if (value) {
updateFieldValidation(fieldName, value);
try {
const result = await validateFormData(formData, components, tableColumns, screenInfo.tableName);
// 필드별 상태 업데이트
const fieldStates: Record<string, FieldValidationState> = {};
// 기존 필드 상태 초기화
Object.keys(formData).forEach((fieldName) => {
fieldStates[fieldName] = {
status: "valid",
lastValidated: new Date(),
};
});
// 오류가 있는 필드 업데이트
result.errors.forEach((error) => {
fieldStates[error.field] = {
status: "invalid",
error,
lastValidated: new Date(),
};
});
// 경고가 있는 필드 업데이트
result.warnings.forEach((warning) => {
if (fieldStates[warning.field]) {
fieldStates[warning.field].warning = warning;
} else {
fieldStates[warning.field] = {
status: "valid",
warning,
lastValidated: new Date(),
};
}
});
setValidationState((prev) => ({
status: result.isValid ? "valid" : "invalid",
isValid: result.isValid,
errors: result.errors,
warnings: result.warnings,
fieldStates,
lastValidated: new Date(),
validationCount: prev.validationCount + 1,
}));
if (showToastMessages) {
if (result.isValid && result.warnings.length > 0) {
toast({
title: "검증 완료",
description: `${result.warnings.length}개의 경고가 있습니다.`,
variant: "default",
});
} else if (!result.isValid) {
toast({
title: "검증 실패",
description: `${result.errors.length}개의 오류를 수정해주세요.`,
variant: "destructive",
});
}
}
return result;
} catch (error) {
console.error("❌ 폼 검증 중 오류:", error);
const errorResult: ValidationResult = {
isValid: false,
errors: [
{
field: "form",
code: "VALIDATION_ERROR",
message: `검증 중 오류가 발생했습니다: ${error}`,
severity: "error",
},
],
warnings: [],
};
setValidationState((prev) => ({
...prev,
status: "invalid",
isValid: false,
errors: errorResult.errors,
warnings: [],
lastValidated: new Date(),
validationCount: prev.validationCount + 1,
}));
return errorResult;
}
}, [formData, components, tableColumns, screenInfo, showToastMessages, toast]);
/**
*
*/
const validateField = useCallback(
async (fieldName: string, value: any): Promise<void> => {
const component = components.find((c) => (c as any).columnName === fieldName || c.id === fieldName);
if (!component || component.type !== "widget") return;
setValidationState((prev) => ({
...prev,
fieldStates: {
...prev.fieldStates,
[fieldName]: {
...prev.fieldStates[fieldName],
status: "validating",
},
},
}));
// 개별 필드 검증 로직
// (실제 구현에서는 validateFieldValue 함수 사용)
setValidationState((prev) => ({
...prev,
fieldStates: {
...prev.fieldStates,
[fieldName]: {
status: "valid",
lastValidated: new Date(),
},
},
}));
},
[components],
);
/**
*
*/
const saveForm = useCallback(async (): Promise<boolean> => {
if (!validationState.isValid) {
if (showToastMessages) {
toast({
title: "저장 실패",
description: "검증 오류를 먼저 수정해주세요.",
variant: "destructive",
});
}
return false;
}
setSaveState({ status: "saving" });
try {
const saveContext: SaveContext = {
tableName: screenInfo.tableName,
screenInfo,
components,
formData,
options: {
transformData: true,
showProgress: true,
},
};
const result = await enhancedFormService.saveFormData(saveContext);
setSaveState({
status: result.success ? "success" : "error",
message: result.message,
result,
lastSaved: new Date(),
});
if (showToastMessages) {
toast({
title: result.success ? "저장 성공" : "저장 실패",
description: result.message,
variant: result.success ? "default" : "destructive",
});
}
return result.success;
} catch (error) {
console.error("❌ 폼 저장 중 오류:", error);
setSaveState({
status: "error",
message: `저장 중 오류가 발생했습니다: ${error}`,
lastSaved: new Date(),
});
if (showToastMessages) {
toast({
title: "저장 실패",
description: "저장 중 오류가 발생했습니다.",
variant: "destructive",
});
}
return false;
}
}, [validationState.isValid, screenInfo, components, formData, showToastMessages, toast]);
/**
*
*/
const clearValidation = useCallback(() => {
setValidationState({
status: "idle",
isValid: false,
errors: [],
warnings: [],
fieldStates: {},
validationCount: 0,
});
setSaveState({ status: "idle" });
}, []);
/**
*
*/
const getFieldError = useCallback(
(fieldName: string): ValidationError | undefined => {
return validationState.fieldStates[fieldName]?.error;
},
[validationState.fieldStates],
);
/**
*
*/
const getFieldWarning = useCallback(
(fieldName: string): ValidationWarning | undefined => {
return validationState.fieldStates[fieldName]?.warning;
},
[validationState.fieldStates],
);
/**
*
*/
const hasFieldError = useCallback(
(fieldName: string): boolean => {
return validationState.fieldStates[fieldName]?.status === "invalid";
},
[validationState.fieldStates],
);
/**
*
*/
const isFieldValid = useCallback(
(fieldName: string): boolean => {
const fieldState = validationState.fieldStates[fieldName];
return fieldState?.status === "valid" || !fieldState;
},
[validationState.fieldStates],
);
// 저장 가능 여부
const canSave = validationState.isValid && saveState.status !== "saving" && Object.keys(formData).length > 0;
// 실시간 검증 (debounced)
useEffect(() => {
if (!enableRealTimeValidation) return;
const currentDataString = JSON.stringify(formData);
if (currentDataString === lastValidationData.current) return;
// 이전 타이머 클리어
if (validationTimer.current) {
clearTimeout(validationTimer.current);
}
// 새 타이머 설정
validationTimer.current = setTimeout(() => {
lastValidationData.current = currentDataString;
validateForm();
}, validationDelay);
return () => {
if (validationTimer.current) {
clearTimeout(validationTimer.current);
}
};
}, [formData, enableRealTimeValidation, validationDelay, validateForm]);
// 모든 필수 필드가 검증되었는지 확인
const areAllFieldsValidated = (requiredFields?: string[]) => {
const fieldsToCheck = requiredFields || fields;
return fieldsToCheck.every((field) => validationStates[field]?.enabled);
};
// 자동 저장
useEffect(() => {
if (!enableAutoSave || !validationState.isValid) return;
// 검증 상태 초기화
const resetValidation = (newStates?: Partial<ValidationStates>) => {
if (newStates) {
setValidationStates((prev) => {
const updated = { ...prev };
Object.entries(newStates).forEach(([key, value]) => {
if (value !== undefined) {
updated[key] = value;
}
});
return updated;
});
} else {
setValidationStates(initValidationStates());
// 이전 타이머 클리어
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current);
}
};
// 특정 필드 검증 상태 확인
const isFieldValidated = (fieldName: string) => validationStates[fieldName]?.enabled || false;
// 새 타이머 설정
autoSaveTimer.current = setTimeout(() => {
saveForm();
}, autoSaveDelay);
// 필드 값 가져오기
const getFieldValue = (fieldName: string) => validationStates[fieldName]?.value || "";
return () => {
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current);
}
};
}, [validationState.isValid, enableAutoSave, autoSaveDelay, saveForm]);
// 마운트 시 검증
useEffect(() => {
if (validateOnMount && Object.keys(formData).length > 0) {
validateForm();
}
}, [validateOnMount]); // formData는 의존성에서 제외 (무한 루프 방지)
// 클린업
useEffect(() => {
return () => {
if (validationTimer.current) {
clearTimeout(validationTimer.current);
}
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current);
}
};
}, []);
return {
// 상태
validationStates,
// 액션
updateFieldValidation,
resetValidation,
// 유틸리티
createBlurHandler,
areAllFieldsValidated,
isFieldValidated,
getFieldValue,
validationState,
saveState,
validateForm,
validateField,
saveForm,
clearValidation,
getFieldError,
getFieldWarning,
hasFieldError,
isFieldValid,
canSave,
};
}
};

View File

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

127
frontend/lib/api/ddl.ts Normal file
View File

@ -0,0 +1,127 @@
/**
* DDL API
*/
import { apiClient } from "./client";
import {
CreateTableRequest,
AddColumnRequest,
DDLExecutionResult,
ValidationResult,
DDLExecutionLog,
DDLStatistics,
} from "../../types/ddl";
export const ddlApi = {
/**
*
*/
createTable: async (request: CreateTableRequest): Promise<DDLExecutionResult> => {
const response = await apiClient.post("/ddl/tables", request);
return response.data;
},
/**
*
*/
addColumn: async (tableName: string, request: AddColumnRequest): Promise<DDLExecutionResult> => {
const response = await apiClient.post(`/ddl/tables/${tableName}/columns`, request);
return response.data;
},
/**
* ( )
*/
validateTableCreation: async (request: CreateTableRequest): Promise<ValidationResult> => {
const response = await apiClient.post("/ddl/validate/table", request);
return response.data.data;
},
/**
* DDL
*/
getDDLLogs: async (params?: {
limit?: number;
userId?: string;
ddlType?: string;
}): Promise<{
logs: DDLExecutionLog[];
total: number;
}> => {
const response = await apiClient.get("/ddl/logs", { params });
return response.data.data;
},
/**
* DDL
*/
getDDLStatistics: async (params?: { fromDate?: string; toDate?: string }): Promise<DDLStatistics> => {
const response = await apiClient.get("/ddl/statistics", { params });
return response.data.data;
},
/**
* DDL
*/
getTableDDLHistory: async (
tableName: string,
): Promise<{
tableName: string;
history: DDLExecutionLog[];
total: number;
}> => {
const response = await apiClient.get(`/ddl/tables/${tableName}/history`);
return response.data.data;
},
/**
*
*/
getTableInfo: async (
tableName: string,
): Promise<{
tableInfo: any;
columns: any[];
}> => {
const response = await apiClient.get(`/ddl/tables/${tableName}/info`);
return response.data.data;
},
/**
* DDL
*/
cleanupOldLogs: async (
retentionDays?: number,
): Promise<{
deletedCount: number;
retentionDays: number;
}> => {
const response = await apiClient.delete("/ddl/logs/cleanup", {
params: { retentionDays },
});
return response.data.data;
},
/**
* DDL
*/
getServiceInfo: async (): Promise<any> => {
const response = await apiClient.get("/ddl/info");
return response.data.data;
},
/**
* DDL
*/
healthCheck: async (): Promise<{
status: string;
timestamp: string;
checks: {
database: string;
service: string;
};
}> => {
const response = await apiClient.get("/ddl/health");
return response.data;
},
};

View File

@ -28,7 +28,7 @@ export interface FormDataResponse {
// 동적 폼 API 클래스
export class DynamicFormApi {
/**
*
* ( - )
* @param formData
* @returns
*/
@ -57,6 +57,38 @@ export class DynamicFormApi {
}
}
/**
* ( )
* @param formData
* @returns ( )
*/
static async saveData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
try {
console.log("🚀 개선된 폼 데이터 저장 요청:", formData);
const response = await apiClient.post("/dynamic-form/save-enhanced", formData);
console.log("✅ 개선된 폼 데이터 저장 성공:", response.data);
return response.data;
} catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
// 개선된 오류 처리
const errorResponse = error.response?.data;
if (errorResponse && !errorResponse.success) {
return errorResponse; // 서버에서 온 구조화된 오류 응답 그대로 반환
}
const errorMessage = error.response?.data?.message || error.message || "데이터 저장 중 오류가 발생했습니다.";
return {
success: false,
message: errorMessage,
errorCode: error.response?.data?.errorCode,
};
}
}
/**
*
* @param id ID

View File

@ -205,10 +205,11 @@ export class ExternalDbConnectionAPI {
/**
* (ID )
*/
static async testConnection(connectionId: number): Promise<ConnectionTestResult> {
static async testConnection(connectionId: number, password?: string): Promise<ConnectionTestResult> {
try {
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(
`${this.BASE_PATH}/${connectionId}/test`
`${this.BASE_PATH}/${connectionId}/test`,
password ? { password } : undefined
);
if (!response.data.success) {

View File

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

View File

@ -77,7 +77,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -92,11 +92,13 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
>
{component.label}
{component.required && (
<span style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
<span
style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
*
</span>
)}
@ -104,7 +106,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
)}
<label
style={{display: "flex",
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
@ -112,7 +115,8 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
height: "100%",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@ -122,20 +126,28 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
checked={component.value === true || component.value === "true"}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
style={{width: "16px",
style={{
width: "16px",
height: "16px",
accentColor: "#3b82f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onChange={(e) => {
if (component.onChange) {
component.onChange(e.target.checked);
}
}}
/>
<span style={{color: "#374151",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}>{componentConfig.checkboxLabel || component.text || "체크박스"}</span>
<span
style={{
color: "#374151",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
{componentConfig.checkboxLabel || component.text || "체크박스"}
</span>
</label>
</div>
);

View File

@ -303,15 +303,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
}}
>
{component.label}
{component.required && (
<span
style={{
color: "#ef4444",
}}
>
*
</span>
)}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
</label>
)}

View File

@ -74,7 +74,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",

View File

@ -77,7 +77,7 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -91,15 +91,23 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}}
>
{component.label}
{component.required && <span style={{color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>*</span>}
{component.required && (
<span
style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
*
</span>
)}
</label>
)}
<div
style={{width: "100%",
style={{
width: "100%",
height: "100%",
border: "2px dashed #d1d5db",
borderRadius: "8px",
@ -110,9 +118,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
cursor: "pointer",
backgroundColor: "#f9fafb",
position: "relative",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@ -139,22 +147,42 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
}}
/>
<div style={{textAlign: "center", color: "#6b7280", fontSize: "14px",
<div
style={{
textAlign: "center",
color: "#6b7280",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
<div style={{fontSize: "24px", marginBottom: "8px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>📁</div>
<div style={{fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}> </div>
<div style={{fontSize: "12px", marginTop: "4px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
}}
>
<div
style={{
fontSize: "24px",
marginBottom: "8px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
📁
</div>
<div
style={{
fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
</div>
<div
style={{
fontSize: "12px",
marginTop: "4px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
{componentConfig.accept && `지원 형식: ${componentConfig.accept}`}
</div>
</div>

View File

@ -74,7 +74,7 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",

View File

@ -96,15 +96,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
}}
>
{component.label}
{component.required && (
<span
style={{
color: "#ef4444",
}}
>
*
</span>
)}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
</label>
)}

View File

@ -77,7 +77,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -91,23 +91,31 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
}}
>
{component.label}
{component.required && <span style={{color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>*</span>}
{component.required && (
<span
style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
*
</span>
)}
</label>
)}
<div
style={{width: "100%",
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: componentConfig.direction === "horizontal" ? "row" : "column",
gap: "8px",
padding: "8px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@ -115,14 +123,15 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
{(componentConfig.options || []).map((option, index) => (
<label
key={index}
style={{display: "flex",
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
<input
type="radio"
@ -131,53 +140,79 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
checked={component.value === option.value}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
style={{width: "16px",
style={{
width: "16px",
height: "16px",
accentColor: "#3b82f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onChange={(e) => {
if (component.onChange) {
component.onChange(e.target.value);
}
}}
/>
<span style={{color: "#374151",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>{option.label}</span>
<span
style={{
color: "#374151",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
{option.label}
</span>
</label>
))}
{(!componentConfig.options || componentConfig.options.length === 0) && (
<>
<label style={{display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
<input
type="radio"
name={component.id || "radio-group"}
value="option1"
style={{width: "16px", height: "16px", accentColor: "#3b82f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
style={{
width: "16px",
height: "16px",
accentColor: "#3b82f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
/>
<span> 1</span>
</label>
<label style={{display: "flex", alignItems: "center", gap: "6px", cursor: "pointer", fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
<input
type="radio"
name={component.id || "radio-group"}
value="option2"
style={{width: "16px", height: "16px", accentColor: "#3b82f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
style={{
width: "16px",
height: "16px",
accentColor: "#3b82f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
/>
<span> 2</span>
</label>

View File

@ -58,7 +58,7 @@ const loadGlobalTableCodeCategory = async (tableName: string, columnName: string
try {
await globalState.activeRequests.get(`table_${key}`);
} catch (error) {
console.error(`❌ 테이블 설정 로딩 대기 중 오류:`, error);
console.error("❌ 테이블 설정 로딩 대기 중 오류:", error);
}
}
@ -111,7 +111,7 @@ const loadGlobalCodeOptions = async (codeCategory: string): Promise<Option[]> =>
try {
await globalState.activeRequests.get(`code_${codeCategory}`);
} catch (error) {
console.error(`❌ 코드 옵션 로딩 대기 중 오류:`, error);
console.error("❌ 코드 옵션 로딩 대기 중 오류:", error);
}
}
@ -281,7 +281,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
if (newValue !== selectedValue) {
console.log(`🔄 SelectBasicComponent value 업데이트: "${selectedValue}" → "${newValue}"`);
console.log(`🔍 업데이트 조건 분석:`, {
console.log("🔍 업데이트 조건 분석:", {
externalValue,
componentConfigValue: componentConfig?.value,
configValue: config?.value,

View File

@ -77,7 +77,7 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -91,23 +91,31 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
}}
>
{component.label}
{component.required && <span style={{color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>*</span>}
{component.required && (
<span
style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
*
</span>
)}
</label>
)}
<div
style={{width: "100%",
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
gap: "12px",
padding: "8px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@ -120,7 +128,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
value={component.value || componentConfig.min || 0}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
style={{width: "70%",
style={{
width: "70%",
height: "6px",
outline: "none",
borderRadius: "3px",
@ -136,7 +145,8 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
}}
/>
<span
style={{width: "30%",
style={{
width: "30%",
textAlign: "center",
fontSize: "14px",
color: "#374151",

View File

@ -0,0 +1,188 @@
"use client";
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { ColumnConfig } from "./types";
interface SingleTableWithStickyProps {
visibleColumns: ColumnConfig[];
data: Record<string, any>[];
columnLabels: Record<string, string>;
sortColumn: string | null;
sortDirection: "asc" | "desc";
tableConfig: any;
isDesignMode: boolean;
isAllSelected: boolean;
handleSort: (columnName: string) => void;
handleSelectAll: (checked: boolean) => void;
handleRowClick: (row: any) => void;
renderCheckboxCell: (row: any, index: number) => React.ReactNode;
formatCellValue: (value: any, format?: string, columnName?: string) => string;
getColumnWidth: (column: ColumnConfig) => number;
}
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
visibleColumns,
data,
columnLabels,
sortColumn,
sortDirection,
tableConfig,
isDesignMode,
isAllSelected,
handleSort,
handleSelectAll,
handleRowClick,
renderCheckboxCell,
formatCellValue,
getColumnWidth,
}) => {
const checkboxConfig = tableConfig.checkbox || {};
return (
<div className="relative h-full w-full overflow-auto">
<Table className="w-full">
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<TableRow>
{visibleColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = visibleColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
return (
<TableHead
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "h-10 border-b px-4 py-2 text-center align-middle"
: "h-10 cursor-pointer border-b px-4 py-2 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
// 고정 컬럼 스타일
column.fixed === "left" && "sticky z-10 border-r bg-white shadow-sm",
column.fixed === "right" && "sticky z-10 border-l bg-white shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
style={{
width: getColumnWidth(column),
minWidth: getColumnWidth(column),
maxWidth: getColumnWidth(column),
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />
)
) : (
<>
<span className="flex-1 truncate">
{columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && (
<span className="ml-1">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3 text-blue-600" />
) : (
<ArrowDown className="h-3 w-3 text-blue-600" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-8 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={`row-${index}`}
className={cn(
"h-10 cursor-pointer border-b leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/30",
)}
style={{ minHeight: "40px", height: "40px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = visibleColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
return (
<TableCell
key={`cell-${column.columnName}`}
className={cn(
"h-10 px-4 py-2 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" && "sticky z-10 border-r bg-white",
column.fixed === "right" && "sticky z-10 border-l bg-white",
)}
style={{
minHeight: "40px",
height: "40px",
verticalAlign: "middle",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
};

View File

@ -7,14 +7,12 @@ import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
RefreshCw,
ArrowUpDown,
ArrowUp,
@ -23,6 +21,9 @@ import {
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
import { Separator } from "@/components/ui/separator";
import { SingleTableWithSticky } from "./SingleTableWithSticky";
export interface TableListComponentProps {
component: any;
@ -96,10 +97,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); // 로컬 페이지 크기 상태
const [selectedSearchColumn, setSelectedSearchColumn] = useState<string>(""); // 선택된 검색 컬럼
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
// 고급 필터 관련 state
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
// 체크박스 상태 관리
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); // 선택된 행들의 키 집합
const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태
@ -111,39 +114,67 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
maxBatchSize: 5,
});
// 높이 계산 함수
const calculateOptimalHeight = () => {
// 50개 이상일 때는 20개 기준으로 높이 고정
const displayPageSize = localPageSize >= 50 ? 20 : localPageSize;
const headerHeight = 48; // 테이블 헤더
const rowHeight = 40; // 각 행 높이 (normal)
const searchHeight = tableConfig.filter?.enabled ? 48 : 0; // 검색 영역
const footerHeight = tableConfig.showFooter ? 56 : 0; // 페이지네이션
const padding = 8; // 여백
// 높이 계산 함수 (메모이제이션)
const optimalHeight = useMemo(() => {
// 50개 이상일 때는 20개 기준으로 높이 고정하고 스크롤 생성
// 50개 미만일 때는 실제 데이터 개수에 맞춰서 스크롤 없이 표시
const actualDataCount = Math.min(data.length, localPageSize);
const displayPageSize = localPageSize >= 50 ? 20 : Math.max(actualDataCount, 5);
return headerHeight + displayPageSize * rowHeight + searchHeight + footerHeight + padding;
};
const headerHeight = 50; // 테이블 헤더
const rowHeight = 42; // 각 행 높이
const searchHeight = tableConfig.filter?.enabled ? 80 : 0; // 검색 영역
const footerHeight = tableConfig.showFooter ? 60 : 0; // 페이지네이션
const titleHeight = tableConfig.showHeader ? 60 : 0; // 제목 영역
const padding = 40; // 여백
const calculatedHeight =
titleHeight + searchHeight + headerHeight + displayPageSize * rowHeight + footerHeight + padding;
console.log("🔍 테이블 높이 계산:", {
actualDataCount,
localPageSize,
displayPageSize,
willHaveScroll: localPageSize >= 50,
titleHeight,
searchHeight,
headerHeight,
rowHeight,
footerHeight,
padding,
calculatedHeight,
finalHeight: `${calculatedHeight}px`,
});
// 추가 디버깅: 실제 데이터 상황
console.log("🔍 실제 데이터 상황:", {
actualDataLength: data.length,
localPageSize,
currentPage,
totalItems,
totalPages,
});
return calculatedHeight;
}, [data.length, localPageSize, tableConfig.filter?.enabled, tableConfig.showFooter, tableConfig.showHeader]);
// 스타일 계산
const componentStyle: React.CSSProperties = {
width: "100%",
height:
tableConfig.height === "fixed"
? `${tableConfig.fixedHeight || calculateOptimalHeight()}px`
: tableConfig.height === "auto"
? `${calculateOptimalHeight()}px`
: "100%",
height: `${optimalHeight}px`, // 20개 데이터를 모두 보여주는 높이
minHeight: `${optimalHeight}px`, // 최소 높이 보장
...component.style,
...style,
display: "flex",
flexDirection: "column",
boxSizing: "border-box", // 패딩/보더 포함한 크기 계산
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
componentStyle.minHeight = "200px";
// minHeight 제거 - 실제 데이터에 맞는 높이 사용
}
// 컬럼 라벨 정보 가져오기
@ -234,56 +265,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page: currentPage,
size: localPageSize,
search: searchTerm?.trim()
? (() => {
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
let searchColumn = selectedSearchColumn || sortColumn; // 사용자 선택 컬럼 최우선, 그 다음 정렬된 컬럼
search: (() => {
// 고급 필터 값이 있으면 우선 사용
const hasAdvancedFilters = Object.values(searchValues).some((value) => {
if (typeof value === "string") return value.trim() !== "";
if (typeof value === "object" && value !== null) {
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
}
return value !== null && value !== undefined && value !== "";
});
if (!searchColumn) {
// 1순위: name 관련 컬럼 (가장 검색에 적합)
const nameColumns = visibleColumns.filter(
(col) =>
col.columnName.toLowerCase().includes("name") ||
col.columnName.toLowerCase().includes("title") ||
col.columnName.toLowerCase().includes("subject"),
);
if (hasAdvancedFilters) {
console.log("🔍 고급 검색 필터 사용:", searchValues);
console.log("🔍 고급 검색 필터 상세:", JSON.stringify(searchValues, null, 2));
return searchValues;
}
// 2순위: text/varchar 타입 컬럼
const textColumns = visibleColumns.filter(
(col) => col.dataType === "text" || col.dataType === "varchar",
);
// 고급 필터가 없으면 기존 단순 검색 사용
if (searchTerm?.trim()) {
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
let searchColumn = sortColumn; // 정렬된 컬럼 우선
// 3순위: description 관련 컬럼
const descColumns = visibleColumns.filter(
(col) =>
col.columnName.toLowerCase().includes("desc") ||
col.columnName.toLowerCase().includes("comment") ||
col.columnName.toLowerCase().includes("memo"),
);
// 우선순위에 따라 선택
if (nameColumns.length > 0) {
searchColumn = nameColumns[0].columnName;
} else if (textColumns.length > 0) {
searchColumn = textColumns[0].columnName;
} else if (descColumns.length > 0) {
searchColumn = descColumns[0].columnName;
} else {
// 마지막 대안: 첫 번째 컬럼
searchColumn = visibleColumns[0]?.columnName || "id";
}
}
console.log("🔍 선택된 검색 컬럼:", searchColumn);
console.log("🔍 검색어:", searchTerm);
console.log(
"🔍 사용 가능한 컬럼들:",
visibleColumns.map((col) => `${col.columnName}(${col.dataType || "unknown"})`),
if (!searchColumn) {
// 1순위: name 관련 컬럼 (가장 검색에 적합)
const nameColumns = visibleColumns.filter(
(col) =>
col.columnName.toLowerCase().includes("name") ||
col.columnName.toLowerCase().includes("title") ||
col.columnName.toLowerCase().includes("subject"),
);
return { [searchColumn]: searchTerm };
})()
: undefined,
// 2순위: text/varchar 타입 컬럼
const textColumns = visibleColumns.filter((col) => col.dataType === "text" || col.dataType === "varchar");
// 3순위: description 관련 컬럼
const descColumns = visibleColumns.filter(
(col) =>
col.columnName.toLowerCase().includes("desc") ||
col.columnName.toLowerCase().includes("comment") ||
col.columnName.toLowerCase().includes("memo"),
);
// 우선순위에 따라 선택
if (nameColumns.length > 0) {
searchColumn = nameColumns[0].columnName;
} else if (textColumns.length > 0) {
searchColumn = textColumns[0].columnName;
} else if (descColumns.length > 0) {
searchColumn = descColumns[0].columnName;
} else {
// 마지막 대안: 첫 번째 컬럼
searchColumn = visibleColumns[0]?.columnName || "id";
}
}
console.log("🔍 기존 검색 방식 사용:", { [searchColumn]: searchTerm });
return { [searchColumn]: searchTerm };
}
return undefined;
})(),
sortBy: sortColumn || undefined,
sortOrder: sortDirection,
enableEntityJoin: true, // 🎯 Entity 조인 활성화
@ -410,10 +451,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
// 검색
const handleSearch = (term: string) => {
setSearchTerm(term);
setCurrentPage(1); // 검색 시 첫 페이지로 이동
// 고급 필터 핸들러
const handleSearchValueChange = (columnName: string, value: any) => {
setSearchValues((prev) => ({
...prev,
[columnName]: value,
}));
};
const handleAdvancedSearch = () => {
setCurrentPage(1);
fetchTableData();
};
const handleClearAdvancedFilters = () => {
setSearchValues({});
setCurrentPage(1);
fetchTableData();
};
// 새로고침
@ -522,7 +576,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (tableConfig.autoLoad && !isDesignMode) {
fetchTableData();
}
}, [tableConfig.selectedTable, localPageSize, currentPage, searchTerm, sortColumn, sortDirection, columnLabels]);
}, [
tableConfig.selectedTable,
localPageSize,
currentPage,
searchTerm,
sortColumn,
sortDirection,
columnLabels,
searchValues,
]);
// refreshKey 변경 시 테이블 데이터 새로고침
useEffect(() => {
@ -577,8 +640,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
.sort((a, b) => a.order - b.order);
}
// 체크박스가 활성화된 경우 체크박스 컬럼을 추가
if (checkboxConfig.enabled) {
// 체크박스가 활성화되고 실제 데이터 컬럼이 있는 경우에만 체크박스 컬럼을 추가
if (checkboxConfig.enabled && columns.length > 0) {
const checkboxColumn: ColumnConfig = {
columnName: "__checkbox__",
displayName: "",
@ -603,28 +666,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return columns;
}, [displayColumns, tableConfig.columns, tableConfig.checkbox]);
// 컬럼을 고정 위치별로 분류
const columnsByPosition = useMemo(() => {
const leftFixed: ColumnConfig[] = [];
const rightFixed: ColumnConfig[] = [];
const normal: ColumnConfig[] = [];
visibleColumns.forEach((col) => {
if (col.fixed === "left") {
leftFixed.push(col);
} else if (col.fixed === "right") {
rightFixed.push(col);
} else {
normal.push(col);
}
});
// 고정 컬럼들은 fixedOrder로 정렬
leftFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
rightFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
return { leftFixed, rightFixed, normal };
}, [visibleColumns]);
// columnsByPosition은 SingleTableWithSticky에서 사용하지 않으므로 제거
// 기존 테이블에서만 필요한 경우 다시 추가 가능
// 가로 스크롤이 필요한지 계산
const needsHorizontalScroll = useMemo(() => {
@ -819,8 +862,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
{/* 검색 */}
{tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
{/* 검색 - 기존 방식은 주석처리 */}
{/* {tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
@ -831,7 +874,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className="w-64 pl-8"
/>
</div>
{/* 검색 컬럼 선택 드롭다운 */}
{tableConfig.filter?.showColumnSelector && (
<select
value={selectedSearchColumn}
@ -847,7 +889,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</select>
)}
</div>
)}
)} */}
{/* 새로고침 */}
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
@ -857,8 +899,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
{/* 고급 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
{tableConfig.filter?.enabled && visibleColumns && visibleColumns.length > 0 && (
<>
<Separator className="my-1" />
<AdvancedSearchFilters
filters={tableConfig.filter?.filters || []} // 설정된 필터 사용, 없으면 자동 생성
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters}
tableColumns={visibleColumns.map((col) => ({
columnName: col.columnName,
webType: columnMeta[col.columnName]?.webType || "text",
displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
codeCategory: columnMeta[col.columnName]?.codeCategory,
isVisible: col.visible,
// 추가 메타데이터 전달 (필터 자동 생성용)
web_type: columnMeta[col.columnName]?.webType || "text",
column_name: col.columnName,
column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
code_category: columnMeta[col.columnName]?.codeCategory,
}))}
tableName={tableConfig.selectedTable}
/>
</>
)}
{/* 테이블 컨텐츠 */}
<div className={`flex-1 ${localPageSize >= 50 ? "overflow-auto" : "overflow-hidden"}`}>
<div className={`w-full ${localPageSize >= 50 ? "flex-1 overflow-auto" : ""}`}>
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -874,317 +943,43 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
</div>
) : needsHorizontalScroll ? (
// 가로 스크롤이 필요한 경우 - 고정 컬럼 지원 테이블
<div className="relative flex h-full">
{/* 왼쪽 고정 컬럼 */}
{columnsByPosition.leftFixed.length > 0 && (
<div className="flex-shrink-0 border-r bg-gray-50/50">
<table
className="table-fixed-layout table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.leftFixed.map((column) => (
<th
key={`fixed-left-${column.columnName}`}
className={cn(
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center align-middle"
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
style={{
minWidth: `${getColumnWidth(column)}px`,
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
onClick={() => column.sortable && handleSort(column.columnName)}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.leftFixed.length} className="py-8 text-center text-gray-500">
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`fixed-left-row-${index}`}
className={cn(
"h-12 cursor-pointer border-b leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.leftFixed.map((column) => (
<td
key={`fixed-left-cell-${column.columnName}`}
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* 스크롤 가능한 중앙 컬럼들 */}
<div className="flex-1 overflow-x-auto">
<table
className="table-fixed-layout w-full table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
<tr>
{columnsByPosition.normal.map((column) => (
<th
key={`normal-${column.columnName}`}
style={{
minWidth: `${getColumnWidth(column)}px`,
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
className={cn(
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center align-middle"
: "cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.normal.length} className="py-8 text-center text-gray-500">
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.rightFixed.length === 0
? "데이터가 없습니다"
: ""}
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`normal-row-${index}`}
className={cn(
"cursor-pointer border-b",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.normal.map((column) => (
<td
key={`normal-cell-${column.columnName}`}
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* 오른쪽 고정 컬럼 */}
{columnsByPosition.rightFixed.length > 0 && (
<div className="flex-shrink-0 border-l bg-gray-50/50">
<table
className="table-fixed-layout table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.rightFixed.map((column) => (
<th
key={`fixed-right-${column.columnName}`}
className={cn(
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center align-middle"
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
style={{
minWidth: `${getColumnWidth(column)}px`,
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
onClick={() => column.sortable && handleSort(column.columnName)}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.rightFixed.length} className="py-8 text-center text-gray-500">
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.normal.length === 0
? "데이터가 없습니다"
: ""}
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`fixed-right-row-${index}`}
className={cn(
"h-12 cursor-pointer border-b leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.rightFixed.map((column) => (
<td
key={`fixed-right-cell-${column.columnName}`}
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
// 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용
<SingleTableWithSticky
visibleColumns={visibleColumns}
data={data}
columnLabels={columnLabels}
sortColumn={sortColumn}
sortDirection={sortDirection}
tableConfig={tableConfig}
isDesignMode={isDesignMode}
isAllSelected={isAllSelected}
handleSort={handleSort}
handleSelectAll={handleSelectAll}
handleRowClick={handleRowClick}
renderCheckboxCell={renderCheckboxCell}
formatCellValue={formatCellValue}
getColumnWidth={getColumnWidth}
/>
) : (
// 기존 테이블 (가로 스크롤이 필요 없는 경우)
<Table>
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
<TableRow style={{ minHeight: "48px !important", height: "48px !important", lineHeight: "1" }}>
<TableRow style={{ minHeight: "40px !important", height: "40px !important", lineHeight: "1" }}>
{visibleColumns.map((column) => (
<TableHead
key={column.columnName}
style={{
width: column.width ? `${column.width}px` : undefined,
minHeight: "48px !important",
height: "48px !important",
minHeight: "40px !important",
height: "40px !important",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
className={cn(
column.columnName === "__checkbox__"
? "h-12 text-center align-middle"
: "h-12 cursor-pointer align-middle whitespace-nowrap select-none",
? "h-10 text-center align-middle"
: "h-10 cursor-pointer align-middle whitespace-nowrap select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
@ -1226,18 +1021,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<TableRow
key={index}
className={cn(
"h-12 cursor-pointer leading-none",
"h-10 cursor-pointer leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
style={{ minHeight: "40px", height: "40px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column) => (
<TableCell
key={column.columnName}
className={cn("h-12 align-middle whitespace-nowrap", `text-${column.align}`)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
className={cn("h-10 align-middle whitespace-nowrap", `text-${column.align}`)}
style={{ minHeight: "40px", height: "40px", verticalAlign: "middle" }}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)

View File

@ -95,20 +95,33 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
fetchTables();
}, []);
// 선택된 테이블의 컬럼 목록 설정 (tableColumns prop 우선 사용)
// 선택된 테이블의 컬럼 목록 설정
useEffect(() => {
console.log("🔍 useEffect 실행됨 - tableColumns:", tableColumns, "length:", tableColumns?.length);
if (tableColumns && tableColumns.length > 0) {
// tableColumns prop이 있으면 사용
console.log(
"🔍 useEffect 실행됨 - config.selectedTable:",
config.selectedTable,
"screenTableName:",
screenTableName,
);
// 컴포넌트에 명시적으로 테이블이 선택되었거나, 화면에 연결된 테이블이 있는 경우에만 컬럼 목록 표시
const shouldShowColumns = config.selectedTable || (screenTableName && config.columns && config.columns.length > 0);
if (!shouldShowColumns) {
console.log("🔧 컬럼 목록 숨김 - 명시적 테이블 선택 또는 설정된 컬럼이 없음");
setAvailableColumns([]);
return;
}
// tableColumns prop을 우선 사용하되, 컴포넌트가 명시적으로 설정되었을 때만
if (tableColumns && tableColumns.length > 0 && (config.selectedTable || config.columns?.length > 0)) {
console.log("🔧 tableColumns prop 사용:", tableColumns);
console.log("🔧 첫 번째 컬럼 상세:", tableColumns[0]);
const mappedColumns = tableColumns.map((column: any) => ({
columnName: column.columnName || column.name,
dataType: column.dataType || column.type || "text",
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
}));
console.log("🏷️ availableColumns 설정됨:", mappedColumns);
console.log("🏷️ 첫 번째 mappedColumn:", mappedColumns[0]);
setAvailableColumns(mappedColumns);
} else if (config.selectedTable || screenTableName) {
// API에서 컬럼 정보 가져오기
@ -144,7 +157,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
} else {
setAvailableColumns([]);
}
}, [config.selectedTable, screenTableName, tableColumns]);
}, [config.selectedTable, screenTableName, tableColumns, config.columns]);
// Entity 조인 컬럼 정보 가져오기
useEffect(() => {
@ -274,6 +287,72 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
handleChange("columns", columns);
};
// 필터 추가
const addFilter = (columnName: string) => {
const existingFilter = config.filter?.filters?.find((f) => f.columnName === columnName);
if (existingFilter) return;
const column = availableColumns.find((col) => col.columnName === columnName);
if (!column) return;
// tableColumns에서 해당 컬럼의 메타정보 찾기
const tableColumn = tableColumns?.find((tc) => tc.columnName === columnName || tc.column_name === columnName);
// 컬럼의 데이터 타입과 웹타입에 따라 위젯 타입 결정
const inferWidgetType = (dataType: string, webType?: string): string => {
// 웹타입이 있으면 우선 사용
if (webType) {
return webType;
}
// 데이터 타입으로 추론
const type = dataType.toLowerCase();
if (type.includes("int") || type.includes("numeric") || type.includes("decimal")) return "number";
if (type.includes("date") || type.includes("timestamp")) return "date";
if (type.includes("bool")) return "boolean";
return "text";
};
const widgetType = inferWidgetType(column.dataType, tableColumn?.webType || tableColumn?.web_type);
const newFilter = {
columnName,
widgetType,
label: column.label || column.columnName,
gridColumns: 3,
numberFilterMode: "range" as const,
// 코드 타입인 경우 코드 카테고리 추가
...(widgetType === "code" && {
codeCategory: tableColumn?.codeCategory || tableColumn?.code_category,
}),
// 엔티티 타입인 경우 참조 정보 추가
...(widgetType === "entity" && {
referenceTable: tableColumn?.referenceTable || tableColumn?.reference_table,
referenceColumn: tableColumn?.referenceColumn || tableColumn?.reference_column,
displayColumn: tableColumn?.displayColumn || tableColumn?.display_column,
}),
};
console.log("🔍 필터 추가:", newFilter);
const currentFilters = config.filter?.filters || [];
handleNestedChange("filter", "filters", [...currentFilters, newFilter]);
};
// 필터 제거
const removeFilter = (index: number) => {
const currentFilters = config.filter?.filters || [];
const updatedFilters = currentFilters.filter((_, i) => i !== index);
handleNestedChange("filter", "filters", updatedFilters);
};
// 필터 업데이트
const updateFilter = (index: number, key: string, value: any) => {
const currentFilters = config.filter?.filters || [];
const updatedFilters = currentFilters.map((filter, i) => (i === index ? { ...filter, [key]: value } : filter));
handleNestedChange("filter", "filters", updatedFilters);
};
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
@ -620,6 +699,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
</CardContent>
</Card>
) : availableColumns.length === 0 ? (
<Card>
<CardContent className="pt-6">
<div className="text-center text-gray-500">
<p> </p>
<p className="text-sm"> .</p>
<p className="mt-2 text-xs text-blue-600"> : {screenTableName}</p>
</div>
</CardContent>
</Card>
) : (
<>
<Card>
@ -990,54 +1079,141 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
{/* 필터 설정 탭 */}
<TabsContent value="filter" className="space-y-4">
<ScrollArea className="h-[600px] pr-4">
{/* 필터 기능 활성화 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="filterEnabled"
checked={config.filter?.enabled}
checked={config.filter?.enabled || false}
onCheckedChange={(checked) => handleNestedChange("filter", "enabled", checked)}
/>
<Label htmlFor="filterEnabled"> </Label>
</div>
{config.filter?.enabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="quickSearch"
checked={config.filter?.quickSearch}
onCheckedChange={(checked) => handleNestedChange("filter", "quickSearch", checked)}
/>
<Label htmlFor="quickSearch"> </Label>
</div>
{config.filter?.quickSearch && (
<div className="ml-6 flex items-center space-x-2">
<Checkbox
id="showColumnSelector"
checked={config.filter?.showColumnSelector}
onCheckedChange={(checked) => handleNestedChange("filter", "showColumnSelector", checked)}
/>
<Label htmlFor="showColumnSelector"> </Label>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="advancedFilter"
checked={config.filter?.advancedFilter}
onCheckedChange={(checked) => handleNestedChange("filter", "advancedFilter", checked)}
/>
<Label htmlFor="advancedFilter"> </Label>
</div>
</>
)}
</CardContent>
</Card>
{/* 필터 목록 */}
{config.filter?.enabled && (
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 필터 추가 버튼 */}
{availableColumns.length > 0 && (
<div className="flex flex-wrap gap-2">
{availableColumns
.filter((col) => !config.filter?.filters?.find((f) => f.columnName === col.columnName))
.map((column) => (
<Button
key={column.columnName}
variant="outline"
size="sm"
onClick={() => addFilter(column.columnName)}
className="flex items-center gap-1"
>
<Plus className="h-3 w-3" />
{column.label || column.columnName}
<Badge variant="secondary" className="text-xs">
{column.dataType}
</Badge>
</Button>
))}
</div>
)}
{/* 설정된 필터 목록 */}
{config.filter?.filters && config.filter.filters.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
{config.filter.filters.map((filter, index) => (
<div key={filter.columnName} className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">{filter.widgetType}</Badge>
<span className="font-medium">{filter.label}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeFilter(index)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs"></Label>
<Input
value={filter.label}
onChange={(e) => updateFilter(index, "label", e.target.value)}
placeholder="필터 라벨"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.gridColumns.toString()}
onValueChange={(value) => updateFilter(index, "gridColumns", parseInt(value))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="6">6</SelectItem>
</SelectContent>
</Select>
</div>
{/* 숫자 타입인 경우 검색 모드 선택 */}
{(filter.widgetType === "number" || filter.widgetType === "decimal") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.numberFilterMode || "range"}
onValueChange={(value) => updateFilter(index, "numberFilterMode", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exact"> </SelectItem>
<SelectItem value="range"> </SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 코드 타입인 경우 코드 카테고리 */}
{filter.widgetType === "code" && (
<div>
<Label className="text-xs"> </Label>
<Input
value={filter.codeCategory || ""}
onChange={(e) => updateFilter(index, "codeCategory", e.target.value)}
placeholder="코드 카테고리"
/>
</div>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</ScrollArea>
</TabsContent>

View File

@ -59,10 +59,7 @@ export const TableListDefinition = createComponentDefinition({
// 필터 설정
filter: {
enabled: true,
quickSearch: true,
showColumnSelector: true, // 검색컬럼 선택기 표시 기본값
advancedFilter: false,
filterableColumns: [],
filters: [], // 사용자가 설정할 필터 목록
},
// 액션 설정

View File

@ -71,14 +71,17 @@ export interface ColumnConfig {
*/
export interface FilterConfig {
enabled: boolean;
quickSearch: boolean;
showColumnSelector?: boolean; // 검색 컬럼 선택기 표시 여부
advancedFilter: boolean;
filterableColumns: string[];
defaultFilters?: Array<{
column: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
value: any;
// 사용할 필터 목록 (DataTableFilter 타입 사용)
filters: Array<{
columnName: string;
widgetType: string;
label: string;
gridColumns: number;
numberFilterMode?: "exact" | "range"; // 숫자 필터 모드
codeCategory?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
}

View File

@ -71,10 +71,10 @@ export const TestInputComponent: React.FC<TestInputComponentProps> = ({
...domProps
} = props;
return (
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -89,7 +89,7 @@ export const TestInputComponent: React.FC<TestInputComponentProps> = ({
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
</label>
)}
<input
type="text"
value={component.value || ""}

View File

@ -77,7 +77,7 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -91,10 +91,17 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
}}
>
{component.label}
{component.required && <span style={{color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>*</span>}
{component.required && (
<span
style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
*
</span>
)}
</label>
)}
@ -105,7 +112,8 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
required={componentConfig.required || false}
readOnly={componentConfig.readonly || false}
rows={componentConfig.rows || 3}
style={{width: "100%",
style={{
width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "4px",
@ -113,9 +121,9 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
fontSize: "14px",
outline: "none",
resize: "none",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}

View File

@ -77,7 +77,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
return (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -91,30 +91,39 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
}}
>
{component.label}
{component.required && <span style={{color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>*</span>}
{component.required && (
<span
style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
*
</span>
)}
</label>
)}
<label
style={{display: "flex",
style={{
display: "flex",
alignItems: "center",
gap: "12px",
cursor: "pointer",
width: "100%",
height: "100%",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div
style={{position: "relative",
style={{
position: "relative",
width: "48px",
height: "24px",
backgroundColor: component.value === true ? "#3b82f6" : "#d1d5db",
@ -132,14 +141,14 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
style={{
position: "absolute",
position: "absolute",
opacity: 0,
width: "100%",
height: "100%",
cursor: "pointer",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onChange={(e) => {
if (component.onChange) {
component.onChange(e.target.checked);
@ -148,7 +157,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
/>
<div
style={{
position: "absolute",
position: "absolute",
top: "2px",
left: component.value === true ? "26px" : "2px",
width: "20px",
@ -157,15 +166,20 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
borderRadius: "50%",
transition: "left 0.2s",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
/>
</div>
<span
style={{
color: "#374151",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
/>
</div>
<span style={{color: "#374151",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>{componentConfig.toggleLabel || (component.value ? "켜짐" : "꺼짐")}</span>
>
{componentConfig.toggleLabel || (component.value ? "켜짐" : "꺼짐")}
</span>
</label>
</div>
);

View File

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

View File

@ -7,11 +7,11 @@
import {
ButtonActionType,
ButtonTypeConfig,
ExtendedButtonTypeConfig,
ButtonDataflowConfig,
DataflowExecutionResult,
DataflowCondition,
} from "@/types/screen";
} from "@/types";
import { dataflowConfigCache } from "./dataflowCache";
import { dataflowJobQueue, JobPriority } from "./dataflowJobQueue";
import { apiClient } from "@/lib/api/client";
@ -72,7 +72,7 @@ export class OptimizedButtonDataflowService {
static async executeButtonWithDataflow(
buttonId: string,
actionType: ButtonActionType,
buttonConfig: ButtonTypeConfig,
buttonConfig: ExtendedExtendedButtonTypeConfig,
contextData: Record<string, any>,
companyCode: string,
): Promise<OptimizedExecutionResult> {
@ -115,7 +115,7 @@ export class OptimizedButtonDataflowService {
private static async executeAfterTiming(
buttonId: string,
actionType: ButtonActionType,
buttonConfig: ButtonTypeConfig,
buttonConfig: ExtendedButtonTypeConfig,
contextData: Record<string, any>,
companyCode: string,
): Promise<OptimizedExecutionResult> {
@ -155,7 +155,7 @@ export class OptimizedButtonDataflowService {
private static async executeBeforeTiming(
buttonId: string,
actionType: ButtonActionType,
buttonConfig: ButtonTypeConfig,
buttonConfig: ExtendedButtonTypeConfig,
contextData: Record<string, any>,
companyCode: string,
): Promise<OptimizedExecutionResult> {
@ -228,7 +228,7 @@ export class OptimizedButtonDataflowService {
private static async executeReplaceTiming(
buttonId: string,
actionType: ButtonActionType,
buttonConfig: ButtonTypeConfig,
buttonConfig: ExtendedButtonTypeConfig,
contextData: Record<string, any>,
companyCode: string,
): Promise<OptimizedExecutionResult> {
@ -623,7 +623,7 @@ export class OptimizedButtonDataflowService {
*/
private static async executeOriginalAction(
actionType: ButtonActionType,
buttonConfig: ButtonTypeConfig,
buttonConfig: ExtendedButtonTypeConfig,
contextData: Record<string, any>,
): Promise<any> {
const startTime = performance.now();
@ -677,42 +677,42 @@ export class OptimizedButtonDataflowService {
/**
*
*/
private static async executeSaveAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeSaveAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
// TODO: 실제 저장 로직 구현
return { success: true, message: "저장되었습니다." };
}
private static async executeDeleteAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeDeleteAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
// TODO: 실제 삭제 로직 구현
return { success: true, message: "삭제되었습니다." };
}
private static async executeSearchAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeSearchAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
// TODO: 실제 검색 로직 구현
return { success: true, message: "검색되었습니다.", data: [] };
}
private static async executeEditAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeEditAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
return { success: true, message: "수정 모드로 전환되었습니다." };
}
private static async executeAddAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeAddAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
return { success: true, message: "추가 모드로 전환되었습니다." };
}
private static async executeResetAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeResetAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
return { success: true, message: "초기화되었습니다." };
}
private static async executeSubmitAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeSubmitAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
return { success: true, message: "제출되었습니다." };
}
private static async executeCloseAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeCloseAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
return { success: true, message: "닫기 액션이 실행되었습니다." };
}
private static async executePopupAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executePopupAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
return {
success: true,
message: "팝업이 열렸습니다.",
@ -721,7 +721,7 @@ export class OptimizedButtonDataflowService {
};
}
private static async executeNavigateAction(config: ButtonTypeConfig, data: Record<string, any>) {
private static async executeNavigateAction(config: ExtendedButtonTypeConfig, data: Record<string, any>) {
return {
success: true,
message: "페이지 이동이 실행되었습니다.",

View File

@ -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,
};
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

343
frontend/types/ddl.ts Normal file
View File

@ -0,0 +1,343 @@
/**
* DDL ()
*/
// 기본 웹타입
export type WebType =
| "text"
| "number"
| "decimal"
| "date"
| "datetime"
| "boolean"
| "code"
| "entity"
| "textarea"
| "select"
| "checkbox"
| "radio"
| "file"
| "email"
| "tel";
// 컬럼 정의 인터페이스
export interface CreateColumnDefinition {
/** 컬럼명 (영문자, 숫자, 언더스코어만 허용) */
name: string;
/** 컬럼 라벨 (화면 표시용) */
label?: string;
/** 입력타입 */
inputType: string;
/** 웹타입 (레거시 호환용) */
webType?: WebType;
/** NULL 허용 여부 */
nullable?: boolean;
/** 컬럼 길이 (text, code 타입에서 사용) */
length?: number;
/** 기본값 */
defaultValue?: string;
/** 컬럼 설명 */
description?: string;
/** 표시 순서 */
order?: number;
/** 상세 설정 (JSON 형태) */
detailSettings?: Record<string, any>;
}
// 테이블 생성 요청 인터페이스
export interface CreateTableRequest {
/** 테이블명 */
tableName: string;
/** 테이블 설명 */
description?: string;
/** 컬럼 정의 목록 */
columns: CreateColumnDefinition[];
}
// 컬럼 추가 요청 인터페이스
export interface AddColumnRequest {
/** 컬럼 정의 */
column: CreateColumnDefinition;
}
// DDL 실행 결과 인터페이스
export interface DDLExecutionResult {
/** 실행 성공 여부 */
success: boolean;
/** 결과 메시지 */
message: string;
/** 결과 데이터 */
data?: any;
/** 오류 정보 */
error?: {
code: string;
details: string;
};
}
// 검증 결과 인터페이스
export interface ValidationResult {
/** 검증 통과 여부 */
isValid: boolean;
/** 오류 메시지 목록 */
errors: string[];
/** 경고 메시지 목록 */
warnings?: string[];
/** 검증 요약 */
summary?: string;
}
// DDL 실행 로그 인터페이스
export interface DDLExecutionLog {
/** 로그 ID */
id: number;
/** 사용자 ID */
user_id: string;
/** 회사 코드 */
company_code: string;
/** DDL 유형 */
ddl_type: "CREATE_TABLE" | "ADD_COLUMN" | "DROP_TABLE" | "DROP_COLUMN";
/** 테이블명 */
table_name: string;
/** 실행된 DDL 쿼리 (미리보기용) */
ddl_query_preview?: string;
/** 실행 성공 여부 */
success: boolean;
/** 오류 메시지 (실패 시) */
error_message?: string;
/** 실행 시간 */
executed_at: string;
}
// DDL 통계 인터페이스
export interface DDLStatistics {
/** 전체 실행 횟수 */
totalExecutions: number;
/** 성공 횟수 */
successfulExecutions: number;
/** 실패 횟수 */
failedExecutions: number;
/** DDL 타입별 통계 */
byDDLType: Record<string, number>;
/** 사용자별 통계 */
byUser: Record<string, number>;
/** 최근 실패 로그 */
recentFailures: any[];
}
// 웹타입 옵션 (UI용)
export interface WebTypeOption {
value: WebType;
label: string;
description?: string;
supportsLength: boolean;
defaultLength?: number;
}
// 웹타입 옵션 목록
export const WEB_TYPE_OPTIONS: WebTypeOption[] = [
{
value: "text",
label: "텍스트",
description: "일반 텍스트 입력",
supportsLength: true,
defaultLength: 255,
},
{
value: "number",
label: "숫자",
description: "정수 입력",
supportsLength: false,
},
{
value: "decimal",
label: "소수",
description: "소수점 숫자 입력",
supportsLength: false,
},
{
value: "date",
label: "날짜",
description: "날짜 선택",
supportsLength: false,
},
{
value: "datetime",
label: "날짜시간",
description: "날짜와 시간 선택",
supportsLength: false,
},
{
value: "boolean",
label: "불린",
description: "참/거짓 값",
supportsLength: false,
},
{
value: "code",
label: "코드",
description: "공통코드 선택",
supportsLength: true,
defaultLength: 100,
},
{
value: "entity",
label: "엔티티",
description: "다른 테이블 참조",
supportsLength: false,
},
{
value: "textarea",
label: "긴 텍스트",
description: "여러 줄 텍스트 입력",
supportsLength: false,
},
{
value: "select",
label: "선택박스",
description: "드롭다운 선택",
supportsLength: true,
defaultLength: 100,
},
{
value: "checkbox",
label: "체크박스",
description: "체크박스 입력",
supportsLength: false,
},
{
value: "radio",
label: "라디오버튼",
description: "단일 선택 라디오버튼",
supportsLength: true,
defaultLength: 100,
},
{
value: "file",
label: "파일",
description: "파일 업로드",
supportsLength: false,
},
{
value: "email",
label: "이메일",
description: "이메일 주소 입력",
supportsLength: true,
defaultLength: 255,
},
{
value: "tel",
label: "전화번호",
description: "전화번호 입력",
supportsLength: true,
defaultLength: 50,
},
];
// 컬럼 정의 테이블 속성
export interface ColumnDefinitionTableProps {
columns: CreateColumnDefinition[];
onChange: (columns: CreateColumnDefinition[]) => void;
disabled?: boolean;
}
// 테이블 생성 모달 속성
export interface CreateTableModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (result: DDLExecutionResult) => void;
}
// 컬럼 추가 모달 속성
export interface AddColumnModalProps {
isOpen: boolean;
onClose: () => void;
tableName: string;
onSuccess: (result: DDLExecutionResult) => void;
}
// DDL 로그 뷰어 속성
export interface DDLLogViewerProps {
isOpen: boolean;
onClose: () => void;
}
// 유효성 검사 규칙
export const VALIDATION_RULES = {
tableName: {
pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
minLength: 2,
maxLength: 63,
errorMessage: "테이블명은 영문자로 시작하고, 영문자/숫자/언더스코어만 사용 가능합니다 (2-63자)",
},
columnName: {
pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
minLength: 2,
maxLength: 63,
errorMessage: "컬럼명은 영문자로 시작하고, 영문자/숫자/언더스코어만 사용 가능합니다 (2-63자)",
},
columnLength: {
min: 1,
max: 65535,
errorMessage: "컬럼 길이는 1 이상 65535 이하여야 합니다",
},
} as const;
// 시스템 테이블 목록 (보호 대상)
export const SYSTEM_TABLES = [
"user_info",
"company_mng",
"menu_info",
"auth_group",
"table_labels",
"column_labels",
"screen_definitions",
"screen_layouts",
"common_code",
"multi_lang_key_master",
"multi_lang_text",
"button_action_standards",
"ddl_execution_log",
] as const;
// 예약어 목록
export const RESERVED_WORDS = [
"user",
"order",
"group",
"table",
"column",
"index",
"select",
"insert",
"update",
"delete",
"from",
"where",
"join",
"on",
"as",
"and",
"or",
"not",
"null",
"true",
"false",
"create",
"alter",
"drop",
"primary",
"key",
"foreign",
"references",
"constraint",
"default",
"unique",
"check",
"view",
"procedure",
"function",
] as const;
// 예약된 컬럼명 목록 (자동 추가되는 기본 컬럼들)
export const RESERVED_COLUMNS = ["id", "created_date", "updated_date", "company_code"] as const;

343
frontend/types/index.ts Normal file
View File

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

View File

@ -0,0 +1,235 @@
/**
* ( )
* 8
*
* 주의: .
*/
// 8개 핵심 입력 타입
export type InputType =
| "text" // 텍스트
| "number" // 숫자
| "date" // 날짜
| "code" // 코드
| "entity" // 엔티티
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio"; // 라디오버튼
// 입력 타입 옵션 정의
export interface InputTypeOption {
value: InputType;
label: string;
description: string;
category: InputTypeCategory;
icon?: string;
}
// 입력 타입 카테고리
export type InputTypeCategory =
| "basic" // 기본 입력 (text, number, date)
| "reference" // 참조 입력 (code, entity)
| "selection"; // 선택 입력 (select, checkbox, radio)
// 입력 타입 옵션 목록
export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
{
value: "text",
label: "텍스트",
description: "일반 텍스트 입력",
category: "basic",
icon: "Type",
},
{
value: "number",
label: "숫자",
description: "숫자 입력 (정수/소수)",
category: "basic",
icon: "Hash",
},
{
value: "date",
label: "날짜",
description: "날짜 선택",
category: "basic",
icon: "Calendar",
},
{
value: "code",
label: "코드",
description: "공통코드 참조",
category: "reference",
icon: "Code",
},
{
value: "entity",
label: "엔티티",
description: "다른 테이블 참조",
category: "reference",
icon: "Database",
},
{
value: "select",
label: "선택박스",
description: "드롭다운 선택",
category: "selection",
icon: "ChevronDown",
},
{
value: "checkbox",
label: "체크박스",
description: "체크박스 입력",
category: "selection",
icon: "CheckSquare",
},
{
value: "radio",
label: "라디오버튼",
description: "단일 선택",
category: "selection",
icon: "Circle",
},
];
// 카테고리별 입력 타입 그룹화
export const INPUT_TYPE_GROUPS = {
basic: INPUT_TYPE_OPTIONS.filter((option) => option.category === "basic"),
reference: INPUT_TYPE_OPTIONS.filter((option) => option.category === "reference"),
selection: INPUT_TYPE_OPTIONS.filter((option) => option.category === "selection"),
};
// 입력 타입 검증 함수
export const isValidInputType = (inputType: string): inputType is InputType => {
return INPUT_TYPE_OPTIONS.some((option) => option.value === inputType);
};
// 입력 타입 정보 조회
export const getInputTypeInfo = (inputType: InputType): InputTypeOption | undefined => {
return INPUT_TYPE_OPTIONS.find((option) => option.value === inputType);
};
// 입력 타입별 기본 설정
export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>> = {
text: {
maxLength: 500,
placeholder: "텍스트를 입력하세요",
},
number: {
min: 0,
step: 1,
placeholder: "숫자를 입력하세요",
},
date: {
format: "YYYY-MM-DD",
placeholder: "날짜를 선택하세요",
},
code: {
placeholder: "코드를 선택하세요",
searchable: true,
},
entity: {
placeholder: "항목을 선택하세요",
searchable: true,
},
select: {
placeholder: "선택하세요",
searchable: false,
},
checkbox: {
defaultChecked: false,
trueValue: "Y",
falseValue: "N",
},
radio: {
inline: false,
},
};
// 레거시 웹 타입 → 입력 타입 매핑
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
// 텍스트 관련
text: "text",
textarea: "text",
email: "text",
tel: "text",
url: "text",
password: "text",
// 숫자 관련
number: "number",
decimal: "number",
// 날짜 관련
date: "date",
datetime: "date",
time: "date",
// 선택 관련
select: "select",
dropdown: "select",
checkbox: "checkbox",
boolean: "checkbox",
radio: "radio",
// 참조 관련
code: "code",
entity: "entity",
// 기타 (기본값: text)
file: "text",
button: "text",
};
// 입력 타입 → 웹 타입 역매핑 (화면관리 시스템 호환용)
export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
text: "text",
number: "number",
date: "date",
code: "code",
entity: "entity",
select: "select",
checkbox: "checkbox",
radio: "radio",
};
// 입력 타입 변환 함수
export const convertWebTypeToInputType = (webType: string): InputType => {
return WEB_TYPE_TO_INPUT_TYPE[webType] || "text";
};
// 입력 타입별 검증 규칙
export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>> = {
text: {
type: "string",
trim: true,
maxLength: 500,
},
number: {
type: "number",
allowFloat: true,
},
date: {
type: "date",
format: "YYYY-MM-DD",
},
code: {
type: "string",
required: false,
},
entity: {
type: "string",
required: false,
},
select: {
type: "string",
options: true,
},
checkbox: {
type: "boolean",
values: ["Y", "N"],
},
radio: {
type: "string",
options: true,
},
};

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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());
};

View File

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

View File

@ -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 }, // 레거시 지원
};

View File

@ -0,0 +1,852 @@
# 제어관리 시스템 트랜잭션 및 조건부 실행 개선방안
## 🚨 현재 문제점 분석
### 1. 트랜잭션 처리 부재
**문제**: 여러 액션 중 하나가 실패해도 이전 액션들이 그대로 유지됨
#### 현재 상황:
```
저장액션1 (성공) → 저장액션2 (실패)
결과: 저장액션1의 데이터는 DB에 그대로 남아있음 (데이터 불일치)
```
#### 예시 시나리오:
1. **고객정보 저장** (성공)
2. **주문정보 저장** (실패)
3. **결제정보 저장** (실행되지 않음)
→ 고객정보만 저장되어 데이터 정합성 깨짐
### 2. 조건부 실행 로직 부재
**문제**: AND/OR 조건에 따른 유연한 액션 실행이 불가능
#### 현재 한계:
- 모든 액션이 순차적으로 실행됨
- 하나 실패하면 전체 중단
- 대안 액션 실행 불가
#### 원하는 동작:
```
액션그룹1: (저장액션1 AND 저장액션2) OR 저장액션3
→ 저장액션1,2가 모두 성공하면 완료
→ 둘 중 하나라도 실패하면 저장액션3 실행
```
## 🎯 해결방안 설계
## Phase 1: 트랜잭션 관리 시스템 구축
### 1.1 트랜잭션 단위 정의
```typescript
// frontend/types/control-management.ts
export interface TransactionGroup {
id: string;
name: string;
description?: string;
actions: DataflowAction[];
rollbackStrategy: RollbackStrategy;
executionMode: "sequential" | "parallel";
onFailure: FailureHandling;
}
export type RollbackStrategy =
| "none" // 롤백 안함 (현재 방식)
| "partial" // 실패한 액션만 롤백
| "complete"; // 전체 트랜잭션 롤백
export type FailureHandling =
| "stop" // 실패 시 중단 (현재 방식)
| "continue" // 실패해도 계속 진행
| "alternative"; // 대안 액션 실행
```
### 1.2 조건부 실행 로직 구조
```typescript
export interface ConditionalExecutionPlan {
id: string;
name: string;
conditions: ExecutionCondition[];
logic: "AND" | "OR" | "CUSTOM";
customLogic?: string; // "(A AND B) OR (C AND D)"
}
export interface ExecutionCondition {
id: string;
type: "action_group" | "validation" | "data_check";
// 액션 그룹 조건
actionGroup?: TransactionGroup;
// 검증 조건
validation?: {
field: string;
operator: ConditionOperator;
value: unknown;
};
// 성공/실패 조건
expectedResult: "success" | "failure" | "any";
}
```
### 1.3 액션 실행 결과 추적
```typescript
export interface ActionExecutionResult {
actionId: string;
transactionId: string;
status: "pending" | "running" | "success" | "failed" | "rolled_back";
startTime: Date;
endTime?: Date;
result?: unknown;
error?: {
code: string;
message: string;
details?: unknown;
};
rollbackData?: unknown; // 롤백을 위한 데이터
}
export interface TransactionExecutionState {
transactionId: string;
status:
| "pending"
| "running"
| "success"
| "failed"
| "rolling_back"
| "rolled_back";
actions: ActionExecutionResult[];
rollbackActions?: ActionExecutionResult[];
startTime: Date;
endTime?: Date;
}
```
## Phase 2: 고급 조건부 실행 시스템
### 2.1 조건부 액션 그룹 정의
```typescript
export interface ConditionalActionGroup {
id: string;
name: string;
description?: string;
// 실행 조건
executionCondition: {
type: "always" | "conditional" | "fallback";
conditions?: DataflowCondition[];
logic?: "AND" | "OR";
};
// 액션들
actions: DataflowAction[];
// 성공/실패 조건 정의
successCriteria: {
type: "all_success" | "any_success" | "custom";
customLogic?: string; // "action1 AND (action2 OR action3)"
};
// 다음 단계 정의
onSuccess?: {
nextGroup?: string;
completeTransaction?: boolean;
};
onFailure?: {
retryCount?: number;
fallbackGroup?: string;
rollbackStrategy?: RollbackStrategy;
};
}
```
### 2.2 복잡한 실행 계획 예시
```typescript
// 예시: 주문 처리 시스템
const orderProcessingPlan: ConditionalExecutionPlan = {
id: "order_processing",
name: "주문 처리",
conditions: [
{
id: "primary_payment",
type: "action_group",
actionGroup: {
id: "payment_group_1",
name: "주결제 수단",
actions: [
{
type: "database",
operation: "UPDATE",
tableName: "customer" /* ... */,
},
{
type: "database",
operation: "INSERT",
tableName: "orders" /* ... */,
},
{ type: "api", endpoint: "/payment/card" /* ... */ },
],
rollbackStrategy: "complete",
executionMode: "sequential",
},
expectedResult: "success",
},
{
id: "alternative_payment",
type: "action_group",
actionGroup: {
id: "payment_group_2",
name: "대안 결제 수단",
actions: [
{ type: "api", endpoint: "/payment/bank" /* ... */ },
{
type: "database",
operation: "UPDATE",
tableName: "orders" /* ... */,
},
],
rollbackStrategy: "complete",
executionMode: "sequential",
},
expectedResult: "success",
},
],
logic: "OR", // primary_payment OR alternative_payment
customLogic: "primary_payment OR alternative_payment",
};
```
## Phase 3: 트랜잭션 실행 엔진 구현
### 3.1 트랜잭션 매니저 클래스
```typescript
// frontend/lib/services/transactionManager.ts
export class TransactionManager {
private activeTransactions: Map<string, TransactionExecutionState> =
new Map();
private rollbackHandlers: Map<string, RollbackHandler[]> = new Map();
/**
* 트랜잭션 실행
*/
async executeTransaction(
plan: ConditionalExecutionPlan,
context: ExtendedControlContext
): Promise<TransactionExecutionResult> {
const transactionId = this.generateTransactionId();
const state: TransactionExecutionState = {
transactionId,
status: "pending",
actions: [],
startTime: new Date(),
};
this.activeTransactions.set(transactionId, state);
try {
state.status = "running";
// 조건부 실행 로직 평가
const executionResult = await this.evaluateExecutionPlan(
plan,
context,
transactionId
);
if (executionResult.success) {
state.status = "success";
} else {
state.status = "failed";
// 실패 시 롤백 처리
if (executionResult.requiresRollback) {
await this.rollbackTransaction(transactionId);
}
}
state.endTime = new Date();
return executionResult;
} catch (error) {
state.status = "failed";
state.endTime = new Date();
await this.rollbackTransaction(transactionId);
throw error;
} finally {
// 트랜잭션 정리 (일정 시간 후)
setTimeout(() => this.cleanupTransaction(transactionId), 300000); // 5분 후
}
}
/**
* 실행 계획 평가
*/
private async evaluateExecutionPlan(
plan: ConditionalExecutionPlan,
context: ExtendedControlContext,
transactionId: string
): Promise<TransactionExecutionResult> {
const results: Map<string, boolean> = new Map();
// 각 조건별로 실행
for (const condition of plan.conditions) {
const result = await this.executeCondition(
condition,
context,
transactionId
);
results.set(condition.id, result.success);
// 실패 시 즉시 중단할지 결정
if (!result.success && this.shouldStopOnFailure(plan, condition)) {
return {
success: false,
message: `조건 ${condition.id} 실행 실패`,
requiresRollback: true,
results: Array.from(results.entries()),
};
}
}
// 전체 로직 평가
const overallSuccess = this.evaluateLogic(
plan.logic,
plan.customLogic,
results
);
return {
success: overallSuccess,
message: overallSuccess ? "모든 조건 실행 성공" : "조건 실행 실패",
requiresRollback: !overallSuccess,
results: Array.from(results.entries()),
};
}
/**
* 개별 조건 실행
*/
private async executeCondition(
condition: ExecutionCondition,
context: ExtendedControlContext,
transactionId: string
): Promise<{ success: boolean; result?: unknown }> {
if (condition.type === "action_group" && condition.actionGroup) {
return await this.executeActionGroup(
condition.actionGroup,
context,
transactionId
);
}
// 다른 조건 타입들 처리...
return { success: true };
}
/**
* 액션 그룹 실행
*/
private async executeActionGroup(
group: TransactionGroup,
context: ExtendedControlContext,
transactionId: string
): Promise<{ success: boolean; result?: unknown }> {
const state = this.activeTransactions.get(transactionId)!;
const groupResults: ActionExecutionResult[] = [];
try {
if (group.executionMode === "sequential") {
// 순차 실행
for (const action of group.actions) {
const result = await this.executeAction(
action,
context,
transactionId
);
groupResults.push(result);
state.actions.push(result);
if (result.status === "failed" && group.onFailure === "stop") {
throw new Error(
`액션 ${action.id} 실행 실패: ${result.error?.message}`
);
}
}
} else {
// 병렬 실행
const promises = group.actions.map((action) =>
this.executeAction(action, context, transactionId)
);
const results = await Promise.allSettled(promises);
results.forEach((result, index) => {
const actionResult: ActionExecutionResult = {
actionId: group.actions[index].id,
transactionId,
status:
result.status === "fulfilled" && result.value.status === "success"
? "success"
: "failed",
startTime: new Date(),
endTime: new Date(),
result:
result.status === "fulfilled" ? result.value.result : undefined,
error:
result.status === "rejected"
? { code: "EXECUTION_ERROR", message: result.reason }
: undefined,
};
groupResults.push(actionResult);
state.actions.push(actionResult);
});
}
// 성공 기준 평가
const success = this.evaluateSuccessCriteria(
group.successCriteria,
groupResults
);
if (!success && group.rollbackStrategy === "complete") {
// 그룹 내 모든 액션 롤백
await this.rollbackActionGroup(group, groupResults, transactionId);
}
return { success, result: groupResults };
} catch (error) {
// 오류 발생 시 롤백
if (group.rollbackStrategy !== "none") {
await this.rollbackActionGroup(group, groupResults, transactionId);
}
return { success: false, result: error };
}
}
/**
* 개별 액션 실행
*/
private async executeAction(
action: DataflowAction,
context: ExtendedControlContext,
transactionId: string
): Promise<ActionExecutionResult> {
const result: ActionExecutionResult = {
actionId: action.id,
transactionId,
status: "running",
startTime: new Date(),
};
try {
// 액션 타입별 실행
let executionResult: unknown;
switch (action.type) {
case "database":
executionResult = await this.executeDatabaseAction(action, context);
// 롤백 데이터 저장 (UPDATE/DELETE의 경우)
if (action.operation === "UPDATE" || action.operation === "DELETE") {
result.rollbackData = await this.captureRollbackData(
action,
context
);
}
break;
case "api":
executionResult = await this.executeApiAction(action, context);
break;
case "notification":
executionResult = await this.executeNotificationAction(
action,
context
);
break;
default:
throw new Error(`Unsupported action type: ${action.type}`);
}
result.status = "success";
result.result = executionResult;
result.endTime = new Date();
return result;
} catch (error) {
result.status = "failed";
result.error = {
code: "ACTION_EXECUTION_ERROR",
message: error.message,
details: error,
};
result.endTime = new Date();
return result;
}
}
/**
* 트랜잭션 롤백
*/
private async rollbackTransaction(transactionId: string): Promise<void> {
const state = this.activeTransactions.get(transactionId);
if (!state) return;
state.status = "rolling_back";
// 성공한 액션들을 역순으로 롤백
const successfulActions = state.actions
.filter((action) => action.status === "success")
.reverse();
const rollbackResults: ActionExecutionResult[] = [];
for (const action of successfulActions) {
try {
const rollbackResult = await this.rollbackAction(action);
rollbackResults.push(rollbackResult);
} catch (error) {
console.error(`롤백 실패: ${action.actionId}`, error);
// 롤백 실패는 로그만 남기고 계속 진행
}
}
state.rollbackActions = rollbackResults;
state.status = "rolled_back";
state.endTime = new Date();
}
/**
* 개별 액션 롤백
*/
private async rollbackAction(
action: ActionExecutionResult
): Promise<ActionExecutionResult> {
// 롤백 액션 실행
// 이 부분은 액션 타입별로 구체적인 롤백 로직 구현 필요
return {
actionId: `rollback_${action.actionId}`,
transactionId: action.transactionId,
status: "success",
startTime: new Date(),
endTime: new Date(),
result: "롤백 완료",
};
}
/**
* 로직 평가 (AND/OR/CUSTOM)
*/
private evaluateLogic(
logic: "AND" | "OR" | "CUSTOM",
customLogic: string | undefined,
results: Map<string, boolean>
): boolean {
switch (logic) {
case "AND":
return Array.from(results.values()).every((result) => result);
case "OR":
return Array.from(results.values()).some((result) => result);
case "CUSTOM":
if (!customLogic) return false;
return this.evaluateCustomLogic(customLogic, results);
default:
return false;
}
}
/**
* 커스텀 로직 평가
*/
private evaluateCustomLogic(
logic: string,
results: Map<string, boolean>
): boolean {
// "(A AND B) OR (C AND D)" 형태의 로직 파싱 및 평가
let expression = logic;
// 변수를 실제 결과값으로 치환
for (const [id, result] of results) {
expression = expression.replace(
new RegExp(`\\b${id}\\b`, "g"),
result.toString()
);
}
// AND/OR를 JavaScript 연산자로 변환
expression = expression
.replace(/\bAND\b/g, "&&")
.replace(/\bOR\b/g, "||")
.replace(/\btrue\b/g, "true")
.replace(/\bfalse\b/g, "false");
try {
// 안전한 평가를 위해 Function 생성자 사용
return new Function(`return ${expression}`)();
} catch (error) {
console.error("커스텀 로직 평가 오류:", error);
return false;
}
}
// ... 기타 헬퍼 메서드들
}
export interface TransactionExecutionResult {
success: boolean;
message: string;
requiresRollback: boolean;
results: [string, boolean][];
}
export interface RollbackHandler {
actionId: string;
rollbackFn: () => Promise<void>;
}
```
### 3.2 데이터베이스 액션 실행기
```typescript
// frontend/lib/services/databaseActionExecutor.ts
export class DatabaseActionExecutor {
/**
* 데이터베이스 액션 실행
*/
static async executeAction(
action: DataflowAction,
context: ExtendedControlContext
): Promise<unknown> {
const { tableName, operation, fields, conditions } = action;
switch (operation) {
case "INSERT":
return await this.executeInsert(tableName!, fields!, context);
case "UPDATE":
return await this.executeUpdate(
tableName!,
fields!,
conditions!,
context
);
case "DELETE":
return await this.executeDelete(tableName!, conditions!, context);
case "SELECT":
return await this.executeSelect(
tableName!,
fields!,
conditions!,
context
);
default:
throw new Error(`Unsupported database operation: ${operation}`);
}
}
/**
* 롤백 데이터 캡처
*/
static async captureRollbackData(
action: DataflowAction,
context: ExtendedControlContext
): Promise<unknown> {
const { tableName, conditions } = action;
if (action.operation === "UPDATE" || action.operation === "DELETE") {
// 변경 전 데이터를 조회하여 저장
return await this.executeSelect(tableName!, ["*"], conditions!, context);
}
return null;
}
/**
* 롤백 실행
*/
static async executeRollback(
originalAction: ActionExecutionResult,
rollbackData: unknown
): Promise<void> {
// 원본 액션의 반대 작업 수행
// INSERT -> DELETE
// UPDATE -> UPDATE (원본 데이터로)
// DELETE -> INSERT (원본 데이터로)
// 구체적인 롤백 로직 구현...
}
// ... 개별 operation 구현 메서드들
}
```
## Phase 4: 사용자 인터페이스 개선
### 4.1 조건부 실행 설정 UI
```typescript
// frontend/components/screen/config-panels/ConditionalExecutionPanel.tsx
export const ConditionalExecutionPanel: React.FC<{
config: ButtonDataflowConfig;
onConfigChange: (config: ButtonDataflowConfig) => void;
}> = ({ config, onConfigChange }) => {
return (
<div className="space-y-6">
{/* 실행 모드 선택 */}
<div>
<Label>실행 모드</Label>
<Select>
<SelectItem value="simple">단순 실행</SelectItem>
<SelectItem value="conditional">조건부 실행</SelectItem>
<SelectItem value="transaction">트랜잭션 실행</SelectItem>
</Select>
</div>
{/* 트랜잭션 설정 */}
<div>
<Label>트랜잭션 롤백 전략</Label>
<Select>
<SelectItem value="none">롤백 안함</SelectItem>
<SelectItem value="partial">부분 롤백</SelectItem>
<SelectItem value="complete">전체 롤백</SelectItem>
</Select>
</div>
{/* 액션 그룹 설정 */}
<div>
<Label>액션 그룹</Label>
<ActionGroupEditor />
</div>
{/* 조건부 로직 설정 */}
<div>
<Label>실행 조건</Label>
<ConditionalLogicEditor />
</div>
</div>
);
};
```
### 4.2 트랜잭션 모니터링 UI
```typescript
// frontend/components/screen/TransactionMonitor.tsx
export const TransactionMonitor: React.FC = () => {
const [transactions, setTransactions] = useState<TransactionExecutionState[]>(
[]
);
return (
<div className="space-y-4">
<h3>트랜잭션 실행 현황</h3>
{transactions.map((transaction) => (
<Card key={transaction.transactionId}>
<CardHeader>
<div className="flex justify-between">
<span>트랜잭션 {transaction.transactionId}</span>
<Badge variant={getStatusVariant(transaction.status)}>
{transaction.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{transaction.actions.map((action) => (
<div key={action.actionId} className="flex justify-between">
<span>{action.actionId}</span>
<Badge variant={getStatusVariant(action.status)}>
{action.status}
</Badge>
</div>
))}
</div>
{transaction.status === "failed" && (
<Button
onClick={() => retryTransaction(transaction.transactionId)}
>
재시도
</Button>
)}
</CardContent>
</Card>
))}
</div>
);
};
```
## 📋 구현 우선순위
### 🔥 즉시 구현 (Critical)
1. **TransactionManager 기본 구조** - 트랜잭션 단위 실행
2. **롤백 메커니즘** - 실패 시 이전 상태 복구
3. **AND/OR 조건부 실행** - 기본적인 조건부 로직
### ⚡ 단기 구현 (High)
4. **데이터베이스 액션 실행기** - 실제 DB 작업 처리
5. **에러 핸들링 강화** - 상세한 오류 정보 제공
6. **트랜잭션 상태 추적** - 실행 과정 모니터링
### 📅 중장기 구현 (Medium)
7. **복잡한 조건부 로직** - 커스텀 로직 지원
8. **병렬 실행 지원** - 성능 최적화
9. **모니터링 UI** - 실시간 트랜잭션 추적
## 💡 기대 효과
### 데이터 일관성 보장
- 트랜잭션 롤백으로 부분 실행 방지
- All-or-Nothing 원칙 적용
- 데이터 정합성 확보
### 유연한 비즈니스 로직
- 복잡한 조건부 실행 지원
- 대안 액션 자동 실행
- 비즈니스 요구사항 정확한 반영
### 시스템 안정성 향상
- 실패 시 자동 복구
- 상세한 실행 로그
- 문제 상황 신속 파악
이 개선방안에 대한 의견이나 우선순위 조정이 필요한 부분이 있으시면 말씀해 주세요!

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,301 @@
# 테이블 타입 관리 개선 사용 가이드
## 🎯 개선 내용 요약
테이블 타입 관리 시스템이 다음과 같이 개선되었습니다:
### 주요 변경사항
- **용어 통일**: 웹 타입 → **입력 타입**
- **타입 단순화**: 20개 → **8개 핵심 타입**
- **DB 타입 제거**: dbType 필드 완전 삭제
- **저장 방식**: 모든 사용자 정의 컬럼을 **VARCHAR(500)로 통일**
- **형변환**: 애플리케이션 레벨에서 입력 타입별 처리
## 📋 8개 핵심 입력 타입
| 번호 | 입력 타입 | 설명 | 예시 | 저장 형태 |
| ---- | ---------- | ---------- | -------------------- | ------------ |
| 1 | `text` | 텍스트 | 이름, 제목, 설명 | "홍길동" |
| 2 | `number` | 숫자 | 수량, 건수, 순번 | "15000.50" |
| 3 | `date` | 날짜 | 생성일, 완료일, 기한 | "2024-03-15" |
| 4 | `code` | 코드 | 상태코드, 유형코드 | "ACTIVE" |
| 5 | `entity` | 엔티티 | 고객선택, 제품선택 | "123" |
| 6 | `select` | 선택박스 | 드롭다운 목록 | "option1" |
| 7 | `checkbox` | 체크박스 | Y/N, 사용여부 | "Y" / "N" |
| 8 | `radio` | 라디오버튼 | 단일선택 옵션 | "high" |
## 🚀 마이그레이션 실행
### 1. 데이터베이스 마이그레이션
```bash
# 백엔드 디렉토리에서 실행
cd backend-node
node scripts/migrate-to-input-types.js
```
**실행 결과:**
- `web_type``input_type` 컬럼명 변경
- `db_type` 컬럼 제거
- 기존 웹 타입을 8개 입력 타입으로 자동 변환
- 기존 데이터 백업 (`table_type_columns_backup`)
### 2. 서비스 재시작
```bash
# 백엔드 서비스 재시작
npm run dev
# 프론트엔드 서비스 재시작 (별도 터미널)
cd ../frontend
npm run dev
```
## 💻 사용법
### 1. 테이블 생성
새로운 테이블 생성 시 자동으로 기본 컬럼이 추가됩니다:
```sql
CREATE TABLE "products" (
-- 기본 컬럼 (자동 추가)
"id" serial PRIMARY KEY,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(100),
"company_code" varchar(50) DEFAULT '*',
-- 사용자 정의 컬럼 (모두 VARCHAR(500))
"product_name" varchar(500), -- 입력타입: text
"price" varchar(500), -- 입력타입: number
"launch_date" varchar(500), -- 입력타입: date
"is_active" varchar(500) -- 입력타입: checkbox
);
```
### 2. 데이터 입력/조회
#### 백엔드에서 데이터 처리
```typescript
import { InputTypeService } from "./services/inputTypeService";
// 데이터 저장 시
const inputData = {
product_name: "테스트 제품",
price: 15000.5,
launch_date: new Date("2024-03-15"),
is_active: true,
};
const columnTypes = {
product_name: "text",
price: "number",
launch_date: "date",
is_active: "checkbox",
};
// 저장용 변환 (모든 값을 문자열로)
const convertedData = InputTypeService.convertBatchForStorage(
inputData,
columnTypes
);
// 결과: { product_name: "테스트 제품", price: "15000.5", launch_date: "2024-03-15", is_active: "Y" }
// 데이터베이스에 저장
await prisma.products.create({ data: convertedData });
```
#### 데이터 조회 시
```typescript
// 데이터베이스에서 조회 (모든 값이 문자열)
const rawData = await prisma.products.findFirst();
// 결과: { product_name: "테스트 제품", price: "15000.5", launch_date: "2024-03-15", is_active: "Y" }
// 표시용 변환 (적절한 타입으로)
const displayData = InputTypeService.convertBatchForDisplay(
rawData,
columnTypes
);
// 결과: { product_name: "테스트 제품", price: 15000.5, launch_date: "2024-03-15", is_active: true }
```
### 3. 프론트엔드에서 사용
#### 테이블 관리 화면
1. **관리자 > 테이블 관리** 메뉴 접속
2. 테이블 선택 후 컬럼 목록 확인
3. **입력 타입** 컬럼에서 8개 타입 중 선택
4. **DB 타입** 컬럼은 제거됨 (더 이상 표시되지 않음)
#### 화면 관리 시스템 연동
```typescript
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
// 입력 타입 옵션 사용
const inputTypeSelect = (
<Select>
{INPUT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label} - {option.description}
</SelectItem>
))}
</Select>
);
```
## 🧪 테스트
### 전체 시스템 테스트
```bash
cd backend-node
node scripts/test-input-type-system.js
```
**테스트 내용:**
- ✅ 테이블 생성 (기본 컬럼 자동 추가)
- ✅ VARCHAR(500) 통일 저장
- ✅ 입력 타입별 형변환
- ✅ 배치 데이터 처리
- ✅ 입력값 검증
- ✅ 성능 테스트
## 🔧 개발자 가이드
### 새로운 입력 타입 추가
1. **타입 정의 업데이트**
```typescript
// frontend/types/input-types.ts & backend-node/src/types/input-types.ts
export type InputType =
| "text"
| "number"
| "date"
| "code"
| "entity"
| "select"
| "checkbox"
| "radio"
| "new_type"; // 새 타입 추가
```
2. **변환 로직 추가**
```typescript
// backend-node/src/services/inputTypeService.ts
case "new_type":
return processNewType(value);
```
3. **UI 옵션 추가**
```typescript
// frontend/types/input-types.ts
export const INPUT_TYPE_OPTIONS = [
// ... 기존 옵션들
{
value: "new_type",
label: "새 타입",
description: "새로운 입력 타입",
category: "basic",
},
];
```
### API 엔드포인트
#### 입력 타입 변경
```http
PUT /api/table-management/tables/{tableName}/columns/{columnName}/input-type
Content-Type: application/json
{
"inputType": "number",
"detailSettings": "{}"
}
```
#### 컬럼 입력 타입 조회
```http
GET /api/table-management/tables/{tableName}/input-types
```
## ⚠️ 주의사항
### 1. 데이터 검증 강화 필요
VARCHAR 통일 방식에서는 애플리케이션 레벨 검증이 중요합니다:
```typescript
// 반드시 검증 후 저장
const validation = InputTypeService.validate(value, inputType);
if (!validation.isValid) {
throw new Error(validation.message);
}
```
### 2. 기존 데이터 호환성
- **기존 테이블**: 현재 타입 구조 유지
- **신규 테이블**: 새로운 입력 타입 체계 적용
- **점진적 전환**: 필요에 따라 기존 테이블도 단계적 전환
### 3. 성능 고려사항
- 대용량 데이터 처리 시 배치 변환 사용
- 자주 사용되는 변환 결과는 캐싱 고려
- 복잡한 검증 로직은 비동기 처리
## 🆘 문제 해결
### 마이그레이션 실패 시 롤백
```sql
-- 1. 기존 테이블 삭제
DROP TABLE table_type_columns;
-- 2. 백업에서 복원
ALTER TABLE table_type_columns_backup RENAME TO table_type_columns;
```
### 입력 타입 변환 오류
```typescript
// 안전한 변환을 위한 try-catch 사용
try {
const converted = InputTypeService.convertForStorage(value, inputType);
return converted;
} catch (error) {
logger.error("변환 실패", { value, inputType, error });
return String(value); // 기본값으로 문자열 반환
}
```
### UI에서 입력 타입이 표시되지 않는 경우
1. 브라우저 캐시 클리어
2. 프론트엔드 서비스 재시작
3. `INPUT_TYPE_OPTIONS` import 확인
## 📞 지원
문제가 발생하거나 추가 기능이 필요한 경우:
1. **로그 확인**: 백엔드 콘솔에서 상세 로그 확인
2. **테스트 실행**: `test-input-type-system.js`로 시스템 상태 점검
3. **데이터 백업**: 중요한 변경 전 항상 백업 실행
---
**🎉 테이블 타입 관리 개선으로 더욱 유연하고 안정적인 시스템을 경험하세요!**

View File

@ -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**: 실시간 협업 기능
---
이 가이드는 화면관리 검증 시스템의 핵심 기능과 사용법을 다룹니다. 추가 질문이나 개선 제안은 개발팀에 문의해주세요! 🚀