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, + }; +};