diff --git a/docs/reference/endpoint-inventory.json b/docs/reference/endpoint-inventory.json index e4c00d3c0..6b4a9beaf 100644 --- a/docs/reference/endpoint-inventory.json +++ b/docs/reference/endpoint-inventory.json @@ -837,10 +837,6 @@ "method": "GET", "path": "/v1/employees/:employeeId/jobs" }, - { - "method": "GET", - "path": "/v1/employees/:employeeId/pay_stubs" - }, { "method": "GET", "path": "/v1/employees/:employeeUuid/federal_taxes" @@ -852,18 +848,13 @@ { "method": "DELETE", "path": "/v1/jobs/:jobId" - }, - { - "method": "GET", - "path": "/v1/payrolls/:payrollId/employees/:employeeId/pay_stub" } ], "variables": [ "compensationId", "employeeId", "employeeUuid", - "jobId", - "payrollId" + "jobId" ] }, "Employee.HomeAddress": { @@ -1982,10 +1973,6 @@ "method": "GET", "path": "/v1/employees/:employeeId/jobs" }, - { - "method": "GET", - "path": "/v1/employees/:employeeId/pay_stubs" - }, { "method": "GET", "path": "/v1/employees/:employeeUuid/federal_taxes" @@ -1997,18 +1984,13 @@ { "method": "DELETE", "path": "/v1/jobs/:jobId" - }, - { - "method": "GET", - "path": "/v1/payrolls/:payrollId/employees/:employeeId/pay_stub" } ], "variables": [ "compensationId", "employeeId", "employeeUuid", - "jobId", - "payrollId" + "jobId" ] }, "EmployeeManagement.HomeAddress": { @@ -2075,6 +2057,22 @@ "employeeId" ] }, + "EmployeeManagement.PaystubsCard": { + "endpoints": [ + { + "method": "GET", + "path": "/v1/employees/:employeeId/pay_stubs" + }, + { + "method": "GET", + "path": "/v1/payrolls/:payrollId/employees/:employeeId/pay_stub" + } + ], + "variables": [ + "employeeId", + "payrollId" + ] + }, "EmployeeManagement.TerminateEmployee": { "endpoints": [ { @@ -2612,11 +2610,11 @@ "Employee.Compensation", "Employee.Deductions", "Employee.HomeAddress", - "Employee.PaymentMethod", "Employee.WorkAddress", "EmployeeManagement.DocumentManager", "EmployeeManagement.FederalTaxes", "EmployeeManagement.PaymentMethod", + "EmployeeManagement.PaystubsCard", "EmployeeManagement.Profile", "EmployeeManagement.StateTaxes" ] @@ -2668,11 +2666,11 @@ }, "EmployeeManagement.DashboardFlow": { "blocks": [ - "Employee.PaymentMethod", "EmployeeManagement.DocumentManager", "EmployeeManagement.FederalTaxes", "EmployeeManagement.HomeAddress", "EmployeeManagement.PaymentMethod", + "EmployeeManagement.PaystubsCard", "EmployeeManagement.Profile", "EmployeeManagement.StateTaxes", "EmployeeManagement.WorkAddress", diff --git a/docs/reference/endpoint-reference.md b/docs/reference/endpoint-reference.md index aa0f5c79b..0e18f5787 100644 --- a/docs/reference/endpoint-reference.md +++ b/docs/reference/endpoint-reference.md @@ -163,11 +163,9 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | | GET | `/v1/employees/:employeeId` | | | GET | `/v1/employees/:employeeId/forms` | | | GET | `/v1/employees/:employeeId/jobs` | -| | GET | `/v1/employees/:employeeId/pay_stubs` | | | GET | `/v1/employees/:employeeUuid/federal_taxes` | | | GET | `/v1/employees/:employeeUuid/state_taxes` | | | DELETE | `/v1/jobs/:jobId` | -| | GET | `/v1/payrolls/:payrollId/employees/:employeeId/pay_stub` | | **Employee.HomeAddress** | GET | `/v1/employees/:employeeId` | | | GET | `/v1/employees/:employeeId/home_addresses` | | | DELETE | `/v1/home_addresses/:homeAddressUuid` | @@ -374,11 +372,9 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | | GET | `/v1/employees/:employeeId` | | | GET | `/v1/employees/:employeeId/forms` | | | GET | `/v1/employees/:employeeId/jobs` | -| | GET | `/v1/employees/:employeeId/pay_stubs` | | | GET | `/v1/employees/:employeeUuid/federal_taxes` | | | GET | `/v1/employees/:employeeUuid/state_taxes` | | | DELETE | `/v1/jobs/:jobId` | -| | GET | `/v1/payrolls/:payrollId/employees/:employeeId/pay_stub` | | **EmployeeManagement.HomeAddress** | GET | `/v1/employees/:employeeId` | | | GET | `/v1/employees/:employeeId/home_addresses` | | | DELETE | `/v1/home_addresses/:homeAddressUuid` | @@ -389,6 +385,8 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | | GET | `/v1/employees/:employeeId` | | | PUT | `/v1/employees/:employeeId` | | | PUT | `/v1/employees/:employeeId/onboarding_status` | +| **EmployeeManagement.PaystubsCard** | GET | `/v1/employees/:employeeId/pay_stubs` | +| | GET | `/v1/payrolls/:payrollId/employees/:employeeId/pay_stub` | | **EmployeeManagement.TerminateEmployee** | GET | `/v1/companies/:companyId/pay_periods/unprocessed_termination_pay_periods` | | | GET | `/v1/companies/:companyId/payrolls` | | | POST | `/v1/companies/:companyId/payrolls` | @@ -485,13 +483,13 @@ Flows compose multiple blocks into a single workflow. The endpoint list for a fl | **Contractor.OnboardingFlow** | Contractor.Address, Contractor.ContractorList, Contractor.ContractorProfile, Contractor.ContractorSubmit, Contractor.NewHireReport, Contractor.PaymentMethod | | **Contractor.PaymentFlow** | Contractor.CreatePayment, Contractor.PaymentHistory, Contractor.PaymentStatement, Contractor.PaymentSummary, Contractor.PaymentsList, InformationRequests.InformationRequestsFlow | | **ContractorOnboarding.OnboardingFlow** | ContractorOnboarding.Address, ContractorOnboarding.ContractorList, ContractorOnboarding.ContractorProfile, ContractorOnboarding.ContractorSubmit, ContractorOnboarding.NewHireReport, ContractorOnboarding.PaymentMethod | -| **Employee.DashboardFlow** | Employee.Compensation, Employee.Deductions, Employee.HomeAddress, Employee.PaymentMethod, Employee.WorkAddress, EmployeeManagement.DocumentManager, EmployeeManagement.FederalTaxes, EmployeeManagement.PaymentMethod, EmployeeManagement.Profile, EmployeeManagement.StateTaxes | +| **Employee.DashboardFlow** | Employee.Compensation, Employee.Deductions, Employee.HomeAddress, Employee.WorkAddress, EmployeeManagement.DocumentManager, EmployeeManagement.FederalTaxes, EmployeeManagement.PaymentMethod, EmployeeManagement.PaystubsCard, EmployeeManagement.Profile, EmployeeManagement.StateTaxes | | **Employee.EmployeeListFlow** | Employee.DashboardFlow, Employee.OnboardingExecutionFlow, Employee.TerminationFlow, EmployeeManagement.EmployeeList | | **Employee.OnboardingExecutionFlow** | Employee.Compensation, Employee.Deductions, Employee.EmployeeDocuments, Employee.OnboardingSummary, Employee.PaymentMethod, Employee.Profile, EmployeeOnboarding.FederalTaxes, EmployeeOnboarding.StateTaxes | | **Employee.OnboardingFlow** | Employee.EmployeeList, Employee.OnboardingExecutionFlow | | **Employee.SelfOnboardingFlow** | Employee.DocumentSigner, Employee.Landing, Employee.OnboardingSummary, Employee.PaymentMethod, Employee.Profile, EmployeeOnboarding.FederalTaxes, EmployeeOnboarding.StateTaxes | | **Employee.TerminationFlow** | Employee.TerminateEmployee, Employee.TerminationSummary, Payroll.DismissalFlow, Payroll.PayrollLanding | -| **EmployeeManagement.DashboardFlow** | Employee.PaymentMethod, EmployeeManagement.DocumentManager, EmployeeManagement.FederalTaxes, EmployeeManagement.HomeAddress, EmployeeManagement.PaymentMethod, EmployeeManagement.Profile, EmployeeManagement.StateTaxes, EmployeeManagement.WorkAddress, EmployeeOnboarding.Compensation, EmployeeOnboarding.Deductions | +| **EmployeeManagement.DashboardFlow** | EmployeeManagement.DocumentManager, EmployeeManagement.FederalTaxes, EmployeeManagement.HomeAddress, EmployeeManagement.PaymentMethod, EmployeeManagement.PaystubsCard, EmployeeManagement.Profile, EmployeeManagement.StateTaxes, EmployeeManagement.WorkAddress, EmployeeOnboarding.Compensation, EmployeeOnboarding.Deductions | | **EmployeeManagement.EmployeeListFlow** | EmployeeManagement.DashboardFlow, EmployeeManagement.EmployeeList, EmployeeManagement.TerminationFlow, EmployeeOnboarding.OnboardingExecutionFlow | | **EmployeeManagement.TerminationFlow** | EmployeeManagement.TerminateEmployee, EmployeeManagement.TerminationSummary, Payroll.DismissalFlow, Payroll.PayrollLanding | | **EmployeeOnboarding.OnboardingExecutionFlow** | Employee.EmployeeDocuments, Employee.PaymentMethod, EmployeeOnboarding.Compensation, EmployeeOnboarding.Deductions, EmployeeOnboarding.FederalTaxes, EmployeeOnboarding.OnboardingSummary, EmployeeOnboarding.Profile, EmployeeOnboarding.StateTaxes | diff --git a/docs/workflows-overview/employee-management/employee-management.md b/docs/workflows-overview/employee-management/employee-management.md index 4951d7d52..b2d42fbe5 100644 --- a/docs/workflows-overview/employee-management/employee-management.md +++ b/docs/workflows-overview/employee-management/employee-management.md @@ -39,6 +39,7 @@ Employee management components can be used to compose your own workflow, or can - [Composing from EmployeeManagement.HomeAddressCard and EmployeeManagement.HomeAddressEditForm directly](#composing-from-employeemanagementhomeaddresscard-and-employeemanagementhomeaddresseditform-directly) - [EmployeeManagement.WorkAddress](#employeemanagementworkaddress) - [Composing from EmployeeManagement.WorkAddressCard and EmployeeManagement.WorkAddressEditForm directly](#composing-from-employeemanagementworkaddresscard-and-employeemanagementworkaddresseditform-directly) +- [EmployeeManagement.PaystubsCard](#employeemanagementpaystubscard) ### EmployeeManagement.DashboardFlow @@ -536,3 +537,42 @@ function MyWorkAddressPanel({ employeeId }) { | EMPLOYEE_MANAGEMENT_WORK_ADDRESS_UPDATED | Fired after a work address is updated | Updated `EmployeeWorkAddress` entity | | EMPLOYEE_MANAGEMENT_WORK_ADDRESS_DELETED | Fired after a work address is deleted | Deleted `EmployeeWorkAddress` entity | | EMPLOYEE_MANAGEMENT_WORK_ADDRESS_EDIT_CANCELLED | Fired when the user clicks Back on the edit screen | None | + +### EmployeeManagement.PaystubsCard + +A self-contained, read-only card for viewing an employee's paystubs — the same "Paystubs" surface the dashboard renders, drop-in usable anywhere. Renders a paginated table showing each payday, check amount, gross pay, and payment method, with a per-row download button. Clicking a row's download button fetches that paystub's PDF and opens it in a new browser tab — there is no edit surface or view to swap into; the card's only action is the download side effect. + +Unlike most other `EmployeeManagement.*` components, the paystubs surface is exported only as a card and not as a block: there is no edit form to orchestrate transitions with, so the card is the entire feature. Render it inline anywhere a `
` would go; wrap it in your own error and suspense boundaries if you want fallback UI for those scenarios. + +```jsx +import { componentEvents, EmployeeManagement } from '@gusto/embedded-react-sdk' + +function MyPaystubsPanel({ employeeId }) { + return ( + { + if (eventType === componentEvents.EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOADED) { + // payload is { employeeId, payrollUuid } + } + }} + /> + ) +} +``` + +The card populates its "Payment method" column from the employee's payment method, which it fetches internally alongside the paystubs list — the per-paystub event payload does not carry the payment method itself. + +#### Props + +| Name | Type | Description | +| ------------------- | -------- | -------------------------------------- | +| employeeId Required | string | The associated employee identifier. | +| onEvent Required | function | See events table for available events. | + +#### Events + +| Event type | Description | Data | +| ---------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------- | +| EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOAD_REQUESTED | Fired when the user clicks a row's download button, before the PDF is fetched | { employeeId: string, payrollUuid: string } | +| EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOADED | Fired after the paystub PDF is successfully fetched and opened in a new tab | { employeeId: string, payrollUuid: string } | diff --git a/sdk-app/src/generated-registry-data.ts b/sdk-app/src/generated-registry-data.ts index 3c6e37f2a..6f974bdfa 100644 --- a/sdk-app/src/generated-registry-data.ts +++ b/sdk-app/src/generated-registry-data.ts @@ -45,6 +45,7 @@ export const ENTITY_REQUIREMENTS: Record = { 'EmployeeManagement.PaymentMethodBankForm': ['employeeId'], 'EmployeeManagement.PaymentMethodCard': ['employeeId'], 'EmployeeManagement.PaymentMethodSplitForm': ['employeeId'], + 'EmployeeManagement.PaystubsCard': ['employeeId'], 'EmployeeManagement.Profile': ['employeeId'], 'EmployeeManagement.ProfileCard': ['employeeId'], 'EmployeeManagement.ProfileEditForm': ['employeeId'], diff --git a/src/components/Employee/Dashboard/JobAndPayView.tsx b/src/components/Employee/Dashboard/JobAndPayView.tsx index f792487a6..a1e3cb30e 100644 --- a/src/components/Employee/Dashboard/JobAndPayView.tsx +++ b/src/components/Employee/Dashboard/JobAndPayView.tsx @@ -1,12 +1,8 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useGustoEmbeddedContext } from '@gusto/embedded-api-v-2025-11-15/react-query/_context' -import { payrollsGetPayStub } from '@gusto/embedded-api-v-2025-11-15/funcs/payrollsGetPayStub' -import { useErrorBoundary } from 'react-error-boundary' import { useJobsAndCompensationsDeleteMutation } from '@gusto/embedded-api-v-2025-11-15/react-query/jobsAndCompensationsDelete' import type { Job } from '@gusto/embedded-api-v-2025-11-15/models/components/job' import type { Garnishment } from '@gusto/embedded-api-v-2025-11-15/models/components/garnishment' -import type { GetV1EmployeesEmployeeUuidPayStubsResponse } from '@gusto/embedded-api-v-2025-11-15/models/operations/getv1employeesemployeeuuidpaystubs' import { useEmployeeCompensation } from './hooks' import type { PendingCompensationChange } from './getPendingCompensationChanges' import { usePendingChangeDetailRenderer } from './usePendingChangeDetailRenderer' @@ -18,12 +14,10 @@ import { DataView, useDataView, EmptyData, Loading, VisuallyHidden } from '@/com import { HamburgerMenu } from '@/components/Common/HamburgerMenu' import { BaseLayout } from '@/components/Base/Base' import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' -import { readableStreamToBlob } from '@/helpers/readableStreamToBlob' import { formatDateLongWithYear, formatDateToStringDate } from '@/helpers/dateFormatting' import { useFormatCompensationRate } from '@/helpers/formattedStrings' import useNumberFormatter from '@/hooks/useNumberFormatter' import { useI18n } from '@/i18n' -import { usePaymentMethodList } from '@/components/Employee/PaymentMethod/shared' import { PaymentMethodCard } from '@/components/Employee/PaymentMethod/management' import { useDeductionsList, @@ -31,17 +25,13 @@ import { DeleteDeductionDialog, formatDeductionAmount, } from '@/components/Employee/Deductions/shared' +import { PaystubsCard } from '@/components/Employee/Paystubs/management/PaystubsCard' import { componentEvents, FlsaStatus, type EventType } from '@/shared/constants' import type { OnEventType } from '@/components/Base/useBase' import PlusCircleIcon from '@/assets/icons/plus-circle.svg?react' -import DownloadCloudIcon from '@/assets/icons/download-cloud.svg?react' import TrashCanSvg from '@/assets/icons/trashcan.svg?react' import PencilSvg from '@/assets/icons/pencil.svg?react' -type EmployeePayStub = NonNullable< - GetV1EmployeesEmployeeUuidPayStubsResponse['employeePayStubsList'] ->[number] - function parseJobRate(rate: Job['rate']): number | null { if (rate === undefined) return null const numericRate = parseFloat(rate) @@ -76,8 +66,6 @@ export function JobAndPayView({ const formatCompensationRate = useFormatCompensationRate() const formatCurrency = useNumberFormatter('currency') const formatPercent = useNumberFormatter('percent') - const gustoEmbedded = useGustoEmbeddedContext() - const { showBoundary } = useErrorBoundary() const compensation = useEmployeeCompensation({ employeeId }) const { @@ -85,14 +73,11 @@ export function JobAndPayView({ primaryFlsaStatus, pendingChanges, hasMultipleJobs: hasMultipleJobsFromHook, - payStubs, employeeFirstName, } = compensation.data - const payStubsPagination = compensation.pagination.payStubs const cancellingCompensationUuid = compensation.status.cancellingCompensationUuid const { cancelPendingChange } = compensation.actions const isCompensationCardLoading = compensation.status.isCompensationLoading - const isPayStubsLoading = compensation.status.isPayStubsLoading const handleCancelChange = useCallback( async (pendingChange: PendingCompensationChange) => { @@ -107,77 +92,6 @@ export function JobAndPayView({ [cancelPendingChange, onEvent, employeeId], ) - const [downloadingPayrollUuids, setDownloadingPayrollUuids] = useState>( - () => new Set(), - ) - - const handlePaystubDownload = useCallback( - async (payrollUuid: string) => { - // Omit `noopener` — it makes window.open return null in modern browsers, - // which would leave us unable to navigate the new tab to the blob URL. - const newWindow = window.open('', '_blank') - const loadingMessage = t('jobAndPay.paystubs.downloadLoadingMessage') - if (newWindow) { - // Avoid the user staring at about:blank while we fetch the PDF. The - // navigation to the Blob URL below replaces this document. - const doc = newWindow.document - doc.title = loadingMessage - const style = doc.createElement('style') - style.textContent = - 'body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;' + - 'justify-content:center;height:100vh;margin:0;color:#444;gap:12px}' + - '.spinner{width:20px;height:20px;border:2px solid #ccc;border-top-color:#444;' + - 'border-radius:50%;animation:spin .8s linear infinite}' + - '@keyframes spin{to{transform:rotate(360deg)}}' - doc.head.appendChild(style) - const spinner = doc.createElement('div') - spinner.className = 'spinner' - spinner.setAttribute('aria-hidden', 'true') - const label = doc.createElement('span') - label.textContent = loadingMessage - doc.body.replaceChildren(spinner, label) - } - setDownloadingPayrollUuids(prev => { - const next = new Set(prev) - next.add(payrollUuid) - return next - }) - try { - const response = await payrollsGetPayStub(gustoEmbedded, { - payrollId: payrollUuid, - employeeId, - }) - if (!response.value?.responseStream) { - throw new Error(t('jobAndPay.paystubs.downloadError')) - } - const pdfBlob = await readableStreamToBlob(response.value.responseStream, 'application/pdf') - const url = URL.createObjectURL(pdfBlob) - if (newWindow) { - // Revoke after the new tab has loaded the blob; revoking synchronously - // would race the navigation and leave the tab blank. - newWindow.addEventListener('load', () => { - URL.revokeObjectURL(url) - }) - newWindow.location.href = url - } else { - URL.revokeObjectURL(url) - } - } catch (err) { - if (newWindow) { - newWindow.close() - } - showBoundary(err instanceof Error ? err : new Error(String(err))) - } finally { - setDownloadingPayrollUuids(prev => { - const next = new Set(prev) - next.delete(payrollUuid) - return next - }) - } - }, - [gustoEmbedded, employeeId, t, showBoundary], - ) - const [pendingDeleteJob, setPendingDeleteJob] = useState<{ uuid: string title: string @@ -257,15 +171,6 @@ export function JobAndPayView({ const showInlineAlert = hasPendingUpdates && !showSummaryAlert const nextChange = updatePendingChanges[0] - // The Paystubs column reads `paymentMethod?.type` for one row of metadata. - // The Payment card itself is rendered standalone by `` - // below and owns its own data fetch + error handling — this call is just - // for the Paystubs column. React Query dedupes the request. - const paymentMethodList = usePaymentMethodList({ employeeId }) - const paymentMethod = paymentMethodList.isLoading - ? undefined - : paymentMethodList.data.paymentMethod - const deductionsList = useDeductionsList({ employeeId }) const deductions = deductionsList.isLoading ? [] : deductionsList.data.deductions const deletingGarnishmentUuid = deductionsList.isLoading @@ -421,37 +326,6 @@ export function JobAndPayView({ }, ] - const payStubsColumns = [ - { - key: 'payday', - title: t('jobAndPay.paystubs.payday'), - render: (payStub: EmployeePayStub) => formatDateLongWithYear(payStub.checkDate) || '-', - }, - { - key: 'checkAmount', - title: t('jobAndPay.paystubs.checkAmount'), - render: (payStub: EmployeePayStub) => { - if (!payStub.netPay) return '-' - const amount = parseFloat(payStub.netPay) - return isNaN(amount) ? '-' : formatCurrency(amount) - }, - }, - { - key: 'grossPay', - title: t('jobAndPay.paystubs.grossPay'), - render: (payStub: EmployeePayStub) => { - if (!payStub.grossPay) return '-' - const amount = parseFloat(payStub.grossPay) - return isNaN(amount) ? '-' : formatCurrency(amount) - }, - }, - { - key: 'paymentMethod', - title: t('jobAndPay.paystubs.paymentMethod'), - render: () => paymentMethod?.type || t('jobAndPay.paystubs.noPaymentMethod'), - }, - ] - const garnishmentsDataView = useDataView({ data: deductions, columns: garnishmentsColumns, @@ -483,37 +357,6 @@ export function JobAndPayView({ ), }) - const payStubsDataView = useDataView({ - data: payStubs, - columns: payStubsColumns, - pagination: payStubsPagination, - itemMenu: payStub => { - const isDownloading = - !!payStub.payrollUuid && downloadingPayrollUuids.has(payStub.payrollUuid) - return ( - { - if (payStub.payrollUuid) { - void handlePaystubDownload(payStub.payrollUuid) - } - }} - > - - - ) - }, - emptyState: () => ( - - ), - }) - // `useDeductionsList` still uses the older `HookLoadingResult | Ready` // shape, which returns `isLoading: true` when the query has errored AND // data is missing. Treat as "not loading" so the section doesn't show a @@ -719,16 +562,7 @@ export function JobAndPayView({ )} - } - > - {isPayStubsLoading ? ( - - ) : ( - - )} - + { }) expect(result.current.status.isCompensationLoading).toBe(true) - expect(result.current.status.isPayStubsLoading).toBe(true) expect(result.current.data.jobs).toEqual([]) - expect(result.current.data.payStubs).toEqual([]) await waitFor(() => { expect(result.current.status.isCompensationLoading).toBe(false) - expect(result.current.status.isPayStubsLoading).toBe(false) }) expect(result.current.data.jobs.length).toBeGreaterThan(0) @@ -274,24 +270,4 @@ describe('useEmployeeCompensation', () => { expect(jobsRequestUrl).toContain('/v1/employees/employee-123/jobs') expect(jobsRequestUrl).toContain('include=all_compensations') }) - - it('surfaces paystub pagination control props once the paystubs query resolves', async () => { - server.use( - http.get(`${API_BASE_URL}/v1/employees/:employee_uuid/pay_stubs`, () => - HttpResponse.json([], { - headers: { 'x-total-count': '0', 'x-total-pages': '1', 'x-page': '1' }, - }), - ), - ) - - const { result } = renderHook(() => useEmployeeCompensation({ employeeId: 'employee-123' }), { - wrapper: GustoTestProvider, - }) - - await waitFor(() => { - expect(result.current.status.isPayStubsLoading).toBe(false) - }) - - expect(result.current.pagination.payStubs).toBeDefined() - }) }) diff --git a/src/components/Employee/Dashboard/hooks/useEmployeeCompensation.tsx b/src/components/Employee/Dashboard/hooks/useEmployeeCompensation.tsx index 58914f3b1..6becfbef9 100644 --- a/src/components/Employee/Dashboard/hooks/useEmployeeCompensation.tsx +++ b/src/components/Employee/Dashboard/hooks/useEmployeeCompensation.tsx @@ -3,9 +3,7 @@ import { useEmployeesGet } from '@gusto/embedded-api-v-2025-11-15/react-query/em import { useJobsAndCompensationsGetJobs } from '@gusto/embedded-api-v-2025-11-15/react-query/jobsAndCompensationsGetJobs' import { GetV1EmployeesEmployeeIdJobsQueryParamInclude } from '@gusto/embedded-api-v-2025-11-15/models/operations/getv1employeesemployeeidjobs' import { useJobsAndCompensationsDeleteCompensationMutation } from '@gusto/embedded-api-v-2025-11-15/react-query/jobsAndCompensationsDeleteCompensation' -import { usePayrollsGetPayStubs } from '@gusto/embedded-api-v-2025-11-15/react-query/payrollsGetPayStubs' import type { Job } from '@gusto/embedded-api-v-2025-11-15/models/components/job' -import type { GetV1EmployeesEmployeeUuidPayStubsResponse } from '@gusto/embedded-api-v-2025-11-15/models/operations/getv1employeesemployeeuuidpaystubs' import { getPendingCompensationChanges, type PendingCompensationChange, @@ -13,13 +11,7 @@ import { import { derivePrimaryFlsaStatus } from '@/components/Employee/Compensation/shared/derivePrimaryFlsaStatus' import { useBaseSubmit } from '@/components/Base/useBaseSubmit' import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' -import { usePagination } from '@/hooks/usePagination/usePagination' import type { BaseHookReady, HookSubmitResult } from '@/partner-hook-utils/types' -import type { PaginationControlProps } from '@/components/Common/PaginationControl/PaginationControlTypes' - -type EmployeePayStub = NonNullable< - GetV1EmployeesEmployeeUuidPayStubsResponse['employeePayStubsList'] ->[number] export interface UseEmployeeCompensationProps { employeeId: string @@ -32,7 +24,6 @@ export interface UseEmployeeCompensationResult extends BaseHookReady< primaryFlsaStatus?: string hasMultipleJobs: boolean pendingChanges: PendingCompensationChange[] - payStubs: EmployeePayStub[] /** First name from the shared employee fetch; useful for cosmetic copy * in alerts (e.g. "Heads up, Jane has pending changes"). Optional * because the employee record can omit it. */ @@ -44,13 +35,8 @@ export interface UseEmployeeCompensationResult extends BaseHookReady< /** Compensation card depends on the jobs fetch (jobs, pending * changes, FLSA status). */ isCompensationLoading: boolean - /** Paystubs card depends on a separate paginated endpoint. */ - isPayStubsLoading: boolean } > { - pagination: { - payStubs?: PaginationControlProps - } actions: { cancelPendingChange: ( pendingChange: PendingCompensationChange, @@ -59,18 +45,15 @@ export interface UseEmployeeCompensationResult extends BaseHookReady< } /** - * Phase B: non-Suspense queries so the Compensation and Paystubs cards - * can paint independently within the Job and pay tab. JobAndPayView - * already gets the Payment and Deductions cards as separate non-Suspense - * hooks, so this completes the four-section incremental render. + * Non-Suspense queries for the Compensation card on the Job and pay tab. + * Returns jobs + pending-changes + the employee's first name (for cosmetic + * alert copy) along with a cancel-pending-change action. Paystubs data + * moved into `@/components/Employee/Paystubs/shared/usePaystubsList` when + * the Paystubs card became its own management block. */ export function useEmployeeCompensation({ employeeId, }: UseEmployeeCompensationProps): UseEmployeeCompensationResult { - const { currentPage, itemsPerPage, getPaginationProps } = usePagination({ - defaultItemsPerPage: 10, - }) - // staleTime: Infinity on dashboard reads — the SDK QueryClient already // invalidates all queries on any mutation success, so post-write // freshness is preserved. Without this, every subscriber re-mount @@ -87,10 +70,6 @@ export function useEmployeeCompensation({ // Employee query is a lightweight secondary fetch for cosmetic data only // (first name used in alert copy). Jobs / compensation data comes from jobsQuery. const employeeQuery = useEmployeesGet({ employeeId }, { staleTime: Infinity }) - const payStubsQuery = usePayrollsGetPayStubs( - { employeeId, page: currentPage, per: itemsPerPage }, - { staleTime: Infinity }, - ) const cancelCompensationMutation = useJobsAndCompensationsDeleteCompensationMutation() const { baseSubmitHandler, @@ -107,14 +86,6 @@ export function useEmployeeCompensation({ const pendingChanges = useMemo(() => getPendingCompensationChanges(jobs), [jobs]) - const payStubs = payStubsQuery.data?.employeePayStubsList ?? [] - - const payStubsPagination = useMemo(() => { - const headers = payStubsQuery.data?.httpMeta.response.headers - if (!headers) return undefined - return getPaginationProps(headers, payStubsQuery.isFetching) - }, [payStubsQuery.data?.httpMeta.response.headers, payStubsQuery.isFetching, getPaginationProps]) - const cancellingCompensationUuid = cancelCompensationMutation.isPending ? cancelCompensationMutation.variables.request.compensationId : null @@ -136,12 +107,9 @@ export function useEmployeeCompensation({ ) const isPending = - jobsQuery.isFetching || - employeeQuery.isFetching || - payStubsQuery.isFetching || - cancelCompensationMutation.isPending + jobsQuery.isFetching || employeeQuery.isFetching || cancelCompensationMutation.isPending - const errorHandling = composeErrorHandler([jobsQuery, employeeQuery, payStubsQuery], { + const errorHandling = composeErrorHandler([jobsQuery, employeeQuery], { submitError, setSubmitError, }) @@ -154,17 +122,12 @@ export function useEmployeeCompensation({ primaryFlsaStatus, hasMultipleJobs, pendingChanges, - payStubs, employeeFirstName: employee?.firstName ?? undefined, }, status: { isPending, cancellingCompensationUuid, isCompensationLoading: jobsQuery.isLoading, - isPayStubsLoading: payStubsQuery.isLoading, - }, - pagination: { - payStubs: payStubsPagination, }, actions: { cancelPendingChange, diff --git a/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.test.tsx b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.test.tsx new file mode 100644 index 000000000..7f8d938b6 --- /dev/null +++ b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.test.tsx @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse, type HttpResponseResolver } from 'msw' +import { PaystubsCard } from './PaystubsCard' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { server } from '@/test/mocks/server' +import { API_BASE_URL } from '@/test/constants' +import { componentEvents } from '@/shared/constants' + +const stubPayStubs = ( + payStubs: Array>, + headers: Record = { 'x-total-count': '0', 'x-total-pages': '1', 'x-page': '1' }, +) => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_uuid/pay_stubs`, () => + HttpResponse.json(payStubs, { headers }), + ), + ) +} + +const TWO_STUBS = [ + { + uuid: 'stub-1', + payroll_uuid: 'payroll-1', + check_date: '2025-01-15', + gross_pay: '2000.00', + net_pay: '1500.00', + }, + { + uuid: 'stub-2', + payroll_uuid: 'payroll-2', + check_date: '2024-12-31', + gross_pay: '1800.00', + net_pay: '1300.00', + }, +] + +describe('PaystubsCard', () => { + const onEvent = vi.fn() + + beforeEach(() => { + setupApiTestMocks() + onEvent.mockClear() + }) + + it('renders the title and a download action per row once paystubs load', async () => { + stubPayStubs(TWO_STUBS, { 'x-total-count': '2', 'x-total-pages': '1', 'x-page': '1' }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Paystubs')).toBeInTheDocument() + }) + + const downloadButtons = await screen.findAllByRole('button', { name: 'Download paystub' }) + expect(downloadButtons).toHaveLength(2) + }) + + it('renders the empty state when the employee has no paystubs', async () => { + stubPayStubs([]) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('No paystubs')).toBeInTheDocument() + }) + + expect(screen.getByText('Paystubs will appear here after payroll is run')).toBeInTheDocument() + }) + + it('fires EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOAD_REQUESTED and _DOWNLOADED when a row download succeeds', async () => { + stubPayStubs(TWO_STUBS, { 'x-total-count': '2', 'x-total-pages': '1', 'x-page': '1' }) + const downloadResolver = vi.fn(() => { + return new HttpResponse(new Uint8Array([0x25, 0x50, 0x44, 0x46]), { + headers: { 'content-type': 'application/pdf' }, + }) + }) + server.use( + http.get( + `${API_BASE_URL}/v1/payrolls/:payroll_id/employees/:employee_id/pay_stub`, + downloadResolver, + ), + ) + + // window.open returns null in jsdom — handleDownload tolerates this and skips + // the new-tab loading UI. URL.createObjectURL needs a stub. + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const createObjectURLSpy = vi + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:mock-blob-url') + const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + + try { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: 'Download paystub' })).toHaveLength(2) + }) + + const [firstDownload] = screen.getAllByRole('button', { name: 'Download paystub' }) + await user.click(firstDownload!) + + await waitFor(() => { + expect(downloadResolver).toHaveBeenCalledTimes(1) + }) + + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOAD_REQUESTED, + { employeeId: 'employee-123', payrollUuid: 'payroll-1' }, + ) + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith( + componentEvents.EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOADED, + { employeeId: 'employee-123', payrollUuid: 'payroll-1' }, + ) + }) + + // Regression: the per-row loading spinner must clear once the download + // finishes. Previously the UUID was added to `downloadingPayrollUuids` + // but never removed, so the button stayed in its loading state forever. + await waitFor(() => { + const [firstAfter] = screen.getAllByRole('button', { name: 'Download paystub' }) + expect(firstAfter).not.toHaveAttribute('data-loading') + }) + } finally { + openSpy.mockRestore() + createObjectURLSpy.mockRestore() + revokeObjectURLSpy.mockRestore() + } + }) +}) diff --git a/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx new file mode 100644 index 000000000..8cff3d181 --- /dev/null +++ b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx @@ -0,0 +1,235 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useErrorBoundary } from 'react-error-boundary' +import { + usePaystubsList, + type EmployeePayStub, + type UsePaystubsListReady, +} from '../../shared/usePaystubsList' +import { DataView, EmptyData, useDataView } from '@/components/Common' +import { BaseLayout } from '@/components/Base/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' +import { + usePaymentMethodList, + type UsePaymentMethodListResult, +} from '@/components/Employee/PaymentMethod/shared' +import { formatDateLongWithYear } from '@/helpers/dateFormatting' +import useNumberFormatter from '@/hooks/useNumberFormatter' +import { useI18n } from '@/i18n' +import { componentEvents, type EventType } from '@/shared/constants' +import type { OnEventType } from '@/components/Base/useBase' +import DownloadCloudIcon from '@/assets/icons/download-cloud.svg?react' + +export interface PaystubsCardProps { + employeeId: string + onEvent: OnEventType +} + +/** + * Standalone "Paystubs" card. Owns its own data fetch via + * {@link usePaystubsList} and renders the paginated paystubs table with a + * per-row PDF download action. Emits the management block's scoped events + * (`EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_*`) on download request and on + * download success. The card has no edit transitions and no alert API — + * paystubs is a read-only surface whose only action is a download side + * effect that opens the PDF in a new tab. + */ +export function PaystubsCard({ employeeId, onEvent }: PaystubsCardProps) { + useI18n('Employee.Management.Paystubs') + const paystubsList = usePaystubsList({ employeeId }) + const paymentMethodList = usePaymentMethodList({ employeeId }) + + const errorHandling = composeErrorHandler([paystubsList, paymentMethodList]) + + if (paystubsList.isLoading) { + return + } + + return ( + + ) +} + +interface PaystubsCardReadyProps extends PaystubsCardProps { + paystubsList: UsePaystubsListReady + paymentMethodList: UsePaymentMethodListResult + errorHandling: ReturnType +} + +function PaystubsCardReady({ + employeeId, + onEvent, + paystubsList, + paymentMethodList, + errorHandling, +}: PaystubsCardReadyProps) { + const { t } = useTranslation('Employee.Management.Paystubs') + const Components = useComponentContext() + const formatCurrency = useNumberFormatter('currency') + const { showBoundary } = useErrorBoundary() + + const [downloadingPayrollUuids, setDownloadingPayrollUuids] = useState>( + () => new Set(), + ) + + const { payStubs } = paystubsList.data + const payStubsPagination = paystubsList.pagination.payStubs + const paymentMethod = paymentMethodList.isLoading + ? undefined + : paymentMethodList.data.paymentMethod + + const handleDownload = useCallback( + async (payrollUuid: string) => { + onEvent(componentEvents.EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOAD_REQUESTED, { + employeeId, + payrollUuid, + }) + + // Omit `noopener` — it makes window.open return null in modern browsers, + // which would leave us unable to navigate the new tab to the blob URL. + const newWindow = window.open('', '_blank') + const loadingMessage = t('downloadLoadingMessage') + if (newWindow) { + // Avoid the user staring at about:blank while we fetch the PDF. The + // navigation to the Blob URL below replaces this document. + const doc = newWindow.document + doc.title = loadingMessage + const style = doc.createElement('style') + style.textContent = + 'body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;' + + 'justify-content:center;height:100vh;margin:0;color:#444;gap:12px}' + + '.spinner{width:20px;height:20px;border:2px solid #ccc;border-top-color:#444;' + + 'border-radius:50%;animation:spin .8s linear infinite}' + + '@keyframes spin{to{transform:rotate(360deg)}}' + doc.head.appendChild(style) + const spinner = doc.createElement('div') + spinner.className = 'spinner' + spinner.setAttribute('aria-hidden', 'true') + const label = doc.createElement('span') + label.textContent = loadingMessage + doc.body.replaceChildren(spinner, label) + } + setDownloadingPayrollUuids(prev => { + const next = new Set(prev) + next.add(payrollUuid) + return next + }) + try { + const result = await paystubsList.actions.downloadPayStub(payrollUuid) + if (!result) { + if (newWindow) newWindow.close() + return + } + const url = URL.createObjectURL(result.data) + if (newWindow) { + // Revoke after the new tab has loaded the blob; revoking synchronously + // would race the navigation and leave the tab blank. + newWindow.addEventListener('load', () => { + URL.revokeObjectURL(url) + }) + newWindow.location.href = url + } else { + URL.revokeObjectURL(url) + } + onEvent(componentEvents.EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOADED, { + employeeId, + payrollUuid, + }) + } catch (err) { + if (newWindow) newWindow.close() + showBoundary(err instanceof Error ? err : new Error(String(err))) + } finally { + setDownloadingPayrollUuids(prev => { + if (!prev.has(payrollUuid)) return prev + const next = new Set(prev) + next.delete(payrollUuid) + return next + }) + } + }, + [paystubsList.actions, onEvent, employeeId, t, showBoundary], + ) + + const payStubsColumns = [ + { + key: 'payday', + title: t('payday'), + render: (payStub: EmployeePayStub) => formatDateLongWithYear(payStub.checkDate) || '-', + }, + { + key: 'checkAmount', + title: t('checkAmount'), + render: (payStub: EmployeePayStub) => { + if (!payStub.netPay) return '-' + const amount = parseFloat(payStub.netPay) + return isNaN(amount) ? '-' : formatCurrency(amount) + }, + }, + { + key: 'grossPay', + title: t('grossPay'), + render: (payStub: EmployeePayStub) => { + if (!payStub.grossPay) return '-' + const amount = parseFloat(payStub.grossPay) + return isNaN(amount) ? '-' : formatCurrency(amount) + }, + }, + { + key: 'paymentMethod', + title: t('paymentMethod'), + render: () => paymentMethod?.type || t('noPaymentMethod'), + }, + ] + + const payStubsDataView = useDataView({ + data: payStubs, + columns: payStubsColumns, + pagination: payStubsPagination, + itemMenu: payStub => { + const isDownloading = + !!payStub.payrollUuid && downloadingPayrollUuids.has(payStub.payrollUuid) + return ( + { + if (payStub.payrollUuid) { + void handleDownload(payStub.payrollUuid) + } + }} + > + + + ) + }, + emptyState: () => ( + + ), + }) + + const isShowingTable = payStubs.length > 0 + + return ( + + } + > + {isShowingTable ? ( + + ) : ( + + )} + + + ) +} diff --git a/src/components/Employee/Paystubs/management/PaystubsCard/index.ts b/src/components/Employee/Paystubs/management/PaystubsCard/index.ts new file mode 100644 index 000000000..3f5bcbb46 --- /dev/null +++ b/src/components/Employee/Paystubs/management/PaystubsCard/index.ts @@ -0,0 +1,2 @@ +export { PaystubsCard } from './PaystubsCard' +export type { PaystubsCardProps } from './PaystubsCard' diff --git a/src/components/Employee/Paystubs/management/index.ts b/src/components/Employee/Paystubs/management/index.ts new file mode 100644 index 000000000..3f5bcbb46 --- /dev/null +++ b/src/components/Employee/Paystubs/management/index.ts @@ -0,0 +1,2 @@ +export { PaystubsCard } from './PaystubsCard' +export type { PaystubsCardProps } from './PaystubsCard' diff --git a/src/components/Employee/Paystubs/shared/index.ts b/src/components/Employee/Paystubs/shared/index.ts new file mode 100644 index 000000000..2499de057 --- /dev/null +++ b/src/components/Employee/Paystubs/shared/index.ts @@ -0,0 +1,7 @@ +export { usePaystubsList } from './usePaystubsList' +export type { + UsePaystubsListParams, + UsePaystubsListResult, + UsePaystubsListReady, + EmployeePayStub, +} from './usePaystubsList' diff --git a/src/components/Employee/Paystubs/shared/usePaystubsList/index.ts b/src/components/Employee/Paystubs/shared/usePaystubsList/index.ts new file mode 100644 index 000000000..2499de057 --- /dev/null +++ b/src/components/Employee/Paystubs/shared/usePaystubsList/index.ts @@ -0,0 +1,7 @@ +export { usePaystubsList } from './usePaystubsList' +export type { + UsePaystubsListParams, + UsePaystubsListResult, + UsePaystubsListReady, + EmployeePayStub, +} from './usePaystubsList' diff --git a/src/components/Employee/Paystubs/shared/usePaystubsList/usePaystubsList.test.tsx b/src/components/Employee/Paystubs/shared/usePaystubsList/usePaystubsList.test.tsx new file mode 100644 index 000000000..8f673e84b --- /dev/null +++ b/src/components/Employee/Paystubs/shared/usePaystubsList/usePaystubsList.test.tsx @@ -0,0 +1,148 @@ +import { renderHook, act, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { http, HttpResponse, type HttpResponseResolver } from 'msw' +import { usePaystubsList, type UsePaystubsListResult } from './usePaystubsList' +import { server } from '@/test/mocks/server' +import { setupApiTestMocks } from '@/test/mocks/apiServer' +import { GustoTestProvider } from '@/test/GustoTestApiProvider' +import { API_BASE_URL } from '@/test/constants' + +type ReadyResult = Extract + +function assertReady(hookResult: UsePaystubsListResult): asserts hookResult is ReadyResult { + if (hookResult.isLoading) { + throw new Error('Expected hook to be ready but it is still loading') + } +} + +type PayStubApiFixture = { + uuid: string + payroll_uuid: string + check_date: string + gross_pay: string + net_pay: string +} + +const buildPayStubFixture = (overrides: Partial & { uuid: string }) => ({ + payroll_uuid: `payroll-${overrides.uuid}`, + check_date: '2025-01-15', + gross_pay: '2000.00', + net_pay: '1500.00', + ...overrides, +}) + +const stubList = ( + payStubs: ReturnType[], + headers: Record = { 'x-total-count': '0', 'x-total-pages': '1', 'x-page': '1' }, +) => { + server.use( + http.get(`${API_BASE_URL}/v1/employees/:employee_uuid/pay_stubs`, () => + HttpResponse.json(payStubs, { headers }), + ), + ) +} + +describe('usePaystubsList', () => { + beforeEach(() => { + setupApiTestMocks() + }) + + it('starts loading then resolves to a ready state with paystubs and pagination', async () => { + stubList( + [ + buildPayStubFixture({ uuid: 'stub-1', check_date: '2025-01-15' }), + buildPayStubFixture({ uuid: 'stub-2', check_date: '2024-12-31' }), + ], + { 'x-total-count': '2', 'x-total-pages': '1', 'x-page': '1' }, + ) + + const { result } = renderHook(() => usePaystubsList({ employeeId: 'employee-123' }), { + wrapper: GustoTestProvider, + }) + + expect(result.current.isLoading).toBe(true) + expect(result.current.errorHandling.errors).toEqual([]) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + assertReady(result.current) + expect(result.current.data.payStubs).toHaveLength(2) + expect(result.current.data.payStubs[0]?.uuid).toBe('stub-1') + expect(result.current.status.isFetching).toBe(false) + expect(result.current.status.isPending).toBe(false) + expect(result.current.pagination.payStubs).toBeDefined() + expect(result.current.actions.downloadPayStub).toBeDefined() + }) + + it('actions.downloadPayStub GETs the paystub PDF and returns a Blob HookSubmitResult', async () => { + stubList([buildPayStubFixture({ uuid: 'stub-1' })]) + + let downloadPath: string | null = null + const downloadResolver = vi.fn(({ request }) => { + downloadPath = new URL(request.url).pathname + // The funcs/payrollsGetPayStub helper expects a binary PDF response; + // MSW serves it as a Blob with the right content-type so readableStreamToBlob + // succeeds. The exact bytes don't matter — only that a Blob comes back. + return new HttpResponse(new Uint8Array([0x25, 0x50, 0x44, 0x46]), { + headers: { 'content-type': 'application/pdf' }, + }) + }) + server.use( + http.get( + `${API_BASE_URL}/v1/payrolls/:payroll_id/employees/:employee_id/pay_stub`, + downloadResolver, + ), + ) + + const { result } = renderHook(() => usePaystubsList({ employeeId: 'employee-123' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + let submitResult: Awaited> + await act(async () => { + assertReady(result.current) + submitResult = await result.current.actions.downloadPayStub('payroll-stub-1') + }) + + expect(downloadResolver).toHaveBeenCalledTimes(1) + expect(downloadPath).toBe('/v1/payrolls/payroll-stub-1/employees/employee-123/pay_stub') + expect(submitResult).toMatchObject({ mode: 'update' }) + expect(submitResult?.data).toBeInstanceOf(Blob) + expect(submitResult?.data.type).toBe('application/pdf') + }) + + it('surfaces a paystub download failure through errorHandling.errors', async () => { + stubList([buildPayStubFixture({ uuid: 'stub-1' })]) + server.use( + http.get(`${API_BASE_URL}/v1/payrolls/:payroll_id/employees/:employee_id/pay_stub`, () => + HttpResponse.json( + { error_key: 'server_error', errors: [{ message: 'Boom' }] }, + { status: 500 }, + ), + ), + ) + + const { result } = renderHook(() => usePaystubsList({ employeeId: 'employee-123' }), { + wrapper: GustoTestProvider, + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + await act(async () => { + assertReady(result.current) + await result.current.actions.downloadPayStub('payroll-stub-1') + }) + + await waitFor(() => { + expect(result.current.errorHandling.errors.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/src/components/Employee/Paystubs/shared/usePaystubsList/usePaystubsList.ts b/src/components/Employee/Paystubs/shared/usePaystubsList/usePaystubsList.ts new file mode 100644 index 000000000..1b430dce4 --- /dev/null +++ b/src/components/Employee/Paystubs/shared/usePaystubsList/usePaystubsList.ts @@ -0,0 +1,115 @@ +import { useGustoEmbeddedContext } from '@gusto/embedded-api-v-2025-11-15/react-query/_context' +import { usePayrollsGetPayStubs } from '@gusto/embedded-api-v-2025-11-15/react-query/payrollsGetPayStubs' +import { payrollsGetPayStub } from '@gusto/embedded-api-v-2025-11-15/funcs/payrollsGetPayStub' +import { useCallback, useMemo } from 'react' +import type { GetV1EmployeesEmployeeUuidPayStubsResponse } from '@gusto/embedded-api-v-2025-11-15/models/operations/getv1employeesemployeeuuidpaystubs' +import { useBaseSubmit } from '@/components/Base/useBaseSubmit' +import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler' +import { readableStreamToBlob } from '@/helpers/readableStreamToBlob' +import { usePagination } from '@/hooks/usePagination/usePagination' +import type { BaseHookReady, HookLoadingResult, HookSubmitResult } from '@/partner-hook-utils/types' +import type { + PaginationControlProps, + PaginationItemsPerPage, +} from '@/components/Common/PaginationControl/PaginationControlTypes' + +export type EmployeePayStub = NonNullable< + GetV1EmployeesEmployeeUuidPayStubsResponse['employeePayStubsList'] +>[number] + +export interface UsePaystubsListParams { + employeeId: string + /** Items per page for the paystubs list. Defaults to 10. */ + defaultItemsPerPage?: PaginationItemsPerPage +} + +export interface UsePaystubsListReady extends BaseHookReady< + { payStubs: EmployeePayStub[] }, + { isFetching: boolean; isPending: boolean } +> { + pagination: { payStubs?: PaginationControlProps } + actions: { + /** Fetch the paystub PDF for the given payroll. Returns a Blob on success + * so the caller decides how to surface it (open in a new tab, save to + * disk, embed, etc.). */ + downloadPayStub: (payrollId: string) => Promise | undefined> + } +} + +export type UsePaystubsListResult = HookLoadingResult | UsePaystubsListReady + +/** + * Data hook for the employee paystubs list. Returns the paginated list of + * paystubs and an action to download an individual paystub PDF. Used by the + * standalone `PaystubsCard` component and consumable directly by partners + * building a fully custom paystubs UI. + */ +export function usePaystubsList({ + employeeId, + defaultItemsPerPage = 10, +}: UsePaystubsListParams): UsePaystubsListResult { + const { currentPage, itemsPerPage, getPaginationProps } = usePagination({ + defaultItemsPerPage, + }) + + const gustoEmbedded = useGustoEmbeddedContext() + const payStubsQuery = usePayrollsGetPayStubs( + { employeeId, page: currentPage, per: itemsPerPage }, + { staleTime: Infinity }, + ) + + const { + baseSubmitHandler, + error: submitError, + setError: setSubmitError, + } = useBaseSubmit('PaystubsList') + + const payStubs = payStubsQuery.data?.employeePayStubsList ?? [] + + const payStubsPagination = useMemo(() => { + const headers = payStubsQuery.data?.httpMeta.response.headers + if (!headers) return undefined + return getPaginationProps(headers, payStubsQuery.isFetching) + }, [payStubsQuery.data?.httpMeta.response.headers, payStubsQuery.isFetching, getPaginationProps]) + + const errorHandling = composeErrorHandler([payStubsQuery], { submitError, setSubmitError }) + + const downloadPayStub = useCallback( + async (payrollId: string): Promise | undefined> => { + let submitResult: HookSubmitResult | undefined + await baseSubmitHandler(payrollId, async id => { + const response = await payrollsGetPayStub(gustoEmbedded, { payrollId: id, employeeId }) + // `funcs/payrollsGetPayStub` returns a Result discriminated union; + // surface API/validation errors so `baseSubmitHandler` can route them + // through the standard SDK error pipeline instead of bubbling to the + // error boundary as an unknown throw. + if (!response.ok) { + throw response.error + } + if (!response.value.responseStream) { + throw new Error('Pay stub response missing PDF stream') + } + const pdfBlob = await readableStreamToBlob(response.value.responseStream, 'application/pdf') + submitResult = { mode: 'update', data: pdfBlob } + }) + return submitResult + }, + [baseSubmitHandler, gustoEmbedded, employeeId], + ) + + if (payStubsQuery.isLoading) { + return { isLoading: true, errorHandling } + } + + return { + isLoading: false, + data: { payStubs }, + status: { + isFetching: payStubsQuery.isFetching, + isPending: false, + }, + pagination: { payStubs: payStubsPagination }, + actions: { downloadPayStub }, + errorHandling, + } +} diff --git a/src/components/Employee/exports/employeeManagement.ts b/src/components/Employee/exports/employeeManagement.ts index caacb41e3..99b3dbe7b 100644 --- a/src/components/Employee/exports/employeeManagement.ts +++ b/src/components/Employee/exports/employeeManagement.ts @@ -31,6 +31,8 @@ export { PaymentMethodSplitForm, type PaymentMethodSplitFormProps, } from '../PaymentMethod/management' +export { PaystubsCard } from '../Paystubs/management' +export type { PaystubsCardProps } from '../Paystubs/management' export { TerminateEmployee } from '../Terminations/TerminateEmployee/TerminateEmployee' export { TerminationSummary } from '../Terminations/TerminationSummary/TerminationSummary' export { TerminationFlow } from '../Terminations/TerminationFlow/TerminationFlow' diff --git a/src/i18n/en/Employee.Dashboard.json b/src/i18n/en/Employee.Dashboard.json index 3ce557a7f..0410c03e6 100644 --- a/src/i18n/en/Employee.Dashboard.json +++ b/src/i18n/en/Employee.Dashboard.json @@ -85,22 +85,6 @@ "title": "No deductions", "description": "Employee deductions will appear here" } - }, - "paystubs": { - "title": "Paystubs", - "listLabel": "List of paystubs", - "payday": "Payday", - "checkAmount": "Check amount", - "grossPay": "Gross pay", - "paymentMethod": "Payment method", - "noPaymentMethod": "Not available", - "downloadCta": "Download paystub", - "downloadError": "Unable to download paystub", - "downloadLoadingMessage": "Generating paystub…", - "emptyState": { - "title": "No paystubs", - "description": "Paystubs will appear here after payroll is run" - } } }, "taxes": { diff --git a/src/i18n/en/Employee.Management.Paystubs.json b/src/i18n/en/Employee.Management.Paystubs.json new file mode 100644 index 000000000..7f94ba6fe --- /dev/null +++ b/src/i18n/en/Employee.Management.Paystubs.json @@ -0,0 +1,17 @@ +{ + "title": "Paystubs", + "listLabel": "List of paystubs", + "payday": "Payday", + "checkAmount": "Check amount", + "grossPay": "Gross pay", + "paymentMethod": "Payment method", + "noPaymentMethod": "Not available", + "downloadCta": "Download paystub", + "downloadError": "Unable to download paystub", + "downloadLoadingMessage": "Generating paystub…", + "listEmptyPlaceholder": "No value", + "emptyState": { + "title": "No paystubs", + "description": "Paystubs will appear here after payroll is run" + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8e6c34328..ec1ad3f8b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -100,6 +100,9 @@ export const employeeEvents = { 'employee/management/paymentMethod/splitForm/cancelled', EMPLOYEE_MANAGEMENT_PAYMENT_METHOD_ALERT_DISMISSED: 'employee/management/paymentMethod/alertDismissed', + EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOAD_REQUESTED: + 'employee/management/paystubs/card/downloadRequested', + EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOADED: 'employee/management/paystubs/card/downloaded', } as const export const companyEvents = { diff --git a/src/types/i18next.d.ts b/src/types/i18next.d.ts index c897f1fb9..ee691ccda 100644 --- a/src/types/i18next.d.ts +++ b/src/types/i18next.d.ts @@ -1515,22 +1515,6 @@ export interface EmployeeDashboard{ "description":string; }; }; -"paystubs":{ -"title":string; -"listLabel":string; -"payday":string; -"checkAmount":string; -"grossPay":string; -"paymentMethod":string; -"noPaymentMethod":string; -"downloadCta":string; -"downloadError":string; -"downloadLoadingMessage":string; -"emptyState":{ -"title":string; -"description":string; -}; -}; }; "taxes":{ "federal":{ @@ -2083,6 +2067,23 @@ export interface EmployeeManagementPaymentMethodSplitForm{ "percentageAmountError":string; }; }; +export interface EmployeeManagementPaystubs{ +"title":string; +"listLabel":string; +"payday":string; +"checkAmount":string; +"grossPay":string; +"paymentMethod":string; +"noPaymentMethod":string; +"downloadCta":string; +"downloadError":string; +"downloadLoadingMessage":string; +"listEmptyPlaceholder":string; +"emptyState":{ +"title":string; +"description":string; +}; +}; export interface EmployeeManagementWorkAddress{ "cardTitle":string; "cardManageCta":string; @@ -3669,6 +3670,6 @@ export interface common{ interface CustomTypeOptions { defaultNS: 'common'; - resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Company.TimeOff.CreateTimeOffPolicy': CompanyTimeOffCreateTimeOffPolicy, 'Company.TimeOff.EmployeeTable': CompanyTimeOffEmployeeTable, 'Company.TimeOff.HolidayPolicy': CompanyTimeOffHolidayPolicy, 'Company.TimeOff.PolicyDetail': CompanyTimeOffPolicyDetail, 'Company.TimeOff.SelectEmployees': CompanyTimeOffSelectEmployees, 'Company.TimeOff.SelectPolicyType': CompanyTimeOffSelectPolicyType, 'Company.TimeOff.TimeOffPolicies': CompanyTimeOffTimeOffPolicies, 'Company.TimeOff.TimeOffPolicyDetails': CompanyTimeOffTimeOffPolicyDetails, 'Company.TimeOff.TimeOffRequests': CompanyTimeOffTimeOffRequests, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentStatement': ContractorPaymentsPaymentStatement, 'Contractor.Payments.PaymentSummary': ContractorPaymentsPaymentSummary, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Dashboard': EmployeeDashboard, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentManager': EmployeeDocumentManager, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeDocuments': EmployeeEmployeeDocuments, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.EmploymentEligibility': EmployeeEmploymentEligibility, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress.Management': EmployeeHomeAddressManagement, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.I9SignatureForm': EmployeeI9SignatureForm, 'Employee.Landing': EmployeeLanding, 'Employee.Management.PaymentMethod': EmployeeManagementPaymentMethod, 'Employee.Management.PaymentMethodBankForm': EmployeeManagementPaymentMethodBankForm, 'Employee.Management.PaymentMethodSplitForm': EmployeeManagementPaymentMethodSplitForm, 'Employee.Management.WorkAddress': EmployeeManagementWorkAddress, 'Employee.ManagementEmployeeList': EmployeeManagementEmployeeList, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile.Management': EmployeeProfileManagement, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Employee.Terminations.TerminateEmployee': EmployeeTerminationsTerminateEmployee, 'Employee.Terminations.TerminationFlow': EmployeeTerminationsTerminationFlow, 'Employee.Terminations.TerminationSummary': EmployeeTerminationsTerminationSummary, 'InformationRequests.InformationRequestForm': InformationRequestsInformationRequestForm, 'InformationRequests.InformationRequestList': InformationRequestsInformationRequestList, 'InformationRequests': InformationRequests, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.Dismissal': PayrollDismissal, 'Payroll.EmployeeSelection': PayrollEmployeeSelection, 'Payroll.GrossUpModal': PayrollGrossUpModal, 'Payroll.OffCycle': PayrollOffCycle, 'Payroll.OffCycleCreation': PayrollOffCycleCreation, 'Payroll.OffCycleDeductionsSetting': PayrollOffCycleDeductionsSetting, 'Payroll.OffCyclePayPeriodDateForm': PayrollOffCyclePayPeriodDateForm, 'Payroll.OffCycleReasonSelection': PayrollOffCycleReasonSelection, 'Payroll.OffCycleTaxWithholding': PayrollOffCycleTaxWithholding, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.RecoveryCasesList': PayrollRecoveryCasesList, 'Payroll.RecoveryCasesResubmit': PayrollRecoveryCasesResubmit, 'Payroll.Transition': PayrollTransition, 'Payroll.TransitionCreation': PayrollTransitionCreation, 'Payroll.TransitionPayrollAlert': PayrollTransitionPayrollAlert, 'Payroll.WireInstructions': PayrollWireInstructions, 'common': common, } + resources: { 'Company.Addresses': CompanyAddresses, 'Company.AssignSignatory': CompanyAssignSignatory, 'Company.BankAccount': CompanyBankAccount, 'Company.DocumentList': CompanyDocumentList, 'Company.FederalTaxes': CompanyFederalTaxes, 'Company.Industry': CompanyIndustry, 'Company.Locations': CompanyLocations, 'Company.OnboardingOverview': CompanyOnboardingOverview, 'Company.PaySchedule': CompanyPaySchedule, 'Company.SignatureForm': CompanySignatureForm, 'Company.StateTaxes': CompanyStateTaxes, 'Company.TimeOff.CreateTimeOffPolicy': CompanyTimeOffCreateTimeOffPolicy, 'Company.TimeOff.EmployeeTable': CompanyTimeOffEmployeeTable, 'Company.TimeOff.HolidayPolicy': CompanyTimeOffHolidayPolicy, 'Company.TimeOff.PolicyDetail': CompanyTimeOffPolicyDetail, 'Company.TimeOff.SelectEmployees': CompanyTimeOffSelectEmployees, 'Company.TimeOff.SelectPolicyType': CompanyTimeOffSelectPolicyType, 'Company.TimeOff.TimeOffPolicies': CompanyTimeOffTimeOffPolicies, 'Company.TimeOff.TimeOffPolicyDetails': CompanyTimeOffTimeOffPolicyDetails, 'Company.TimeOff.TimeOffRequests': CompanyTimeOffTimeOffRequests, 'Contractor.Address': ContractorAddress, 'Contractor.ContractorList': ContractorContractorList, 'Contractor.NewHireReport': ContractorNewHireReport, 'Contractor.PaymentMethod': ContractorPaymentMethod, 'Contractor.Payments.CreatePayment': ContractorPaymentsCreatePayment, 'Contractor.Payments.PaymentHistory': ContractorPaymentsPaymentHistory, 'Contractor.Payments.PaymentStatement': ContractorPaymentsPaymentStatement, 'Contractor.Payments.PaymentSummary': ContractorPaymentsPaymentSummary, 'Contractor.Payments.PaymentsList': ContractorPaymentsPaymentsList, 'Contractor.Profile': ContractorProfile, 'Contractor.Submit': ContractorSubmit, 'Employee.BankAccount': EmployeeBankAccount, 'Employee.Compensation': EmployeeCompensation, 'Employee.Dashboard': EmployeeDashboard, 'Employee.Deductions': EmployeeDeductions, 'Employee.DocumentManager': EmployeeDocumentManager, 'Employee.DocumentSigner': EmployeeDocumentSigner, 'Employee.EmployeeDocuments': EmployeeEmployeeDocuments, 'Employee.EmployeeList': EmployeeEmployeeList, 'Employee.EmploymentEligibility': EmployeeEmploymentEligibility, 'Employee.FederalTaxes': EmployeeFederalTaxes, 'Employee.HomeAddress.Management': EmployeeHomeAddressManagement, 'Employee.HomeAddress': EmployeeHomeAddress, 'Employee.I9SignatureForm': EmployeeI9SignatureForm, 'Employee.Landing': EmployeeLanding, 'Employee.Management.PaymentMethod': EmployeeManagementPaymentMethod, 'Employee.Management.PaymentMethodBankForm': EmployeeManagementPaymentMethodBankForm, 'Employee.Management.PaymentMethodSplitForm': EmployeeManagementPaymentMethodSplitForm, 'Employee.Management.Paystubs': EmployeeManagementPaystubs, 'Employee.Management.WorkAddress': EmployeeManagementWorkAddress, 'Employee.ManagementEmployeeList': EmployeeManagementEmployeeList, 'Employee.OnboardingSummary': EmployeeOnboardingSummary, 'Employee.PaySchedules': EmployeePaySchedules, 'Employee.PaymentMethod': EmployeePaymentMethod, 'Employee.Profile.Management': EmployeeProfileManagement, 'Employee.Profile': EmployeeProfile, 'Employee.SplitPaycheck': EmployeeSplitPaycheck, 'Employee.StateTaxes': EmployeeStateTaxes, 'Employee.Taxes': EmployeeTaxes, 'Employee.Terminations.TerminateEmployee': EmployeeTerminationsTerminateEmployee, 'Employee.Terminations.TerminationFlow': EmployeeTerminationsTerminationFlow, 'Employee.Terminations.TerminationSummary': EmployeeTerminationsTerminationSummary, 'InformationRequests.InformationRequestForm': InformationRequestsInformationRequestForm, 'InformationRequests.InformationRequestList': InformationRequestsInformationRequestList, 'InformationRequests': InformationRequests, 'Payroll.Common': PayrollCommon, 'Payroll.ConfirmWireDetailsBanner': PayrollConfirmWireDetailsBanner, 'Payroll.ConfirmWireDetailsForm': PayrollConfirmWireDetailsForm, 'Payroll.Dismissal': PayrollDismissal, 'Payroll.EmployeeSelection': PayrollEmployeeSelection, 'Payroll.GrossUpModal': PayrollGrossUpModal, 'Payroll.OffCycle': PayrollOffCycle, 'Payroll.OffCycleCreation': PayrollOffCycleCreation, 'Payroll.OffCycleDeductionsSetting': PayrollOffCycleDeductionsSetting, 'Payroll.OffCyclePayPeriodDateForm': PayrollOffCyclePayPeriodDateForm, 'Payroll.OffCycleReasonSelection': PayrollOffCycleReasonSelection, 'Payroll.OffCycleTaxWithholding': PayrollOffCycleTaxWithholding, 'Payroll.PayrollBlocker': PayrollPayrollBlocker, 'Payroll.PayrollConfiguration': PayrollPayrollConfiguration, 'Payroll.PayrollEditEmployee': PayrollPayrollEditEmployee, 'Payroll.PayrollFlow': PayrollPayrollFlow, 'Payroll.PayrollHistory': PayrollPayrollHistory, 'Payroll.PayrollLanding': PayrollPayrollLanding, 'Payroll.PayrollList': PayrollPayrollList, 'Payroll.PayrollOverview': PayrollPayrollOverview, 'Payroll.PayrollReceipts': PayrollPayrollReceipts, 'Payroll.RecoveryCasesList': PayrollRecoveryCasesList, 'Payroll.RecoveryCasesResubmit': PayrollRecoveryCasesResubmit, 'Payroll.Transition': PayrollTransition, 'Payroll.TransitionCreation': PayrollTransitionCreation, 'Payroll.TransitionPayrollAlert': PayrollTransitionPayrollAlert, 'Payroll.WireInstructions': PayrollWireInstructions, 'common': common, } }; } \ No newline at end of file