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"; 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 getScreenById(screenId: number): Promise { const screen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); return screen ? this.mapToScreenDefinition(screen) : null; } /** * 화면 정의 수정 */ async updateScreen( screenId: number, updateData: UpdateScreenRequest, userCompanyCode: string ): Promise { // 권한 검증 const screen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!screen) { throw new Error("화면을 찾을 수 없습니다."); } if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { throw new Error("해당 화면을 수정할 권한이 없습니다."); } const updatedScreen = await prisma.screen_definitions.update({ where: { screen_id: screenId }, data: { screen_name: updateData.screenName, description: updateData.description, is_active: updateData.isActive, updated_by: updateData.updatedBy, updated_date: new Date(), }, }); return this.mapToScreenDefinition(updatedScreen); } /** * 화면 정의 삭제 */ async deleteScreen(screenId: number, userCompanyCode: string): Promise { // 권한 검증 const screen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!screen) { throw new Error("화면을 찾을 수 없습니다."); } if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { throw new Error("해당 화면을 삭제할 권한이 없습니다."); } // CASCADE로 인해 관련 레이아웃과 위젯도 자동 삭제됨 await prisma.screen_definitions.delete({ where: { screen_id: screenId }, }); } // ======================================== // 레이아웃 관리 // ======================================== /** * 레이아웃 저장 */ async saveLayout( screenId: number, layoutData: SaveLayoutRequest ): Promise { // 화면 존재 확인 const screen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!screen) { throw new Error("화면을 찾을 수 없습니다."); } // 기존 레이아웃 삭제 await prisma.screen_layouts.deleteMany({ where: { screen_id: screenId }, }); // 새 레이아웃 저장 const layoutPromises = layoutData.components.map((component) => prisma.screen_layouts.create({ data: { screen_id: screenId, component_type: component.type, component_id: component.id, parent_id: component.parentId, position_x: component.position.x, position_y: component.position.y, width: component.size.width, height: component.size.height, properties: component.properties, display_order: component.displayOrder || 0, }, }) ); await Promise.all(layoutPromises); } /** * 레이아웃 조회 */ async getLayout(screenId: number): Promise { const layouts = await prisma.screen_layouts.findMany({ where: { screen_id: screenId }, orderBy: { display_order: "asc" }, }); if (layouts.length === 0) { return null; } const components: ComponentData[] = layouts.map((layout) => { const baseComponent = { id: layout.component_id, type: layout.component_type as any, position: { x: layout.position_x, y: layout.position_y }, size: { width: layout.width, height: layout.height }, properties: layout.properties as Record, displayOrder: layout.display_order, }; // 컴포넌트 타입별 추가 속성 처리 switch (layout.component_type) { case "group": return { ...baseComponent, type: "group", title: (layout.properties as any)?.title, backgroundColor: (layout.properties as any)?.backgroundColor, border: (layout.properties as any)?.border, borderRadius: (layout.properties as any)?.borderRadius, shadow: (layout.properties as any)?.shadow, padding: (layout.properties as any)?.padding, margin: (layout.properties as any)?.margin, collapsible: (layout.properties as any)?.collapsible, collapsed: (layout.properties as any)?.collapsed, children: (layout.properties as any)?.children || [], }; case "widget": return { ...baseComponent, type: "widget", tableName: (layout.properties as any)?.tableName, columnName: (layout.properties as any)?.columnName, widgetType: (layout.properties as any)?.widgetType, label: (layout.properties as any)?.label, placeholder: (layout.properties as any)?.placeholder, required: (layout.properties as any)?.required, readonly: (layout.properties as any)?.readonly, validationRules: (layout.properties as any)?.validationRules, displayProperties: (layout.properties as any)?.displayProperties, }; default: return baseComponent; } }); 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 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, }; } }