From cb9815de4008c5aa19cffd1eee37ede03c84cc54 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 17 Sep 2026 15:14:42 -0600 Subject: [PATCH 1/2] [MPDX-9992] Split the MPGA salary row by person The Staff Expense report already gives each spouse their own salary rows, but the MPGA report still rolled a couple's payroll into a single "Salary" line. Ask the API for each transaction's person number, load the household from HCM the way the Staff Expense report does, and split the combined Salary row into one row per person, reader first, labeled "Salary (Name)". Unattributed payroll stays with the reader and a person HCM does not list reads "Spouse". A single staff member's row is unchanged. Each person's monthly amount is summed from their own transactions, as the Staff Expense report does, so a rounding gap between the API's month total and its transactions never becomes a phantom row for a person the month did not pay. Every split row keeps the Salary category, so the pie chart, totals, CSV export, and breakdown modal work as before. The supervisor drawer's MPGA link now carries the person number so a supervisor sees the same split. The HCM and report requests leave together, and the report counts as loading until both land, so salary is never shown combined and then split. Co-Authored-By: Claude Fable 5.1 --- .../mpgaIncomeExpenses/index.page.test.tsx | 18 + .../reports/mpgaIncomeExpenses/index.page.tsx | 8 +- .../StaffDetailsTabs/MPGA/MPGA.test.tsx | 10 +- .../StaffDetailsTabs/MPGA/MPGA.tsx | 3 + .../StaffMemberDrawer/StaffMemberDrawer.tsx | 5 +- .../ExportCsvButton/ExportCsvButton.test.tsx | 43 ++- .../ExportCsvButton/ExportCsvButton.tsx | 7 +- .../Helper/MPGAReportEnum.tsx | 2 + .../Helper/filterFunds.ts | 226 ++++++++++-- .../MPGAIncomeExpensesContext.test.tsx | 185 +++++++++- .../MPGAIncomeExpensesContext.tsx | 43 ++- .../MPGAIncomeExpensesReportTestWrapper.tsx | 44 ++- .../MPGATransactions.graphql | 1 + .../Reports/Shared/Helpers/household.ts | 5 + .../Helpers/filterTransactions.tsx | 7 +- .../StaffExpenseReport/StaffExpenseReport.tsx | 7 +- src/hooks/useFilteredFunds.test.ts | 327 +++++++++++++++++- src/hooks/useFilteredFunds.ts | 12 +- 18 files changed, 878 insertions(+), 75 deletions(-) create mode 100644 src/components/Reports/Shared/Helpers/household.ts diff --git a/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.test.tsx b/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.test.tsx index 200172bf5..04cf91a68 100644 --- a/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.test.tsx +++ b/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.test.tsx @@ -16,12 +16,14 @@ import MPGAReportPage, { getServerSideProps } from './index.page'; const mutationSpy = jest.fn(); const id = '1000000001'; +const personNumber = '000000111'; interface ComponentProps { userType?: UserTypeEnum; staffAccountId?: string | null; supervisesStaff?: boolean; viewedStaffAccountId?: string; + viewedPersonNumber?: string; } const Components = ({ @@ -29,6 +31,7 @@ const Components = ({ staffAccountId = '12345', supervisesStaff = false, viewedStaffAccountId, + viewedPersonNumber, }: ComponentProps) => ( @@ -158,5 +162,19 @@ describe('MPGA Report Page', () => { }), ); }); + + it('passes the person number from the url into the household query', async () => { + render( + , + ); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('Hcm', { personNumber }), + ); + }); }); }); diff --git a/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx b/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx index 44c522e44..814380e27 100644 --- a/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx +++ b/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx @@ -28,8 +28,9 @@ const MPGAReportPage: React.FC = () => { const { t } = useTranslation(); const { query } = useRouter(); // A blank param is a request for somebody else, which the API denies. - // Only a missing one falls back to your own account. + // Only a missing one falls back to your own account and HCM record. const staffAccountId = getQueryParam(query, 'staffAccountId') || undefined; + const personNumber = getQueryParam(query, 'personNumber') || undefined; const [isNavListOpen, setIsNavListOpen] = useState(false); @@ -64,7 +65,10 @@ const MPGAReportPage: React.FC = () => { leftOpen={isNavListOpen} leftWidth="290px" mainContent={ - + render( - + , ); describe('StaffTabMPGA', () => { - it('links to the staff member MPGA report', () => { + it('links to the staff member MPGA report with their person number', () => { const { getByRole } = renderMPGA(staffAccountId); expect(getByRole('link', { name: 'View MPGA Report' })).toHaveAttribute( 'href', - `/accountLists/${accountListId}/reports/mpgaIncomeExpenses?staffAccountId=1000000001`, + `/accountLists/${accountListId}/reports/mpgaIncomeExpenses?staffAccountId=${staffAccountId}&personNumber=${personNumber}`, ); }); }); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/MPGA.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/MPGA.tsx index faabbf445..756c076a1 100644 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/MPGA.tsx +++ b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/MPGA.tsx @@ -4,10 +4,12 @@ import { ViewReportLink } from '../ViewReportLink/ViewReportLink'; interface StaffTabMPGAProps { staffAccountId: string; + personNumber: string; } export const StaffTabMPGA: React.FC = ({ staffAccountId, + personNumber, }) => { const { t } = useTranslation(); @@ -16,6 +18,7 @@ export const StaffTabMPGA: React.FC = ({ staffAccountId={staffAccountId} reportLink="mpgaIncomeExpenses" reportName={t('MPGA')} + personNumber={personNumber} /> ); }; diff --git a/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.tsx b/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.tsx index 89e3c80e5..cde9b5f03 100644 --- a/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.tsx +++ b/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.tsx @@ -260,7 +260,10 @@ export const StaffMemberDrawer: React.FC = () => { - + { expect(exportToCsv).not.toHaveBeenCalled(); }); + it('disables all exports while the report is still loading', async () => { + // Rows can exist before the household has answered, and an export taken then would show a + // couple's salary as one row. Only the finished report is exportable. + const loadingContext = { + allData: mockData, + dataLoading: true, + monthLabels: months, + startBalance: null, + } as unknown as ContextType; + + const { getByRole, findByRole } = render( + + + , + ); + + userEvent.click(getByRole('button', { name: 'Export CSV' })); + + expect( + await findByRole('menuitem', { name: 'Income Report' }), + ).toHaveAttribute('aria-disabled', 'true'); + expect(getByRole('menuitem', { name: 'Expenses Report' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect(getByRole('menuitem', { name: 'Balance Report' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + it('closes the menu after an export is selected', async () => { const { findByRole, queryByRole } = render(); userEvent.click(await findByRole('button', { name: 'Export CSV' })); - userEvent.click(await findByRole('menuitem', { name: 'Income Report' })); + + const income = await findByRole('menuitem', { name: 'Income Report' }); + await waitFor(() => + expect(income).not.toHaveAttribute('aria-disabled', 'true'), + ); + userEvent.click(income); await waitFor(() => expect( diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx index 9ad17a75a..9b798f908 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx @@ -11,7 +11,7 @@ export const ExportCsvButton: React.FC = () => { const { t } = useTranslation(); const locale = useLocale(); - const { allData: data, monthLabels } = useMPGAIncomeExpenses(); + const { allData: data, dataLoading, monthLabels } = useMPGAIncomeExpenses(); const balanceData = useBalanceTableData(); return ( @@ -31,7 +31,8 @@ export const ExportCsvButton: React.FC = () => { }, { label: t('Income Report'), - disabled: !data.income.length, + // Rows can exist before the household answers, so only the finished report is exportable. + disabled: dataLoading || !data.income.length, onClick: () => exportToCsv( data.income, @@ -42,7 +43,7 @@ export const ExportCsvButton: React.FC = () => { }, { label: t('Expenses Report'), - disabled: !data.expenses.length, + disabled: dataLoading || !data.expenses.length, onClick: () => exportToCsv( data.expenses, diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Helper/MPGAReportEnum.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Helper/MPGAReportEnum.tsx index 9c48b8833..08926cf86 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Helper/MPGAReportEnum.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Helper/MPGAReportEnum.tsx @@ -17,6 +17,8 @@ interface Transaction { transactedAt: string; description?: string | null; amount: number; + /** Null when SAA has no employee for the transaction's EMPLID. */ + personNumber?: string | null; } interface BreakdownByMonth { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts b/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts index 6d02db2b8..994b07ece 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts +++ b/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts @@ -1,5 +1,9 @@ import { TFunction } from 'react-i18next'; -import { StaffExpensesSubCategoryEnum } from 'src/graphql/types.generated'; +import { + StaffExpenseCategoryEnum, + StaffExpensesSubCategoryEnum, +} from 'src/graphql/types.generated'; +import { HouseholdMember } from '../../Shared/Helpers/household'; import { getLocalizedCategory, getLocalizedSubCategory, @@ -119,6 +123,96 @@ export function addRowPerSubcategory({ }); } +interface AddCombinedRowProps extends AddRowProps { + household?: HouseholdMember[]; +} + +/** One person's share of a combined category, split by sign into income and expense rows. */ +interface PersonAccumulator { + incomeMonthly: number[]; + expenseMonthly: number[]; + incomeTransactions: TransactionBreakdown[]; + expenseTransactions: TransactionBreakdown[]; +} + +/** One person's slice of a subcategory month. */ +interface PersonMonth { + /** Whose slice this is, or null where the household stays together. */ + personNumber: string | null; + total: number; + transactions: TransactionBreakdown[]; +} + +const createAccumulator = (monthCount: number): PersonAccumulator => ({ + incomeMonthly: new Array(monthCount).fill(0), + expenseMonthly: new Array(monthCount).fill(0), + incomeTransactions: [], + expenseTransactions: [], +}); + +/** + * Salary splits one row per person once a second person turns up, either listed by HCM or carried + * on a payroll line. Until then the row is the API's month total, exactly as before splitting + * existed. Benefits and everything else stay one household row. + */ +const splitsPerPerson = ( + category: Categories, + household: HouseholdMember[], +): boolean => { + const [reader, ...others] = household; + if (category.category !== StaffExpenseCategoryEnum.Salary || !reader) { + return false; + } + return ( + others.length > 0 || + category.subcategories.some((subcategory) => + subcategory.breakdownByMonth.some((month) => + month.transactions?.some( + ({ personNumber }) => + personNumber && personNumber !== reader.personNumber, + ), + ), + ) + ); +}; + +/** + * Divides a subcategory month between the people it paid, each summed from their own transactions + * the way the Staff Expense report does. Payroll SAA could not attribute falls to the reader: a + * couple share one account, so the alternative is a row belonging to nobody. The month total is + * not consulted here; charging its rounding gap to anyone would invent a row for a person the + * month never paid. + */ +const splitMonth = ( + transactions: { + personNumber: string | null; + breakdown: TransactionBreakdown; + }[], + reader: string, +): PersonMonth[] => { + // The reader leads whoever else turns up, so their slice is seeded first. + const slices = new Map([ + [reader, { personNumber: reader, total: 0, transactions: [] }], + ]); + + transactions.forEach(({ personNumber, breakdown }) => { + const owner = personNumber ?? reader; + const slice = slices.get(owner) ?? { + personNumber: owner, + total: 0, + transactions: [], + }; + slice.total += breakdown.amount; + slice.transactions.push(breakdown); + slices.set(owner, slice); + }); + + return Array.from(slices.values(), (slice) => ({ + ...slice, + total: roundTwoDecimals(slice.total), + })); +}; + // Checked category with subcategories: combine its subcategories into one row export function addCombinedSubcategoryRow({ baseId, @@ -126,59 +220,117 @@ export function addCombinedSubcategoryRow({ t, incomeData, expenseData, -}: AddRowProps) { + household = [], +}: AddCombinedRowProps) { const monthCount = category.breakdownByMonth.length; - const incomeMonthly = new Array(monthCount).fill(0); - const expenseMonthly = new Array(monthCount).fill(0); + const reader = splitsPerPerson(category, household) + ? household[0].personNumber + : null; - const incomeTransactions: TransactionBreakdown[] = []; - const expenseTransactions: TransactionBreakdown[] = []; + // Insertion order is display order, so the reader's row leads whoever else turns up. + const people = new Map(); + const accumulatorFor = (personNumber: string | null) => { + const existing = people.get(personNumber); + if (existing) { + return existing; + } + const created = createAccumulator(monthCount); + people.set(personNumber, created); + return created; + }; + accumulatorFor(reader); category.subcategories.forEach((subcategory) => { subcategory.breakdownByMonth.forEach((month, index) => { - const value = roundTwoDecimals(month.total); - const isIncome = value >= 0; - const bucket = isIncome ? incomeMonthly : expenseMonthly; - bucket[index] += value; - - const breakdown = isIncome ? incomeTransactions : expenseTransactions; - month.transactions?.forEach((transaction) => { - breakdown.push({ + const monthTotal = roundTwoDecimals(month.total); + const transactions = (month.transactions ?? []).map((transaction) => ({ + // A blank person number is as unattributed as a missing one. + personNumber: transaction.personNumber || null, + breakdown: { date: transaction.transactedAt, description: transaction.description ?? '', category: category.category, subCategory: subcategory.subCategory, amount: transaction.amount, - }); + }, + })); + + const slices: PersonMonth[] = + reader === null + ? [ + { + personNumber: null, + total: monthTotal, + transactions: transactions.map(({ breakdown }) => breakdown), + }, + ] + : splitMonth(transactions, reader); + + slices.forEach((slice) => { + const accumulator = accumulatorFor(slice.personNumber); + const isIncome = slice.total >= 0; + (isIncome ? accumulator.incomeMonthly : accumulator.expenseMonthly)[ + index + ] += slice.total; + (isIncome + ? accumulator.incomeTransactions + : accumulator.expenseTransactions + ).push(...slice.transactions); }); }); }); - const description = + const categoryLabel = getPluralizedDescription(category.category, t) || getLocalizedCategory(category.category, t); - const pushAggregateRow = ( - id: string, - monthly: number[], - transactions: TransactionBreakdown[], - ) => { - pushData( - { - id, - description, - category: category.category, - transactions, - monthly, - average: average(monthly), - total: sum(monthly), - }, - incomeData, - expenseData, - ); - }; + // Naming a person is only worth the noise once the report holds someone besides the reader. + const namePeople = people.size > 1; + // A person number HCM does not list belongs to neither spouse, so the household cannot name it. + const unknownName = t('Spouse'); - pushAggregateRow(`${baseId}-income`, incomeMonthly, incomeTransactions); - pushAggregateRow(`${baseId}-expense`, expenseMonthly, expenseTransactions); + people.forEach((accumulator, personNumber) => { + const description = + namePeople && personNumber !== null + ? t('{{bucket}} ({{person}})', { + bucket: categoryLabel, + person: + household.find((member) => member.personNumber === personNumber) + ?.name ?? unknownName, + }) + : categoryLabel; + const rowId = namePeople ? `${baseId}-${personNumber}` : baseId; + + const pushAggregateRow = ( + id: string, + monthly: number[], + transactions: TransactionBreakdown[], + ) => { + pushData( + { + id, + description, + category: category.category, + transactions, + monthly, + average: average(monthly), + total: sum(monthly), + }, + incomeData, + expenseData, + ); + }; + + pushAggregateRow( + `${rowId}-income`, + accumulator.incomeMonthly, + accumulator.incomeTransactions, + ); + pushAggregateRow( + `${rowId}-expense`, + accumulator.expenseMonthly, + accumulator.expenseTransactions, + ); + }); } // Checked or unchecked category with no subcategories: use the category-level rollup diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.test.tsx index c7a0820e0..e00d0bb81 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.test.tsx @@ -1,11 +1,22 @@ import React from 'react'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { render } from '__tests__/util/testingLibraryReactMock'; +import { + StaffExpenseCategoryEnum, + StaffExpensesSubCategoryEnum, +} from 'src/graphql/types.generated'; import { DateRange } from '../../StaffExpenseReport/Helpers/StaffReportEnum'; -import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; +import { + MPGAIncomeExpensesReportTestWrapper, + hcmHouseholdMock, +} from '../MPGAIncomeExpensesReportTestWrapper'; import { MpgaTransactionsQuery } from '../MPGATransactions.generated'; -import { useMPGAIncomeExpenses } from './MPGAIncomeExpensesContext'; +import { + MPGAIncomeExpensesReportProvider, + useMPGAIncomeExpenses, +} from './MPGAIncomeExpensesContext'; const mutationSpy = jest.fn(); const lastCompletedYear = 2019; @@ -94,6 +105,19 @@ function FilterConsumer() { ); } +function IncomeRowsConsumer() { + const { allData, dataLoading } = useMPGAIncomeExpenses(); + + return ( +
+
{String(dataLoading)}
+
+ {allData.income.map((row) => row.description).join('|')} +
+
+ ); +} + function StaffAccountConsumer() { const { staffName, isSupervisorView } = useMPGAIncomeExpenses(); @@ -298,4 +322,161 @@ describe('MPGAIncomeExpensesContext', () => { ); }); }); + + describe('household', () => { + const payroll = (amount: number, personNumber: string | null) => ({ + id: `${personNumber}-${amount}`, + amount, + transactedAt: '2019-01-15T00:00:00Z', + description: 'Payroll', + personNumber, + }); + + const coupleSalaryMock: MpgaTransactionsQuery = { + reportsStaffExpenses: { + name: 'Test Account', + transactionYears: [], + funds: [ + { + id: 'fund-1', + fundType: 'Primary', + startBalance: 0, + total: 300, + categories: [ + { + category: StaffExpenseCategoryEnum.Salary, + averagePerMonth: 300, + total: 300, + breakdownByMonth: [{ month: '2019-01-01', total: 300 }], + subcategories: [ + { + subCategory: StaffExpensesSubCategoryEnum.RegularPay, + averagePerMonth: 300, + total: 300, + breakdownByMonth: [ + { + month: '2019-01-01', + total: 300, + transactions: [ + payroll(200, '000000111'), + payroll(100, '000000222'), + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + it('requests your own household when no person number is given', async () => { + render( + + + , + ); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('Hcm', { + personNumber: undefined, + }), + ); + }); + + it('requests the household of the staff member being viewed', async () => { + render( + + + , + ); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('Hcm', { + personNumber: '000000111', + }), + ); + }); + + it('requests the report without waiting for the household', async () => { + // A mock given as a function runs when the request leaves, and onCall runs when its + // response lands, so the log shows whether the report waited on HCM. + const log: string[] = []; + const requestLogger = + (name: string, data: T) => + () => { + log.push(`request ${name}`); + return data; + }; + + render( + + } + onCall={({ operation }) => + log.push(`response ${operation.operationName}`) + } + > + + + + , + ); + + await waitFor(() => expect(log).toContain('response MPGATransactions')); + expect(log).toEqual([ + 'request Hcm', + 'request MPGATransactions', + 'response Hcm', + 'response MPGATransactions', + ]); + }); + + it('does not request a household for a supervisor with no person number', async () => { + const { getByTestId } = render( + + + , + ); + + await waitFor(() => + expect(getByTestId('dataLoading')).toHaveTextContent('false'), + ); + expect(mutationSpy).not.toHaveGraphqlOperation('Hcm'); + }); + + it("splits a couple's salary into one row per person", async () => { + const { getByTestId } = render( + + + , + ); + + await waitFor(() => + expect(getByTestId('incomeRows')).toHaveTextContent( + 'Salary (Alex)|Salary (Jordan)', + ), + ); + }); + }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.tsx index 477c93695..5846e65ee 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesContext/MPGAIncomeExpensesContext.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useMemo, useState } from 'react'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; +import { useHcmQuery } from 'src/components/HrTools/Shared/HcmData/Hcm.generated'; import { useExpenseCategories } from 'src/hooks/useExpenseCategories'; import { useFilteredFunds } from 'src/hooks/useFilteredFunds'; import { useGetLastTwelveMonths } from 'src/hooks/useGetLastTwelveMonths'; import { useLocale } from 'src/hooks/useLocale'; import { monthYearFormat } from 'src/lib/intlFormat'; +import { HouseholdMember } from '../../Shared/Helpers/household'; import { transformTransactionDate } from '../../Shared/Helpers/transformTransactionDate'; import { Filters } from '../../Shared/SettingsDialog/SettingsDialog'; import { DateRange } from '../../StaffExpenseReport/Helpers/StaffReportEnum'; @@ -68,6 +70,8 @@ export const useMPGAIncomeExpenses = (): ContextType => { interface Props { children?: React.ReactNode; staffAccountId?: string | null; + /** The HCM person whose household to load when a supervisor views someone else's report. */ + personNumber?: string; } const sum = (rows?: DataFields[]): number => { @@ -77,11 +81,30 @@ const sum = (rows?: DataFields[]): number => { export const MPGAIncomeExpensesReportProvider: React.FC = ({ children, staffAccountId, + personNumber, }) => { const { t } = useTranslation(); const locale = useLocale(); const currency = 'USD'; + const isSupervisorView = Boolean(staffAccountId); + + // Person numbers tell the reader's payroll from their spouse's. HCM lists the reader first, then + // their spouse. Both requests leave together; `loading` below covers them both so salary is not + // rendered as one household total and then split. + const { data: hcmData, loading: hcmLoading } = useHcmQuery({ + variables: { personNumber }, + skip: isSupervisorView && !personNumber, + }); + const household: HouseholdMember[] = useMemo( + () => + hcmData?.hcm.map(({ staffInfo }) => ({ + personNumber: staffInfo.personNumber, + name: staffInfo.preferredName ?? staffInfo.lastName, + })) ?? [], + [hcmData], + ); + const [filters, setFilters] = useState(null); const now = useMemo(() => DateTime.now(), []); @@ -142,17 +165,19 @@ export const MPGAIncomeExpensesReportProvider: React.FC = ({ }); }, [selectedYear, isYearToDate, startDate, endDate, locale, t]); - const { data: reportData, loading } = useMpgaTransactionsQuery({ - variables: { - fundTypes: [FundTypes.Primary], - startMonth: startDate.toISODate(), - endMonth: endDate.toISODate(), - staffAccountId, + const { data: reportData, loading: reportLoading } = useMpgaTransactionsQuery( + { + variables: { + fundTypes: [FundTypes.Primary], + startMonth: startDate.toISODate(), + endMonth: endDate.toISODate(), + staffAccountId, + }, }, - }); + ); + const loading = reportLoading || hcmLoading; const staffName = reportData?.reportsStaffExpenses?.name; - const isSupervisorView = Boolean(staffAccountId); // Filter out the current year since we only want to show previous years in filter dropdown const transactionYears = useMemo( @@ -185,6 +210,7 @@ export const MPGAIncomeExpensesReportProvider: React.FC = ({ ), description: transaction.description ?? '', amount: transaction.amount, + personNumber: transaction.personNumber, })), })), })), @@ -197,6 +223,7 @@ export const MPGAIncomeExpensesReportProvider: React.FC = ({ transformedData, filters?.categories ?? null, t, + household, ); const allData: AllData = useMemo(() => { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx index 752e3a910..c471396bd 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx @@ -4,6 +4,7 @@ import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { MockLinkCallHandler } from 'graphql-ergonomock/dist/apollo/MockLink'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { HcmQuery } from 'src/components/HrTools/Shared/HcmData/Hcm.generated'; import { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; import theme from 'src/theme'; import { FundTypes } from './Helper/MPGAReportEnum'; @@ -108,17 +109,46 @@ export const mpgaTransactionsMock: MpgaTransactionsQuery = { }, }; +/** A staff member on their own, so unattributed payroll in a mock never splits into a spouse row. */ +export const hcmSingleMock: HcmQuery = { + hcm: [ + { + staffInfo: { personNumber: '000000111', preferredName: 'Alex' }, + }, + ], +} as HcmQuery; + +/** HCM lists the reader first, then their spouse. */ +export const hcmHouseholdMock: HcmQuery = { + hcm: [ + ...hcmSingleMock.hcm, + { + staffInfo: { personNumber: '000000222', preferredName: 'Jordan' }, + }, + ], +} as HcmQuery; + interface MPGAIncomeExpensesReportTestWrapperProps { onCall?: MockLinkCallHandler; isEmpty?: boolean; mocks?: MpgaTransactionsQuery; + hcmMocks?: HcmQuery; staffAccountId?: string | null; + personNumber?: string; children?: React.ReactNode; } export const MPGAIncomeExpensesReportTestWrapper: React.FC< MPGAIncomeExpensesReportTestWrapperProps -> = ({ onCall, isEmpty, mocks, staffAccountId, children }) => { +> = ({ + onCall, + isEmpty, + mocks, + hcmMocks = hcmSingleMock, + staffAccountId, + personNumber, + children, +}) => { const mpgaTransactions = mocks ?? (isEmpty @@ -134,11 +164,17 @@ export const MPGAIncomeExpensesReportTestWrapper: React.FC< return ( - - mocks={{ MPGATransactions: mpgaTransactions }} + + mocks={{ MPGATransactions: mpgaTransactions, Hcm: hcmMocks }} onCall={onCall} > - + {children} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql b/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql index ce51a75b2..b1a7c2865 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql @@ -31,6 +31,7 @@ query MPGATransactions( amount transactedAt description + personNumber } } subCategory diff --git a/src/components/Reports/Shared/Helpers/household.ts b/src/components/Reports/Shared/Helpers/household.ts new file mode 100644 index 000000000..7da6d20df --- /dev/null +++ b/src/components/Reports/Shared/Helpers/household.ts @@ -0,0 +1,5 @@ +/** A person sharing the account, in HCM's order: the staff member reading the report, then their spouse. */ +export interface HouseholdMember { + personNumber: string; + name: string; +} diff --git a/src/components/Reports/StaffExpenseReport/Helpers/filterTransactions.tsx b/src/components/Reports/StaffExpenseReport/Helpers/filterTransactions.tsx index 6b128fdf3..d8e9d999b 100644 --- a/src/components/Reports/StaffExpenseReport/Helpers/filterTransactions.tsx +++ b/src/components/Reports/StaffExpenseReport/Helpers/filterTransactions.tsx @@ -6,6 +6,7 @@ import { StaffExpenseCategoryEnum, StaffExpensesSubCategoryEnum, } from 'src/graphql/types.generated'; +import { HouseholdMember } from '../../Shared/Helpers/household'; import { getLocalizedCategory, getLocalizedSubCategory, @@ -34,12 +35,6 @@ export interface Transaction { personNumber?: string | null; } -/** A person sharing the account, in HCM's order: the staff member reading the report, then their spouse. */ -export interface HouseholdMember { - personNumber: string; - name: string; -} - export interface GroupedTransaction extends Transaction { groupedTransactions: Transaction[]; bucketKey: string; diff --git a/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx b/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx index fa55be8fb..fff1aaf4c 100644 --- a/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx +++ b/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx @@ -26,6 +26,7 @@ import theme from 'src/theme'; import { AccountInfoBox } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBox'; import { AccountInfoBoxSkeleton } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton'; import { EmptyTable } from '../../HrTools/Shared/EmptyTable/EmptyTable'; +import { HouseholdMember } from '../Shared/Helpers/household'; import { SettingsButtonGroup } from '../Shared/SettingsButtonGroup/SettingsButtonGroup'; import { Filters, @@ -42,11 +43,7 @@ import { BalanceCardList } from './BalanceCardList/BalanceCardList'; import { ExportCsvButton } from './ExportCsvButton/ExportCsvButton'; import { useReportsStaffExpensesQuery } from './GetStaffExpense.generated'; import { ReportType } from './Helpers/StaffReportEnum'; -import { - HouseholdMember, - Transaction, - filterTransactions, -} from './Helpers/filterTransactions'; +import { Transaction, filterTransactions } from './Helpers/filterTransactions'; import { dateRangeToString, getFormattedDateString, diff --git a/src/hooks/useFilteredFunds.test.ts b/src/hooks/useFilteredFunds.test.ts index 2aa1bd3e7..a8666dc2d 100644 --- a/src/hooks/useFilteredFunds.test.ts +++ b/src/hooks/useFilteredFunds.test.ts @@ -1,15 +1,21 @@ import { renderHook } from '@testing-library/react'; +import { TFunction } from 'i18next'; import { Categories, Funds, } from 'src/components/Reports/MPGAIncomeExpensesReport/Helper/MPGAReportEnum'; +import { HouseholdMember } from 'src/components/Reports/Shared/Helpers/household'; import { StaffExpenseCategoryEnum, StaffExpensesSubCategoryEnum, } from 'src/graphql/types.generated'; import { useFilteredFunds } from './useFilteredFunds'; -const t = jest.fn((key) => key); +// Interpolates like i18next so per-person labels read the way they will on screen. +const t = ((key: string, options?: Record) => + options + ? key.replace(/{{(\w+)}}/g, (_match, name: string) => options[name]) + : key) as TFunction; const selectedCategories = [ StaffExpenseCategoryEnum.Salary, StaffExpenseCategoryEnum.HealthcareReimbursement, @@ -18,7 +24,8 @@ const selectedCategories = [ const renderUseFilteredFunds = ( funds: Funds[], selected: string[] | null = null, -) => renderHook(() => useFilteredFunds(funds, selected, t)); + household: HouseholdMember[] = [], +) => renderHook(() => useFilteredFunds(funds, selected, t, household)); const months = (totals: number[]) => totals.map((total, index) => ({ @@ -506,4 +513,320 @@ describe('useFilteredFunds', () => { ]); }); }); + + describe('salary per person', () => { + const reader = { personNumber: '000000111', name: 'Alex' }; + const spouse = { personNumber: '000000222', name: 'Jordan' }; + + const payroll = ( + amount: number, + personNumber: string | null, + transactedAt = '2024-01-15T00:00:00Z', + ) => ({ + transactedAt, + description: 'Payroll', + amount, + personNumber, + }); + + const salaryFund = ( + monthly: { total: number; transactions: ReturnType[] }[], + ): Funds[] => [ + { + id: '1', + fundType: 'Primary', + total: sum(monthly.map((month) => month.total)), + categories: [ + { + category: StaffExpenseCategoryEnum.Salary, + total: sum(monthly.map((month) => month.total)), + averagePerMonth: 0, + breakdownByMonth: months(monthly.map((month) => month.total)), + subcategories: [ + { + subCategory: StaffExpensesSubCategoryEnum.RegularPay, + total: sum(monthly.map((month) => month.total)), + averagePerMonth: 0, + breakdownByMonth: monthly.map((month, index) => ({ + month: `2024-${String(index + 1).padStart(2, '0')}-01`, + ...month, + })), + }, + ], + }, + ], + }, + ]; + + it("splits a couple's salary into one row per person, reader first", () => { + const funds = salaryFund([ + { + total: 300, + transactions: [ + payroll(100, spouse.personNumber), + payroll(200, reader.personNumber), + ], + }, + { + total: 250, + transactions: [ + payroll(150, reader.personNumber), + payroll(100, spouse.personNumber), + ], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect( + result.current.incomeData.map( + ({ id, description, monthly, total }) => ({ + id, + description, + monthly, + total, + }), + ), + ).toEqual([ + { + id: '1-SALARY-000000111-income', + description: 'Salary (Alex)', + monthly: [200, 150], + total: 350, + }, + { + id: '1-SALARY-000000222-income', + description: 'Salary (Jordan)', + monthly: [100, 100], + total: 200, + }, + ]); + expect(result.current.expenseData).toEqual([]); + }); + + it("lists only that person's transactions in each row's breakdown", () => { + const funds = salaryFund([ + { + total: 300, + transactions: [ + payroll(100, spouse.personNumber), + payroll(200, reader.personNumber), + ], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect( + result.current.incomeData.map((row) => + (row.transactions ?? []).map((entry) => entry.amount), + ), + ).toEqual([[200], [100]]); + }); + + it('credits payroll SAA could not attribute to the reader', () => { + const funds = salaryFund([ + { + total: 300, + transactions: [payroll(100, spouse.personNumber), payroll(200, null)], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect( + result.current.incomeData.map(({ description, monthly }) => ({ + description, + monthly, + })), + ).toEqual([ + { description: 'Salary (Alex)', monthly: [200] }, + { description: 'Salary (Jordan)', monthly: [100] }, + ]); + }); + + it('treats a blank person number like a missing one', () => { + const funds = salaryFund([ + { + total: 300, + transactions: [payroll(100, spouse.personNumber), payroll(200, '')], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect( + result.current.incomeData.map(({ description, monthly }) => ({ + description, + monthly, + })), + ).toEqual([ + { description: 'Salary (Alex)', monthly: [200] }, + { description: 'Salary (Jordan)', monthly: [100] }, + ]); + }); + + it('sums each person from their own transactions when the month total disagrees', () => { + const funds = salaryFund([ + { + total: 300, + transactions: [ + payroll(100, spouse.personNumber), + payroll(150, reader.personNumber), + ], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect(result.current.incomeData.map((row) => row.monthly)).toEqual([ + [150], + [100], + ]); + }); + + it('shows no row for a person the month did not pay', () => { + // The API's month total can round differently from its transactions. The reader was not + // paid this month, so a rounding gap must not become a phantom row of theirs. + const funds = salaryFund([ + { total: 299.99, transactions: [payroll(300, spouse.personNumber)] }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect( + result.current.incomeData.map(({ description, monthly }) => ({ + description, + monthly, + })), + ).toEqual([{ description: 'Salary (Jordan)', monthly: [300] }]); + expect(result.current.expenseData).toEqual([]); + }); + + it('keeps a single staff member on one plain Salary row', () => { + const funds = salaryFund([ + { + total: 300, + transactions: [payroll(100, reader.personNumber), payroll(200, null)], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect( + result.current.incomeData.map(({ id, description, monthly }) => ({ + id, + description, + monthly, + })), + ).toEqual([ + { id: '1-SALARY-income', description: 'Salary', monthly: [300] }, + ]); + }); + + it('reports the month total for a staff member nobody else shares payroll with', () => { + // No second person in HCM or in the payroll, so the row reads exactly as it did before + // splitting existed: the API's month total, not a sum of transactions. + const funds = salaryFund([ + { + total: 300, + transactions: [payroll(150, reader.personNumber), payroll(100, null)], + }, + { total: 200, transactions: [] }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader]); + + expect( + result.current.incomeData.map(({ id, description, monthly }) => ({ + id, + description, + monthly, + })), + ).toEqual([ + { id: '1-SALARY-income', description: 'Salary', monthly: [300, 200] }, + ]); + }); + + it('labels a person HCM does not list as Spouse', () => { + const funds = salaryFund([ + { + total: 300, + transactions: [ + payroll(100, '000000999'), + payroll(200, reader.personNumber), + ], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader]); + + expect(result.current.incomeData.map((row) => row.description)).toEqual([ + 'Salary (Alex)', + 'Salary (Spouse)', + ]); + }); + + it('does not split salary without a household', () => { + const funds = salaryFund([ + { + total: 300, + transactions: [ + payroll(100, spouse.personNumber), + payroll(200, reader.personNumber), + ], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, []); + + expect( + result.current.incomeData.map(({ id, description }) => ({ + id, + description, + })), + ).toEqual([{ id: '1-SALARY-income', description: 'Salary' }]); + }); + + it('leaves non-salary categories as one household row', () => { + const funds: Funds[] = [ + { + id: '1', + fundType: 'Primary', + total: -300, + categories: [ + { + category: StaffExpenseCategoryEnum.Benefits, + total: -300, + averagePerMonth: -300, + breakdownByMonth: months([-300]), + subcategories: [ + { + subCategory: StaffExpensesSubCategoryEnum.LifeInsurance, + total: -300, + averagePerMonth: -300, + breakdownByMonth: [ + { + month: '2024-01-01', + total: -300, + transactions: [ + payroll(-100, spouse.personNumber), + payroll(-200, reader.personNumber), + ], + }, + ], + }, + ], + }, + ], + }, + ]; + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect(result.current.expenseData.map((row) => row.description)).toEqual([ + 'Benefits', + ]); + }); + }); }); diff --git a/src/hooks/useFilteredFunds.ts b/src/hooks/useFilteredFunds.ts index f93178194..8bff3603f 100644 --- a/src/hooks/useFilteredFunds.ts +++ b/src/hooks/useFilteredFunds.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { TFunction } from 'react-i18next'; import { Funds } from 'src/components/Reports/MPGAIncomeExpensesReport/Helper/MPGAReportEnum'; +import { HouseholdMember } from 'src/components/Reports/Shared/Helpers/household'; import { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; import { addCategoryRow, @@ -14,10 +15,18 @@ import { } from '../components/Reports/MPGAIncomeExpensesReport/Helper/sortFunds'; import { DataFields } from '../components/Reports/MPGAIncomeExpensesReport/mockData'; +// A stable default so the memo below is not invalidated by a fresh array every render. +const noHousehold: HouseholdMember[] = []; + +/** + * `household` lists the people sharing the account, reader first. With a spouse present, the + * combined Salary row splits into one row per person. + */ export function useFilteredFunds( funds: Funds[], selectedCategories: string[] | null, t: TFunction, + household: HouseholdMember[] = noHousehold, ) { return useMemo(() => { const incomeData: DataFields[] = []; @@ -52,6 +61,7 @@ export function useFilteredFunds( t, incomeData, expenseData, + household, }); } else { addCategoryRow({ baseId, category, t, incomeData, expenseData }); @@ -71,5 +81,5 @@ export function useFilteredFunds( incomeData, expenseData, }; - }, [funds, selectedCategories, t]); + }, [funds, selectedCategories, t, household]); } From 09b333b873ba305e0b3c7595f4ebab914afa0cd9 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 18 Sep 2026 09:02:25 -0600 Subject: [PATCH 2/2] [MPDX-9992] Name the person in a per-person salary row's breakdown With salary split one row per person, both rows opened a breakdown titled plain "Salary Breakdown" with different totals, so the two could not be told apart. Carry the person's name on the row and into the breakdown target, and compose the modal title and footer from the category plus person using the same string the row label uses. Rows that are not split are unchanged. Co-Authored-By: Claude Fable 5.1 --- .../BreakdownModal/BreakdownModal.test.tsx | 23 +++++++++++++++ .../BreakdownModal/BreakdownModal.tsx | 28 +++++++++++------- .../Helper/createRows.tsx | 6 ++-- .../Helper/filterFunds.ts | 16 +++++----- .../Tables/TableCard.test.tsx | 25 ++++++++++++++++ .../Tables/TableCard.tsx | 1 + .../MPGAIncomeExpensesReport/mockData.tsx | 2 ++ src/hooks/useFilteredFunds.test.ts | 29 +++++++++++++++++++ 8 files changed, 110 insertions(+), 20 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx index 071f85b9b..518d32646 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx @@ -62,4 +62,27 @@ describe('BreakdownModal', () => { expect(getByRole('dialog')).toBeInTheDocument(); expect(queryByText('Donation - Non Cash')).not.toBeInTheDocument(); }); + + it('names the person whose row is being broken down', () => { + const { getByText } = render( + , + ); + + expect(getByText('Salary (Alex) Breakdown')).toBeInTheDocument(); + expect(getByText('Total Salary (Alex) Income')).toBeInTheDocument(); + }); + + it('ignores a person on any category other than salary', () => { + const { getByText, queryByText } = render( + , + ); + + expect(getByText('Donation Breakdown')).toBeInTheDocument(); + expect(getByText('Total Donation Income')).toBeInTheDocument(); + expect(queryByText(/Alex/)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx index 165662114..23f6effea 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx @@ -10,7 +10,10 @@ import { Typography, } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { StaffExpensesSubCategoryEnum } from 'src/graphql/types.generated'; +import { + StaffExpenseCategoryEnum, + StaffExpensesSubCategoryEnum, +} from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat, monthYearFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; @@ -29,6 +32,7 @@ export const BreakdownModal: React.FC = ({ open, onClose, category, + person, transactions, }) => { const { t } = useTranslation(); @@ -36,6 +40,16 @@ export const BreakdownModal: React.FC = ({ const currency = 'USD'; const { startDate, endDate } = useMPGAIncomeExpenses(); + // Salary is the only category split one row per person. Its breakdown is named the way the row + // is, so two people's modals cannot be mistaken for each other; no other category names anyone. + const categoryName = + person && category === StaffExpenseCategoryEnum.Salary + ? t('{{bucket}} ({{person}})', { + bucket: getLocalizedCategory(category, t), + person, + }) + : getLocalizedCategory(category, t); + const subcategoryBreakdown = useMemo(() => { const grouped = new Map< StaffExpensesSubCategoryEnum, @@ -68,11 +82,7 @@ export const BreakdownModal: React.FC = ({ ); return ( - + = ({ fontWeight="bold" > {overallTotal >= 0 - ? t('Total {{category}} Income', { - category: getLocalizedCategory(category, t), - }) + ? t('Total {{category}} Income', { category: categoryName }) : t('Total {{category}} Expense', { - category: getLocalizedCategory(category, t), + category: categoryName, })} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Helper/createRows.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Helper/createRows.tsx index 4975e992b..49255a382 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Helper/createRows.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Helper/createRows.tsx @@ -12,7 +12,7 @@ export const populateCardTableRows = ( openBreakdownModal: (breakdown: BreakdownTarget) => void, ) => { const description: RenderCell = ({ row }) => { - const { category, transactions } = row; + const { category, person, transactions } = row; return ( @@ -26,7 +26,9 @@ export const populateCardTableRows = ( size="small" sx={{ ml: 'auto', flexShrink: 0 }} aria-label={t('View breakdown')} - onClick={() => openBreakdownModal({ category, transactions })} + onClick={() => + openBreakdownModal({ category, person, transactions }) + } > diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts b/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts index 994b07ece..0e94de22f 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts +++ b/src/components/Reports/MPGAIncomeExpensesReport/Helper/filterFunds.ts @@ -289,15 +289,14 @@ export function addCombinedSubcategoryRow({ const unknownName = t('Spouse'); people.forEach((accumulator, personNumber) => { - const description = + const person = namePeople && personNumber !== null - ? t('{{bucket}} ({{person}})', { - bucket: categoryLabel, - person: - household.find((member) => member.personNumber === personNumber) - ?.name ?? unknownName, - }) - : categoryLabel; + ? (household.find((member) => member.personNumber === personNumber) + ?.name ?? unknownName) + : undefined; + const description = person + ? t('{{bucket}} ({{person}})', { bucket: categoryLabel, person }) + : categoryLabel; const rowId = namePeople ? `${baseId}-${personNumber}` : baseId; const pushAggregateRow = ( @@ -310,6 +309,7 @@ export function addCombinedSubcategoryRow({ id, description, category: category.category, + person, transactions, monthly, average: average(monthly), diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx index fc405ba73..1366eb0e9 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; import theme from 'src/theme'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; @@ -176,6 +177,30 @@ describe('TableCard', () => { expect(within(dialog).queryByText('Donation')).not.toBeInTheDocument(); }); + it('titles the modal with the person whose row was clicked', async () => { + const [row] = mockData.income; + const { findByRole } = render( + , + ); + + userEvent.click(await findByRole('button', { name: 'View breakdown' })); + + const dialog = await findByRole('dialog'); + expect( + within(dialog).getByText('Salary (Alex) Breakdown'), + ).toBeInTheDocument(); + }); + it('closes the modal', async () => { const { findByRole, queryByRole } = render(); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx index 4bf00779d..a031f8299 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx @@ -347,6 +347,7 @@ export const TableCard: React.FC = ({ open onClose={() => setOpenBreakdownModal(null)} category={openBreakdownModal.category} + person={openBreakdownModal.person} transactions={openBreakdownModal.transactions} /> )} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/mockData.tsx b/src/components/Reports/MPGAIncomeExpensesReport/mockData.tsx index bb45c3e79..b5e22f1c9 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/mockData.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/mockData.tsx @@ -19,6 +19,7 @@ export interface SubcategoryBreakdown { export interface BreakdownTarget { category: StaffExpenseCategoryEnum; + person?: string; transactions: TransactionBreakdown[]; } @@ -26,6 +27,7 @@ export interface DataFields { id: string; description: string; category?: StaffExpenseCategoryEnum; + person?: string; transactions?: TransactionBreakdown[]; monthly: number[]; average: number; diff --git a/src/hooks/useFilteredFunds.test.ts b/src/hooks/useFilteredFunds.test.ts index a8666dc2d..dae5e5142 100644 --- a/src/hooks/useFilteredFunds.test.ts +++ b/src/hooks/useFilteredFunds.test.ts @@ -604,6 +604,35 @@ describe('useFilteredFunds', () => { expect(result.current.expenseData).toEqual([]); }); + it('names the person on each split row so the breakdown can too', () => { + const funds = salaryFund([ + { + total: 300, + transactions: [ + payroll(100, spouse.personNumber), + payroll(200, reader.personNumber), + ], + }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect(result.current.incomeData.map((row) => row.person)).toEqual([ + 'Alex', + 'Jordan', + ]); + }); + + it("names nobody on a single staff member's row", () => { + const funds = salaryFund([ + { total: 300, transactions: [payroll(300, reader.personNumber)] }, + ]); + + const { result } = renderUseFilteredFunds(funds, null, [reader, spouse]); + + expect(result.current.incomeData[0].person).toBeUndefined(); + }); + it("lists only that person's transactions in each row's breakdown", () => { const funds = salaryFund([ {