chpark-sync #425

Merged
kjs merged 293 commits from chpark-sync into main 2026-03-23 09:36:36 +09:00
5 changed files with 294 additions and 131 deletions
Showing only changes of commit a98d86bcf1 - Show all commits

View File

@ -1417,6 +1417,75 @@ export async function updateMenu(
} }
} }
/**
* ID를
*/
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
const allIds: number[] = [];
// 직접 자식 메뉴들 조회
const children = await query<any>(
`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<void> {
// 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<any>( const currentMenu = await queryOne<any>(
`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)] [Number(menuId)]
); );
@ -1478,67 +1547,50 @@ export async function deleteMenu(
} }
} }
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
const menuObjid = Number(menuId); const menuObjid = Number(menuId);
// 1. category_column_mapping에서 menu_objid를 NULL로 설정 // 하위 메뉴들 재귀적으로 수집
await query( const childMenuIds = await collectAllChildMenuIds(menuObjid);
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
[menuObjid]
);
// 2. code_category에서 menu_objid를 NULL로 설정 logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}`, {
await query( menuName: currentMenu.menu_name_kor,
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, totalCount: allMenuIdsToDelete.length,
[menuObjid] childMenuIds,
); });
// 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 }); // 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allMenuIdsToDelete) {
await cleanupMenuRelatedData(objid);
}
// Raw Query를 사용한 메뉴 삭제 logger.info("메뉴 관련 데이터 정리 완료", {
const [deletedMenu] = await query<any>( menuObjid,
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`, totalCleaned: allMenuIdsToDelete.length
[menuObjid] });
);
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<any> = { const response: ApiResponse<any> = {
success: true, success: true,
message: "메뉴가 성공적으로 삭제되었습니다.", message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
data: { data: {
objid: deletedMenu.objid.toString(), objid: menuObjid.toString(),
menuNameKor: deletedMenu.menu_name_kor, menuNameKor: currentMenu.menu_name_kor,
menuNameEng: deletedMenu.menu_name_eng, deletedCount: allMenuIdsToDelete.length,
menuUrl: deletedMenu.menu_url, deletedChildCount: childMenuIds.length,
menuDesc: deletedMenu.menu_desc,
status: deletedMenu.status,
writer: deletedMenu.writer,
regdate: new Date(deletedMenu.regdate).toISOString(),
}, },
}; };
@ -1623,18 +1675,49 @@ export async function deleteMenusBatch(
} }
} }
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
const allMenuIdsToDelete = new Set<number>();
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를 사용한 메뉴 일괄 삭제 // Raw Query를 사용한 메뉴 일괄 삭제
let deletedCount = 0; let deletedCount = 0;
let failedCount = 0; let failedCount = 0;
const deletedMenus: any[] = []; const deletedMenus: any[] = [];
const failedMenuIds: string[] = []; const failedMenuIds: string[] = [];
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
const reversedIds = [...allIdsArray].reverse();
// 각 메뉴 ID에 대해 삭제 시도 // 각 메뉴 ID에 대해 삭제 시도
for (const menuId of menuIds) { for (const menuObjid of reversedIds) {
try { try {
const result = await query<any>( const result = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`, `DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[Number(menuId)] [menuObjid]
); );
if (result.length > 0) { if (result.length > 0) {
@ -1645,20 +1728,20 @@ export async function deleteMenusBatch(
}); });
} else { } else {
failedCount++; failedCount++;
failedMenuIds.push(menuId); failedMenuIds.push(String(menuObjid));
} }
} catch (error) { } catch (error) {
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error); logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
failedCount++; failedCount++;
failedMenuIds.push(menuId); failedMenuIds.push(String(menuObjid));
} }
} }
logger.info("메뉴 일괄 삭제 완료", { logger.info("메뉴 일괄 삭제 완료", {
total: menuIds.length, requested: menuIds.length,
totalWithChildren: allIdsArray.length,
deletedCount, deletedCount,
failedCount, failedCount,
deletedMenus,
failedMenuIds, failedMenuIds,
}); });

View File

@ -2090,7 +2090,7 @@ export class MenuCopyService {
menu.menu_url, menu.menu_url,
menu.menu_desc, menu.menu_desc,
userId, userId,
menu.status, 'active', // 복제된 메뉴는 항상 활성화 상태
menu.system_name, menu.system_name,
targetCompanyCode, // 새 회사 코드 targetCompanyCode, // 새 회사 코드
menu.lang_key, menu.lang_key,

View File

@ -142,7 +142,7 @@ export async function syncScreenGroupsToMenu(
const newObjid = Date.now(); const newObjid = Date.now();
const createRootQuery = ` const createRootQuery = `
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) 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 RETURNING objid
`; `;
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); 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() || ''); 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<number>();
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) { for (const group of screenGroupsResult.rows) {
const groupId = group.id; const groupId = group.id;
const groupName = group.group_name?.trim(); const groupName = group.group_name?.trim();
const groupNameLower = groupName?.toLowerCase() || ''; 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) { if (group.menu_objid) {
const menuExists = existingMenuObjids.has(Number(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 보장 const newObjid = Date.now() + groupId; // 고유 ID 보장
// 부모 메뉴 objid 결정 // 부모 메뉴 objid 결정
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
let parentMenuObjid = userMenuRootObjid; let parentMenuObjid = userMenuRootObjid;
if (group.parent_group_id && group.parent_menu_objid) { if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
parentMenuObjid = Number(group.parent_menu_objid); // 현재 트랜잭션에서 생성된 부모 메뉴 사용
} else if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
parentMenuObjid = groupToMenuMap.get(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 // 같은 부모 아래에서 가장 높은 seq 조회 후 +1
@ -261,7 +291,7 @@ export async function syncScreenGroupsToMenu(
INSERT INTO menu_info ( INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng, objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc 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 RETURNING objid
`; `;
await client.query(insertMenuQuery, [ await client.query(insertMenuQuery, [

View File

@ -172,6 +172,7 @@ export function ScreenGroupTreeView({
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null); const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [syncDirection, setSyncDirection] = useState<"screen-to-menu" | "menu-to-screen" | "all" | null>(null); 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(); const { user } = useAuth();
@ -328,14 +329,31 @@ export function ScreenGroupTreeView({
setIsSyncing(true); setIsSyncing(true);
setSyncDirection(direction); setSyncDirection(direction);
setSyncProgress({
message: direction === "screen-to-menu"
? "화면관리 → 메뉴 동기화 중..."
: "메뉴 → 화면관리 동기화 중...",
detail: "데이터를 분석하고 있습니다..."
});
try { try {
setSyncProgress({
message: direction === "screen-to-menu"
? "화면관리 → 메뉴 동기화 중..."
: "메뉴 → 화면관리 동기화 중...",
detail: "동기화 작업을 수행하고 있습니다..."
});
const response = direction === "screen-to-menu" const response = direction === "screen-to-menu"
? await syncScreenGroupsToMenu(targetCompanyCode) ? await syncScreenGroupsToMenu(targetCompanyCode)
: await syncMenuToScreenGroups(targetCompanyCode); : await syncMenuToScreenGroups(targetCompanyCode);
if (response.success) { if (response.success) {
const data = response.data; const data = response.data;
setSyncProgress({
message: "동기화 완료!",
detail: `생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}`
});
toast.success( toast.success(
`동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}` `동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}`
); );
@ -347,13 +365,17 @@ export function ScreenGroupTreeView({
setSyncStatus(statusResponse.data); setSyncStatus(statusResponse.data);
} }
} else { } else {
setSyncProgress(null);
toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`); toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`);
} }
} catch (error: any) { } catch (error: any) {
setSyncProgress(null);
toast.error(`동기화 실패: ${error.message}`); toast.error(`동기화 실패: ${error.message}`);
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
setSyncDirection(null); setSyncDirection(null);
// 3초 후 진행 메시지 초기화
setTimeout(() => setSyncProgress(null), 3000);
} }
}; };
@ -366,27 +388,42 @@ export function ScreenGroupTreeView({
setIsSyncing(true); setIsSyncing(true);
setSyncDirection("all"); setSyncDirection("all");
setSyncProgress({
message: "전체 회사 동기화 중...",
detail: "모든 회사의 데이터를 분석하고 있습니다..."
});
try { try {
setSyncProgress({
message: "전체 회사 동기화 중...",
detail: "양방향 동기화 작업을 수행하고 있습니다..."
});
const response = await syncAllCompanies(); const response = await syncAllCompanies();
if (response.success && response.data) { if (response.success && response.data) {
const data = response.data; const data = response.data;
setSyncProgress({
message: "전체 동기화 완료!",
detail: `${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}`
});
toast.success( toast.success(
`전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}` `전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}`
); );
// 그룹 데이터 새로고침 // 그룹 데이터 새로고침
await loadGroupsData(); await loadGroupsData();
// 동기화 다이얼로그 닫기
setIsSyncDialogOpen(false);
} else { } else {
setSyncProgress(null);
toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`); toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`);
} }
} catch (error: any) { } catch (error: any) {
setSyncProgress(null);
toast.error(`전체 동기화 실패: ${error.message}`); toast.error(`전체 동기화 실패: ${error.message}`);
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
setSyncDirection(null); setSyncDirection(null);
// 3초 후 진행 메시지 초기화
setTimeout(() => setSyncProgress(null), 3000);
} }
}; };
@ -979,15 +1016,17 @@ export function ScreenGroupTreeView({
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
<Button {isSuperAdmin && (
onClick={handleOpenSyncDialog} <Button
variant="ghost" onClick={handleOpenSyncDialog}
size="sm" variant="ghost"
className="w-full gap-2 text-muted-foreground" size="sm"
> className="w-full gap-2 text-muted-foreground"
<RefreshCw className="h-4 w-4" /> >
<RefreshCw className="h-4 w-4" />
</Button>
</Button>
)}
</div> </div>
{/* 트리 목록 */} {/* 트리 목록 */}
@ -1816,7 +1855,23 @@ export function ScreenGroupTreeView({
{/* 메뉴-화면그룹 동기화 다이얼로그 */} {/* 메뉴-화면그룹 동기화 다이얼로그 */}
<Dialog open={isSyncDialogOpen} onOpenChange={setIsSyncDialogOpen}> <Dialog open={isSyncDialogOpen} onOpenChange={setIsSyncDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px] overflow-hidden">
{/* 동기화 진행 중 오버레이 (삭제와 동일한 스타일) */}
{isSyncing && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="mt-4 text-sm font-medium">{syncProgress?.message || "동기화 중..."}</p>
{syncProgress?.detail && (
<p className="mt-1 text-xs text-muted-foreground">{syncProgress.detail}</p>
)}
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-primary animate-pulse"
style={{ width: "100%" }}
/>
</div>
</div>
)}
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg">- </DialogTitle> <DialogTitle className="text-base sm:text-lg">- </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">

View File

@ -384,20 +384,36 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]); }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 복원 (localStorage) // 상태 복원 (localStorage) - 프로덕션 안전성 강화
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const savedState = localStorage.getItem(stateStorageKey);
if (savedState) { try {
try { const savedState = localStorage.getItem(stateStorageKey);
const parsed = JSON.parse(savedState); if (!savedState) return;
if (parsed.fields) setFields(parsed.fields);
if (parsed.pivotState) setPivotState(parsed.pivotState); const parsed = JSON.parse(savedState);
if (parsed.sortConfig) setSortConfig(parsed.sortConfig);
if (parsed.columnWidths) setColumnWidths(parsed.columnWidths); // 필드 복원 시 유효성 검사 (중요!)
} catch (e) { if (parsed.fields && Array.isArray(parsed.fields) && parsed.fields.length > 0) {
console.warn("피벗 상태 복원 실패:", e); // 저장된 필드가 현재 데이터와 호환되는지 확인
const validFields = parsed.fields.filter((f: PivotFieldConfig) =>
f && typeof f.field === "string" && typeof f.area === "string"
);
if (validFields.length > 0) {
setFields(validFields);
}
} }
// 나머지 상태 복원
if (parsed.pivotState) setPivotState(parsed.pivotState);
if (parsed.sortConfig) setSortConfig(parsed.sortConfig);
if (parsed.columnWidths) setColumnWidths(parsed.columnWidths);
} catch (e) {
console.warn("피벗 상태 복원 실패, localStorage 초기화:", e);
// 손상된 상태는 제거
localStorage.removeItem(stateStorageKey);
} }
}, [stateStorageKey]); }, [stateStorageKey]);
@ -512,15 +528,15 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
return null; return null;
} }
const visibleFields = fields.filter((f) => f.visible !== false); // FieldChooser에서 이미 필드를 완전히 제거하므로 visible 필터링 불필요
// 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외)
if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { if (fields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) {
return null; return null;
} }
const result = processPivotData( const result = processPivotData(
filteredData, filteredData,
visibleFields, fields,
pivotState.expandedRowPaths, pivotState.expandedRowPaths,
pivotState.expandedColumnPaths pivotState.expandedColumnPaths
); );
@ -536,32 +552,18 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
}); });
return result; return result;
}, [ }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
filteredData,
fields,
JSON.stringify(pivotState.expandedRowPaths),
JSON.stringify(pivotState.expandedColumnPaths)
]);
// 🆕 초기 로드 시 첫 레벨 자동 확장 // 초기 로드 시 첫 레벨 자동 확장
useEffect(() => { useEffect(() => {
if (pivotResult && pivotResult.flatRows.length > 0 && !isInitialExpanded) { if (pivotResult && pivotResult.flatRows.length > 0 && !isInitialExpanded) {
console.log("🔶 피벗 결과 생성됨:", {
flatRowsCount: pivotResult.flatRows.length,
expandedRowPaths: pivotState.expandedRowPaths.length,
isInitialExpanded,
});
// 첫 레벨 행들의 경로 수집 (level 0인 행들) // 첫 레벨 행들의 경로 수집 (level 0인 행들)
const firstLevelRows = pivotResult.flatRows.filter(row => row.level === 0 && row.hasChildren); 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) { if (firstLevelRows.length > 0) {
const firstLevelPaths = firstLevelRows.map(row => row.path); const firstLevelPaths = firstLevelRows.map((row) => row.path);
console.log("🔶 초기 자동 확장 실행 (한 번만):", firstLevelPaths); setPivotState((prev) => ({
setPivotState(prev => ({
...prev, ...prev,
expandedRowPaths: firstLevelPaths, expandedRowPaths: firstLevelPaths,
})); }));
@ -725,19 +727,16 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 필드 변경 // 필드 변경
const handleFieldsChange = useCallback( const handleFieldsChange = useCallback(
(newFields: PivotFieldConfig[]) => { (newFields: PivotFieldConfig[]) => {
// 🆕 visible: false 필드 제거 (FieldChooser에서 "사용 안함"으로 설정한 필드) // FieldChooser에서 이미 필드를 완전히 제거하므로 추가 필터링 불필요
const visibleFields = newFields.filter(f => f.visible !== false);
console.log("🔷 [handleFieldsChange] 필드 변경:", { console.log("🔷 [handleFieldsChange] 필드 변경:", {
totalFields: newFields.length, totalFields: newFields.length,
visibleFields: visibleFields.length, filterFields: newFields.filter(f => f.area === "filter").length,
removedFields: newFields.length - visibleFields.length, filterFieldNames: newFields.filter(f => f.area === "filter").map(f => f.field),
filterFields: visibleFields.filter(f => f.area === "filter").length, rowFields: newFields.filter(f => f.area === "row").length,
filterFieldNames: visibleFields.filter(f => f.area === "filter").map(f => f.field), columnFields: newFields.filter(f => f.area === "column").length,
dataFields: newFields.filter(f => f.area === "data").length,
}); });
console.log("🔷 [handleFieldsChange] setFields 호출 전"); setFields(newFields);
setFields(visibleFields);
console.log("🔷 [handleFieldsChange] setFields 호출 후");
}, },
[] []
); );
@ -945,6 +944,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함) // 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함)
const handlePrint = useCallback(() => { const handlePrint = useCallback(() => {
if (typeof window === "undefined") return;
const printContent = tableRef.current; const printContent = tableRef.current;
if (!printContent) return; if (!printContent) return;
@ -1047,8 +1048,10 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 상태 초기화 (확장/축소, 정렬, 필터만 초기화, 필드 설정은 유지) // 상태 초기화 (확장/축소, 정렬, 필터만 초기화, 필드 설정은 유지)
const handleResetState = useCallback(() => { const handleResetState = useCallback(() => {
// 로컬 스토리지에서 상태 제거 // 로컬 스토리지에서 상태 제거 (SSR 보호)
localStorage.removeItem(stateStorageKey); if (typeof window !== "undefined") {
localStorage.removeItem(stateStorageKey);
}
// 확장/축소, 정렬, 필터 상태만 초기화 // 확장/축소, 정렬, 필터 상태만 초기화
setPivotState({ setPivotState({
@ -1061,9 +1064,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
setColumnWidths({}); setColumnWidths({});
setSelectedCell(null); setSelectedCell(null);
setSelectionRange(null); setSelectionRange(null);
// 🆕 필드 설정은 유지 (initialFields로 되돌리지 않음)
console.log("🔷 피벗 상태가 초기화되었습니다 (필드 설정은 유지)");
}, [stateStorageKey]); }, [stateStorageKey]);
// 필드 숨기기/표시 상태 // 필드 숨기기/표시 상태
@ -1081,11 +1081,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
}); });
}, []); }, []);
// 숨겨진 필드 제외한 활성 필드들
const visibleFields = useMemo(() => {
return fields.filter((f) => !hiddenFields.has(f.field));
}, [fields, hiddenFields]);
// 숨겨진 필드 목록 // 숨겨진 필드 목록
const hiddenFieldsList = useMemo(() => { const hiddenFieldsList = useMemo(() => {
return fields.filter((f) => hiddenFields.has(f.field)); return fields.filter((f) => hiddenFields.has(f.field));