diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index da0ea772..3ac5d26b 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1428,10 +1428,51 @@ export async function deleteMenu( } } + // 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리 + const menuObjid = Number(menuId); + + // 1. category_column_mapping에서 menu_objid를 NULL로 설정 + await query( + `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 2. code_category에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 3. code_info에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 4. numbering_rules에서 menu_objid를 NULL로 설정 + await query( + `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 5. rel_menu_auth에서 관련 권한 삭제 + await query( + `DELETE FROM rel_menu_auth WHERE menu_objid = $1`, + [menuObjid] + ); + + // 6. screen_menu_assignments에서 관련 할당 삭제 + await query( + `DELETE FROM screen_menu_assignments WHERE menu_objid = $1`, + [menuObjid] + ); + + logger.info("메뉴 관련 데이터 정리 완료", { menuObjid }); + // Raw Query를 사용한 메뉴 삭제 const [deletedMenu] = await query( `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [Number(menuId)] + [menuObjid] ); logger.info("메뉴 삭제 성공", { deletedMenu }); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index c7ecf75e..5605031e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -325,6 +325,53 @@ export const getDeletedScreens = async ( } }; +// 활성 화면 일괄 삭제 (휴지통으로 이동) +export const bulkDeleteScreens = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { companyCode, userId } = req.user as any; + const { screenIds, deleteReason, force } = req.body; + + if (!Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ + success: false, + message: "삭제할 화면 ID 목록이 필요합니다.", + }); + } + + const result = await screenManagementService.bulkDeleteScreens( + screenIds, + companyCode, + userId, + deleteReason, + force || false + ); + + let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`; + if (result.skippedCount > 0) { + message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`; + } + + return res.json({ + success: true, + message, + result: { + deletedCount: result.deletedCount, + skippedCount: result.skippedCount, + errors: result.errors, + }, + }); + } catch (error) { + console.error("활성 화면 일괄 삭제 실패:", error); + return res.status(500).json({ + success: false, + message: "일괄 삭제에 실패했습니다.", + }); + } +}; + // 휴지통 화면 일괄 영구 삭제 export const bulkPermanentDeleteScreens = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 4207c719..67263277 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -8,6 +8,7 @@ import { updateScreen, updateScreenInfo, deleteScreen, + bulkDeleteScreens, checkScreenDependencies, restoreScreen, permanentDeleteScreen, @@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen); router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 +router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동) router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지 router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크 router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 70b45af4..a0e707c1 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -53,6 +53,7 @@ interface ScreenDefinition { layout_metadata: any; db_source_type: string | null; db_connection_id: number | null; + source_screen_id: number | null; // 원본 화면 ID (복사 추적용) } /** @@ -234,6 +235,27 @@ export class MenuCopyService { } } } + + // 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId) + if (props?.componentConfig?.leftScreenId) { + const leftScreenId = props.componentConfig.leftScreenId; + const numId = + typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); + } + } + + if (props?.componentConfig?.rightScreenId) { + const rightScreenId = props.componentConfig.rightScreenId; + const numId = + typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); + } + } } return referenced; @@ -431,14 +453,16 @@ export class MenuCopyService { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; - // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열) if ( key === "screen_id" || key === "screenId" || - key === "targetScreenId" + key === "targetScreenId" || + key === "leftScreenId" || + key === "rightScreenId" ) { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = screenIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 @@ -856,7 +880,10 @@ export class MenuCopyService { } /** - * 화면 복사 + * 화면 복사 (업데이트 또는 신규 생성) + * - source_screen_id로 기존 복사본 찾기 + * - 변경된 내용이 있으면 업데이트 + * - 없으면 새로 복사 */ private async copyScreens( screenIds: Set, @@ -876,18 +903,19 @@ export class MenuCopyService { return screenIdMap; } - logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); - // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; - newScreenId: number; + targetScreenId: number; screenDef: ScreenDefinition; + isUpdate: boolean; // 업데이트인지 신규 생성인지 }> = []; for (const originalScreenId of screenIds) { try { - // 1) screen_definitions 조회 + // 1) 원본 screen_definitions 조회 const screenDefResult = await client.query( `SELECT * FROM screen_definitions WHERE screen_id = $1`, [originalScreenId] @@ -900,122 +928,198 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; - // 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인 - const existingScreenResult = await client.query<{ screen_id: number }>( - `SELECT screen_id FROM screen_definitions - WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + // 2) 기존 복사본 찾기: source_screen_id로 검색 + const existingCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, - [screenDef.screen_code, targetCompanyCode] + [originalScreenId, targetCompanyCode] ); - if (existingScreenResult.rows.length > 0) { - // 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑 - const existingScreenId = existingScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, existingScreenId); - logger.info( - ` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})` - ); - continue; // 레이아웃 복사도 스킵 - } - - // 3) 새 screen_code 생성 - const newScreenCode = await this.generateUniqueScreenCode( - targetCompanyCode, - client - ); - - // 4) 화면명 변환 적용 + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { - // 1. 제거할 텍스트 제거 if (screenNameConfig.removeText?.trim()) { transformedScreenName = transformedScreenName.replace( new RegExp(screenNameConfig.removeText.trim(), "g"), "" ); - transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거 + transformedScreenName = transformedScreenName.trim(); } - - // 2. 접두사 추가 if (screenNameConfig.addPrefix?.trim()) { transformedScreenName = screenNameConfig.addPrefix.trim() + " " + transformedScreenName; } } - // 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) - const newScreenResult = await client.query<{ screen_id: number }>( - `INSERT INTO screen_definitions ( - screen_name, screen_code, table_name, company_code, - description, is_active, layout_metadata, - db_source_type, db_connection_id, created_by, - deleted_date, deleted_by, delete_reason - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING screen_id`, - [ - transformedScreenName, // 변환된 화면명 - newScreenCode, // 새 화면 코드 - screenDef.table_name, - targetCompanyCode, // 새 회사 코드 - screenDef.description, - screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 - screenDef.layout_metadata, - screenDef.db_source_type, - screenDef.db_connection_id, - userId, - null, // deleted_date: NULL (새 화면은 삭제되지 않음) - null, // deleted_by: NULL - null, // delete_reason: NULL - ] - ); + if (existingCopyResult.rows.length > 0) { + // === 기존 복사본이 있는 경우: 업데이트 === + const existingScreen = existingCopyResult.rows[0]; + const existingScreenId = existingScreen.screen_id; - const newScreenId = newScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, newScreenId); + // 원본 레이아웃 조회 + const sourceLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); - logger.info( - ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` - ); + // 대상 레이아웃 조회 + const targetLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [existingScreenId] + ); - // 저장해서 2단계에서 처리 - screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + // 변경 여부 확인 (레이아웃 개수 또는 내용 비교) + const hasChanges = this.hasLayoutChanges( + sourceLayoutsResult.rows, + targetLayoutsResult.rows + ); + + if (hasChanges) { + // 변경 사항이 있으면 업데이트 + logger.info( + ` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + + // screen_definitions 업데이트 + await client.query( + `UPDATE screen_definitions SET + screen_name = $1, + table_name = $2, + description = $3, + is_active = $4, + layout_metadata = $5, + db_source_type = $6, + db_connection_id = $7, + updated_by = $8, + updated_date = NOW() + WHERE screen_id = $9`, + [ + transformedScreenName, + screenDef.table_name, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + existingScreenId, + ] + ); + + screenIdMap.set(originalScreenId, existingScreenId); + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: existingScreenId, + screenDef, + isUpdate: true, + }); + } else { + // 변경 사항이 없으면 스킵 + screenIdMap.set(originalScreenId, existingScreenId); + logger.info( + ` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + } + } else { + // === 기존 복사본이 없는 경우: 신규 생성 === + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason, source_screen_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING screen_id`, + [ + transformedScreenName, + newScreenCode, + screenDef.table_name, + targetCompanyCode, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, + null, + null, + originalScreenId, // source_screen_id 저장 + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: newScreenId, + screenDef, + isUpdate: false, + }); + } } catch (error: any) { logger.error( - `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + `❌ 화면 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + // === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) === logger.info( - `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + `\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); for (const { originalScreenId, - newScreenId, + targetScreenId, screenDef, + isUpdate, } of screenDefsToProcess) { try { - // screen_layouts 복사 + // 원본 레이아웃 조회 const layoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [originalScreenId] ); - // 1단계: component_id 매핑 생성 (원본 → 새 ID) + if (isUpdate) { + // 업데이트: 기존 레이아웃 삭제 후 새로 삽입 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = $1`, + [targetScreenId] + ); + logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`); + } + + // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); for (const layout of layoutsResult.rows) { const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; componentIdMap.set(layout.component_id, newComponentId); } - // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + // 레이아웃 삽입 for (const layout of layoutsResult.rows) { const newComponentId = componentIdMap.get(layout.component_id)!; - // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) const newParentId = layout.parent_id ? componentIdMap.get(layout.parent_id) || layout.parent_id : null; @@ -1023,7 +1127,6 @@ export class MenuCopyService { ? componentIdMap.get(layout.zone_id) || layout.zone_id : null; - // properties 내부 참조 업데이트 const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, @@ -1037,38 +1140,94 @@ export class MenuCopyService { display_order, layout_type, layout_config, zones_config, zone_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, [ - newScreenId, // 새 화면 ID + targetScreenId, layout.component_type, - newComponentId, // 새 컴포넌트 ID - newParentId, // 매핑된 parent_id + newComponentId, + newParentId, layout.position_x, layout.position_y, layout.width, layout.height, - updatedProperties, // 업데이트된 속성 + updatedProperties, layout.display_order, layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, // 매핑된 zone_id + newZoneId, ] ); } - logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + const action = isUpdate ? "업데이트" : "복사"; + logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`); } catch (error: any) { logger.error( - `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + `❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + // 통계 출력 + const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length; + const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length; + const skipCount = screenIds.size - screenDefsToProcess.length; + + logger.info(` +✅ 화면 처리 완료: + - 신규 복사: ${newCount}개 + - 업데이트: ${updateCount}개 + - 스킵 (변경 없음): ${skipCount}개 + - 총 매핑: ${screenIdMap.size}개 + `); + return screenIdMap; } + /** + * 레이아웃 변경 여부 확인 + */ + private hasLayoutChanges( + sourceLayouts: ScreenLayout[], + targetLayouts: ScreenLayout[] + ): boolean { + // 1. 레이아웃 개수가 다르면 변경됨 + if (sourceLayouts.length !== targetLayouts.length) { + return true; + } + + // 2. 각 레이아웃의 주요 속성 비교 + for (let i = 0; i < sourceLayouts.length; i++) { + const source = sourceLayouts[i]; + const target = targetLayouts[i]; + + // component_type이 다르면 변경됨 + if (source.component_type !== target.component_type) { + return true; + } + + // 위치/크기가 다르면 변경됨 + if ( + source.position_x !== target.position_x || + source.position_y !== target.position_y || + source.width !== target.width || + source.height !== target.height + ) { + return true; + } + + // properties의 JSON 문자열 비교 (깊은 비교) + const sourceProps = JSON.stringify(source.properties || {}); + const targetProps = JSON.stringify(target.properties || {}); + if (sourceProps !== targetProps) { + return true; + } + } + + return false; + } + /** * 메뉴 위상 정렬 (부모 먼저) */ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 007a39e7..6628cf4c 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -892,6 +892,134 @@ export class ScreenManagementService { }; } + /** + * 활성 화면 일괄 삭제 (휴지통으로 이동) + */ + async bulkDeleteScreens( + screenIds: number[], + userCompanyCode: string, + deletedBy: string, + deleteReason?: string, + force: boolean = false + ): Promise<{ + deletedCount: number; + skippedCount: number; + errors: Array<{ screenId: number; error: string }>; + }> { + if (screenIds.length === 0) { + throw new Error("삭제할 화면을 선택해주세요."); + } + + let deletedCount = 0; + let skippedCount = 0; + const errors: Array<{ screenId: number; error: string }> = []; + + // 각 화면을 개별적으로 삭제 처리 + for (const screenId of screenIds) { + try { + // 권한 확인 (Raw Query) + const existingResult = await query<{ + company_code: string | null; + is_active: string; + screen_name: string; + }>( + `SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (existingResult.length === 0) { + skippedCount++; + errors.push({ + screenId, + error: "화면을 찾을 수 없습니다.", + }); + continue; + } + + const existingScreen = existingResult[0]; + + // 권한 확인 + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + skippedCount++; + errors.push({ + screenId, + error: "이 화면을 삭제할 권한이 없습니다.", + }); + continue; + } + + // 이미 삭제된 화면인지 확인 + if (existingScreen.is_active === "D") { + skippedCount++; + errors.push({ + screenId, + error: "이미 삭제된 화면입니다.", + }); + continue; + } + + // 강제 삭제가 아닌 경우 의존성 체크 + if (!force) { + const dependencyCheck = await this.checkScreenDependencies( + screenId, + userCompanyCode + ); + if (dependencyCheck.hasDependencies) { + skippedCount++; + errors.push({ + screenId, + error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`, + }); + continue; + } + } + + // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 + await transaction(async (client) => { + const now = new Date(); + + // 소프트 삭제 (휴지통으로 이동) + 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`, + [now, deletedBy, deleteReason || null, now, deletedBy, screenId] + ); + + // 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거) + await client.query( + `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, + [screenId] + ); + }); + + deletedCount++; + logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`); + } catch (error) { + skippedCount++; + errors.push({ + screenId, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + logger.error(`화면 삭제 실패: ${screenId}`, error); + } + } + + logger.info( + `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개` + ); + + return { deletedCount, skippedCount, errors }; + } + /** * 휴지통 화면 일괄 영구 삭제 */ @@ -1517,11 +1645,23 @@ export class ScreenManagementService { }; } + // 🔥 최신 inputType 정보 조회 (table_type_columns에서) + const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode); + const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; + + // 🔥 최신 inputType으로 widgetType 및 componentType 업데이트 + const tableName = properties?.tableName; + const columnName = properties?.columnName; + const latestTypeInfo = tableName && columnName + ? inputTypeMap.get(`${tableName}.${columnName}`) + : null; + const component = { id: layout.component_id, - type: layout.component_type as any, + // 🔥 최신 componentType이 있으면 type 덮어쓰기 + type: latestTypeInfo?.componentType || layout.component_type as any, position: { x: layout.position_x, y: layout.position_y, @@ -1530,6 +1670,17 @@ export class ScreenManagementService { size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, + // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 + ...(latestTypeInfo && { + widgetType: latestTypeInfo.inputType, + inputType: latestTypeInfo.inputType, + componentType: latestTypeInfo.componentType, + componentConfig: { + ...properties?.componentConfig, + type: latestTypeInfo.componentType, + inputType: latestTypeInfo.inputType, + }, + }), }; console.log(`로드된 컴포넌트:`, { @@ -1539,6 +1690,9 @@ export class ScreenManagementService { size: component.size, parentId: component.parentId, title: (component as any).title, + widgetType: (component as any).widgetType, + componentType: (component as any).componentType, + latestTypeInfo, }); return component; @@ -1558,6 +1712,112 @@ export class ScreenManagementService { }; } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컴포넌트들의 최신 inputType 정보 조회 + * @param layouts - 레이아웃 목록 + * @param companyCode - 회사 코드 + * @returns Map<"tableName.columnName", { inputType, componentType }> + */ + private async getLatestInputTypes( + layouts: any[], + companyCode: string + ): Promise> { + const inputTypeMap = new Map(); + + // tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출 + const tableColumnPairs = new Set(); + for (const layout of layouts) { + const properties = layout.properties as any; + if (properties?.tableName && properties?.columnName) { + tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`); + } + } + + if (tableColumnPairs.size === 0) { + return inputTypeMap; + } + + // 각 테이블-컬럼 조합에 대해 최신 inputType 조회 + const pairs = Array.from(tableColumnPairs).map(pair => { + const [tableName, columnName] = pair.split('|'); + return { tableName, columnName }; + }); + + // 배치 쿼리로 한 번에 조회 + const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', '); + const params = pairs.flatMap(p => [p.tableName, p.columnName]); + + try { + const results = await query<{ table_name: string; column_name: string; input_type: string }>( + `SELECT table_name, column_name, input_type + FROM table_type_columns + WHERE (table_name, column_name) IN (${placeholders}) + AND company_code = $${params.length + 1}`, + [...params, companyCode] + ); + + for (const row of results) { + const componentType = this.getComponentIdFromInputType(row.input_type); + inputTypeMap.set(`${row.table_name}.${row.column_name}`, { + inputType: row.input_type, + componentType: componentType, + }); + } + + console.log(`최신 inputType 조회 완료: ${results.length}개`); + } catch (error) { + console.warn(`최신 inputType 조회 실패 (무시됨):`, error); + } + + return inputTypeMap; + } + // ======================================== // 템플릿 관리 // ======================================== diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 64eb44c8..8e01903b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -797,6 +797,9 @@ export class TableManagementService { ] ); + // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 + await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode); + // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; cache.delete(cacheKeyPattern); @@ -816,6 +819,135 @@ export class TableManagementService { } } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화 + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param inputType - 새로운 입력 타입 + * @param companyCode - 회사 코드 + */ + private async syncScreenLayoutsInputType( + tableName: string, + columnName: string, + inputType: string, + companyCode: string + ): Promise { + try { + // 해당 컬럼을 사용하는 화면 레이아웃 조회 + const affectedLayouts = await query<{ + layout_id: number; + screen_id: number; + component_id: string; + component_type: string; + properties: any; + }>( + `SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sl.properties->>'tableName' = $1 + AND sl.properties->>'columnName' = $2 + AND (sd.company_code = $3 OR $3 = '*')`, + [tableName, columnName, companyCode] + ); + + if (affectedLayouts.length === 0) { + logger.info( + `화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음` + ); + return; + } + + logger.info( + `화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견` + ); + + // 새로운 componentType 계산 + const newComponentType = this.getComponentIdFromInputType(inputType); + + // 각 레이아웃의 widgetType, componentType 업데이트 + for (const layout of affectedLayouts) { + const updatedProperties = { + ...layout.properties, + widgetType: inputType, + inputType: inputType, + // componentConfig 내부의 type도 업데이트 + componentConfig: { + ...layout.properties?.componentConfig, + type: newComponentType, + inputType: inputType, + }, + }; + + await query( + `UPDATE screen_layouts + SET properties = $1, component_type = $2 + WHERE layout_id = $3`, + [JSON.stringify(updatedProperties), newComponentType, layout.layout_id] + ); + + logger.info( + `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}` + ); + } + + logger.info( + `화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨` + ); + } catch (error) { + // 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행 + logger.warn( + `화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`, + error + ); + } + } + /** * 입력 타입별 기본 상세 설정 생성 */ diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 5dcbb6be..cd0c462d 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -1093,229 +1093,283 @@ export default function TableManagementPage() { {/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
-
-
- {!selectedTable ? ( -
-
-

- {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} -

-
+
+ {!selectedTable ? ( +
+
+

+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} +

- ) : ( - <> - {/* 테이블 라벨 설정 */} -
-
- setTableLabel(e.target.value)} - placeholder="테이블 표시명" - className="h-10 text-sm" - /> -
-
- setTableDescription(e.target.value)} - placeholder="테이블 설명" - className="h-10 text-sm" - /> -
+
+ ) : ( + <> + {/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */} +
+
+ setTableLabel(e.target.value)} + placeholder="테이블 표시명" + className="h-10 text-sm" + />
+
+ setTableDescription(e.target.value)} + placeholder="테이블 설명" + className="h-10 text-sm" + /> +
+ {/* 저장 버튼 (항상 보이도록 상단에 배치) */} + +
- {columnsLoading ? ( -
- - - {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} - + {columnsLoading ? ( +
+ + + {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} + +
+ ) : columns.length === 0 ? ( +
+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} +
+ ) : ( +
+ {/* 컬럼 헤더 (고정) */} +
+
컬럼명
+
라벨
+
입력 타입
+
설명
- ) : columns.length === 0 ? ( -
- {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} -
- ) : ( -
- {/* 컬럼 헤더 */} -
-
컬럼명
-
라벨
-
입력 타입
-
설명
-
- {/* 컬럼 리스트 */} -
{ - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 - if (scrollHeight - scrollTop <= clientHeight + 100) { - loadMoreColumns(); - } - }} - > - {columns.map((column, index) => ( -
-
-
{column.columnName}
-
-
- handleLabelChange(column.columnName, e.target.value)} - placeholder={column.columnName} - className="h-8 text-xs" - /> -
-
-
- {/* 입력 타입 선택 */} + {/* 컬럼 리스트 (스크롤 영역) */} +
{ + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 + if (scrollHeight - scrollTop <= clientHeight + 100) { + loadMoreColumns(); + } + }} + > + {columns.map((column, index) => ( +
+
+
{column.columnName}
+
+
+ handleLabelChange(column.columnName, e.target.value)} + placeholder={column.columnName} + className="h-8 text-xs" + /> +
+
+
+ {/* 입력 타입 선택 */} + + {/* 입력 타입이 'code'인 경우 공통코드 선택 */} + {column.inputType === "code" && ( - {/* 입력 타입이 'code'인 경우 공통코드 선택 */} - {column.inputType === "code" && ( - - )} - {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} - {column.inputType === "category" && ( -
- -
- {secondLevelMenus.length === 0 ? ( -

- 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다. -

- ) : ( - secondLevelMenus.map((menu) => { - // menuObjid를 숫자로 변환하여 비교 - const menuObjidNum = Number(menu.menuObjid); - const isChecked = (column.categoryMenus || []).includes(menuObjidNum); - - return ( -
- { - const currentMenus = column.categoryMenus || []; - const newMenus = e.target.checked - ? [...currentMenus, menuObjidNum] - : currentMenus.filter((id) => id !== menuObjidNum); - - setColumns((prev) => - prev.map((col) => - col.columnName === column.columnName - ? { ...col, categoryMenus: newMenus } - : col - ) - ); - }} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" - /> - -
- ); - }) - )} -
- {column.categoryMenus && column.categoryMenus.length > 0 && ( -

- {column.categoryMenus.length}개 메뉴 선택됨 + )} + {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} + {column.inputType === "category" && ( +

+ +
+ {secondLevelMenus.length === 0 ? ( +

+ 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.

+ ) : ( + secondLevelMenus.map((menu) => { + // menuObjid를 숫자로 변환하여 비교 + const menuObjidNum = Number(menu.menuObjid); + const isChecked = (column.categoryMenus || []).includes(menuObjidNum); + + return ( +
+ { + const currentMenus = column.categoryMenus || []; + const newMenus = e.target.checked + ? [...currentMenus, menuObjidNum] + : currentMenus.filter((id) => id !== menuObjidNum); + + setColumns((prev) => + prev.map((col) => + col.columnName === column.columnName + ? { ...col, categoryMenus: newMenus } + : col + ) + ); + }} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" + /> + +
+ ); + }) )}
- )} - {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} - {column.inputType === "entity" && ( - <> - {/* 참조 테이블 */} + {column.categoryMenus && column.categoryMenus.length > 0 && ( +

+ {column.categoryMenus.length}개 메뉴 선택됨 +

+ )} +
+ )} + {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} + {column.inputType === "entity" && ( + <> + {/* 참조 테이블 */} +
+ + +
+ + {/* 조인 컬럼 */} + {column.referenceTable && column.referenceTable !== "none" && (
+ )} - {/* 조인 컬럼 */} - {column.referenceTable && column.referenceTable !== "none" && ( + {/* 표시 컬럼 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && (
- handleDetailSettingsChange( - column.columnName, - "entity_display_column", - value, - ) - } - > - - - - - -- 선택 안함 -- - {referenceTableColumns[column.referenceTable]?.map((refCol, index) => ( - - {refCol.columnName} - - ))} - {(!referenceTableColumns[column.referenceTable] || - referenceTableColumns[column.referenceTable].length === 0) && ( - -
-
- 로딩중 -
-
- )} -
- -
- )} - - {/* 설정 완료 표시 */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( -
- - 설정 완료 -
- )} - - )} -
-
-
- handleColumnChange(index, "description", e.target.value)} - placeholder="설명" - className="h-8 w-full text-xs" - /> + {/* 설정 완료 표시 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && + column.displayColumn && + column.displayColumn !== "none" && ( +
+ + 설정 완료 +
+ )} + + )}
- ))} -
+
+ handleColumnChange(index, "description", e.target.value)} + placeholder="설명" + className="h-8 w-full text-xs" + /> +
+
+ ))} {/* 로딩 표시 */} {columnsLoading && ( @@ -1428,28 +1435,16 @@ export default function TableManagementPage() { 더 많은 컬럼 로딩 중...
)} - - {/* 페이지 정보 */} -
- {columns.length} / {totalColumns} 컬럼 표시됨 -
- - {/* 전체 저장 버튼 */} -
- -
- )} - - )} -
+ + {/* 페이지 정보 (고정 하단) */} +
+ {columns.length} / {totalColumns} 컬럼 표시됨 +
+
+ )} + + )}
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 8ab31ff7..5ce253cb 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -308,7 +308,7 @@ function ScreenViewPage() {
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && ( diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 8394cd6d..faba6df5 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -15,6 +15,8 @@ import { ChevronDown, ChevronRight, UserCheck, + LogOut, + User, } from "lucide-react"; import { useMenu } from "@/contexts/MenuContext"; import { useAuth } from "@/hooks/useAuth"; @@ -22,8 +24,17 @@ import { useProfile } from "@/hooks/useProfile"; import { MenuItem } from "@/lib/api/menu"; import { menuScreenApi } from "@/lib/api/screen"; import { toast } from "sonner"; -import { MainHeader } from "./MainHeader"; import { ProfileModal } from "./ProfileModal"; +import { Logo } from "./Logo"; +import { SideMenu } from "./SideMenu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; // useAuth의 UserInfo 타입을 확장 interface ExtendedUserInfo { @@ -397,82 +408,152 @@ function AppLayoutInner({ children }: AppLayoutProps) { const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); return ( -
- {/* MainHeader 컴포넌트 사용 */} - { - // 모바일에서만 토글 동작 - if (isMobile) { - setSidebarOpen(!sidebarOpen); - } - }} - onProfileClick={openProfileModal} - onLogout={handleLogout} - /> +
+ {/* 모바일 사이드바 오버레이 */} + {sidebarOpen && isMobile && ( +
setSidebarOpen(false)} /> + )} -
- {/* 모바일 사이드바 오버레이 */} - {sidebarOpen && isMobile && ( -
setSidebarOpen(false)} /> + {/* 왼쪽 사이드바 */} +
+ + + + + 프로필 + + + + 로그아웃 + + + +
+ + + {/* 가운데 컨텐츠 영역 - 스크롤 가능 */} +
+ {children} +
{/* 프로필 수정 모달 */} { const oldWidth = screenResolution.width; @@ -1273,122 +1273,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const newWidth = newResolution.width; const newHeight = newResolution.height; - console.log("📱 해상도 변경 시작:", { + console.log("📱 해상도 변경:", { from: `${oldWidth}x${oldHeight}`, to: `${newWidth}x${newHeight}`, - hasComponents: layout.components.length > 0, - snapToGrid: layout.gridSettings?.snapToGrid || false, + componentsCount: layout.components.length, }); setScreenResolution(newResolution); - // 컴포넌트가 없으면 해상도만 변경 - if (layout.components.length === 0) { - const updatedLayout = { - ...layout, - screenResolution: newResolution, - }; - setLayout(updatedLayout); - saveToHistory(updatedLayout); - console.log("✅ 해상도 변경 완료 (컴포넌트 없음)"); - return; - } - - // 비율 계산 - const scaleX = newWidth / oldWidth; - const scaleY = newHeight / oldHeight; - - console.log("📐 스케일링 비율:", { - scaleX: `${(scaleX * 100).toFixed(2)}%`, - scaleY: `${(scaleY * 100).toFixed(2)}%`, - }); - - // 컴포넌트 재귀적으로 스케일링하는 함수 - const scaleComponent = (comp: ComponentData): ComponentData => { - // 위치 스케일링 - const scaledPosition = { - x: comp.position.x * scaleX, - y: comp.position.y * scaleY, - z: comp.position.z || 1, - }; - - // 크기 스케일링 - const scaledSize = { - width: comp.size.width * scaleX, - height: comp.size.height * scaleY, - }; - - return { - ...comp, - position: scaledPosition, - size: scaledSize, - }; - }; - - // 모든 컴포넌트 스케일링 (그룹의 자식도 자동으로 스케일링됨) - const scaledComponents = layout.components.map(scaleComponent); - - console.log("🔄 컴포넌트 스케일링 완료:", { - totalComponents: scaledComponents.length, - groupComponents: scaledComponents.filter((c) => c.type === "group").length, - note: "그룹의 자식 컴포넌트도 모두 스케일링됨", - }); - - // 격자 스냅이 활성화된 경우 격자에 맞춰 재조정 - let finalComponents = scaledComponents; - if (layout.gridSettings?.snapToGrid) { - const newGridInfo = calculateGridInfo(newWidth, newHeight, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - const gridUtilSettings = { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: true, - }; - - finalComponents = scaledComponents.map((comp) => { - const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings); - const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings); - - // gridColumns 재계산 - const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings); - - return { - ...comp, - position: snappedPosition, - size: snappedSize, - gridColumns: adjustedGridColumns, - }; - }); - - console.log("🧲 격자 스냅 적용 완료"); - } - + // 해상도만 변경하고 컴포넌트 크기/위치는 그대로 유지 const updatedLayout = { ...layout, - components: finalComponents, screenResolution: newResolution, }; setLayout(updatedLayout); saveToHistory(updatedLayout); - toast.success(`해상도 변경 완료! ${scaledComponents.length}개 컴포넌트가 자동으로 조정되었습니다.`, { + toast.success(`해상도가 변경되었습니다.`, { description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`, }); - console.log("✅ 해상도 변경 완료:", { - newResolution: `${newWidth}x${newHeight}`, - scaledComponents: finalComponents.length, - scaleX: `${(scaleX * 100).toFixed(2)}%`, - scaleY: `${(scaleY * 100).toFixed(2)}%`, - note: "모든 컴포넌트가 비율에 맞게 자동 조정됨", - }); + console.log("✅ 해상도 변경 완료 (컴포넌트 크기/위치 유지)"); }, [layout, saveToHistory, screenResolution], ); diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index f56ecb51..916fe60e 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -120,11 +120,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false); const [screenToPermanentDelete, setScreenToPermanentDelete] = useState(null); - // 일괄삭제 관련 상태 + // 휴지통 일괄삭제 관련 상태 const [selectedScreenIds, setSelectedScreenIds] = useState([]); const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); + // 활성 화면 일괄삭제 관련 상태 + const [selectedActiveScreenIds, setSelectedActiveScreenIds] = useState([]); + const [activeBulkDeleteDialogOpen, setActiveBulkDeleteDialogOpen] = useState(false); + const [activeBulkDeleteReason, setActiveBulkDeleteReason] = useState(""); + const [activeBulkDeleting, setActiveBulkDeleting] = useState(false); + // 편집 관련 상태 const [editDialogOpen, setEditDialogOpen] = useState(false); const [screenToEdit, setScreenToEdit] = useState(null); @@ -479,7 +485,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } }; - // 체크박스 선택 처리 + // 휴지통 체크박스 선택 처리 const handleScreenCheck = (screenId: number, checked: boolean) => { if (checked) { setSelectedScreenIds((prev) => [...prev, screenId]); @@ -488,7 +494,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } }; - // 전체 선택/해제 + // 휴지통 전체 선택/해제 const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId)); @@ -497,7 +503,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } }; - // 일괄삭제 실행 + // 휴지통 일괄삭제 실행 const handleBulkDelete = () => { if (selectedScreenIds.length === 0) { alert("삭제할 화면을 선택해주세요."); @@ -506,6 +512,70 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setBulkDeleteDialogOpen(true); }; + // 활성 화면 체크박스 선택 처리 + const handleActiveScreenCheck = (screenId: number, checked: boolean) => { + if (checked) { + setSelectedActiveScreenIds((prev) => [...prev, screenId]); + } else { + setSelectedActiveScreenIds((prev) => prev.filter((id) => id !== screenId)); + } + }; + + // 활성 화면 전체 선택/해제 + const handleActiveSelectAll = (checked: boolean) => { + if (checked) { + setSelectedActiveScreenIds(screens.map((screen) => screen.screenId)); + } else { + setSelectedActiveScreenIds([]); + } + }; + + // 활성 화면 일괄삭제 실행 + const handleActiveBulkDelete = () => { + if (selectedActiveScreenIds.length === 0) { + alert("삭제할 화면을 선택해주세요."); + return; + } + setActiveBulkDeleteDialogOpen(true); + }; + + // 활성 화면 일괄삭제 확인 + const confirmActiveBulkDelete = async () => { + if (selectedActiveScreenIds.length === 0) return; + + try { + setActiveBulkDeleting(true); + const result = await screenApi.bulkDeleteScreens( + selectedActiveScreenIds, + activeBulkDeleteReason || undefined, + true // 강제 삭제 (의존성 무시) + ); + + // 삭제된 화면들을 목록에서 제거 + setScreens((prev) => prev.filter((screen) => !selectedActiveScreenIds.includes(screen.screenId))); + + setSelectedActiveScreenIds([]); + setActiveBulkDeleteDialogOpen(false); + setActiveBulkDeleteReason(""); + + // 결과 메시지 표시 + let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`; + if (result.skippedCount > 0) { + message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`; + } + if (result.errors.length > 0) { + message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`; + } + + alert(message); + } catch (error) { + console.error("일괄 삭제 실패:", error); + alert("일괄 삭제에 실패했습니다."); + } finally { + setActiveBulkDeleting(false); + } + }; + const confirmBulkDelete = async () => { if (selectedScreenIds.length === 0) return; @@ -633,7 +703,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 탭 구조 */} - + { + setActiveTab(value); + // 탭 전환 시 선택 상태 초기화 + setSelectedActiveScreenIds([]); + setSelectedScreenIds([]); + }}> 활성 화면 휴지통 @@ -641,11 +716,47 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr {/* 활성 화면 탭 */} + {/* 선택 삭제 헤더 (선택된 항목이 있을 때만 표시) */} + {selectedActiveScreenIds.length > 0 && ( +
+ + {selectedActiveScreenIds.length}개 화면 선택됨 + +
+ + +
+
+ )} + {/* 데스크톱 테이블 뷰 (lg 이상) */}
+ + 0 && selectedActiveScreenIds.length === screens.length} + onCheckedChange={handleActiveSelectAll} + aria-label="전체 선택" + /> + 화면명 테이블명 상태 @@ -659,9 +770,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr key={screen.screenId} className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${ selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : "" - }`} + } ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30" : ""}`} onClick={() => onDesignScreen(screen)} > + + handleActiveScreenCheck(screen.screenId, checked as boolean)} + onClick={(e) => e.stopPropagation()} + aria-label={`${screen.screenName} 선택`} + /> +
{screen.screenName}
@@ -757,24 +876,57 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 모바일/태블릿 카드 뷰 (lg 미만) */} -
- {screens.map((screen) => ( -
handleScreenSelect(screen)} - > - {/* 헤더 */} -
-
-

{screen.screenName}

+
+ {/* 선택 헤더 */} +
+
+ 0 && selectedActiveScreenIds.length === screens.length} + onCheckedChange={handleActiveSelectAll} + aria-label="전체 선택" + /> + 전체 선택 +
+ {selectedActiveScreenIds.length > 0 && ( + + )} +
+ + {/* 카드 목록 */} +
+ {screens.map((screen) => ( +
handleScreenSelect(screen)} + > + {/* 헤더 */} +
+ handleActiveScreenCheck(screen.screenId, checked as boolean)} + onClick={(e) => e.stopPropagation()} + className="mt-1" + aria-label={`${screen.screenName} 선택`} + /> +
+

{screen.screenName}

+
+ + {screen.isActive === "Y" ? "활성" : "비활성"} +
- - {screen.isActive === "Y" ? "활성" : "비활성"} - -
{/* 설명 */} {screen.description &&

{screen.description}

} @@ -863,11 +1015,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
))} - {filteredScreens.length === 0 && ( -
-

검색 결과가 없습니다.

-
- )} + {filteredScreens.length === 0 && ( +
+

검색 결과가 없습니다.

+
+ )} +
@@ -1225,13 +1378,13 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr - {/* 일괄삭제 확인 다이얼로그 */} + {/* 휴지통 일괄삭제 확인 다이얼로그 */} 일괄 영구 삭제 확인 - ⚠️ 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까? + 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다!
@@ -1254,6 +1407,44 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
+ {/* 활성 화면 일괄삭제 확인 다이얼로그 */} + + + + 선택 화면 삭제 확인 + + 선택된 {selectedActiveScreenIds.length}개 화면을 휴지통으로 이동하시겠습니까? +
+ 휴지통에서 언제든지 복원할 수 있습니다. +
+
+
+ +