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..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',
- Salary = 'salary',
+ SupportItem = 'support-item',
SummaryReport = 'summary-report',
}
diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx
index 84aafa76ab..54814c6cd4 100644
--- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx
+++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx
@@ -174,6 +174,29 @@ export const PdsGoalCalculatorTestWrapper: React.FC<
label: MpdGoalMiscConstantLabelEnum.EmployerFicaRate,
fee: 0.08,
},
+ {
+ category:
+ MpdGoalMiscConstantCategoryEnum.AdditionalRates,
+ label:
+ MpdGoalMiscConstantLabelEnum.PartTimeWorkCompensation,
+ fee: 0.17,
+ },
+ {
+ category: MpdGoalMiscConstantCategoryEnum.Rates,
+ label: MpdGoalMiscConstantLabelEnum.AttritionRate,
+ fee: 0.06,
+ },
+ {
+ category:
+ MpdGoalMiscConstantCategoryEnum.AdditionalRates,
+ label: MpdGoalMiscConstantLabelEnum.CreditCardFeeRate,
+ fee: 0.06,
+ },
+ {
+ category: MpdGoalMiscConstantCategoryEnum.Rates,
+ label: MpdGoalMiscConstantLabelEnum.AdminRate,
+ fee: 0.12,
+ },
],
},
constantsMock,
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/SalaryStep.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx
deleted file mode 100644
index 6065edb96f..0000000000
--- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-import { render } from '@testing-library/react';
-import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper';
-import { SalaryStep } from './SalaryStep';
-
-describe('SalaryStep', () => {
- it('renders the Salary section', async () => {
- const { findByRole } = render(
-
-
- ,
- );
-
- expect(await findByRole('heading', { name: 'Salary' })).toBeInTheDocument();
- });
-});
diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx
deleted file mode 100644
index 59a73ecc6e..0000000000
--- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import React from 'react';
-import { SalarySection } from './SalarySection';
-
-export const SalaryStep: React.FC = () => {
- return ;
-};
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: [
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..16b34fe7f6
--- /dev/null
+++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.test.tsx
@@ -0,0 +1,181 @@
+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('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(
+ ,
+ );
+
+ 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/SupportItem/OtherSection.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx
new file mode 100644
index 0000000000..1f44f34b25
--- /dev/null
+++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx
@@ -0,0 +1,124 @@
+import React, { useMemo } from 'react';
+import { Divider, 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 { OtherExpensesConstants } from '../calculations/OtherExpenses';
+import { calculateReimbursableTotals } from '../calculations/reimbursableExpenses';
+import { calculateSalaryTotals } from '../calculations/salaryCalculation';
+import {
+ buildOtherBreakdownColumns,
+ buildOtherBreakdownRows,
+} from './otherBreakdown';
+import { GridContainer, StyledGrid } from './styledGrid';
+
+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 = goalMiscConstants.RATES?.ATTRITION_RATE?.fee;
+ const creditCardFeeRate = additionalRates?.CREDIT_CARD_FEE_RATE?.fee;
+ const adminRate = goalMiscConstants.RATES?.ADMIN_RATE?.fee;
+
+ const rows = useMemo(() => {
+ if (
+ !calculation ||
+ employerFicaRate === undefined ||
+ workCompPercentage === undefined ||
+ attritionRate === undefined ||
+ creditCardFeeRate === undefined ||
+ adminRate === 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,
+ adminRate,
+ };
+
+ return buildOtherBreakdownRows(calculation, constants, locale, t);
+ }, [
+ calculation,
+ hcmUser,
+ employerFicaRate,
+ workCompPercentage,
+ attritionRate,
+ creditCardFeeRate,
+ adminRate,
+ 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}
+ />
+
+ >
+ );
+};
diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/SalarySection.tsx
similarity index 70%
rename from src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx
rename to src/components/Reports/PdsGoalCalculator/SupportItem/SalarySection.tsx
index 68e185295b..0b5038b882 100644
--- a/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx
+++ b/src/components/Reports/PdsGoalCalculator/SupportItem/SalarySection.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 { Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants';
import { useLocale } from 'src/hooks/useLocale';
@@ -10,35 +9,7 @@ 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;
+import { GridContainer, StyledGrid } from './styledGrid';
export const SalarySection: React.FC = () => {
const { t } = useTranslation();
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..979a393e28
--- /dev/null
+++ b/src/components/Reports/PdsGoalCalculator/SupportItem/otherBreakdown.test.tsx
@@ -0,0 +1,182 @@
+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..9fdf037958
--- /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) × {{rate}}', {
+ rate: percentageFormat(constants.adminRate, locale),
+ }),
+ 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/Salary/salaryBreakdown.test.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx
similarity index 64%
rename from src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx
rename to src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx
index 793bee2d5a..2c0be8fc45 100644
--- a/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx
+++ b/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx
@@ -56,6 +56,52 @@ describe('buildSalaryBreakdownRows', () => {
]);
});
+ 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,
diff --git a/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx
similarity index 100%
rename from src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.tsx
rename to src/components/Reports/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx
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
new file mode 100644
index 0000000000..bc6f01250f
--- /dev/null
+++ b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.test.ts
@@ -0,0 +1,190 @@
+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,
+ adminRate: 0.12,
+};
+
+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 + 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: 0,
+ });
+ expect(result.assessment).toBe(0);
+ });
+ });
+
+ 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(997.76, 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
+ // 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(887.19, 1);
+ });
+ });
+});
diff --git a/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts
new file mode 100644
index 0000000000..63debadd4c
--- /dev/null
+++ b/src/components/Reports/PdsGoalCalculator/calculations/OtherExpenses.ts
@@ -0,0 +1,67 @@
+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 adminRate = constants.adminRate;
+ const assessment = (subtotal + creditCardFees + attrition) * adminRate;
+
+ return {
+ reimbursableExpenses,
+ fourOThreeBContributions,
+ workComp,
+ benefits,
+ subtotal,
+ attrition,
+ creditCardFees,
+ assessment,
+ };
+};