From eb5ea411c9d859328e5ed5476bc5a5c5f0dc3568 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 16:02:09 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 43 ++- .../controllers/screenManagementController.ts | 47 +++ .../src/routes/screenManagementRoutes.ts | 2 + backend-node/src/services/menuCopyService.ts | 325 +++++++++++++----- .../src/services/screenManagementService.ts | 128 +++++++ frontend/components/screen/ScreenList.tsx | 251 ++++++++++++-- frontend/lib/api/screen.ts | 16 + .../card-display/CardDisplayComponent.tsx | 207 ++++++----- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 11 files changed, 830 insertions(+), 192 deletions(-) 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 646fc8d6..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 }; + } + /** * 휴지통 화면 일괄 영구 삭제 */ 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}개 화면을 휴지통으로 이동하시겠습니까? +
+ 휴지통에서 언제든지 복원할 수 있습니다. +
+
+
+ +