// ✅ 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 = Array.from( 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 targetScreens = await query<{ company_code: string | null }>( `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId] ); if (targetScreens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const targetScreen = targetScreens[0]; if ( userCompanyCode !== "*" && targetScreen.company_code !== "*" && targetScreen.company_code !== userCompanyCode ) { throw new Error("이 화면에 접근할 권한이 없습니다."); } // 같은 회사의 모든 활성 화면에서 이 화면을 참조하는지 확인 const whereConditions: string[] = ["sd.is_active != 'D'"]; const params: any[] = []; if (userCompanyCode !== "*") { whereConditions.push( `sd.company_code IN ($${params.length + 1}, $${params.length + 2})` ); params.push(userCompanyCode, "*"); } const whereSQL = whereConditions.join(" AND "); // 화면과 레이아웃을 JOIN해서 조회 const allScreens = await query( `SELECT sd.screen_id, sd.screen_name, sd.screen_code, sd.company_code, sl.layout_id, sl.component_id, sl.component_type, sl.properties FROM screen_definitions sd LEFT JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE ${whereSQL} ORDER BY sd.screen_id, sl.layout_id`, params ); 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 테이블에서 버튼 컴포넌트 확인 (위젯 타입만) if (screen.component_type === "widget") { const properties = screen.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: screen.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: screen.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: screen.component_id, componentType: "button", referenceType: "url", }); } } } // 기존 layout_metadata도 확인 (하위 호환성) - 현재는 사용하지 않음 // 실제 데이터는 screen_layouts 테이블에서 개별적으로 조회해야 함 } catch (error) { console.error( `화면 ${screen.screen_id}의 레이아웃 분석 중 오류:`, error ); continue; } } // 메뉴 할당 확인 (Raw Query) try { const menuAssignments = await query<{ assignment_id: number; menu_objid: number; menu_name_kor?: string; }>( `SELECT sma.assignment_id, sma.menu_objid, mi.menu_name_kor FROM screen_menu_assignments sma LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid WHERE sma.screen_id = $1 AND sma.is_active = 'Y'`, [screenId] ); // 메뉴에 할당된 경우 의존성에 추가 for (const assignment of menuAssignments) { dependencies.push({ screenId: 0, // 메뉴는 화면이 아니므로 0으로 설정 screenName: assignment.menu_name_kor || "알 수 없는 메뉴", screenCode: `MENU_${assignment.menu_objid}`, componentId: `menu_${assignment.assignment_id}`, componentType: "menu", referenceType: "menu_assignment", }); } } catch (error) { console.error("메뉴 할당 확인 중 오류:", error); // 메뉴 할당 확인 실패해도 다른 의존성 체크는 계속 진행 } 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] ); }); } /** * 화면 복원 (휴지통에서 복원) (✅ Raw Query 전환 완료) */ async restoreScreen( screenId: number, userCompanyCode: string, restoredBy: string ): Promise { // 권한 확인 const screens = await query<{ company_code: string | null; is_active: string; screen_code: string; }>( `SELECT company_code, is_active, screen_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, [screenId] ); if (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; 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] ); }); } /** * 휴지통 화면들의 메뉴 할당 정리 (관리자용) (✅ Raw Query 전환 완료) */ async cleanupDeletedScreenMenuAssignments(): Promise<{ updatedCount: number; message: string; }> { const result = await query( `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'`, [] ); const updatedCount = result.length; return { updatedCount, message: `${updatedCount}개의 메뉴 할당이 정리되었습니다.`, }; } /** * 화면 영구 삭제 (휴지통에서 완전 삭제) (✅ Raw Query 전환 완료) */ async permanentDeleteScreen( screenId: number, userCompanyCode: string ): Promise { // 권한 확인 const screens = 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 (screens.length === 0) { throw new Error("화면을 찾을 수 없습니다."); } const existingScreen = screens[0]; if ( userCompanyCode !== "*" && existingScreen.company_code !== userCompanyCode ) { throw new Error("이 화면을 영구 삭제할 권한이 없습니다."); } // 삭제된 화면이 아닌 경우 영구 삭제 불가 if (existingScreen.is_active !== "D") { throw new Error("휴지통에 있는 화면만 영구 삭제할 수 있습니다."); } // 물리적 삭제 (수동으로 관련 데이터 삭제) await transaction(async (client) => { await client.query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [ screenId, ]); await client.query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, [screenId] ); await client.query( `DELETE FROM screen_definitions WHERE screen_id = $1`, [screenId] ); }); } /** * 휴지통 화면 목록 조회 (✅ Raw Query 전환 완료) */ async getDeletedScreens( companyCode: string, page: number = 1, size: number = 20 ): Promise< PaginatedResponse< ScreenDefinition & { deletedDate?: Date; deletedBy?: string; deleteReason?: string; } > > { const offset = (page - 1) * size; 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, totalResult] = await Promise.all([ query( `SELECT * FROM screen_definitions WHERE ${whereSQL} ORDER BY deleted_date DESC NULLS LAST 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); // 테이블 라벨 정보를 한 번에 조회 const tableNames = Array.from( new Set(screens.map((s: any) => s.table_name).filter(Boolean)) ); let tableLabelMap = new Map(); if (tableNames.length > 0) { 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: any) => [ tl.table_name, tl.table_label || tl.table_name, ]) ); } return { data: screens.map((screen: any) => ({ ...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, }; } // ======================================== // 테이블 관리 // ======================================== /** * 테이블 목록 조회 (모든 테이블) (✅ Raw Query 전환 완료) */ async getTables(companyCode: string): Promise { try { // PostgreSQL에서 사용 가능한 테이블 목록 조회 const tables = await query<{ table_name: string }>( `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 query<{ table_name: string }>( `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name = $1`, [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} 정보를 조회할 수 없습니다.`); } } /** * 테이블 컬럼 정보 조회 (✅ Raw Query 전환 완료) */ async getTableColumns( tableName: string, companyCode: string ): Promise { try { // 테이블 컬럼 정보 조회 const columns = await query<{ 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 = $1 ORDER BY ordinal_position`, [tableName] ); // column_labels 테이블에서 웹타입 정보 조회 (있는 경우) const webTypeInfo = await query<{ column_name: string; web_type: string | null; column_label: string | null; detail_settings: any; }>( `SELECT column_name, web_type, column_label, detail_settings FROM column_labels WHERE table_name = $1`, [tableName] ); // 컬럼 정보 매핑 return columns.map((column: any) => { 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, Math.round(component.position.x), // 정수로 반올림 Math.round(component.position.y), // 정수로 반올림 Math.round(component.size.width), // 정수로 반올림 Math.round(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] ); } // ======================================== // 테이블 타입 연계 // ======================================== /** * 컬럼 정보 조회 (웹 타입 포함) (✅ Raw Query 전환 완료) */ async getColumnInfo(tableName: string): Promise { const columns = await query( `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 = $1 ORDER BY COALESCE(cl.display_order, c.ordinal_position)`, [tableName] ); return columns as ColumnInfo[]; } /** * 웹 타입 설정 (✅ Raw Query 전환 완료) */ async setColumnWebType( tableName: string, columnName: string, webType: WebType, additionalSettings?: Partial ): Promise { // UPSERT를 INSERT ... ON CONFLICT로 변환 await query( `INSERT INTO column_labels ( table_name, column_name, column_label, web_type, detail_settings, code_category, reference_table, reference_column, display_column, is_visible, display_order, description, created_date, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (table_name, column_name) DO UPDATE SET web_type = $4, column_label = $3, detail_settings = $5, code_category = $6, reference_table = $7, reference_column = $8, display_column = $9, is_visible = $10, display_order = $11, description = $12, updated_date = $14`, [ tableName, columnName, additionalSettings?.columnLabel || null, webType, additionalSettings?.detailSettings ? JSON.stringify(additionalSettings.detailSettings) : null, additionalSettings?.codeCategory || null, additionalSettings?.referenceTable || null, additionalSettings?.referenceColumn || null, (additionalSettings as any)?.displayColumn || null, additionalSettings?.isVisible ?? true, additionalSettings?.displayOrder ?? 0, additionalSettings?.description || null, new 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, }; } /** * 화면 코드 자동 생성 (회사코드 + '_' + 순번) (✅ Raw Query 전환 완료) */ async generateScreenCode(companyCode: string): Promise { // 해당 회사의 기존 화면 코드들 조회 (Raw Query) const existingScreens = await query<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions WHERE company_code = $1 AND screen_code LIKE $2 ORDER BY screen_code DESC`, [companyCode, `${companyCode}%`] ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 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, Math.round(sourceLayout.position_x), // 정수로 반올림 Math.round(sourceLayout.position_y), // 정수로 반올림 Math.round(sourceLayout.width), // 정수로 반올림 Math.round(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();