Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .reports/embedded-react-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4376,6 +4376,7 @@ interface PayrollOverviewProps extends BaseComponentInterface<'Payroll.PayrollOv
companyId: string;
ConfirmWireDetailsComponent?: ConfirmWireDetailsComponentType;
payrollId: string;
readOnly?: boolean;
withReimbursements?: boolean;
}

Expand Down
4 changes: 3 additions & 1 deletion docs/reference/payroll/blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,8 @@ The payroll referenced by `payrollId` must already be calculated; rendering with
uncalculated payroll throws. Unresolved submission blockers (e.g. fast-ACH threshold,
wire-in funding) are surfaced inline and the submit action stays disabled until each
blocker has a selected unblock option. While the payroll is processing, the component
polls until success or failure and emits the corresponding event.
polls until success or failure and emits the corresponding event. Pass `readOnly` to hide
the edit and cancel actions while keeping submit available.

<br />

Expand All @@ -655,6 +656,7 @@ Props for [PayrollOverview](#payrolloverview).
| `alerts?` | [`PayrollFlowAlert`](#payrollflowalert)[] | Alert banners to display above the payroll summary. |
| `ConfirmWireDetailsComponent?` | [`ConfirmWireDetailsComponentType`](#confirmwiredetailscomponenttype) | Custom component to replace the default wire details confirmation UI. |
| `dictionary?` | `Record`\<`"en"`, [`DeepPartial`](../Translations/index.md#deeppartial)\<[`PayrollPayrollOverview`](../Translations/index.md#payrollpayrolloverview)\>\> | Overrides for the component's i18n strings. Supply a partial object whose keys match the component's resource namespace — any omitted keys fall back to SDK defaults. See the [Translation guide](https://docs.gusto.com/embedded-payroll/docs/translation) for details. |
| `readOnly?` | `boolean` | Hides the edit and cancel actions, leaving submit and receipt/paystub actions available. Use for a deep link to a specific payroll where editing shouldn't be offered. Defaults to `false`. |
| `withReimbursements?` | `boolean` | Whether reimbursement fields are shown in the totals and per-employee tables. Defaults to `true`. |

_Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `LoaderComponent` from [BaseComponentInterface](../blocks.md#basecomponentinterface)._
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,100 @@ export const PayrollOverviewStory = () => {
)
}

export const PayrollOverviewReadOnly = () => {
return (
<PayrollOverviewPresentation
onEdit={fn().mockName('edit')}
onSubmit={fn().mockName('submit')}
taxes={{ 'Federal Income Tax': { employee: 100, employer: 200 } }}
isProcessed={false}
canEdit={false}
status={PayrollOverviewStatus.Viewing}
onCancel={fn().mockName('cancel')}
onPayrollReceipt={fn().mockName('payrollReceipt')}
onPaystubDownload={fn().mockName('paystubDownload')}
payrollData={{
payrollDeadline: new Date('2025-09-24T23:00:00.000Z'),
checkDate: '2025-09-26',
processed: false,
processedDate: null,
calculatedAt: new Date('2025-09-15T16:25:07.000Z'),
uuid: 'payroll-uuid',
payrollUuid: 'payroll-uuid',
companyUuid: 'company-uuid',
offCycle: false,
external: false,
payPeriod: {
startDate: '2025-09-12',
endDate: '2025-09-18',
payScheduleUuid: 'schedule-uuid',
},
totals: {
companyDebit: '5000.00',
netPayDebit: '4000.00',
taxDebit: '1000.00',
reimbursementDebit: '0.00',
childSupportDebit: '0.00',
reimbursements: '0.00',
netPay: '4000.00',
grossPay: '5000.00',
employeeBonuses: '0.00',
employeeCommissions: '0.00',
employeeCashTips: '0.00',
employeePaycheckTips: '0.00',
additionalEarnings: '0.00',
ownersDraw: '0.00',
checkAmount: '0.00',
employerTaxes: '500.00',
employeeTaxes: '500.00',
benefits: '0.00',
employeeBenefitsDeductions: '0.00',
imputedPay: '0.00',
deferredPayrollTaxes: '0.00',
otherDeductions: '0.00',
},
companyTaxes: [],
createdAt: new Date('2025-09-15T16:19:04.000Z'),
submissionBlockers: [],
processingRequest: { status: 'calculate_success', errors: [] },
partnerOwnedDisbursement: false,
employeeCompensations: [
{
employeeUuid: 'emp-active',
firstName: 'Isaiah',
lastName: 'Berlin',
excluded: false,
version: 'v1',
grossPay: '5000',
netPay: '4000',
checkAmount: '4000',
paymentMethod: 'Direct Deposit',
memo: null,
fixedCompensations: [],
hourlyCompensations: [
{
name: 'Regular Hours',
hours: '40.000',
amount: '5000.0',
jobUuid: 'job-1',
compensationMultiplier: 1,
flsaStatus: 'Nonexempt',
},
],
paidTimeOff: [],
taxes: [
{ name: 'Federal Income Tax', employer: false, amount: '100' },
{ name: 'Federal Income Tax', employer: true, amount: '200' },
],
benefits: [],
deductions: [],
},
],
}}
/>
)
}

export const PayrollOverviewWithWireFunds = () => {
return (
<PayrollOverviewPresentation
Expand Down
68 changes: 68 additions & 0 deletions src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { PayrollShow } from '@gusto/embedded-api/models/components/payrollshow'
import { OffCycleReasonType } from '@gusto/embedded-api/models/components/payrollshow'
import { canCancelPayroll } from '../helpers'
import { PayrollOverview } from './PayrollOverview'
import { componentEvents } from '@/shared/constants'
import { renderWithProviders } from '@/test-utils/renderWithProviders'
Expand Down Expand Up @@ -122,6 +123,14 @@ vi.mock('@gusto/embedded-api/funcs/payrollsGetPayStub', () => ({
payrollsGetPayStub: vi.fn(),
}))

vi.mock('../helpers', async importOriginal => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
canCancelPayroll: vi.fn(),
}
})

describe('PayrollOverview polling', () => {
const mockOnEvent = vi.fn()

Expand Down Expand Up @@ -369,3 +378,62 @@ describe('PayrollOverview print checks modal', () => {
expect(await screen.findByText('Choose check stock')).toBeInTheDocument()
})
})

describe('PayrollOverview readOnly mode', () => {
const mockOnEvent = vi.fn()

beforeEach(() => {
vi.clearAllMocks()
mockPayrollData = { ...basePayrollData }
mockIsFetching = false
vi.mocked(canCancelPayroll).mockReturnValue(false)
})

it('hides Edit but keeps Submit enabled and functional on an unprocessed payroll', async () => {
const user = userEvent.setup()
mockSubmitPayroll.mockResolvedValue({ payrollUuid: 'payroll-uuid' })

renderWithProviders(
<PayrollOverview
companyId="company-uuid"
payrollId="payroll-uuid"
onEvent={mockOnEvent}
readOnly
/>,
)

expect(await screen.findByRole('button', { name: 'Submit' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull()

await user.click(screen.getByRole('button', { name: 'Submit' }))

await waitFor(() => {
expect(mockSubmitPayroll).toHaveBeenCalled()
expect(mockOnEvent).toHaveBeenCalledWith(
componentEvents.RUN_PAYROLL_SUBMITTED,
expect.anything(),
)
})
})

it('hides Cancel on a processed payroll even when the payroll is otherwise cancellable', async () => {
vi.mocked(canCancelPayroll).mockReturnValue(true)
mockPayrollData = {
...basePayrollData,
processed: true,
processingRequest: { status: 'submit_success', errors: [] },
}

renderWithProviders(
<PayrollOverview
companyId="company-uuid"
payrollId="payroll-uuid"
onEvent={mockOnEvent}
readOnly
/>,
)

expect(await screen.findByRole('button', { name: 'View payroll receipt' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Cancel payroll' })).toBeNull()
})
})
22 changes: 17 additions & 5 deletions src/components/Payroll/PayrollOverview/PayrollOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export interface PayrollOverviewProps extends BaseComponentInterface<'Payroll.Pa
withReimbursements?: boolean
/** Custom component to replace the default wire details confirmation UI. */
ConfirmWireDetailsComponent?: ConfirmWireDetailsComponentType
/**
* Hides the edit and cancel actions, leaving submit and receipt/paystub actions available.
* Use for a deep link to a specific payroll where editing shouldn't be offered. Defaults to
* `false`.
*/
readOnly?: boolean
}

const findUnresolvedBlockersWithOptions = (
Expand Down Expand Up @@ -96,7 +102,8 @@ const findWireInRequestUuid = (
* uncalculated payroll throws. Unresolved submission blockers (e.g. fast-ACH threshold,
* wire-in funding) are surfaced inline and the submit action stays disabled until each
* blocker has a selected unblock option. While the payroll is processing, the component
* polls until success or failure and emits the corresponding event.
* polls until success or failure and emits the corresponding event. Pass `readOnly` to hide
* the edit and cancel actions while keeping submit available.
*
* @events
* | Event | Description | Data |
Expand Down Expand Up @@ -138,6 +145,7 @@ const Root = ({
alerts,
withReimbursements = true,
ConfirmWireDetailsComponent = ConfirmWireDetails,
readOnly = false,
}: PayrollOverviewProps) => {
useComponentDictionary('Payroll.PayrollOverview', dictionary)
useI18n('Payroll.PayrollOverview')
Expand Down Expand Up @@ -283,9 +291,11 @@ const Root = ({
content: (
<Flex flexDirection="column" gap={16}>
<UnorderedList items={renderErrorList(payrollData.processingRequest.errors ?? [])} />
<Button variant="secondary" onClick={onEdit}>
{t('alerts.payrollProcessingFailedCtaLabel')}
</Button>
{!readOnly && (
<Button variant="secondary" onClick={onEdit}>
{t('alerts.payrollProcessingFailedCtaLabel')}
</Button>
)}
</Flex>
),
},
Expand All @@ -305,6 +315,7 @@ const Root = ({
payrollData?.totals?.companyDebit,
payrollData?.payrollStatusMeta?.expectedDebitTime,
payrollData?.payrollDeadline,
readOnly,
])

const { data: bankAccountData } = useBankAccountsGetSuspense({
Expand Down Expand Up @@ -460,7 +471,8 @@ const Root = ({
payrollData.processed === true ||
payrollData.processingRequest?.status === PAYROLL_PROCESSING_STATUS.submit_success
}
canCancel={canCancelPayroll(payrollData)}
canCancel={canCancelPayroll(payrollData) && !readOnly}
canEdit={!readOnly}
payrollData={payrollData}
bankAccount={bankAccount}
taxes={taxes}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -665,5 +665,14 @@ describe('PayrollOverviewPresentation', () => {
expect(await screen.findByRole('button', { name: /^Edit$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Submit$/i })).toBeInTheDocument()
})

it('hides Edit but keeps Submit when canEdit is false on an unprocessed payroll', async () => {
renderWithProviders(
<PayrollOverviewPresentation {...defaultProps} isProcessed={false} canEdit={false} />,
)

expect(await screen.findByRole('button', { name: /^Submit$/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Edit$/i })).not.toBeInTheDocument()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface PayrollOverviewProps {
status?: PayrollOverviewStatus
isProcessed: boolean
canCancel?: boolean
canEdit?: boolean
alerts?: PayrollFlowAlert[]
submissionBlockers?: PayrollSubmissionBlockerType[]
selectedUnblockOptions?: Record<string, string>
Expand Down Expand Up @@ -82,6 +83,7 @@ export const PayrollOverviewPresentation = ({
status = PayrollOverviewStatus.Viewing,
isProcessed,
canCancel = false,
canEdit = true,
alerts = [],
submissionBlockers = [],
selectedUnblockOptions = {},
Expand Down Expand Up @@ -582,9 +584,11 @@ export const PayrollOverviewPresentation = ({
</>
) : (
<>
<Button onClick={onEdit} variant="secondary" isDisabled={isLoading}>
{t('editCta')}
</Button>
{canEdit && (
<Button onClick={onEdit} variant="secondary" isDisabled={isLoading}>
{t('editCta')}
</Button>
)}
<Button
onClick={onSubmit}
isDisabled={
Expand Down
Loading