From 6d9ccc89a36fc718526d0e67cab0949905830ed5 Mon Sep 17 00:00:00 2001 From: Kristine White Date: Thu, 23 Jul 2026 13:31:31 -0700 Subject: [PATCH 1/3] feat(Payroll): redesign Edit payroll UI as card/table sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders each Edit payroll section (Regular and overtime hours, Time off, Additional earnings, Other) as a bordered Box with a DataView table, matching the design-mode reference. Overtime/Double overtime rows are hidden behind an "Add overtime" reveal button unless the employee already has non-zero overtime hours. Splits "Additional earnings" into "Additional earnings" (Bonus, Commission, Correction payment) and "Other" (Cash tips, Paycheck tips), and moves Additional earnings above Time off per design review. UI only — the per-workweek hours/earnings split (RRoP) is a separate ticket (SDK-1134). Co-Authored-By: Claude Sonnet 5 --- ...ayrollEditEmployeePresentation.module.scss | 6 - .../PayrollEditEmployeePresentation.test.tsx | 95 +++++++ .../PayrollEditEmployeePresentation.tsx | 237 ++++++++++++++---- src/i18n/en/Payroll.PayrollEditEmployee.json | 12 +- src/i18n/types.d.ts | 18 +- 5 files changed, 315 insertions(+), 53 deletions(-) diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss index cdcdc8bd3..3b7ce907b 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss @@ -18,16 +18,10 @@ } .fieldGroup { - padding: toRem(32) 0; - border-top: 1px solid var(--g-colorBorderSecondary); width: 100%; display: flex; flex-direction: column; gap: toRem(16); - - &:first-child { - border-top: none; - } } .grossPayLabel { diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx index 7c0eefae0..f0ad9b686 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx @@ -473,6 +473,72 @@ describe('PayrollEditEmployeePresentation', () => { ) }) + describe('Overtime', () => { + it('hides overtime rows behind an Add overtime button until revealed', async () => { + const user = userEvent.setup() + const compensationWithZeroOvertime: PayrollEmployeeCompensationsType = { + ...mockEmployeeCompensation, + hourlyCompensations: [ + { + name: 'Regular Hours', + hours: '40.000', + flsaStatus: 'Nonexempt', + jobUuid: 'job-1', + amount: '1000.0', + compensationMultiplier: 1.0, + }, + { + name: 'Overtime', + hours: '0', + flsaStatus: 'Nonexempt', + jobUuid: 'job-1', + amount: '0', + compensationMultiplier: 1.5, + }, + ], + } + + renderWithProviders( + , + ) + + await waitFor(() => { + expect(screen.getByLabelText('Regular Hours')).toBeInTheDocument() + }) + expect(screen.queryByLabelText('Overtime')).not.toBeInTheDocument() + + const addOvertimeButton = screen.getByText('Add overtime') + await user.click(addOvertimeButton) + + expect(screen.getByLabelText('Overtime')).toBeInTheDocument() + expect(screen.queryByText('Add overtime')).not.toBeInTheDocument() + }) + + it('reveals overtime rows by default when the employee already has overtime hours', async () => { + renderWithProviders() + + expect(await screen.findByLabelText('Overtime')).toBeInTheDocument() + expect(screen.queryByText('Add overtime')).not.toBeInTheDocument() + }) + }) + + describe('Section order', () => { + it('renders Additional earnings before Time off', async () => { + renderWithProviders() + + const additionalEarningsHeading = await screen.findByText('Additional earnings') + const timeOffHeading = screen.getByText('Time off') + + expect( + additionalEarningsHeading.compareDocumentPosition(timeOffHeading) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + }) + }) + describe('Time Off', () => { it('renders time off section when employee has time off data', async () => { renderWithProviders() @@ -821,6 +887,35 @@ describe('PayrollEditEmployeePresentation', () => { }) }) + it('renders cash and paycheck tips under a separate Other section', async () => { + const compensationWithTips: PayrollEmployeeCompensationsType = { + ...mockEmployeeCompensation, + fixedCompensations: [ + { name: 'Bonus', amount: '500.00', jobUuid: 'job-1' }, + { name: 'Cash Tips', amount: '20.00', jobUuid: 'job-1' }, + ], + } + + renderWithProviders( + , + ) + + await waitFor(() => { + expect(screen.getByText('Additional earnings')).toBeInTheDocument() + }) + expect(screen.getByLabelText('Bonus')).toBeInTheDocument() + const otherHeading = screen.getByText('Other') + const cashTipsInput = screen.getByLabelText('Cash tips') + expect(cashTipsInput).toBeInTheDocument() + expect( + otherHeading.compareDocumentPosition(cashTipsInput) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + }) + it('renders reimbursement rows when present', async () => { renderWithProviders() diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx index 7473fb165..7077529dc 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx @@ -219,6 +219,125 @@ function TimeOffDataView({ timeOffs, employee, label, Field }: TimeOffDataViewPr return } +interface HourRow { + compensationName: string + label: string + fieldName: string +} + +interface HoursDataViewProps { + rows: HourRow[] + label: string + title: string + isOvertimeRevealed: boolean + onAddOvertime: () => void +} + +function HoursDataView({ + rows, + label, + title, + isOvertimeRevealed, + onAddOvertime, +}: HoursDataViewProps) { + const { t } = useTranslation('Payroll.PayrollEditEmployee') + const { Box, BoxHeader, Button } = useComponentContext() + + const overtimeRows = rows.filter( + r => + r.compensationName === COMPENSATION_NAME_OVERTIME || + r.compensationName === COMPENSATION_NAME_DOUBLE_OVERTIME, + ) + const baseRows = rows.filter( + r => + r.compensationName !== COMPENSATION_NAME_OVERTIME && + r.compensationName !== COMPENSATION_NAME_DOUBLE_OVERTIME, + ) + const visibleRows = isOvertimeRevealed ? rows : baseRows + + const dataViewProps = useDataView({ + data: visibleRows, + columns: [ + { title: t('hoursColumns.type'), key: 'label' }, + { + title: t('hoursColumns.hours'), + justify: 'end', + render: row => ( +
+ +
+ ), + }, + ], + }) + + const showAddOvertimeFooter = overtimeRows.length > 0 && !isOvertimeRevealed + + return ( + } + withPadding={false} + footer={ + showAddOvertimeFooter ? ( + + ) : undefined + } + > + + + ) +} + +interface FixedAmountRow { + name: string + label: string + fieldName: string +} + +interface FixedAmountsDataViewProps { + rows: FixedAmountRow[] + label: string +} + +function FixedAmountsDataView({ rows, label }: FixedAmountsDataViewProps) { + const { t } = useTranslation('Payroll.PayrollEditEmployee') + + const dataViewProps = useDataView({ + data: rows, + columns: [ + { title: t('fixedAmountColumns.type'), key: 'label' }, + { + title: t('fixedAmountColumns.amount'), + justify: 'end', + render: row => ( +
+ +
+ ), + }, + ], + }) + return +} + /** @internal */ export const PayrollEditEmployeePresentation = ({ onSave, @@ -332,6 +451,25 @@ export const PayrollEditEmployeePresentation = ({ } } + const PRIMARY_EARNING_NAMES = new Set( + [ + COMPENSATION_NAME_BONUS, + COMPENSATION_NAME_COMMISSION, + COMPENSATION_NAME_CORRECTION_PAYMENT, + ].map(n => n.toLowerCase()), + ) + const toFixedAmountRow = (item: { name?: string | null }): FixedAmountRow => ({ + name: item.name!, + label: getFixedCompensationLabel(item.name ?? undefined) ?? item.name!, + fieldName: `fixedCompensations.${item.name}`, + }) + const primaryEarningRows: FixedAmountRow[] = additionalEarnings + .filter(item => PRIMARY_EARNING_NAMES.has((item.name ?? '').toLowerCase())) + .map(toFixedAmountRow) + const otherEarningRows: FixedAmountRow[] = additionalEarnings + .filter(item => !PRIMARY_EARNING_NAMES.has((item.name ?? '').toLowerCase())) + .map(toFixedAmountRow) + const defaultValues = { hourlyCompensations: (() => { const hourlyCompensations: PayrollEditEmployeeFormValues['hourlyCompensations'] = {} @@ -407,6 +545,14 @@ export const PayrollEditEmployeePresentation = ({ defaultValues, }) + const initialHasOvertimeValues = hourlyJobs.some(job => + [COMPENSATION_NAME_OVERTIME, COMPENSATION_NAME_DOUBLE_OVERTIME].some( + name => parseFloat(defaultValues.hourlyCompensations[job.uuid]?.[name] ?? '0') > 0, + ), + ) + const [forceShowOvertime, setForceShowOvertime] = useState(initialHasOvertimeValues) + const isOvertimeRevealed = forceShowOvertime + const { fields: reimbursementFields, append: appendReimbursement, @@ -649,33 +795,48 @@ export const PayrollEditEmployeePresentation = ({
{hourlyJobs.length > 0 && (
- {t('regularHoursTitle')} - {hourlyJobs.map(hourlyJob => ( - - {hourlyJobs.length > 1 && {hourlyJob.title}} - - {HOURS_COMPENSATION_NAMES.map(compensationName => { - const employeeHourlyCompensation = findMatchingCompensation( - hourlyJob.uuid, - compensationName, - ) - if (employeeHourlyCompensation) { - return ( - - ) - } - })} - - - ))} + {hourlyJobs.length > 1 && {t('regularHoursTitle')}} + {hourlyJobs.map(hourlyJob => { + const rows: HourRow[] = HOURS_COMPENSATION_NAMES.flatMap(compensationName => { + const match = findMatchingCompensation(hourlyJob.uuid, compensationName) + if (!match) return [] + return [ + { + compensationName, + label: getCompensationLabel(compensationName) ?? compensationName, + fieldName: `hourlyCompensations.${hourlyJob.uuid}.${match.name!}`, + }, + ] + }) + + const boxTitle = + hourlyJobs.length > 1 + ? (hourlyJob.title ?? t('regularHoursTitle')) + : t('regularHoursTitle') + + return ( + { + setForceShowOvertime(true) + }} + /> + ) + })} +
+ )} + {primaryEarningRows.length > 0 && ( +
+ } withPadding={false}> + +
)} {timeOff.length > 0 && ( @@ -721,25 +882,11 @@ export const PayrollEditEmployeePresentation = ({ )} - {additionalEarnings.length > 0 && ( + {otherEarningRows.length > 0 && (
- {t('additionalEarningsTitle')} - - {additionalEarnings.map(fixedCompensation => ( - - ))} - + } withPadding={false}> + +
)} {showLegacyReimbursementField && ( diff --git a/src/i18n/en/Payroll.PayrollEditEmployee.json b/src/i18n/en/Payroll.PayrollEditEmployee.json index 80d8886bb..1c8fa7cf9 100644 --- a/src/i18n/en/Payroll.PayrollEditEmployee.json +++ b/src/i18n/en/Payroll.PayrollEditEmployee.json @@ -3,8 +3,13 @@ "breadcrumbLabel": "{{firstName}} {{lastName}}", "grossPayLabel": "Gross pay (excluding reimbursements)", "grossPayLabelMobile": "Gross pay: {{grossPay}} (excluding reimbursements)", - "regularHoursTitle": "Regular hours", + "regularHoursTitle": "Regular and overtime hours", "hoursUnit": "Hours", + "addOvertimeCta": "Add overtime", + "hoursColumns": { + "type": "Hour type", + "hours": "Hours" + }, "saveCta": "Save", "cancelCta": "Cancel", "compensationNames": { @@ -24,6 +29,11 @@ "hours": "Hours" }, "additionalEarningsTitle": "Additional earnings", + "otherEarningsTitle": "Other", + "fixedAmountColumns": { + "type": "Type", + "amount": "Amount" + }, "reimbursementTitle": "Reimbursements", "reimbursementDescriptionLabel": "Description", "reimbursementDescriptionPlaceholder": "e.g., Office supplies", diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index ae0f07786..03cd9f6a9 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -6434,10 +6434,18 @@ export namespace Translations { grossPayLabel: string /** @defaultValue `"Gross pay: {{grossPay}} (excluding reimbursements)"` */ grossPayLabelMobile: string - /** @defaultValue `"Regular hours"` */ + /** @defaultValue `"Regular and overtime hours"` */ regularHoursTitle: string /** @defaultValue `"Hours"` */ hoursUnit: string + /** @defaultValue `"Add overtime"` */ + addOvertimeCta: string + hoursColumns: { + /** @defaultValue `"Hour type"` */ + type: string + /** @defaultValue `"Hours"` */ + hours: string + } /** @defaultValue `"Save"` */ saveCta: string /** @defaultValue `"Cancel"` */ @@ -6470,6 +6478,14 @@ export namespace Translations { } /** @defaultValue `"Additional earnings"` */ additionalEarningsTitle: string + /** @defaultValue `"Other"` */ + otherEarningsTitle: string + fixedAmountColumns: { + /** @defaultValue `"Type"` */ + type: string + /** @defaultValue `"Amount"` */ + amount: string + } /** @defaultValue `"Reimbursements"` */ reimbursementTitle: string /** @defaultValue `"Description"` */ From f05765e5ded8c867206fbee37d67dd0b89c2759d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Jul 2026 20:36:19 +0000 Subject: [PATCH 2/3] chore: update derived files --- docs/reference/Translations/index.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md index ffa1966bc..2eac70da4 100644 --- a/docs/reference/Translations/index.md +++ b/docs/reference/Translations/index.md @@ -4434,6 +4434,7 @@ Translation keys for the `Payroll.PayrollEditEmployee` i18n namespace. | Property | Default value | | ------ | ------ | | `additionalEarningsTitle` | `"Additional earnings"` | +| `addOvertimeCta` | `"Add overtime"` | | `addReimbursementCta` | `"Add one-time reimbursement"` | | `addReimbursementLink` | `"Add one-time reimbursement"` | | `breadcrumbLabel` | `"{{firstName}} {{lastName}}"` | @@ -4445,6 +4446,9 @@ Translation keys for the `Payroll.PayrollEditEmployee` i18n namespace. | `compensationNames.regularHours` | `"Regular Hours"` | | `finalPayoutDescription` | `"Enter the unused hours to pay out on the final paycheck. This is separate from time off hours used during the pay period."` | | `finalPayoutTitle` | `"Unused time off payout"` | +| `fixedAmountColumns` | | +| `fixedAmountColumns.amount` | `"Amount"` | +| `fixedAmountColumns.type` | `"Type"` | | `fixedCompensationNames` | | | `fixedCompensationNames.bonus` | `"Bonus"` | | `fixedCompensationNames.cashTips` | `"Cash tips"` | @@ -4454,7 +4458,11 @@ Translation keys for the `Payroll.PayrollEditEmployee` i18n namespace. | `fixedCompensationNames.reimbursement` | `"Reimbursement"` | | `grossPayLabel` | `"Gross pay (excluding reimbursements)"` | | `grossPayLabelMobile` | `"Gross pay: {{grossPay}} (excluding reimbursements)"` | +| `hoursColumns` | | +| `hoursColumns.hours` | `"Hours"` | +| `hoursColumns.type` | `"Hour type"` | | `hoursUnit` | `"Hours"` | +| `otherEarningsTitle` | `"Other"` | | `pageTitle` | `"Edit payroll for {{employeeName}}"` | | `paymentMethodDescription` | `"Changing the default payment method will only apply to this payroll."` | | `paymentMethodLabel` | `"Payment method"` | @@ -4464,7 +4472,7 @@ Translation keys for the `Payroll.PayrollEditEmployee` i18n namespace. | `paymentMethodTitle` | `"Payment"` | | `recurringReimbursementLabel` | `"{{description}} (recurring reimbursement)"` | | `recurringReimbursementTooltip` | `"Recurring reimbursements are managed outside of payroll."` | -| `regularHoursTitle` | `"Regular hours"` | +| `regularHoursTitle` | `"Regular and overtime hours"` | | `reimbursementAmountColumn` | `"Amount"` | | `reimbursementAmountLabel` | `"Amount"` | | `reimbursementDescriptionColumn` | `"Description"` | @@ -4482,6 +4490,9 @@ Translation keys for the `Payroll.PayrollEditEmployee` i18n namespace. | `saveReimbursementCta` | `"Save reimbursement"` | | `timeOffBalance` | | | `timeOffBalance.remaining` | `"{{balance}} remaining"` | +| `timeOffColumns` | | +| `timeOffColumns.hours` | `"Hours"` | +| `timeOffColumns.type` | `"Type"` | | `timeOffTitle` | `"Time off"` | | `timeOffTitleDismissal` | `"Time off hours used this pay period"` | From 6ddd47f2d099c8a3f938520e22684c6ee8ca23a9 Mon Sep 17 00:00:00 2001 From: Kristine White Date: Fri, 24 Jul 2026 16:25:59 -0700 Subject: [PATCH 3/3] test(OffCycle): use a role query for the Regular Hours input The Edit-payroll card/table redesign gives each hours row's an ARIA rowheader-derived accessible name equal to the row label (e.g. "Regular Hours"), which is the same text as the input's own label. getByLabelText matches both the row and the input, so it's ambiguous; getByRole('spinbutton', ...) only matches the actual form control. Co-Authored-By: Claude Sonnet 5 --- src/components/Payroll/OffCycle/OffCycleExecution.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Payroll/OffCycle/OffCycleExecution.test.tsx b/src/components/Payroll/OffCycle/OffCycleExecution.test.tsx index 573763555..2230efaec 100644 --- a/src/components/Payroll/OffCycle/OffCycleExecution.test.tsx +++ b/src/components/Payroll/OffCycle/OffCycleExecution.test.tsx @@ -352,7 +352,7 @@ describe('OffCycleExecution - edit employee hours round-trip', () => { ).toBeInTheDocument() }) - const regularHoursInput = await screen.findByLabelText('Regular Hours') + const regularHoursInput = await screen.findByRole('spinbutton', { name: 'Regular Hours' }) await user.clear(regularHoursInput) await user.type(regularHoursInput, '20')