Skip to content

Commit 4e85b30

Browse files
committed
fix: resolve PayrollConfiguration pagination race condition
- Replace useEmployeesListSuspense with useEmployeesList + keepPreviousData - Add committedData pattern to sync employee data with compensations - Add stale request detection in usePreparedPayrollData to prevent race conditions - Handle empty page edge case in isDataInSync logic - Add isFetching prop to PayrollConfigurationPresentation
1 parent 7eef735 commit 4e85b30

File tree

5 files changed

+151
-96
lines changed

5 files changed

+151
-96
lines changed

src/components/Payroll/PayrollConfiguration/PayrollConfiguration.tsx

Lines changed: 132 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useState, type ReactNode } from 'react'
2-
import { useEmployeesListSuspense } from '@gusto/embedded-api/react-query/employeesList'
2+
import { useEmployeesList } from '@gusto/embedded-api/react-query/employeesList'
33
import { usePayrollsGetSuspense } from '@gusto/embedded-api/react-query/payrollsGet'
4+
import { keepPreviousData } from '@tanstack/react-query'
45
import { usePayrollsCalculateMutation } from '@gusto/embedded-api/react-query/payrollsCalculate'
56
import type { Employee } from '@gusto/embedded-api/models/components/employee'
67
import type { PayrollProcessingRequest } from '@gusto/embedded-api/models/components/payrollprocessingrequest'
@@ -9,6 +10,7 @@ import { useTranslation } from 'react-i18next'
910
import { usePayrollsUpdateMutation } from '@gusto/embedded-api/react-query/payrollsUpdate'
1011
import type { PayrollEmployeeCompensationsType } from '@gusto/embedded-api/models/components/payrollemployeecompensationstype'
1112
import type { PayrollUpdateEmployeeCompensations } from '@gusto/embedded-api/models/components/payrollupdate'
13+
import type { EmployeeCompensations } from '@gusto/embedded-api/models/components/payrollshow'
1214
import { usePreparedPayrollData } from '../usePreparedPayrollData'
1315
import { payrollSubmitHandler, type ApiPayrollBlocker } from '../PayrollBlocker/payrollHelpers'
1416
import { PayrollConfigurationPresentation } from './PayrollConfigurationPresentation'
@@ -18,7 +20,9 @@ import { componentEvents } from '@/shared/constants'
1820
import { useComponentDictionary, useI18n } from '@/i18n'
1921
import { useBase } from '@/components/Base'
2022
import type { PaginationItemsPerPage } from '@/components/Common/PaginationControl/PaginationControlTypes'
23+
import { Loading } from '@/components/Common'
2124
import { useDateFormatter } from '@/hooks/useDateFormatter'
25+
import { usePagination } from '@/hooks/usePagination'
2226

2327
const isCalculating = (processingRequest?: PayrollProcessingRequest | null) =>
2428
processingRequest?.status === PayrollProcessingRequestStatus.Calculating
@@ -32,6 +36,12 @@ interface PayrollConfigurationProps extends BaseComponentInterface<'Payroll.Payr
3236
withReimbursements?: boolean
3337
}
3438

39+
interface CommittedTableData {
40+
employees: Employee[]
41+
compensations: EmployeeCompensations[]
42+
page: number
43+
}
44+
3545
export function PayrollConfiguration(props: PayrollConfigurationProps & BaseComponentInterface) {
3646
return (
3747
<BaseComponent {...props}>
@@ -53,56 +63,42 @@ export const Root = ({
5363
const { t } = useTranslation('Payroll.PayrollConfiguration')
5464
const { baseSubmitHandler } = useBase()
5565
const dateFormatter = useDateFormatter()
56-
const defaultItemsPerPage = 10
5766

5867
const [currentPage, setCurrentPage] = useState(1)
59-
const [itemsPerPage, setItemsPerPage] = useState<PaginationItemsPerPage>(defaultItemsPerPage)
68+
const [itemsPerPage, setItemsPerPage] = useState<PaginationItemsPerPage>(10)
6069
const [isPolling, setIsPolling] = useState(false)
6170
const [payrollBlockers, setPayrollBlockers] = useState<ApiPayrollBlocker[]>([])
6271

63-
const { data: employeeData, isFetching: isFetchingEmployeeData } = useEmployeesListSuspense({
64-
companyId,
65-
payrollUuid: payrollId, // get back list of employees to specific payroll
66-
per: itemsPerPage,
67-
page: currentPage,
68-
sortBy: 'name', // sort alphanumeric by employee last_names
69-
})
70-
71-
// get list of employee uuids to filter into prepare endpoint to get back employee_compensation data
72-
const employeeUuids = useMemo(() => {
73-
return employeeData.showEmployees?.map(e => e.uuid) || []
74-
}, [employeeData.showEmployees])
75-
76-
const totalPages = Number(employeeData.httpMeta.response.headers.get('x-total-pages') ?? 1)
77-
78-
const handleItemsPerPageChange = (newCount: PaginationItemsPerPage) => {
79-
setItemsPerPage(newCount)
80-
}
81-
const handleFirstPage = () => {
82-
setCurrentPage(1)
83-
}
84-
const handlePreviousPage = () => {
85-
setCurrentPage(prevPage => Math.max(prevPage - 1, 1))
86-
}
87-
const handleNextPage = () => {
88-
setCurrentPage(prevPage => Math.min(prevPage + 1, totalPages))
89-
}
90-
const handleLastPage = () => {
91-
setCurrentPage(totalPages)
92-
}
72+
const {
73+
data: employeeData,
74+
isFetching: isFetchingEmployeeData,
75+
fetchStatus,
76+
} = useEmployeesList(
77+
{
78+
companyId,
79+
payrollUuid: payrollId,
80+
per: itemsPerPage,
81+
page: currentPage,
82+
sortBy: 'name',
83+
},
84+
{ placeholderData: keepPreviousData },
85+
)
9386

9487
const pagination = {
95-
currentPage,
96-
handleFirstPage,
97-
handlePreviousPage,
98-
handleNextPage,
99-
handleLastPage,
100-
handleItemsPerPageChange,
101-
totalPages,
88+
...usePagination(employeeData?.httpMeta, {
89+
currentPage,
90+
itemsPerPage,
91+
setCurrentPage,
92+
setItemsPerPage,
93+
}),
10294
isFetching: isFetchingEmployeeData,
103-
itemsPerPage,
10495
}
10596

97+
const employeeUuids = useMemo(
98+
() => employeeData?.showEmployees?.map(e => e.uuid) ?? [],
99+
[employeeData?.showEmployees],
100+
)
101+
106102
const { data: payrollData } = usePayrollsGetSuspense(
107103
{
108104
companyId,
@@ -125,11 +121,83 @@ export const Root = ({
125121
companyId,
126122
payrollId,
127123
employeeUuids,
128-
sortBy: 'last_name', // sort alphanumeric by employee last_names to match employees GET
124+
sortBy: 'last_name',
129125
})
130126

127+
const [committedData, setCommittedData] = useState<CommittedTableData | null>(null)
128+
129+
const compensationUuids = useMemo(
130+
() => new Set(preparedPayroll?.employeeCompensations?.map(c => c.employeeUuid) ?? []),
131+
[preparedPayroll?.employeeCompensations],
132+
)
133+
134+
const isEmptyPage = employeeData?.showEmployees !== undefined && employeeUuids.length === 0
135+
136+
const doUuidsMatch = useMemo(
137+
() => employeeUuids.length > 0 && employeeUuids.every(uuid => compensationUuids.has(uuid)),
138+
[employeeUuids, compensationUuids],
139+
)
140+
141+
const isDataInSync =
142+
!isFetchingEmployeeData &&
143+
!isPrepareLoading &&
144+
employeeData?.showEmployees !== undefined &&
145+
(isEmptyPage || (preparedPayroll?.employeeCompensations && doUuidsMatch))
146+
147+
useEffect(() => {
148+
if (isDataInSync) {
149+
setCommittedData({
150+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
151+
employees: employeeData?.showEmployees ?? [],
152+
compensations: isEmptyPage ? [] : (preparedPayroll?.employeeCompensations ?? []),
153+
page: currentPage,
154+
})
155+
}
156+
}, [
157+
isDataInSync,
158+
employeeData?.showEmployees,
159+
preparedPayroll?.employeeCompensations,
160+
currentPage,
161+
isEmptyPage,
162+
])
163+
164+
useEffect(() => {
165+
if (!payrollData.payrollShow) return
166+
167+
if (isCalculating(payrollData.payrollShow.processingRequest) && !isPolling) {
168+
setIsPolling(true)
169+
}
170+
if (isPolling && isCalculated(payrollData.payrollShow.processingRequest)) {
171+
onEvent(componentEvents.RUN_PAYROLL_CALCULATED, {
172+
payrollId,
173+
alert: { type: 'success', title: t('alerts.progressSaved') },
174+
payPeriod: payrollData.payrollShow.payPeriod,
175+
})
176+
setPayrollBlockers([])
177+
setIsPolling(false)
178+
}
179+
if (
180+
isPolling &&
181+
payrollData.payrollShow.processingRequest?.status ===
182+
PayrollProcessingRequestStatus.ProcessingFailed
183+
) {
184+
onEvent(componentEvents.RUN_PAYROLL_PROCESSING_FAILED)
185+
setIsPolling(false)
186+
}
187+
}, [
188+
payrollData.payrollShow?.processingRequest,
189+
isPolling,
190+
onEvent,
191+
t,
192+
payrollId,
193+
payrollData.payrollShow?.calculatedAt,
194+
])
195+
196+
if (fetchStatus === 'fetching' && !employeeData) {
197+
return <Loading />
198+
}
199+
131200
const onCalculatePayroll = async () => {
132-
// Clear any existing blockers before attempting calculation
133201
setPayrollBlockers([])
134202

135203
await baseSubmitHandler({}, async () => {
@@ -148,24 +216,25 @@ export const Root = ({
148216
}
149217
})
150218
}
219+
151220
const onEdit = (employee: Employee) => {
152221
onEvent(componentEvents.RUN_PAYROLL_EMPLOYEE_EDIT, {
153222
employeeId: employee.uuid,
154223
firstName: employee.firstName,
155224
lastName: employee.lastName,
156225
})
157226
}
227+
158228
const transformEmployeeCompensation = ({
159229
paymentMethod,
160230
reimbursements,
161231
...compensation
162-
}: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => {
163-
return {
164-
...compensation,
165-
...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}),
166-
memo: compensation.memo || undefined,
167-
}
168-
}
232+
}: PayrollEmployeeCompensationsType): PayrollUpdateEmployeeCompensations => ({
233+
...compensation,
234+
...(paymentMethod && paymentMethod !== 'Historical' ? { paymentMethod } : {}),
235+
memo: compensation.memo || undefined,
236+
})
237+
169238
const onToggleExclude = async (employeeCompensation: PayrollEmployeeCompensationsType) => {
170239
onEvent(componentEvents.RUN_PAYROLL_EMPLOYEE_SKIP, {
171240
employeeId: employeeCompensation.employeeUuid,
@@ -186,49 +255,10 @@ export const Root = ({
186255
onEvent(componentEvents.RUN_PAYROLL_EMPLOYEE_SAVED, {
187256
payrollPrepared: result.payrollPrepared,
188257
})
189-
// Refresh preparedPayroll to get updated data
190258
await handlePreparePayroll()
191259
})
192260
}
193261

194-
const onViewBlockers = () => {
195-
onEvent(componentEvents.RUN_PAYROLL_BLOCKERS_VIEW_ALL)
196-
}
197-
198-
useEffect(() => {
199-
// Start polling when payroll is calculating and not already polling
200-
if (isCalculating(payrollData.payrollShow?.processingRequest) && !isPolling) {
201-
setIsPolling(true)
202-
}
203-
// Stop polling and emit event when payroll is calculated successfully
204-
if (isPolling && isCalculated(payrollData.payrollShow?.processingRequest)) {
205-
onEvent(componentEvents.RUN_PAYROLL_CALCULATED, {
206-
payrollId,
207-
alert: { type: 'success', title: t('alerts.progressSaved') },
208-
payPeriod: payrollData.payrollShow?.payPeriod,
209-
})
210-
// Clear blockers on successful calculation
211-
setPayrollBlockers([])
212-
setIsPolling(false)
213-
}
214-
// If we are polling and payroll is in failed state, stop polling, and emit failure event
215-
if (
216-
isPolling &&
217-
payrollData.payrollShow?.processingRequest?.status ===
218-
PayrollProcessingRequestStatus.ProcessingFailed
219-
) {
220-
onEvent(componentEvents.RUN_PAYROLL_PROCESSING_FAILED)
221-
setIsPolling(false)
222-
}
223-
}, [
224-
payrollData.payrollShow?.processingRequest,
225-
isPolling,
226-
onEvent,
227-
t,
228-
payrollId,
229-
payrollData.payrollShow?.calculatedAt,
230-
])
231-
232262
const payrollDeadlineNotice = payrollData.payrollShow
233263
? {
234264
label: t('alerts.directDepositDeadline', {
@@ -239,20 +269,29 @@ export const Root = ({
239269
}
240270
: undefined
241271

272+
// Fallback: Use committed (synced) data first, then live data, then empty array.
273+
// This ensures employee names and compensations display together after pagination.
274+
const displayCompensations =
275+
committedData?.compensations ?? preparedPayroll?.employeeCompensations ?? []
276+
const displayEmployees = committedData?.employees ?? employeeData?.showEmployees ?? []
277+
242278
return (
243279
<PayrollConfigurationPresentation
244280
onCalculatePayroll={onCalculatePayroll}
245281
onEdit={onEdit}
246282
onToggleExclude={onToggleExclude}
247-
onViewBlockers={onViewBlockers}
248-
employeeCompensations={preparedPayroll?.employeeCompensations || []}
249-
employeeDetails={employeeData.showEmployees || []}
283+
onViewBlockers={() => {
284+
onEvent(componentEvents.RUN_PAYROLL_BLOCKERS_VIEW_ALL)
285+
}}
286+
employeeCompensations={displayCompensations}
287+
employeeDetails={displayEmployees}
250288
payPeriod={preparedPayroll?.payPeriod}
251289
paySchedule={paySchedule}
252290
isOffCycle={preparedPayroll?.offCycle}
253291
alerts={alerts}
254292
payrollDeadlineNotice={payrollDeadlineNotice}
255-
isPending={isPolling || isPrepareLoading || isUpdatingPayroll}
293+
isPending={isPolling || (isPrepareLoading && !preparedPayroll) || isUpdatingPayroll}
294+
isFetching={isFetchingEmployeeData || isPrepareLoading}
256295
payrollBlockers={payrollBlockers}
257296
pagination={pagination}
258297
withReimbursements={withReimbursements}

src/components/Payroll/PayrollConfiguration/PayrollConfigurationPresentation.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ interface PayrollConfigurationPresentationProps {
4747
content?: ReactNode
4848
}
4949
isPending?: boolean
50+
isFetching?: boolean
5051
payrollBlockers?: ApiPayrollBlocker[]
5152
pagination?: PaginationControlProps
5253
withReimbursements?: boolean
@@ -75,6 +76,7 @@ export const PayrollConfigurationPresentation = ({
7576
alerts,
7677
payrollDeadlineNotice,
7778
isPending,
79+
isFetching,
7880
payrollBlockers = [],
7981
pagination,
8082
withReimbursements = true,
@@ -264,6 +266,8 @@ export const PayrollConfigurationPresentation = ({
264266
/>
265267
)}
266268
pagination={pagination}
269+
isFetching={isFetching}
270+
emptyState={() => <Text>{t('emptyState')}</Text>}
267271
/>
268272
</>
269273
)}

src/components/Payroll/usePreparedPayrollData.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback } from 'react'
1+
import { useState, useEffect, useCallback, useRef } from 'react'
22
import { usePayrollsPrepareMutation } from '@gusto/embedded-api/react-query/payrollsPrepare'
33
import { usePaySchedulesGet } from '@gusto/embedded-api/react-query/paySchedulesGet'
44
import type { PayrollPrepared } from '@gusto/embedded-api/models/components/payrollprepared'
@@ -41,7 +41,15 @@ export const usePreparedPayrollData = ({
4141
},
4242
)
4343

44+
const requestIdRef = useRef(0)
45+
4446
const handlePreparePayroll = useCallback(async () => {
47+
if (!employeeUuids?.length) {
48+
return
49+
}
50+
51+
const thisRequestId = ++requestIdRef.current
52+
4553
await baseSubmitHandler(null, async () => {
4654
const result = await preparePayroll({
4755
request: {
@@ -53,9 +61,11 @@ export const usePreparedPayrollData = ({
5361
},
5462
},
5563
})
64+
65+
if (thisRequestId !== requestIdRef.current) return
5666
setPreparedPayroll(result.payrollPrepared)
5767
})
58-
}, [companyId, payrollId, preparePayroll, employeeUuids, baseSubmitHandler])
68+
}, [companyId, payrollId, preparePayroll, employeeUuids, sortBy, baseSubmitHandler])
5969

6070
useEffect(() => {
6171
void handlePreparePayroll()

src/i18n/en/Payroll.PayrollConfiguration.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,6 @@
3939
}
4040
},
4141
"loadingTitle": "Preparing payroll...",
42-
"loadingDescription": "This may take a minute or two. You can navigate away while this happens."
42+
"loadingDescription": "This may take a minute or two. You can navigate away while this happens.",
43+
"emptyState": "No employees found for this payroll"
4344
}

0 commit comments

Comments
 (0)