From 9121ec6e8b7ee7ab3eb5a7e8f7eae238389f077d Mon Sep 17 00:00:00 2001 From: Kristine White Date: Tue, 18 Aug 2026 15:11:15 -0700 Subject: [PATCH 1/6] feat(SDK-1161): add print-checks terminal screens and event vocabulary First slice of the print-checks feature (split out of #2572 for reviewability): the shared event constants and the two terminal screens of the upcoming print-checks flow, PrintChecksFailure and PrintChecksSummary. Both are standalone, presentational components that will be wired into a state-machine orchestrator in a follow-up PR stacked on this one. Co-Authored-By: Claude Sonnet 5 --- .../PrintChecksFailure.stories.tsx | 35 +++++++++++ .../PrintChecksFailure.test.tsx | 40 ++++++++++++ .../PrintChecksFailure/PrintChecksFailure.tsx | 63 +++++++++++++++++++ .../PrintChecks/PrintChecksFailure/index.ts | 1 + .../PrintChecksSummary.stories.tsx | 35 +++++++++++ .../PrintChecksSummary.test.tsx | 38 +++++++++++ .../PrintChecksSummary/PrintChecksSummary.tsx | 59 +++++++++++++++++ .../PrintChecks/PrintChecksSummary/index.ts | 1 + src/i18n/en/Payroll.PrintChecksFailure.json | 5 ++ src/i18n/en/Payroll.PrintChecksSummary.json | 6 ++ src/i18n/types.d.ts | 22 +++++++ src/shared/constants.ts | 21 +++++++ 12 files changed, 326 insertions(+) create mode 100644 src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.stories.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.test.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksFailure/index.ts create mode 100644 src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.stories.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.test.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksSummary/index.ts create mode 100644 src/i18n/en/Payroll.PrintChecksFailure.json create mode 100644 src/i18n/en/Payroll.PrintChecksSummary.json diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.stories.tsx b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.stories.tsx new file mode 100644 index 000000000..4cfc4c80e --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.stories.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import { fn } from 'storybook/test' +import { MockBaseProvider } from '../../../../../.storybook/helpers/MockBaseProvider' +import { PrintChecksFailure } from './PrintChecksFailure' +import { useI18n } from '@/i18n' + +function I18nLoader({ children }: { children: React.ReactNode }) { + useI18n('Payroll.PrintChecksFailure') + return <>{children} +} + +export default { + title: 'Domain/Payroll/PrintChecksFailure', + decorators: [ + (Story: React.ComponentType) => ( + Loading translations...}> + + + + + + + ), + ], +} + +export const Default = () => ( + <> + + + +) diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.test.tsx new file mode 100644 index 000000000..4b969ea51 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PrintChecksFailure } from './PrintChecksFailure' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { printChecksEvents } from '@/shared/constants' + +describe('PrintChecksFailure', () => { + const user = userEvent.setup() + + it('renders the error message', async () => { + renderWithProviders( + , + ) + + expect(await screen.findByText("We couldn't generate your checks")).toBeInTheDocument() + expect(screen.getByText('Cannot generate checks on an unprocessed payroll')).toBeInTheDocument() + }) + + it('fires PRINT_CHECKS_RETRY when the retry button is clicked', async () => { + const onEvent = vi.fn() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Try again' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_RETRY) + }) + + it('fires PRINT_CHECKS_CLOSE when the Footer close button is clicked', async () => { + const onEvent = vi.fn() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Close' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_CLOSE) + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.tsx b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.tsx new file mode 100644 index 000000000..db6e3a8f6 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/PrintChecksFailure.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next' +import { BaseComponent, type BaseComponentInterface } from '@/components/Base' +import type { OnEventType } from '@/components/Base/useBase' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useComponentDictionary, useI18n } from '@/i18n' +import { ActionsLayout, Flex } from '@/components/Common' +import { printChecksEvents, type EventType } from '@/shared/constants' + +interface PrintChecksFailureProps extends BaseComponentInterface<'Payroll.PrintChecksFailure'> { + errorMessage?: string +} + +/** @internal */ +export function PrintChecksFailure(props: PrintChecksFailureProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ dictionary, errorMessage, onEvent }: PrintChecksFailureProps) => { + useComponentDictionary('Payroll.PrintChecksFailure', dictionary) + useI18n('Payroll.PrintChecksFailure') + const { t } = useTranslation('Payroll.PrintChecksFailure') + const { Alert, Button } = useComponentContext() + + return ( + + + {errorMessage} + + + + ) +} + +const Footer = ({ onEvent }: { onEvent: OnEventType }) => { + useI18n('Payroll.PrintChecksFailure') + const { t } = useTranslation('Payroll.PrintChecksFailure') + const { Button } = useComponentContext() + + return ( + + + + ) +} +PrintChecksFailure.Footer = Footer diff --git a/src/components/Payroll/PrintChecks/PrintChecksFailure/index.ts b/src/components/Payroll/PrintChecks/PrintChecksFailure/index.ts new file mode 100644 index 000000000..49e02db11 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksFailure/index.ts @@ -0,0 +1 @@ +export { PrintChecksFailure } from './PrintChecksFailure' diff --git a/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.stories.tsx b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.stories.tsx new file mode 100644 index 000000000..5b10df686 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.stories.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import { fn } from 'storybook/test' +import { MockBaseProvider } from '../../../../../.storybook/helpers/MockBaseProvider' +import { PrintChecksSummary } from './PrintChecksSummary' +import { useI18n } from '@/i18n' + +function I18nLoader({ children }: { children: React.ReactNode }) { + useI18n('Payroll.PrintChecksSummary') + return <>{children} +} + +export default { + title: 'Domain/Payroll/PrintChecksSummary', + decorators: [ + (Story: React.ComponentType) => ( + Loading translations...}> + + + + + + + ), + ], +} + +export const Default = () => ( + <> + + + +) diff --git a/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.test.tsx new file mode 100644 index 000000000..1acef761c --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PrintChecksSummary } from './PrintChecksSummary' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { printChecksEvents } from '@/shared/constants' + +describe('PrintChecksSummary', () => { + const user = userEvent.setup() + + it('renders a link to the document when a documentUrl is provided', async () => { + renderWithProviders( + , + ) + + expect(await screen.findByText('Your checks are ready')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View checks' })).toHaveAttribute( + 'href', + 'https://example.com/checks.pdf', + ) + }) + + it('omits the link when no documentUrl is provided', async () => { + renderWithProviders() + + expect(await screen.findByText('Your checks are ready')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'View checks' })).toBeNull() + }) + + it('fires PRINT_CHECKS_CLOSE when the Footer close button is clicked', async () => { + const onEvent = vi.fn() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'Close' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_CLOSE) + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.tsx b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.tsx new file mode 100644 index 000000000..fb99ebf71 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/PrintChecksSummary.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next' +import { BaseComponent, type BaseComponentInterface } from '@/components/Base' +import type { OnEventType } from '@/components/Base/useBase' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useComponentDictionary, useI18n } from '@/i18n' +import { ActionsLayout, Flex } from '@/components/Common' +import { printChecksEvents, type EventType } from '@/shared/constants' + +interface PrintChecksSummaryProps extends BaseComponentInterface<'Payroll.PrintChecksSummary'> { + documentUrl?: string +} + +/** @internal */ +export function PrintChecksSummary(props: PrintChecksSummaryProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ dictionary, documentUrl }: PrintChecksSummaryProps) => { + useComponentDictionary('Payroll.PrintChecksSummary', dictionary) + useI18n('Payroll.PrintChecksSummary') + const { t } = useTranslation('Payroll.PrintChecksSummary') + const { Heading, Text, Link } = useComponentContext() + + return ( + + {t('succeededTitle')} + {t('succeededDescription')} + {documentUrl && ( + + {t('viewChecksCta')} + + )} + + ) +} + +const Footer = ({ onEvent }: { onEvent: OnEventType }) => { + useI18n('Payroll.PrintChecksSummary') + const { t } = useTranslation('Payroll.PrintChecksSummary') + const { Button } = useComponentContext() + + return ( + + + + ) +} +PrintChecksSummary.Footer = Footer diff --git a/src/components/Payroll/PrintChecks/PrintChecksSummary/index.ts b/src/components/Payroll/PrintChecks/PrintChecksSummary/index.ts new file mode 100644 index 000000000..22904e615 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksSummary/index.ts @@ -0,0 +1 @@ +export { PrintChecksSummary } from './PrintChecksSummary' diff --git a/src/i18n/en/Payroll.PrintChecksFailure.json b/src/i18n/en/Payroll.PrintChecksFailure.json new file mode 100644 index 000000000..f94121065 --- /dev/null +++ b/src/i18n/en/Payroll.PrintChecksFailure.json @@ -0,0 +1,5 @@ +{ + "failedTitle": "We couldn't generate your checks", + "retryCta": "Try again", + "closeCta": "Close" +} diff --git a/src/i18n/en/Payroll.PrintChecksSummary.json b/src/i18n/en/Payroll.PrintChecksSummary.json new file mode 100644 index 000000000..95e736c6e --- /dev/null +++ b/src/i18n/en/Payroll.PrintChecksSummary.json @@ -0,0 +1,6 @@ +{ + "succeededTitle": "Your checks are ready", + "succeededDescription": "The download should have started automatically. If not, use the link below.", + "viewChecksCta": "View checks", + "closeCta": "Close" +} diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index d559c6f23..e760e8bb9 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -127,6 +127,8 @@ export interface Resources { 'Payroll.PayrollList': Translations.PayrollPayrollList 'Payroll.PayrollOverview': Translations.PayrollPayrollOverview 'Payroll.PayrollReceipts': Translations.PayrollPayrollReceipts + 'Payroll.PrintChecksFailure': Translations.PayrollPrintChecksFailure + 'Payroll.PrintChecksSummary': Translations.PayrollPrintChecksSummary 'Payroll.RecoveryCasesList': Translations.PayrollRecoveryCasesList 'Payroll.RecoveryCasesResubmit': Translations.PayrollRecoveryCasesResubmit 'Payroll.Transition': Translations.PayrollTransition @@ -7665,6 +7667,26 @@ export namespace Translations { totalEmployees_other: string } } + /** Translation keys for the `Payroll.PrintChecksFailure` i18n namespace. */ + export interface PayrollPrintChecksFailure { + /** @defaultValue `"We couldn't generate your checks"` */ + failedTitle: string + /** @defaultValue `"Try again"` */ + retryCta: string + /** @defaultValue `"Close"` */ + closeCta: string + } + /** Translation keys for the `Payroll.PrintChecksSummary` i18n namespace. */ + export interface PayrollPrintChecksSummary { + /** @defaultValue `"Your checks are ready"` */ + succeededTitle: string + /** @defaultValue `"The download should have started automatically. If not, use the link below."` */ + succeededDescription: string + /** @defaultValue `"View checks"` */ + viewChecksCta: string + /** @defaultValue `"Close"` */ + closeCta: string + } /** Translation keys for the `Payroll.RecoveryCasesList` i18n namespace. */ export interface PayrollRecoveryCasesList { /** @defaultValue `"Recovery cases"` */ diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 153799c8b..c57f77132 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -466,6 +466,26 @@ export const recoveryCasesEvents = { RECOVERY_CASE_RESUBMIT_DONE: 'recoveryCase/resubmit/done', } as const +/** + * Event keys emitted by the print-checks component. + * + * @remarks + * These keys are merged into {@link componentEvents}. Consume them through the + * `onEvent` handler of the print-checks component and compare against the + * value of an entry on this object. + * + * @public + */ +export const printChecksEvents = { + PRINT_CHECKS_START: 'payroll/printChecks/start', + PRINT_CHECKS_GENERATE_START: 'payroll/printChecks/generate/start', + PRINT_CHECKS_GENERATE_SUCCEEDED: 'payroll/printChecks/generate/succeeded', + PRINT_CHECKS_GENERATE_FAILED: 'payroll/printChecks/generate/failed', + PRINT_CHECKS_RETRY: 'payroll/printChecks/retry', + PRINT_CHECKS_CANCEL: 'payroll/printChecks/cancel', + PRINT_CHECKS_CLOSE: 'payroll/printChecks/close', +} as const + /** * Event keys emitted by off-cycle payroll and transition components. * @@ -567,6 +587,7 @@ export const componentEvents = { ...payrollWireEvents, ...informationRequestEvents, ...recoveryCasesEvents, + ...printChecksEvents, ...contractorPaymentEvents, ...contractorHistoricalPaymentEvents, ...offCycleEvents, From a1b611fd8b28b78289ca9570ef3570398fd134c2 Mon Sep 17 00:00:00 2001 From: Kristine White Date: Tue, 18 Aug 2026 15:14:38 -0700 Subject: [PATCH 2/6] feat(SDK-1161): add PrintChecksBanner Second slice of the print-checks feature stack (see #2584). A standalone banner that fetches its own payroll data via usePayrollsGet, shows an info alert when the payroll has employees paid by check, and surfaces a "View and print checks" CTA once the payroll is processed. Fires PRINT_CHECKS_START when clicked. Stacked on kw/feat/print-checks-1-terminal-screens; targets that branch, not main. Co-Authored-By: Claude Sonnet 5 --- .../PrintChecksBanner.test.tsx | 91 +++++++++++++++++++ .../PrintChecksBanner/PrintChecksBanner.tsx | 65 +++++++++++++ .../PrintChecks/PrintChecksBanner/index.ts | 1 + src/i18n/en/Payroll.PrintChecksBanner.json | 6 ++ src/i18n/types.d.ts | 12 +++ 5 files changed, 175 insertions(+) create mode 100644 src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.test.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksBanner/index.ts create mode 100644 src/i18n/en/Payroll.PrintChecksBanner.json diff --git a/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.test.tsx new file mode 100644 index 000000000..77aca09ab --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.test.tsx @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { PrintChecksBanner } from './PrintChecksBanner' +import { renderWithProviders } from '@/test-utils/renderWithProviders' + +const checkCompensation = { + employeeUuid: 'emp-check-1', + excluded: false, + paymentMethod: 'Check', +} + +let mockEmployeeCompensations: Record[] = [] +let mockProcessed = false +let mockProcessingStatus: string | undefined + +vi.mock('@gusto/embedded-api/react-query/payrollsGet', () => ({ + usePayrollsGet: () => ({ + data: { + payrollShow: { + processed: mockProcessed, + processingRequest: mockProcessingStatus ? { status: mockProcessingStatus } : undefined, + employeeCompensations: mockEmployeeCompensations, + }, + }, + }), +})) + +describe('PrintChecksBanner', () => { + const onEvent = vi.fn() + const onStartPrintChecks = vi.fn() + const user = userEvent.setup() + + const defaultProps = { + companyId: 'company-1', + payrollId: 'payroll-1', + onStartPrintChecks, + onEvent, + } + + beforeEach(() => { + vi.clearAllMocks() + mockEmployeeCompensations = [] + mockProcessed = false + mockProcessingStatus = undefined + }) + + it('renders nothing when no employees are paid by check', () => { + renderWithProviders() + + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows the alert without a CTA when the payroll has not been processed', async () => { + mockEmployeeCompensations = [checkCompensation] + + renderWithProviders() + + expect(await screen.findByText(/noted 1 employee/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View and print checks' })).toBeNull() + }) + + it('shows the CTA once the payroll is processed, and fires onStartPrintChecks when clicked', async () => { + mockEmployeeCompensations = [checkCompensation] + mockProcessed = true + + renderWithProviders() + + const cta = await screen.findByRole('button', { name: 'View and print checks' }) + await user.click(cta) + + expect(onStartPrintChecks).toHaveBeenCalledTimes(1) + }) + + it('shows the CTA when the processing request succeeded even if processed is not yet true', async () => { + mockEmployeeCompensations = [checkCompensation] + mockProcessingStatus = 'submit_success' + + renderWithProviders() + + expect(await screen.findByRole('button', { name: 'View and print checks' })).toBeInTheDocument() + }) + + it('excludes compensations marked as excluded from the count', () => { + mockEmployeeCompensations = [{ ...checkCompensation, excluded: true }] + + renderWithProviders() + + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.tsx b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.tsx new file mode 100644 index 000000000..9ae13f1c5 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksBanner/PrintChecksBanner.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { usePayrollsGet } from '@gusto/embedded-api/react-query/payrollsGet' +import { BaseComponent, type BaseComponentInterface } from '@/components/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useComponentDictionary, useI18n } from '@/i18n' +import { PAYMENT_METHODS, PAYROLL_PROCESSING_STATUS } from '@/shared/constants' + +interface PrintChecksBannerProps extends BaseComponentInterface<'Payroll.PrintChecksBanner'> { + companyId: string + payrollId: string + onStartPrintChecks: () => void +} + +/** @internal */ +export function PrintChecksBanner(props: PrintChecksBannerProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ companyId, payrollId, dictionary, onStartPrintChecks }: PrintChecksBannerProps) => { + useComponentDictionary('Payroll.PrintChecksBanner', dictionary) + useI18n('Payroll.PrintChecksBanner') + const { t } = useTranslation('Payroll.PrintChecksBanner') + const { Alert, Button } = useComponentContext() + + const { data } = usePayrollsGet({ + companyId, + payrollId, + }) + const payrollData = data?.payrollShow + + const checkPaymentsCount = + payrollData?.employeeCompensations?.reduce( + (acc, comp) => + !comp.excluded && comp.paymentMethod === PAYMENT_METHODS.check ? acc + 1 : acc, + 0, + ) ?? 0 + + const isProcessed = + payrollData?.processed === true || + payrollData?.processingRequest?.status === PAYROLL_PROCESSING_STATUS.submit_success + + if (checkPaymentsCount === 0) { + return null + } + + return ( + + {t('cta')} + + ) + } + > + {t('description')} + + ) +} diff --git a/src/components/Payroll/PrintChecks/PrintChecksBanner/index.ts b/src/components/Payroll/PrintChecks/PrintChecksBanner/index.ts new file mode 100644 index 000000000..fb2fa9e31 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksBanner/index.ts @@ -0,0 +1 @@ +export { PrintChecksBanner } from './PrintChecksBanner' diff --git a/src/i18n/en/Payroll.PrintChecksBanner.json b/src/i18n/en/Payroll.PrintChecksBanner.json new file mode 100644 index 000000000..d75b58b22 --- /dev/null +++ b/src/i18n/en/Payroll.PrintChecksBanner.json @@ -0,0 +1,6 @@ +{ + "title_one": "You noted {{count}} employee that should be paid by check.", + "title_other": "You noted {{count}} employees that should be paid by check.", + "description": "Employees with this payment method will need their checks delivered to them. If you aren't using your own checks, you can view and print checks.", + "cta": "View and print checks" +} diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index e760e8bb9..cfc273e6e 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -127,6 +127,7 @@ export interface Resources { 'Payroll.PayrollList': Translations.PayrollPayrollList 'Payroll.PayrollOverview': Translations.PayrollPayrollOverview 'Payroll.PayrollReceipts': Translations.PayrollPayrollReceipts + 'Payroll.PrintChecksBanner': Translations.PayrollPrintChecksBanner 'Payroll.PrintChecksFailure': Translations.PayrollPrintChecksFailure 'Payroll.PrintChecksSummary': Translations.PayrollPrintChecksSummary 'Payroll.RecoveryCasesList': Translations.PayrollRecoveryCasesList @@ -7667,6 +7668,17 @@ export namespace Translations { totalEmployees_other: string } } + /** Translation keys for the `Payroll.PrintChecksBanner` i18n namespace. */ + export interface PayrollPrintChecksBanner { + /** @defaultValue `"You noted {{count}} employee that should be paid by check."` */ + title_one: string + /** @defaultValue `"You noted {{count}} employees that should be paid by check."` */ + title_other: string + /** @defaultValue `"Employees with this payment method will need their checks delivered to them. If you aren't using your own checks, you can view and print checks."` */ + description: string + /** @defaultValue `"View and print checks"` */ + cta: string + } /** Translation keys for the `Payroll.PrintChecksFailure` i18n namespace. */ export interface PayrollPrintChecksFailure { /** @defaultValue `"We couldn't generate your checks"` */ From 61fb2a2abe93389d87e5cf4892be05b5fc4b598b Mon Sep 17 00:00:00 2001 From: Kristine White Date: Tue, 18 Aug 2026 15:20:21 -0700 Subject: [PATCH 3/6] feat(SDK-1161): add PrintChecksForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the print-checks feature stack (see #2584). The core piece: the check-stock form, the generate-and-poll cycle against usePayrollsGeneratePrintableChecksMutation/useGeneratedDocumentsGet, and the checks-download mechanism. The download navigates directly to the generated document's signed URL via a synthetic anchor click rather than fetching it into a blob: the URL already has response-content-disposition=attachment baked into its query string, so a direct browser-level request downloads it via that response header. A client-side fetch() would be subject to CORS, which the document host doesn't allow, and fails outright. Defines a narrow PrintChecksFormFlowContext (just the isGenerating flag its Footer needs from the shared machine context) rather than depending on the orchestrator's full context type, which doesn't land until the next PR in the stack — keeps this PR buildable and testable on its own. Stacked on kw/feat/print-checks-2-banner; targets that branch, not main. Co-Authored-By: Claude Sonnet 5 --- .../PrintChecksForm.stories.tsx | 33 +++ .../PrintChecksForm/PrintChecksForm.test.tsx | 280 ++++++++++++++++++ .../PrintChecksForm/PrintChecksForm.tsx | 238 +++++++++++++++ .../PrintChecks/PrintChecksForm/index.ts | 1 + src/i18n/en/Payroll.PrintChecksForm.json | 15 + src/i18n/types.d.ts | 28 ++ .../mocks/apis/printable_payroll_checks.ts | 50 ++++ src/test/mocks/handlers.ts | 2 + 8 files changed, 647 insertions(+) create mode 100644 src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.stories.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.test.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksForm/index.ts create mode 100644 src/i18n/en/Payroll.PrintChecksForm.json create mode 100644 src/test/mocks/apis/printable_payroll_checks.ts diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.stories.tsx b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.stories.tsx new file mode 100644 index 000000000..d4fc3c831 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.stories.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react' +import { fn } from 'storybook/test' +import { PrintChecksForm } from './PrintChecksForm' +import { GustoTestProvider } from '@/test/GustoTestApiProvider' +import { FlowContext } from '@/components/Flow/useFlow' +import { useI18n } from '@/i18n' + +function I18nLoader({ children }: { children: React.ReactNode }) { + useI18n('Payroll.PrintChecksForm') + return <>{children} +} + +export default { + title: 'Domain/Payroll/PrintChecksForm', + decorators: [ + (Story: React.ComponentType) => ( + Loading translations...}> + + + + + + + ), + ], +} + +export const Default = () => ( + + + + +) diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.test.tsx b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.test.tsx new file mode 100644 index 000000000..2fde39bdc --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.test.tsx @@ -0,0 +1,280 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse, type HttpResponseResolver } from 'msw' +import { PrintChecksForm } from './PrintChecksForm' +import { + handlePayrollsGeneratePrintableChecks, + handleGeneratedDocumentsGet, + createPayrollCheck, + createGeneratedDocument, +} from '@/test/mocks/apis/printable_payroll_checks' +import { server } from '@/test/mocks/server' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { printChecksEvents } from '@/shared/constants' +import { FlowContext } from '@/components/Flow/useFlow' + +describe('PrintChecksForm', () => { + const onEvent = vi.fn() + const user = userEvent.setup() + let anchorClickSpy: ReturnType + + const flowContextValue = { + component: null, + onEvent, + } + + const renderForm = () => + renderWithProviders( + + + + , + ) + + beforeEach(() => { + vi.clearAllMocks() + // The download is triggered via a synthetic anchor click rather than a real navigation — + // jsdom throws "Not implemented: navigation" if this isn't stubbed. + anchorClickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + it('omits starting_check_number from the request body for custom check stock', async () => { + let capturedBody: Record | null = null + const generateResolver = vi.fn(async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json(createPayrollCheck(), { status: 200 }) + }) + server.use(handlePayrollsGeneratePrintableChecks(generateResolver)) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument({ status: 'pending' })), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(generateResolver).toHaveBeenCalledTimes(1) + }) + expect(capturedBody).toEqual({ printing_format: 'top' }) + }) + + it('includes starting_check_number in the request body for blank check stock', async () => { + let capturedBody: Record | null = null + const generateResolver = vi.fn(async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json(createPayrollCheck(), { status: 200 }) + }) + server.use(handlePayrollsGeneratePrintableChecks(generateResolver)) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument({ status: 'pending' })), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('radio', { name: 'Blank check stock' })) + const input = screen.getByLabelText(/Check number starts with/) + await user.clear(input) + await user.type(input, '1001') + await user.click(screen.getByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(generateResolver).toHaveBeenCalledTimes(1) + }) + expect(capturedBody).toEqual({ printing_format: 'bottom', starting_check_number: 1001 }) + }) + + it('includes a starting_check_number of 0 in the request body for blank check stock', async () => { + let capturedBody: Record | null = null + const generateResolver = vi.fn(async ({ request }) => { + capturedBody = (await request.json()) as Record + return HttpResponse.json(createPayrollCheck(), { status: 200 }) + }) + server.use(handlePayrollsGeneratePrintableChecks(generateResolver)) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument({ status: 'pending' })), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('radio', { name: 'Blank check stock' })) + await user.click(screen.getByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(generateResolver).toHaveBeenCalledTimes(1) + }) + expect(capturedBody).toEqual({ printing_format: 'bottom', starting_check_number: 0 }) + }) + + it('calls the generate endpoint before the poll endpoint', async () => { + const generateResolver = vi.fn(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ) + const getDocumentResolver = vi.fn(() => + HttpResponse.json(createGeneratedDocument({ status: 'succeeded' })), + ) + server.use(handlePayrollsGeneratePrintableChecks(generateResolver)) + server.use(handleGeneratedDocumentsGet(getDocumentResolver)) + + renderForm() + + await user.click(await screen.findByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(getDocumentResolver).toHaveBeenCalled() + }) + expect(generateResolver.mock.invocationCallOrder[0]!).toBeLessThan( + getDocumentResolver.mock.invocationCallOrder[0]!, + ) + }) + + it('fires PRINT_CHECKS_GENERATE_START on submit', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument({ status: 'pending' })), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('button', { name: 'View checks' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_START) + }) + + it('fires PRINT_CHECKS_GENERATE_SUCCEEDED with the document URL once the poll succeeds', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json( + createGeneratedDocument({ + status: 'succeeded', + document_urls: ['https://example.com/checks.pdf'], + }), + ), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED, { + documentUrl: 'https://example.com/checks.pdf', + }) + }) + await waitFor(() => { + expect(anchorClickSpy).toHaveBeenCalledTimes(1) + }) + expect(anchorClickSpy.mock.contexts[0]).toHaveProperty('href', 'https://example.com/checks.pdf') + }) + + it('does not open any window or navigate anywhere when generation succeeds', async () => { + const openSpy = vi.spyOn(window, 'open') + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json( + createGeneratedDocument({ + status: 'succeeded', + document_urls: ['https://example.com/checks.pdf'], + }), + ), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(anchorClickSpy).toHaveBeenCalledTimes(1) + }) + expect(openSpy).not.toHaveBeenCalled() + }) + + it('fires PRINT_CHECKS_GENERATE_FAILED when the poll reports failure', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument({ status: 'failed', document_urls: [] })), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, { + errorMessage: null, + }) + }) + }) + + it('fires PRINT_CHECKS_GENERATE_FAILED with the server error message when the mutation itself is rejected', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json( + { + errors: [ + { + error_key: 'invalid_action', + category: 'invalid_operation', + message: 'Cannot generate checks on an unprocessed payroll', + }, + ], + }, + { status: 422 }, + ), + ), + ) + + renderForm() + + await user.click(await screen.findByRole('button', { name: 'View checks' })) + + await waitFor(() => { + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, { + errorMessage: 'Cannot generate checks on an unprocessed payroll', + }) + }) + expect(onEvent).not.toHaveBeenCalledWith( + printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED, + expect.anything(), + ) + }) + + it('fires PRINT_CHECKS_CANCEL when the Footer cancel button is clicked', async () => { + renderForm() + + await user.click(await screen.findByRole('button', { name: 'Cancel' })) + + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_CANCEL) + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx new file mode 100644 index 000000000..e4b24eec0 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksForm/PrintChecksForm.tsx @@ -0,0 +1,238 @@ +import { useEffect, useState } from 'react' +import { FormProvider, useForm, useWatch } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useTranslation } from 'react-i18next' +import { z } from 'zod' +import { usePayrollsGeneratePrintableChecksMutation } from '@gusto/embedded-api/react-query/payrollsGeneratePrintableChecks' +import { useGeneratedDocumentsGet } from '@gusto/embedded-api/react-query/generatedDocumentsGet' +import { + PrintingFormat, + type PrintablePayrollChecksBody, +} from '@gusto/embedded-api/models/components/printablepayrollchecksbody' +import { DocumentType } from '@gusto/embedded-api/models/operations/getv1generateddocumentsdocumenttyperequestuuid' +import { GeneratedDocumentStatus } from '@gusto/embedded-api/models/components/generateddocument' +import { BaseComponent, useBase, type BaseComponentInterface } from '@/components/Base' +import type { OnEventType } from '@/components/Base/useBase' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { useComponentDictionary, useI18n } from '@/i18n' +import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow' +import { ActionsLayout, Flex, NumberInputField, RadioGroupField } from '@/components/Common' +import { Form } from '@/components/Common/Form' +import { printChecksEvents, type EventType } from '@/shared/constants' +import type { RadioGroupOption } from '@/index' + +interface PrintChecksFormProps extends BaseComponentInterface<'Payroll.PrintChecksForm'> { + payrollId: string + /** Whether a generate-and-poll cycle is in flight; disables the fields while true. */ + isGenerating?: boolean +} + +// The Footer is rendered by the top-level orchestrator as a sibling of this component (not a +// child), so it can only read shared state — like whether a generate-and-poll cycle is in +// flight — via the machine's FlowContext rather than as a prop. This narrow interface describes +// only the field Footer needs, rather than depending on the orchestrator's full context type. +interface PrintChecksFormFlowContext extends FlowContextInterface { + isGenerating?: boolean +} + +const PrintChecksFormSchema = z.object({ + printingFormat: z.nativeEnum(PrintingFormat), + startingCheckNumber: z + .number({ message: 'invalidStartingCheckNumber' }) + .int({ message: 'invalidStartingCheckNumber' }) + .nonnegative({ message: 'invalidStartingCheckNumber' }) + .lt(10_000_000_000, { message: 'invalidStartingCheckNumber' }) + .optional(), +}) + +type PrintChecksFormValues = z.infer + +const PRINT_CHECKS_FORM_ID = 'gusto-sdk-print-checks-form' + +const isErrorList = (val: unknown): val is { message?: string }[] => + Array.isArray(val) && val.every(entry => typeof entry === 'object' && entry !== null) + +const extractErrorMessage = (err: unknown): string | null => { + if (err && typeof err === 'object' && 'errors' in err && isErrorList(err.errors)) { + const [firstError] = err.errors + if (firstError?.message) return firstError.message + } + return err instanceof Error ? err.message : null +} + +const buildRequestBody = (data: PrintChecksFormValues): PrintablePayrollChecksBody => ({ + printingFormat: data.printingFormat, + ...(data.printingFormat === PrintingFormat.Bottom && data.startingCheckNumber !== undefined + ? { startingCheckNumber: data.startingCheckNumber } + : {}), +}) + +// Navigates directly to `url` via a synthetic anchor click rather than `fetch`-ing it into a blob: +// the generated-document URL is a signed, cross-origin S3 URL with `response-content-disposition: +// attachment` baked into its query string, so a direct browser-level request downloads it via that +// response header — but a `fetch()` from JS is subject to CORS, which the bucket doesn't allow, and +// fails outright. A plain navigation isn't subject to CORS and never opens a new tab or navigates +// the host page, since the browser intercepts the download instead of rendering a response. +const downloadGeneratedChecks = (url: string) => { + const link = document.createElement('a') + link.href = url + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +/** @internal */ +export function PrintChecksForm(props: PrintChecksFormProps) { + return ( + + {props.children} + + ) +} + +const Root = ({ dictionary, payrollId, isGenerating }: PrintChecksFormProps) => { + useComponentDictionary('Payroll.PrintChecksForm', dictionary) + useI18n('Payroll.PrintChecksForm') + const { t } = useTranslation('Payroll.PrintChecksForm') + const { onEvent, baseSubmitHandler } = useBase() + const [requestUuid, setRequestUuid] = useState(null) + const [isPolling, setIsPolling] = useState(false) + + const formMethods = useForm({ + resolver: zodResolver(PrintChecksFormSchema), + defaultValues: { printingFormat: PrintingFormat.Top, startingCheckNumber: 0 }, + }) + + const printingFormat = useWatch({ + name: 'printingFormat', + control: formMethods.control, + }) + + const { mutateAsync } = usePayrollsGeneratePrintableChecksMutation() + + const { data } = useGeneratedDocumentsGet( + { + documentType: DocumentType.PrintablePayrollChecks, + requestUuid: requestUuid || '', + }, + { + enabled: !!requestUuid, + refetchInterval: isPolling ? 5_000 : false, + }, + ) + + useEffect(() => { + const status = data?.generatedDocument?.status + if (!isPolling || !status) return + + if (status === GeneratedDocumentStatus.Succeeded) { + setIsPolling(false) + const url = data.generatedDocument?.documentUrls?.[0] ?? null + onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED, { documentUrl: url }) + if (url) { + downloadGeneratedChecks(url) + } + } else if (status === GeneratedDocumentStatus.Failed) { + setIsPolling(false) + onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, { errorMessage: null }) + } + }, [data, isPolling, onEvent]) + + const onSubmit = async (formData: PrintChecksFormValues) => { + onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_START) + + await baseSubmitHandler(formData, async submittedData => { + try { + const result = await mutateAsync({ + request: { + payrollUuid: payrollId, + printablePayrollChecksBody: buildRequestBody(submittedData), + }, + }) + + const nextRequestUuid = result.payrollCheck?.requestUuid + if (!nextRequestUuid) { + throw new Error('Missing requestUuid in generate-printable-checks response') + } + + setRequestUuid(nextRequestUuid) + setIsPolling(true) + } catch (err) { + onEvent(printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, { + errorMessage: extractErrorMessage(err), + }) + throw err + } + }) + } + + const startingCheckNumberErrorCode = formMethods.formState.errors.startingCheckNumber?.message + const startingCheckNumberErrorMessage = startingCheckNumberErrorCode + ? t('validations.startingCheckNumber') + : undefined + + const printingFormatOptions: RadioGroupOption[] = [ + { + value: PrintingFormat.Top, + label: t('customStockLabel'), + description: t('customStockDescription'), + }, + { + value: PrintingFormat.Bottom, + label: t('blankStockLabel'), + description: t('blankStockDescription'), + }, + ] + + return ( + +
+ + + {printingFormat === PrintingFormat.Bottom && ( + + )} + +
+
+ ) +} + +const Footer = ({ onEvent }: { onEvent: OnEventType }) => { + useI18n('Payroll.PrintChecksForm') + const { t } = useTranslation('Payroll.PrintChecksForm') + const { Button } = useComponentContext() + const { isGenerating } = useFlow() + + return ( + + + + + ) +} +PrintChecksForm.Footer = Footer diff --git a/src/components/Payroll/PrintChecks/PrintChecksForm/index.ts b/src/components/Payroll/PrintChecks/PrintChecksForm/index.ts new file mode 100644 index 000000000..8cc0912dd --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksForm/index.ts @@ -0,0 +1 @@ +export { PrintChecksForm } from './PrintChecksForm' diff --git a/src/i18n/en/Payroll.PrintChecksForm.json b/src/i18n/en/Payroll.PrintChecksForm.json new file mode 100644 index 000000000..b894ba348 --- /dev/null +++ b/src/i18n/en/Payroll.PrintChecksForm.json @@ -0,0 +1,15 @@ +{ + "modalTitle": "Choose check stock", + "customStockLabel": "Custom check stock", + "customStockDescription": "Use this check stock if you have check stock that is pre-printed with your company and bank information. The physical check will appear on the top of the check PDF. Check numbers should already be pre-printed on the check stock you purchased.", + "blankStockLabel": "Blank check stock", + "blankStockDescription": "Use this check stock if you have blank check stock and need us to populate your company and bank information. The physical check will always be on the bottom of the check PDF.", + "startingCheckNumberLabel": "Check number starts with", + "startingCheckNumberDescription": "This will be the first check number, all other checks will follow sequentially.", + "cancelCta": "Cancel", + "submitCta": "View checks", + "submitCtaLoading": "Generating...", + "validations": { + "startingCheckNumber": "Enter a valid check number" + } +} diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index cfc273e6e..5b60db09e 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -129,6 +129,7 @@ export interface Resources { 'Payroll.PayrollReceipts': Translations.PayrollPayrollReceipts 'Payroll.PrintChecksBanner': Translations.PayrollPrintChecksBanner 'Payroll.PrintChecksFailure': Translations.PayrollPrintChecksFailure + 'Payroll.PrintChecksForm': Translations.PayrollPrintChecksForm 'Payroll.PrintChecksSummary': Translations.PayrollPrintChecksSummary 'Payroll.RecoveryCasesList': Translations.PayrollRecoveryCasesList 'Payroll.RecoveryCasesResubmit': Translations.PayrollRecoveryCasesResubmit @@ -7688,6 +7689,33 @@ export namespace Translations { /** @defaultValue `"Close"` */ closeCta: string } + /** Translation keys for the `Payroll.PrintChecksForm` i18n namespace. */ + export interface PayrollPrintChecksForm { + /** @defaultValue `"Choose check stock"` */ + modalTitle: string + /** @defaultValue `"Custom check stock"` */ + customStockLabel: string + /** @defaultValue `"Use this check stock if you have check stock that is pre-printed with your company and bank information. The physical check will appear on the top of the check PDF. Check numbers should already be pre-printed on the check stock you purchased."` */ + customStockDescription: string + /** @defaultValue `"Blank check stock"` */ + blankStockLabel: string + /** @defaultValue `"Use this check stock if you have blank check stock and need us to populate your company and bank information. The physical check will always be on the bottom of the check PDF."` */ + blankStockDescription: string + /** @defaultValue `"Check number starts with"` */ + startingCheckNumberLabel: string + /** @defaultValue `"This will be the first check number, all other checks will follow sequentially."` */ + startingCheckNumberDescription: string + /** @defaultValue `"Cancel"` */ + cancelCta: string + /** @defaultValue `"View checks"` */ + submitCta: string + /** @defaultValue `"Generating..."` */ + submitCtaLoading: string + validations: { + /** @defaultValue `"Enter a valid check number"` */ + startingCheckNumber: string + } + } /** Translation keys for the `Payroll.PrintChecksSummary` i18n namespace. */ export interface PayrollPrintChecksSummary { /** @defaultValue `"Your checks are ready"` */ diff --git a/src/test/mocks/apis/printable_payroll_checks.ts b/src/test/mocks/apis/printable_payroll_checks.ts new file mode 100644 index 000000000..cf3ae3b05 --- /dev/null +++ b/src/test/mocks/apis/printable_payroll_checks.ts @@ -0,0 +1,50 @@ +import type { HttpResponseResolver, PathParams } from 'msw' +import { http, HttpResponse } from 'msw' +import type { PostV1PayrollsPayrollUuidGeneratedDocumentsPrintablePayrollChecksRequest } from '@gusto/embedded-api/models/operations/postv1payrollspayrolluuidgenerateddocumentsprintablepayrollchecks' +import type { GetV1GeneratedDocumentsDocumentTypeRequestUuidRequest } from '@gusto/embedded-api/models/operations/getv1generateddocumentsdocumenttyperequestuuid' +import { API_BASE_URL } from '@/test/constants' + +export const createPayrollCheck = (overrides: Record = {}) => ({ + payroll_uuid: 'payroll-1', + printing_format: 'top', + starting_check_number: null, + request_uuid: 'print-checks-request-1', + status: 'pending', + employee_check_number_mapping: [{ employee_uuid: 'employee-1', check_number: 1001 }], + ...overrides, +}) + +export const createGeneratedDocument = (overrides: Record = {}) => ({ + request_uuid: 'print-checks-request-1', + status: 'succeeded', + document_urls: ['https://example.com/checks.pdf'], + ...overrides, +}) + +export function handlePayrollsGeneratePrintableChecks( + resolver: HttpResponseResolver< + PathParams, + PostV1PayrollsPayrollUuidGeneratedDocumentsPrintablePayrollChecksRequest + >, +) { + return http.post( + `${API_BASE_URL}/v1/payrolls/:payroll_uuid/generated_documents/printable_payroll_checks`, + resolver, + ) +} + +export function handleGeneratedDocumentsGet( + resolver: HttpResponseResolver, +) { + return http.get(`${API_BASE_URL}/v1/generated_documents/:document_type/:request_uuid`, resolver) +} + +export const generatePrintableChecks = handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), +) + +export const getGeneratedDocument = handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument()), +) + +export default [generatePrintableChecks, getGeneratedDocument] diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts index b61b3c6fc..47351757d 100644 --- a/src/test/mocks/handlers.ts +++ b/src/test/mocks/handlers.ts @@ -23,6 +23,7 @@ import ContractorHandlers from './apis/contractors' import ContractorDocumentsHandlers from './apis/contractor_documents' import ContractorPaymentGroupsHandlers from './apis/contractor_payment_groups' import WireInRequestsHandlers from './apis/wire_in_requests' +import PrintablePayrollChecksHandlers from './apis/printable_payroll_checks' import InformationRequestsHandlers from './apis/information_requests' import I9AuthorizationHandlers from './apis/i9_authorization' import EmployeeFormHandlers from './apis/employee_forms' @@ -74,6 +75,7 @@ export const handlers = [ ...ContractorDocumentsHandlers, ...ContractorPaymentGroupsHandlers, ...WireInRequestsHandlers, + ...PrintablePayrollChecksHandlers, ...InformationRequestsHandlers, ...I9AuthorizationHandlers, ...EmployeeFormHandlers, From 43c09dbe643dc40b3a6a790ca40a54a9a805d724 Mon Sep 17 00:00:00 2001 From: Kristine White Date: Tue, 18 Aug 2026 15:31:24 -0700 Subject: [PATCH 4/6] feat(SDK-1161): add PrintChecks state machine and orchestrator Fourth slice of the print-checks feature stack (see #2584). Wires PrintChecksBanner/Form/Failure/Summary together into the public Payroll.PrintChecks component via a robot3 state machine, following the same pattern as ConfirmWireDetails/RecoveryCases: banner -> form -> summary/failure, unmounting each screen on transition instead of manually resetting form state. No inert final() state, unlike the two reference machines that define one but never transition to it -- per src/CLAUDE.md, hub-and-spoke components (no natural "end", user can reopen the flow freely) shouldn't model completion as a final state. Exports PrintChecks from the Payroll namespace and regenerates the SDK Dev App's component-props registry so it picks up companyId/payrollId as auto-provisioned entity IDs. Stacked on kw/feat/print-checks-3-form; targets that branch, not main. Co-Authored-By: Claude Sonnet 5 --- sdk-app/src/generated-registry-data.ts | 1 + .../Payroll/PrintChecks/PrintChecks.test.tsx | 123 ++++++++++++++++ .../Payroll/PrintChecks/PrintChecks.tsx | 106 ++++++++++++++ .../PrintChecks/PrintChecksComponents.tsx | 61 ++++++++ src/components/Payroll/PrintChecks/index.ts | 2 + .../PrintChecks/printChecksStateMachine.tsx | 135 ++++++++++++++++++ src/components/Payroll/PrintChecks/types.ts | 15 ++ src/components/Payroll/index.ts | 2 + 8 files changed, 445 insertions(+) create mode 100644 src/components/Payroll/PrintChecks/PrintChecks.test.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecks.tsx create mode 100644 src/components/Payroll/PrintChecks/PrintChecksComponents.tsx create mode 100644 src/components/Payroll/PrintChecks/index.ts create mode 100644 src/components/Payroll/PrintChecks/printChecksStateMachine.tsx create mode 100644 src/components/Payroll/PrintChecks/types.ts diff --git a/sdk-app/src/generated-registry-data.ts b/sdk-app/src/generated-registry-data.ts index c914208d0..00dc1a84e 100644 --- a/sdk-app/src/generated-registry-data.ts +++ b/sdk-app/src/generated-registry-data.ts @@ -139,6 +139,7 @@ export const ENTITY_REQUIREMENTS: Record = { 'Payroll.PayrollList': ['companyId'], 'Payroll.PayrollOverview': ['companyId', 'payrollId'], 'Payroll.PayrollReceipts': ['payrollId'], + 'Payroll.PrintChecks': ['companyId', 'payrollId'], 'Payroll.RecoveryCases': ['companyId'], 'Payroll.TransitionCreation': ['companyId'], 'Payroll.TransitionFlow': ['companyId'], diff --git a/src/components/Payroll/PrintChecks/PrintChecks.test.tsx b/src/components/Payroll/PrintChecks/PrintChecks.test.tsx new file mode 100644 index 000000000..4ec1acae0 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecks.test.tsx @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { PrintChecks } from './PrintChecks' +import { + handlePayrollsGeneratePrintableChecks, + handleGeneratedDocumentsGet, + createPayrollCheck, + createGeneratedDocument, +} from '@/test/mocks/apis/printable_payroll_checks' +import { server } from '@/test/mocks/server' +import { renderWithProviders } from '@/test-utils/renderWithProviders' +import { printChecksEvents } from '@/shared/constants' + +const checkCompensation = { + employeeUuid: 'emp-check-1', + excluded: false, + paymentMethod: 'Check', +} + +vi.mock('@gusto/embedded-api/react-query/payrollsGet', () => ({ + usePayrollsGet: () => ({ + data: { + payrollShow: { + processed: true, + employeeCompensations: [checkCompensation], + }, + }, + }), +})) + +describe('PrintChecks', () => { + const onEvent = vi.fn() + const user = userEvent.setup() + + const defaultProps = { + companyId: 'company-1', + payrollId: 'payroll-1', + onEvent, + } + + beforeEach(() => { + vi.clearAllMocks() + // The download is triggered via a synthetic anchor click rather than a real navigation — + // jsdom throws "Not implemented: navigation" if this isn't stubbed. + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + it('opens the form from the banner CTA and cancels back to the banner', async () => { + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + + expect(await screen.findByText('Choose check stock')).toBeInTheDocument() + expect(onEvent).toHaveBeenCalledWith(printChecksEvents.PRINT_CHECKS_START, undefined) + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByText('Choose check stock')).toBeNull() + }) + }) + + it('walks through a successful generate flow to the summary screen and back', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json( + createGeneratedDocument({ + status: 'succeeded', + document_urls: ['https://example.com/checks.pdf'], + }), + ), + ), + ) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + await user.click(screen.getByRole('button', { name: 'View checks' })) + + expect(await screen.findByText('Your checks are ready')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'View checks' })).toHaveAttribute( + 'href', + 'https://example.com/checks.pdf', + ) + + await user.click(screen.getByRole('button', { name: 'Close' })) + + await waitFor(() => { + expect(screen.queryByText('Your checks are ready')).toBeNull() + }) + }) + + it('walks through a failed generate flow and allows retrying', async () => { + server.use( + handlePayrollsGeneratePrintableChecks(() => + HttpResponse.json(createPayrollCheck(), { status: 200 }), + ), + ) + server.use( + handleGeneratedDocumentsGet(() => + HttpResponse.json(createGeneratedDocument({ status: 'failed', document_urls: [] })), + ), + ) + + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + await user.click(screen.getByRole('button', { name: 'View checks' })) + + expect(await screen.findByText("We couldn't generate your checks")).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Try again' })) + + expect(await screen.findByText('Choose check stock')).toBeInTheDocument() + }) +}) diff --git a/src/components/Payroll/PrintChecks/PrintChecks.tsx b/src/components/Payroll/PrintChecks/PrintChecks.tsx new file mode 100644 index 000000000..ac39353d1 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecks.tsx @@ -0,0 +1,106 @@ +import { createMachine } from 'robot3' +import { useMachine } from 'react-robot' +import { useMemo, useState } from 'react' +import { PrintChecksBanner } from './PrintChecksBanner' +import { printChecksMachine } from './printChecksStateMachine' +import { type PrintChecksContextInterface } from './PrintChecksComponents' +import type { PrintChecksProps } from './types' +import { BaseComponent } from '@/components/Base' +import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' +import { FlowContext } from '@/components/Flow/useFlow' +import { printChecksEvents, type EventType } from '@/shared/constants' + +/** + * Displays a banner prompting the user to print checks for employees paid by check on a + * processed payroll, and walks them through choosing check stock and generating the check PDF. + * + * @events + * | Event | Description | Data | + * | ----- | ----------- | ---- | + * | `payroll/printChecks/start` | User opened the print-checks modal from the banner | — | + * | `payroll/printChecks/generate/start` | User submitted the print-checks form | — | + * | `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | + * | `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | + * | `payroll/printChecks/retry` | User retried after a failed generation | — | + * | `payroll/printChecks/cancel` | User cancelled the print-checks form | — | + * | `payroll/printChecks/close` | User closed the failure or summary screen | — | + * + * @param props - {@link PrintChecksProps} + * @returns The print-checks banner and modal flow. + * @public + */ +export function PrintChecks({ onEvent = () => {}, ...props }: PrintChecksProps) { + return ( + + + + ) +} + +function Root({ companyId, payrollId, onEvent = () => {} }: PrintChecksProps) { + const { Modal } = useComponentContext() + const [isModalOpen, setIsModalOpen] = useState(false) + + const printChecksMachineInstance = useMemo( + () => + createMachine('banner', printChecksMachine, (): PrintChecksContextInterface => ({ + component: null, + companyId, + payrollId, + onEvent: handleEvent, + })), + [companyId, payrollId], + ) + const [current, send] = useMachine(printChecksMachineInstance) + + function handleEvent(type: EventType, data?: unknown): void { + send({ type, payload: data }) + + if (type === printChecksEvents.PRINT_CHECKS_START) { + setIsModalOpen(true) + } + + if ( + type === printChecksEvents.PRINT_CHECKS_CANCEL || + type === printChecksEvents.PRINT_CHECKS_CLOSE + ) { + setIsModalOpen(false) + } + + onEvent(type, data) + } + + const handleStartPrintChecks = () => { + handleEvent(printChecksEvents.PRINT_CHECKS_START) + } + + const handleCloseModal = () => { + setIsModalOpen(false) + } + + const CurrentComponent = current.context.component + const Footer = CurrentComponent?.Footer || undefined + + return ( + + + } + > + {CurrentComponent && } + + + ) +} diff --git a/src/components/Payroll/PrintChecks/PrintChecksComponents.tsx b/src/components/Payroll/PrintChecks/PrintChecksComponents.tsx new file mode 100644 index 000000000..e3c4e2b74 --- /dev/null +++ b/src/components/Payroll/PrintChecks/PrintChecksComponents.tsx @@ -0,0 +1,61 @@ +import { PrintChecksForm } from './PrintChecksForm' +import { PrintChecksFailure } from './PrintChecksFailure' +import { PrintChecksSummary } from './PrintChecksSummary' +import { useFlow } from '@/components/Flow/useFlow' +import type { FlowContextInterface } from '@/components/Flow/useFlow' +import type { CommonComponentInterface } from '@/components/Base' +import type { EventType } from '@/types/Helpers' +import type { OnEventType } from '@/components/Base/useBase' + +/** + * Flow-machine context shared across the print-checks states. + * + * @internal + */ +export interface PrintChecksContextInterface extends FlowContextInterface { + /** Company identifier. */ + companyId: string + /** Payroll being printed. */ + payrollId: string + /** URL of the generated checks PDF, once generation succeeds. */ + documentUrl?: string + /** Error message surfaced on the failure screen. */ + errorMessage?: string + /** Whether a generate-and-poll cycle is in flight, shared between the form body and its footer. */ + isGenerating?: boolean + /** Component to render inside the modal for the current state, with an optional footer slot. */ + component: + | (React.ComponentType & { + Footer?: React.ComponentType<{ + onEvent: OnEventType + }> + }) + | null +} + +/** @internal */ +export function PrintChecksFormContextual() { + const { payrollId, isGenerating, onEvent } = useFlow() + + return +} + +PrintChecksFormContextual.Footer = PrintChecksForm.Footer + +/** @internal */ +export function PrintChecksFailureContextual() { + const { errorMessage, onEvent } = useFlow() + + return +} + +PrintChecksFailureContextual.Footer = PrintChecksFailure.Footer + +/** @internal */ +export function PrintChecksSummaryContextual() { + const { documentUrl, onEvent } = useFlow() + + return +} + +PrintChecksSummaryContextual.Footer = PrintChecksSummary.Footer diff --git a/src/components/Payroll/PrintChecks/index.ts b/src/components/Payroll/PrintChecks/index.ts new file mode 100644 index 000000000..4bc806098 --- /dev/null +++ b/src/components/Payroll/PrintChecks/index.ts @@ -0,0 +1,2 @@ +export { PrintChecks } from './PrintChecks' +export type { PrintChecksProps } from './types' diff --git a/src/components/Payroll/PrintChecks/printChecksStateMachine.tsx b/src/components/Payroll/PrintChecks/printChecksStateMachine.tsx new file mode 100644 index 000000000..612451835 --- /dev/null +++ b/src/components/Payroll/PrintChecks/printChecksStateMachine.tsx @@ -0,0 +1,135 @@ +import { state, transition, reduce } from 'robot3' +import type { PrintChecksContextInterface } from './PrintChecksComponents' +import { + PrintChecksFormContextual, + PrintChecksFailureContextual, + PrintChecksSummaryContextual, +} from './PrintChecksComponents' +import { printChecksEvents } from '@/shared/constants' +import type { MachineEventType, MachineTransition } from '@/types/Helpers' + +/** + * Payload shapes for each print-checks state machine event. + * + * @internal + */ +export type EventPayloads = { + /** Banner CTA pressed to begin the print-checks flow. */ + [printChecksEvents.PRINT_CHECKS_START]: undefined + /** Form submitted; a generate-and-poll cycle has started. */ + [printChecksEvents.PRINT_CHECKS_GENERATE_START]: undefined + /** Generation succeeded; carries the generated checks document URL. */ + [printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED]: { + documentUrl: string | null + } + /** Generation failed; carries the error message to surface. */ + [printChecksEvents.PRINT_CHECKS_GENERATE_FAILED]: { + errorMessage: string | null + } + /** User retried after a failure. */ + [printChecksEvents.PRINT_CHECKS_RETRY]: undefined + /** Form cancelled. */ + [printChecksEvents.PRINT_CHECKS_CANCEL]: undefined + /** Failure or summary screen closed. */ + [printChecksEvents.PRINT_CHECKS_CLOSE]: undefined +} + +/** @internal */ +export const printChecksMachine = { + banner: state( + transition( + printChecksEvents.PRINT_CHECKS_START, + 'form', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksFormContextual, + })), + ), + ), + form: state( + transition( + printChecksEvents.PRINT_CHECKS_GENERATE_START, + 'form', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + isGenerating: true, + })), + ), + transition( + printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED, + 'summary', + reduce( + ( + ctx: PrintChecksContextInterface, + ev: MachineEventType< + EventPayloads, + typeof printChecksEvents.PRINT_CHECKS_GENERATE_SUCCEEDED + >, + ): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksSummaryContextual, + documentUrl: ev.payload.documentUrl ?? undefined, + isGenerating: false, + }), + ), + ), + transition( + printChecksEvents.PRINT_CHECKS_GENERATE_FAILED, + 'failure', + reduce( + ( + ctx: PrintChecksContextInterface, + ev: MachineEventType< + EventPayloads, + typeof printChecksEvents.PRINT_CHECKS_GENERATE_FAILED + >, + ): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksFailureContextual, + errorMessage: ev.payload.errorMessage ?? undefined, + isGenerating: false, + }), + ), + ), + transition( + printChecksEvents.PRINT_CHECKS_CANCEL, + 'banner', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: null, + isGenerating: false, + })), + ), + ), + failure: state( + transition( + printChecksEvents.PRINT_CHECKS_RETRY, + 'form', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: PrintChecksFormContextual, + errorMessage: undefined, + })), + ), + transition( + printChecksEvents.PRINT_CHECKS_CLOSE, + 'banner', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: null, + errorMessage: undefined, + })), + ), + ), + summary: state( + transition( + printChecksEvents.PRINT_CHECKS_CLOSE, + 'banner', + reduce((ctx: PrintChecksContextInterface): PrintChecksContextInterface => ({ + ...ctx, + component: null, + documentUrl: undefined, + })), + ), + ), +} diff --git a/src/components/Payroll/PrintChecks/types.ts b/src/components/Payroll/PrintChecks/types.ts new file mode 100644 index 000000000..9bc1cacbc --- /dev/null +++ b/src/components/Payroll/PrintChecks/types.ts @@ -0,0 +1,15 @@ +import type { BaseComponentInterface } from '@/components/Base' + +/** + * Props for {@link PrintChecks}. + * + * @public + */ +export interface PrintChecksProps extends Omit, 'onEvent'> { + /** Identifier of the company that owns the payroll. */ + companyId: string + /** Identifier of the payroll to generate printable checks for. */ + payrollId: string + /** Callback invoked each time the component emits an event. */ + onEvent?: BaseComponentInterface['onEvent'] +} diff --git a/src/components/Payroll/index.ts b/src/components/Payroll/index.ts index 37d306da1..06e79deec 100644 --- a/src/components/Payroll/index.ts +++ b/src/components/Payroll/index.ts @@ -38,6 +38,8 @@ export { PayrollBlockerList, type ApiPayrollBlocker } from './PayrollBlocker' export type { PayrollBlockerListProps } from './PayrollBlocker/components/PayrollBlockerList' export { RecoveryCases } from './RecoveryCases/RecoveryCases' export type { RecoveryCasesProps } from './RecoveryCases/RecoveryCases' +export { PrintChecks } from './PrintChecks/PrintChecks' +export type { PrintChecksProps } from './PrintChecks' export type { OffCyclePayPeriodDateFormData, OffCyclePayrollDateType, From 98dde24ae7406ce35bffb18e98b60fc2c531c146 Mon Sep 17 00:00:00 2001 From: Kristine White Date: Tue, 18 Aug 2026 15:35:14 -0700 Subject: [PATCH 5/6] feat(SDK-1161): wire PrintChecks into PayrollOverview Fifth and final slice of the print-checks feature stack (see #2584). The actual cutover: mounts Payroll.PrintChecks (from #2587) as its own banner in PayrollOverview, following the same pattern already used for the embedded ConfirmWireDetails component. Removes the isPrintChecksOpen state and onPrintChecksOpen prop that a bare modal mount would have needed, since PrintChecks now owns its own trigger banner and Modal lifecycle. Stacked on kw/feat/print-checks-4-orchestrator; targets that branch, not main. Once this and the rest of the stack merge, this replaces #2572 entirely. Co-Authored-By: Claude Sonnet 5 --- .../PayrollOverview.stories.tsx | 109 ++++++++++++++++++ .../PayrollOverview/PayrollOverview.test.tsx | 58 ++++++++++ .../PayrollOverview/PayrollOverview.tsx | 13 +++ .../PayrollOverviewPresentation.tsx | 18 +-- src/i18n/en/Payroll.PayrollOverview.json | 3 - 5 files changed, 183 insertions(+), 18 deletions(-) diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx index 20834d568..6391e6eea 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.stories.tsx @@ -1357,3 +1357,112 @@ export const WithSkippedEmployee = () => { /> ) } + +const checkPaymentPayrollData = { + payrollDeadline: new Date('2025-09-24T23:00:00.000Z'), + checkDate: '2025-09-26', + 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: '1000.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: [], + partnerOwnedDisbursement: false, + employeeCompensations: [ + { + employeeUuid: 'emp-check-1', + firstName: 'Isaiah', + lastName: 'Berlin', + excluded: false, + version: 'v1', + grossPay: '4000', + netPay: '3200', + checkAmount: '3200', + paymentMethod: 'Check' as const, + memo: null, + fixedCompensations: [], + hourlyCompensations: [ + { + name: 'Regular Hours', + hours: '40.000', + amount: '4000.0', + jobUuid: 'job-1', + compensationMultiplier: 1, + flsaStatus: 'Nonexempt', + }, + ], + paidTimeOff: [], + taxes: [ + { name: 'Federal Income Tax', employer: false, amount: '80' }, + { name: 'Federal Income Tax', employer: true, amount: '160' }, + ], + benefits: [], + deductions: [], + }, + ], +} + +export const WithCheckPaymentEmployeeUnprocessed = () => { + return ( + + ) +} + +export const WithCheckPaymentEmployeeProcessed = () => { + return ( + + ) +} diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx index 361fd9c7a..872b9a52a 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.test.tsx @@ -311,3 +311,61 @@ describe('PayrollOverview calculatedAt guard', () => { expect(screen.queryByText(/Review payroll/i)).toBeNull() }) }) + +describe('PayrollOverview print checks modal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPayrollData = { + ...basePayrollData, + employeeCompensations: [ + { + employeeUuid: 'emp-check-1', + firstName: 'Isaiah', + lastName: 'Berlin', + excluded: false, + version: 'v1', + grossPay: '4000', + netPay: '3200', + checkAmount: '3200', + paymentMethod: 'Check', + memo: null, + fixedCompensations: [], + hourlyCompensations: [], + paidTimeOff: [], + taxes: [], + benefits: [], + deductions: [], + }, + ], + } + mockIsFetching = false + }) + + it('hides the View and print checks button until the payroll is processed', async () => { + mockPayrollData = { ...mockPayrollData, processed: false } + + renderWithProviders( + , + ) + + expect(await screen.findByText(/noted 1 employee/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View and print checks' })).toBeNull() + }) + + it('opens the print checks modal from the alert once the payroll is processed', async () => { + const user = userEvent.setup() + mockPayrollData = { + ...mockPayrollData, + processed: true, + processingRequest: { status: 'submit_success', errors: [] }, + } + + renderWithProviders( + , + ) + + await user.click(await screen.findByRole('button', { name: 'View and print checks' })) + + expect(await screen.findByText('Choose check stock')).toBeInTheDocument() + }) +}) diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx index 2d679f12d..1bca84233 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx @@ -20,6 +20,7 @@ import { type ConfirmWireDetailsComponentType, } from '../ConfirmWireDetails/ConfirmWireDetails' import { canCancelPayroll } from '../helpers' +import { PrintChecks } from '../PrintChecks/PrintChecks' import { PayrollOverviewPresentation } from './PayrollOverviewPresentation' import { PayrollOverviewStatus } from './PayrollOverviewTypes' import { useCompanyPaymentSpeed } from '@/hooks/useCompanyPaymentSpeed' @@ -109,6 +110,13 @@ const findWireInRequestUuid = ( * | `runPayroll/receipt/get` | User requested the payroll receipt | `{ payrollId }` | * | `runPayroll/pdfPaystub/viewed` | User opened an employee's paystub PDF | `{ employeeId }` | * | `payroll/wire/form/done` | Wire-in details were confirmed via the embedded wire form | Submit wire-in response | + * | `payroll/printChecks/start` | User opened the print-checks modal from the embedded print-checks banner | — | + * | `payroll/printChecks/generate/start` | User submitted the print-checks form | — | + * | `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | + * | `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | + * | `payroll/printChecks/retry` | User retried after a failed check generation | — | + * | `payroll/printChecks/cancel` | User cancelled the print-checks form | — | + * | `payroll/printChecks/close` | User closed the print-checks failure or summary screen | — | * * @param props - See {@link PayrollOverviewProps}. * @returns The payroll overview surface. @@ -195,6 +203,10 @@ const Root = ({ /> ) + const printChecksBanner = ( + + ) + useEffect(() => { if (wireInRequest?.status === 'pending_review' && !showWireDetailsConfirmation) { setShowWireDetailsConfirmation(true) @@ -435,6 +447,7 @@ const Root = ({ setSelectedUnblockOptions(prev => ({ ...prev, [blockerType]: value })) }} wireInConfirmationRequest={wireInConfirmationRequest} + printChecksBanner={printChecksBanner} withReimbursements={withReimbursements} paymentSpeed={paymentSpeed} pagination={pagination} diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx index 2420743bf..29dfa7c42 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx @@ -32,7 +32,6 @@ import { compensationTypeLabels, FlsaStatus, PAYROLL_RESOLVABLE_SUBMISSION_BLOCKER_TYPES, - PAYMENT_METHODS, } from '@/shared/constants' import type { PaginationControlProps } from '@/components/Common/PaginationControl/PaginationControlTypes' import DownloadIcon from '@/assets/icons/download-cloud.svg?react' @@ -48,6 +47,7 @@ interface PayrollOverviewProps { submissionBlockers?: PayrollSubmissionBlockerType[] selectedUnblockOptions?: Record wireInConfirmationRequest?: React.ReactNode + printChecksBanner?: React.ReactNode pagination?: PaginationControlProps onEdit: () => void onSubmit: () => void @@ -87,6 +87,7 @@ export const PayrollOverviewPresentation = ({ selectedUnblockOptions = {}, onUnblockOptionChange, wireInConfirmationRequest, + printChecksBanner, withReimbursements = true, paymentSpeed, pagination, @@ -212,12 +213,6 @@ export const PayrollOverviewPresentation = ({ ) } - const checkPaymentsCount = - payrollData.employeeCompensations?.reduce( - (acc, comp) => - !comp.excluded && comp.paymentMethod === PAYMENT_METHODS.check ? acc + 1 : acc, - 0, - ) ?? 0 const companyPaysColumns: Array<{ key: string title: string @@ -780,14 +775,7 @@ export const PayrollOverviewPresentation = ({ data={[{}]} /> )} - {checkPaymentsCount > 0 && ( - - {t('alerts.checkPaymentWarningDescription')} - - )} + {printChecksBanner} Date: Tue, 18 Aug 2026 22:39:23 +0000 Subject: [PATCH 6/6] chore: update derived files --- .reports/embedded-react-sdk.api.md | 33 +++++++++++ docs/guides/endpoint-inventory.json | 26 +++++++++ docs/guides/endpoint-reference.md | 3 + docs/reference/APIModels/index.md | 83 ++++++++++++++++++++++++++++ docs/reference/Translations/index.md | 82 ++++++++++++++++++++++++++- docs/reference/blocks.md | 1 + docs/reference/events.md | 7 +++ docs/reference/index.mdx | 2 +- docs/reference/payroll/blocks.md | 56 +++++++++++++++++++ docs/reference/payroll/index.mdx | 2 +- docs/reference/payroll/namespace.md | 1 + src/i18n/types.d.ts | 6 -- src/models/external.ts | 3 + 13 files changed, 294 insertions(+), 11 deletions(-) diff --git a/.reports/embedded-react-sdk.api.md b/.reports/embedded-react-sdk.api.md index b054f4ce2..a4a9b5c99 100644 --- a/.reports/embedded-react-sdk.api.md +++ b/.reports/embedded-react-sdk.api.md @@ -129,6 +129,7 @@ import { FunctionComponent } from 'react'; import { Garnishment } from '@gusto/embedded-api/models/components/garnishment'; import { GarnishmentChildSupport } from '@gusto/embedded-api/models/components/garnishmentchildsupport'; import { GarnishmentType } from '@gusto/embedded-api/models/components/garnishment'; +import { GeneratedDocumentStatus } from '@gusto/embedded-api/models/components/generateddocument'; import { HolidayPayPolicy } from '@gusto/embedded-api/models/components/holidaypaypolicy'; import { HolidayPayPolicyEmployees } from '@gusto/embedded-api/models/components/holidaypaypolicy'; import { HourlyCompensations } from '@gusto/embedded-api/models/components/payrollemployeecompensationstype'; @@ -247,6 +248,8 @@ import { PayScheduleShow } from '@gusto/embedded-api/models/components/payschedu import { PlaidStatus } from '@gusto/embedded-api/models/components/companybankaccount'; import { PolicyType } from '@gusto/embedded-api/models/components/timeoffpolicy'; import { PresidentsDay } from '@gusto/embedded-api/models/components/holidaypaypolicy'; +import { PrintablePayrollChecksBody } from '@gusto/embedded-api/models/components/printablepayrollchecksbody'; +import { PrintingFormat } from '@gusto/embedded-api/models/components/printablepayrollchecksbody'; import { QueryClient } from '@tanstack/react-query'; import { Questions } from '@gusto/embedded-api/models/components/employeestatetaxesrequest'; import { RateType } from '@gusto/embedded-api/models/components/taxrequirementmetadata'; @@ -537,6 +540,7 @@ declare namespace APIModels { Garnishment, GarnishmentChildSupport, PaymentPeriod, + GeneratedDocumentStatus, ChristmasDay, ColumbusDay, HolidayPayPolicyEmployees, @@ -656,6 +660,8 @@ declare namespace APIModels { PayScheduleFrequency_2 as PayScheduleFrequency, PaySchedulePreviewPayPeriod, PayScheduleShow, + PrintablePayrollChecksBody, + PrintingFormat, RecoveryCase, RecoveryCaseStatus, IdentityVerificationStatus, @@ -1293,6 +1299,13 @@ export const componentEvents: { readonly CONTRACTOR_PAYMENT_CANCEL: "contractor/payments/cancel"; readonly CONTRACTOR_PAYMENT_EXIT: "contractor/payments/exit"; readonly CONTRACTOR_PAYMENT_RFI_RESPOND: "contractor/payments/rfi/respond"; + readonly PRINT_CHECKS_START: "payroll/printChecks/start"; + readonly PRINT_CHECKS_GENERATE_START: "payroll/printChecks/generate/start"; + readonly PRINT_CHECKS_GENERATE_SUCCEEDED: "payroll/printChecks/generate/succeeded"; + readonly PRINT_CHECKS_GENERATE_FAILED: "payroll/printChecks/generate/failed"; + readonly PRINT_CHECKS_RETRY: "payroll/printChecks/retry"; + readonly PRINT_CHECKS_CANCEL: "payroll/printChecks/cancel"; + readonly PRINT_CHECKS_CLOSE: "payroll/printChecks/close"; readonly RECOVERY_CASE_RESOLVE: "recoveryCase/resolve"; readonly RECOVERY_CASE_RESUBMIT: "recoveryCase/resubmit"; readonly RECOVERY_CASE_RESUBMIT_CANCEL: "recoveryCase/resubmit/cancel"; @@ -4222,6 +4235,8 @@ declare namespace Payroll { PayrollBlockerListProps, RecoveryCases, RecoveryCasesProps, + PrintChecks, + PrintChecksProps, OffCyclePayPeriodDateFormData, OffCyclePayrollDateType, OffCycleCreation, @@ -4573,6 +4588,16 @@ export type PreparerSelectFieldProps = HookFieldProps>; +// @public +function PrintChecks(input: PrintChecksProps): JSX; + +// @public +interface PrintChecksProps extends Omit, 'onEvent'> { + companyId: string; + onEvent?: BaseComponentInterface['onEvent']; + payrollId: string; +} + // @public function Profile(input: ProfileProps): JSX; @@ -4993,6 +5018,14 @@ export interface Resources { // (undocumented) 'Payroll.PayrollReceipts': Translations.PayrollPayrollReceipts // (undocumented) + 'Payroll.PrintChecksBanner': Translations.PayrollPrintChecksBanner + // (undocumented) + 'Payroll.PrintChecksFailure': Translations.PayrollPrintChecksFailure + // (undocumented) + 'Payroll.PrintChecksForm': Translations.PayrollPrintChecksForm + // (undocumented) + 'Payroll.PrintChecksSummary': Translations.PayrollPrintChecksSummary + // (undocumented) 'Payroll.RecoveryCasesList': Translations.PayrollRecoveryCasesList // (undocumented) 'Payroll.RecoveryCasesResubmit': Translations.PayrollRecoveryCasesResubmit diff --git a/docs/guides/endpoint-inventory.json b/docs/guides/endpoint-inventory.json index c32a75f04..62162dce7 100644 --- a/docs/guides/endpoint-inventory.json +++ b/docs/guides/endpoint-inventory.json @@ -384,6 +384,32 @@ "recoveryCaseUuid" ] }, + "Payroll.PrintChecks": { + "endpoints": [ + { + "method": "GET", + "path": "/v1/companies/:companyId/payrolls/:payrollId", + "docsUrl": "https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-payrolls-payroll_id" + }, + { + "method": "GET", + "path": "/v1/generated_documents/:documentType/:requestUuid", + "docsUrl": "https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-generated_documents-document_type-request_uuid" + }, + { + "method": "POST", + "path": "/v1/payrolls/:payrollUuid/generated_documents/printable_payroll_checks", + "docsUrl": "https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-payrolls-payroll_uuid-generated_documents-printable_payroll_checks" + } + ], + "variables": [ + "companyId", + "documentType", + "payrollId", + "payrollUuid", + "requestUuid" + ] + }, "Payroll.OffCycleCreation": { "endpoints": [ { diff --git a/docs/guides/endpoint-reference.md b/docs/guides/endpoint-reference.md index 168817245..91c8983c1 100644 --- a/docs/guides/endpoint-reference.md +++ b/docs/guides/endpoint-reference.md @@ -447,6 +447,9 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json' | | GET | [`/v1/companies/:companyUuid/recovery_cases`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-recovery-cases) | | **Payroll.RecoveryCases** | GET | [`/v1/companies/:companyUuid/recovery_cases`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-recovery-cases) | | | PUT | [`/v1/recovery_cases/:recoveryCaseUuid/redebit`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/redebit-recovery-case) | +| **Payroll.PrintChecks** | GET | [`/v1/companies/:companyId/payrolls/:payrollId`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-payrolls-payroll_id) | +| | GET | [`/v1/generated_documents/:documentType/:requestUuid`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-generated_documents-document_type-request_uuid) | +| | POST | [`/v1/payrolls/:payrollUuid/generated_documents/printable_payroll_checks`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-payrolls-payroll_uuid-generated_documents-printable_payroll_checks) | | **Payroll.OffCycleCreation** | GET | [`/v1/companies/:companyId/employees`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-employees) | | | POST | [`/v1/companies/:companyId/payrolls`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-companies-company_id-payrolls) | | | GET | [`/v1/companies/:companyUuid/payment_configs`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-company-payment-configs) | diff --git a/docs/reference/APIModels/index.md b/docs/reference/APIModels/index.md index fcead82f1..eba04f4f4 100644 --- a/docs/reference/APIModels/index.md +++ b/docs/reference/APIModels/index.md @@ -3036,6 +3036,39 @@ Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/garnishment. *** + + +## GeneratedDocumentStatus + +> `const` **GeneratedDocumentStatus**: `object` + +Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/generateddocument.ts:15](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/generateddocument.ts#L15) + +Current status of the Generated Document + +### Type Declaration + +| Name | Type | +| ------ | ------ | +| `Failed` | `"failed"` | +| `Pending` | `"pending"` | +| `Started` | `"started"` | +| `Succeeded` | `"succeeded"` | + +*** + + + +## GeneratedDocumentStatus + +> **GeneratedDocumentStatus** = `ClosedEnum`\<*typeof* [`GeneratedDocumentStatus`](#generateddocumentstatus)\> + +Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/generateddocument.ts:15](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/generateddocument.ts#L15) + +Current status of the Generated Document + +*** + ## HolidayPayPolicy @@ -6028,6 +6061,56 @@ Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/holidaypaypo *** + + +## PrintablePayrollChecksBody + +> **PrintablePayrollChecksBody** = `object` + +Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/printablepayrollchecksbody.ts:24](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/printablepayrollchecksbody.ts#L24) + +Request body for generating printable payroll checks. + +### Properties + +| Property | Type | Description | +| ------ | ------ | ------ | +| `printingFormat` | [`PrintingFormat`](#printingformat-1) | The type of check stock being printed. Check the "Types of check stock" section in this [link](https://support.gusto.com/article/999877761000000/Pay-your-team-by-check) for more info on check types | +| `startingCheckNumber?` | `number` | The starting check number we will start generating checks from. Use to override the sequence that will be used to generate check numbers. | + +*** + + + +## PrintingFormat + +> `const` **PrintingFormat**: `object` + +Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/printablepayrollchecksbody.ts:12](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/printablepayrollchecksbody.ts#L12) + +The type of check stock being printed. Check the "Types of check stock" section in this [link](https://support.gusto.com/article/999877761000000/Pay-your-team-by-check) for more info on check types + +### Type Declaration + +| Name | Type | +| ------ | ------ | +| `Bottom` | `"bottom"` | +| `Top` | `"top"` | + +*** + + + +## PrintingFormat + +> **PrintingFormat** = `ClosedEnum`\<*typeof* [`PrintingFormat`](#printingformat)\> + +Defined in: [gusto\_embedded\_v\_2026\_06\_15/src/models/components/printablepayrollchecksbody.ts:12](https://github.com/Gusto/gusto-typescript-client/blob/gusto_embedded_v_2026_06_15/v0.1.0/gusto_embedded_v_2026_06_15/src/models/components/printablepayrollchecksbody.ts#L12) + +The type of check stock being printed. Check the "Types of check stock" section in this [link](https://support.gusto.com/article/999877761000000/Pay-your-team-by-check) for more info on check types + +*** + ## Questions diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md index 96e5d9944..3bd8f0fc5 100644 --- a/docs/reference/Translations/index.md +++ b/docs/reference/Translations/index.md @@ -5069,9 +5069,6 @@ Translation keys for the `Payroll.PayrollOverview` i18n namespace. | Property | Default value | | ------ | ------ | | `alerts` | | -| `alerts.checkPaymentWarning_one` | `"You noted {{count}} employee who should be paid by check."` | -| `alerts.checkPaymentWarning_other` | `"You noted {{count}} employees who should be paid by check."` | -| `alerts.checkPaymentWarningDescription` | `"Employees with this payment method will need their checks delivered to them."` | | `alerts.directDepositDeadline` | `"To pay your employees with direct deposit by {{payDate}}, you'll need to run payroll by {{time}} on {{date}}"` | | `alerts.payrollNotCalculated` | `"Payroll is not calculated"` | | `alerts.payrollProcessedMessage` | `"{{amount}} will be debited on {{date}}. Make sure you have these funds available."` | @@ -5234,6 +5231,81 @@ Translation keys for the `Payroll.PayrollReceipts` i18n namespace. *** + + +### PayrollPrintChecksBanner + +Translation keys for the `Payroll.PrintChecksBanner` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `cta` | `"View and print checks"` | +| `description` | `"Employees with this payment method will need their checks delivered to them. If you aren't using your own checks, you can view and print checks."` | +| `title_one` | `"You noted {{count}} employee that should be paid by check."` | +| `title_other` | `"You noted {{count}} employees that should be paid by check."` | + +*** + + + +### PayrollPrintChecksFailure + +Translation keys for the `Payroll.PrintChecksFailure` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `closeCta` | `"Close"` | +| `failedTitle` | `"We couldn't generate your checks"` | +| `retryCta` | `"Try again"` | + +*** + + + +### PayrollPrintChecksForm + +Translation keys for the `Payroll.PrintChecksForm` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `blankStockDescription` | `"Use this check stock if you have blank check stock and need us to populate your company and bank information. The physical check will always be on the bottom of the check PDF."` | +| `blankStockLabel` | `"Blank check stock"` | +| `cancelCta` | `"Cancel"` | +| `customStockDescription` | `"Use this check stock if you have check stock that is pre-printed with your company and bank information. The physical check will appear on the top of the check PDF. Check numbers should already be pre-printed on the check stock you purchased."` | +| `customStockLabel` | `"Custom check stock"` | +| `modalTitle` | `"Choose check stock"` | +| `startingCheckNumberDescription` | `"This will be the first check number, all other checks will follow sequentially."` | +| `startingCheckNumberLabel` | `"Check number starts with"` | +| `submitCta` | `"View checks"` | +| `submitCtaLoading` | `"Generating..."` | +| `validations` | | +| `validations.startingCheckNumber` | `"Enter a valid check number"` | + +*** + + + +### PayrollPrintChecksSummary + +Translation keys for the `Payroll.PrintChecksSummary` i18n namespace. + +#### Properties + +| Property | Default value | +| ------ | ------ | +| `closeCta` | `"Close"` | +| `succeededDescription` | `"The download should have started automatically. If not, use the link below."` | +| `succeededTitle` | `"Your checks are ready"` | +| `viewChecksCta` | `"View checks"` | + +*** + ### PayrollRecoveryCasesList @@ -5606,6 +5678,10 @@ yields that namespace's keys. Backs i18next `t()` typing and `ResourceDictionary | `Payroll.PayrollList` | [`PayrollPayrollList`](#payrollpayrolllist) | | `Payroll.PayrollOverview` | [`PayrollPayrollOverview`](#payrollpayrolloverview) | | `Payroll.PayrollReceipts` | [`PayrollPayrollReceipts`](#payrollpayrollreceipts) | +| `Payroll.PrintChecksBanner` | [`PayrollPrintChecksBanner`](#payrollprintchecksbanner) | +| `Payroll.PrintChecksFailure` | [`PayrollPrintChecksFailure`](#payrollprintchecksfailure) | +| `Payroll.PrintChecksForm` | [`PayrollPrintChecksForm`](#payrollprintchecksform) | +| `Payroll.PrintChecksSummary` | [`PayrollPrintChecksSummary`](#payrollprintcheckssummary) | | `Payroll.RecoveryCasesList` | [`PayrollRecoveryCasesList`](#payrollrecoverycaseslist) | | `Payroll.RecoveryCasesResubmit` | [`PayrollRecoveryCasesResubmit`](#payrollrecoverycasesresubmit) | | `Payroll.Transition` | [`PayrollTransition`](#payrolltransition) | diff --git a/docs/reference/blocks.md b/docs/reference/blocks.md index ef3b6e8fd..08b107960 100644 --- a/docs/reference/blocks.md +++ b/docs/reference/blocks.md @@ -132,6 +132,7 @@ Individual form and UI components with SDK logic built in — use these for cust | [Payroll.PayrollList](payroll/blocks#payrolllist) | Lists upcoming payrolls and lets users start running them. | | [Payroll.PayrollOverview](payroll/blocks#payrolloverview) | Final review screen for a calculated payroll before submission, with submit, cancel, and edit controls. After submission, tracks processing status and surfaces the receipt and per-employee paystub downloads once complete. | | [Payroll.PayrollReceipts](payroll/blocks#payrollreceipts) | Displays a detailed receipt for a completed payroll, including the debited total, per-category breakdown, tax breakdown, and a per-employee summary of payment method, garnishments, reimbursements, taxes, and net pay. | +| [Payroll.PrintChecks](payroll/blocks#printchecks) | Displays a banner prompting the user to print checks for employees paid by check on a processed payroll, and walks them through choosing check stock and generating the check PDF. | | [Payroll.RecoveryCases](payroll/blocks#recoverycases) | Displays open recovery cases for a company and provides an in-modal resubmit workflow for resolving them. | | [Payroll.TransitionCreation](payroll/blocks#transitioncreation) | Creation form for transition payrolls covering the gap between an old and new pay schedule. | | [TimeOff.AddEmployeesHoliday](time-off/blocks#addemployeesholiday) | Employee selection screen for assigning employees to a company's holiday pay policy. | diff --git a/docs/reference/events.md b/docs/reference/events.md index 78f4aa192..f2179f239 100644 --- a/docs/reference/events.md +++ b/docs/reference/events.md @@ -304,6 +304,13 @@ import { componentEvents, EmployeeOnboarding } from '@gusto/embedded-react-sdk' | `PAYROLL_WIRE_INSTRUCTIONS_DONE` | `"payroll/wire/instructions/done"` | | `PAYROLL_WIRE_INSTRUCTIONS_SELECT` | `"payroll/wire/instructions/select"` | | `PAYROLL_WIRE_START_TRANSFER` | `"payroll/wire/startTransfer"` | +| `PRINT_CHECKS_CANCEL` | `"payroll/printChecks/cancel"` | +| `PRINT_CHECKS_CLOSE` | `"payroll/printChecks/close"` | +| `PRINT_CHECKS_GENERATE_FAILED` | `"payroll/printChecks/generate/failed"` | +| `PRINT_CHECKS_GENERATE_START` | `"payroll/printChecks/generate/start"` | +| `PRINT_CHECKS_GENERATE_SUCCEEDED` | `"payroll/printChecks/generate/succeeded"` | +| `PRINT_CHECKS_RETRY` | `"payroll/printChecks/retry"` | +| `PRINT_CHECKS_START` | `"payroll/printChecks/start"` | | `RECOVERY_CASE_RESOLVE` | `"recoveryCase/resolve"` | | `RECOVERY_CASE_RESUBMIT` | `"recoveryCase/resubmit"` | | `RECOVERY_CASE_RESUBMIT_CANCEL` | `"recoveryCase/resubmit/cancel"` | diff --git a/docs/reference/index.mdx b/docs/reference/index.mdx index 4ade1effe..1403c7dfb 100644 --- a/docs/reference/index.mdx +++ b/docs/reference/index.mdx @@ -12,7 +12,7 @@ custom_edit_url: null ## Browse by domain - + --- diff --git a/docs/reference/payroll/blocks.md b/docs/reference/payroll/blocks.md index a41f8a2d6..af1cd7936 100644 --- a/docs/reference/payroll/blocks.md +++ b/docs/reference/payroll/blocks.md @@ -665,6 +665,13 @@ _Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `Loader | `runPayroll/receipt/get` | User requested the payroll receipt | `{ payrollId }` | | `runPayroll/pdfPaystub/viewed` | User opened an employee's paystub PDF | `{ employeeId }` | | `payroll/wire/form/done` | Wire-in details were confirmed via the embedded wire form | Submit wire-in response | +| `payroll/printChecks/start` | User opened the print-checks modal from the embedded print-checks banner | — | +| `payroll/printChecks/generate/start` | User submitted the print-checks form | — | +| `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | +| `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | +| `payroll/printChecks/retry` | User retried after a failed check generation | — | +| `payroll/printChecks/cancel` | User cancelled the print-checks form | — | +| `payroll/printChecks/close` | User closed the print-checks failure or summary screen | — |
@@ -729,6 +736,55 @@ _Inherits `children`, `className`, `defaultValues`, `FallbackComponent`, `Loader *** + + +## PrintChecks + +Displays a banner prompting the user to print checks for employees paid by check on a +processed payroll, and walks them through choosing check stock and generating the check PDF. + +
+ +### PrintChecksProps + + + +Props for [PrintChecks](#printchecks). + +| Property | Type | Description | +| ------ | ------ | ------ | +| `companyId` | `string` | Identifier of the company that owns the payroll. | +| `payrollId` | `string` | Identifier of the payroll to generate printable checks for. | +| `onEvent?` | [`OnEventType`](../events.md#oneventtype)\<[`EventType`](../events.md#eventtype), `unknown`\> | Callback invoked each time the component emits an event. | + +_Inherits `children`, `className`, `defaultValues`, `dictionary`, `FallbackComponent`, `LoaderComponent` from Omit._ + +
+ +### Events + +| Event | Description | Data | +| ----- | ----------- | ---- | +| `payroll/printChecks/start` | User opened the print-checks modal from the banner | — | +| `payroll/printChecks/generate/start` | User submitted the print-checks form | — | +| `payroll/printChecks/generate/succeeded` | Printable checks finished generating | `{ documentUrl }` | +| `payroll/printChecks/generate/failed` | The print-checks request was rejected or generation failed | `{ errorMessage }` | +| `payroll/printChecks/retry` | User retried after a failed generation | — | +| `payroll/printChecks/cancel` | User cancelled the print-checks form | — | +| `payroll/printChecks/close` | User closed the failure or summary screen | — | + +
+ +### Endpoints + +| Method | Path | +| --- | --- | +| GET | [`/v1/companies/:companyId/payrolls/:payrollId`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-companies-company_id-payrolls-payroll_id) | +| GET | [`/v1/generated_documents/:documentType/:requestUuid`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/get-v1-generated_documents-document_type-request_uuid) | +| POST | [`/v1/payrolls/:payrollUuid/generated_documents/printable_payroll_checks`](https://docs.gusto.com/embedded-payroll/v2026-06-15/reference/post-v1-payrolls-payroll_uuid-generated_documents-printable_payroll_checks) | + +*** + ## RecoveryCases diff --git a/docs/reference/payroll/index.mdx b/docs/reference/payroll/index.mdx index 8a97b1413..595d2b00e 100644 --- a/docs/reference/payroll/index.mdx +++ b/docs/reference/payroll/index.mdx @@ -23,4 +23,4 @@ Flows and blocks for running and managing payroll across a company's pay schedul import { Payroll } from '@gusto/embedded-react-sdk' ``` - + diff --git a/docs/reference/payroll/namespace.md b/docs/reference/payroll/namespace.md index d115a11db..c3b44936f 100644 --- a/docs/reference/payroll/namespace.md +++ b/docs/reference/payroll/namespace.md @@ -45,5 +45,6 @@ import { Payroll } from '@gusto/embedded-react-sdk' | [PayrollList](blocks.md#payrolllist) | Lists upcoming payrolls and lets users start running them. | | [PayrollOverview](blocks.md#payrolloverview) | Final review screen for a calculated payroll before submission, with submit, cancel, and edit controls. After submission, tracks processing status and surfaces the receipt and per-employee paystub downloads once complete. | | [PayrollReceipts](blocks.md#payrollreceipts) | Displays a detailed receipt for a completed payroll, including the debited total, per-category breakdown, tax breakdown, and a per-employee summary of payment method, garnishments, reimbursements, taxes, and net pay. | +| [PrintChecks](blocks.md#printchecks) | Displays a banner prompting the user to print checks for employees paid by check on a processed payroll, and walks them through choosing check stock and generating the check PDF. | | [RecoveryCases](blocks.md#recoverycases) | Displays open recovery cases for a company and provides an in-modal resubmit workflow for resolving them. | | [TransitionCreation](blocks.md#transitioncreation) | Creation form for transition payrolls covering the gap between an old and new pay schedule. | diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index 5b60db09e..831af021c 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -7420,12 +7420,6 @@ export namespace Translations { directDepositDeadline: string /** @defaultValue `"There was an issue generating the paystub PDF. Please try again later."` */ paystubPdfError: string - /** @defaultValue `"You noted {{count}} employee who should be paid by check."` */ - checkPaymentWarning_one: string - /** @defaultValue `"You noted {{count}} employees who should be paid by check."` */ - checkPaymentWarning_other: string - /** @defaultValue `"Employees with this payment method will need their checks delivered to them."` */ - checkPaymentWarningDescription: string /** @defaultValue `"Payroll submitted"` */ payrollProcessedTitle: string /** @defaultValue `"{{amount}} will be debited on {{date}}. Make sure you have these funds available."` */ diff --git a/src/models/external.ts b/src/models/external.ts index 58a5ac872..65dca838a 100644 --- a/src/models/external.ts +++ b/src/models/external.ts @@ -189,6 +189,7 @@ export { GarnishmentType } from '@gusto/embedded-api/models/components/garnishme export type { Garnishment } from '@gusto/embedded-api/models/components/garnishment' export type { GarnishmentChildSupport } from '@gusto/embedded-api/models/components/garnishmentchildsupport' export { PaymentPeriod } from '@gusto/embedded-api/models/components/garnishmentchildsupport' +export { GeneratedDocumentStatus } from '@gusto/embedded-api/models/components/generateddocument' /** `ChristmasDay` entity from the Gusto Embedded API. */ export type { ChristmasDay } from '@gusto/embedded-api/models/components/holidaypaypolicy' /** `ColumbusDay` entity from the Gusto Embedded API. */ @@ -383,6 +384,8 @@ export type { export { PayScheduleFrequency } from '@gusto/embedded-api/models/components/payschedulefrequency' export type { PaySchedulePreviewPayPeriod } from '@gusto/embedded-api/models/components/payschedulepreviewpayperiod' export type { PayScheduleShow } from '@gusto/embedded-api/models/components/payscheduleshow' +export type { PrintablePayrollChecksBody } from '@gusto/embedded-api/models/components/printablepayrollchecksbody' +export { PrintingFormat } from '@gusto/embedded-api/models/components/printablepayrollchecksbody' export type { RecoveryCase } from '@gusto/embedded-api/models/components/recoverycase' export { RecoveryCaseStatus } from '@gusto/embedded-api/models/components/recoverycase' /** `IdentityVerificationStatus` entity from the Gusto Embedded API. */