diff --git a/src/components/Payroll/OffCycleCreation/OffCycleCreation.stories.tsx b/src/components/Payroll/OffCycleCreation/OffCycleCreation.stories.tsx index 1449cf2b7a..798033ac60 100644 --- a/src/components/Payroll/OffCycleCreation/OffCycleCreation.stories.tsx +++ b/src/components/Payroll/OffCycleCreation/OffCycleCreation.stories.tsx @@ -21,6 +21,12 @@ const mockEmployees = [ { label: 'John Doe', value: 'uuid-3' }, ] +// Fixed rather than derived from today so the stories render deterministically. +const mockDateBounds = { + minCheckDate: new Date(2026, 8, 4), + minCheckOnlyDate: new Date(2026, 8, 2), +} + const defaultFormValues = { reason: 'bonus', isCheckOnly: false, @@ -72,12 +78,24 @@ export default { export const Default = () => { const taxWithholding = useTaxWithholdingState() - return + return ( + + ) } export const CorrectionSelected = () => { const taxWithholding = useTaxWithholdingState('regular') - return + return ( + + ) } CorrectionSelected.decorators = [ (Story: React.ComponentType) => ( @@ -91,7 +109,13 @@ CorrectionSelected.decorators = [ export const CheckOnlyMode = () => { const taxWithholding = useTaxWithholdingState() - return + return ( + + ) } CheckOnlyMode.decorators = [ (Story: React.ComponentType) => ( diff --git a/src/components/Payroll/OffCycleCreation/OffCycleCreation.test.tsx b/src/components/Payroll/OffCycleCreation/OffCycleCreation.test.tsx index a112123a1e..9ee602dce9 100644 --- a/src/components/Payroll/OffCycleCreation/OffCycleCreation.test.tsx +++ b/src/components/Payroll/OffCycleCreation/OffCycleCreation.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createOffCyclePayPeriodDateFormSchema } from '../OffCyclePayPeriodDateForm/OffCyclePayPeriodDateFormTypes' @@ -132,6 +132,61 @@ describe('OffCycleCreation', () => { }) }) + // The payment date carried no minDate, so the ACH lead-time rule existed only as a + // submit-time error while legacy gws-flows disables invalid dates in the picker + // (SDK-1274). System time is pinned because the bound is derived from today. + describe('payment date minimum', () => { + // Wed Sep 2 2026 + 2 business days (the mocked paymentSpeedDays) = Fri Sep 4 2026. + const TODAY = new Date(2026, 8, 2) + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.setSystemTime(TODAY) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const openPaymentDateCalendar = async (user: ReturnType) => { + const group = await waitFor(() => screen.getByRole('group', { name: 'Payment date' })) + await user.click(within(group).getByRole('button')) + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + return screen.getAllByRole('gridcell') + } + + const cellFor = (cells: HTMLElement[], day: string) => + cells.find(cell => cell.textContent.trim() === day) + + it('disables dates before the ACH lead time', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + renderComponent() + + const cells = await openPaymentDateCalendar(user) + + expect(cellFor(cells, '3')).toHaveAttribute('aria-disabled', 'true') + expect(cellFor(cells, '4')).not.toHaveAttribute('aria-disabled') + }) + + it('relaxes the minimum to today when check-only is selected', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + renderComponent() + + const checkOnly = await waitFor(() => + screen.getByRole('checkbox', { name: /check-only payroll/i }), + ) + await user.click(checkOnly) + + const cells = await openPaymentDateCalendar(user) + + // Today becomes selectable; the day before it stays out of range. + expect(cellFor(cells, '2')).not.toHaveAttribute('aria-disabled') + expect(cellFor(cells, '1')).toHaveAttribute('aria-disabled', 'true') + }) + }) + describe('payrollType initialization', () => { it('defaults to bonus reason when no payrollType is provided', async () => { renderComponent() diff --git a/src/components/Payroll/OffCycleCreation/OffCycleCreation.tsx b/src/components/Payroll/OffCycleCreation/OffCycleCreation.tsx index 81a03de443..dd1057039f 100644 --- a/src/components/Payroll/OffCycleCreation/OffCycleCreation.tsx +++ b/src/components/Payroll/OffCycleCreation/OffCycleCreation.tsx @@ -232,6 +232,8 @@ function Root({ dictionary, companyId, payrollType = 'bonus' }: OffCycleCreation {t('payPeriodSectionTitle')} {t('payPeriodSectionDescription')} - +
diff --git a/src/components/Payroll/OffCycleCreation/OffCycleCreationTypes.ts b/src/components/Payroll/OffCycleCreation/OffCycleCreationTypes.ts index 6bbf2aa27b..6a03d9e37c 100644 --- a/src/components/Payroll/OffCycleCreation/OffCycleCreationTypes.ts +++ b/src/components/Payroll/OffCycleCreation/OffCycleCreationTypes.ts @@ -41,6 +41,10 @@ export interface OffCycleCreationPresentationProps { employees: MultiSelectComboBoxOption[] /** Whether the off-cycle create mutation is in flight. */ isPending?: boolean + /** Earliest selectable payment date for direct deposit (today plus the ACH lead time). */ + minCheckDate: Date + /** Earliest selectable payment date when the payroll is check-only (today). */ + minCheckOnlyDate: Date /** Current tax withholding configuration shown in the table. */ taxWithholdingConfig: OffCycleTaxWithholdingConfig /** Whether the tax withholding edit modal is open. */ diff --git a/src/components/Payroll/OffCyclePayPeriodDateForm/OffCyclePayPeriodDateFormPresentation.tsx b/src/components/Payroll/OffCyclePayPeriodDateForm/OffCyclePayPeriodDateFormPresentation.tsx index aa2319e499..63c4d8e283 100644 --- a/src/components/Payroll/OffCyclePayPeriodDateForm/OffCyclePayPeriodDateFormPresentation.tsx +++ b/src/components/Payroll/OffCyclePayPeriodDateForm/OffCyclePayPeriodDateFormPresentation.tsx @@ -1,13 +1,35 @@ +import { useFormContext, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import type { OffCyclePayPeriodDateFormData } from './OffCyclePayPeriodDateFormTypes' import styles from './OffCyclePayPeriodDateFormPresentation.module.scss' import { useI18n } from '@/i18n' import { CheckboxField, DatePickerField } from '@/components/Common' /** @internal */ -export function OffCyclePayPeriodDateFormPresentation() { +export interface OffCyclePayPeriodDateFormPresentationProps { + /** Earliest selectable payment date for direct deposit (today plus the ACH lead time). */ + minCheckDate: Date + /** Earliest selectable payment date when the payroll is check-only (today). */ + minCheckOnlyDate: Date +} + +/** @internal */ +export function OffCyclePayPeriodDateFormPresentation({ + minCheckDate, + minCheckOnlyDate, +}: OffCyclePayPeriodDateFormPresentationProps) { useI18n('Payroll.OffCyclePayPeriodDateForm') const { t } = useTranslation('Payroll.OffCyclePayPeriodDateForm') + const { control } = useFormContext() + const isCheckOnly = useWatch({ control, name: 'isCheckOnly' }) + + // Mirrors the resolver's own `isCheckOnly ? today : minCheckDate` so the picker and the + // validation agree. Without a minDate the rule existed only as a submit-time error, and + // legacy gws-flows disables invalid dates in the picker (SDK-1274). Checking "check-only" + // has to widen the bound back to today, live. + const checkDateMinimum = isCheckOnly ? minCheckOnlyDate : minCheckDate + return (
@@ -16,7 +38,12 @@ export function OffCyclePayPeriodDateFormPresentation() {
- +