diff --git a/src/components/hr/AttendanceManagement/index.tsx b/src/components/hr/AttendanceManagement/index.tsx index d7a3dff0..e8804a9c 100644 --- a/src/components/hr/AttendanceManagement/index.tsx +++ b/src/components/hr/AttendanceManagement/index.tsx @@ -483,11 +483,63 @@ export function AttendanceManagement() { searchPlaceholder: '이름, 부서 검색...', - // 엑셀 다운로드 설정 (클라이언트 사이드 필터링이므로 filteredData 사용) + // 엑셀 다운로드 설정 (fetchAllUrl로 전체 데이터 조회) excelDownload: { columns: excelColumns, filename: '근태현황', sheetName: '근태', + fetchAllUrl: '/api/proxy/attendances', + fetchAllParams: () => { + const params: Record = {}; + if (startDate) params.date_from = startDate; + if (endDate) params.date_to = endDate; + return params; + }, + mapResponse: (result: unknown) => { + const res = result as { data?: { data?: Record[] } }; + const items = res.data?.data ?? []; + return items.map((item) => { + const user = item.user as Record | undefined; + const profiles = (user?.tenant_profiles ?? []) as Record[]; + const profile = profiles[0] as Record | undefined; + const dept = profile?.department as Record | undefined; + const legacyProfile = user?.tenant_profile as Record | undefined; + const legacyDept = legacyProfile?.department as Record | undefined; + const jsonDetails = (item.json_details ?? {}) as Record; + 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, diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index dbfea19d..cf203c01 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -545,18 +545,12 @@ export function UniversalListPage({ console.log('[Excel] filteredData.length:', filteredData.length); console.log('[Excel] fetchAllUrl:', fetchAllUrl); - // 클라이언트 사이드 필터링: 현재 필터링된 전체 데이터 사용 - if (config.clientSideFiltering) { - dataToDownload = filteredData; - } - // 서버 사이드: fetchAllUrl로 전체 데이터 조회 - else if (fetchAllUrl) { - const params = new URLSearchParams(); - // 전체 데이터 조회 - API마다 다른 파라미터명 사용 가능하므로 둘 다 설정 - params.append('size', '10000'); - params.append('per_page', '10000'); + // fetchAllUrl이 있으면 서버에서 전체 데이터 페이지별 순차 조회 (clientSideFiltering 여부 무관) + if (fetchAllUrl) { + const PAGE_SIZE = 1000; - // 동적 파라미터 추가 + // 동적 파라미터 구성 + const baseParams = new URLSearchParams(); if (fetchAllParams) { const additionalParams = fetchAllParams({ activeTab, @@ -564,23 +558,69 @@ export function UniversalListPage({ searchValue: debouncedSearchValue, }); 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()}`); - const result = await response.json(); + // 1) 첫 페이지 호출로 total 확인 + 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) { - throw new Error(result.message || '데이터 조회에 실패했습니다.'); + const firstResponse = await fetch(`${fetchAllUrl}?${firstParams.toString()}`); + const firstResult = await firstResponse.json(); + + if (!firstResult.success) { + throw new Error(firstResult.message || '데이터 조회에 실패했습니다.'); } - // 응답 매핑 - const rawData = mapResponse - ? mapResponse(result) - : (result.data?.data ?? result.data ?? []); + const firstPageData = mapResponse + ? mapResponse(firstResult) + : (firstResult.data?.data ?? firstResult.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 없으면 현재 로드된 데이터 사용 else {