From 6a952ceaee6f35574b9396abb74d4b752bf58080 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:05:16 -0500 Subject: [PATCH 1/2] Add calculations for reimbursable expenses and salary totals --- .../AnnualReimbursableSection.tsx | 2 +- .../MonthlyReimbursableSection.tsx | 2 +- .../ReimbursableExpensesGrid.tsx | 2 +- .../TotalReimbursableSection.tsx | 2 +- .../reimbursableExpenses.test.ts | 0 .../reimbursableExpenses.ts | 0 .../calculations/salaryCalculation.test.ts | 127 ++++++++++++++++++ .../calculations/salaryCalculation.ts | 39 ++++++ 8 files changed, 170 insertions(+), 4 deletions(-) rename src/components/Reports/PdsGoalCalculator/{ReimbursableExpenses => calculations}/reimbursableExpenses.test.ts (100%) rename src/components/Reports/PdsGoalCalculator/{ReimbursableExpenses => calculations}/reimbursableExpenses.ts (100%) create mode 100644 src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.test.ts create mode 100644 src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.ts diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx index bd7b184c64..fa98617825 100644 --- a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx +++ b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/AnnualReimbursableSection.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { calculateReimbursableTotals } from '../calculations/reimbursableExpenses'; import { ReimbursableExpensesGrid, ReimbursableField, } from './ReimbursableExpensesGrid'; -import { calculateReimbursableTotals } from './reimbursableExpenses'; export const AnnualReimbursableSection: React.FC = () => { const { t } = useTranslation(); diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx index fb00ee1762..49220d9382 100644 --- a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx +++ b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; +import { calculateReimbursableTotals } from '../calculations/reimbursableExpenses'; import { ReimbursableExpensesGrid, ReimbursableField, } from './ReimbursableExpensesGrid'; -import { calculateReimbursableTotals } from './reimbursableExpenses'; export const MonthlyReimbursableSection: React.FC = () => { const { t } = useTranslation(); diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx index a063040378..aa0375095c 100644 --- a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx +++ b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/ReimbursableExpensesGrid.tsx @@ -16,7 +16,7 @@ import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; import { useSaveField } from '../Shared/Autosave/useSaveField'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; -import { ReimbursableCalculationFields } from './reimbursableExpenses'; +import { ReimbursableCalculationFields } from '../calculations/reimbursableExpenses'; export type ReimbursableFieldName = keyof ReimbursableCalculationFields; diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx index 3508de8e49..846a2ca8ba 100644 --- a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx +++ b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx @@ -14,7 +14,7 @@ import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; import { REIMBURSABLE_FLOOR, calculateReimbursableTotals, -} from './reimbursableExpenses'; +} from '../calculations/reimbursableExpenses'; const AmountTypography = styled(Typography)(({ theme }) => ({ fontSize: '2.5rem', diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/reimbursableExpenses.test.ts b/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.test.ts similarity index 100% rename from src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/reimbursableExpenses.test.ts rename to src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.test.ts diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/reimbursableExpenses.ts b/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.ts similarity index 100% rename from src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/reimbursableExpenses.ts rename to src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.ts diff --git a/src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.test.ts b/src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.test.ts new file mode 100644 index 0000000000..c78088deed --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.test.ts @@ -0,0 +1,127 @@ +import { DesignationSupportSalaryType } from 'src/graphql/types.generated'; +import { + SalaryCalculationFields, + calculateSalaryTotals, +} from './salaryCalculation'; + +const FICA_RATE = 0.08; +const GEO_MULTIPLIER = 0.06; + +const salaried = ( + overrides: Partial = {}, +): SalaryCalculationFields => ({ + salaryOrHourly: DesignationSupportSalaryType.Salaried, + // Yearly salary — divided by 12 for monthly base + payRate: 60000, + hoursWorkedPerWeek: null, + geographicLocation: null, + ...overrides, +}); + +const hourly = ( + overrides: Partial = {}, +): SalaryCalculationFields => ({ + salaryOrHourly: DesignationSupportSalaryType.Hourly, + payRate: 25, + hoursWorkedPerWeek: 40, + geographicLocation: null, + ...overrides, +}); + +describe('calculateSalaryTotals', () => { + describe('salaried', () => { + it('divides yearly payRate by 12 when there is no geographic multiplier', () => { + const result = calculateSalaryTotals(salaried(), { + geographicMultiplier: 0, + employerFicaRate: FICA_RATE, + }); + // 60000 / 12 + expect(result.grossMonthlyPay).toBe(5000); + }); + + it('applies geographic multiplier additively', () => { + const result = calculateSalaryTotals(salaried(), { + geographicMultiplier: GEO_MULTIPLIER, + employerFicaRate: FICA_RATE, + }); + // (60000 / 12) * 1.06 + expect(result.grossMonthlyPay).toBeCloseTo(5300); + }); + + it('ignores hoursWorkedPerWeek', () => { + const result = calculateSalaryTotals( + salaried({ hoursWorkedPerWeek: 40 }), + { geographicMultiplier: 0, employerFicaRate: FICA_RATE }, + ); + expect(result.grossMonthlyPay).toBe(5000); + }); + }); + + describe('hourly', () => { + it('converts hourly rate to monthly when there is no geographic multiplier', () => { + const result = calculateSalaryTotals(hourly(), { + geographicMultiplier: 0, + employerFicaRate: FICA_RATE, + }); + // 25 * 40 * 52 / 12 + expect(result.grossMonthlyPay).toBeCloseTo(4333.333, 2); + }); + + it('applies geographic multiplier to the hourly-derived monthly base', () => { + const result = calculateSalaryTotals(hourly(), { + geographicMultiplier: GEO_MULTIPLIER, + employerFicaRate: FICA_RATE, + }); + // (25 * 40 * 52 / 12) * 1.06 + expect(result.grossMonthlyPay).toBeCloseTo(4593.333, 2); + }); + }); + + describe('null amounts', () => { + it('treats null payRate as 0 when salaried', () => { + const result = calculateSalaryTotals(salaried({ payRate: null }), { + geographicMultiplier: GEO_MULTIPLIER, + employerFicaRate: FICA_RATE, + }); + expect(result.grossMonthlyPay).toBe(0); + expect(result.employerFica).toBe(0); + expect(result.subtotal).toBe(0); + }); + + it('treats null hoursWorkedPerWeek as 0 when hourly', () => { + const result = calculateSalaryTotals( + hourly({ hoursWorkedPerWeek: null }), + { geographicMultiplier: 0, employerFicaRate: FICA_RATE }, + ); + expect(result.grossMonthlyPay).toBe(0); + }); + }); + + describe('employer FICA and subtotal', () => { + it('multiplies grossMonthlyPay by the provided FICA rate', () => { + const result = calculateSalaryTotals(salaried(), { + geographicMultiplier: 0, + employerFicaRate: FICA_RATE, + }); + expect(result.employerFica).toBeCloseTo(400); + }); + + it('uses the passed-in FICA rate verbatim (no fallback applied)', () => { + const result = calculateSalaryTotals(salaried(), { + geographicMultiplier: 0, + employerFicaRate: 0.0765, + }); + expect(result.employerFica).toBeCloseTo(382.5); + }); + + it('returns subtotal as the sum of grossMonthlyPay and employerFica', () => { + const result = calculateSalaryTotals(salaried(), { + geographicMultiplier: GEO_MULTIPLIER, + employerFicaRate: FICA_RATE, + }); + expect(result.subtotal).toBeCloseTo( + result.grossMonthlyPay + result.employerFica, + ); + }); + }); +}); diff --git a/src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.ts b/src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.ts new file mode 100644 index 0000000000..d47af4622d --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/calculations/salaryCalculation.ts @@ -0,0 +1,39 @@ +import { + DesignationSupportCalculation, + DesignationSupportSalaryType, +} from 'src/graphql/types.generated'; + +export type SalaryCalculationFields = Pick< + DesignationSupportCalculation, + 'salaryOrHourly' | 'payRate' | 'hoursWorkedPerWeek' | 'geographicLocation' +>; + +export interface SalaryConstants { + geographicMultiplier: number; + employerFicaRate: number; +} + +export interface SalaryTotals { + monthlyBase: number; + grossMonthlyPay: number; + employerFica: number; + subtotal: number; +} + +export const calculateSalaryTotals = ( + calculation: SalaryCalculationFields, + constants: SalaryConstants, +): SalaryTotals => { + const { geographicMultiplier, employerFicaRate } = constants; + const payRate = calculation.payRate ?? 0; + const hours = calculation.hoursWorkedPerWeek ?? 0; + const isSalaried = + calculation.salaryOrHourly === DesignationSupportSalaryType.Salaried; + + const monthlyBase = isSalaried ? payRate / 12 : (payRate * hours * 52) / 12; + const grossMonthlyPay = monthlyBase * (1 + geographicMultiplier); + const employerFica = grossMonthlyPay * employerFicaRate; + const subtotal = grossMonthlyPay + employerFica; + + return { monthlyBase, grossMonthlyPay, employerFica, subtotal }; +}; From 18a45bc7b9f5218c73ee4e43ced8f720507d3362 Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:06:29 -0500 Subject: [PATCH 2/2] Add SalarySection component and related tests for salary breakdown calculations --- .../PdsGoalCalculatorTestWrapper.tsx | 18 ++- .../Salary/SalarySection.test.tsx | 151 ++++++++++++++++++ .../Salary/SalarySection.tsx | 103 ++++++++++++ .../Salary/SalaryStep.test.tsx | 6 +- .../PdsGoalCalculator/Salary/SalaryStep.tsx | 11 +- .../Salary/salaryBreakdown.test.tsx | 109 +++++++++++++ .../Salary/salaryBreakdown.tsx | 148 +++++++++++++++++ 7 files changed, 531 insertions(+), 15 deletions(-) create mode 100644 src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx create mode 100644 src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.tsx diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index 9b64fc7dfd..84aafa76ab 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; -import { merge } from 'lodash'; +import { merge, mergeWith } from 'lodash'; import { SnackbarProvider } from 'notistack'; import { DeepPartial } from 'ts-essentials'; import TestRouter from '__tests__/util/TestRouter'; @@ -10,6 +10,8 @@ import { GetUserQuery } from 'src/components/User/GetUser.generated'; import { DesignationSupportSalaryType, DesignationSupportStatus, + MpdGoalMiscConstantCategoryEnum, + MpdGoalMiscConstantLabelEnum, } from 'src/graphql/types.generated'; import { GoalCalculatorConstantsQuery } from 'src/hooks/goalCalculatorConstants.generated'; import theme from 'src/theme'; @@ -147,7 +149,8 @@ export const PdsGoalCalculatorTestWrapper: React.FC< }, ...(userMock ? { GetUser: userMock } : {}), GoalCalculatorConstants: { - constant: merge( + constant: mergeWith( + {}, { mpdGoalBenefitsConstants: [], mpdGoalGeographicConstants: [ @@ -164,9 +167,18 @@ export const PdsGoalCalculatorTestWrapper: React.FC< percentageMultiplier: 0.12, }, ], - mpdGoalMiscConstants: [], + mpdGoalMiscConstants: [ + { + category: + MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: MpdGoalMiscConstantLabelEnum.EmployerFicaRate, + fee: 0.08, + }, + ], }, constantsMock, + (_objValue, srcValue) => + Array.isArray(srcValue) ? srcValue : undefined, ), }, }} diff --git a/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx new file mode 100644 index 0000000000..9a84b64fe5 --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.test.tsx @@ -0,0 +1,151 @@ +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 new file mode 100644 index 0000000000..68e185295b --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/Salary/SalarySection.tsx @@ -0,0 +1,103 @@ +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/SalaryStep.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx index 6b8a2047f4..6065edb96f 100644 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.test.tsx @@ -4,13 +4,13 @@ import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; import { SalaryStep } from './SalaryStep'; describe('SalaryStep', () => { - it('renders the support item heading', () => { - const { getByText } = render( + it('renders the Salary section', async () => { + const { findByRole } = render( , ); - expect(getByText('Support Item')).toBeInTheDocument(); + 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 index dfb80bf5f7..59a73ecc6e 100644 --- a/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx +++ b/src/components/Reports/PdsGoalCalculator/Salary/SalaryStep.tsx @@ -1,13 +1,6 @@ import React from 'react'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; +import { SalarySection } from './SalarySection'; export const SalaryStep: React.FC = () => { - const { t } = useTranslation(); - - return ( - - {t('Support Item')} - - ); + return ; }; diff --git a/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx b/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx new file mode 100644 index 0000000000..793bee2d5a --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/Salary/salaryBreakdown.test.tsx @@ -0,0 +1,109 @@ +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 new file mode 100644 index 0000000000..cd8138c7bc --- /dev/null +++ b/src/components/Reports/PdsGoalCalculator/Salary/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}; + }, + }, +];