From 4014b3fb8418bec72b37e0fd56f3cfe4e547fb51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 29 Jan 2026 16:48:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=91=EC=85=80=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=A0=84=EC=B2=B4=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=B3=84=20=EC=88=9C?= =?UTF-8?q?=EC=B0=A8=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UniversalListPage: size=10000 단건 호출을 1000건씩 페이지 순회 방식으로 변경 - fetchAllUrl 우선순위를 clientSideFiltering보다 상위로 조정 - 첫 페이지에서 total 확인 후 나머지 페이지 병렬(Promise.all) 호출 - 10000건 초과 데이터도 누락 없이 다운로드 가능 - 근태현황: fetchAllUrl, fetchAllParams, mapResponse 추가 Co-Authored-By: Claude Opus 4.5 --- .../hr/AttendanceManagement/index.tsx | 54 +++++++++++- .../templates/UniversalListPage/index.tsx | 82 ++++++++++++++----- 2 files changed, 114 insertions(+), 22 deletions(-) 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 {