From fa736728f94eaf310694cde6f128afc7dd923418 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 14:07:14 -0400 Subject: [PATCH 01/11] Use miscGoalConstant --- .../PdsGoalCalculatorTestWrapper.tsx | 19 +++ .../SupportItem/OtherSection.tsx | 151 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index 84aafa76ab..338ee3522d 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -174,6 +174,25 @@ export const PdsGoalCalculatorTestWrapper: React.FC< label: MpdGoalMiscConstantLabelEnum.EmployerFicaRate, fee: 0.08, }, + { + category: + MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: + MpdGoalMiscConstantLabelEnum.PartTimeWorkCompensation, + fee: 0.17, + }, + { + category: + MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: MpdGoalMiscConstantLabelEnum.AttritionRate, + fee: 0.06, + }, + { + category: + MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: MpdGoalMiscConstantLabelEnum.CreditCardFeeRate, + fee: 0.06, + }, ], }, constantsMock, diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx new file mode 100644 index 0000000000..865f71acbf --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx @@ -0,0 +1,151 @@ +import React, { useMemo } from 'react'; +import { Box, Typography, styled } from '@mui/material'; +import { DataGrid } from '@mui/x-data-grid'; +import { useTranslation } from 'react-i18next'; +import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; +import { useLocale } from 'src/hooks/useLocale'; +import { useDataGridLocaleText } from 'src/hooks/useMuiLocaleText'; +import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { OtherExpensesConstants } from '../calculations/OtherExpenses'; +import { calculateReimbursableTotals } from '../calculations/reimbursableExpenses'; +import { calculateSalaryTotals } from '../calculations/salaryCalculation'; +import { + buildOtherBreakdownColumns, + buildOtherBreakdownRows, +} from './otherBreakdown'; + +const GridContainer = styled(Box)({ + display: 'flex', + flexDirection: 'column', +}); + +const StyledGrid = styled(DataGrid)(({ theme }) => ({ + fontSize: theme.typography.body1.fontSize, + '.MuiDataGrid-columnHeaderTitle': { + fontWeight: 'bold', + color: theme.palette.mpdxBlue.main, + }, + '.MuiDataGrid-row.top-border .MuiDataGrid-cell': { + borderTop: `2px solid ${theme.palette.divider}`, + }, + '.MuiDataGrid-row.bold-row': { + fontWeight: 'bold', + }, + '.MuiDataGrid-row.bottom-border .MuiDataGrid-cell': { + borderBottom: `2px solid ${theme.palette.divider}`, + }, + '.category-cell': { + lineHeight: 1.3, + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + }, + '.category-formula': { + display: 'block', + color: theme.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + }, +})) as typeof DataGrid; + +export const OtherSection: React.FC = () => { + const { t } = useTranslation(); + const locale = useLocale(); + const localeText = useDataGridLocaleText(); + const { calculation, hcmUser } = usePdsGoalCalculator(); + const { goalMiscConstants, goalGeographicConstantMap } = + useGoalCalculatorConstants(); + + const additionalRates = goalMiscConstants.ADDITIONAL_RATES; + const employerFicaRate = additionalRates?.EMPLOYER_FICA_RATE?.fee; + const workCompPercentage = additionalRates?.PART_TIME_WORK_COMPENSATION?.fee; + const attritionRate = additionalRates?.ATTRITION_RATE?.fee; + const creditCardFeeRate = additionalRates?.CREDIT_CARD_FEE_RATE?.fee; + + const rows = useMemo(() => { + if ( + !calculation || + employerFicaRate === undefined || + workCompPercentage === undefined || + attritionRate === undefined || + creditCardFeeRate === undefined + ) { + return []; + } + + const geographicMultiplier = + goalGeographicConstantMap.get(calculation.geographicLocation ?? '') ?? 0; + + const salaryTotals = calculateSalaryTotals(calculation, { + geographicMultiplier, + employerFicaRate, + }); + const reimbursableTotals = calculateReimbursableTotals(calculation); + + const taxDeferredPct = + (hcmUser?.fourOThreeB?.currentTaxDeferredContributionPercentage ?? 0) / + 100; + const rothPct = + (hcmUser?.fourOThreeB?.currentRothContributionPercentage ?? 0) / 100; + + const constants: OtherExpensesConstants = { + reimbursableTotal: reimbursableTotals.total, + salarySubtotal: salaryTotals.subtotal, + fourOThreeBPercentage: taxDeferredPct + rothPct, + grossMonthlyPay: salaryTotals.grossMonthlyPay, + workCompPercentage, + attritionRate, + creditCardFeeRate, + }; + + return buildOtherBreakdownRows(calculation, constants, locale, t); + }, [ + calculation, + hcmUser, + employerFicaRate, + workCompPercentage, + attritionRate, + creditCardFeeRate, + goalGeographicConstantMap, + locale, + t, + ]); + + const columns = useMemo( + () => buildOtherBreakdownColumns(locale, t), + [locale, t], + ); + + if (rows.length === 0) { + return null; + } + + return ( + <> + + {t('Other')} + + + { + const classes: string[] = []; + if (params.id === 'subtotal') { + classes.push('top-border'); + classes.push('bottom-border'); + } + if (params.row.bold) { + classes.push('bold-row'); + } + return classes.join(' '); + }} + disableColumnMenu + disableColumnSorting + disableRowSelectionOnClick + hideFooter + localeText={localeText} + /> + + + ); +}; From 9539a811bdc22ba39aab7bbe59b3f487ca166ab0 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 14:10:31 -0400 Subject: [PATCH 02/11] Update tests --- .../SupportItem/OtherSection.test.tsx | 163 ++++++++++++++++++ .../calculations/OtherExpenses.test.ts | 155 +++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx new file mode 100644 index 0000000000..7232c6e865 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { + DesignationSupportSalaryType, + DesignationSupportStatus, +} from 'src/graphql/types.generated'; +import { + HcmUserMock, + PdsGoalCalculationMock, + PdsGoalCalculatorTestWrapper, +} from '../PdsGoalCalculatorTestWrapper'; +import { OtherSection } from './OtherSection'; + +interface Props { + calculationMock?: PdsGoalCalculationMock; + hcmUserMock?: HcmUserMock | null; +} + +const TestComponent: React.FC = ({ calculationMock, hcmUserMock }) => ( + + + +); + +const reimbursableZero = { + ministryCellPhone: 0, + ministryInternet: 0, + mpdNewsletter: 0, + mpdMiscellaneous: 0, + accountTransfers: 0, + otherMonthlyReimbursements: 0, + conferenceRetreatCosts: 0, + ministryTravelMeals: 0, + otherAnnualReimbursements: 0, +}; + +const fullTimeMock: PdsGoalCalculationMock = { + salaryOrHourly: DesignationSupportSalaryType.Salaried, + payRate: 60000, + hoursWorkedPerWeek: null, + geographicLocation: null, + status: DesignationSupportStatus.FullTime, + benefits: 1500, + ...reimbursableZero, +}; + +const partTimeMock: PdsGoalCalculationMock = { + salaryOrHourly: DesignationSupportSalaryType.Salaried, + payRate: 60000, + hoursWorkedPerWeek: null, + geographicLocation: null, + status: DesignationSupportStatus.PartTime, + benefits: 0, + ...reimbursableZero, +}; + +describe('OtherSection', () => { + it('renders the Other heading', async () => { + const { findByRole } = render( + , + ); + + expect(await findByRole('heading', { name: 'Other' })).toBeInTheDocument(); + }); + + describe('full-time breakdown', () => { + it('renders benefits row and all expected rows for a full-time user', async () => { + const { findByTestId, getByTestId, getByRole, queryByTestId } = render( + , + ); + + await findByTestId('other-subtotal'); + + // Reimbursable floor of $300 (no reimbursable fields set) + expect(getByTestId('other-reimbursable-expenses')).toHaveTextContent( + '$300', + ); + + // 403b = 0 (no contribution percentages set) + expect(getByTestId('other-403b-contributions')).toHaveTextContent('$0'); + + // Benefits for full-time + expect(getByTestId('other-benefits')).toHaveTextContent('$1,500'); + + // Work comp should NOT appear for full-time + expect(queryByTestId('other-work-comp')).not.toBeInTheDocument(); + + // Subtotal, attrition, credit card fees, and assessment should all render + expect(getByTestId('other-subtotal')).toBeInTheDocument(); + expect(getByTestId('other-attrition')).toBeInTheDocument(); + expect(getByTestId('other-credit-card-fees')).toBeInTheDocument(); + expect(getByTestId('other-assessment')).toBeInTheDocument(); + + // Benefits row should be present in the grid + expect( + getByRole('gridcell', { name: 'Benefits for Full-time' }), + ).toBeInTheDocument(); + }); + }); + + describe('part-time breakdown', () => { + it('renders work comp row instead of benefits for a part-time user', async () => { + const { findByTestId, getByTestId, getByRole, queryByTestId } = render( + , + ); + + await findByTestId('other-subtotal'); + + // Work comp should appear for part-time + expect(getByTestId('other-work-comp')).toBeInTheDocument(); + expect( + getByRole('gridcell', { name: 'Work Comp for Part-time' }), + ).toBeInTheDocument(); + + // Benefits should NOT appear for part-time + expect(queryByTestId('other-benefits')).not.toBeInTheDocument(); + }); + }); + + describe('403b contributions', () => { + it('includes 403b contributions when the user has contribution percentages', async () => { + const { findByTestId, getByTestId } = render( + , + ); + + await findByTestId('other-403b-contributions'); + + // grossMonthlyPay = 60000/12 = 5000 + // 403b percentage = (5 + 3) / 100 = 0.08 + // 403b contributions = 5000 * 0.08 = 400 + expect(getByTestId('other-403b-contributions')).toHaveTextContent('$400'); + }); + }); + + it('renders nothing when constants are missing', async () => { + const { container, queryByRole } = render( + + + , + ); + + await waitFor(() => + expect( + queryByRole('heading', { name: 'Other' }), + ).not.toBeInTheDocument(), + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts new file mode 100644 index 0000000000..dde350724c --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts @@ -0,0 +1,155 @@ +import { DesignationSupportStatus } from 'src/graphql/types.generated'; +import { + OtherExpensesConstants, + OtherExpensesFields, + calculateOtherExpenses, +} from './OtherExpenses'; + +const defaultConstants: OtherExpensesConstants = { + reimbursableTotal: 500, + salarySubtotal: 5000, + fourOThreeBPercentage: 0.1, + grossMonthlyPay: 4000, + workCompPercentage: 0.17, + attritionRate: 0.06, + creditCardFeeRate: 0.06, +}; + +const fullTime = ( + overrides: Partial = {}, +): OtherExpensesFields => ({ + status: DesignationSupportStatus.FullTime, + benefits: 1500, + ...overrides, +}); + +const partTime = ( + overrides: Partial = {}, +): OtherExpensesFields => ({ + status: DesignationSupportStatus.PartTime, + benefits: null, + ...overrides, +}); + +const noStatus = ( + overrides: Partial = {}, +): OtherExpensesFields => ({ + status: null, + benefits: null, + ...overrides, +}); + +describe('calculateOtherExpenses', () => { + describe('reimbursable expenses', () => { + it('passes through the reimbursable total from constants', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + expect(result.reimbursableExpenses).toBe(500); + }); + }); + + describe('403b contributions', () => { + it('calculates as grossMonthlyPay × fourOThreeBPercentage', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + // 4000 * 0.1 + expect(result.fourOThreeBContributions).toBeCloseTo(400); + }); + }); + + describe('work comp', () => { + it('calculates as grossMonthlyPay × workCompPercentage for part-time', () => { + const result = calculateOtherExpenses(partTime(), defaultConstants); + // 4000 * 0.17 + expect(result.workComp).toBeCloseTo(680); + }); + + it('is 0 for full-time', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + expect(result.workComp).toBe(0); + }); + }); + + describe('benefits', () => { + it('uses calculation.benefits for full-time', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + expect(result.benefits).toBe(1500); + }); + + it('is 0 for part-time', () => { + const result = calculateOtherExpenses(partTime(), defaultConstants); + expect(result.benefits).toBe(0); + }); + + it('treats null benefits as 0 for full-time', () => { + const result = calculateOtherExpenses( + fullTime({ benefits: null }), + defaultConstants, + ); + expect(result.benefits).toBe(0); + }); + }); + + // When status is null or undefined, both isFullTime and isPartTime are false. + // This means no work comp (part-time only) AND no benefits (full-time only). + describe('null/undefined status', () => { + it('excludes both workComp and benefits when status is null', () => { + const result = calculateOtherExpenses(noStatus(), defaultConstants); + expect(result.workComp).toBe(0); + expect(result.benefits).toBe(0); + }); + + it('excludes both workComp and benefits when status is undefined', () => { + const result = calculateOtherExpenses( + noStatus({ status: undefined }), + defaultConstants, + ); + expect(result.workComp).toBe(0); + expect(result.benefits).toBe(0); + }); + + it('calculates subtotal without workComp or benefits when status is null', () => { + const result = calculateOtherExpenses(noStatus(), defaultConstants); + // 5000 + 500 + 400 + 0 + 0 + expect(result.subtotal).toBeCloseTo(5900); + }); + }); + + describe('subtotal', () => { + it('sums salarySubtotal + reimbursable + 403b + benefits for full-time', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + // 5000 + 500 + 400 + 0 + 1500 + expect(result.subtotal).toBeCloseTo(7400); + }); + + it('sums salarySubtotal + reimbursable + 403b + workComp for part-time', () => { + const result = calculateOtherExpenses(partTime(), defaultConstants); + // 5000 + 500 + 400 + 680 + 0 + expect(result.subtotal).toBeCloseTo(6580); + }); + }); + + describe('attrition', () => { + it('is 6% of subtotal', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + // 7400 * 0.06 + expect(result.attrition).toBeCloseTo(444); + }); + }); + + describe('credit card fees', () => { + it('is 6% of (subtotal + attrition)', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + // (7400 + 444) * 0.06 + expect(result.creditCardFees).toBeCloseTo(470.64); + }); + }); + + describe('assessment', () => { + it('is (subtotal + attrition + creditCardFees) / 0.88 minus itself', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + const preAssessment = + result.subtotal + result.attrition + result.creditCardFees; + const expected = preAssessment / 0.88 - preAssessment; + expect(result.assessment).toBeCloseTo(expected); + }); + }); +}); From ce31ae37302aa36ddfa08597c7feb8909af8d13e Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 14:11:46 -0400 Subject: [PATCH 03/11] Remove Salary folder --- .../Salary/SalarySection.test.tsx | 151 ------------------ .../Salary/SalarySection.tsx | 103 ------------ .../Salary/salaryBreakdown.test.tsx | 109 ------------- .../Salary/salaryBreakdown.tsx | 148 ----------------- 4 files changed, 511 deletions(-) delete mode 100644 src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx delete mode 100644 src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx delete mode 100644 src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx delete mode 100644 src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.tsx diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx deleted file mode 100644 index 9a84b64fe5..0000000000 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React from 'react'; -import { render, waitFor } from '@testing-library/react'; -import { DesignationSupportSalaryType } from 'src/graphql/types.generated'; -import { - PdsGoalCalculationMock, - PdsGoalCalculatorTestWrapper, -} from '../PdsGoalCalculatorTestWrapper'; -import { SalarySection } from './SalarySection'; - -interface Props { - calculationMock?: PdsGoalCalculationMock; -} - -const TestComponent: React.FC = ({ calculationMock }) => ( - - - -); - -describe('SalarySection', () => { - it('renders the Salary heading', async () => { - const { findByRole } = render( - , - ); - - expect(await findByRole('heading', { name: 'Salary' })).toBeInTheDocument(); - }); - - describe('salaried breakdown', () => { - it('renders a six-row breakdown for a salaried user with no geographic multiplier', async () => { - const { findByTestId, getByTestId, getByRole } = render( - , - ); - - await findByTestId('gross-monthly-pay'); - - // 60000 / 12 = 5000 monthly base - const monthlyBaseRow = getByRole('gridcell', { - name: /Monthly Base.*Yearly Salary ÷ 12/, - }).closest('[role="row"]'); - expect(monthlyBaseRow).toHaveTextContent('$5,000'); - - expect(getByTestId('gross-monthly-pay')).toHaveTextContent('$5,000'); - expect(getByTestId('employer-fica')).toHaveTextContent('$400'); - expect(getByTestId('salary-subtotal')).toHaveTextContent('$5,400'); - }); - - it('applies the geographic multiplier additively and surfaces the percent in the breakdown', async () => { - const { findByTestId, getByTestId, getByRole } = render( - , - ); - - await findByTestId('gross-monthly-pay'); - - const geoRow = getByRole('gridcell', { - name: 'Geographic Multiplier', - }).closest('[role="row"]'); - expect(geoRow).toHaveTextContent('6%'); - - // (60000 / 12) * (1 + 0.06) = 5300 - expect(getByTestId('gross-monthly-pay')).toHaveTextContent('$5,300'); - // 5300 * 0.08 - expect(getByTestId('employer-fica')).toHaveTextContent('$424'); - // 5300 + 424 - expect(getByTestId('salary-subtotal')).toHaveTextContent('$5,724'); - }); - }); - - describe('hourly breakdown', () => { - it('renders Hours per Week and Monthly Base rows and uses the hourly formula', async () => { - const { findByTestId, getByTestId, getByRole } = render( - , - ); - - await findByTestId('gross-monthly-pay'); - - const hoursRow = getByRole('gridcell', { - name: 'Hours per Week', - }).closest('[role="row"]'); - expect(hoursRow).toHaveTextContent('40'); - - const monthlyBaseRow = getByRole('gridcell', { - name: /Monthly Base.*Pay Rate × Hours per Week/, - }).closest('[role="row"]'); - // 25 * 40 * 52 / 12 = 4333.33 - expect(monthlyBaseRow).toHaveTextContent('$4,333.33'); - - expect(getByTestId('gross-monthly-pay')).toHaveTextContent('$4,333.33'); - // 4333.33 * 0.08 = 346.67 - expect(getByTestId('employer-fica')).toHaveTextContent('$346.67'); - // 4333.33 + 346.67 = 4680.00 - expect(getByTestId('salary-subtotal')).toHaveTextContent('$4,680'); - }); - }); - - it('renders nothing when the EMPLOYER_FICA_RATE constant is missing', async () => { - const { container, queryByRole } = render( - - - , - ); - - await waitFor(() => - expect( - queryByRole('heading', { name: 'Salary' }), - ).not.toBeInTheDocument(), - ); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx deleted file mode 100644 index 68e185295b..0000000000 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useMemo } from 'react'; -import { Box, Typography, styled } from '@mui/material'; -import { DataGrid } from '@mui/x-data-grid'; -import { useTranslation } from 'react-i18next'; -import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; -import { useLocale } from 'src/hooks/useLocale'; -import { useDataGridLocaleText } from 'src/hooks/useMuiLocaleText'; -import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; -import { - buildSalaryBreakdownColumns, - buildSalaryBreakdownRows, -} from './salaryBreakdown'; - -const GridContainer = styled(Box)({ - display: 'flex', - flexDirection: 'column', -}); - -const StyledGrid = styled(DataGrid)(({ theme }) => ({ - fontSize: theme.typography.body1.fontSize, - '.MuiDataGrid-columnHeaderTitle': { - fontWeight: 'bold', - color: theme.palette.mpdxBlue.main, - }, - '.MuiDataGrid-row.top-border .MuiDataGrid-cell': { - borderTop: `2px solid ${theme.palette.divider}`, - }, - '.MuiDataGrid-row[data-id="total"]': { - fontWeight: 'bold', - }, - '.category-cell': { - lineHeight: 1.3, - paddingTop: theme.spacing(1), - paddingBottom: theme.spacing(1), - }, - '.category-formula': { - display: 'block', - color: theme.palette.text.secondary, - fontSize: theme.typography.body2.fontSize, - }, -})) as typeof DataGrid; - -export const SalarySection: React.FC = () => { - const { t } = useTranslation(); - const locale = useLocale(); - const localeText = useDataGridLocaleText(); - const { calculation } = usePdsGoalCalculator(); - const { goalMiscConstants, goalGeographicConstantMap } = - useGoalCalculatorConstants(); - - const employerFicaRate = - goalMiscConstants.ADDITIONAL_RATES?.EMPLOYER_FICA_RATE?.fee; - - const rows = useMemo(() => { - if (!calculation || employerFicaRate === undefined) { - return []; - } - const { geographicLocation } = calculation; - const geographicMultiplier = - goalGeographicConstantMap.get(geographicLocation ?? '') ?? 0; - - return buildSalaryBreakdownRows( - calculation, - { geographicMultiplier, employerFicaRate }, - locale, - t, - ); - }, [calculation, employerFicaRate, goalGeographicConstantMap, locale, t]); - - const columns = useMemo( - () => buildSalaryBreakdownColumns(locale, t), - [locale, t], - ); - - if (rows.length === 0) { - return null; - } - - return ( - <> - - {t('Salary')} - - - - params.id === 'gross-monthly-pay' || params.id === 'total' - ? 'top-border' - : '' - } - disableColumnMenu - disableColumnSorting - disableRowSelectionOnClick - hideFooter - localeText={localeText} - /> - - - ); -}; diff --git a/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx deleted file mode 100644 index 793bee2d5a..0000000000 --- a/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { DataGrid } from '@mui/x-data-grid'; -import { render } from '@testing-library/react'; -import { t } from 'i18next'; -import { DesignationSupportSalaryType } from 'src/graphql/types.generated'; -import { SalaryCalculationFields } from '../calculations/salaryCalculation'; -import { - SalaryBreakdownRow, - buildSalaryBreakdownColumns, - buildSalaryBreakdownRows, -} from './salaryBreakdown'; - -const constants = { geographicMultiplier: 0, employerFicaRate: 0.08 }; - -const salariedCalculation: SalaryCalculationFields = { - salaryOrHourly: DesignationSupportSalaryType.Salaried, - // Yearly salary — divided by 12 for monthly base - payRate: 60000, - hoursWorkedPerWeek: null, - geographicLocation: null, -}; - -const hourlyCalculation: SalaryCalculationFields = { - salaryOrHourly: DesignationSupportSalaryType.Hourly, - payRate: 25, - hoursWorkedPerWeek: 40, - geographicLocation: null, -}; - -const renderBreakdown = (rows: SalaryBreakdownRow[]) => - render( -
- -
, - ); - -describe('buildSalaryBreakdownRows', () => { - it('returns the salaried row sequence', () => { - const rows = buildSalaryBreakdownRows( - salariedCalculation, - constants, - 'en-US', - t, - ); - expect(rows.map((row) => row.id)).toEqual([ - 'pay-rate', - 'monthly-base', - 'geographic-multiplier', - 'gross-monthly-pay', - 'employer-fica', - 'total', - ]); - }); - - it('inserts hours-per-week and monthly-base rows for hourly', () => { - const rows = buildSalaryBreakdownRows( - hourlyCalculation, - constants, - 'en-US', - t, - ); - expect(rows.map((row) => row.id)).toEqual([ - 'pay-rate', - 'hours-per-week', - 'monthly-base', - 'geographic-multiplier', - 'gross-monthly-pay', - 'employer-fica', - 'total', - ]); - }); -}); - -describe('buildSalaryBreakdownColumns', () => { - it('renders a category without its formula subtext', () => { - const { getByRole, queryByRole } = renderBreakdown([ - { - id: 'pay-rate', - category: 'Pay Rate', - amount: 5000, - format: 'currency', - }, - ]); - - expect(getByRole('gridcell', { name: 'Pay Rate' })).toBeInTheDocument(); - expect(queryByRole('gridcell', { name: /×/ })).not.toBeInTheDocument(); - }); - - it('renders a category with its formula subtext', () => { - const { getByRole } = renderBreakdown([ - { - id: 'gross-monthly-pay', - category: 'Gross Monthly Pay', - formula: 'Pay Rate × (1 + Geographic Multiplier)', - amount: 5000, - format: 'currency', - }, - ]); - expect( - getByRole('gridcell', { - name: 'Gross Monthly Pay Pay Rate × (1 + Geographic Multiplier)', - }), - ).toBeInTheDocument(); - }); -}); diff --git a/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.tsx b/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.tsx deleted file mode 100644 index cd8138c7bc..0000000000 --- a/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React from 'react'; -import { Box, styled } from '@mui/material'; -import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; -import { TFunction } from 'i18next'; -import { DesignationSupportSalaryType } from 'src/graphql/types.generated'; -import { - currencyFormat, - numberFormat, - percentageFormat, -} from 'src/lib/intlFormat'; -import { - SalaryCalculationFields, - SalaryConstants, - calculateSalaryTotals, -} from '../calculations/salaryCalculation'; - -type AmountFormat = 'currency' | 'percentage' | 'number'; - -const CategoryCellBox = styled(Box)({ - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - height: '100%', -}); - -export interface SalaryBreakdownRow { - id: string; - category: string; - formula?: string; - amount: number; - format: AmountFormat; - testId?: string; -} - -export const buildSalaryBreakdownRows = ( - calculation: SalaryCalculationFields, - constants: SalaryConstants, - locale: string, - t: TFunction, -): SalaryBreakdownRow[] => { - const { geographicMultiplier, employerFicaRate } = constants; - const { salaryOrHourly } = calculation; - const payRate = calculation.payRate ?? 0; - const hoursPerWeek = calculation.hoursWorkedPerWeek ?? 0; - const isSalaried = salaryOrHourly === DesignationSupportSalaryType.Salaried; - - const { monthlyBase, grossMonthlyPay, employerFica, subtotal } = - calculateSalaryTotals(calculation, constants); - - return [ - { - id: 'pay-rate', - category: t('Pay Rate'), - amount: payRate, - format: 'currency', - }, - ...(isSalaried - ? [] - : [ - { - id: 'hours-per-week', - category: t('Hours per Week'), - amount: hoursPerWeek, - format: 'number' as const, - }, - ]), - { - id: 'monthly-base', - category: t('Monthly Base'), - formula: isSalaried - ? t('Yearly Salary ÷ 12') - : t('Pay Rate × Hours per Week × 52 ÷ 12'), - amount: monthlyBase, - format: 'currency', - }, - { - id: 'geographic-multiplier', - category: t('Geographic Multiplier'), - amount: geographicMultiplier, - format: 'percentage', - }, - { - id: 'gross-monthly-pay', - category: t('Gross Monthly Pay'), - formula: t('Monthly Base × (1 + Geographic Multiplier)'), - amount: grossMonthlyPay, - format: 'currency', - testId: 'gross-monthly-pay', - }, - { - id: 'employer-fica', - category: t('Employer ½ FICA'), - formula: t('Gross Monthly Pay × {{rate}}', { - rate: percentageFormat(employerFicaRate, locale), - }), - amount: employerFica, - format: 'currency', - testId: 'employer-fica', - }, - { - id: 'total', - category: t('Subtotal'), - formula: t('Gross Monthly Pay + Employer ½ FICA'), - amount: subtotal, - format: 'currency', - testId: 'salary-subtotal', - }, - ]; -}; - -export const buildSalaryBreakdownColumns = ( - locale: string, - t: TFunction, -): GridColDef[] => [ - { - field: 'category', - headerName: t('Category'), - flex: 1, - minWidth: 200, - cellClassName: 'category-cell', - renderCell: (params: GridRenderCellParams) => ( - - {params.row.category} - {params.row.formula && ( - {params.row.formula} - )} - - ), - }, - { - field: 'amount', - headerName: t('Amount'), - flex: 1, - minWidth: 140, - align: 'left', - headerAlign: 'left', - renderCell: (params: GridRenderCellParams) => { - const { amount, format, testId } = params.row; - const formatted = - format === 'currency' - ? currencyFormat(amount, 'USD', locale) - : format === 'percentage' - ? percentageFormat(amount, locale) - : numberFormat(amount, locale); - return {formatted}; - }, - }, -]; From 2c61257275788052f2e6b8c6fef30c129b542d9b Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 14:24:01 -0400 Subject: [PATCH 04/11] PR review edits --- .../PdsGoalCalculatorTestWrapper.tsx | 5 + .../Salary/SalaryStep.test.tsx | 39 +++- .../SupportItem/OtherSection.test.tsx | 20 ++ .../SupportItem/OtherSection.tsx | 43 +---- .../SupportItem/SalarySection.tsx | 74 ++++++++ .../SupportItem/SupportItemStep.tsx | 12 ++ .../SupportItem/otherBreakdown.test.tsx | 179 ++++++++++++++++++ .../SupportItem/otherBreakdown.tsx | 176 +++++++++++++++++ .../SupportItem/salaryBreakdown.test.tsx | 155 +++++++++++++++ .../SupportItem/styledGrid.tsx | 37 ++++ .../calculations/OtherExpenses.test.ts | 47 ++++- .../calculations/OtherExpenses.ts | 69 +++++++ 12 files changed, 814 insertions(+), 42 deletions(-) create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/SalarySection.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/SupportItemStep.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/styledGrid.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index 338ee3522d..2be32b2dab 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -193,6 +193,11 @@ export const PdsGoalCalculatorTestWrapper: React.FC< label: MpdGoalMiscConstantLabelEnum.CreditCardFeeRate, fee: 0.06, }, + { + category: MpdGoalMiscConstantCategoryEnum.Rates, + label: MpdGoalMiscConstantLabelEnum.AdminRate, + fee: 0.12, + }, ], }, constantsMock, diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx index 6065edb96f..390972398f 100644 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx @@ -1,16 +1,51 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; +import { + DesignationSupportSalaryType, + DesignationSupportStatus, +} from 'src/graphql/types.generated'; +import { + PdsGoalCalculationMock, + PdsGoalCalculatorTestWrapper, +} from '../PdsGoalCalculatorTestWrapper'; import { SalaryStep } from './SalaryStep'; +const calculationMock: PdsGoalCalculationMock = { + salaryOrHourly: DesignationSupportSalaryType.Salaried, + payRate: 60000, + hoursWorkedPerWeek: null, + geographicLocation: null, + status: DesignationSupportStatus.FullTime, + benefits: 1500, + ministryCellPhone: 0, + ministryInternet: 0, + mpdNewsletter: 0, + mpdMiscellaneous: 0, + accountTransfers: 0, + otherMonthlyReimbursements: 0, + conferenceRetreatCosts: 0, + ministryTravelMeals: 0, + otherAnnualReimbursements: 0, +}; + describe('SalaryStep', () => { it('renders the Salary section', async () => { const { findByRole } = render( - + , ); expect(await findByRole('heading', { name: 'Salary' })).toBeInTheDocument(); }); + + it('renders the Other section', async () => { + const { findByRole } = render( + + + , + ); + + expect(await findByRole('heading', { name: 'Other' })).toBeInTheDocument(); + }); }); diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx index 7232c6e865..ade584c533 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx @@ -120,6 +120,26 @@ describe('OtherSection', () => { }); }); + describe('null status', () => { + it('renders neither benefits nor work comp when status is null', async () => { + const nullStatusMock: PdsGoalCalculationMock = { + ...fullTimeMock, + status: null, + }; + + const { findByTestId, queryByTestId } = render( + , + ); + + await findByTestId('other-subtotal'); + + // When status is null, both isFullTime and isPartTime are false, + // so neither the benefits row nor the work comp row should render. + expect(queryByTestId('other-benefits')).not.toBeInTheDocument(); + expect(queryByTestId('other-work-comp')).not.toBeInTheDocument(); + }); + }); + describe('403b contributions', () => { it('includes 403b contributions when the user has contribution percentages', async () => { const { findByTestId, getByTestId } = render( diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx index 865f71acbf..c23ab4c4ad 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx @@ -1,6 +1,5 @@ import React, { useMemo } from 'react'; -import { Box, Typography, styled } from '@mui/material'; -import { DataGrid } from '@mui/x-data-grid'; +import { Divider, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { useLocale } from 'src/hooks/useLocale'; @@ -13,38 +12,7 @@ import { buildOtherBreakdownColumns, buildOtherBreakdownRows, } from './otherBreakdown'; - -const GridContainer = styled(Box)({ - display: 'flex', - flexDirection: 'column', -}); - -const StyledGrid = styled(DataGrid)(({ theme }) => ({ - fontSize: theme.typography.body1.fontSize, - '.MuiDataGrid-columnHeaderTitle': { - fontWeight: 'bold', - color: theme.palette.mpdxBlue.main, - }, - '.MuiDataGrid-row.top-border .MuiDataGrid-cell': { - borderTop: `2px solid ${theme.palette.divider}`, - }, - '.MuiDataGrid-row.bold-row': { - fontWeight: 'bold', - }, - '.MuiDataGrid-row.bottom-border .MuiDataGrid-cell': { - borderBottom: `2px solid ${theme.palette.divider}`, - }, - '.category-cell': { - lineHeight: 1.3, - paddingTop: theme.spacing(1), - paddingBottom: theme.spacing(1), - }, - '.category-formula': { - display: 'block', - color: theme.palette.text.secondary, - fontSize: theme.typography.body2.fontSize, - }, -})) as typeof DataGrid; +import { GridContainer, StyledGrid } from './styledGrid'; export const OtherSection: React.FC = () => { const { t } = useTranslation(); @@ -59,6 +27,7 @@ export const OtherSection: React.FC = () => { const workCompPercentage = additionalRates?.PART_TIME_WORK_COMPENSATION?.fee; const attritionRate = additionalRates?.ATTRITION_RATE?.fee; const creditCardFeeRate = additionalRates?.CREDIT_CARD_FEE_RATE?.fee; + const adminRate = goalMiscConstants.RATES?.ADMIN_RATE?.fee; const rows = useMemo(() => { if ( @@ -66,7 +35,8 @@ export const OtherSection: React.FC = () => { employerFicaRate === undefined || workCompPercentage === undefined || attritionRate === undefined || - creditCardFeeRate === undefined + creditCardFeeRate === undefined || + adminRate === undefined ) { return []; } @@ -94,6 +64,7 @@ export const OtherSection: React.FC = () => { workCompPercentage, attritionRate, creditCardFeeRate, + adminRate, }; return buildOtherBreakdownRows(calculation, constants, locale, t); @@ -104,6 +75,7 @@ export const OtherSection: React.FC = () => { workCompPercentage, attritionRate, creditCardFeeRate, + adminRate, goalGeographicConstantMap, locale, t, @@ -120,6 +92,7 @@ export const OtherSection: React.FC = () => { return ( <> + {t('Other')} diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/SalarySection.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/SalarySection.tsx new file mode 100644 index 0000000000..0b5038b882 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/SalarySection.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; +import { Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; +import { useLocale } from 'src/hooks/useLocale'; +import { useDataGridLocaleText } from 'src/hooks/useMuiLocaleText'; +import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { + buildSalaryBreakdownColumns, + buildSalaryBreakdownRows, +} from './salaryBreakdown'; +import { GridContainer, StyledGrid } from './styledGrid'; + +export const SalarySection: React.FC = () => { + const { t } = useTranslation(); + const locale = useLocale(); + const localeText = useDataGridLocaleText(); + const { calculation } = usePdsGoalCalculator(); + const { goalMiscConstants, goalGeographicConstantMap } = + useGoalCalculatorConstants(); + + const employerFicaRate = + goalMiscConstants.ADDITIONAL_RATES?.EMPLOYER_FICA_RATE?.fee; + + const rows = useMemo(() => { + if (!calculation || employerFicaRate === undefined) { + return []; + } + const { geographicLocation } = calculation; + const geographicMultiplier = + goalGeographicConstantMap.get(geographicLocation ?? '') ?? 0; + + return buildSalaryBreakdownRows( + calculation, + { geographicMultiplier, employerFicaRate }, + locale, + t, + ); + }, [calculation, employerFicaRate, goalGeographicConstantMap, locale, t]); + + const columns = useMemo( + () => buildSalaryBreakdownColumns(locale, t), + [locale, t], + ); + + if (rows.length === 0) { + return null; + } + + return ( + <> + + {t('Salary')} + + + + params.id === 'gross-monthly-pay' || params.id === 'total' + ? 'top-border' + : '' + } + disableColumnMenu + disableColumnSorting + disableRowSelectionOnClick + hideFooter + localeText={localeText} + /> + + + ); +}; diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/SupportItemStep.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/SupportItemStep.tsx new file mode 100644 index 0000000000..a1343e388a --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/SupportItemStep.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { OtherSection } from './OtherSection'; +import { SalarySection } from './SalarySection'; + +export const SupportItemStep: React.FC = () => { + return ( + <> + + + + ); +}; diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx new file mode 100644 index 0000000000..9f6575d66e --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx @@ -0,0 +1,179 @@ +import { DataGrid } from '@mui/x-data-grid'; +import { render } from '@testing-library/react'; +import { t } from 'i18next'; +import { DesignationSupportStatus } from 'src/graphql/types.generated'; +import { OtherExpensesConstants, OtherExpensesFields } from '../calculations/OtherExpenses'; +import { + OtherBreakdownRow, + buildOtherBreakdownColumns, + buildOtherBreakdownRows, +} from './otherBreakdown'; + +const constants: OtherExpensesConstants = { + reimbursableTotal: 300, + salarySubtotal: 5000, + fourOThreeBPercentage: 0.08, + grossMonthlyPay: 5000, + workCompPercentage: 0.02, + attritionRate: 0.05, + creditCardFeeRate: 0.03, + adminRate: 0.1, +}; + +const fullTimeCalculation: OtherExpensesFields = { + status: DesignationSupportStatus.FullTime, + benefits: 1500, +}; + +const partTimeCalculation: OtherExpensesFields = { + status: DesignationSupportStatus.PartTime, + benefits: 0, +}; + +const nullStatusCalculation: OtherExpensesFields = { + status: null, + benefits: 0, +}; + +const renderBreakdown = (rows: OtherBreakdownRow[]) => + render( +
+ +
, + ); + +describe('buildOtherBreakdownRows', () => { + it('returns the full-time row sequence with benefits', () => { + const rows = buildOtherBreakdownRows( + fullTimeCalculation, + constants, + 'en-US', + t, + ); + expect(rows.map((row) => row.id)).toEqual([ + 'reimbursable-expenses', + '403b-contributions', + 'benefits', + 'subtotal', + 'attrition', + 'credit-card-fees', + 'assessment', + ]); + }); + + it('returns the part-time row sequence with work comp', () => { + const rows = buildOtherBreakdownRows( + partTimeCalculation, + constants, + 'en-US', + t, + ); + expect(rows.map((row) => row.id)).toEqual([ + 'reimbursable-expenses', + '403b-contributions', + 'work-comp', + 'subtotal', + 'attrition', + 'credit-card-fees', + 'assessment', + ]); + }); + + it('omits both benefits and work comp when status is null', () => { + const rows = buildOtherBreakdownRows( + nullStatusCalculation, + constants, + 'en-US', + t, + ); + expect(rows.map((row) => row.id)).toEqual([ + 'reimbursable-expenses', + '403b-contributions', + 'subtotal', + 'attrition', + 'credit-card-fees', + 'assessment', + ]); + }); + + it('marks subtotal, attrition, credit-card-fees, and assessment as bold', () => { + const rows = buildOtherBreakdownRows( + fullTimeCalculation, + constants, + 'en-US', + t, + ); + const boldIds = rows.filter((row) => row.bold).map((row) => row.id); + expect(boldIds).toEqual([ + 'subtotal', + 'attrition', + 'credit-card-fees', + 'assessment', + ]); + }); + + it('sets testId on every row', () => { + const rows = buildOtherBreakdownRows( + fullTimeCalculation, + constants, + 'en-US', + t, + ); + rows.forEach((row) => { + expect(row.testId).toBeDefined(); + }); + }); +}); + +describe('buildOtherBreakdownColumns', () => { + it('renders a category without formula subtext', () => { + const { getByRole, queryByRole } = renderBreakdown([ + { + id: 'benefits', + category: 'Benefits for Full-time', + amount: 1500, + }, + ]); + + expect( + getByRole('gridcell', { name: 'Benefits for Full-time' }), + ).toBeInTheDocument(); + expect(queryByRole('gridcell', { name: /×/ })).not.toBeInTheDocument(); + }); + + it('renders a category with formula subtext', () => { + const { getByRole } = renderBreakdown([ + { + id: 'attrition', + category: 'Attrition', + formula: 'Subtotal × 5%', + amount: 340, + }, + ]); + expect( + getByRole('gridcell', { name: 'Attrition Subtotal × 5%' }), + ).toBeInTheDocument(); + }); + + it('renders a tooltip icon when tooltip is provided', () => { + const { getByLabelText } = renderBreakdown([ + { + id: 'reimbursable-expenses', + category: 'Reimbursable Expenses', + amount: 300, + tooltip: 'To change this amount, update the Reimbursable Expenses step', + }, + ]); + + expect( + getByLabelText( + 'To change this amount, update the Reimbursable Expenses step', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx new file mode 100644 index 0000000000..a24e6b2682 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import InfoIcon from '@mui/icons-material/Info'; +import { Box, Tooltip, styled } from '@mui/material'; +import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { TFunction } from 'i18next'; +import { DesignationSupportStatus } from 'src/graphql/types.generated'; +import { currencyFormat, percentageFormat } from 'src/lib/intlFormat'; +import { + OtherExpensesConstants, + OtherExpensesFields, + OtherExpensesTotals, + calculateOtherExpenses, +} from '../calculations/OtherExpenses'; + +const CategoryCellBox = styled(Box)({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + height: '100%', +}); + +export interface OtherBreakdownRow { + id: string; + category: string; + formula?: string; + amount: number; + testId?: string; + bold?: boolean; + tooltip?: string; +} + +export const buildOtherBreakdownRows = ( + calculation: OtherExpensesFields, + constants: OtherExpensesConstants, + locale: string, + t: TFunction, +): OtherBreakdownRow[] => { + const totals: OtherExpensesTotals = calculateOtherExpenses( + calculation, + constants, + ); + + const rows: OtherBreakdownRow[] = [ + { + id: 'reimbursable-expenses', + category: t('Reimbursable Expenses'), + formula: t( + 'The greater of $300/month or the amount calculated in the Reimbursable Expenses step', + ), + amount: totals.reimbursableExpenses, + testId: 'other-reimbursable-expenses', + tooltip: t( + 'To change this amount, update the Reimbursable Expenses step', + ), + }, + { + id: '403b-contributions', + category: t('403b Contributions if Applicable'), + formula: t( + 'Gross Monthly Pay from Salary section × 403b Contribution Percentage from Setup section', + ), + amount: totals.fourOThreeBContributions, + testId: 'other-403b-contributions', + }, + ...(calculation.status === DesignationSupportStatus.PartTime + ? [ + { + id: 'work-comp', + category: t('Work Comp for Part-time'), + amount: totals.workComp, + testId: 'other-work-comp', + }, + ] + : []), + ...(calculation.status === DesignationSupportStatus.FullTime + ? [ + { + id: 'benefits', + category: t('Benefits for Full-time'), + amount: totals.benefits, + testId: 'other-benefits', + }, + ] + : []), + { + id: 'subtotal', + category: t('Subtotal'), + formula: t( + 'Gross Monthly Pay Subtotal + Reimbursable Expenses + 403b Contributions + Work Comp + Benefits', + ), + amount: totals.subtotal, + testId: 'other-subtotal', + bold: true, + }, + { + id: 'attrition', + category: t('Attrition'), + formula: t('Subtotal × {{rate}}', { + rate: percentageFormat(constants.attritionRate, locale), + }), + amount: totals.attrition, + testId: 'other-attrition', + bold: true, + }, + { + id: 'credit-card-fees', + category: t('Credit Card Fees'), + formula: t('(Subtotal + Attrition) × {{rate}}', { + rate: percentageFormat(constants.creditCardFeeRate, locale), + }), + amount: totals.creditCardFees, + testId: 'other-credit-card-fees', + bold: true, + }, + { + id: 'assessment', + category: t('Assessment'), + formula: t( + '(Subtotal + Credit Card Fees + Attrition) ÷ (1 − Admin Rate) − (Subtotal + Credit Card Fees + Attrition)', + ), + amount: totals.assessment, + testId: 'other-assessment', + bold: true, + }, + ]; + + return rows; +}; + +export const buildOtherBreakdownColumns = ( + locale: string, + t: TFunction, +): GridColDef[] => [ + { + field: 'category', + headerName: t('Category'), + flex: 1, + minWidth: 200, + cellClassName: 'category-cell', + renderCell: (params: GridRenderCellParams) => ( + + + {params.row.category} + {params.row.tooltip && ( + + + + )} + + {params.row.formula && ( + {params.row.formula} + )} + + ), + }, + { + field: 'amount', + headerName: t('Amount'), + flex: 1, + minWidth: 140, + align: 'left', + headerAlign: 'left', + renderCell: (params: GridRenderCellParams) => { + const { amount, testId } = params.row; + return ( + + {currencyFormat(amount, 'USD', locale)} + + ); + }, + }, +]; diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx new file mode 100644 index 0000000000..2c0be8fc45 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx @@ -0,0 +1,155 @@ +import { DataGrid } from '@mui/x-data-grid'; +import { render } from '@testing-library/react'; +import { t } from 'i18next'; +import { DesignationSupportSalaryType } from 'src/graphql/types.generated'; +import { SalaryCalculationFields } from '../calculations/salaryCalculation'; +import { + SalaryBreakdownRow, + buildSalaryBreakdownColumns, + buildSalaryBreakdownRows, +} from './salaryBreakdown'; + +const constants = { geographicMultiplier: 0, employerFicaRate: 0.08 }; + +const salariedCalculation: SalaryCalculationFields = { + salaryOrHourly: DesignationSupportSalaryType.Salaried, + // Yearly salary — divided by 12 for monthly base + payRate: 60000, + hoursWorkedPerWeek: null, + geographicLocation: null, +}; + +const hourlyCalculation: SalaryCalculationFields = { + salaryOrHourly: DesignationSupportSalaryType.Hourly, + payRate: 25, + hoursWorkedPerWeek: 40, + geographicLocation: null, +}; + +const renderBreakdown = (rows: SalaryBreakdownRow[]) => + render( +
+ +
, + ); + +describe('buildSalaryBreakdownRows', () => { + it('returns the salaried row sequence', () => { + const rows = buildSalaryBreakdownRows( + salariedCalculation, + constants, + 'en-US', + t, + ); + expect(rows.map((row) => row.id)).toEqual([ + 'pay-rate', + 'monthly-base', + 'geographic-multiplier', + 'gross-monthly-pay', + 'employer-fica', + 'total', + ]); + }); + + it('computes correct amounts for salaried employee', () => { + const rows = buildSalaryBreakdownRows( + salariedCalculation, + constants, + 'en-US', + t, + ); + const byId = Object.fromEntries(rows.map((r) => [r.id, r.amount])); + + // payRate = 60000 + expect(byId['pay-rate']).toBe(60000); + // monthlyBase = 60000 / 12 = 5000 + expect(byId['monthly-base']).toBe(5000); + // geographicMultiplier passed through from constants + expect(byId['geographic-multiplier']).toBe(0); + // grossMonthlyPay = 5000 * (1 + 0) = 5000 + expect(byId['gross-monthly-pay']).toBe(5000); + // employerFica = 5000 * 0.08 = 400 + expect(byId['employer-fica']).toBe(400); + // subtotal = 5000 + 400 = 5400 + expect(byId['total']).toBe(5400); + }); + + it('computes correct amounts for hourly employee', () => { + const rows = buildSalaryBreakdownRows( + hourlyCalculation, + constants, + 'en-US', + t, + ); + const byId = Object.fromEntries(rows.map((r) => [r.id, r.amount])); + + // payRate = 25 + expect(byId['pay-rate']).toBe(25); + // hoursPerWeek = 40 + expect(byId['hours-per-week']).toBe(40); + // monthlyBase = (25 * 40 * 52) / 12 ≈ 4333.33 + expect(byId['monthly-base']).toBeCloseTo(4333.33, 2); + // grossMonthlyPay = 4333.33 * (1 + 0) ≈ 4333.33 + expect(byId['gross-monthly-pay']).toBeCloseTo(4333.33, 2); + // employerFica = 4333.33 * 0.08 ≈ 346.67 + expect(byId['employer-fica']).toBeCloseTo(346.67, 2); + // subtotal = 4333.33 + 346.67 ≈ 4680.00 + expect(byId['total']).toBeCloseTo(4680.0, 2); + }); + + it('inserts hours-per-week and monthly-base rows for hourly', () => { + const rows = buildSalaryBreakdownRows( + hourlyCalculation, + constants, + 'en-US', + t, + ); + expect(rows.map((row) => row.id)).toEqual([ + 'pay-rate', + 'hours-per-week', + 'monthly-base', + 'geographic-multiplier', + 'gross-monthly-pay', + 'employer-fica', + 'total', + ]); + }); +}); + +describe('buildSalaryBreakdownColumns', () => { + it('renders a category without its formula subtext', () => { + const { getByRole, queryByRole } = renderBreakdown([ + { + id: 'pay-rate', + category: 'Pay Rate', + amount: 5000, + format: 'currency', + }, + ]); + + expect(getByRole('gridcell', { name: 'Pay Rate' })).toBeInTheDocument(); + expect(queryByRole('gridcell', { name: /×/ })).not.toBeInTheDocument(); + }); + + it('renders a category with its formula subtext', () => { + const { getByRole } = renderBreakdown([ + { + id: 'gross-monthly-pay', + category: 'Gross Monthly Pay', + formula: 'Pay Rate × (1 + Geographic Multiplier)', + amount: 5000, + format: 'currency', + }, + ]); + expect( + getByRole('gridcell', { + name: 'Gross Monthly Pay Pay Rate × (1 + Geographic Multiplier)', + }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/styledGrid.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/styledGrid.tsx new file mode 100644 index 0000000000..5167a4a484 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/styledGrid.tsx @@ -0,0 +1,37 @@ +import { Box, styled } from '@mui/material'; +import { DataGrid } from '@mui/x-data-grid'; + +export const GridContainer = styled(Box)({ + display: 'flex', + flexDirection: 'column', +}); + +export const StyledGrid = styled(DataGrid)(({ theme }) => ({ + fontSize: theme.typography.body1.fontSize, + '.MuiDataGrid-columnHeaderTitle': { + fontWeight: 'bold', + color: theme.palette.mpdxBlue.main, + }, + '.MuiDataGrid-row.top-border .MuiDataGrid-cell': { + borderTop: `2px solid ${theme.palette.divider}`, + }, + '.MuiDataGrid-row.bold-row': { + fontWeight: 'bold', + }, + '.MuiDataGrid-row[data-id="total"]': { + fontWeight: 'bold', + }, + '.MuiDataGrid-row.bottom-border .MuiDataGrid-cell': { + borderBottom: `2px solid ${theme.palette.divider}`, + }, + '.category-cell': { + lineHeight: 1.3, + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + }, + '.category-formula': { + display: 'block', + color: theme.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + }, +})) as typeof DataGrid; diff --git a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts index dde350724c..78b4fc801e 100644 --- a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts +++ b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts @@ -13,6 +13,7 @@ const defaultConstants: OtherExpensesConstants = { workCompPercentage: 0.17, attritionRate: 0.06, creditCardFeeRate: 0.06, + adminRate: 0.12, }; const fullTime = ( @@ -144,12 +145,48 @@ describe('calculateOtherExpenses', () => { }); describe('assessment', () => { - it('is (subtotal + attrition + creditCardFees) / 0.88 minus itself', () => { + it('returns 0 when adminRate is 1 (avoids division by zero)', () => { + const result = calculateOtherExpenses(fullTime(), { + ...defaultConstants, + adminRate: 1, + }); + expect(result.assessment).toBe(0); + }); + + it('is the 12% assessment on (subtotal + attrition + creditCardFees)', () => { const result = calculateOtherExpenses(fullTime(), defaultConstants); - const preAssessment = - result.subtotal + result.attrition + result.creditCardFees; - const expected = preAssessment / 0.88 - preAssessment; - expect(result.assessment).toBeCloseTo(expected); + // subtotal=7400, attrition=444, creditCardFees=470.64 + // preAssessment = 8314.64 + // assessment = 8314.64 / (1 - 0.12) - 8314.64 ≈ 1133.81 + expect(result.assessment).toBeCloseTo(1133.81, 1); + }); + }); + + describe('end-to-end totals', () => { + it('produces correct totals for a full-time employee', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + expect(result.reimbursableExpenses).toBe(500); + expect(result.fourOThreeBContributions).toBeCloseTo(400); + expect(result.workComp).toBe(0); + expect(result.benefits).toBe(1500); + expect(result.subtotal).toBeCloseTo(7400); + expect(result.attrition).toBeCloseTo(444); + expect(result.creditCardFees).toBeCloseTo(470.64); + expect(result.assessment).toBeCloseTo(1133.81, 1); + }); + + it('produces correct totals for a part-time employee', () => { + const result = calculateOtherExpenses(partTime(), defaultConstants); + // reimbursable=500, 403b=400, workComp=4000*0.17=680, benefits=0 + // subtotal=5000+500+400+680+0=6580 + // attrition=6580*0.06=394.80 + // creditCardFees=(6580+394.80)*0.06=418.49 + // preAssessment=6580+394.80+418.49=7393.29 + // assessment=7393.29/(1-0.12)-7393.29≈1008.13 + expect(result.subtotal).toBeCloseTo(6580); + expect(result.attrition).toBeCloseTo(394.8); + expect(result.creditCardFees).toBeCloseTo(418.49, 1); + expect(result.assessment).toBeCloseTo(1008.13, 0); }); }); }); diff --git a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts new file mode 100644 index 0000000000..b5869a4ccb --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts @@ -0,0 +1,69 @@ +import { DesignationSupportStatus } from 'src/graphql/types.generated'; + +export interface OtherExpensesFields { + status?: DesignationSupportStatus | null; + benefits?: number | null; +} + +export interface OtherExpensesConstants { + reimbursableTotal: number; + salarySubtotal: number; + fourOThreeBPercentage: number; + grossMonthlyPay: number; + workCompPercentage: number; + attritionRate: number; + creditCardFeeRate: number; + adminRate: number; +} + +export interface OtherExpensesTotals { + reimbursableExpenses: number; + fourOThreeBContributions: number; + workComp: number; + benefits: number; + subtotal: number; + attrition: number; + creditCardFees: number; + assessment: number; +} + +export const calculateOtherExpenses = ( + calculation: OtherExpensesFields, + constants: OtherExpensesConstants, +): OtherExpensesTotals => { + const isPartTime = calculation.status === DesignationSupportStatus.PartTime; + const isFullTime = calculation.status === DesignationSupportStatus.FullTime; + + const reimbursableExpenses = constants.reimbursableTotal; + const fourOThreeBContributions = + constants.grossMonthlyPay * constants.fourOThreeBPercentage; + const workComp = isPartTime + ? constants.grossMonthlyPay * constants.workCompPercentage + : 0; + const benefits = isFullTime ? (calculation.benefits ?? 0) : 0; + + const subtotal = + constants.salarySubtotal + + reimbursableExpenses + + fourOThreeBContributions + + workComp + + benefits; + + const attrition = subtotal * constants.attritionRate; + const creditCardFees = (subtotal + attrition) * constants.creditCardFeeRate; + const preAssessment = subtotal + attrition + creditCardFees; + const adminDivisor = 1 - constants.adminRate; + const assessment = + adminDivisor === 0 ? 0 : preAssessment / adminDivisor - preAssessment; + + return { + reimbursableExpenses, + fourOThreeBContributions, + workComp, + benefits, + subtotal, + attrition, + creditCardFees, + assessment, + }; +}; From 6ed34947a06d2b88fc97c654d5e618c38e707798 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 14:41:29 -0400 Subject: [PATCH 05/11] Fix assessment formula --- .../SupportItem/otherBreakdown.tsx | 5 +++- .../calculations/OtherExpenses.test.ts | 26 +++++++++---------- .../calculations/OtherExpenses.ts | 6 ++--- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx index a24e6b2682..bb9fddfb91 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx @@ -116,7 +116,10 @@ export const buildOtherBreakdownRows = ( id: 'assessment', category: t('Assessment'), formula: t( - '(Subtotal + Credit Card Fees + Attrition) ÷ (1 − Admin Rate) − (Subtotal + Credit Card Fees + Attrition)', + '(Subtotal + Credit Card Fees + Attrition) × {{rate}}', + { + rate: percentageFormat(constants.adminRate, locale), + }, ), amount: totals.assessment, testId: 'other-assessment', diff --git a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts index 78b4fc801e..bc6f01250f 100644 --- a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts +++ b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts @@ -145,21 +145,20 @@ describe('calculateOtherExpenses', () => { }); describe('assessment', () => { - it('returns 0 when adminRate is 1 (avoids division by zero)', () => { + it('is (subtotal + creditCardFees + attrition) × adminRate', () => { + const result = calculateOtherExpenses(fullTime(), defaultConstants); + // subtotal=7400, attrition=444, creditCardFees=470.64 + // (7400 + 470.64 + 444) * 0.12 ≈ 997.76 + expect(result.assessment).toBeCloseTo(997.76, 1); + }); + + it('returns 0 when adminRate is 0', () => { const result = calculateOtherExpenses(fullTime(), { ...defaultConstants, - adminRate: 1, + adminRate: 0, }); expect(result.assessment).toBe(0); }); - - it('is the 12% assessment on (subtotal + attrition + creditCardFees)', () => { - const result = calculateOtherExpenses(fullTime(), defaultConstants); - // subtotal=7400, attrition=444, creditCardFees=470.64 - // preAssessment = 8314.64 - // assessment = 8314.64 / (1 - 0.12) - 8314.64 ≈ 1133.81 - expect(result.assessment).toBeCloseTo(1133.81, 1); - }); }); describe('end-to-end totals', () => { @@ -172,7 +171,7 @@ describe('calculateOtherExpenses', () => { expect(result.subtotal).toBeCloseTo(7400); expect(result.attrition).toBeCloseTo(444); expect(result.creditCardFees).toBeCloseTo(470.64); - expect(result.assessment).toBeCloseTo(1133.81, 1); + expect(result.assessment).toBeCloseTo(997.76, 1); }); it('produces correct totals for a part-time employee', () => { @@ -181,12 +180,11 @@ describe('calculateOtherExpenses', () => { // subtotal=5000+500+400+680+0=6580 // attrition=6580*0.06=394.80 // creditCardFees=(6580+394.80)*0.06=418.49 - // preAssessment=6580+394.80+418.49=7393.29 - // assessment=7393.29/(1-0.12)-7393.29≈1008.13 + // assessment=(6580+418.49+394.80)*0.12≈887.19 expect(result.subtotal).toBeCloseTo(6580); expect(result.attrition).toBeCloseTo(394.8); expect(result.creditCardFees).toBeCloseTo(418.49, 1); - expect(result.assessment).toBeCloseTo(1008.13, 0); + expect(result.assessment).toBeCloseTo(887.19, 1); }); }); }); diff --git a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts index b5869a4ccb..63debadd4c 100644 --- a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts +++ b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts @@ -51,10 +51,8 @@ export const calculateOtherExpenses = ( const attrition = subtotal * constants.attritionRate; const creditCardFees = (subtotal + attrition) * constants.creditCardFeeRate; - const preAssessment = subtotal + attrition + creditCardFees; - const adminDivisor = 1 - constants.adminRate; - const assessment = - adminDivisor === 0 ? 0 : preAssessment / adminDivisor - preAssessment; + const adminRate = constants.adminRate; + const assessment = (subtotal + creditCardFees + attrition) * adminRate; return { reimbursableExpenses, From 0406e8271e21b0b39207140fde225f0504271c97 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 14:50:50 -0400 Subject: [PATCH 06/11] Lost files in rebase --- .../PdsGoalCalculator/Salary/SalaryStep.tsx | 2 +- .../SupportItem/salaryBreakdown.tsx | 148 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx index 59a73ecc6e..54f59d20c6 100644 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx +++ b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { SalarySection } from './SalarySection'; +import { SalarySection } from '../SupportItem/SalarySection'; export const SalaryStep: React.FC = () => { return ; diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx new file mode 100644 index 0000000000..cd8138c7bc --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { Box, styled } from '@mui/material'; +import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { TFunction } from 'i18next'; +import { DesignationSupportSalaryType } from 'src/graphql/types.generated'; +import { + currencyFormat, + numberFormat, + percentageFormat, +} from 'src/lib/intlFormat'; +import { + SalaryCalculationFields, + SalaryConstants, + calculateSalaryTotals, +} from '../calculations/salaryCalculation'; + +type AmountFormat = 'currency' | 'percentage' | 'number'; + +const CategoryCellBox = styled(Box)({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + height: '100%', +}); + +export interface SalaryBreakdownRow { + id: string; + category: string; + formula?: string; + amount: number; + format: AmountFormat; + testId?: string; +} + +export const buildSalaryBreakdownRows = ( + calculation: SalaryCalculationFields, + constants: SalaryConstants, + locale: string, + t: TFunction, +): SalaryBreakdownRow[] => { + const { geographicMultiplier, employerFicaRate } = constants; + const { salaryOrHourly } = calculation; + const payRate = calculation.payRate ?? 0; + const hoursPerWeek = calculation.hoursWorkedPerWeek ?? 0; + const isSalaried = salaryOrHourly === DesignationSupportSalaryType.Salaried; + + const { monthlyBase, grossMonthlyPay, employerFica, subtotal } = + calculateSalaryTotals(calculation, constants); + + return [ + { + id: 'pay-rate', + category: t('Pay Rate'), + amount: payRate, + format: 'currency', + }, + ...(isSalaried + ? [] + : [ + { + id: 'hours-per-week', + category: t('Hours per Week'), + amount: hoursPerWeek, + format: 'number' as const, + }, + ]), + { + id: 'monthly-base', + category: t('Monthly Base'), + formula: isSalaried + ? t('Yearly Salary ÷ 12') + : t('Pay Rate × Hours per Week × 52 ÷ 12'), + amount: monthlyBase, + format: 'currency', + }, + { + id: 'geographic-multiplier', + category: t('Geographic Multiplier'), + amount: geographicMultiplier, + format: 'percentage', + }, + { + id: 'gross-monthly-pay', + category: t('Gross Monthly Pay'), + formula: t('Monthly Base × (1 + Geographic Multiplier)'), + amount: grossMonthlyPay, + format: 'currency', + testId: 'gross-monthly-pay', + }, + { + id: 'employer-fica', + category: t('Employer ½ FICA'), + formula: t('Gross Monthly Pay × {{rate}}', { + rate: percentageFormat(employerFicaRate, locale), + }), + amount: employerFica, + format: 'currency', + testId: 'employer-fica', + }, + { + id: 'total', + category: t('Subtotal'), + formula: t('Gross Monthly Pay + Employer ½ FICA'), + amount: subtotal, + format: 'currency', + testId: 'salary-subtotal', + }, + ]; +}; + +export const buildSalaryBreakdownColumns = ( + locale: string, + t: TFunction, +): GridColDef[] => [ + { + field: 'category', + headerName: t('Category'), + flex: 1, + minWidth: 200, + cellClassName: 'category-cell', + renderCell: (params: GridRenderCellParams) => ( + + {params.row.category} + {params.row.formula && ( + {params.row.formula} + )} + + ), + }, + { + field: 'amount', + headerName: t('Amount'), + flex: 1, + minWidth: 140, + align: 'left', + headerAlign: 'left', + renderCell: (params: GridRenderCellParams) => { + const { amount, format, testId } = params.row; + const formatted = + format === 'currency' + ? currencyFormat(amount, 'USD', locale) + : format === 'percentage' + ? percentageFormat(amount, locale) + : numberFormat(amount, locale); + return {formatted}; + }, + }, +]; From 7b212d2810aa3e0e1bedcc8925c0f91e4b0e86fc Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 15:20:40 -0400 Subject: [PATCH 07/11] Lost changes from rebase --- .../Reports/PdsGoalCalculator/PdsGoalCalculator.tsx | 6 +++--- .../Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts | 2 +- .../Reports/PdsGoalCalculator/Shared/useSteps.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculator.tsx b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculator.tsx index a61dbf08f3..7d7842faff 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculator.tsx +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculator.tsx @@ -3,11 +3,11 @@ import { SectionList } from 'src/components/Reports/GoalCalculator/SharedCompone import { DirectionButtons } from 'src/components/Reports/Shared/CalculationReports/DirectionButtons/DirectionButtons'; import { PdsGoalCalculatorStepEnum } from './PdsGoalCalculatorHelper'; import { ReimbursableExpensesStep } from './ReimbursableExpenses/ReimbursableExpensesStep'; -import { SalaryStep } from './Salary/SalaryStep'; import { SetupStep } from './Setup/SetupStep'; import { usePdsGoalCalculator } from './Shared/PdsGoalCalculatorContext'; import { PdsGoalCalculatorLayout } from './Shared/PdsGoalCalculatorLayout'; import { SummaryReportStep } from './SummaryReport/SummaryReportStep'; +import { SupportItemStep } from './SupportItem/SupportItemStep'; const CurrentStep: React.FC = () => { const { currentStep } = usePdsGoalCalculator(); @@ -17,8 +17,8 @@ const CurrentStep: React.FC = () => { return ; case PdsGoalCalculatorStepEnum.ReimbursableExpenses: return ; - case PdsGoalCalculatorStepEnum.Salary: - return ; + case PdsGoalCalculatorStepEnum.SupportItem: + return ; case PdsGoalCalculatorStepEnum.SummaryReport: return ; } diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts index 7378eaa187..1ef4268d59 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts @@ -1,6 +1,6 @@ export enum PdsGoalCalculatorStepEnum { Setup = 'setup', ReimbursableExpenses = 'reimbursable-expenses', - Salary = 'salary', + SupportItem = 'SupportItem', SummaryReport = 'summary-report', } diff --git a/src/components/Reports/PdsGoalCalculator/Shared/useSteps.tsx b/src/components/Reports/PdsGoalCalculator/Shared/useSteps.tsx index d0126a7dc4..c175374c50 100644 --- a/src/components/Reports/PdsGoalCalculator/Shared/useSteps.tsx +++ b/src/components/Reports/PdsGoalCalculator/Shared/useSteps.tsx @@ -39,7 +39,7 @@ export const useSteps = (): PdsGoalCalculatorStep[] => { ], }, { - step: PdsGoalCalculatorStepEnum.Salary, + step: PdsGoalCalculatorStepEnum.SupportItem, title: t('Support Item'), icon: , sections: [ From 44541cd699bdeb31d9a6a3b9700d66dfd22f9ba8 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 15:32:11 -0400 Subject: [PATCH 08/11] Fix miscGoalConstants bug --- .../Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts | 2 +- .../Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts index 1ef4268d59..26b4fa9300 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorHelper.ts @@ -1,6 +1,6 @@ export enum PdsGoalCalculatorStepEnum { Setup = 'setup', ReimbursableExpenses = 'reimbursable-expenses', - SupportItem = 'SupportItem', + SupportItem = 'support-item', SummaryReport = 'summary-report', } diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx index c23ab4c4ad..1f44f34b25 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx @@ -25,7 +25,7 @@ export const OtherSection: React.FC = () => { const additionalRates = goalMiscConstants.ADDITIONAL_RATES; const employerFicaRate = additionalRates?.EMPLOYER_FICA_RATE?.fee; const workCompPercentage = additionalRates?.PART_TIME_WORK_COMPENSATION?.fee; - const attritionRate = additionalRates?.ATTRITION_RATE?.fee; + const attritionRate = goalMiscConstants.RATES?.ATTRITION_RATE?.fee; const creditCardFeeRate = additionalRates?.CREDIT_CARD_FEE_RATE?.fee; const adminRate = goalMiscConstants.RATES?.ADMIN_RATE?.fee; From 34d2eac7afe8c8f69f21405baf57fce583b56d53 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 15:32:52 -0400 Subject: [PATCH 09/11] Runs prettier --- .../PdsGoalCalculator/SupportItem/OtherSection.test.tsx | 4 +--- .../SupportItem/otherBreakdown.test.tsx | 5 ++++- .../PdsGoalCalculator/SupportItem/otherBreakdown.tsx | 9 +++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx index ade584c533..16b34fe7f6 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx @@ -174,9 +174,7 @@ describe('OtherSection', () => { ); await waitFor(() => - expect( - queryByRole('heading', { name: 'Other' }), - ).not.toBeInTheDocument(), + expect(queryByRole('heading', { name: 'Other' })).not.toBeInTheDocument(), ); expect(container).toBeEmptyDOMElement(); }); diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx index 9f6575d66e..979a393e28 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx @@ -2,7 +2,10 @@ import { DataGrid } from '@mui/x-data-grid'; import { render } from '@testing-library/react'; import { t } from 'i18next'; import { DesignationSupportStatus } from 'src/graphql/types.generated'; -import { OtherExpensesConstants, OtherExpensesFields } from '../calculations/OtherExpenses'; +import { + OtherExpensesConstants, + OtherExpensesFields, +} from '../calculations/OtherExpenses'; import { OtherBreakdownRow, buildOtherBreakdownColumns, diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx index bb9fddfb91..9fdf037958 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.tsx @@ -115,12 +115,9 @@ export const buildOtherBreakdownRows = ( { id: 'assessment', category: t('Assessment'), - formula: t( - '(Subtotal + Credit Card Fees + Attrition) × {{rate}}', - { - rate: percentageFormat(constants.adminRate, locale), - }, - ), + formula: t('(Subtotal + Credit Card Fees + Attrition) × {{rate}}', { + rate: percentageFormat(constants.adminRate, locale), + }), amount: totals.assessment, testId: 'other-assessment', bold: true, From eac0b6306771ea3cdb5b36b8a22d7651d21f9c54 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 15:35:28 -0400 Subject: [PATCH 10/11] Removes dead code --- .../Salary/SalaryStep.test.tsx | 51 ------------------- .../PdsGoalCalculator/Salary/SalaryStep.tsx | 6 --- 2 files changed, 57 deletions(-) delete mode 100644 src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx delete mode 100644 src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx deleted file mode 100644 index 390972398f..0000000000 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { - DesignationSupportSalaryType, - DesignationSupportStatus, -} from 'src/graphql/types.generated'; -import { - PdsGoalCalculationMock, - PdsGoalCalculatorTestWrapper, -} from '../PdsGoalCalculatorTestWrapper'; -import { SalaryStep } from './SalaryStep'; - -const calculationMock: PdsGoalCalculationMock = { - salaryOrHourly: DesignationSupportSalaryType.Salaried, - payRate: 60000, - hoursWorkedPerWeek: null, - geographicLocation: null, - status: DesignationSupportStatus.FullTime, - benefits: 1500, - ministryCellPhone: 0, - ministryInternet: 0, - mpdNewsletter: 0, - mpdMiscellaneous: 0, - accountTransfers: 0, - otherMonthlyReimbursements: 0, - conferenceRetreatCosts: 0, - ministryTravelMeals: 0, - otherAnnualReimbursements: 0, -}; - -describe('SalaryStep', () => { - it('renders the Salary section', async () => { - const { findByRole } = render( - - - , - ); - - expect(await findByRole('heading', { name: 'Salary' })).toBeInTheDocument(); - }); - - it('renders the Other section', async () => { - const { findByRole } = render( - - - , - ); - - expect(await findByRole('heading', { name: 'Other' })).toBeInTheDocument(); - }); -}); diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx deleted file mode 100644 index 54f59d20c6..0000000000 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import { SalarySection } from '../SupportItem/SalarySection'; - -export const SalaryStep: React.FC = () => { - return ; -}; From abad16c40de9cccf50e5e409975d538ccc991787 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Mon, 20 Apr 2026 15:52:36 -0400 Subject: [PATCH 11/11] Fix test --- .../Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index 2be32b2dab..54814c6cd4 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -182,8 +182,7 @@ export const PdsGoalCalculatorTestWrapper: React.FC< fee: 0.17, }, { - category: - MpdGoalMiscConstantCategoryEnum.AdditionalRates, + category: MpdGoalMiscConstantCategoryEnum.Rates, label: MpdGoalMiscConstantLabelEnum.AttritionRate, fee: 0.06, },