feat: 엑셀 다운로드 전체 데이터 페이지별 순차 조회 방식으로 개선
- UniversalListPage: size=10000 단건 호출을 1000건씩 페이지 순회 방식으로 변경 - fetchAllUrl 우선순위를 clientSideFiltering보다 상위로 조정 - 첫 페이지에서 total 확인 후 나머지 페이지 병렬(Promise.all) 호출 - 10000건 초과 데이터도 누락 없이 다운로드 가능 - 근태현황: fetchAllUrl, fetchAllParams, mapResponse 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -483,11 +483,63 @@ export function AttendanceManagement() {
|
|||||||
|
|
||||||
searchPlaceholder: '이름, 부서 검색...',
|
searchPlaceholder: '이름, 부서 검색...',
|
||||||
|
|
||||||
// 엑셀 다운로드 설정 (클라이언트 사이드 필터링이므로 filteredData 사용)
|
// 엑셀 다운로드 설정 (fetchAllUrl로 전체 데이터 조회)
|
||||||
excelDownload: {
|
excelDownload: {
|
||||||
columns: excelColumns,
|
columns: excelColumns,
|
||||||
filename: '근태현황',
|
filename: '근태현황',
|
||||||
sheetName: '근태',
|
sheetName: '근태',
|
||||||
|
fetchAllUrl: '/api/proxy/attendances',
|
||||||
|
fetchAllParams: () => {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (startDate) params.date_from = startDate;
|
||||||
|
if (endDate) params.date_to = endDate;
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
mapResponse: (result: unknown) => {
|
||||||
|
const res = result as { data?: { data?: Record<string, unknown>[] } };
|
||||||
|
const items = res.data?.data ?? [];
|
||||||
|
return items.map((item) => {
|
||||||
|
const user = item.user as Record<string, unknown> | undefined;
|
||||||
|
const profiles = (user?.tenant_profiles ?? []) as Record<string, unknown>[];
|
||||||
|
const profile = profiles[0] as Record<string, unknown> | undefined;
|
||||||
|
const dept = profile?.department as Record<string, unknown> | undefined;
|
||||||
|
const legacyProfile = user?.tenant_profile as Record<string, unknown> | undefined;
|
||||||
|
const legacyDept = legacyProfile?.department as Record<string, unknown> | undefined;
|
||||||
|
const jsonDetails = (item.json_details ?? {}) as Record<string, unknown>;
|
||||||
|
const breakMins = item.break_minutes as number | null;
|
||||||
|
const overtimeMins = jsonDetails.overtime_minutes as number | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(item.id),
|
||||||
|
employeeId: String(item.user_id),
|
||||||
|
employeeName: (user?.name ?? '') as string,
|
||||||
|
department: (dept?.name ?? legacyDept?.name ?? '') as string,
|
||||||
|
position: (profile?.position_key ?? '') as string,
|
||||||
|
rank: ((legacyProfile?.rank ?? '') as string),
|
||||||
|
baseDate: item.base_date as string,
|
||||||
|
checkIn: (item.check_in ?? jsonDetails.check_in ?? null) as string | null,
|
||||||
|
checkOut: (item.check_out ?? jsonDetails.check_out ?? null) as string | null,
|
||||||
|
breakTime: breakMins != null
|
||||||
|
? `${Math.floor(breakMins / 60)}:${(breakMins % 60).toString().padStart(2, '0')}`
|
||||||
|
: (jsonDetails.break_time as string || null),
|
||||||
|
overtimeHours: overtimeMins
|
||||||
|
? (() => {
|
||||||
|
const h = Math.floor(overtimeMins / 60);
|
||||||
|
const m = overtimeMins % 60;
|
||||||
|
if (h > 0 && m > 0) return `${h}시간 ${m}분`;
|
||||||
|
if (h > 0) return `${h}시간`;
|
||||||
|
return `${m}분`;
|
||||||
|
})()
|
||||||
|
: null,
|
||||||
|
workMinutes: (jsonDetails.work_minutes || null) as number | null,
|
||||||
|
reason: (jsonDetails.reason || null) as AttendanceRecord['reason'],
|
||||||
|
status: item.status as string,
|
||||||
|
remarks: (item.remarks ?? null) as string | null,
|
||||||
|
createdAt: item.created_at as string,
|
||||||
|
updatedAt: item.updated_at as string,
|
||||||
|
} as AttendanceRecord;
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
itemsPerPage: itemsPerPage,
|
itemsPerPage: itemsPerPage,
|
||||||
|
|||||||
@@ -545,18 +545,12 @@ export function UniversalListPage<T>({
|
|||||||
console.log('[Excel] filteredData.length:', filteredData.length);
|
console.log('[Excel] filteredData.length:', filteredData.length);
|
||||||
console.log('[Excel] fetchAllUrl:', fetchAllUrl);
|
console.log('[Excel] fetchAllUrl:', fetchAllUrl);
|
||||||
|
|
||||||
// 클라이언트 사이드 필터링: 현재 필터링된 전체 데이터 사용
|
// fetchAllUrl이 있으면 서버에서 전체 데이터 페이지별 순차 조회 (clientSideFiltering 여부 무관)
|
||||||
if (config.clientSideFiltering) {
|
if (fetchAllUrl) {
|
||||||
dataToDownload = filteredData;
|
const PAGE_SIZE = 1000;
|
||||||
}
|
|
||||||
// 서버 사이드: fetchAllUrl로 전체 데이터 조회
|
|
||||||
else if (fetchAllUrl) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
// 전체 데이터 조회 - API마다 다른 파라미터명 사용 가능하므로 둘 다 설정
|
|
||||||
params.append('size', '10000');
|
|
||||||
params.append('per_page', '10000');
|
|
||||||
|
|
||||||
// 동적 파라미터 추가
|
// 동적 파라미터 구성
|
||||||
|
const baseParams = new URLSearchParams();
|
||||||
if (fetchAllParams) {
|
if (fetchAllParams) {
|
||||||
const additionalParams = fetchAllParams({
|
const additionalParams = fetchAllParams({
|
||||||
activeTab,
|
activeTab,
|
||||||
@@ -564,23 +558,69 @@ export function UniversalListPage<T>({
|
|||||||
searchValue: debouncedSearchValue,
|
searchValue: debouncedSearchValue,
|
||||||
});
|
});
|
||||||
Object.entries(additionalParams).forEach(([key, value]) => {
|
Object.entries(additionalParams).forEach(([key, value]) => {
|
||||||
if (value) params.append(key, value);
|
if (value) baseParams.append(key, value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${fetchAllUrl}?${params.toString()}`);
|
// 1) 첫 페이지 호출로 total 확인
|
||||||
const result = await response.json();
|
const firstParams = new URLSearchParams(baseParams);
|
||||||
|
firstParams.set('size', String(PAGE_SIZE));
|
||||||
|
firstParams.set('per_page', String(PAGE_SIZE));
|
||||||
|
firstParams.set('page', '1');
|
||||||
|
|
||||||
if (!result.success) {
|
const firstResponse = await fetch(`${fetchAllUrl}?${firstParams.toString()}`);
|
||||||
throw new Error(result.message || '데이터 조회에 실패했습니다.');
|
const firstResult = await firstResponse.json();
|
||||||
|
|
||||||
|
if (!firstResult.success) {
|
||||||
|
throw new Error(firstResult.message || '데이터 조회에 실패했습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 응답 매핑
|
const firstPageData = mapResponse
|
||||||
const rawData = mapResponse
|
? mapResponse(firstResult)
|
||||||
? mapResponse(result)
|
: (firstResult.data?.data ?? firstResult.data ?? []);
|
||||||
: (result.data?.data ?? result.data ?? []);
|
|
||||||
|
|
||||||
dataToDownload = rawData as T[];
|
const total = firstResult.data?.total ?? firstPageData.length;
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
console.log(`[Excel] 전체 ${total}건, ${totalPages}페이지 조회 시작`);
|
||||||
|
|
||||||
|
// 2) 나머지 페이지 병렬 호출
|
||||||
|
const allData: T[] = [...(firstPageData as T[])];
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
const remainingPages = Array.from(
|
||||||
|
{ length: totalPages - 1 },
|
||||||
|
(_, i) => i + 2
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageResults = await Promise.all(
|
||||||
|
remainingPages.map(async (page) => {
|
||||||
|
const pageParams = new URLSearchParams(baseParams);
|
||||||
|
pageParams.set('size', String(PAGE_SIZE));
|
||||||
|
pageParams.set('per_page', String(PAGE_SIZE));
|
||||||
|
pageParams.set('page', String(page));
|
||||||
|
|
||||||
|
const res = await fetch(`${fetchAllUrl}?${pageParams.toString()}`);
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.warn(`[Excel] 페이지 ${page} 조회 실패:`, result.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapResponse
|
||||||
|
? mapResponse(result)
|
||||||
|
: (result.data?.data ?? result.data ?? []);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const pageData of pageResults) {
|
||||||
|
allData.push(...(pageData as T[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Excel] 총 ${allData.length}건 조회 완료`);
|
||||||
|
dataToDownload = allData;
|
||||||
}
|
}
|
||||||
// fetchAllUrl 없으면 현재 로드된 데이터 사용
|
// fetchAllUrl 없으면 현재 로드된 데이터 사용
|
||||||
else {
|
else {
|
||||||
|
|||||||
Reference in New Issue
Block a user