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(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/ExportCsvButton/ExportCsvButton.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx index 51ec100d9..a5973dbcc 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx @@ -4,8 +4,13 @@ import userEvent from '@testing-library/user-event'; import { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; import { exportToCsv } from '../CustomExport/CustomExport'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; +import { + ContextType, + MPGAIncomeExpensesContext, +} from '../MPGAIncomeExpensesContext/MPGAIncomeExpensesContext'; import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; import { MpgaTransactionsQuery } from '../MPGATransactions.generated'; +import { mockData, months } from '../mockData'; import { ExportCsvButton } from './ExportCsvButton'; const mutationSpy = jest.fn(); @@ -173,11 +178,47 @@ describe('ExportCsvButton', () => { 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/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 6d02db2b8..0e94de22f 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 person = + namePeople && personNumber !== null + ? (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 = ( + id: string, + monthly: number[], + transactions: TransactionBreakdown[], + ) => { + pushData( + { + id, + description, + category: category.category, + person, + 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/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/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..dae5e5142 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,349 @@ 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('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([ + { + 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]); }