diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx index 3c61f6b3a1..83ddbae96a 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculator.test.tsx @@ -60,7 +60,7 @@ describe('PdsGoalCalculator', () => { expect(continueButton).not.toBeDisabled(); }); - it('re-enables Continue when switching from Hourly to Salaried hides the only invalid field', async () => { + it('re-enables Continue after switching from Hourly to Salaried and entering a new Pay Rate', async () => { const { findByRole, getByRole, queryByRole } = render( { queryByRole('spinbutton', { name: 'Hours Worked' }), ).not.toBeInTheDocument(); }); + // Switching Pay Type clears payRate, so the user must re-enter it before + // Continue becomes enabled. + const payRateInput = await findByRole('spinbutton', { name: 'Pay Rate' }); + userEvent.type(payRateInput, '50000'); + await waitFor(() => expect(continueButton).not.toBeDisabled()); }); diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.test.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.test.tsx index 20618995e1..6355ec195d 100644 --- a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.test.tsx @@ -45,11 +45,11 @@ describe('AnnualReimbursableSection', () => { expect(getAllByRole('row')).toHaveLength(5); }); - it('renders the info tooltip icon with an accessible label', async () => { - const { findByLabelText } = render(); + it('renders the description text below the heading', async () => { + const { findByText } = render(); expect( - await findByLabelText( + await findByText( 'This annual amount will be divided by 12 when added to the total.', ), ).toBeInTheDocument(); diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx index fa98617825..5ca9cd02a8 100644 --- a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx @@ -30,7 +30,7 @@ export const AnnualReimbursableSection: React.FC = () => { return ( ( +interface TestComponentProps { + ministryInternet?: number; +} + +const TestComponent: React.FC = ({ + ministryInternet = 30, +}) => ( ( ); describe('MonthlyReimbursableSection', () => { + it('renders the description text below the heading', async () => { + const { findByText } = render(); + + expect( + await findByText( + 'Ministry Cell Phone and Ministry Internet reimbursements are capped at the per-month maximums shown next to each field name. Amounts entered above the maximum will be saved as the maximum.', + ), + ).toBeInTheDocument(); + }); + + it('shows each maximum inline with the field name', async () => { + const { findByRole, getByRole } = render(); + + expect( + await findByRole('gridcell', { + name: /Ministry Cell Phone \(max \$35\/mo\)/, + }), + ).toBeInTheDocument(); + expect( + getByRole('gridcell', { + name: /Ministry Internet \(max \$30\/mo\)/, + }), + ).toBeInTheDocument(); + }); + + it('renders an info icon on the cell phone and internet rows with a prepopulation tooltip', async () => { + const { findAllByLabelText, findByRole } = render(); + + const icons = await findAllByLabelText(prepopulatedTooltipText); + expect(icons).toHaveLength(2); + + userEvent.hover(icons[0]); + expect(await findByRole('tooltip')).toHaveTextContent( + prepopulatedTooltipText, + ); + }); + it('renders the section heading and column headers', async () => { const { findByRole, getByRole } = render(); @@ -57,7 +104,9 @@ describe('MonthlyReimbursableSection', () => { it('renders a row for every monthly field plus the subtotal', async () => { const { findByRole, getAllByRole } = render(); - await findByRole('gridcell', { name: 'Ministry Cell Phone' }); + await findByRole('gridcell', { + name: /Ministry Cell Phone \(max \$35\/mo\)/, + }); // 1 header row + 6 field rows + 1 subtotal row expect(getAllByRole('row')).toHaveLength(8); }); @@ -82,16 +131,23 @@ describe('MonthlyReimbursableSection', () => { ); }); - it('shows an error and skips saving when an amount exceeds the configured maximum', async () => { - const { findByRole } = render(); + it('clips an over-max amount to the configured maximum, saves the clipped value, and notifies the user', async () => { + const { findByRole, findByText } = render( + , + ); - await editAmountCell(findByRole, 'Ministry Cell Phone', '999'); + await editAmountCell(findByRole, 'Ministry Internet', '999'); - expect(await findByRole('alert')).toHaveTextContent( - 'Amount cannot exceed $35', + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { id: 'goal-1', ministryInternet: 30 }, + }), ); - - expect(mutationSpy).not.toHaveGraphqlOperation('UpdatePdsGoalCalculation'); + expect( + await findByText( + 'Ministry Internet (max $30/mo) reduced to its maximum of $30.', + ), + ).toBeInTheDocument(); }); it('shows an error and skips saving when a negative amount is entered', async () => { diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx index 49220d9382..855f28aa61 100644 --- a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat } from 'src/lib/intlFormat'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; import { calculateReimbursableTotals } from '../calculations/reimbursableExpenses'; import { @@ -10,6 +12,7 @@ import { export const MonthlyReimbursableSection: React.FC = () => { const { t } = useTranslation(); + const locale = useLocale(); const { calculation } = usePdsGoalCalculator(); const { goalMiscConstants } = useGoalCalculatorConstants(); const phoneMax = goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.PHONE?.fee; @@ -20,16 +23,34 @@ export const MonthlyReimbursableSection: React.FC = () => { ? calculateReimbursableTotals(calculation).monthlySubtotal : 0; + const formatMaxPerMonth = (max: number) => currencyFormat(max, 'USD', locale); + + const prepopulatedTooltip = t( + 'Pre-filled with the maximum allowed amount. Edit to a lower value if needed.', + ); + const fields: ReimbursableField[] = [ { fieldName: 'ministryCellPhone', - label: t('Ministry Cell Phone'), + label: + phoneMax !== undefined + ? t('Ministry Cell Phone (max {{max}}/mo)', { + max: formatMaxPerMonth(phoneMax), + }) + : t('Ministry Cell Phone'), max: phoneMax, + tooltip: prepopulatedTooltip, }, { fieldName: 'ministryInternet', - label: t('Ministry Internet'), + label: + internetMax !== undefined + ? t('Ministry Internet (max {{max}}/mo)', { + max: formatMaxPerMonth(internetMax), + }) + : t('Ministry Internet'), max: internetMax, + tooltip: prepopulatedTooltip, }, { fieldName: 'mpdNewsletter', label: t('MPD Newsletter') }, { fieldName: 'mpdMiscellaneous', label: t('MPD Miscellaneous') }, @@ -43,6 +64,9 @@ export const MonthlyReimbursableSection: React.FC = () => { return ( { expect(getAllByRole('row')).toHaveLength(4); }); - it('clears the error after a subsequent valid edit', async () => { - const { findByRole, queryByRole } = render(); + it('clips an over-max amount to the field maximum, saves the clipped value, and notifies the user', async () => { + const { findByRole, findByText } = render(); await editAmountCell(findByRole, 'Ministry Cell Phone', '999'); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { id: 'goal-1', ministryCellPhone: 35 }, + }), + ); + expect( + await findByText('Ministry Cell Phone reduced to its maximum of $35.'), + ).toBeInTheDocument(); + }); + + it('clears the negative-amount error after a subsequent valid edit', async () => { + const { findByRole, queryByRole } = render(); + + await editAmountCell(findByRole, 'MPD Newsletter', '-5'); expect(await findByRole('alert')).toHaveTextContent( - 'Amount cannot exceed $35', + 'Amount must be positive', ); - await editAmountCell(findByRole, 'Ministry Cell Phone', '20'); + await editAmountCell(findByRole, 'MPD Newsletter', '20'); await waitFor(() => expect(queryByRole('alert')).not.toBeInTheDocument()); }); diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx index 6f0e885744..acf9eea812 100644 --- a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx @@ -4,12 +4,14 @@ import { Box, Card, FormHelperText, + IconButton, Stack, Tooltip, Typography, styled, } from '@mui/material'; import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; @@ -24,17 +26,19 @@ export interface ReimbursableField { fieldName: ReimbursableFieldName; label: string; max?: number; + tooltip?: string; } interface ReimbursableRow { id: ReimbursableFieldName | 'total'; label: string; amount: number; + tooltip?: string; } interface ReimbursableExpensesGridProps { title: string; - titleTooltip?: string; + description?: string; fields: ReimbursableField[]; subtotalLabel: string; subtotalValue: number; @@ -58,7 +62,7 @@ export const ReimbursableExpensesGrid: React.FC< ReimbursableExpensesGridProps > = ({ title, - titleTooltip, + description, fields, subtotalLabel, subtotalValue, @@ -66,6 +70,7 @@ export const ReimbursableExpensesGrid: React.FC< }) => { const { t } = useTranslation(); const locale = useLocale(); + const { enqueueSnackbar } = useSnackbar(); const { calculation } = usePdsGoalCalculator(); const saveField = useSaveField(); const [cellErrors, setCellErrors] = useState>({}); @@ -74,23 +79,12 @@ export const ReimbursableExpensesGrid: React.FC< return null; } - const validateAmount = (field: ReimbursableField, amount: number) => { - if (amount < 0) { - return t('Amount must be positive'); - } - if (field.max !== undefined && amount > field.max) { - return t('Amount cannot exceed {{max}}', { - max: currencyFormat(field.max, 'USD', locale), - }); - } - return null; - }; - const rows: ReimbursableRow[] = [ ...fields.map((field) => ({ id: field.fieldName, label: field.label, amount: calculation[field.fieldName] ?? 0, + tooltip: field.tooltip, })), { id: 'total', label: subtotalLabel, amount: subtotalValue }, ]; @@ -101,25 +95,62 @@ export const ReimbursableExpensesGrid: React.FC< return newRow; } - const { amount } = newRow; const cellKey = `${field.fieldName}-amount`; - const error = validateAmount(field, amount); + + if (newRow.amount < 0) { + setCellErrors((prev) => ({ + ...prev, + [cellKey]: t('Amount must be positive'), + })); + return newRow; + } + + let amount = newRow.amount; + if (field.max !== undefined && newRow.amount > field.max) { + amount = field.max; + enqueueSnackbar( + t('{{label}} reduced to its maximum of {{max}}.', { + label: field.label, + max: currencyFormat(field.max, 'USD', locale), + }), + { variant: 'info' }, + ); + } setCellErrors((prev) => { - const next = { ...prev }; - if (error) { - next[cellKey] = error; - } else { - delete next[cellKey]; + if (!(cellKey in prev)) { + return prev; } + const next = { ...prev }; + delete next[cellKey]; return next; }); - if (!error) { - await saveField({ [field.fieldName]: amount }); - } + await saveField({ [field.fieldName]: amount }); - return newRow; + return { ...newRow, amount }; + }; + + const renderLabelCell = (params: GridRenderCellParams) => { + const { row } = params; + if (!row.tooltip) { + return row.label; + } + return ( + + {row.label} + + + + + + + ); }; const renderAmountCell = (params: GridRenderCellParams) => { @@ -145,6 +176,7 @@ export const ReimbursableExpensesGrid: React.FC< flex: 1, minWidth: 200, sortable: false, + renderCell: renderLabelCell, }, { field: 'amount', @@ -162,14 +194,14 @@ export const ReimbursableExpensesGrid: React.FC< return ( <> - + {title} - {titleTooltip && ( - - - + {description && ( + + {description} + )} - + { + const { t } = useTranslation(); + return ( <> + + + {t('Reimbursable Expenses')} + + + {t( + 'Enter the ministry expenses you are reimbursed for each year. Monthly entries are used as-is and annual entries are divided by 12; the combined total is included in your support goal.', + )} + + diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx index 269ad38336..23b56d592a 100644 --- a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; import { TotalReimbursableSection } from './TotalReimbursableSection'; @@ -40,24 +39,16 @@ describe('TotalReimbursableSection', () => { ).toBeInTheDocument(); }); - it('renders the info icon with an accessible label', async () => { - const { findByLabelText } = render(); + it('renders the description text below the heading', async () => { + const { findByText } = render(); expect( - await findByLabelText('Total reimbursable information'), + await findByText( + 'Reimbursable expenses have a $300 per month minimum. If the sum of your monthly entries (plus annual entries divided by 12) falls below $300, the $300 minimum is used in your support goal instead.', + ), ).toBeInTheDocument(); }); - it('shows a tooltip describing the $300 minimum floor on hover', async () => { - const { findByLabelText, findByRole } = render(); - - userEvent.hover(await findByLabelText('Total reimbursable information')); - - expect(await findByRole('tooltip')).toHaveTextContent( - 'The total is the greater of the $300 minimum or your calculated amount.', - ); - }); - it('applies the $300 floor when the calculated amount is below it', async () => { const { getByTestId } = render( , diff --git a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx index 846a2ca8ba..987863a25b 100644 --- a/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx +++ b/src/components/HrTools/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx @@ -1,12 +1,4 @@ -import InfoIcon from '@mui/icons-material/Info'; -import { - Card, - CardContent, - Stack, - Tooltip, - Typography, - styled, -} from '@mui/material'; +import { Card, CardContent, Typography, styled } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; @@ -36,24 +28,15 @@ export const TotalReimbursableSection: React.FC = () => { return ( - - - {t('Total Reimbursable Expenses')} - - - - - + {t('Total Reimbursable Expenses')} + + {t( + 'Reimbursable expenses have a {{floor}} per month minimum. If the sum of your monthly entries (plus annual entries divided by 12) falls below {{floor}}, the {{floor}} minimum is used in your support goal instead.', + { + floor: currencyFormat(REIMBURSABLE_FLOOR, 'USD', locale), + }, + )} + {currencyFormat(total, 'USD', locale)} diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx index 93c4e20461..50a7a1e29b 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.test.tsx @@ -84,8 +84,8 @@ describe('HoursPerWeekGrid', () => { await waitForDataToLoad(); // Regular Week 40hrs * 48wks = 1920 total hours, 48 total weeks - // Average = 1920 / 48 = 40.0 - expect(await findByText('40.0')).toBeInTheDocument(); + // Average = 1920 / 48 = 40.00 + expect(await findByText('40.00')).toBeInTheDocument(); }); it('adds a new entry when clicking add button', async () => { @@ -225,7 +225,7 @@ describe('HoursPerWeekGrid', () => { }); userEvent.tab(); - // Average = 20 hrs * 48 wks / 48 wks = 20.0 + // Average = 20 hrs * 48 wks / 48 wks = 20.00 await waitFor(() => expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { attributes: { @@ -272,6 +272,51 @@ describe('HoursPerWeekGrid', () => { expect(onApply).toHaveBeenCalled(); }); + it('rounds the applied average to two decimal places', async () => { + const onApply = jest.fn(); + const { findByText, getByDisplayValue, getByRole } = render( + + + , + ); + + await waitForDataToLoad(); + + // Edit Travel weeks from 0 to 4 (48 + 4 = 52 total) + const travelRow = (await findByText('Travel')).closest('[role="row"]'); + const travelWeeksCell = travelRow?.querySelector('[data-field="weeks"]'); + userEvent.dblClick(travelWeeksCell!); + await waitFor(() => { + const input = getByDisplayValue('0'); + userEvent.clear(input); + userEvent.type(input, '4'); + }); + userEvent.tab(); + + // Edit Travel hours from 0 to 7 — average becomes (40*48 + 7*4) / 52 = 37.4615... + await waitFor(() => { + const travelHoursCell = travelRow?.querySelector( + '[data-field="hoursPerWeek"]', + ); + userEvent.dblClick(travelHoursCell!); + }); + await waitFor(() => { + const input = getByDisplayValue('0'); + userEvent.clear(input); + userEvent.type(input, '7'); + }); + userEvent.tab(); + + const applyButton = getByRole('button', { name: 'Apply to Hours Worked' }); + await waitFor(() => expect(applyButton).not.toBeDisabled()); + userEvent.click(applyButton); + + expect(onApply).toHaveBeenCalledWith(37.46); + }); + it('shows delete button only for custom entries on hover', async () => { const { findByText, findByLabelText, getByText } = render( { expect(await findByLabelText('Delete')).toBeInTheDocument(); }); - it('renders 0.0 (not NaN) when total weeks is zero', async () => { + it('renders 0.00 (not NaN) when total weeks is zero', async () => { const { findByText, getByDisplayValue, queryByText } = render( { await waitFor(() => { expect(queryByText('NaN')).not.toBeInTheDocument(); }); - expect(await findByText('0.0')).toBeInTheDocument(); + expect(await findByText('0.00')).toBeInTheDocument(); }); it('clamps weeks to 52 total across all entries', async () => { @@ -372,7 +417,7 @@ describe('HoursPerWeekGrid', () => { await waitFor(() => { expect(queryByText('New Entry')).not.toBeInTheDocument(); }); - // Default Regular Week 40*48/48 = 40.0 remains - expect(getByText('40.0')).toBeInTheDocument(); + // Default Regular Week 40*48/48 = 40.00 remains + expect(getByText('40.00')).toBeInTheDocument(); }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx index dc0b9976ac..5d8af2daa7 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/HoursPerWeekGrid/HoursPerWeekGrid.tsx @@ -24,6 +24,8 @@ import { import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { BaseGrid } from 'src/components/HrTools/GoalCalculator/SharedComponents/GoalCalculatorGrid/BaseGrid'; +import { useLocale } from 'src/hooks/useLocale'; +import { numberFormat } from 'src/lib/intlFormat'; import { useCreateDesignationSupportHoursItemMutation, useDeleteDesignationSupportHoursItemMutation, @@ -64,6 +66,7 @@ export const HoursPerWeekGrid: React.FC = ({ onApply, }) => { const { t } = useTranslation(); + const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); const { calculation, trackMutation } = usePdsGoalCalculator(); const [createHoursItem] = useCreateDesignationSupportHoursItemMutation(); @@ -110,10 +113,16 @@ export const HoursPerWeekGrid: React.FC = ({ [entries], ); - const averageHoursPerWeek = useMemo( - () => (totalWeeks > 0 ? totalHours / totalWeeks : 0), - [totalHours, totalWeeks], - ); + const averageHoursPerWeek = useMemo(() => { + if (totalWeeks <= 0) { + return 0; + } + // WYSIWYS boundary: pre-round here so the value displayed via numberFormat + // matches the value submitted via saveField/onApply. Math.round uses + // half-away-from-zero; Intl.NumberFormat uses half-to-even — they only + // agree because the formatter receives this already-rounded value. + return Math.round((totalHours / totalWeeks) * 100) / 100; + }, [totalHours, totalWeeks]); const weeksRemaining = MAX_TOTAL_WEEKS - totalWeeks; @@ -266,7 +275,10 @@ export const HoursPerWeekGrid: React.FC = ({ (sum, e) => sum + e.hoursPerWeek * e.weeks, 0, ); - const newAverage = newTotalWeeks > 0 ? newTotalHours / newTotalWeeks : 0; + const newAverage = + newTotalWeeks > 0 + ? Math.round((newTotalHours / newTotalWeeks) * 100) / 100 + : 0; try { if (!entryId.startsWith('temp-') && !entryId.startsWith('default-')) { @@ -329,7 +341,10 @@ export const HoursPerWeekGrid: React.FC = ({ (sum, e) => sum + e.hoursPerWeek * e.weeks, 0, ); - const newAverage = newTotalWeeks > 0 ? newTotalHours / newTotalWeeks : 0; + const newAverage = + newTotalWeeks > 0 + ? Math.round((newTotalHours / newTotalWeeks) * 100) / 100 + : 0; saveField({ averageHoursPerWeek: newAverage, }); @@ -490,7 +505,10 @@ export const HoursPerWeekGrid: React.FC = ({ {t('Average Hours Worked Per Week')} - {averageHoursPerWeek.toFixed(1)} + {numberFormat(averageHoursPerWeek, locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index 430ea947f1..a667553266 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -256,6 +256,23 @@ describe('SetupStep', () => { ).toBeInTheDocument(); }); + it('renders Geographic Multiplier option without percentage when multiplier is 0', async () => { + const { findByRole, queryByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + }); + + const input = await findByRole('combobox', { + name: 'Geographic Multiplier', + }); + await waitFor(() => expect(input).not.toBeDisabled()); + userEvent.click(input); + + expect(await findByRole('option', { name: 'None' })).toBeInTheDocument(); + expect( + queryByRole('option', { name: /None \(.*%\)/ }), + ).not.toBeInTheDocument(); + }); + it('fires mutation when Goal Name is changed', async () => { const { findByRole } = renderSetup({ calculationMock: { ...fullTimeSalariedMock, name: 'Test Goal' }, @@ -290,7 +307,7 @@ describe('SetupStep', () => { }); await waitFor(() => expect(input).not.toBeDisabled()); userEvent.type(input, 'Orlando'); - const option = await findByRole('option', { name: 'Orlando, FL' }); + const option = await findByRole('option', { name: 'Orlando, FL (6%)' }); userEvent.click(option); await waitFor(() => @@ -378,6 +395,20 @@ describe('SetupStep', () => { ).toBeInTheDocument(); }); + it('renders both sentences of the Form Type helper text', async () => { + const { findByText } = renderSetup({ + calculationMock: { + ...fullTimeSalariedMock, + formType: DesignationSupportFormType.Detailed, + }, + }); + + expect( + await findByText(/Default includes reimbursable expenses/), + ).toBeInTheDocument(); + expect(await findByText(/Simple excludes them/)).toBeInTheDocument(); + }); + it('renders the Form Type select with both options', async () => { const { findByRole, getByRole } = renderSetup({ calculationMock: { @@ -448,6 +479,93 @@ describe('SetupStep', () => { ); }); + it('clears Pay Rate when switching from Hourly to Salaried', async () => { + const { findByRole, getByRole } = renderSetup({ + calculationMock: fullTimeHourlyMock, + onCall: mutationSpy, + }); + + const payTypeSelect = await findByRole('combobox', { name: /Pay Type/ }); + await waitFor(() => expect(payTypeSelect).toHaveTextContent('Hourly')); + + userEvent.click(payTypeSelect); + userEvent.click(getByRole('option', { name: 'Salaried' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + id: 'goal-1', + salaryOrHourly: DesignationSupportSalaryType.Salaried, + payRate: null, + }, + }), + ); + }); + + it('clears Pay Rate when switching from Salaried to Hourly', async () => { + const { findByRole, getByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + onCall: mutationSpy, + }); + + const payTypeSelect = await findByRole('combobox', { name: /Pay Type/ }); + await waitFor(() => expect(payTypeSelect).toHaveTextContent('Salaried')); + + userEvent.click(payTypeSelect); + userEvent.click(getByRole('option', { name: 'Hourly' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + id: 'goal-1', + salaryOrHourly: DesignationSupportSalaryType.Hourly, + payRate: null, + }, + }), + ); + }); + + it('disables Pay Type while a Pay Type save is in flight', async () => { + const { findByRole, getByRole } = renderSetup({ + calculationMock: fullTimeSalariedMock, + onCall: mutationSpy, + }); + + const payTypeSelect = await findByRole('combobox', { name: /Pay Type/ }); + await waitFor(() => expect(payTypeSelect).toHaveTextContent('Salaried')); + + userEvent.click(payTypeSelect); + userEvent.click(getByRole('option', { name: 'Hourly' })); + + // While the mutation is in flight, the select is disabled so a concurrent + // save cannot race the atomic salaryOrHourly + payRate: null write. + await waitFor(() => + expect(payTypeSelect).toHaveAttribute('aria-disabled', 'true'), + ); + // After the mutation resolves, the select is re-enabled. + await waitFor(() => + expect(payTypeSelect).not.toHaveAttribute('aria-disabled', 'true'), + ); + }); + + it('does not fire a mutation when Pay Type is set to its current value', async () => { + mutationSpy.mockClear(); + const { findByRole, getByRole } = renderSetup({ + calculationMock: fullTimeHourlyMock, + onCall: mutationSpy, + }); + + const payTypeSelect = await findByRole('combobox', { name: /Pay Type/ }); + await waitFor(() => expect(payTypeSelect).toHaveTextContent('Hourly')); + + userEvent.click(payTypeSelect); + userEvent.click(getByRole('option', { name: 'Hourly' })); + + // Yield to the microtask queue so any pending mutation would have fired. + await new Promise((r) => setTimeout(r, 0)); + expect(mutationSpy).not.toHaveGraphqlOperation('UpdatePdsGoalCalculation'); + }); + it('shows the 403b Contribution Percentage field when formType is null (legacy goal)', async () => { const { findByRole } = renderSetup({ calculationMock: { diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index fda526e1da..29acf5267b 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -24,6 +24,8 @@ import { DesignationSupportStatus, } from 'src/graphql/types.generated'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; +import { useLocale } from 'src/hooks/useLocale'; +import { percentageFormat } from 'src/lib/intlFormat'; import { CurrencyAdornment, PercentageAdornment, @@ -36,7 +38,8 @@ import { HoursPerWeekGrid } from './HoursPerWeekGrid/HoursPerWeekGrid'; export const SetupStep: React.FC = () => { const { t } = useTranslation(); const theme = useTheme(); - const { calculation, hcmUser, setRightPanelContent } = usePdsGoalCalculator(); + const { calculation, hcmUser, isMutating, setRightPanelContent } = + usePdsGoalCalculator(); const { data: userData } = useGetUserQuery(); const schema = useMemo( () => @@ -74,12 +77,21 @@ export const SetupStep: React.FC = () => { ); const { goalGeographicConstantMap } = useGoalCalculatorConstants(); const saveField = useSaveField(); + const locale = useLocale(); const locations = useMemo( () => Array.from(goalGeographicConstantMap.keys()), [goalGeographicConstantMap], ); + const getLocationLabel = (location: string) => { + const multiplier = goalGeographicConstantMap.get(location); + if (multiplier === undefined || multiplier === 0) { + return location; + } + return `${location} (${percentageFormat(multiplier, locale)})`; + }; + const isSalaried = calculation?.salaryOrHourly === DesignationSupportSalaryType.Salaried; const isPartTime = calculation?.status === DesignationSupportStatus.PartTime; @@ -148,9 +160,20 @@ export const SetupStep: React.FC = () => { schema={schema} select label={t('Form Type')} - helperText={t( - 'Default includes reimbursable expenses and 403b contributions in the goal total. Simple excludes them; existing entries are preserved and will count again if you switch back.', - )} + helperText={ + <> + + {t( + 'Default includes reimbursable expenses and 403b contributions in the goal total.', + )} + + + {t( + 'Simple excludes them; existing entries are preserved and will count again if you switch back.', + )} + + + } > {t('Default')} @@ -178,11 +201,25 @@ export const SetupStep: React.FC = () => { - { + const newValue = event.target + .value as DesignationSupportSalaryType; + if (newValue !== calculation?.salaryOrHourly) { + saveField({ salaryOrHourly: newValue, payRate: null }); + } + }} > {t('Salaried')} @@ -190,7 +227,7 @@ export const SetupStep: React.FC = () => { {t('Hourly')} - + @@ -269,6 +306,7 @@ export const SetupStep: React.FC = () => { saveField({ geographicLocation: newValue }) diff --git a/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportStep.tsx b/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportStep.tsx index 95f167a6f8..cba5db5c82 100644 --- a/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SummaryReport/SummaryReportStep.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Box, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import Loading from 'src/components/Loading'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useAccountListSupportRaisedQuery } from '../../GoalCalculator/Shared/GoalLineItems.generated'; @@ -6,6 +8,7 @@ import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; import { PdsSummaryTable } from './PdsSummaryTable'; export const SummaryReportStep: React.FC = () => { + const { t } = useTranslation(); const accountListId = useAccountListId() ?? ''; const { calculationLoading } = usePdsGoalCalculator(); const { data } = useAccountListSupportRaisedQuery({ @@ -17,5 +20,19 @@ export const SummaryReportStep: React.FC = () => { return ; } - return ; + return ( + <> + + + {t('Summary')} + + + {t( + 'Review your complete PDS goal and current support progress. Use this summary to share your goal and track your fundraising.', + )} + + + + + ); }; diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.tsx index b7bb8663a5..b3571e4668 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/OtherSection.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Divider, Typography } from '@mui/material'; +import { Box, Divider, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; import { useDataGridLocaleText } from 'src/hooks/useMuiLocaleText'; @@ -41,9 +41,16 @@ export const OtherSection: React.FC = () => { return ( <> - - {t('Other')} - + + + {t('Other')} + + + {t( + 'Additional support items beyond your base salary, including benefits, contributions, fees, and reimbursable expenses.', + )} + + { return ( <> - - {t('Salary')} - + + + {t('Salary')} + + + {t( + 'Your gross monthly pay broken down by category, calculated from the values entered in Setup.', + )} + + { + const { t } = useTranslation(); + return ( <> + + + {t('Support Items')} + + + {t( + 'A breakdown of the items that make up your support goal, calculated from the information you entered in Setup and Reimbursable Expenses.', + )} + + diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx index 41081d08eb..3193cbb055 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx @@ -102,6 +102,42 @@ describe('buildSalaryBreakdownRows', () => { expect(byId['total']).toBeCloseTo(4680.0, 2); }); + it('includes the geographic location as a suffix on the multiplier amount and in the formula when set', () => { + const rows = buildSalaryBreakdownRows( + { ...salariedCalculation, geographicLocation: 'Atlanta' }, + constants, + 'en-US', + i18n.t, + ); + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + + expect(byId['geographic-multiplier'].category).toBe( + 'Geographic Multiplier', + ); + expect(byId['geographic-multiplier'].amountSuffix).toBe('(Atlanta)'); + expect(byId['gross-monthly-pay'].formula).toBe( + 'Monthly Base × (1 + Geographic Multiplier (Atlanta))', + ); + }); + + it('omits the location suffix when geographicLocation is null', () => { + const rows = buildSalaryBreakdownRows( + salariedCalculation, + constants, + 'en-US', + i18n.t, + ); + const byId = Object.fromEntries(rows.map((r) => [r.id, r])); + + expect(byId['geographic-multiplier'].category).toBe( + 'Geographic Multiplier', + ); + expect(byId['geographic-multiplier'].amountSuffix).toBeUndefined(); + expect(byId['gross-monthly-pay'].formula).toBe( + 'Monthly Base × (1 + Geographic Multiplier)', + ); + }); + it('inserts hours-per-week and monthly-base rows for hourly', () => { const rows = buildSalaryBreakdownRows( hourlyCalculation, diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx index cd8138c7bc..135c6f9bd1 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx @@ -23,11 +23,16 @@ const CategoryCellBox = styled(Box)({ height: '100%', }); +const AmountSuffix = styled('span')(({ theme }) => ({ + marginLeft: theme.spacing(1), +})); + export interface SalaryBreakdownRow { id: string; category: string; formula?: string; amount: number; + amountSuffix?: string; format: AmountFormat; testId?: string; } @@ -39,7 +44,7 @@ export const buildSalaryBreakdownRows = ( t: TFunction, ): SalaryBreakdownRow[] => { const { geographicMultiplier, employerFicaRate } = constants; - const { salaryOrHourly } = calculation; + const { salaryOrHourly, geographicLocation } = calculation; const payRate = calculation.payRate ?? 0; const hoursPerWeek = calculation.hoursWorkedPerWeek ?? 0; const isSalaried = salaryOrHourly === DesignationSupportSalaryType.Salaried; @@ -47,6 +52,15 @@ export const buildSalaryBreakdownRows = ( const { monthlyBase, grossMonthlyPay, employerFica, subtotal } = calculateSalaryTotals(calculation, constants); + const geographicMultiplierSuffix = geographicLocation + ? `(${geographicLocation})` + : undefined; + const grossMonthlyPayFormula = geographicLocation + ? t('Monthly Base × (1 + Geographic Multiplier ({{location}}))', { + location: geographicLocation, + }) + : t('Monthly Base × (1 + Geographic Multiplier)'); + return [ { id: 'pay-rate', @@ -77,12 +91,13 @@ export const buildSalaryBreakdownRows = ( id: 'geographic-multiplier', category: t('Geographic Multiplier'), amount: geographicMultiplier, + amountSuffix: geographicMultiplierSuffix, format: 'percentage', }, { id: 'gross-monthly-pay', category: t('Gross Monthly Pay'), - formula: t('Monthly Base × (1 + Geographic Multiplier)'), + formula: grossMonthlyPayFormula, amount: grossMonthlyPay, format: 'currency', testId: 'gross-monthly-pay', @@ -135,14 +150,19 @@ export const buildSalaryBreakdownColumns = ( align: 'left', headerAlign: 'left', renderCell: (params: GridRenderCellParams) => { - const { amount, format, testId } = params.row; + const { amount, amountSuffix, format, testId } = params.row; const formatted = format === 'currency' ? currencyFormat(amount, 'USD', locale) : format === 'percentage' ? percentageFormat(amount, locale) : numberFormat(amount, locale); - return {formatted}; + return ( + + {formatted} + {amountSuffix && {amountSuffix}} + + ); }, }, ]; diff --git a/src/lib/intlFormat.test.ts b/src/lib/intlFormat.test.ts index beedcdf0ca..87582a71ae 100644 --- a/src/lib/intlFormat.test.ts +++ b/src/lib/intlFormat.test.ts @@ -46,6 +46,35 @@ describe('intlFormat', () => { expect(currencyFormat(6000.5, 'JPY', 'ja-JP')).toEqual('¥6,001'); }); }); + + describe('with options', () => { + it('respects minimumFractionDigits', () => { + expect(numberFormat(40, 'en-US', { minimumFractionDigits: 2 })).toEqual( + '40.00', + ); + }); + + it('respects maximumFractionDigits', () => { + expect( + numberFormat(0.1234, 'en-US', { maximumFractionDigits: 2 }), + ).toEqual('0.12'); + }); + + it('applies fraction-digit options to the NaN fallback', () => { + expect( + numberFormat(NaN, 'en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + ).toEqual('0.00'); + }); + + it('formats per locale', () => { + expect( + numberFormat(0.18, 'de-DE', { minimumFractionDigits: 2 }), + ).toEqual('0,18'); + }); + }); }); describe('percentageFormat', () => { diff --git a/src/lib/intlFormat.ts b/src/lib/intlFormat.ts index f64dc3a723..dcd6ad0bec 100644 --- a/src/lib/intlFormat.ts +++ b/src/lib/intlFormat.ts @@ -1,8 +1,19 @@ import { DateTime } from 'luxon'; -export const numberFormat = (value: number, locale: string): string => +interface NumberFormatOptions { + minimumFractionDigits?: number; + maximumFractionDigits?: number; +} + +export const numberFormat = ( + value: number, + locale: string, + options?: NumberFormatOptions, +): string => new Intl.NumberFormat(locale, { style: 'decimal', + minimumFractionDigits: options?.minimumFractionDigits, + maximumFractionDigits: options?.maximumFractionDigits, }).format(Number.isFinite(value) ? value : 0); interface PercentageFormatOptions {