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}

+ )} +
+
+
+
+ )} 메뉴-화면 동기화 diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index f7c03b7d..57bc2e8a 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -296,24 +296,6 @@ export const PivotGridComponent: React.FC = ({ onFieldDrop, onExpandChange, }) => { - // 디버깅 로그 - console.log("🔶 PivotGridComponent props:", { - title, - hasExternalData: !!externalData, - externalDataLength: externalData?.length, - initialFieldsLength: initialFields?.length, - }); - - // 🆕 데이터 샘플 확인 - if (externalData && externalData.length > 0) { - console.log("🔶 첫 번째 데이터 샘플:", externalData[0]); - console.log("🔶 전체 데이터 개수:", externalData.length); - } - - // 🆕 필드 설정 확인 - if (initialFields && initialFields.length > 0) { - console.log("🔶 필드 설정:", initialFields); - } // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); @@ -384,20 +366,63 @@ export const PivotGridComponent: React.FC = ({ localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]); - // 상태 복원 (localStorage) + // 상태 복원 (localStorage) - 프로덕션 안전성 강화 useEffect(() => { if (typeof window === "undefined") return; - const savedState = localStorage.getItem(stateStorageKey); - if (savedState) { - try { - const parsed = JSON.parse(savedState); - if (parsed.fields) setFields(parsed.fields); - if (parsed.pivotState) setPivotState(parsed.pivotState); - if (parsed.sortConfig) setSortConfig(parsed.sortConfig); - if (parsed.columnWidths) setColumnWidths(parsed.columnWidths); - } catch (e) { - console.warn("피벗 상태 복원 실패:", e); + + try { + const savedState = localStorage.getItem(stateStorageKey); + if (!savedState) return; + + const parsed = JSON.parse(savedState); + + // 버전 체크 - 버전이 다르면 이전 상태 무시 + if (parsed.version !== PIVOT_STATE_VERSION) { + localStorage.removeItem(stateStorageKey); + return; } + + // 필드 복원 시 유효성 검사 (중요!) + if (parsed.fields && Array.isArray(parsed.fields) && parsed.fields.length > 0) { + // 저장된 필드가 현재 데이터와 호환되는지 확인 + const validFields = parsed.fields.filter((f: PivotFieldConfig) => + f && typeof f.field === "string" && typeof f.area === "string" + ); + + if (validFields.length > 0) { + setFields(validFields); + } + } + + // pivotState 복원 시 유효성 검사 (확장 경로 검증) + if (parsed.pivotState && typeof parsed.pivotState === "object") { + const restoredState: PivotGridState = { + // expandedRowPaths는 배열의 배열이어야 함 + expandedRowPaths: Array.isArray(parsed.pivotState.expandedRowPaths) + ? parsed.pivotState.expandedRowPaths.filter( + (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") + ) + : [], + // expandedColumnPaths도 동일하게 검증 + expandedColumnPaths: Array.isArray(parsed.pivotState.expandedColumnPaths) + ? parsed.pivotState.expandedColumnPaths.filter( + (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") + ) + : [], + sortConfig: parsed.pivotState.sortConfig || null, + filterConfig: parsed.pivotState.filterConfig || {}, + }; + setPivotState(restoredState); + } + + if (parsed.sortConfig) setSortConfig(parsed.sortConfig); + if (parsed.columnWidths && typeof parsed.columnWidths === "object") { + setColumnWidths(parsed.columnWidths); + } + } catch (e) { + console.warn("피벗 상태 복원 실패, localStorage 초기화:", e); + // 손상된 상태는 제거 + localStorage.removeItem(stateStorageKey); } }, [stateStorageKey]); @@ -436,14 +461,6 @@ export const PivotGridComponent: React.FC = ({ const result = fields .filter((f) => f.area === "filter" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - - console.log("🔷 [filterFields] 필터 필드 계산:", { - totalFields: fields.length, - filterFieldsCount: result.length, - filterFieldNames: result.map(f => f.field), - allFieldAreas: fields.map(f => ({ field: f.field, area: f.area, visible: f.visible })), - }); - return result; }, [fields] @@ -490,83 +507,84 @@ export const PivotGridComponent: React.FC = ({ if (activeFilters.length === 0) return data; - return data.filter((row) => { + const result = data.filter((row) => { return activeFilters.every((filter) => { - const value = row[filter.field]; + const rawValue = row[filter.field]; const filterValues = filter.filterValues || []; const filterType = filter.filterType || "include"; + // 타입 안전한 비교: 값을 문자열로 변환하여 비교 + const value = rawValue === null || rawValue === undefined + ? "(빈 값)" + : String(rawValue); + if (filterType === "include") { - return filterValues.includes(value); + return filterValues.some((fv) => String(fv) === value); } else { - return !filterValues.includes(value); + return filterValues.every((fv) => String(fv) !== value); } }); }); + + // 모든 데이터가 필터링되면 경고 (디버깅용) + if (result.length === 0 && data.length > 0) { + console.warn("⚠️ [PivotGrid] 필터로 인해 모든 데이터가 제거됨"); + } + + return result; }, [data, fields]); // ==================== 피벗 처리 ==================== const pivotResult = useMemo(() => { - if (!filteredData || filteredData.length === 0 || fields.length === 0) { - return null; - } - - const visibleFields = fields.filter((f) => f.visible !== false); - // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) - if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { - return null; - } - - const result = processPivotData( - filteredData, - visibleFields, - pivotState.expandedRowPaths, - pivotState.expandedColumnPaths - ); - - // 🆕 피벗 결과 확인 - console.log("🔶 피벗 처리 결과:", { - hasResult: !!result, - flatRowsCount: result?.flatRows?.length, - flatColumnsCount: result?.flatColumns?.length, - dataMatrixSize: result?.dataMatrix?.size, - expandedRowPaths: pivotState.expandedRowPaths.length, - expandedColumnPaths: pivotState.expandedColumnPaths.length, - }); - - return result; - }, [ - filteredData, - fields, - JSON.stringify(pivotState.expandedRowPaths), - JSON.stringify(pivotState.expandedColumnPaths) - ]); - - // 🆕 초기 로드 시 첫 레벨 자동 확장 - useEffect(() => { - if (pivotResult && pivotResult.flatRows.length > 0 && !isInitialExpanded) { - console.log("🔶 피벗 결과 생성됨:", { - flatRowsCount: pivotResult.flatRows.length, - expandedRowPaths: pivotState.expandedRowPaths.length, - isInitialExpanded, - }); - - // 첫 레벨 행들의 경로 수집 (level 0인 행들) - const firstLevelRows = pivotResult.flatRows.filter(row => row.level === 0 && row.hasChildren); - - console.log("🔶 첫 레벨 행 (level 0, hasChildren):", firstLevelRows.map(r => ({ path: r.path, caption: r.caption }))); - - // 첫 레벨 행이 있으면 자동 확장 - if (firstLevelRows.length > 0) { - const firstLevelPaths = firstLevelRows.map(row => row.path); - console.log("🔶 초기 자동 확장 실행 (한 번만):", firstLevelPaths); - setPivotState(prev => ({ - ...prev, - expandedRowPaths: firstLevelPaths, - })); - setIsInitialExpanded(true); + try { + if (!filteredData || filteredData.length === 0 || fields.length === 0) { + return null; } + + // FieldChooser에서 이미 필드를 완전히 제거하므로 visible 필터링 불필요 + // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) + if (fields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { + return null; + } + + const result = processPivotData( + filteredData, + fields, + pivotState.expandedRowPaths, + pivotState.expandedColumnPaths + ); + + return result; + } catch (error) { + console.error("❌ [pivotResult] 피벗 처리 에러:", error); + return null; + } + }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + + // 초기 로드 시 첫 레벨 자동 확장 + useEffect(() => { + try { + if (pivotResult && pivotResult.flatRows && pivotResult.flatRows.length > 0 && !isInitialExpanded) { + // 첫 레벨 행들의 경로 수집 (level 0인 행들) + const firstLevelRows = pivotResult.flatRows.filter((row) => row.level === 0 && row.hasChildren); + + // 첫 레벨 행이 있으면 자동 확장 + if (firstLevelRows.length > 0 && firstLevelRows.length < 100) { + const firstLevelPaths = firstLevelRows.map((row) => row.path); + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: firstLevelPaths, + })); + setIsInitialExpanded(true); + } else { + // 행이 너무 많으면 자동 확장 건너뛰기 + setIsInitialExpanded(true); + } + } + } catch (error) { + console.error("❌ [초기 확장] 에러:", error); + setIsInitialExpanded(true); } }, [pivotResult, isInitialExpanded]); @@ -725,19 +743,7 @@ export const PivotGridComponent: React.FC = ({ // 필드 변경 const handleFieldsChange = useCallback( (newFields: PivotFieldConfig[]) => { - // 🆕 visible: false 필드 제거 (FieldChooser에서 "사용 안함"으로 설정한 필드) - const visibleFields = newFields.filter(f => f.visible !== false); - - console.log("🔷 [handleFieldsChange] 필드 변경:", { - totalFields: newFields.length, - visibleFields: visibleFields.length, - removedFields: newFields.length - visibleFields.length, - filterFields: visibleFields.filter(f => f.area === "filter").length, - filterFieldNames: visibleFields.filter(f => f.area === "filter").map(f => f.field), - }); - console.log("🔷 [handleFieldsChange] setFields 호출 전"); - setFields(visibleFields); - console.log("🔷 [handleFieldsChange] setFields 호출 후"); + setFields(newFields); }, [] ); @@ -745,8 +751,6 @@ export const PivotGridComponent: React.FC = ({ // 행 확장/축소 const handleToggleRowExpand = useCallback( (path: string[]) => { - console.log("🔶 행 확장/축소 클릭:", path); - setPivotState((prev) => { const pathKey = pathToKey(path); const existingIndex = prev.expandedRowPaths.findIndex( @@ -755,16 +759,13 @@ export const PivotGridComponent: React.FC = ({ let newPaths: string[][]; if (existingIndex >= 0) { - console.log("🔶 행 축소:", path); newPaths = prev.expandedRowPaths.filter( (_, i) => i !== existingIndex ); } else { - console.log("🔶 행 확장:", path); newPaths = [...prev.expandedRowPaths, path]; } - console.log("🔶 새로운 확장 경로:", newPaths); onExpandChange?.(newPaths); return { @@ -778,59 +779,58 @@ export const PivotGridComponent: React.FC = ({ // 전체 확장 (재귀적으로 모든 레벨 확장) const handleExpandAll = useCallback(() => { - if (!pivotResult) { - console.log("❌ [handleExpandAll] pivotResult가 없음"); - return; - } - - // 🆕 재귀적으로 모든 가능한 경로 생성 - const allRowPaths: string[][] = []; - const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false); - - // 데이터에서 모든 고유한 경로 추출 - const pathSet = new Set(); - filteredData.forEach((item) => { - for (let depth = 1; depth <= rowFields.length; depth++) { - const path = rowFields.slice(0, depth).map((f) => String(item[f.field] ?? "")); - const pathKey = JSON.stringify(path); - pathSet.add(pathKey); + try { + if (!pivotResult) { + return; } - }); - // Set을 배열로 변환 - pathSet.forEach((pathKey) => { - allRowPaths.push(JSON.parse(pathKey)); - }); + // 재귀적으로 모든 가능한 경로 생성 + const allRowPaths: string[][] = []; + const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false); + + // 행 필드가 없으면 종료 + if (rowFields.length === 0) { + return; + } + + // 데이터에서 모든 고유한 경로 추출 + const pathSet = new Set(); + filteredData.forEach((item) => { + // 마지막 레벨은 제외 (확장할 자식이 없으므로) + for (let depth = 1; depth < rowFields.length; depth++) { + const path = rowFields.slice(0, depth).map((f) => String(item[f.field] ?? "")); + const pathKey = JSON.stringify(path); + pathSet.add(pathKey); + } + }); - console.log("🔷 [handleExpandAll] 확장할 행:", { - totalRows: pivotResult.flatRows.length, - rowsWithChildren: allRowPaths.length, - paths: allRowPaths.slice(0, 5), // 처음 5개만 로그 - }); + // Set을 배열로 변환 (최대 1000개로 제한하여 성능 보호) + const MAX_PATHS = 1000; + let count = 0; + pathSet.forEach((pathKey) => { + if (count < MAX_PATHS) { + allRowPaths.push(JSON.parse(pathKey)); + count++; + } + }); - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: allRowPaths, - expandedColumnPaths: [], - })); + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + } catch (error) { + console.error("❌ [handleExpandAll] 에러:", error); + } }, [pivotResult, fields, filteredData]); // 전체 축소 const handleCollapseAll = useCallback(() => { - console.log("🔷 [handleCollapseAll] 전체 축소 실행"); - - setPivotState((prev) => { - console.log("🔷 [handleCollapseAll] 이전 상태:", { - expandedRowPaths: prev.expandedRowPaths.length, - expandedColumnPaths: prev.expandedColumnPaths.length, - }); - - return { - ...prev, - expandedRowPaths: [], - expandedColumnPaths: [], - }; - }); + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: [], + expandedColumnPaths: [], + })); }, []); // 셀 클릭 @@ -945,6 +945,8 @@ export const PivotGridComponent: React.FC = ({ // 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함) const handlePrint = useCallback(() => { + if (typeof window === "undefined") return; + const printContent = tableRef.current; if (!printContent) return; @@ -1047,8 +1049,10 @@ export const PivotGridComponent: React.FC = ({ // 상태 초기화 (확장/축소, 정렬, 필터만 초기화, 필드 설정은 유지) const handleResetState = useCallback(() => { - // 로컬 스토리지에서 상태 제거 - localStorage.removeItem(stateStorageKey); + // 로컬 스토리지에서 상태 제거 (SSR 보호) + if (typeof window !== "undefined") { + localStorage.removeItem(stateStorageKey); + } // 확장/축소, 정렬, 필터 상태만 초기화 setPivotState({ @@ -1061,9 +1065,6 @@ export const PivotGridComponent: React.FC = ({ setColumnWidths({}); setSelectedCell(null); setSelectionRange(null); - - // 🆕 필드 설정은 유지 (initialFields로 되돌리지 않음) - console.log("🔷 피벗 상태가 초기화되었습니다 (필드 설정은 유지)"); }, [stateStorageKey]); // 필드 숨기기/표시 상태 @@ -1081,11 +1082,6 @@ export const PivotGridComponent: React.FC = ({ }); }, []); - // 숨겨진 필드 제외한 활성 필드들 - const visibleFields = useMemo(() => { - return fields.filter((f) => !hiddenFields.has(f.field)); - }, [fields, hiddenFields]); - // 숨겨진 필드 목록 const hiddenFieldsList = useMemo(() => { return fields.filter((f) => hiddenFields.has(f.field)); @@ -1676,7 +1672,10 @@ export const PivotGridComponent: React.FC = ({
0 ? containerHeight : undefined, + minHeight: 100 // 최소 높이 보장 - 블라인드 효과 방지 + }} tabIndex={0} onKeyDown={handleKeyDown} > @@ -1951,12 +1950,15 @@ export const PivotGridComponent: React.FC = ({ }); })()} - {/* 가상 스크롤 하단 여백 */} - {enableVirtualScroll && ( - - - - )} + {/* 가상 스크롤 하단 여백 - 음수 방지 */} + {enableVirtualScroll && (() => { + const bottomPadding = Math.max(0, virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT)); + return bottomPadding > 0 ? ( + + + + ) : null; + })()} {/* 열 총계 행 (하단 위치 - 기본값) */} {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && ( diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx index 191f3610..61ebacd7 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; @@ -8,6 +8,66 @@ import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; import { PivotFieldConfig } from "./types"; import { dataApi } from "@/lib/api/data"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ==================== 에러 경계 ==================== + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +class PivotGridErrorBoundary extends Component< + { children: ReactNode; onReset?: () => void }, + ErrorBoundaryState +> { + constructor(props: { children: ReactNode; onReset?: () => void }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("🔴 [PivotGrid] 렌더링 에러:", error); + console.error("🔴 [PivotGrid] 에러 정보:", errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }); + this.props.onReset?.(); + }; + + render() { + if (this.state.hasError) { + return ( +
+ +

+ 피벗 그리드 오류 +

+

+ {this.state.error?.message || "알 수 없는 오류가 발생했습니다."} +

+ +
+ ); + } + + return this.props.children; + } +} // ==================== 샘플 데이터 (미리보기용) ==================== @@ -111,19 +171,14 @@ const PivotGridWrapper: React.FC = (props) => { setIsLoading(true); try { - console.log("🔷 [PivotGrid] 테이블 데이터 로딩 시작:", tableName); - const response = await dataApi.getTableData(tableName, { page: 1, - size: 10000, // 피벗 분석용 대량 데이터 (pageSize → size) + size: 10000, // 피벗 분석용 대량 데이터 }); - console.log("🔷 [PivotGrid] API 응답:", response); - // dataApi.getTableData는 { data, total, page, size, totalPages } 구조 if (response.data && Array.isArray(response.data)) { setLoadedData(response.data); - console.log("✅ [PivotGrid] 데이터 로딩 완료:", response.data.length, "건"); } else { console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음"); setLoadedData([]); @@ -137,21 +192,6 @@ const PivotGridWrapper: React.FC = (props) => { loadTableData(); }, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]); - - // 디버깅 로그 - console.log("🔷 PivotGridWrapper props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasLoadedData: loadedData.length > 0, - loadedDataLength: loadedData.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - isLoading, - }); // 디자인 모드 판단: // 1. isDesignMode === true @@ -173,13 +213,6 @@ const PivotGridWrapper: React.FC = (props) => { ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" : (componentConfig.title || props.title); - console.log("🔷 PivotGridWrapper final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - // 총계 설정 const totalsConfig = componentConfig.totals || props.totals || { showRowGrandTotals: true, @@ -200,24 +233,27 @@ const PivotGridWrapper: React.FC = (props) => { ); } + // 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함 return ( - + + + ); }; @@ -283,18 +319,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { const componentConfig = props.componentConfig || props.config || {}; const configFields = componentConfig.fields || props.fields; const configData = props.data; - - // 디버깅 로그 - console.log("🔷 PivotGridRenderer props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - }); // 디자인 모드 판단: // 1. isDesignMode === true @@ -314,13 +338,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" : (componentConfig.title || props.title); - console.log("🔷 PivotGridRenderer final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - // 총계 설정 const totalsConfig = componentConfig.totals || props.totals || { showRowGrandTotals: true, diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx index 89fe5128..fba64e65 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -267,11 +267,9 @@ export const FieldChooser: React.FC = ({ const existingConfig = selectedFields.find((f) => f.field === field.field); if (area === "none") { - // 필드 제거 또는 숨기기 + // 필드 완전 제거 (visible: false 대신 배열에서 제거) if (existingConfig) { - const newFields = selectedFields.map((f) => - f.field === field.field ? { ...f, visible: false } : f - ); + const newFields = selectedFields.filter((f) => f.field !== field.field); onFieldsChange(newFields); } } else { diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index 08dca70e..967afd08 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -360,7 +360,6 @@ export const FieldPanel: React.FC = ({ // 1. overId가 영역 자체인 경우 (filter, column, row, data) if (["filter", "column", "row", "data"].includes(overId)) { setOverArea(overId as PivotAreaType); - console.log("🔷 [handleDragOver] 영역 감지:", overId); return; } @@ -368,7 +367,6 @@ export const FieldPanel: React.FC = ({ const targetArea = overId.split("-")[0] as PivotAreaType; if (["filter", "column", "row", "data"].includes(targetArea)) { setOverArea(targetArea); - console.log("🔷 [handleDragOver] 필드 영역 감지:", targetArea); } }; @@ -380,19 +378,12 @@ export const FieldPanel: React.FC = ({ setOverArea(null); if (!over) { - console.log("🔷 [FieldPanel] 드롭 대상 없음"); return; } const activeId = active.id as string; const overId = over.id as string; - console.log("🔷 [FieldPanel] 드래그 종료:", { - activeId, - overId, - detectedOverArea: currentOverArea, - }); - // 필드 정보 파싱 const [sourceArea, sourceField] = activeId.split("-") as [ PivotAreaType, @@ -409,13 +400,6 @@ export const FieldPanel: React.FC = ({ targetArea = overId.split("-")[0] as PivotAreaType; } - console.log("🔷 [FieldPanel] 파싱 결과:", { - sourceArea, - sourceField, - targetArea, - usedOverArea: !!currentOverArea, - }); - // 같은 영역 내 정렬 if (sourceArea === targetArea) { const areaFields = fields.filter((f) => f.area === sourceArea); @@ -447,12 +431,6 @@ export const FieldPanel: React.FC = ({ // 다른 영역으로 이동 if (["filter", "column", "row", "data"].includes(targetArea)) { - console.log("🔷 [FieldPanel] 영역 이동:", { - field: sourceField, - from: sourceArea, - to: targetArea, - }); - const newFields = fields.map((f) => { if (f.field === sourceField && f.area === sourceArea) { return { @@ -464,12 +442,6 @@ export const FieldPanel: React.FC = ({ return f; }); - console.log("🔷 [FieldPanel] 변경된 필드:", { - totalFields: newFields.length, - filterFields: newFields.filter(f => f.area === "filter").length, - changedField: newFields.find(f => f.field === sourceField), - }); - onFieldsChange(newFields); } }; diff --git a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts index 152cb2df..6557dee3 100644 --- a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts +++ b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts @@ -51,14 +51,18 @@ export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollRe // 보이는 아이템 수 const visibleCount = Math.ceil(containerHeight / itemHeight); - // 시작/끝 인덱스 계산 + // 시작/끝 인덱스 계산 (음수 방지) const { startIndex, endIndex } = useMemo(() => { + // itemCount가 0이면 빈 배열 + if (itemCount === 0) { + return { startIndex: 0, endIndex: -1 }; + } const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); const end = Math.min( itemCount - 1, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan ); - return { startIndex: start, endIndex: end }; + return { startIndex: start, endIndex: Math.max(start, end) }; // end가 start보다 작지 않도록 }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); // 전체 높이 diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 4d3fecfd..02dd4608 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -710,27 +710,19 @@ export function processPivotData( .filter((f) => f.area === "data" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - const filterFields = fields.filter( - (f) => f.area === "filter" && f.visible !== false + // 참고: 필터링은 PivotGridComponent에서 이미 처리됨 + // 여기서는 추가 필터링 없이 전달받은 데이터 사용 + const filteredData = data; + + // 확장 경로 Set 변환 (잘못된 형식 필터링) + const validRowPaths = (expandedRowPaths || []).filter( + (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") ); - - // 필터 적용 - let filteredData = data; - for (const filterField of filterFields) { - if (filterField.filterValues && filterField.filterValues.length > 0) { - filteredData = filteredData.filter((row) => { - const value = getFieldValue(row, filterField); - if (filterField.filterType === "exclude") { - return !filterField.filterValues!.includes(value); - } - return filterField.filterValues!.includes(value); - }); - } - } - - // 확장 경로 Set 변환 - const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); - const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); + const validColPaths = (expandedColumnPaths || []).filter( + (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") + ); + const expandedRowSet = new Set(validRowPaths.map(pathToKey)); + const expandedColSet = new Set(validColPaths.map(pathToKey)); // 기본 확장: 첫 번째 레벨 모두 확장 if (expandedRowPaths.length === 0 && rowFields.length > 0) {