테이블 추가기능 수정사항

This commit is contained in:
kjs 2025-09-23 10:40:21 +09:00
parent 474cc33aee
commit e653effac0
19 changed files with 1931 additions and 201 deletions

View File

@ -42,11 +42,17 @@ export class DDLController {
ip: req.ip, ip: req.ip,
}); });
// inputType을 webType으로 변환 (레거시 호환성)
const processedColumns = columns.map((col) => ({
...col,
webType: (col.inputType || col.webType || "text") as any,
}));
// DDL 실행 서비스 호출 // DDL 실행 서비스 호출
const ddlService = new DDLExecutionService(); const ddlService = new DDLExecutionService();
const result = await ddlService.createTable( const result = await ddlService.createTable(
tableName, tableName,
columns, processedColumns,
userCompanyCode, userCompanyCode,
userId, userId,
description description
@ -112,12 +118,12 @@ export class DDLController {
return; return;
} }
if (!column || !column.name || !column.webType) { if (!column || !column.name || (!column.inputType && !column.webType)) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: { error: {
code: "INVALID_INPUT", code: "INVALID_INPUT",
details: "컬럼명과 타입이 필요합니다.", details: "컬럼명과 입력타입이 필요합니다.",
}, },
}); });
return; return;
@ -131,11 +137,17 @@ export class DDLController {
ip: req.ip, ip: req.ip,
}); });
// inputType을 webType으로 변환 (레거시 호환성)
const processedColumn = {
...column,
webType: (column.inputType || column.webType || "text") as any,
};
// DDL 실행 서비스 호출 // DDL 실행 서비스 호출
const ddlService = new DDLExecutionService(); const ddlService = new DDLExecutionService();
const result = await ddlService.addColumn( const result = await ddlService.addColumn(
tableName, tableName,
column, processedColumn,
userCompanyCode, userCompanyCode,
userId userId
); );

View File

@ -443,24 +443,24 @@ export async function updateTableLabel(
} }
/** /**
* *
*/ */
export async function updateColumnWebType( export async function updateColumnInputType(
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response res: Response
): Promise<void> { ): Promise<void> {
try { try {
const { tableName, columnName } = req.params; const { tableName, columnName } = req.params;
const { webType, detailSettings, inputType } = req.body; const { inputType, detailSettings } = req.body;
logger.info( logger.info(
`=== 컬럼 타입 설정 시작: ${tableName}.${columnName} = ${webType} ===` `=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType} ===`
); );
if (!tableName || !columnName || !webType) { if (!tableName || !columnName || !inputType) {
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
success: false, success: false,
message: "테이블명, 컬럼명, 타입이 모두 필요합니다.", message: "테이블명, 컬럼명, 입력 타입이 모두 필요합니다.",
error: { error: {
code: "MISSING_PARAMETERS", code: "MISSING_PARAMETERS",
details: "필수 파라미터가 누락되었습니다.", details: "필수 파라미터가 누락되었습니다.",
@ -471,33 +471,32 @@ export async function updateColumnWebType(
} }
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
await tableManagementService.updateColumnWebType( await tableManagementService.updateColumnInputType(
tableName, tableName,
columnName, columnName,
webType, inputType,
detailSettings, detailSettings
inputType
); );
logger.info( logger.info(
`컬럼 타입 설정 완료: ${tableName}.${columnName} = ${webType}` `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}`
); );
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
success: true, success: true,
message: "컬럼 타입이 성공적으로 설정되었습니다.", message: "컬럼 입력 타입이 성공적으로 설정되었습니다.",
data: null, data: null,
}; };
res.status(200).json(response); res.status(200).json(response);
} catch (error) { } catch (error) {
logger.error("컬럼 타입 설정 중 오류 발생:", error); logger.error("컬럼 입력 타입 설정 중 오류 발생:", error);
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
success: false, success: false,
message: "컬럼 타입 설정 중 오류가 발생했습니다.", message: "컬럼 입력 타입 설정 중 오류가 발생했습니다.",
error: { error: {
code: "WEB_TYPE_UPDATE_ERROR", code: "INPUT_TYPE_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error", details: error instanceof Error ? error.message : "Unknown error",
}, },
}; };
@ -866,16 +865,17 @@ export async function getColumnWebTypes(
} }
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
const webTypes = await tableManagementService.getColumnWebTypes(tableName); const inputTypes =
await tableManagementService.getColumnInputTypes(tableName);
logger.info( logger.info(
`컬럼 웹타입 정보 조회 완료: ${tableName}, ${webTypes.length}개 컬럼` `컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼`
); );
const response: ApiResponse<ColumnTypeInfo[]> = { const response: ApiResponse<ColumnTypeInfo[]> = {
success: true, success: true,
message: "컬럼 타입 정보를 성공적으로 조회했습니다.", message: "컬럼 입력타입 정보를 성공적으로 조회했습니다.",
data: webTypes, data: inputTypes,
}; };
res.status(200).json(response); res.status(200).json(response);
@ -1010,3 +1010,41 @@ export async function deleteTableData(
res.status(500).json(response); 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

@ -8,6 +8,7 @@ import {
getTableLabels, getTableLabels,
getColumnLabels, getColumnLabels,
updateColumnWebType, updateColumnWebType,
updateColumnInputType,
updateTableLabel, updateTableLabel,
getTableData, getTableData,
addTableData, addTableData,
@ -70,7 +71,7 @@ router.get("/tables/:tableName/labels", getTableLabels);
router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels); router.get("/tables/:tableName/columns/:columnName/labels", getColumnLabels);
/** /**
* * ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/web-type * PUT /api/table-management/tables/:tableName/columns/:columnName/web-type
*/ */
router.put( router.put(
@ -78,6 +79,15 @@ router.put(
updateColumnWebType updateColumnWebType
); );
/**
* ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/input-type
*/
router.put(
"/tables/:tableName/columns/:columnName/input-type",
updateColumnInputType
);
/** /**
* (PUT ) * (PUT )
* PUT /api/table-management/tables/:tableName/columns/:columnName * PUT /api/table-management/tables/:tableName/columns/:columnName

View File

@ -342,14 +342,11 @@ export class DDLExecutionService {
tableName: string, tableName: string,
columns: CreateColumnDefinition[] columns: CreateColumnDefinition[]
): string { ): string {
// 사용자 정의 컬럼들 // 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 통일
const columnDefinitions = columns const columnDefinitions = columns
.map((col) => { .map((col) => {
const postgresType = this.mapWebTypeToPostgresType( // 입력 타입과 관계없이 모든 컬럼을 VARCHAR(500)로 생성
col.webType, let definition = `"${col.name}" varchar(500)`;
col.length
);
let definition = `"${col.name}" ${postgresType}`;
if (!col.nullable) { if (!col.nullable) {
definition += " NOT NULL"; definition += " NOT NULL";
@ -363,13 +360,13 @@ export class DDLExecutionService {
}) })
.join(",\n "); .join(",\n ");
// 기본 컬럼들 (시스템 필수 컬럼) // 기본 컬럼들 (날짜는 TIMESTAMP, 나머지는 VARCHAR)
const baseColumns = ` const baseColumns = `
"id" serial PRIMARY KEY, "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(), "created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(), "updated_date" timestamp DEFAULT now(),
"writer" varchar(100), "writer" varchar(500),
"company_code" varchar(50) DEFAULT '*'`; "company_code" varchar(500)`;
// 최종 CREATE TABLE 쿼리 // 최종 CREATE TABLE 쿼리
return ` return `
@ -385,11 +382,8 @@ CREATE TABLE "${tableName}" (${baseColumns},
tableName: string, tableName: string,
column: CreateColumnDefinition column: CreateColumnDefinition
): string { ): string {
const postgresType = this.mapWebTypeToPostgresType( // 새로 추가되는 컬럼도 VARCHAR(500)로 통일
column.webType, let definition = `"${column.name}" varchar(500)`;
column.length
);
let definition = `"${column.name}" ${postgresType}`;
if (!column.nullable) { if (!column.nullable) {
definition += " NOT NULL"; definition += " NOT NULL";
@ -403,23 +397,27 @@ CREATE TABLE "${tableName}" (${baseColumns},
} }
/** /**
* PostgreSQL * 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 { private mapWebTypeToPostgresType(webType: WebType, length?: number): string {
const mapping = WEB_TYPE_TO_POSTGRES_MAP[webType]; // 레거시 지원을 위해 유지하되, VARCHAR(500)로 통일
logger.info(`레거시 웹타입 사용: ${webType} → varchar(500)로 변환`);
if (!mapping) { return "varchar(500)";
logger.warn(`알 수 없는 웹타입: ${webType}, text로 대체`);
return "text";
}
if (mapping.supportsLength && length && length > 0) {
if (mapping.postgresType === "varchar") {
return `varchar(${length})`;
}
}
return mapping.postgresType;
} }
/** /**
@ -472,6 +470,127 @@ CREATE TABLE "${tableName}" (${baseColumns},
}, },
}); });
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
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) { for (const column of columns) {
await tx.column_labels.upsert({ await tx.column_labels.upsert({
where: { where: {
@ -482,7 +601,7 @@ CREATE TABLE "${tableName}" (${baseColumns},
}, },
update: { update: {
column_label: column.label || column.name, column_label: column.label || column.name,
web_type: column.webType, input_type: this.convertWebTypeToInputType(column.webType || "text"),
detail_settings: JSON.stringify(column.detailSettings || {}), detail_settings: JSON.stringify(column.detailSettings || {}),
description: column.description, description: column.description,
display_order: column.order || 0, display_order: column.order || 0,
@ -493,7 +612,7 @@ CREATE TABLE "${tableName}" (${baseColumns},
table_name: tableName, table_name: tableName,
column_name: column.name, column_name: column.name,
column_label: column.label || column.name, column_label: column.label || column.name,
web_type: column.webType, input_type: this.convertWebTypeToInputType(column.webType || "text"),
detail_settings: JSON.stringify(column.detailSettings || {}), detail_settings: JSON.stringify(column.detailSettings || {}),
description: column.description, description: column.description,
display_order: column.order || 0, display_order: column.order || 0,
@ -505,6 +624,47 @@ CREATE TABLE "${tableName}" (${baseColumns},
} }
} }
/**
*
*/
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";
}
/** /**
* ( ) * ( )
*/ */

View File

@ -256,11 +256,11 @@ export class DDLSafetyValidator {
if (column.length !== undefined) { if (column.length !== undefined) {
if ( if (
!["text", "code", "email", "tel", "select", "radio"].includes( !["text", "code", "email", "tel", "select", "radio"].includes(
column.webType column.webType || "text"
) )
) { ) {
warnings.push( warnings.push(
`${prefix}${column.webType} 타입에서는 길이 설정이 무시됩니다.` `${prefix}${column.webType || "text"} 타입에서는 길이 설정이 무시됩니다.`
); );
} else if (column.length <= 0 || column.length > 65535) { } else if (column.length <= 0 || column.length > 65535) {
errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`); errors.push(`${prefix}길이는 1 이상 65535 이하여야 합니다.`);

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

@ -329,7 +329,7 @@ export class TableManagementService {
}, },
update: { update: {
column_label: settings.columnLabel, column_label: settings.columnLabel,
web_type: settings.webType, input_type: settings.inputType,
detail_settings: settings.detailSettings, detail_settings: settings.detailSettings,
code_category: settings.codeCategory, code_category: settings.codeCategory,
code_value: settings.codeValue, code_value: settings.codeValue,
@ -345,7 +345,7 @@ export class TableManagementService {
table_name: tableName, table_name: tableName,
column_name: columnName, column_name: columnName,
column_label: settings.columnLabel, column_label: settings.columnLabel,
web_type: settings.webType, input_type: settings.inputType,
detail_settings: settings.detailSettings, detail_settings: settings.detailSettings,
code_category: settings.codeCategory, code_category: settings.codeCategory,
code_value: settings.codeValue, code_value: settings.codeValue,
@ -626,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> { private generateDefaultDetailSettings(webType: string): Record<string, any> {
switch (webType) { switch (webType) {
@ -2363,22 +2479,21 @@ export class TableManagementService {
} }
/** /**
* ( ) * ( )
*/ */
async getColumnWebTypes(tableName: string): Promise<ColumnTypeInfo[]> { async getColumnInputTypes(tableName: string): Promise<ColumnTypeInfo[]> {
try { try {
logger.info(`컬럼 타입 정보 조회: ${tableName}`); logger.info(`컬럼 입력타입 정보 조회: ${tableName}`);
// table_type_columns에서 타입 정보 조회 // table_type_columns에서 입력타입 정보 조회
const rawWebTypes = await prisma.$queryRaw<any[]>` const rawInputTypes = await prisma.$queryRaw<any[]>`
SELECT SELECT
ttc.column_name as "columnName", ttc.column_name as "columnName",
ttc.column_name as "displayName", ttc.column_name as "displayName",
COALESCE(ttc.web_type, 'text') as "webType", COALESCE(ttc.input_type, 'text') as "inputType",
COALESCE(ttc.detail_settings, '{}') as "detailSettings", COALESCE(ttc.detail_settings, '{}') as "detailSettings",
ttc.is_nullable as "isNullable", ttc.is_nullable as "isNullable",
ic.data_type as "dataType", ic.data_type as "dataType"
ic.udt_name as "dbType"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
@ -2386,14 +2501,12 @@ export class TableManagementService {
ORDER BY ttc.display_order, ttc.column_name ORDER BY ttc.display_order, ttc.column_name
`; `;
const webTypes: ColumnTypeInfo[] = rawWebTypes.map((col) => ({ const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({
tableName: tableName, tableName: tableName,
columnName: col.columnName, columnName: col.columnName,
displayName: col.displayName, displayName: col.displayName,
dataType: col.dataType || "text", dataType: col.dataType || "varchar",
dbType: col.dbType || "text", inputType: col.inputType,
webType: col.webType,
inputType: "direct",
detailSettings: col.detailSettings, detailSettings: col.detailSettings,
description: "", // 필수 필드 추가 description: "", // 필수 필드 추가
isNullable: col.isNullable, isNullable: col.isNullable,
@ -2403,15 +2516,26 @@ export class TableManagementService {
})); }));
logger.info( logger.info(
`컬럼 웹타입 정보 조회 완료: ${tableName}, ${webTypes.length}개 컬럼` `컬럼 입력타입 정보 조회 완료: ${tableName}, ${inputTypes.length}개 컬럼`
); );
return webTypes; return inputTypes;
} catch (error) { } catch (error) {
logger.error(`컬럼 타입 정보 조회 실패: ${tableName}`, error); logger.error(`컬럼 입력타입 정보 조회 실패: ${tableName}`, error);
throw error; throw error;
} }
} }
/**
* 지원: 컬럼
* @deprecated getColumnInputTypes
*/
async getColumnWebTypes(tableName: string): Promise<ColumnTypeInfo[]> {
logger.warn(
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
);
return this.getColumnInputTypes(tableName);
}
/** /**
* *
*/ */

View File

@ -26,8 +26,10 @@ export interface CreateColumnDefinition {
name: string; name: string;
/** 컬럼 라벨 (화면 표시용) */ /** 컬럼 라벨 (화면 표시용) */
label?: string; label?: string;
/** 웹타입 */ /** 입력타입 */
webType: WebType; inputType?: string;
/** 웹타입 (레거시 호환용) */
webType?: WebType;
/** NULL 허용 여부 */ /** NULL 허용 여부 */
nullable?: boolean; nullable?: boolean;
/** 컬럼 길이 (text, code 타입에서 사용) */ /** 컬럼 길이 (text, code 타입에서 사용) */

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

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

View File

@ -12,8 +12,8 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- PORT=8080 - PORT=8080
- DATABASE_URL=postgresql://postgres:postgres@postgres-erp:5432/ilshin - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_SECRET=your-super-secret-jwt-key-change-in-production
- JWT_EXPIRES_IN=24h - JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:9771 - CORS_ORIGIN=http://localhost:9771
- CORS_CREDENTIALS=true - CORS_CREDENTIALS=true

View File

@ -12,7 +12,8 @@ import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement"; import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
@ -31,8 +32,7 @@ interface TableInfo {
interface ColumnTypeInfo { interface ColumnTypeInfo {
columnName: string; columnName: string;
displayName: string; displayName: string;
dbType: string; inputType: string; // webType → inputType 변경
webType: string;
detailSettings: string; detailSettings: string;
description: string; description: string;
isNullable: string; isNullable: string;
@ -148,38 +148,15 @@ export default function TableManagementPage() {
[], // 의존성 배열에서 referenceTableColumns 제거 [], // 의존성 배열에서 referenceTableColumns 제거
); );
// 웹 타입 옵션 (한글 직접 표시) // 입력 타입 옵션 (8개 핵심 타입)
const webTypeOptions = WEB_TYPE_OPTIONS_WITH_KEYS.map((option) => { const inputTypeOptions = INPUT_TYPE_OPTIONS.map((option) => ({
// 한국어 라벨 직접 매핑 (다국어 키값 대신)
const koreanLabels: Record<string, string> = {
text: "텍스트",
number: "숫자",
date: "날짜",
code: "코드",
entity: "엔티티",
textarea: "텍스트 영역",
select: "선택박스",
checkbox: "체크박스",
radio: "라디오버튼",
file: "파일",
decimal: "소수",
datetime: "날짜시간",
boolean: "불린",
email: "이메일",
tel: "전화번호",
url: "URL",
dropdown: "드롭다운",
};
return {
value: option.value, value: option.value,
label: koreanLabels[option.value] || option.value, label: option.label,
description: koreanLabels[option.value] || option.value, description: option.description,
}; }));
});
// 메모이제이션된 타입 옵션 // 메모이제이션된 입력타입 옵션
const memoizedWebTypeOptions = useMemo(() => webTypeOptions, [uiTexts]); const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴)
const referenceTableOptions = [ const referenceTableOptions = [
@ -254,14 +231,21 @@ export default function TableManagementPage() {
// 응답 상태 확인 // 응답 상태 확인
if (response.data.success) { if (response.data.success) {
const data = response.data.data; const data = response.data.data;
// 컬럼 데이터에 기본값 설정
const processedColumns = (data.columns || data).map((col: any) => ({
...col,
inputType: col.inputType || "text", // 기본값: text
}));
if (page === 1) { if (page === 1) {
setColumns(data.columns || data); setColumns(processedColumns);
setOriginalColumns(data.columns || data); setOriginalColumns(processedColumns);
} else { } 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("컬럼 정보를 성공적으로 로드했습니다."); toast.success("컬럼 정보를 성공적으로 로드했습니다.");
} else { } else {
toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다."); toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
@ -291,24 +275,24 @@ export default function TableManagementPage() {
[loadColumnTypes, pageSize, tables], [loadColumnTypes, pageSize, tables],
); );
// 타입 변경 // 입력 타입 변경
const handleWebTypeChange = useCallback( const handleInputTypeChange = useCallback(
(columnName: string, newWebType: string) => { (columnName: string, newInputType: string) => {
setColumns((prev) => setColumns((prev) =>
prev.map((col) => { prev.map((col) => {
if (col.columnName === columnName) { if (col.columnName === columnName) {
const webTypeOption = memoizedWebTypeOptions.find((option) => option.value === newWebType); const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType);
return { return {
...col, ...col,
webType: newWebType, inputType: newInputType,
detailSettings: webTypeOption?.description || col.detailSettings, detailSettings: inputTypeOption?.description || col.detailSettings,
}; };
} }
return col; return col;
}), }),
); );
}, },
[memoizedWebTypeOptions], [memoizedInputTypeOptions],
); );
// 상세 설정 변경 (코드/엔티티 타입용) // 상세 설정 변경 (코드/엔티티 타입용)
@ -418,7 +402,7 @@ export default function TableManagementPage() {
const columnSetting = { const columnSetting = {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명 columnLabel: column.displayName, // 사용자가 입력한 표시명
webType: column.webType || "text", inputType: column.inputType || "text",
detailSettings: column.detailSettings || "", detailSettings: column.detailSettings || "",
codeCategory: column.codeCategory || "", codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
@ -473,7 +457,7 @@ export default function TableManagementPage() {
const columnSettings = columns.map((column) => ({ const columnSettings = columns.map((column) => ({
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명 columnLabel: column.displayName, // 사용자가 입력한 표시명
webType: column.webType || "text", inputType: column.inputType || "text",
detailSettings: column.detailSettings || "", detailSettings: column.detailSettings || "",
description: column.description || "", description: column.description || "",
codeCategory: column.codeCategory || "", codeCategory: column.codeCategory || "",
@ -737,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="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-40 px-4"></div>
<div className="w-48 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 className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
</div> </div>
@ -772,21 +755,16 @@ export default function TableManagementPage() {
className="h-7 text-xs" className="h-7 text-xs"
/> />
</div> </div>
<div className="w-40 px-4">
<Badge variant="outline" className="text-xs">
{column.dbType}
</Badge>
</div>
<div className="w-48 px-4"> <div className="w-48 px-4">
<Select <Select
value={column.webType} value={column.inputType || "text"}
onValueChange={(value) => handleWebTypeChange(column.columnName, value)} onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
> >
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-7 text-xs">
<SelectValue /> <SelectValue placeholder="입력 타입 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{memoizedWebTypeOptions.map((option) => ( {memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
</SelectItem> </SelectItem>
@ -796,7 +774,7 @@ export default function TableManagementPage() {
</div> </div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}> <div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 웹 타입이 'code'인 경우 공통코드 선택 */} {/* 웹 타입이 'code'인 경우 공통코드 선택 */}
{column.webType === "code" && ( {column.inputType === "code" && (
<Select <Select
value={column.codeCategory || "none"} value={column.codeCategory || "none"}
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)} onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
@ -814,7 +792,7 @@ export default function TableManagementPage() {
</Select> </Select>
)} )}
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.webType === "entity" && ( {column.inputType === "entity" && (
<div className="space-y-1"> <div className="space-y-1">
{/* 🎯 Entity 타입 설정 - 가로 배치 */} {/* 🎯 Entity 타입 설정 - 가로 배치 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-2"> <div className="rounded-lg border border-blue-200 bg-blue-50 p-2">
@ -949,7 +927,7 @@ export default function TableManagementPage() {
</div> </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 className="flex h-7 items-center text-xs text-gray-400">-</div>
)} )}
</div> </div>

View File

@ -20,17 +20,17 @@ import { ddlApi } from "../../lib/api/ddl";
import { import {
AddColumnModalProps, AddColumnModalProps,
CreateColumnDefinition, CreateColumnDefinition,
WEB_TYPE_OPTIONS,
VALIDATION_RULES, VALIDATION_RULES,
RESERVED_WORDS, RESERVED_WORDS,
RESERVED_COLUMNS, RESERVED_COLUMNS,
} from "../../types/ddl"; } from "../../types/ddl";
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) { export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddColumnModalProps) {
const [column, setColumn] = useState<CreateColumnDefinition>({ const [column, setColumn] = useState<CreateColumnDefinition>({
name: "", name: "",
label: "", label: "",
webType: "text", inputType: "text",
nullable: true, nullable: true,
order: 0, order: 0,
}); });
@ -44,7 +44,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
setColumn({ setColumn({
name: "", name: "",
label: "", label: "",
webType: "text", inputType: "text",
nullable: true, nullable: true,
order: 0, order: 0,
}); });
@ -114,14 +114,14 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
} }
} }
// 타입 검증 // 입력타입 검증
if (!columnData.webType) { if (!columnData.inputType) {
errors.push("타입을 선택해주세요."); errors.push("입력타입을 선택해주세요.");
} }
// 길이 검증 (길이를 지원하는 타입인 경우) // 길이 검증 (길이를 지원하는 타입인 경우)
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === columnData.webType); const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === columnData.inputType);
if (webTypeOption?.supportsLength && columnData.length !== undefined) { if (inputTypeOption?.supportsLength && columnData.length !== undefined) {
if ( if (
columnData.length < VALIDATION_RULES.columnLength.min || columnData.length < VALIDATION_RULES.columnLength.min ||
columnData.length > VALIDATION_RULES.columnLength.max columnData.length > VALIDATION_RULES.columnLength.max
@ -135,19 +135,19 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
}; };
/** /**
* *
*/ */
const handleWebTypeChange = (webType: string) => { const handleInputTypeChange = (inputType: string) => {
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === webType); const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
const updates: Partial<CreateColumnDefinition> = { webType: webType as any }; const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정 // 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
if (webTypeOption?.supportsLength && !column.length && webTypeOption.defaultLength) { if (inputTypeOption?.supportsLength && !column.length && inputTypeOption.defaultLength) {
updates.length = webTypeOption.defaultLength; updates.length = inputTypeOption.defaultLength;
} }
// 길이를 지원하지 않는 타입이면 길이 제거 // 길이를 지원하지 않는 타입이면 길이 제거
if (!webTypeOption?.supportsLength) { if (!inputTypeOption?.supportsLength) {
updates.length = undefined; updates.length = undefined;
} }
@ -185,9 +185,9 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
/** /**
* *
*/ */
const isFormValid = validationErrors.length === 0 && column.name && column.webType; const isFormValid = validationErrors.length === 0 && column.name && column.inputType;
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === column.webType); const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
@ -248,14 +248,14 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</Label> </Label>
<Select value={column.webType} onValueChange={handleWebTypeChange} disabled={loading}> <Select value={column.inputType} onValueChange={handleInputTypeChange} disabled={loading}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="타입 선택" /> <SelectValue placeholder="입력타입 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{WEB_TYPE_OPTIONS.map((option) => ( {INPUT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
<div> <div>
<div className="font-medium">{option.label}</div> <div className="font-medium">{option.label}</div>
@ -280,13 +280,13 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
length: e.target.value ? parseInt(e.target.value) : undefined, length: e.target.value ? parseInt(e.target.value) : undefined,
}) })
} }
placeholder={webTypeOption?.defaultLength?.toString() || ""} placeholder={inputTypeOption?.defaultLength?.toString() || ""}
disabled={loading || !webTypeOption?.supportsLength} disabled={loading || !inputTypeOption?.supportsLength}
min={1} min={1}
max={65535} max={65535}
/> />
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{webTypeOption?.supportsLength ? "1-65535 범위에서 설정 가능" : "이 타입은 길이 설정이 불가능합니다"} {inputTypeOption?.supportsLength ? "1-65535 범위에서 설정 가능" : "이 타입은 길이 설정이 불가능합니다"}
</p> </p>
</div> </div>
</div> </div>

View File

@ -17,11 +17,11 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
CreateColumnDefinition, CreateColumnDefinition,
ColumnDefinitionTableProps, ColumnDefinitionTableProps,
WEB_TYPE_OPTIONS,
VALIDATION_RULES, VALIDATION_RULES,
RESERVED_WORDS, RESERVED_WORDS,
RESERVED_COLUMNS, RESERVED_COLUMNS,
} from "../../types/ddl"; } from "../../types/ddl";
import { INPUT_TYPE_OPTIONS } from "../../types/input-types";
export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) { export function ColumnDefinitionTable({ columns, onChange, disabled = false }: ColumnDefinitionTableProps) {
const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({}); const [validationErrors, setValidationErrors] = useState<Record<number, string[]>>({});
@ -105,14 +105,14 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
} }
} }
// 타입 검증 // 입력타입 검증
if (!column.webType) { if (!column.inputType) {
errors.push("타입을 선택해주세요"); errors.push("입력타입을 선택해주세요");
} }
// 길이 검증 (길이를 지원하는 타입인 경우) // 길이 검증 (길이를 지원하는 타입인 경우)
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === column.webType); const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
if (webTypeOption?.supportsLength && column.length !== undefined) { if (inputTypeOption?.supportsLength && column.length !== undefined) {
if (column.length < VALIDATION_RULES.columnLength.min || column.length > VALIDATION_RULES.columnLength.max) { if (column.length < VALIDATION_RULES.columnLength.min || column.length > VALIDATION_RULES.columnLength.max) {
errors.push(VALIDATION_RULES.columnLength.errorMessage); errors.push(VALIDATION_RULES.columnLength.errorMessage);
} }
@ -128,19 +128,19 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
}; };
/** /**
* *
*/ */
const handleWebTypeChange = (index: number, webType: string) => { const handleInputTypeChange = (index: number, inputType: string) => {
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === webType); const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === inputType);
const updates: Partial<CreateColumnDefinition> = { webType: webType as any }; const updates: Partial<CreateColumnDefinition> = { inputType: inputType as any };
// 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정 // 길이를 지원하는 타입이고 현재 길이가 없으면 기본값 설정
if (webTypeOption?.supportsLength && !columns[index].length && webTypeOption.defaultLength) { if (inputTypeOption?.supportsLength && !columns[index].length && inputTypeOption.defaultLength) {
updates.length = webTypeOption.defaultLength; updates.length = inputTypeOption.defaultLength;
} }
// 길이를 지원하지 않는 타입이면 길이 제거 // 길이를 지원하지 않는 타입이면 길이 제거
if (!webTypeOption?.supportsLength) { if (!inputTypeOption?.supportsLength) {
updates.length = undefined; updates.length = undefined;
} }
@ -172,7 +172,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
</TableHead> </TableHead>
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"> <TableHead className="w-[120px]">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</TableHead> </TableHead>
<TableHead className="w-[80px]"></TableHead> <TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
@ -183,7 +183,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{columns.map((column, index) => { {columns.map((column, index) => {
const webTypeOption = WEB_TYPE_OPTIONS.find((opt) => opt.value === column.webType); const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
const rowErrors = validationErrors[index] || []; const rowErrors = validationErrors[index] || [];
const hasRowError = rowErrors.length > 0; const hasRowError = rowErrors.length > 0;
@ -219,15 +219,15 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
<TableCell> <TableCell>
<Select <Select
value={column.webType} value={column.inputType}
onValueChange={(value) => handleWebTypeChange(index, value)} onValueChange={(value) => handleInputTypeChange(index, value)}
disabled={disabled} disabled={disabled}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="선택" /> <SelectValue placeholder="입력 타입 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{WEB_TYPE_OPTIONS.map((option) => ( {INPUT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
<div> <div>
<div className="font-medium">{option.label}</div> <div className="font-medium">{option.label}</div>
@ -260,8 +260,8 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
length: e.target.value ? parseInt(e.target.value) : undefined, length: e.target.value ? parseInt(e.target.value) : undefined,
}) })
} }
placeholder={webTypeOption?.defaultLength?.toString() || ""} placeholder={inputTypeOption?.defaultLength?.toString() || ""}
disabled={disabled || !webTypeOption?.supportsLength} disabled={disabled || !inputTypeOption?.supportsLength}
min={1} min={1}
max={65535} max={65535}
/> />

View File

@ -38,7 +38,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
{ {
name: "", name: "",
label: "", label: "",
webType: "text", inputType: "text",
nullable: true, nullable: true,
order: 1, order: 1,
}, },
@ -58,7 +58,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
{ {
name: "", name: "",
label: "", label: "",
webType: "text", inputType: "text",
nullable: true, nullable: true,
order: 1, order: 1,
}, },
@ -134,7 +134,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
{ {
name: "", name: "",
label: "", label: "",
webType: "text", inputType: "text",
nullable: true, nullable: true,
order: columns.length + 1, order: columns.length + 1,
}, },
@ -150,7 +150,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
return; return;
} }
const validColumns = columns.filter((col) => col.name && col.webType); const validColumns = columns.filter((col) => col.name && col.inputType);
if (validColumns.length === 0) { if (validColumns.length === 0) {
toast.error("최소 1개의 유효한 컬럼이 필요합니다."); toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
return; return;
@ -188,7 +188,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
return; return;
} }
const validColumns = columns.filter((col) => col.name && col.webType); const validColumns = columns.filter((col) => col.name && col.inputType);
if (validColumns.length === 0) { if (validColumns.length === 0) {
toast.error("최소 1개의 유효한 컬럼이 필요합니다."); toast.error("최소 1개의 유효한 컬럼이 필요합니다.");
return; return;
@ -220,7 +220,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
/** /**
* *
*/ */
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.webType); const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>

View File

@ -26,8 +26,10 @@ export interface CreateColumnDefinition {
name: string; name: string;
/** 컬럼 라벨 (화면 표시용) */ /** 컬럼 라벨 (화면 표시용) */
label?: string; label?: string;
/** 웹타입 */ /** 입력타입 */
webType: WebType; inputType: string;
/** 웹타입 (레거시 호환용) */
webType?: WebType;
/** NULL 허용 여부 */ /** NULL 허용 여부 */
nullable?: boolean; nullable?: boolean;
/** 컬럼 길이 (text, code 타입에서 사용) */ /** 컬럼 길이 (text, code 타입에서 사용) */

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

View File

@ -0,0 +1,472 @@
# 테이블 타입 관리 개선 계획서
## 🎯 개선 목표
현재 테이블 타입 관리 시스템의 용어 통일과 타입 단순화를 통해 사용자 친화적이고 유연한 시스템으로 개선합니다.
## 📋 주요 변경사항
### 1. 용어 변경
- **웹 타입(Web Type)** → **입력 타입(Input Type)**
- 사용자에게 더 직관적인 명칭으로 변경
### 2. 입력 타입 단순화
기존 20개 타입에서 **8개 핵심 타입**으로 단순화:
| 번호 | 입력 타입 | 설명 | 예시 |
| ---- | ---------- | ---------- | -------------------- |
| 1 | `text` | 텍스트 | 이름, 제목, 설명 |
| 2 | `number` | 숫자 | 수량, 건수, 순번 |
| 3 | `date` | 날짜 | 생성일, 완료일, 기한 |
| 4 | `code` | 코드 | 상태코드, 유형코드 |
| 5 | `entity` | 엔티티 | 고객선택, 제품선택 |
| 6 | `select` | 선택박스 | 드롭다운 목록 |
| 7 | `checkbox` | 체크박스 | Y/N, 사용여부 |
| 8 | `radio` | 라디오버튼 | 단일선택 옵션 |
### 3. DB 타입 제거
- 컬럼 정보에서 `dbType` 필드 제거
- 입력 타입만으로 데이터 처리 방식 결정
## 🛠️ 기술적 구현 방안
### 전체 VARCHAR 통일 방식 (확정)
**모든 신규 테이블의 사용자 정의 컬럼을 VARCHAR(500)로 생성하고 애플리케이션 레벨에서 형변환 처리**
#### 핵심 장점
- ✅ **최대 유연성**: 어떤 데이터든 타입 에러 없이 저장 가능
- ✅ **에러 제로**: 타입 불일치로 인한 DB 삽입/수정 에러 완전 차단
- ✅ **개발 속도**: 복잡한 타입 고민 없이 빠른 개발 가능
- ✅ **요구사항 대응**: 필드 성격 변경 시에도 스키마 수정 불필요
- ✅ **데이터 마이그레이션**: 기존 시스템 데이터 이관 시 100% 안전
#### 실제 예시
```sql
-- 기존 방식 (타입별 생성)
CREATE TABLE products (
id serial PRIMARY KEY,
name varchar(255), -- 텍스트
price numeric(10,2), -- 숫자
launch_date date, -- 날짜
is_active boolean -- 체크박스
);
-- 새로운 방식 (VARCHAR 통일)
CREATE TABLE products (
id serial PRIMARY KEY,
created_date timestamp DEFAULT now(),
updated_date timestamp DEFAULT now(),
company_code varchar(50) DEFAULT '*',
writer varchar(100),
-- 사용자 정의 컬럼들은 모두 VARCHAR(500)
name varchar(500), -- 입력타입: text
price varchar(500), -- 입력타입: number → "15000.50"
launch_date varchar(500), -- 입력타입: date → "2024-03-15"
is_active varchar(500) -- 입력타입: checkbox → "Y"/"N"
);
```
#### 애플리케이션 레벨 처리
````typescript
// 입력 타입별 형변환 및 검증
export class InputTypeProcessor {
// 저장 전 변환 (화면 → DB)
static convertForStorage(value: any, inputType: string): string {
if (value === null || value === undefined) return "";
switch (inputType) {
case "text":
return String(value);
case "number":
const num = parseFloat(String(value));
return isNaN(num) ? "0" : String(num);
case "date":
if (!value) return "";
const date = new Date(value);
return isNaN(date.getTime()) ? "" : date.toISOString().split('T')[0];
case "checkbox":
return ["true", "1", "Y", "yes"].includes(String(value).toLowerCase()) ? "Y" : "N";
default:
return String(value);
}
}
// 표시용 변환 (DB → 화면)
static convertForDisplay(value: string, inputType: string): any {
if (!value) return inputType === "number" ? 0 : "";
switch (inputType) {
case "number":
const num = parseFloat(value);
return isNaN(num) ? 0 : num;
case "date":
return value; // YYYY-MM-DD 형식 그대로
case "checkbox":
return value === "Y";
default:
return value;
}
}
}
## 🏗️ 구현 단계
### Phase 1: 타입 정의 수정 (1-2일)
#### 1.1 입력 타입 enum 업데이트
```typescript
// frontend/types/unified-web-types.ts
export type InputType =
| "text" // 텍스트
| "number" // 숫자
| "date" // 날짜
| "code" // 코드
| "entity" // 엔티티
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio"; // 라디오버튼
````
#### 1.2 UI 표시명 업데이트
```typescript
export const INPUT_TYPE_OPTIONS = [
{ value: "text", label: "텍스트", description: "일반 텍스트 입력" },
{ value: "number", label: "숫자", description: "숫자 입력 (정수/소수)" },
{ value: "date", label: "날짜", description: "날짜 선택" },
{ value: "code", label: "코드", description: "공통코드 참조" },
{ value: "entity", label: "엔티티", description: "다른 테이블 참조" },
{ value: "select", label: "선택박스", description: "드롭다운 선택" },
{ value: "checkbox", label: "체크박스", description: "체크박스 입력" },
{ value: "radio", label: "라디오버튼", description: "단일 선택" },
];
```
### Phase 2: 데이터베이스 스키마 수정 (1일)
#### 2.1 테이블 스키마 수정
```sql
-- table_type_columns 테이블 수정
ALTER TABLE table_type_columns
DROP COLUMN IF EXISTS db_type;
-- 컬럼명 변경
ALTER TABLE table_type_columns
RENAME COLUMN web_type TO input_type;
-- 기존 데이터 마이그레이션
UPDATE table_type_columns
SET input_type = CASE
WHEN input_type IN ('textarea') THEN 'text'
WHEN input_type IN ('decimal') THEN 'number'
WHEN input_type IN ('datetime') THEN 'date'
WHEN input_type IN ('dropdown') THEN 'select'
WHEN input_type IN ('boolean') THEN 'checkbox'
WHEN input_type NOT IN ('text', 'number', 'date', 'code', 'entity', 'select', 'checkbox', 'radio')
THEN 'text'
ELSE input_type
END;
```
### Phase 3: 백엔드 서비스 수정 (2-3일)
#### 3.1 DDL 생성 로직 수정
```typescript
// 전체 VARCHAR 통일 방식으로 수정
private mapInputTypeToPostgresType(inputType: string): string {
// 기본 컬럼들은 기존 타입 유지 (시스템 컬럼)
// 사용자 정의 컬럼은 입력 타입과 관계없이 모두 VARCHAR(500)로 통일
return "varchar(500)";
}
private generateCreateTableQuery(
tableName: string,
columns: CreateColumnDefinition[]
): string {
// 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 생성
const columnDefinitions = columns
.map((col) => {
let definition = `"${col.name}" varchar(500)`; // 타입 통일
if (!col.nullable) {
definition += " NOT NULL";
}
if (col.defaultValue) {
definition += ` DEFAULT '${col.defaultValue}'`;
}
return definition;
})
.join(",\n ");
// 기본 컬럼들 (시스템 필수 컬럼 - 기존 타입 유지)
const baseColumns = `
"id" serial PRIMARY KEY,
"created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),
"writer" varchar(100),
"company_code" varchar(50) DEFAULT '*'`;
return `
CREATE TABLE "${tableName}" (${baseColumns},
${columnDefinitions}
);`.trim();
}
```
#### 3.2 입력 타입 처리 서비스 구현
```typescript
// 통합 입력 타입 처리 서비스
export class InputTypeService {
// 데이터 저장 전 형변환 (화면 입력값 → DB 저장값)
static convertForStorage(value: any, inputType: string): string {
if (value === null || value === undefined) return "";
switch (inputType) {
case "text":
case "select":
case "radio":
return String(value);
case "number":
const num = parseFloat(String(value));
return isNaN(num) ? "0" : String(num);
case "date":
if (!value) return "";
const date = new Date(value);
return isNaN(date.getTime()) ? "" : date.toISOString().split("T")[0];
case "checkbox":
return ["true", "1", "Y", "yes", true].includes(value) ? "Y" : "N";
case "code":
case "entity":
return String(value || "");
default:
return String(value);
}
}
// 화면 표시용 형변환 (DB 저장값 → 화면 표시값)
static convertForDisplay(value: string, inputType: string): any {
if (!value && value !== "0") {
return inputType === "number" ? 0 : inputType === "checkbox" ? false : "";
}
switch (inputType) {
case "number":
const num = parseFloat(value);
return isNaN(num) ? 0 : num;
case "checkbox":
return value === "Y" || value === "true";
case "date":
return value; // YYYY-MM-DD 형식 그대로 사용
default:
return value;
}
}
// 입력값 검증
static validate(
value: any,
inputType: string
): { isValid: boolean; message?: string } {
if (!value && value !== 0) {
return { isValid: true }; // 빈 값은 허용
}
switch (inputType) {
case "number":
const num = parseFloat(String(value));
return {
isValid: !isNaN(num),
message: isNaN(num) ? "숫자 형식이 올바르지 않습니다." : undefined,
};
case "date":
const date = new Date(value);
return {
isValid: !isNaN(date.getTime()),
message: isNaN(date.getTime())
? "날짜 형식이 올바르지 않습니다."
: undefined,
};
default:
return { isValid: true };
}
}
}
```
### Phase 4: 프론트엔드 UI 수정 (2일)
#### 4.1 테이블 관리 화면 수정
- "웹 타입" → "입력 타입" 라벨 변경
- DB 타입 컬럼 제거
- 8개 타입만 선택 가능하도록 UI 수정
#### 4.2 화면 관리 시스템 연동
- 웹타입 → 입력타입 용어 통일
- 기존 화면관리 컴포넌트와 호환성 유지
### Phase 5: 기본 컬럼 유지 로직 보강 (1일)
#### 5.1 테이블 생성 시 기본 컬럼 자동 추가
```typescript
const DEFAULT_COLUMNS = [
{
name: "id",
type: "serial PRIMARY KEY",
description: "기본키 (자동증가)",
},
{
name: "created_date",
type: "timestamp DEFAULT now()",
description: "생성일시",
},
{
name: "updated_date",
type: "timestamp DEFAULT now()",
description: "수정일시",
},
{
name: "writer",
type: "varchar(100)",
description: "작성자",
},
{
name: "company_code",
type: "varchar(50) DEFAULT '*'",
description: "회사코드",
},
];
```
## 🧪 테스트 계획
### 1. 단위 테스트
- [ ] 입력 타입별 형변환 함수 테스트
- [ ] 데이터 검증 로직 테스트
- [ ] DDL 생성 로직 테스트
### 2. 통합 테스트
- [ ] 테이블 생성 → 데이터 입력 → 조회 전체 플로우
- [ ] 기존 테이블과의 호환성 테스트
- [ ] 화면관리 시스템 연동 테스트
### 3. 성능 테스트
- [ ] VARCHAR vs 전용 타입 성능 비교
- [ ] 대용량 데이터 입력 테스트
- [ ] 형변환 오버헤드 측정
## 📊 마이그레이션 전략
### 기존 데이터 호환성
1. **기존 테이블**: 현재 타입 구조 유지
2. **신규 테이블**: 새로운 입력 타입 체계 적용
3. **점진적 전환**: 필요에 따라 기존 테이블도 단계적 전환
### 데이터 무결성 보장
- 형변환 실패 시 기본값 사용
- 검증 로직을 통한 데이터 품질 관리
- 에러 로깅 및 알림 시스템
## 🎯 예상 효과
### 사용자 경험 개선
- ✅ 직관적인 용어로 학습 비용 감소
- ✅ 8개 타입으로 선택 복잡도 감소
- ✅ 일관된 인터페이스 제공
### 개발 생산성 향상
- ✅ 타입 관리 복잡도 감소
- ✅ 에러 발생률 감소
- ✅ 빠른 프로토타이핑 가능
### 시스템 유연성 확보
- ✅ 요구사항 변경에 빠른 대응
- ✅ 다양한 데이터 형식 수용
- ✅ 확장성 있는 구조
## 🚨 주의사항
### 데이터 검증 강화 필요
VARCHAR 통일 방식 적용 시 애플리케이션 레벨에서 철저한 데이터 검증이 필요합니다.
### 성능 모니터링
초기 적용 후 성능 영향도를 지속적으로 모니터링하여 필요 시 최적화 방안을 강구합니다.
### 문서화 업데이트
새로운 입력 타입 체계에 대한 사용자 가이드 및 개발 문서를 업데이트합니다.
## 📅 일정
| 단계 | 소요시간 | 담당 |
| ---------------------------- | --------- | ---------- |
| Phase 1: 타입 정의 수정 | 1-2일 | 프론트엔드 |
| Phase 2: DB 스키마 수정 | 1일 | 백엔드 |
| Phase 3: 백엔드 서비스 수정 | 2-3일 | 백엔드 |
| Phase 4: 프론트엔드 UI 수정 | 2일 | 프론트엔드 |
| Phase 5: 기본 컬럼 로직 보강 | 1일 | 백엔드 |
| **총 소요시간** | **7-9일** | |
---
**결론**: 전체 VARCHAR 통일 방식을 확정하여 최대한의 유연성과 안정성을 확보합니다.
## 🎯 핵심 결정사항 요약
### ✅ 확정된 방향성
1. **용어 통일**: 웹 타입 → 입력 타입
2. **타입 단순화**: 20개 → 8개 핵심 타입
3. **DB 타입 제거**: dbType 필드 완전 삭제
4. **저장 방식**: 모든 사용자 정의 컬럼을 VARCHAR(500)로 통일
5. **형변환**: 애플리케이션 레벨에서 입력 타입별 처리
### 🚀 예상 효과
- **개발 속도 3배 향상**: 타입 고민 시간 제거
- **에러율 90% 감소**: DB 타입 불일치 에러 완전 차단
- **요구사항 대응력 극대화**: 스키마 수정 없이 필드 성격 변경 가능
- **데이터 마이그레이션 100% 안전**: 어떤 형태의 데이터도 수용 가능

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. **데이터 백업**: 중요한 변경 전 항상 백업 실행
---
**🎉 테이블 타입 관리 개선으로 더욱 유연하고 안정적인 시스템을 경험하세요!**