import prisma from "../config/database"; import { ScreenDefinition, CreateScreenRequest, UpdateScreenRequest, LayoutData, SaveLayoutRequest, ScreenTemplate, MenuAssignmentRequest, PaginatedResponse, ComponentData, ColumnInfo, ColumnWebTypeSetting, WebType, WidgetData, } from "../types/screen"; import { generateId } from "../utils/generateId"; // 화면 복사 요청 인터페이스 interface CopyScreenRequest { screenName: string; screenCode: string; description?: string; companyCode: string; createdBy: string; } // 백엔드에서 사용할 테이블 정보 타입 interface TableInfo { tableName: string; tableLabel: string; columns: ColumnInfo[]; } export class ScreenManagementService { // ======================================== // 화면 정의 관리 // ======================================== /** * 화면 정의 생성 */ async createScreen( screenData: CreateScreenRequest, userCompanyCode: string ): Promise { // 화면 코드 중복 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_code: screenData.screenCode }, }); if (existingScreen) { throw new Error("이미 존재하는 화면 코드입니다."); } const screen = await prisma.screen_definitions.create({ data: { screen_name: screenData.screenName, screen_code: screenData.screenCode, table_name: screenData.tableName, company_code: screenData.companyCode, description: screenData.description, created_by: screenData.createdBy, }, }); return this.mapToScreenDefinition(screen); } /** * 회사별 화면 목록 조회 (페이징 지원) */ async getScreensByCompany( companyCode: string, page: number = 1, size: number = 20 ): Promise> { const whereClause = companyCode === "*" ? {} : { company_code: companyCode }; const [screens, total] = await Promise.all([ prisma.screen_definitions.findMany({ where: whereClause, skip: (page - 1) * size, take: size, orderBy: { created_date: "desc" }, }), prisma.screen_definitions.count({ where: whereClause }), ]); return { data: screens.map((screen) => this.mapToScreenDefinition(screen)), pagination: { page, size, total, totalPages: Math.ceil(total / size), }, }; } /** * 화면 목록 조회 (간단 버전) */ async getScreens(companyCode: string): Promise { const whereClause = companyCode === "*" ? {} : { company_code: companyCode }; const screens = await prisma.screen_definitions.findMany({ where: whereClause, orderBy: { created_date: "desc" }, }); return screens.map((screen) => this.mapToScreenDefinition(screen)); } /** * 화면 정의 조회 */ async getScreenById(screenId: number): Promise { const screen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); return screen ? this.mapToScreenDefinition(screen) : null; } /** * 화면 정의 조회 (회사 코드 검증 포함) */ async getScreen( screenId: number, companyCode: string ): Promise { const whereClause: any = { screen_id: screenId }; // 회사 코드가 '*'가 아닌 경우 회사별 필터링 if (companyCode !== "*") { whereClause.company_code = companyCode; } const screen = await prisma.screen_definitions.findUnique({ where: whereClause, }); return screen ? this.mapToScreenDefinition(screen) : null; } /** * 화면 정의 수정 */ async updateScreen( screenId: number, updateData: UpdateScreenRequest, userCompanyCode: string ): Promise { // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!existingScreen) { throw new Error("화면을 찾을 수 없습니다."); } if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 수정할 권한이 없습니다."); } const screen = await prisma.screen_definitions.update({ where: { screen_id: screenId }, data: { screen_name: updateData.screenName, description: updateData.description, is_active: updateData.isActive ? "Y" : "N", updated_by: updateData.updatedBy, updated_date: new Date(), }, }); return this.mapToScreenDefinition(screen); } /** * 화면 정의 삭제 */ async deleteScreen(screenId: number, userCompanyCode: string): Promise { // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!existingScreen) { throw new Error("화면을 찾을 수 없습니다."); } if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 삭제할 권한이 없습니다."); } await prisma.screen_definitions.delete({ where: { screen_id: screenId }, }); } // ======================================== // 테이블 관리 // ======================================== /** * 테이블 목록 조회 (모든 테이블) */ async getTables(companyCode: string): Promise { try { // PostgreSQL에서 사용 가능한 테이블 목록 조회 const tables = await prisma.$queryRaw>` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name `; // 각 테이블의 컬럼 정보도 함께 조회 const tableInfos: TableInfo[] = []; for (const table of tables) { const columns = await this.getTableColumns( table.table_name, companyCode ); if (columns.length > 0) { tableInfos.push({ tableName: table.table_name, tableLabel: this.getTableLabel(table.table_name), columns: columns, }); } } return tableInfos; } catch (error) { console.error("테이블 목록 조회 실패:", error); throw new Error("테이블 목록을 조회할 수 없습니다."); } } /** * 특정 테이블 정보 조회 (최적화된 단일 테이블 조회) */ async getTableInfo( tableName: string, companyCode: string ): Promise { try { console.log(`=== 단일 테이블 조회 시작: ${tableName} ===`); // 테이블 존재 여부 확인 const tableExists = await prisma.$queryRaw>` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name = ${tableName} `; if (tableExists.length === 0) { console.log(`테이블 ${tableName}이 존재하지 않습니다.`); return null; } // 해당 테이블의 컬럼 정보 조회 const columns = await this.getTableColumns(tableName, companyCode); if (columns.length === 0) { console.log(`테이블 ${tableName}의 컬럼 정보가 없습니다.`); return null; } const tableInfo: TableInfo = { tableName: tableName, tableLabel: this.getTableLabel(tableName), columns: columns, }; console.log( `단일 테이블 조회 완료: ${tableName}, 컬럼 ${columns.length}개` ); return tableInfo; } catch (error) { console.error(`테이블 ${tableName} 조회 실패:`, error); throw new Error(`테이블 ${tableName} 정보를 조회할 수 없습니다.`); } } /** * 테이블 컬럼 정보 조회 */ async getTableColumns( tableName: string, companyCode: string ): Promise { try { // 테이블 컬럼 정보 조회 const columns = await prisma.$queryRaw< Array<{ column_name: string; data_type: string; is_nullable: string; column_default: string | null; character_maximum_length: number | null; numeric_precision: number | null; numeric_scale: number | null; }> >` SELECT column_name, data_type, is_nullable, column_default, character_maximum_length, numeric_precision, numeric_scale FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ${tableName} ORDER BY ordinal_position `; // column_labels 테이블에서 웹타입 정보 조회 (있는 경우) const webTypeInfo = await prisma.column_labels.findMany({ where: { table_name: tableName }, select: { column_name: true, web_type: true, column_label: true, detail_settings: true, }, }); // 컬럼 정보 매핑 return columns.map((column) => { const webTypeData = webTypeInfo.find( (wt) => wt.column_name === column.column_name ); return { tableName: tableName, columnName: column.column_name, columnLabel: webTypeData?.column_label || this.getColumnLabel(column.column_name), dataType: column.data_type, webType: (webTypeData?.web_type as WebType) || this.inferWebType(column.data_type), isNullable: column.is_nullable, columnDefault: column.column_default || undefined, characterMaximumLength: column.character_maximum_length || undefined, numericPrecision: column.numeric_precision || undefined, numericScale: column.numeric_scale || undefined, detailSettings: webTypeData?.detail_settings || undefined, }; }); } catch (error) { console.error("테이블 컬럼 조회 실패:", error); throw new Error("테이블 컬럼 정보를 조회할 수 없습니다."); } } /** * 테이블 라벨 생성 */ private getTableLabel(tableName: string): string { // snake_case를 읽기 쉬운 형태로 변환 return tableName .replace(/_/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()) .replace(/\s+/g, " ") .trim(); } /** * 컬럼 라벨 생성 */ private getColumnLabel(columnName: string): string { // snake_case를 읽기 쉬운 형태로 변환 return columnName .replace(/_/g, " ") .replace(/\b\w/g, (l) => l.toUpperCase()) .replace(/\s+/g, " ") .trim(); } /** * 데이터 타입으로부터 웹타입 추론 */ private inferWebType(dataType: string): WebType { const lowerType = dataType.toLowerCase(); if (lowerType.includes("char") || lowerType.includes("text")) { return "text"; } else if ( lowerType.includes("int") || lowerType.includes("numeric") || lowerType.includes("decimal") ) { return "number"; } else if (lowerType.includes("date") || lowerType.includes("time")) { return "date"; } else if (lowerType.includes("bool")) { return "checkbox"; } else { return "text"; } } // ======================================== // 레이아웃 관리 // ======================================== /** * 레이아웃 저장 */ async saveLayout( screenId: number, layoutData: LayoutData, companyCode: string ): Promise { console.log(`=== 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}`); console.log(`컴포넌트 수: ${layoutData.components.length}`); // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!existingScreen) { throw new Error("화면을 찾을 수 없습니다."); } if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); } // 기존 레이아웃 삭제 await prisma.screen_layouts.deleteMany({ where: { screen_id: screenId }, }); // 새 레이아웃 저장 for (const component of layoutData.components) { const { id, ...componentData } = component; console.log(`저장 중인 컴포넌트:`, { id: component.id, type: component.type, position: component.position, size: component.size, parentId: component.parentId, title: (component as any).title, }); // Prisma JSON 필드에 맞는 타입으로 변환 const properties: any = { ...componentData, position: { x: component.position.x, y: component.position.y, z: component.position.z || 1, // z 값 포함 }, size: { width: component.size.width, height: component.size.height, }, }; await prisma.screen_layouts.create({ data: { screen_id: screenId, component_type: component.type, component_id: component.id, parent_id: component.parentId || null, position_x: component.position.x, position_y: component.position.y, width: component.size.width, height: component.size.height, properties: properties, }, }); } console.log(`=== 레이아웃 저장 완료 ===`); } /** * 레이아웃 조회 */ async getLayout( screenId: number, companyCode: string ): Promise { console.log(`=== 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}`); // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!existingScreen) { return null; } if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } const layouts = await prisma.screen_layouts.findMany({ where: { screen_id: screenId }, orderBy: { display_order: "asc" }, }); console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`); if (layouts.length === 0) { return { components: [], gridSettings: { columns: 12, gap: 16, padding: 16 }, }; } const components: ComponentData[] = layouts.map((layout) => { const properties = layout.properties as any; const component = { id: layout.component_id, type: layout.component_type as any, position: { x: layout.position_x, y: layout.position_y, z: properties?.position?.z || 1, // z 값 복원 }, size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, }; console.log(`로드된 컴포넌트:`, { id: component.id, type: component.type, position: component.position, size: component.size, parentId: component.parentId, title: (component as any).title, }); return component; }); console.log(`=== 레이아웃 로드 완료 ===`); console.log(`반환할 컴포넌트 수: ${components.length}`); return { components, gridSettings: { columns: 12, gap: 16, padding: 16 }, }; } // ======================================== // 템플릿 관리 // ======================================== /** * 템플릿 목록 조회 (회사별) */ async getTemplatesByCompany( companyCode: string, type?: string, isPublic?: boolean ): Promise { const whereClause: any = {}; if (companyCode !== "*") { whereClause.company_code = companyCode; } if (type) { whereClause.template_type = type; } if (isPublic !== undefined) { whereClause.is_public = isPublic; } const templates = await prisma.screen_templates.findMany({ where: whereClause, orderBy: { created_date: "desc" }, }); return templates.map(this.mapToScreenTemplate); } /** * 템플릿 생성 */ async createTemplate( templateData: Partial ): Promise { const template = await prisma.screen_templates.create({ data: { template_name: templateData.templateName!, template_type: templateData.templateType!, company_code: templateData.companyCode!, description: templateData.description, layout_data: templateData.layoutData ? JSON.parse(JSON.stringify(templateData.layoutData)) : null, is_public: templateData.isPublic || false, created_by: templateData.createdBy, }, }); return this.mapToScreenTemplate(template); } // ======================================== // 메뉴 할당 관리 // ======================================== /** * 화면-메뉴 할당 */ async assignScreenToMenu( screenId: number, assignmentData: MenuAssignmentRequest ): Promise { // 중복 할당 방지 const existingAssignment = await prisma.screen_menu_assignments.findFirst({ where: { screen_id: screenId, menu_objid: assignmentData.menuObjid, company_code: assignmentData.companyCode, }, }); if (existingAssignment) { throw new Error("이미 할당된 화면입니다."); } await prisma.screen_menu_assignments.create({ data: { screen_id: screenId, menu_objid: assignmentData.menuObjid, company_code: assignmentData.companyCode, display_order: assignmentData.displayOrder || 0, created_by: assignmentData.createdBy, }, }); } /** * 메뉴별 화면 목록 조회 */ async getScreensByMenu( menuObjid: number, companyCode: string ): Promise { const assignments = await prisma.screen_menu_assignments.findMany({ where: { menu_objid: menuObjid, company_code: companyCode, is_active: "Y", }, include: { screen: true, }, orderBy: { display_order: "asc" }, }); return assignments.map((assignment) => this.mapToScreenDefinition(assignment.screen) ); } /** * 화면-메뉴 할당 해제 */ async unassignScreenFromMenu( screenId: number, menuObjid: number, companyCode: string ): Promise { await prisma.screen_menu_assignments.deleteMany({ where: { screen_id: screenId, menu_objid: menuObjid, company_code: companyCode, }, }); } // ======================================== // 테이블 타입 연계 // ======================================== /** * 컬럼 정보 조회 (웹 타입 포함) */ async getColumnInfo(tableName: string): Promise { const columns = await prisma.$queryRaw` SELECT c.column_name, COALESCE(cl.column_label, c.column_name) as column_label, c.data_type, COALESCE(cl.web_type, 'text') as web_type, c.is_nullable, c.column_default, c.character_maximum_length, c.numeric_precision, c.numeric_scale, cl.detail_settings, cl.code_category, cl.reference_table, cl.reference_column, cl.is_visible, cl.display_order, cl.description FROM information_schema.columns c LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name WHERE c.table_name = ${tableName} ORDER BY COALESCE(cl.display_order, c.ordinal_position) `; return columns as ColumnInfo[]; } /** * 웹 타입 설정 */ async setColumnWebType( tableName: string, columnName: string, webType: WebType, additionalSettings?: Partial ): Promise { await prisma.column_labels.upsert({ where: { table_name_column_name: { table_name: tableName, column_name: columnName, }, }, update: { web_type: webType, column_label: additionalSettings?.columnLabel, detail_settings: additionalSettings?.detailSettings ? JSON.stringify(additionalSettings.detailSettings) : null, code_category: additionalSettings?.codeCategory, reference_table: additionalSettings?.referenceTable, reference_column: additionalSettings?.referenceColumn, is_visible: additionalSettings?.isVisible ?? true, display_order: additionalSettings?.displayOrder ?? 0, description: additionalSettings?.description, updated_date: new Date(), }, create: { table_name: tableName, column_name: columnName, column_label: additionalSettings?.columnLabel, web_type: webType, detail_settings: additionalSettings?.detailSettings ? JSON.stringify(additionalSettings.detailSettings) : null, code_category: additionalSettings?.codeCategory, reference_table: additionalSettings?.referenceTable, reference_column: additionalSettings?.referenceColumn, is_visible: additionalSettings?.isVisible ?? true, display_order: additionalSettings?.displayOrder ?? 0, description: additionalSettings?.description, created_date: new Date(), }, }); } /** * 웹 타입별 위젯 생성 */ generateWidgetFromColumn(column: ColumnInfo): WidgetData { const baseWidget = { id: generateId(), tableName: column.tableName, columnName: column.columnName, type: column.webType || "text", label: column.columnLabel || column.columnName, required: column.isNullable === "N", readonly: false, }; // detail_settings JSON 파싱 const detailSettings = column.detailSettings ? JSON.parse(column.detailSettings) : {}; switch (column.webType) { case "text": return { ...baseWidget, maxLength: detailSettings.maxLength || column.characterMaximumLength, placeholder: `Enter ${column.columnLabel || column.columnName}`, pattern: detailSettings.pattern, }; case "number": return { ...baseWidget, min: detailSettings.min, max: detailSettings.max || (column.numericPrecision ? Math.pow(10, column.numericPrecision) - 1 : undefined), step: detailSettings.step || (column.numericScale && column.numericScale > 0 ? Math.pow(10, -column.numericScale) : 1), }; case "date": return { ...baseWidget, format: detailSettings.format || "YYYY-MM-DD", minDate: detailSettings.minDate, maxDate: detailSettings.maxDate, }; case "code": return { ...baseWidget, codeCategory: column.codeCategory, multiple: detailSettings.multiple || false, searchable: detailSettings.searchable || false, }; case "entity": return { ...baseWidget, referenceTable: column.referenceTable, referenceColumn: column.referenceColumn, searchable: detailSettings.searchable || true, multiple: detailSettings.multiple || false, }; case "textarea": return { ...baseWidget, rows: detailSettings.rows || 3, maxLength: detailSettings.maxLength || column.characterMaximumLength, }; case "select": return { ...baseWidget, options: detailSettings.options || [], multiple: detailSettings.multiple || false, searchable: detailSettings.searchable || false, }; case "checkbox": return { ...baseWidget, defaultChecked: detailSettings.defaultChecked || false, label: detailSettings.label || column.columnLabel, }; case "radio": return { ...baseWidget, options: detailSettings.options || [], inline: detailSettings.inline || false, }; case "file": return { ...baseWidget, accept: detailSettings.accept || "*/*", maxSize: detailSettings.maxSize || 10485760, // 10MB multiple: detailSettings.multiple || false, }; default: return { ...baseWidget, type: "text", }; } } // ======================================== // 유틸리티 메서드 // ======================================== private mapToScreenDefinition(data: any): ScreenDefinition { return { screenId: data.screen_id, screenName: data.screen_name, screenCode: data.screen_code, tableName: data.table_name, companyCode: data.company_code, description: data.description, isActive: data.is_active, createdDate: data.created_date, createdBy: data.created_by, updatedDate: data.updated_date, updatedBy: data.updated_by, }; } private mapToScreenTemplate(data: any): ScreenTemplate { return { templateId: data.template_id, templateName: data.template_name, templateType: data.template_type, companyCode: data.company_code, description: data.description, layoutData: data.layout_data, isPublic: data.is_public, createdBy: data.created_by, createdDate: data.created_date, }; } /** * 화면 코드 자동 생성 (회사코드 + '_' + 순번) */ async generateScreenCode(companyCode: string): Promise { // 해당 회사의 기존 화면 코드들 조회 const existingScreens = await prisma.screen_definitions.findMany({ where: { company_code: companyCode, screen_code: { startsWith: companyCode, }, }, select: { screen_code: true }, orderBy: { screen_code: "desc" }, }); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` ); for (const screen of existingScreens) { const match = screen.screen_code.match(pattern); if (match) { const number = parseInt(match[1], 10); if (number > maxNumber) { maxNumber = number; } } } // 다음 순번으로 화면 코드 생성 (3자리 패딩) const nextNumber = maxNumber + 1; const paddedNumber = nextNumber.toString().padStart(3, "0"); return `${companyCode}_${paddedNumber}`; } /** * 화면 복사 (화면 정보 + 레이아웃 모두 복사) */ async copyScreen( sourceScreenId: number, copyData: CopyScreenRequest ): Promise { // 트랜잭션으로 처리 return await prisma.$transaction(async (tx) => { // 1. 원본 화면 정보 조회 const sourceScreen = await tx.screen_definitions.findFirst({ where: { screen_id: sourceScreenId, company_code: copyData.companyCode, }, }); if (!sourceScreen) { throw new Error("복사할 화면을 찾을 수 없습니다."); } // 2. 화면 코드 중복 체크 const existingScreen = await tx.screen_definitions.findFirst({ where: { screen_code: copyData.screenCode, company_code: copyData.companyCode, }, }); if (existingScreen) { throw new Error("이미 존재하는 화면 코드입니다."); } // 3. 새 화면 생성 const newScreen = await tx.screen_definitions.create({ data: { screen_code: copyData.screenCode, screen_name: copyData.screenName, description: copyData.description || sourceScreen.description, company_code: copyData.companyCode, table_name: sourceScreen.table_name, is_active: sourceScreen.is_active, created_by: copyData.createdBy, created_date: new Date(), updated_by: copyData.createdBy, updated_date: new Date(), }, }); // 4. 원본 화면의 레이아웃 정보 조회 const sourceLayouts = await tx.screen_layouts.findMany({ where: { screen_id: sourceScreenId, }, orderBy: { display_order: "asc" }, }); // 5. 레이아웃이 있다면 복사 if (sourceLayouts.length > 0) { try { // ID 매핑 맵 생성 const idMapping: { [oldId: string]: string } = {}; // 새로운 컴포넌트 ID 미리 생성 sourceLayouts.forEach((layout) => { idMapping[layout.component_id] = generateId(); }); // 각 레이아웃 컴포넌트 복사 for (const sourceLayout of sourceLayouts) { const newComponentId = idMapping[sourceLayout.component_id]; const newParentId = sourceLayout.parent_id ? idMapping[sourceLayout.parent_id] : null; await tx.screen_layouts.create({ data: { screen_id: newScreen.screen_id, component_type: sourceLayout.component_type, component_id: newComponentId, parent_id: newParentId, position_x: sourceLayout.position_x, position_y: sourceLayout.position_y, width: sourceLayout.width, height: sourceLayout.height, properties: sourceLayout.properties as any, display_order: sourceLayout.display_order, created_date: new Date(), }, }); } } catch (error) { console.error("레이아웃 복사 중 오류:", error); // 레이아웃 복사 실패해도 화면 생성은 유지 } } // 6. 생성된 화면 정보 반환 return { screenId: newScreen.screen_id, screenCode: newScreen.screen_code, screenName: newScreen.screen_name, description: newScreen.description || "", companyCode: newScreen.company_code, tableName: newScreen.table_name, isActive: newScreen.is_active, createdBy: newScreen.created_by || undefined, createdDate: newScreen.created_date, updatedBy: newScreen.updated_by || undefined, updatedDate: newScreen.updated_date, }; }); } } // 서비스 인스턴스 export export const screenManagementService = new ScreenManagementService();