diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index ce7b9c7f..c86b0064 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1417,6 +1417,75 @@ export async function updateMenu( } } +/** + * 재귀적으로 모든 하위 메뉴 ID를 수집하는 헬퍼 함수 + */ +async function collectAllChildMenuIds(parentObjid: number): Promise { + const allIds: number[] = []; + + // 직접 자식 메뉴들 조회 + const children = await query( + `SELECT objid FROM menu_info WHERE parent_obj_id = $1`, + [parentObjid] + ); + + for (const child of children) { + allIds.push(child.objid); + // 자식의 자식들도 재귀적으로 수집 + const grandChildren = await collectAllChildMenuIds(child.objid); + allIds.push(...grandChildren); + } + + return allIds; +} + +/** + * 메뉴 및 관련 데이터 정리 헬퍼 함수 + */ +async function cleanupMenuRelatedData(menuObjid: number): Promise { + // 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] + ); + + // 7. screen_groups에서 menu_objid를 NULL로 설정 + await query( + `UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); +} + /** * 메뉴 삭제 */ @@ -1443,7 +1512,7 @@ export async function deleteMenu( // 삭제하려는 메뉴 조회 const currentMenu = await queryOne( - `SELECT objid, company_code FROM menu_info WHERE objid = $1`, + `SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`, [Number(menuId)] ); @@ -1478,67 +1547,50 @@ 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] - ); + // 하위 메뉴들 재귀적으로 수집 + const childMenuIds = await collectAllChildMenuIds(menuObjid); + const allMenuIdsToDelete = [menuObjid, ...childMenuIds]; - // 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}) + 하위 메뉴 ${childMenuIds.length}개`, { + menuName: currentMenu.menu_name_kor, + totalCount: allMenuIdsToDelete.length, + childMenuIds, + }); - logger.info("메뉴 관련 데이터 정리 완료", { menuObjid }); + // 모든 삭제 대상 메뉴에 대해 관련 데이터 정리 + for (const objid of allMenuIdsToDelete) { + await cleanupMenuRelatedData(objid); + } - // Raw Query를 사용한 메뉴 삭제 - const [deletedMenu] = await query( - `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [menuObjid] - ); + logger.info("메뉴 관련 데이터 정리 완료", { + menuObjid, + totalCleaned: allMenuIdsToDelete.length + }); - logger.info("메뉴 삭제 성공", { deletedMenu }); + // 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피) + // 가장 깊은 하위부터 삭제해야 하므로 역순으로 + const reversedIds = [...allMenuIdsToDelete].reverse(); + + for (const objid of reversedIds) { + await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]); + } + + logger.info("메뉴 삭제 성공", { + deletedMenuObjid: menuObjid, + deletedMenuName: currentMenu.menu_name_kor, + totalDeleted: allMenuIdsToDelete.length, + }); const response: ApiResponse = { success: true, - message: "메뉴가 성공적으로 삭제되었습니다.", + message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`, data: { - objid: deletedMenu.objid.toString(), - menuNameKor: deletedMenu.menu_name_kor, - menuNameEng: deletedMenu.menu_name_eng, - menuUrl: deletedMenu.menu_url, - menuDesc: deletedMenu.menu_desc, - status: deletedMenu.status, - writer: deletedMenu.writer, - regdate: new Date(deletedMenu.regdate).toISOString(), + objid: menuObjid.toString(), + menuNameKor: currentMenu.menu_name_kor, + deletedCount: allMenuIdsToDelete.length, + deletedChildCount: childMenuIds.length, }, }; @@ -1623,18 +1675,49 @@ export async function deleteMenusBatch( } } + // 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함) + const allMenuIdsToDelete = new Set(); + + for (const menuId of menuIds) { + const objid = Number(menuId); + allMenuIdsToDelete.add(objid); + + // 하위 메뉴들 재귀적으로 수집 + const childMenuIds = await collectAllChildMenuIds(objid); + childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id))); + } + + const allIdsArray = Array.from(allMenuIdsToDelete); + + logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}개`, { + selectedMenuIds: menuIds, + totalWithChildren: allIdsArray.length, + }); + + // 모든 삭제 대상 메뉴에 대해 관련 데이터 정리 + for (const objid of allIdsArray) { + await cleanupMenuRelatedData(objid); + } + + logger.info("메뉴 관련 데이터 정리 완료", { + totalCleaned: allIdsArray.length + }); + // Raw Query를 사용한 메뉴 일괄 삭제 let deletedCount = 0; let failedCount = 0; const deletedMenus: any[] = []; const failedMenuIds: string[] = []; + // 하위 메뉴부터 삭제하기 위해 역순으로 정렬 + const reversedIds = [...allIdsArray].reverse(); + // 각 메뉴 ID에 대해 삭제 시도 - for (const menuId of menuIds) { + for (const menuObjid of reversedIds) { try { const result = await query( `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [Number(menuId)] + [menuObjid] ); if (result.length > 0) { @@ -1645,20 +1728,20 @@ export async function deleteMenusBatch( }); } else { failedCount++; - failedMenuIds.push(menuId); + failedMenuIds.push(String(menuObjid)); } } catch (error) { - logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error); + logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error); failedCount++; - failedMenuIds.push(menuId); + failedMenuIds.push(String(menuObjid)); } } logger.info("메뉴 일괄 삭제 완료", { - total: menuIds.length, + requested: menuIds.length, + totalWithChildren: allIdsArray.length, deletedCount, failedCount, - deletedMenus, failedMenuIds, }); diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index a163f30c..f8b808d3 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -2090,7 +2090,7 @@ export class MenuCopyService { menu.menu_url, menu.menu_desc, userId, - menu.status, + 'active', // 복제된 메뉴는 항상 활성화 상태 menu.system_name, targetCompanyCode, // 새 회사 코드 menu.lang_key, diff --git a/backend-node/src/services/menuScreenSyncService.ts b/backend-node/src/services/menuScreenSyncService.ts index 13c77ed6..d6f27e07 100644 --- a/backend-node/src/services/menuScreenSyncService.ts +++ b/backend-node/src/services/menuScreenSyncService.ts @@ -142,7 +142,7 @@ export async function syncScreenGroupsToMenu( const newObjid = Date.now(); const createRootQuery = ` INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) - VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'Y') + VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active') RETURNING objid `; const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); @@ -159,12 +159,36 @@ export async function syncScreenGroupsToMenu( groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || ''); }); - // 5. 각 screen_group 처리 + // 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL) + // 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치 + const topLevelCompanyFolderIds = new Set(); + for (const group of screenGroupsResult.rows) { + if (group.group_level === 0 && group.parent_group_id === null) { + topLevelCompanyFolderIds.add(group.id); + // 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용) + groupToMenuMap.set(group.id, userMenuRootObjid!); + logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name }); + } + } + + // 6. 각 screen_group 처리 for (const group of screenGroupsResult.rows) { const groupId = group.id; const groupName = group.group_name?.trim(); const groupNameLower = groupName?.toLowerCase() || ''; + // 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵 + if (topLevelCompanyFolderIds.has(groupId)) { + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: groupName, + sourceId: groupId, + reason: '최상위 회사 폴더 (메뉴 생성 스킵)', + }); + continue; + } + // 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인 if (group.menu_objid) { const menuExists = existingMenuObjids.has(Number(group.menu_objid)); @@ -237,11 +261,17 @@ export async function syncScreenGroupsToMenu( const newObjid = Date.now() + groupId; // 고유 ID 보장 // 부모 메뉴 objid 결정 + // 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수) let parentMenuObjid = userMenuRootObjid; - if (group.parent_group_id && group.parent_menu_objid) { - parentMenuObjid = Number(group.parent_menu_objid); - } else if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) { + if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) { + // 현재 트랜잭션에서 생성된 부모 메뉴 사용 parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!; + } else if (group.parent_group_id && group.parent_menu_objid) { + // 기존 parent_menu_objid가 실제로 존재하는지 확인 + const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid)); + if (parentMenuExists) { + parentMenuObjid = Number(group.parent_menu_objid); + } } // 같은 부모 아래에서 가장 높은 seq 조회 후 +1 @@ -261,7 +291,7 @@ export async function syncScreenGroupsToMenu( INSERT INTO menu_info ( objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc - ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'Y', $8, $9) + ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9) RETURNING objid `; await client.query(insertMenuQuery, [ diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index edd36816..7cd1310f 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -172,6 +172,7 @@ export function ScreenGroupTreeView({ const [syncStatus, setSyncStatus] = useState(null); const [isSyncing, setIsSyncing] = useState(false); const [syncDirection, setSyncDirection] = useState<"screen-to-menu" | "menu-to-screen" | "all" | null>(null); + const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null); // 회사 선택 (최고 관리자용) const { user } = useAuth(); @@ -328,14 +329,31 @@ export function ScreenGroupTreeView({ setIsSyncing(true); setSyncDirection(direction); + setSyncProgress({ + message: direction === "screen-to-menu" + ? "화면관리 → 메뉴 동기화 중..." + : "메뉴 → 화면관리 동기화 중...", + detail: "데이터를 분석하고 있습니다..." + }); try { + setSyncProgress({ + message: direction === "screen-to-menu" + ? "화면관리 → 메뉴 동기화 중..." + : "메뉴 → 화면관리 동기화 중...", + detail: "동기화 작업을 수행하고 있습니다..." + }); + const response = direction === "screen-to-menu" ? await syncScreenGroupsToMenu(targetCompanyCode) : await syncMenuToScreenGroups(targetCompanyCode); if (response.success) { const data = response.data; + setSyncProgress({ + message: "동기화 완료!", + detail: `생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개` + }); toast.success( `동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개` ); @@ -347,13 +365,17 @@ export function ScreenGroupTreeView({ setSyncStatus(statusResponse.data); } } else { + setSyncProgress(null); toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`); } } catch (error: any) { + setSyncProgress(null); toast.error(`동기화 실패: ${error.message}`); } finally { setIsSyncing(false); setSyncDirection(null); + // 3초 후 진행 메시지 초기화 + setTimeout(() => setSyncProgress(null), 3000); } }; @@ -366,27 +388,42 @@ export function ScreenGroupTreeView({ setIsSyncing(true); setSyncDirection("all"); + setSyncProgress({ + message: "전체 회사 동기화 중...", + detail: "모든 회사의 데이터를 분석하고 있습니다..." + }); try { + setSyncProgress({ + message: "전체 회사 동기화 중...", + detail: "양방향 동기화 작업을 수행하고 있습니다..." + }); + const response = await syncAllCompanies(); if (response.success && response.data) { const data = response.data; + setSyncProgress({ + message: "전체 동기화 완료!", + detail: `${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개` + }); toast.success( `전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개` ); // 그룹 데이터 새로고침 await loadGroupsData(); - // 동기화 다이얼로그 닫기 - setIsSyncDialogOpen(false); } else { + setSyncProgress(null); toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`); } } catch (error: any) { + setSyncProgress(null); toast.error(`전체 동기화 실패: ${error.message}`); } finally { setIsSyncing(false); setSyncDirection(null); + // 3초 후 진행 메시지 초기화 + setTimeout(() => setSyncProgress(null), 3000); } }; @@ -979,15 +1016,17 @@ export function ScreenGroupTreeView({ 그룹 추가 - +{isSuperAdmin && ( + + )} {/* 트리 목록 */} @@ -1816,7 +1855,23 @@ export function ScreenGroupTreeView({ {/* 메뉴-화면그룹 동기화 다이얼로그 */} - + + {/* 동기화 진행 중 오버레이 (삭제와 동일한 스타일) */} + {isSyncing && ( +
+ +

{syncProgress?.message || "동기화 중..."}

+ {syncProgress?.detail && ( +

{syncProgress.detail}

+ )} +
+
+
+
+ )} 메뉴-화면 동기화