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"` | 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') 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"` */