diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md index ffa1966bca..52bfa2a89c 100644 --- a/docs/reference/Translations/index.md +++ b/docs/reference/Translations/index.md @@ -4482,6 +4482,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/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss index 79f7d0da30..cdcdc8bd32 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.module.scss @@ -33,3 +33,7 @@ .grossPayLabel { color: var(--g-colorBodySubContent); } + +.inputContainer { + max-width: toRem(160); +} diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx index 778b749c0d..7c0eefae08 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.test.tsx @@ -1,5 +1,5 @@ import { expect, describe, it, vi } from 'vitest' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import { type Employee, EmployeePaymentMethod1, @@ -673,12 +673,12 @@ describe('PayrollEditEmployeePresentation', () => { expect(screen.getByText('Unused time off payout')).toBeInTheDocument() }) - const payoutInputs = screen.getAllByRole('spinbutton', { name: /Vacation Hours|Sick Hours/ }) - const vacationPayoutInput = payoutInputs.find( - input => - input.closest('[class*="fieldGroup"]')?.querySelector('h4')?.textContent === - 'Unused time off payout', - ) + const payoutBox = screen + .getAllByTestId('data-box') + .find(box => box.textContent.includes('Unused time off payout')) + const vacationPayoutInput = payoutBox + ? within(payoutBox).getByRole('spinbutton', { name: /Vacation Hours/ }) + : undefined if (vacationPayoutInput) { await user.clear(vacationPayoutInput) @@ -725,12 +725,12 @@ describe('PayrollEditEmployeePresentation', () => { expect(screen.getByText('Unused time off payout')).toBeInTheDocument() }) - const payoutInputs = screen.getAllByRole('spinbutton', { name: /Vacation Hours|Sick Hours/ }) - const vacationPayoutInput = payoutInputs.find( - input => - input.closest('[class*="fieldGroup"]')?.querySelector('h4')?.textContent === - 'Unused time off payout', - ) + const payoutBox = screen + .getAllByTestId('data-box') + .find(box => box.textContent.includes('Unused time off payout')) + const vacationPayoutInput = payoutBox + ? within(payoutBox).getByRole('spinbutton', { name: /Vacation Hours/ }) + : undefined if (vacationPayoutInput) { await user.clear(vacationPayoutInput) diff --git a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx index 710df40802..7473fb1657 100644 --- a/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx +++ b/src/components/Payroll/PayrollEditEmployee/PayrollEditEmployeePresentation.tsx @@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import styles from './PayrollEditEmployeePresentation.module.scss' -import { TimeOffField, PayoutTimeOffField } from './TimeOffField' +import { TimeOffField, PayoutTimeOffField, TimeOffTypeCell } from './TimeOffField' import { Flex, Grid, @@ -186,6 +186,39 @@ const buildCompensationFromFormData = ( return updatedCompensation } +type TimeOffFieldComponent = typeof TimeOffField | typeof PayoutTimeOffField + +interface TimeOffDataViewProps { + timeOffs: PayrollEmployeeCompensationsTypePaidTimeOff[] + employee: Employee + label: string + Field: TimeOffFieldComponent +} + +function TimeOffDataView({ timeOffs, employee, label, Field }: TimeOffDataViewProps) { + const { t } = useTranslation('Payroll.PayrollEditEmployee') + + const dataViewProps = useDataView({ + data: timeOffs, + columns: [ + { + title: t('timeOffColumns.type'), + render: row => , + }, + { + title: t('timeOffColumns.hours'), + justify: 'end', + render: row => ( +
+ +
+ ), + }, + ], + }) + return +} + /** @internal */ export const PayrollEditEmployeePresentation = ({ onSave, @@ -200,7 +233,7 @@ export const PayrollEditEmployeePresentation = ({ withReimbursements = true, hasDirectDepositSetup = true, }: PayrollEditEmployeeProps) => { - const { Button, ButtonIcon, Heading, Text, TextInput } = useComponentContext() + const { Box, BoxHeader, Button, ButtonIcon, Heading, Text, TextInput } = useComponentContext() const { t } = useTranslation('Payroll.PayrollEditEmployee') useI18n('Payroll.PayrollEditEmployee') @@ -647,37 +680,45 @@ export const PayrollEditEmployeePresentation = ({ )} {timeOff.length > 0 && (
- - {payrollCategory === PayrollCategory.Dismissal - ? t('timeOffTitleDismissal') - : t('timeOffTitle')} - - - {timeOff.map(timeOffEntry => ( - - ))} - + } + withPadding={false} + > + +
)} {payrollCategory === PayrollCategory.Dismissal && timeOff.length > 0 && (
- - {t('finalPayoutTitle')} - {t('finalPayoutDescription')} - - - {timeOff.map(timeOffEntry => ( - - ))} - + } + withPadding={false} + > + +
)} {additionalEarnings.length > 0 && ( diff --git a/src/components/Payroll/PayrollEditEmployee/TimeOffField.test.tsx b/src/components/Payroll/PayrollEditEmployee/TimeOffField.test.tsx index 807f9081c2..3db02a2740 100644 --- a/src/components/Payroll/PayrollEditEmployee/TimeOffField.test.tsx +++ b/src/components/Payroll/PayrollEditEmployee/TimeOffField.test.tsx @@ -7,11 +7,35 @@ import { EmployeePaymentMethod1, } from '@gusto/embedded-api/models/components/employee' import type { PayrollEmployeeCompensationsTypePaidTimeOff } from '@gusto/embedded-api/models/components/payrollemployeecompensationstype' -import { TimeOffField } from './TimeOffField' +import { TimeOffField, TimeOffTypeCell } from './TimeOffField' import type { PayrollEditEmployeeFormValues } from './PayrollEditEmployeePresentation' import { renderWithProviders } from '@/test-utils/renderWithProviders' -const TestWrapper = ({ +const TestFieldWrapper = ({ + timeOffEntry, + shouldVisuallyHideLabel, +}: { + timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff + shouldVisuallyHideLabel?: boolean +}) => { + const methods = useForm({ + defaultValues: { + timeOffCompensations: { + [timeOffEntry.name || '']: timeOffEntry.hours || '0', + }, + }, + }) + + return ( + + Loading...}> + + + + ) +} + +const TestTypeCellWrapper = ({ timeOffEntry, employee, }: { @@ -29,7 +53,7 @@ const TestWrapper = ({ return ( Loading...}> - + ) @@ -77,7 +101,7 @@ describe('TimeOffField', () => { hours: '8.0', } - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getByLabelText('Vacation Hours')).toBeInTheDocument() @@ -85,39 +109,55 @@ describe('TimeOffField', () => { expect(screen.getByDisplayValue('8.0')).toBeInTheDocument() }) - it('should show remaining balance for accrual policies', async () => { + it('should keep an accessible label when visually hidden', async () => { const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = { name: 'Vacation Hours', hours: '8.0', } - renderWithProviders() + renderWithProviders() await waitFor(() => { - expect(screen.getByText(/32\.0.*remaining/)).toBeInTheDocument() + expect(screen.getByLabelText('Vacation Hours')).toBeInTheDocument() }) }) - it('should not show balance for unlimited policies', async () => { + it('should not render if timeOffEntry has no name', () => { + const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = { hours: '8.0' } + + renderWithProviders() + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) +}) + +describe('TimeOffTypeCell', () => { + it('should render the time off name and remaining balance for accrual policies', async () => { const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = { - name: 'Sick Hours', - hours: '0.0', + name: 'Vacation Hours', + hours: '8.0', } - renderWithProviders() + renderWithProviders() await waitFor(() => { - expect(screen.getByLabelText('Sick Hours')).toBeInTheDocument() + expect(screen.getByText('Vacation Hours')).toBeInTheDocument() }) - expect(screen.queryByText('remaining')).not.toBeInTheDocument() + expect(screen.getByText(/32\.0.*remaining/)).toBeInTheDocument() }) - it('should not render if timeOffEntry has no name', () => { - const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = { hours: '8.0' } + it('should not show balance for unlimited policies', async () => { + const timeOffEntry: PayrollEmployeeCompensationsTypePaidTimeOff = { + name: 'Sick Hours', + hours: '0.0', + } - renderWithProviders() + renderWithProviders() - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Sick Hours')).toBeInTheDocument() + }) + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() }) it('should not show balance if no matching policy found', async () => { @@ -126,11 +166,11 @@ describe('TimeOffField', () => { hours: '8.0', } - renderWithProviders() + renderWithProviders() await waitFor(() => { - expect(screen.getByLabelText('Unknown Policy')).toBeInTheDocument() + expect(screen.getByText('Unknown Policy')).toBeInTheDocument() }) - expect(screen.queryByText('remaining')).not.toBeInTheDocument() + expect(screen.queryByText(/remaining/)).not.toBeInTheDocument() }) }) diff --git a/src/components/Payroll/PayrollEditEmployee/TimeOffField.tsx b/src/components/Payroll/PayrollEditEmployee/TimeOffField.tsx index 07e071b6bd..99d35a8765 100644 --- a/src/components/Payroll/PayrollEditEmployee/TimeOffField.tsx +++ b/src/components/Payroll/PayrollEditEmployee/TimeOffField.tsx @@ -1,4 +1,3 @@ -import { useId } from 'react' import { useWatch, useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { Employee } from '@gusto/embedded-api/models/components/employee' @@ -8,12 +7,8 @@ import { Flex, TextInputField } from '@/components/Common' import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext' import { useI18n } from '@/i18n' -/** @internal */ -export interface TimeOffFieldProps { - /** The time-off entry whose hours the field captures. */ - timeOff: PayrollEmployeeCompensationsTypePaidTimeOff - /** The employee whose accrual balance is displayed alongside the field. */ - employee: Employee +function rowAriaId(name: string) { + return `timeoff-balance-${name.replace(/\s+/g, '-').toLowerCase()}` } const TimeOffBalance = ({ @@ -45,22 +40,20 @@ const TimeOffBalance = ({ } /** @internal */ -export interface PayoutTimeOffFieldProps { - /** The time-off entry whose final-payout hours the field captures. */ +export interface TimeOffTypeCellProps { + /** The time-off entry to label, with its accrual balance if eligible. */ timeOff: PayrollEmployeeCompensationsTypePaidTimeOff - /** The employee whose accrual balance is displayed alongside the field. */ + /** The employee whose accrual balance is displayed alongside the label. */ employee: Employee } /** @internal */ -export const PayoutTimeOffField = ({ timeOff, employee }: PayoutTimeOffFieldProps) => { - const { t } = useTranslation('Payroll.PayrollEditEmployee') +export const TimeOffTypeCell = ({ timeOff, employee }: TimeOffTypeCellProps) => { + const { Text } = useComponentContext() useI18n('Payroll.PayrollEditEmployee') const { control } = useFormContext() - const id = useId() - - const hoursUsedThisPeriod = useWatch({ + const watchedValue = useWatch({ control, name: `timeOffCompensations.${timeOff.name}`, }) @@ -69,26 +62,18 @@ export const PayoutTimeOffField = ({ timeOff, employee }: PayoutTimeOffFieldProp return null } - const hoursUsed = parseFloat(hoursUsedThisPeriod || '0') + const hoursUsed = parseFloat(watchedValue || '0') const eligiblePolicy = employee.eligiblePaidTimeOff?.find(policy => policy.name === timeOff.name) return ( - + {timeOff.name} {eligiblePolicy?.accrualBalance && ( )} @@ -96,45 +81,67 @@ export const PayoutTimeOffField = ({ timeOff, employee }: PayoutTimeOffFieldProp } /** @internal */ -export const TimeOffField = ({ timeOff, employee }: TimeOffFieldProps) => { +export interface PayoutTimeOffFieldProps { + /** The time-off entry whose final-payout hours the field captures. */ + timeOff: PayrollEmployeeCompensationsTypePaidTimeOff + /** Whether to visually hide the field's label, e.g. when it's rendered as a table cell alongside a `TimeOffTypeCell`. */ + shouldVisuallyHideLabel?: boolean +} + +/** @internal */ +export const PayoutTimeOffField = ({ + timeOff, + shouldVisuallyHideLabel, +}: PayoutTimeOffFieldProps) => { const { t } = useTranslation('Payroll.PayrollEditEmployee') useI18n('Payroll.PayrollEditEmployee') - const { control } = useFormContext() - const id = useId() + if (!timeOff.name) { + return null + } - const watchedValue = useWatch({ - control, - name: `timeOffCompensations.${timeOff.name}`, - }) + return ( + + ) +} + +/** @internal */ +export interface TimeOffFieldProps { + /** The time-off entry whose hours the field captures. */ + timeOff: PayrollEmployeeCompensationsTypePaidTimeOff + /** Whether to visually hide the field's label, e.g. when it's rendered as a table cell alongside a `TimeOffTypeCell`. */ + shouldVisuallyHideLabel?: boolean +} + +/** @internal */ +export const TimeOffField = ({ timeOff, shouldVisuallyHideLabel }: TimeOffFieldProps) => { + const { t } = useTranslation('Payroll.PayrollEditEmployee') + useI18n('Payroll.PayrollEditEmployee') if (!timeOff.name) { return null } - const hoursUsed = parseFloat(watchedValue || '0') - const eligiblePolicy = employee.eligiblePaidTimeOff?.find(policy => policy.name === timeOff.name) - return ( - - - {eligiblePolicy?.accrualBalance && ( - - )} - + ) } diff --git a/src/i18n/en/Payroll.PayrollEditEmployee.json b/src/i18n/en/Payroll.PayrollEditEmployee.json index e563117361..80d8886bb2 100644 --- a/src/i18n/en/Payroll.PayrollEditEmployee.json +++ b/src/i18n/en/Payroll.PayrollEditEmployee.json @@ -19,6 +19,10 @@ "timeOffBalance": { "remaining": "{{balance}} remaining" }, + "timeOffColumns": { + "type": "Type", + "hours": "Hours" + }, "additionalEarningsTitle": "Additional earnings", "reimbursementTitle": "Reimbursements", "reimbursementDescriptionLabel": "Description", diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts index b7a5ffbfc9..ae0f077869 100644 --- a/src/i18n/types.d.ts +++ b/src/i18n/types.d.ts @@ -6462,6 +6462,12 @@ export namespace Translations { /** @defaultValue `"{{balance}} remaining"` */ remaining: string } + timeOffColumns: { + /** @defaultValue `"Type"` */ + type: string + /** @defaultValue `"Hours"` */ + hours: string + } /** @defaultValue `"Additional earnings"` */ additionalEarningsTitle: string /** @defaultValue `"Reimbursements"` */