// ✅ Prisma → Raw Query 전환 (Phase 2.1) import { query, transaction } from "../database/db"; 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 { // ======================================== // 화면 정의 관리 // ======================================== /** * 화면 정의 생성 (✅ Raw Query 전환 완료) */ async createScreen( screenData: CreateScreenRequest, userCompanyCode: string ): Promise { console.log(`=== 화면 생성 요청 ===`); console.log(`요청 데이터:`, screenData); console.log(`사용자 회사 코드:`, userCompanyCode); // 화면 코드 중복 확인 (Raw Query) const existingResult = await query<{ screen_id: number }>( `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND is_active != 'D' LIMIT 1`, [screenData.screenCode] ); console.log( `화면 코드 '${screenData.screenCode}' 중복 검사 결과:`, existingResult.length > 0 ? "중복됨" : "사용 가능" ); if (existingResult.length > 0) { console.log(`기존 화면 정보:`, existingResult[0]); throw new Error("이미 존재하는 화면 코드입니다."); } // 화면 생성 (Raw Query) const [screen] = await query( `INSERT INTO screen_definitions ( screen_name, screen_code, table_name, company_code, description, created_by ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [ screenData.screenName, screenData.screenCode, screenData.tableName, screenData.companyCode, screenData.description || null, screenData.createdBy, ] ); return this.mapToScreenDefinition(screen); } /** * 회사별 화면 목록 조회 (페이징 지원) - 활성 화면만 (✅ Raw Query 전환 완료) */ async getScreensByCompany( companyCode: string, page: number = 1, size: number = 20 ): Promise> { const offset = (page - 1) * size; // WHERE 절 동적 생성 const whereConditions: string[] = ["is_active != 'D'"]; const params: any[] = []; if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } const whereSQL = whereConditions.join(" AND "); // 페이징 쿼리 (Raw Query) const [screens, totalResult] = await Promise.all([ query( `SELECT * FROM screen_definitions WHERE ${whereSQL} ORDER BY created_date DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...params, size, offset] ), query<{ count: string }>( `SELECT COUNT(*)::text as count FROM screen_definitions WHERE ${whereSQL}`, params ), ]); const total = parseInt(totalResult[0]?.count || "0", 10); // 테이블 라벨 정보를 한 번에 조회 (Raw Query) const tableNames = [ ...new Set(screens.map((s: any) => s.table_name).filter(Boolean)), ]; let tableLabelMap = new Map(); if (tableNames.length > 0) { try { const placeholders = tableNames.map((_, i) => `$${i + 1}`).join(", "); const tableLabels = await query<{ table_name: string; table_label: string | null }>( `SELECT table_name, table_label FROM table_labels WHERE table_name IN (${placeholders})`, tableNames ); tableLabelMap = new Map( tableLabels.map((tl) => [ tl.table_name, tl.table_label || tl.table_name, ]) ); // 테스트: company_mng 라벨 직접 확인 if (tableLabelMap.has("company_mng")) { console.log( "✅ company_mng 라벨 찾음:", tableLabelMap.get("company_mng") ); } else { console.log("❌ company_mng 라벨 없음"); } } catch (error) { console.error("테이블 라벨 조회 오류:", error); } } return { data: screens.map((screen) => this.mapToScreenDefinition(screen, tableLabelMap) ), pagination: { page, size, total, totalPages: Math.ceil(total / size), }, }; } /** * 화면 목록 조회 (간단 버전) - 활성 화면만 (✅ Raw Query 전환 완료) */ async getScreens(companyCode: string): Promise { // 동적 WHERE 절 생성 const whereConditions: string[] = ["is_active != 'D'"]; const params: any[] = []; if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } const whereSQL = whereConditions.join(" AND "); const screens = await query( `SELECT * FROM screen_definitions WHERE ${whereSQL} ORDER BY created_date DESC`, params ); return screens.map((screen) => this.mapToScreenDefinition(screen)); } /** * 화면 정의 조회 (활성 화면만) (✅ Raw Query 전환 완료) */ async getScreenById(screenId: number): Promise { const screens = await query( `SELECT * FROM screen_definitions WHERE screen_id = $1 AND is_active != 'D' LIMIT 1`, [screenId] ); return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null; } /** * 화면 정의 조회 (회사 코드 검증 포함, 활성 화면만) (✅ Raw Query 전환 완료) */ async getScreen( screenId: number, companyCode: string ): Promise { // 동적 WHERE 절 생성 const whereConditions: string[] = [ "screen_id = $1", "is_active != 'D'", // 삭제된 화면 제외 ]; const params: any[] = [screenId]; // 회사 코드가 '*'가 아닌 경우 회사별 필터링 if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } const whereSQL = whereConditions.join(" AND "); const screens = await query( `SELECT * FROM screen_definitions WHERE ${whereSQL} LIMIT 1`, params ); return screens.length > 0 ? this.mapToScreenDefinition(screens[0]) : null; } /** * 화면 정의 수정 (✅ Raw Query 전환 완료) */ async updateScreen( screenId: number, updateData: UpdateScreenRequest, userCompanyCode: string ): Promise { // 권한 확인 (Raw Query) const existingResult = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId] ); if (existingResult.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = existingResult[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 수정할 권한이 없습니다."); } // 화면 업데이트 (Raw Query) const [screen] = await query( `UPDATE screen_definitions SET screen_name = $1, description = $2, is_active = $3, updated_by = $4, updated_date = $5 WHERE screen_id = $6 RETURNING *`, [ updateData.screenName, updateData.description || null, updateData.isActive ? "Y" : "N", updateData.updatedBy, new Date(), screenId, ] ); return this.mapToScreenDefinition(screen); } /** * 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인 */ async checkScreenDependencies( screenId: number, userCompanyCode: string ): Promise<{ hasDependencies: boolean; dependencies: Array<{ screenId: number; screenName: string; screenCode: string; componentId: string; componentType: string; referenceType: string; // 'popup', 'navigate', 'targetScreen' 등 }>; }> { // 권한 확인 const targetScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); if (!targetScreen) { throw new Error("화면을 찾을 수 없습니다."); } if ( userCompanyCode !== "*" && targetScreen.company_code !== "*" && targetScreen.company_code !== userCompanyCode ) { throw new Error("이 화면에 접근할 권한이 없습니다."); } // 같은 회사의 모든 활성 화면에서 이 화면을 참조하는지 확인 const whereClause = { is_active: { not: "D" }, ...(userCompanyCode !== "*" && { company_code: { in: [userCompanyCode, "*"] }, }), }; const allScreens = await prisma.screen_definitions.findMany({ where: whereClause, include: { layouts: true, }, }); const dependencies: Array<{ screenId: number; screenName: string; screenCode: string; componentId: string; componentType: string; referenceType: string; }> = []; // 각 화면의 레이아웃에서 버튼 컴포넌트들을 검사 for (const screen of allScreens) { if (screen.screen_id === screenId) continue; // 자기 자신은 제외 try { // screen_layouts 테이블에서 버튼 컴포넌트 확인 const buttonLayouts = screen.layouts.filter( (layout) => layout.component_type === "widget" ); for (const layout of buttonLayouts) { const properties = layout.properties as any; // 버튼 컴포넌트인지 확인 if (properties?.widgetType === "button") { const config = properties.webTypeConfig; if (!config) continue; // popup 액션에서 popupScreenId 확인 if ( config.actionType === "popup" && config.popupScreenId === screenId ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: layout.component_id, componentType: "button", referenceType: "popup", }); } // navigate 액션에서 navigateScreenId 확인 if ( config.actionType === "navigate" && config.navigateScreenId === screenId ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: layout.component_id, componentType: "button", referenceType: "navigate", }); } // navigateUrl에서 화면 ID 패턴 확인 (예: /screens/123) if ( config.navigateUrl && config.navigateUrl.includes(`/screens/${screenId}`) ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: layout.component_id, componentType: "button", referenceType: "url", }); } } } // 기존 layout_metadata도 확인 (하위 호환성) const layoutMetadata = screen.layout_metadata as any; if (layoutMetadata?.components) { const components = layoutMetadata.components; for (const component of components) { // 버튼 컴포넌트인지 확인 if ( component.type === "widget" && component.widgetType === "button" ) { const config = component.webTypeConfig; if (!config) continue; // popup 액션에서 targetScreenId 확인 if ( config.actionType === "popup" && config.targetScreenId === screenId ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: component.id, componentType: "button", referenceType: "popup", }); } // navigate 액션에서 targetScreenId 확인 if ( config.actionType === "navigate" && config.targetScreenId === screenId ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: component.id, componentType: "button", referenceType: "navigate", }); } // navigateUrl에서 화면 ID 패턴 확인 (예: /screens/123) if ( config.navigateUrl && config.navigateUrl.includes(`/screens/${screenId}`) ) { dependencies.push({ screenId: screen.screen_id, screenName: screen.screen_name, screenCode: screen.screen_code, componentId: component.id, componentType: "button", referenceType: "url", }); } } } } } catch (error) { console.error( `화면 ${screen.screen_id}의 레이아웃 분석 중 오류:`, error ); continue; } } // 메뉴 할당 확인 // 메뉴에 할당된 화면인지 확인 (임시 주석 처리) /* const menuAssignments = await prisma.screen_menu_assignments.findMany({ where: { screen_id: screenId, is_active: "Y", }, include: { menu_info: true, // 메뉴 정보도 함께 조회 }, }); // 메뉴에 할당된 경우 의존성에 추가 for (const assignment of menuAssignments) { dependencies.push({ screenId: 0, // 메뉴는 화면이 아니므로 0으로 설정 screenName: assignment.menu_info?.menu_name_kor || "알 수 없는 메뉴", screenCode: `MENU_${assignment.menu_objid}`, componentId: `menu_${assignment.assignment_id}`, componentType: "menu", referenceType: "menu_assignment", }); } */ return { hasDependencies: dependencies.length > 0, dependencies, }; } /** * 화면 정의 삭제 (휴지통으로 이동 - 소프트 삭제) (✅ Raw Query 전환 완료) */ async deleteScreen( screenId: number, userCompanyCode: string, deletedBy: string, deleteReason?: string, force: boolean = false ): Promise { // 권한 확인 (Raw Query) const existingResult = await query<{ company_code: string | null; is_active: string }>( `SELECT company_code, is_active FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId] ); if (existingResult.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = existingResult[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 삭제할 권한이 없습니다."); } // 이미 삭제된 화면인지 확인 if (existingScreen.is_active === "D") { throw new Error("이미 삭제된 화면입니다."); } // 강제 삭제가 아닌 경우 의존성 체크 if (!force) { const dependencyCheck = await this.checkScreenDependencies( screenId, userCompanyCode ); if (dependencyCheck.hasDependencies) { const error = new Error("다른 화면에서 사용 중인 화면입니다.") as any; error.code = "SCREEN_HAS_DEPENDENCIES"; error.dependencies = dependencyCheck.dependencies; throw error; } } // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 (Raw Query) await transaction(async (client) => { // 소프트 삭제 (휴지통으로 이동) await client.query( `UPDATE screen_definitions SET is_active = 'D', deleted_date = $1, deleted_by = $2, delete_reason = $3, updated_date = $4, updated_by = $5 WHERE screen_id = $6`, [new Date(), deletedBy, deleteReason || null, new Date(), deletedBy, screenId] ); // 메뉴 할당도 비활성화 await client.query( `UPDATE screen_menu_assignments SET is_active = 'N' WHERE screen_id = $1 AND is_active = 'Y'`, [screenId] ); }); } /** * 화면 복원 (휴지통에서 복원) */ async restoreScreen( screenId: number, userCompanyCode: string, restoredBy: 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("이 화면을 복원할 권한이 없습니다."); } // 삭제된 화면이 아닌 경우 if (existingScreen.is_active !== "D") { throw new Error("삭제된 화면이 아닙니다."); } // 화면 코드 중복 확인 (복원 시 같은 코드가 이미 존재하는지) const duplicateScreens = await query<{ screen_id: number }>( `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND is_active != 'D' AND screen_id != $2 LIMIT 1`, [existingScreen.screen_code, screenId] ); if (duplicateScreens.length > 0) { throw new Error( "같은 화면 코드를 가진 활성 화면이 이미 존재합니다. 복원하려면 기존 화면의 코드를 변경하거나 삭제해주세요." ); } // 트랜잭션으로 화면 복원과 메뉴 할당 복원을 함께 처리 await transaction(async (client) => { // 화면 복원 await client.query( `UPDATE screen_definitions SET is_active = 'Y', deleted_date = NULL, deleted_by = NULL, delete_reason = NULL, updated_date = $1, updated_by = $2 WHERE screen_id = $3`, [new Date(), restoredBy, screenId] ); // 메뉴 할당도 다시 활성화 await client.query( `UPDATE screen_menu_assignments SET is_active = 'Y' WHERE screen_id = $1 AND is_active = 'N'`, [screenId] ); }); } /** * 휴지통 화면들의 메뉴 할당 정리 (관리자용) */ async cleanupDeletedScreenMenuAssignments(): Promise<{ updatedCount: number; message: string; }> { const result = await prisma.$executeRaw` UPDATE screen_menu_assignments SET is_active = 'N' WHERE screen_id IN ( SELECT screen_id FROM screen_definitions WHERE is_active = 'D' ) AND is_active = 'Y' `; return { updatedCount: Number(result), message: `${result}개의 메뉴 할당이 정리되었습니다.`, }; } /** * 화면 영구 삭제 (휴지통에서 완전 삭제) */ async permanentDeleteScreen( 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("이 화면을 영구 삭제할 권한이 없습니다."); } // 삭제된 화면이 아닌 경우 영구 삭제 불가 if (existingScreen.is_active !== "D") { throw new Error("휴지통에 있는 화면만 영구 삭제할 수 있습니다."); } // 물리적 삭제 (CASCADE로 관련 레이아웃과 메뉴 할당도 함께 삭제됨) await prisma.screen_definitions.delete({ where: { screen_id: screenId }, }); } /** * 휴지통 화면 목록 조회 */ async getDeletedScreens( companyCode: string, page: number = 1, size: number = 20 ): Promise< PaginatedResponse< ScreenDefinition & { deletedDate?: Date; deletedBy?: string; deleteReason?: string; } > > { const whereClause: any = { is_active: "D" }; if (companyCode !== "*") { whereClause.company_code = companyCode; } const [screens, total] = await Promise.all([ prisma.screen_definitions.findMany({ where: whereClause, skip: (page - 1) * size, take: size, orderBy: { deleted_date: "desc" }, }), prisma.screen_definitions.count({ where: whereClause }), ]); // 테이블 라벨 정보를 한 번에 조회 const tableNames = [ ...new Set(screens.map((s) => s.table_name).filter(Boolean)), ]; const tableLabels = await prisma.table_labels.findMany({ where: { table_name: { in: tableNames } }, select: { table_name: true, table_label: true }, }); const tableLabelMap = new Map( tableLabels.map((tl) => [tl.table_name, tl.table_label || tl.table_name]) ); return { data: screens.map((screen) => ({ ...this.mapToScreenDefinition(screen, tableLabelMap), deletedDate: screen.deleted_date || undefined, deletedBy: screen.deleted_by || undefined, deleteReason: screen.delete_reason || undefined, })), pagination: { page, size, total, totalPages: Math.ceil(total / size), }, }; } /** * 휴지통 화면 일괄 영구 삭제 */ async bulkPermanentDeleteScreens( screenIds: number[], userCompanyCode: string ): Promise<{ deletedCount: number; skippedCount: number; errors: Array<{ screenId: number; error: string }>; }> { if (screenIds.length === 0) { throw new Error("삭제할 화면을 선택해주세요."); } // 권한 확인 - 해당 회사의 휴지통 화면들만 조회 const whereClause: any = { screen_id: { in: screenIds }, is_active: "D", // 휴지통에 있는 화면만 }; if (userCompanyCode !== "*") { whereClause.company_code = userCompanyCode; } // WHERE 절 생성 const whereConditions: string[] = ["is_active = 'D'"]; const params: any[] = []; if (userCompanyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(userCompanyCode); } const whereSQL = whereConditions.join(" AND "); const screensToDelete = await query<{ screen_id: number }>( `SELECT screen_id FROM screen_definitions WHERE ${whereSQL}`, params ); let deletedCount = 0; let skippedCount = 0; const errors: Array<{ screenId: number; error: string }> = []; // 각 화면을 개별적으로 삭제 처리 for (const screenId of screenIds) { try { const screenToDelete = screensToDelete.find( (s: any) => s.screen_id === screenId ); if (!screenToDelete) { skippedCount++; errors.push({ screenId, error: "화면을 찾을 수 없거나 삭제 권한이 없습니다.", }); continue; } // 관련 레이아웃 데이터도 함께 삭제 (트랜잭션) await transaction(async (client) => { // screen_layouts 삭제 await client.query( `DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId] ); // screen_menu_assignments 삭제 await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, [screenId] ); // screen_definitions 삭제 await client.query( `DELETE FROM screen_definitions WHERE screen_id = $1`, [screenId] ); }); deletedCount++; } catch (error) { skippedCount++; errors.push({ screenId, error: error instanceof Error ? error.message : "알 수 없는 오류", }); console.error(`화면 ${screenId} 영구 삭제 실패:`, error); } } return { deletedCount, skippedCount, errors, }; } // ======================================== // 테이블 관리 // ======================================== /** * 테이블 목록 조회 (모든 테이블) */ 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 { // 통합 타입 매핑에서 import const { DB_TYPE_TO_WEB_TYPE } = require("../types/unified-web-types"); const lowerType = dataType.toLowerCase(); // 정확한 매핑 우선 확인 if (DB_TYPE_TO_WEB_TYPE[lowerType]) { return DB_TYPE_TO_WEB_TYPE[lowerType]; } // 부분 문자열 매칭 (더 정교한 규칙) for (const [dbType, webType] of Object.entries(DB_TYPE_TO_WEB_TYPE)) { if ( lowerType.includes(dbType.toLowerCase()) || dbType.toLowerCase().includes(lowerType) ) { return webType as WebType; } } // 추가 정밀 매핑 if (lowerType.includes("int") && !lowerType.includes("point")) { return "number"; } else if (lowerType.includes("numeric") || lowerType.includes("decimal")) { return "decimal"; } else if ( lowerType.includes("timestamp") || lowerType.includes("datetime") ) { return "datetime"; } else if (lowerType.includes("date")) { return "date"; } else if (lowerType.includes("time")) { return "datetime"; } else if (lowerType.includes("bool")) { return "checkbox"; } else if ( lowerType.includes("char") || lowerType.includes("text") || lowerType.includes("varchar") ) { return lowerType.includes("text") ? "textarea" : "text"; } // 기본값 return "text"; } // ======================================== // 레이아웃 관리 // ======================================== /** * 레이아웃 저장 (✅ Raw Query 전환 완료) */ async saveLayout( screenId: number, layoutData: LayoutData, companyCode: string ): Promise { console.log(`=== 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}`); console.log(`컴포넌트 수: ${layoutData.components.length}`); console.log(`격자 설정:`, layoutData.gridSettings); console.log(`해상도 설정:`, layoutData.screenResolution); // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId] ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); } // 기존 레이아웃 삭제 (컴포넌트와 메타데이터 모두) await query( `DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId] ); // 1. 메타데이터 저장 (격자 설정과 해상도 정보) if (layoutData.gridSettings || layoutData.screenResolution) { const metadata: any = { gridSettings: layoutData.gridSettings, screenResolution: layoutData.screenResolution, }; await query( `INSERT INTO screen_layouts ( screen_id, component_type, component_id, parent_id, position_x, position_y, width, height, properties, display_order ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ screenId, "_metadata", // 특별한 타입으로 메타데이터 식별 `_metadata_${screenId}`, null, 0, 0, 0, 0, JSON.stringify(metadata), -1, // 메타데이터는 맨 앞에 배치 ] ); console.log(`메타데이터 저장 완료:`, metadata); } // 2. 컴포넌트 저장 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, }); // 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 query( `INSERT INTO screen_layouts ( screen_id, component_type, component_id, parent_id, position_x, position_y, width, height, properties ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ screenId, component.type, component.id, component.parentId || null, component.position.x, component.position.y, component.size.width, component.size.height, JSON.stringify(properties), ] ); } console.log(`=== 레이아웃 저장 완료 ===`); } /** * 레이아웃 조회 (✅ Raw Query 전환 완료) */ async getLayout( screenId: number, companyCode: string ): Promise { console.log(`=== 레이아웃 로드 시작 ===`); console.log(`화면 ID: ${screenId}`); // 권한 확인 const screens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId] ); if (screens.length === 0) { return null; } const existingScreen = screens[0]; if (companyCode !== "*" && existingScreen.company_code !== companyCode) { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } const layouts = await query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order ASC NULLS LAST, layout_id ASC`, [screenId] ); console.log(`DB에서 조회된 레이아웃 수: ${layouts.length}`); // 메타데이터와 컴포넌트 분리 const metadataLayout = layouts.find( (layout) => layout.component_type === "_metadata" ); const componentLayouts = layouts.filter( (layout) => layout.component_type !== "_metadata" ); // 기본 메타데이터 설정 let gridSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true, }; let screenResolution = null; // 저장된 메타데이터가 있으면 적용 if (metadataLayout && metadataLayout.properties) { const metadata = metadataLayout.properties as any; if (metadata.gridSettings) { gridSettings = { ...gridSettings, ...metadata.gridSettings }; } if (metadata.screenResolution) { screenResolution = metadata.screenResolution; } console.log(`메타데이터 로드:`, { gridSettings, screenResolution }); } if (componentLayouts.length === 0) { return { components: [], gridSettings, screenResolution, }; } const components: ComponentData[] = componentLayouts.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}`); console.log(`최종 격자 설정:`, gridSettings); console.log(`최종 해상도 설정:`, screenResolution); return { components, gridSettings, screenResolution, }; } // ======================================== // 템플릿 관리 // ======================================== /** * 템플릿 목록 조회 (회사별) (✅ Raw Query 전환 완료) */ async getTemplatesByCompany( companyCode: string, type?: string, isPublic?: boolean ): Promise { const whereConditions: string[] = []; const params: any[] = []; if (companyCode !== "*") { whereConditions.push(`company_code = $${params.length + 1}`); params.push(companyCode); } if (type) { whereConditions.push(`template_type = $${params.length + 1}`); params.push(type); } if (isPublic !== undefined) { whereConditions.push(`is_public = $${params.length + 1}`); params.push(isPublic); } const whereSQL = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; const templates = await query( `SELECT * FROM screen_templates ${whereSQL} ORDER BY created_date DESC`, params ); return templates.map(this.mapToScreenTemplate); } /** * 템플릿 생성 (✅ Raw Query 전환 완료) */ async createTemplate( templateData: Partial ): Promise { const [template] = await query( `INSERT INTO screen_templates ( template_name, template_type, company_code, description, layout_data, is_public, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ templateData.templateName!, templateData.templateType!, templateData.companyCode!, templateData.description || null, templateData.layoutData ? JSON.stringify(JSON.parse(JSON.stringify(templateData.layoutData))) : null, templateData.isPublic || false, templateData.createdBy || null, ] ); return this.mapToScreenTemplate(template); } // ======================================== // 메뉴 할당 관리 // ======================================== /** * 화면-메뉴 할당 (✅ Raw Query 전환 완료) */ async assignScreenToMenu( screenId: number, assignmentData: MenuAssignmentRequest ): Promise { // 중복 할당 방지 const existing = await query<{ assignment_id: number }>( `SELECT assignment_id FROM screen_menu_assignments WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3 LIMIT 1`, [screenId, assignmentData.menuObjid, assignmentData.companyCode] ); if (existing.length > 0) { throw new Error("이미 할당된 화면입니다."); } await query( `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, created_by ) VALUES ($1, $2, $3, $4, $5)`, [ screenId, assignmentData.menuObjid, assignmentData.companyCode, assignmentData.displayOrder || 0, assignmentData.createdBy || null, ] ); } /** * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) */ async getScreensByMenu( menuObjid: number, companyCode: string ): Promise { const screens = await query( `SELECT sd.* FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 AND sma.company_code = $2 AND sma.is_active = 'Y' ORDER BY sma.display_order ASC`, [menuObjid, companyCode] ); return screens.map((screen) => this.mapToScreenDefinition(screen)); } /** * 화면-메뉴 할당 해제 (✅ Raw Query 전환 완료) */ async unassignScreenFromMenu( screenId: number, menuObjid: number, companyCode: string ): Promise { await query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`, [screenId, menuObjid, 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.display_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, tableLabelMap?: Map ): ScreenDefinition { const tableLabel = tableLabelMap?.get(data.table_name) || data.table_name; return { screenId: data.screen_id, screenName: data.screen_name, screenCode: data.screen_code, tableName: data.table_name, tableLabel: tableLabel, // 라벨이 있으면 라벨, 없으면 테이블명 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}`; } /** * 화면 복사 (화면 정보 + 레이아웃 모두 복사) (✅ Raw Query 전환 완료) */ async copyScreen( sourceScreenId: number, copyData: CopyScreenRequest ): Promise { // 트랜잭션으로 처리 return await transaction(async (client) => { // 1. 원본 화면 정보 조회 const sourceScreens = await client.query( `SELECT * FROM screen_definitions WHERE screen_id = $1 AND company_code = $2 LIMIT 1`, [sourceScreenId, copyData.companyCode] ); if (sourceScreens.rows.length === 0) { throw new Error("복사할 화면을 찾을 수 없습니다."); } const sourceScreen = sourceScreens.rows[0]; // 2. 화면 코드 중복 체크 const existingScreens = await client.query( `SELECT screen_id FROM screen_definitions WHERE screen_code = $1 AND company_code = $2 LIMIT 1`, [copyData.screenCode, copyData.companyCode] ); if (existingScreens.rows.length > 0) { throw new Error("이미 존재하는 화면 코드입니다."); } // 3. 새 화면 생성 const newScreenResult = await client.query( `INSERT INTO screen_definitions ( screen_code, screen_name, description, company_code, table_name, is_active, created_by, created_date, updated_by, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [ copyData.screenCode, copyData.screenName, copyData.description || sourceScreen.description, copyData.companyCode, sourceScreen.table_name, sourceScreen.is_active, copyData.createdBy, new Date(), copyData.createdBy, new Date(), ] ); const newScreen = newScreenResult.rows[0]; // 4. 원본 화면의 레이아웃 정보 조회 const sourceLayoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order ASC NULLS LAST`, [sourceScreenId] ); const sourceLayouts = sourceLayoutsResult.rows; // 5. 레이아웃이 있다면 복사 if (sourceLayouts.length > 0) { try { // ID 매핑 맵 생성 const idMapping: { [oldId: string]: string } = {}; // 새로운 컴포넌트 ID 미리 생성 sourceLayouts.forEach((layout: any) => { 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 client.query( `INSERT INTO screen_layouts ( screen_id, component_type, component_id, parent_id, position_x, position_y, width, height, properties, display_order, created_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ newScreen.screen_id, sourceLayout.component_type, newComponentId, newParentId, sourceLayout.position_x, sourceLayout.position_y, sourceLayout.width, sourceLayout.height, typeof sourceLayout.properties === 'string' ? sourceLayout.properties : JSON.stringify(sourceLayout.properties), sourceLayout.display_order, 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();