From 4e04dbcf667515e399d72303eb250938196e3b29 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Mon, 20 Jul 2026 14:21:05 -0400 Subject: [PATCH 01/11] Add year to date filter to mpga --- .../SettingsDialog/SettingsDialog.test.tsx | 27 +++++- .../Shared/SettingsDialog/SettingsDialog.tsx | 89 ++++++++++--------- 2 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx index c8f60ad8ac..00526df738 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx @@ -503,9 +503,15 @@ describe('SettingsDialog', () => { hideDateRange: true, }; - it('displays the category checkboxes only', async () => { - const { queryByLabelText, getByLabelText, findByLabelText, getByText } = - render(); + it('displays the simplified date range dropdown and category checkboxes without custom date fields', async () => { + const { + queryByLabelText, + getByLabelText, + findByLabelText, + getByText, + getByRole, + queryByRole, + } = render(); expect( getByText( @@ -517,7 +523,20 @@ describe('SettingsDialog', () => { expect(getByLabelText('Salary')).toBeInTheDocument(); expect(getByLabelText('Donation')).toBeInTheDocument(); - expect(queryByLabelText('Select Date Range')).not.toBeInTheDocument(); + const dropdown = getByLabelText('Select Date Range'); + expect(dropdown).toBeInTheDocument(); + userEvent.click(dropdown); + expect( + getByRole('option', { name: 'Last 12 Months' }), + ).toBeInTheDocument(); + expect(getByRole('option', { name: 'Year to Date' })).toBeInTheDocument(); + expect( + queryByRole('option', { name: 'Week to Date' }), + ).not.toBeInTheDocument(); + expect( + queryByRole('option', { name: 'Month to Date' }), + ).not.toBeInTheDocument(); + expect(queryByLabelText('Start Date')).not.toBeInTheDocument(); expect(queryByLabelText('End Date')).not.toBeInTheDocument(); }); diff --git a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx index 960ced2261..ec83ed059d 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx @@ -252,49 +252,56 @@ export const SettingsDialog: React.FC = ({ return (
- {!hideDateRange && ( - <> - { - const value = - e.target.value === '' ? null : e.target.value; - setFieldValue('selectedDateRange', value); - if (value !== null) { - setFieldValue('startDate', null); - setFieldValue('endDate', null); + { + const value = e.target.value === '' ? null : e.target.value; + setFieldValue('selectedDateRange', value); + if (value !== null) { + setFieldValue('startDate', null); + setFieldValue('endDate', null); - setTouched({ - ...touched, - startDate: false, - endDate: false, - }); - } - validateAndRefetch(validateForm, { - ...values, - selectedDateRange: value as DateRange | null, - ...(value !== null && { - startDate: null, - endDate: null, - }), - }); - }} - > - {t('None')} - + setTouched({ + ...touched, + startDate: false, + endDate: false, + }); + } + validateAndRefetch(validateForm, { + ...values, + selectedDateRange: value as DateRange | null, + ...(value !== null && { + startDate: null, + endDate: null, + }), + }); + }} + > + {t('None')} + {!hideDateRange ? ( + [ + {t('Week to Date')} - - + , + {t('Month to Date')} - - - {t('Year to Date')} - - + , + ] + ) : ( + + {t('Last 12 Months')} + + )} + + {t('Year to Date')} + + + {!hideDateRange && ( + <> {t('Or enter a custom date range:')} @@ -354,9 +361,7 @@ export const SettingsDialog: React.FC = ({ )} - + {hideDateRange ? t( `Income and expenses are combined by categories by default. Select which categories to keep consolidated.`, From 51269fe2807566cb3216c8764a06c94952234204 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Tue, 21 Jul 2026 11:56:06 -0400 Subject: [PATCH 02/11] Add past year filter to mpga --- .../MPGAIncomeExpensesReport.test.tsx | 147 ++++++++++++++++++ .../MPGAIncomeExpensesReport.tsx | 101 ++++++++---- .../SettingsDialog/SettingsDialog.test.tsx | 82 +++++++++- .../Shared/SettingsDialog/SettingsDialog.tsx | 106 ++++++++----- src/hooks/useGetLastTwelveMonths.test.ts | 21 ++- src/hooks/useGetLastTwelveMonths.ts | 22 ++- 6 files changed, 400 insertions(+), 79 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx index d62c3fc6a1..e4e44acded 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx @@ -4,6 +4,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { StaffAccountQuery } from 'src/components/Shared/StaffAccount/StaffAccount.generated'; import { @@ -188,5 +189,151 @@ describe('MPGAIncomeExpensesReport', () => { ).toHaveLength(1); expect(await findAllByText('Benefits - Program Based')).toHaveLength(1); }, 15000); + + it('does not show the Clear button until a category filter is applied', async () => { + const { getByRole, findByRole, queryByRole } = render(); + + expect(await findByRole('gridcell', { name: 'Benefits' })).toBeVisible(); + expect(queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); + + userEvent.click(getByRole('button', { name: 'Report Settings' })); + + const benefitsCheckbox = await findByRole('checkbox', { + name: 'Benefits', + }); + userEvent.click(benefitsCheckbox); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).not.toBeDisabled()); + userEvent.click(applyButton); + + expect(await findByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }, 15000); + + it('does not show the Clear button when the dialog is cancelled', async () => { + const { getByRole, findByRole, queryByRole } = render(); + + expect(await findByRole('gridcell', { name: 'Benefits' })).toBeVisible(); + + userEvent.click(getByRole('button', { name: 'Report Settings' })); + + const cancelButton = await findByRole('button', { name: 'Cancel' }); + userEvent.click(cancelButton); + + await waitFor(() => + expect( + queryByRole('button', { name: 'Cancel' }), + ).not.toBeInTheDocument(), + ); + expect(queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); + }, 15000); + + it('resets category filtering when Clear is clicked', async () => { + const { getByRole, findByRole, findAllByText, queryByText } = render( + , + ); + + expect(await findByRole('gridcell', { name: 'Benefits' })).toBeVisible(); + + userEvent.click(getByRole('button', { name: 'Report Settings' })); + + const benefitsCheckbox = await findByRole('checkbox', { + name: 'Benefits', + }); + userEvent.click(benefitsCheckbox); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).not.toBeDisabled()); + userEvent.click(applyButton); + + expect( + await findAllByText('Benefits - Workers Compensation'), + ).toHaveLength(1); + + userEvent.click(await findByRole('button', { name: 'Clear' })); + + await waitFor(() => + expect( + queryByText('Benefits - Workers Compensation'), + ).not.toBeInTheDocument(), + ); + expect(await findByRole('gridcell', { name: 'Benefits' })).toBeVisible(); + }, 15000); + }); + + describe('Date range filtering', () => { + it('queries the full selected year when a year is applied', async () => { + const { getByRole, findByRole } = render(); + + await findByRole('gridcell', { name: 'Benefits' }); + mutationSpy.mockClear(); + + userEvent.click(getByRole('button', { name: 'Report Settings' })); + + const lastCompletedYear = DateTime.now().year - 1; + userEvent.click( + await findByRole('combobox', { name: 'Select Date Range' }), + ); + userEvent.click(getByRole('option', { name: String(lastCompletedYear) })); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).not.toBeDisabled()); + userEvent.click(applyButton); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: `${lastCompletedYear}-01-01`, + endMonth: `${lastCompletedYear}-12-31`, + fundTypes: ['Primary'], + }), + ); + }, 15000); + + it('queries the current year up to today for Year to Date', async () => { + const { getByRole, findByRole } = render(); + + await findByRole('gridcell', { name: 'Benefits' }); + mutationSpy.mockClear(); + + userEvent.click(getByRole('button', { name: 'Report Settings' })); + + userEvent.click( + await findByRole('combobox', { name: 'Select Date Range' }), + ); + userEvent.click(getByRole('option', { name: 'Year to Date' })); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).not.toBeDisabled()); + userEvent.click(applyButton); + + const now = DateTime.now(); + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: `${now.year}-01-01`, + endMonth: now.toISODate(), + fundTypes: ['Primary'], + }), + ); + }, 15000); + + it('shows the Clear button after a year is applied', async () => { + const { getByRole, findByRole, queryByRole } = render(); + + await findByRole('gridcell', { name: 'Benefits' }); + expect(queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); + + userEvent.click(getByRole('button', { name: 'Report Settings' })); + + userEvent.click( + await findByRole('combobox', { name: 'Select Date Range' }), + ); + userEvent.click(getByRole('option', { name: 'Year to Date' })); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).not.toBeDisabled()); + userEvent.click(applyButton); + + expect(await findByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }, 15000); }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index fce9695550..8b22f1d3f2 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useState } from 'react'; -import { Settings } from '@mui/icons-material'; import PrintIcon from '@mui/icons-material/Print'; import { Box, @@ -21,12 +20,12 @@ import { useGetLastTwelveMonths } from 'src/hooks/useGetLastTwelveMonths'; import { useLocale } from 'src/hooks/useLocale'; import { AccountInfoBox } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBox'; import { AccountInfoBoxSkeleton } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton'; +import { SettingsButtonGroup } from '../Shared/SettingsButtonGroup/SettingsButtonGroup'; import { Filters, SettingsDialog, - getFiltersWithCalculatedDates, } from '../Shared/SettingsDialog/SettingsDialog'; -import { StyledFilterButton } from '../Shared/SettingsDialog/StyledFilterButton'; +import { DateRange } from '../StaffExpenseReport/Helpers/StaffReportEnum'; import { SimplePrintOnly, SimpleScreenOnly, @@ -38,7 +37,7 @@ import { ExportCsvButton } from './ExportCsvButton/ExportCsvButton'; import { FundTypes, Funds } from './Helper/MPGAReportEnum'; import { useMpgaTransactionsQuery } from './MPGATransactions.generated'; import { TotalsProvider } from './TotalsContext/TotalsContext'; -import { AllData } from './mockData'; +import { AllData, DataFields } from './mockData'; import { PrintOnly, StyledHeaderBox } from './styledComponents'; interface MPGAIncomeExpensesReportProps { @@ -55,28 +54,55 @@ export const MPGAIncomeExpensesReport: React.FC< const currency = 'USD'; const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const last12Months = useGetLastTwelveMonths(locale, true); + const [filters, setFilters] = useState(null); + + const specificYear = filters?.selectedYear ?? null; + const isYearToDate = + specificYear === null && + filters?.selectedDateRange === DateRange.YearToDate; + + const monthLabels = useGetLastTwelveMonths(locale, specificYear); const { startDate, endDate } = useMemo(() => { const now = DateTime.now(); + // If a specific year is selected, return start and end of that year + if (specificYear !== null) { + const yearStart = DateTime.fromObject({ year: specificYear }).startOf( + 'year', + ); + return { startDate: yearStart, endDate: yearStart.endOf('year') }; + } + // If Year to Date is selected, return start of the year to today + if (isYearToDate) { + return { startDate: now.startOf('year'), endDate: now }; + } return { startDate: now.minus({ months: 11 }).startOf('month'), endDate: now, }; - }, []); + }, [specificYear, isYearToDate]); - const [filters, setFilters] = useState(() => - getFiltersWithCalculatedDates({ + const defaultFilters: Filters = useMemo( + () => ({ selectedDateRange: null, - startDate: startDate, - endDate: endDate.endOf('month'), + startDate, + endDate, categories: null, }), + [startDate, endDate], ); - const onFiltersChange = (newFilters: Filters) => { - setFilters(newFilters); - }; + const isFilterActive = useMemo( + () => + Boolean( + filters && + (filters.categories || + filters.selectedDateRange === DateRange.YearToDate || + (filters.selectedYear !== null && + filters.selectedYear !== undefined)), + ), + [filters], + ); const handleSettingsClick = () => { setIsSettingsOpen(true); @@ -125,11 +151,22 @@ export const MPGAIncomeExpensesReport: React.FC< ); const allData: AllData = useMemo(() => { + if (!isYearToDate) { + return { income: incomeData, expenses: expenseData }; + } + // Year to Date queries only the current year, so each row has fewer months than the 12 columns + const padToColumns = (rows: DataFields[]): DataFields[] => + rows.map((row) => { + const missing = monthLabels.length - row.monthly.length; + return missing > 0 + ? { ...row, monthly: [...new Array(missing).fill(0), ...row.monthly] } + : row; + }); return { - income: incomeData, - expenses: expenseData, + income: padToColumns(incomeData), + expenses: padToColumns(expenseData), }; - }, [incomeData, expenseData]); + }, [incomeData, expenseData, isYearToDate, monthLabels.length]); return ( <> @@ -175,16 +212,13 @@ export const MPGAIncomeExpensesReport: React.FC< alignItems="center" sx={{ gap: 2, '& > button': { ml: 0 } }} > - } - size="small" - onClick={handleSettingsClick} - > - {t('Report Settings')} - + - + @@ -211,7 +245,7 @@ export const MPGAIncomeExpensesReport: React.FC< @@ -220,7 +254,7 @@ export const MPGAIncomeExpensesReport: React.FC< @@ -228,13 +262,18 @@ export const MPGAIncomeExpensesReport: React.FC< {isSettingsOpen && ( { - if (newFilters) { - onFiltersChange(newFilters); - } + const hasActiveFilter = Boolean( + newFilters && + (newFilters.categories || + newFilters.selectedDateRange === DateRange.YearToDate || + (newFilters.selectedYear !== null && + newFilters.selectedYear !== undefined)), + ); + setFilters(hasActiveFilter && newFilters ? newFilters : null); setIsSettingsOpen(false); }} hideDateRange diff --git a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx index 00526df738..bbe3795251 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx @@ -104,6 +104,7 @@ const baseFilters: Filters = { selectedDateRange: null, startDate: null, endDate: null, + selectedYear: null, categories: [], }; @@ -151,6 +152,20 @@ describe('SettingsDialog', () => { expect(getByRole('option', { name: 'Year to Date' })).toBeInTheDocument(); }); + it('does not list year options when not in MPGA mode', () => { + const { getByLabelText, getByRole, queryByRole } = render( + , + ); + + userEvent.click(getByLabelText('Select Date Range')); + + const lastCompletedYear = DateTime.now().year - 1; + expect( + queryByRole('option', { name: String(lastCompletedYear) }), + ).not.toBeInTheDocument(); + expect(getByRole('option', { name: 'Year to Date' })).toBeInTheDocument(); + }); + it('should render all category checkboxes', async () => { const { findByRole, getByRole } = render( , @@ -515,7 +530,7 @@ describe('SettingsDialog', () => { expect( getByText( - 'Income and expenses are combined by categories by default. Select which categories to keep consolidated.', + 'Income and expenses are combined by categories by default. This may be useful for long date ranges (e.g., "Year to Date"). Select which categories to keep consolidated.', ), ).toBeInTheDocument(); @@ -526,10 +541,11 @@ describe('SettingsDialog', () => { const dropdown = getByLabelText('Select Date Range'); expect(dropdown).toBeInTheDocument(); userEvent.click(dropdown); + expect(getByRole('option', { name: 'Year to Date' })).toBeInTheDocument(); + const lastCompletedYear = DateTime.now().year - 1; expect( - getByRole('option', { name: 'Last 12 Months' }), + getByRole('option', { name: String(lastCompletedYear) }), ).toBeInTheDocument(); - expect(getByRole('option', { name: 'Year to Date' })).toBeInTheDocument(); expect( queryByRole('option', { name: 'Week to Date' }), ).not.toBeInTheDocument(); @@ -561,5 +577,65 @@ describe('SettingsDialog', () => { }); }); }); + + it('lists the last 5 completed years in the date range dropdown', () => { + const { getByLabelText, getByRole } = render( + , + ); + + userEvent.click(getByLabelText('Select Date Range')); + + const lastCompletedYear = DateTime.now().year - 1; + expect( + getByRole('option', { name: String(lastCompletedYear) }), + ).toBeInTheDocument(); + expect( + getByRole('option', { name: String(lastCompletedYear - 4) }), + ).toBeInTheDocument(); + }); + + it('correctly gets Jan 1 – Dec 31 and the selected year when a year is picked', async () => { + const { getByLabelText, getByRole, findByRole } = render( + , + ); + + const lastCompletedYear = DateTime.now().year - 1; + userEvent.click(getByLabelText('Select Date Range')); + userEvent.click(getByRole('option', { name: String(lastCompletedYear) })); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).toBeEnabled()); + userEvent.click(applyButton); + + await waitFor(() => { + const submitted = mutationSpy.mock.calls.at(-1)?.[0] as Filters; + expect(submitted.selectedYear).toBe(lastCompletedYear); + expect((submitted.startDate as DateTime).toISODate()).toBe( + `${lastCompletedYear}-01-01`, + ); + expect((submitted.endDate as DateTime).toISODate()).toBe( + `${lastCompletedYear}-12-31`, + ); + }); + }); + + it('submits Year to Date with no specific year', async () => { + const { getByLabelText, getByRole, findByRole } = render( + , + ); + + userEvent.click(getByLabelText('Select Date Range')); + userEvent.click(getByRole('option', { name: 'Year to Date' })); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).toBeEnabled()); + userEvent.click(applyButton); + + await waitFor(() => { + const submitted = mutationSpy.mock.calls.at(-1)?.[0] as Filters; + expect(submitted.selectedDateRange).toBe(DateRange.YearToDate); + expect(submitted.selectedYear).toBeNull(); + }); + }); }); }); diff --git a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx index ec83ed059d..b08aa06c99 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx @@ -41,6 +41,11 @@ export interface Filters { selectedDateRange: DateRange | null; startDate?: DateTime | null; endDate?: DateTime | null; + /** + * A specific calendar year chosen from the MPGA date-range dropdown. When set, + * the range resolves to Jan 1 – Dec 31 of the selected year. + */ + selectedYear?: number | null; /** * `null` means no explicit selection: every available category is shown as * checked and the report aggregates all of them. @@ -127,8 +132,18 @@ const calculateDateRange = ( } }; -export const getFiltersWithCalculatedDates = (values: Filters): Filters => { +const getFiltersWithCalculatedDates = (values: Filters): Filters => { const finalValues = { ...values }; + const selectedYear = values.selectedYear; + + if (selectedYear !== null && selectedYear !== undefined) { + const yearStart = DateTime.fromObject({ + year: selectedYear, + }).startOf('year'); + finalValues.startDate = yearStart; + finalValues.endDate = yearStart.endOf('year'); + return finalValues; + } if (values.selectedDateRange !== null) { const { startDate, endDate } = calculateDateRange(values.selectedDateRange); finalValues.startDate = startDate; @@ -153,6 +168,12 @@ export const SettingsDialog: React.FC = ({ [time], ); + // TODO: Get list of all possible years + const completedYears = useMemo(() => { + const lastCompletedYear = currentTime.year - 1; + return Array.from({ length: 5 }, (_, index) => lastCompletedYear - index); + }, [currentTime]); + const validationSchema = useMemo( () => getValidationSchema(currentTime), [currentTime], @@ -210,6 +231,7 @@ export const SettingsDialog: React.FC = ({ selectedFilters?.selectedDateRange === null ? selectedFilters?.endDate : null, + selectedYear: selectedFilters?.selectedYear ?? null, categories: selectedFilters?.categories ?? null, }; @@ -249,6 +271,11 @@ export const SettingsDialog: React.FC = ({ setFieldValue('categories', newCategories); }; + const dropdownValue = + values.selectedYear !== null && values.selectedYear !== undefined + ? String(values.selectedYear) + : (values.selectedDateRange ?? ''); + return ( @@ -256,48 +283,51 @@ export const SettingsDialog: React.FC = ({ select label={t('Select Date Range')} fullWidth - value={values.selectedDateRange ?? ''} + value={dropdownValue} onChange={(e) => { - const value = e.target.value === '' ? null : e.target.value; - setFieldValue('selectedDateRange', value); - if (value !== null) { - setFieldValue('startDate', null); - setFieldValue('endDate', null); - - setTouched({ - ...touched, - startDate: false, - endDate: false, - }); - } + const raw = e.target.value; + const isYear = /^\d{4}$/.test(raw); + const yearValue = isYear ? Number(raw) : null; + const rangeValue = + isYear || raw === '' ? null : (raw as DateRange); + + setFieldValue('selectedYear', yearValue); + setFieldValue('selectedDateRange', rangeValue); + setFieldValue('startDate', null); + setFieldValue('endDate', null); + setTouched({ + ...touched, + startDate: false, + endDate: false, + }); + validateAndRefetch(validateForm, { ...values, - selectedDateRange: value as DateRange | null, - ...(value !== null && { - startDate: null, - endDate: null, - }), + selectedYear: yearValue, + selectedDateRange: rangeValue, + startDate: null, + endDate: null, }); }} > {t('None')} - {!hideDateRange ? ( - [ - - {t('Week to Date')} - , - - {t('Month to Date')} - , - ] - ) : ( - - {t('Last 12 Months')} - - )} + {!hideDateRange && [ + + {t('Week to Date')} + , + + {t('Month to Date')} + , + ]} {t('Year to Date')} + {hideDateRange && + completedYears.map((year) => ( + + {year} + + ))} {!hideDateRange && ( @@ -362,13 +392,9 @@ export const SettingsDialog: React.FC = ({ )} - {hideDateRange - ? t( - `Income and expenses are combined by categories by default. Select which categories to keep consolidated.`, - ) - : t( - `Income and expenses are combined by categories by default. This may be useful for long date ranges (e.g., "Year to Date").\nSelect which categories to keep consolidated.`, - )} + {t( + `Income and expenses are combined by categories by default. This may be useful for long date ranges (e.g., "Year to Date").\nSelect which categories to keep consolidated.`, + )} diff --git a/src/hooks/useGetLastTwelveMonths.test.ts b/src/hooks/useGetLastTwelveMonths.test.ts index d73c1d42f2..a4b4cf1f39 100644 --- a/src/hooks/useGetLastTwelveMonths.test.ts +++ b/src/hooks/useGetLastTwelveMonths.test.ts @@ -8,7 +8,7 @@ jest.spyOn(DateTime, 'now').mockReturnValue(fixed); describe('useGetLastTwelveMonths', () => { it('should return the last twelve months', () => { - const { result } = renderHook(() => useGetLastTwelveMonths(currency, true)); + const { result } = renderHook(() => useGetLastTwelveMonths(currency)); expect(result.current).toEqual([ 'May 2024', @@ -25,4 +25,23 @@ describe('useGetLastTwelveMonths', () => { 'Apr 2025', ]); }); + + it('should return January–December of a specific year when one is given', () => { + const { result } = renderHook(() => useGetLastTwelveMonths(currency, 2023)); + + expect(result.current).toEqual([ + 'Jan 2023', + 'Feb 2023', + 'Mar 2023', + 'Apr 2023', + 'May 2023', + 'Jun 2023', + 'Jul 2023', + 'Aug 2023', + 'Sep 2023', + 'Oct 2023', + 'Nov 2023', + 'Dec 2023', + ]); + }); }); diff --git a/src/hooks/useGetLastTwelveMonths.ts b/src/hooks/useGetLastTwelveMonths.ts index c9a4f4afdd..ce44674dfd 100644 --- a/src/hooks/useGetLastTwelveMonths.ts +++ b/src/hooks/useGetLastTwelveMonths.ts @@ -2,18 +2,32 @@ import { useMemo } from 'react'; import { DateTime } from 'luxon'; import { monthYearFormat } from 'src/lib/intlFormat'; +/** + * Returns an ordered list of month labels. + * + * By default, it returns the last twelve months ending in the current + * month. When "year" is provided, it instead returns January–December of that + * calendar year. + */ export const useGetLastTwelveMonths = ( locale: string, - fullYear: boolean, + year?: number | null, ): string[] => { return useMemo(() => { - const startDate = DateTime.now(); const result: string[] = []; + if (year !== null && year !== undefined) { + for (let month = 1; month <= 12; month++) { + result.push(monthYearFormat(month, year, locale, true)); + } + return result; + } + + const startDate = DateTime.now(); for (let i = 0; i < 12; i++) { const date = startDate.minus({ months: i }); - result.push(monthYearFormat(date.month, date.year, locale, fullYear)); + result.push(monthYearFormat(date.month, date.year, locale, true)); } return result.reverse(); - }, [locale, fullYear]); + }, [locale, year]); }; From b454f327a5ee1287c4220994d5c1ca0af03aef69 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Tue, 21 Jul 2026 15:28:23 -0400 Subject: [PATCH 03/11] Update label on filter change --- .../DisplayModes/ScreenOnlyReport.test.tsx | 3 ++ .../DisplayModes/ScreenOnlyReport.tsx | 13 +++---- .../MPGAIncomeExpensesReport.test.tsx | 34 +++++++++++++++++++ .../MPGAIncomeExpensesReport.tsx | 26 +++++++++++++- .../Tables/TableCard.test.tsx | 4 +++ .../Tables/TableCard.tsx | 6 ++-- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx index 402b535859..6cbcdc7f93 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx @@ -10,6 +10,7 @@ import { mockData, months } from '../mockData'; import { ScreenOnlyReport } from './ScreenOnlyReport'; const mutationSpy = jest.fn(); +const subtitle = 'Last 12 Months'; const currency = 'USD'; const emptyData = { @@ -24,6 +25,7 @@ const TestComponent: React.FC = () => ( @@ -61,6 +63,7 @@ describe('ScreenOnlyReport', () => { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx index 06b66fca1b..f8f63da95e 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx @@ -12,12 +12,14 @@ import { AllData } from '../mockData'; interface ScreenOnlyReportProps { data: AllData; + subtitle: string; last12Months: string[]; currency: string; } export const ScreenOnlyReport: React.FC = ({ data, + subtitle, last12Months, currency, }) => { @@ -29,14 +31,14 @@ export const ScreenOnlyReport: React.FC = ({ - + @@ -56,6 +58,7 @@ export const ScreenOnlyReport: React.FC = ({ /> } title={t('Income')} + subtitle={subtitle} months={last12Months} /> @@ -72,14 +75,12 @@ export const ScreenOnlyReport: React.FC = ({ /> } title={t('Expenses')} + subtitle={subtitle} months={last12Months} /> - + { expect(await findByRole('button', { name: 'Clear' })).toBeInTheDocument(); }, 15000); }); + + describe('Date Range subtitle', () => { + it('shows default subtitle', async () => { + const { findAllByText } = render(); + + expect((await findAllByText('Last 12 Months')).length).toBeGreaterThan(0); + }); + + it('shows the month range when a specific year is applied', async () => { + const { getByRole, findByRole, findAllByText } = render( + , + ); + + await findByRole('gridcell', { name: 'Benefits' }); + + userEvent.click(getByRole('button', { name: 'Report Settings' })); + + const lastCompletedYear = DateTime.now().year - 1; + userEvent.click( + await findByRole('combobox', { name: 'Select Date Range' }), + ); + userEvent.click(getByRole('option', { name: String(lastCompletedYear) })); + + const applyButton = await findByRole('button', { name: 'Apply Filters' }); + await waitFor(() => expect(applyButton).not.toBeDisabled()); + userEvent.click(applyButton); + + expect( + await findAllByText( + `January ${lastCompletedYear} – December ${lastCompletedYear}`, + ), + ).not.toHaveLength(0); + }, 15000); + }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index 94370c424f..de7c782b9d 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx @@ -18,6 +18,7 @@ import { useStaffAccountQuery } from 'src/components/Shared/StaffAccount/StaffAc 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 { AccountInfoBox } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBox'; import { AccountInfoBoxSkeleton } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton'; import { SettingsButtonGroup } from '../Shared/SettingsButtonGroup/SettingsButtonGroup'; @@ -82,6 +83,28 @@ export const MPGAIncomeExpensesReport: React.FC< }; }, [specificYear, isYearToDate]); + const subtitle = useMemo(() => { + if (specificYear === null && !isYearToDate) { + return t('Last 12 Months'); + } + return t('{{startMonth}} – {{endMonth}}', { + startMonth: monthYearFormat( + startDate.month, + startDate.year, + locale, + true, + true, + ), + endMonth: monthYearFormat( + endDate.month, + endDate.year, + locale, + true, + true, + ), + }); + }, [specificYear, isYearToDate, startDate, endDate, locale, t]); + const defaultFilters: Filters = useMemo( () => ({ selectedDateRange: null, @@ -220,7 +243,7 @@ export const MPGAIncomeExpensesReport: React.FC< - {t('Income & Expenses Analysis: Last 12 Months')} + {t('Income & Expenses Analysis: {{subtitle}}', { subtitle })} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx index 8a21ce6ce3..7cc06d194c 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx @@ -14,6 +14,7 @@ import { TableCard } from './TableCard'; const mutationSpy = jest.fn(); const title = 'Income'; +const subtitle = 'Last 12 Months'; const data = { income: mockData.income, @@ -38,6 +39,7 @@ const TestComponent: React.FC = () => ( breakdownData={mockData.incomeBreakdown} emptyPlaceholder={Empty Table} title={title} + subtitle={subtitle} months={months} /> @@ -90,6 +92,7 @@ describe('TableCard', () => { data={data.income} emptyPlaceholder={Empty Table} title={title} + subtitle={subtitle} months={months} /> @@ -134,6 +137,7 @@ describe('TableCard', () => { data={[]} emptyPlaceholder={Empty Table} title={title} + subtitle={subtitle} months={months} /> diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx index dbc78616d3..e12cad36f2 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx @@ -34,6 +34,7 @@ export interface TableCardProps { >; emptyPlaceholder: React.ReactElement; title: string; + subtitle: string; months: string[]; } @@ -93,6 +94,7 @@ export const TableCard: React.FC = ({ data, breakdownData = {}, title, + subtitle, months, emptyPlaceholder, }) => { @@ -237,7 +239,7 @@ export const TableCard: React.FC = ({ <> @@ -272,7 +274,7 @@ export const TableCard: React.FC = ({ )} ) : ( - + Date: Tue, 21 Jul 2026 16:18:05 -0400 Subject: [PATCH 04/11] Fix failing test --- .../BreakdownModal/BreakdownModal.tsx | 17 ++++-- .../MPGAIncomeExpensesReport.test.tsx | 60 ++++++++++++------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx index 0af2f35d75..1098224f21 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx @@ -76,11 +76,21 @@ export const BreakdownModal: React.FC = ({ open={open} onClose={onClose} > - + {t('Category')} @@ -120,10 +130,9 @@ export const BreakdownModal: React.FC = ({ sx={{ '& .MuiTableCell-footer': { position: 'sticky', - bottom: -1, + bottom: 0, backgroundColor: 'background.paper', borderBottom: 0, - boxShadow: `inset 0 -1px 0 ${theme.palette.divider}`, }, }} > diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx index 95b565c7c8..48ac6eb845 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx @@ -159,7 +159,7 @@ describe('MPGAIncomeExpensesReport', () => { expect(getByTestId('ReportsMenuIcon')).toBeInTheDocument(); userEvent.click(getByTestId('ReportsMenuIcon')); await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); - }); + }, 15000); describe('Category filtering', () => { it('re-renders report rows when a category is unchecked and applied', async () => { @@ -185,9 +185,15 @@ describe('MPGAIncomeExpensesReport', () => { userEvent.click(applyButton); expect( - await findAllByText('Benefits - Workers Compensation'), + await findAllByText('Benefits - Workers Compensation', undefined, { + timeout: 5000, + }), + ).toHaveLength(1); + expect( + await findAllByText('Benefits - Program Based', undefined, { + timeout: 5000, + }), ).toHaveLength(1); - expect(await findAllByText('Benefits - Program Based')).toHaveLength(1); }, 15000); it('does not show the Clear button until a category filter is applied', async () => { @@ -207,7 +213,9 @@ describe('MPGAIncomeExpensesReport', () => { await waitFor(() => expect(applyButton).not.toBeDisabled()); userEvent.click(applyButton); - expect(await findByRole('button', { name: 'Clear' })).toBeInTheDocument(); + expect( + await findByRole('button', { name: 'Clear' }, { timeout: 5000 }), + ).toBeInTheDocument(); }, 15000); it('does not show the Clear button when the dialog is cancelled', async () => { @@ -247,10 +255,14 @@ describe('MPGAIncomeExpensesReport', () => { userEvent.click(applyButton); expect( - await findAllByText('Benefits - Workers Compensation'), + await findAllByText('Benefits - Workers Compensation', undefined, { + timeout: 5000, + }), ).toHaveLength(1); - userEvent.click(await findByRole('button', { name: 'Clear' })); + userEvent.click( + await findByRole('button', { name: 'Clear' }, { timeout: 5000 }), + ); await waitFor(() => expect( @@ -280,12 +292,14 @@ describe('MPGAIncomeExpensesReport', () => { await waitFor(() => expect(applyButton).not.toBeDisabled()); userEvent.click(applyButton); - await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { - startMonth: `${lastCompletedYear}-01-01`, - endMonth: `${lastCompletedYear}-12-31`, - fundTypes: ['Primary'], - }), + await waitFor( + () => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: `${lastCompletedYear}-01-01`, + endMonth: `${lastCompletedYear}-12-31`, + fundTypes: ['Primary'], + }), + { timeout: 5000 }, ); }, 15000); @@ -307,12 +321,14 @@ describe('MPGAIncomeExpensesReport', () => { userEvent.click(applyButton); const now = DateTime.now(); - await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { - startMonth: `${now.year}-01-01`, - endMonth: now.toISODate(), - fundTypes: ['Primary'], - }), + await waitFor( + () => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: `${now.year}-01-01`, + endMonth: now.toISODate(), + fundTypes: ['Primary'], + }), + { timeout: 5000 }, ); }, 15000); @@ -333,7 +349,9 @@ describe('MPGAIncomeExpensesReport', () => { await waitFor(() => expect(applyButton).not.toBeDisabled()); userEvent.click(applyButton); - expect(await findByRole('button', { name: 'Clear' })).toBeInTheDocument(); + expect( + await findByRole('button', { name: 'Clear' }, { timeout: 5000 }), + ).toBeInTheDocument(); }, 15000); }); @@ -342,7 +360,7 @@ describe('MPGAIncomeExpensesReport', () => { const { findAllByText } = render(); expect((await findAllByText('Last 12 Months')).length).toBeGreaterThan(0); - }); + }, 15000); it('shows the month range when a specific year is applied', async () => { const { getByRole, findByRole, findAllByText } = render( @@ -366,6 +384,8 @@ describe('MPGAIncomeExpensesReport', () => { expect( await findAllByText( `January ${lastCompletedYear} – December ${lastCompletedYear}`, + undefined, + { timeout: 5000 }, ), ).not.toHaveLength(0); }, 15000); From 5b0846c6faf74e5f406069687a070bd1505ef4b6 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Wed, 22 Jul 2026 08:13:32 -0400 Subject: [PATCH 05/11] Add grayed out columns when year to date filter applied --- .../DisplayModes/PrintOnlyReport.tsx | 4 + .../DisplayModes/ScreenOnlyReport.tsx | 4 + .../MPGAIncomeExpensesReport.tsx | 61 ++++++----- .../Tables/PrintTables.tsx | 100 +++++++++++++----- .../Tables/TableCard.tsx | 99 ++++++++++++----- .../Tables/TotalRow.tsx | 15 ++- 6 files changed, 201 insertions(+), 82 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx index 92f782a716..8f8577bcd8 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx @@ -10,12 +10,14 @@ import { AllData } from '../mockData'; interface PrintOnlyReportProps { data: AllData; last12Months: string[]; + firstFutureMonthIndex?: number; currency: string; } export const PrintOnlyReport: React.FC = ({ data, last12Months, + firstFutureMonthIndex, currency, }) => { const { t } = useTranslation(); @@ -61,6 +63,7 @@ export const PrintOnlyReport: React.FC = ({ data={data.income ?? []} title={t('Income')} months={last12Months} + firstFutureMonthIndex={firstFutureMonthIndex} /> @@ -69,6 +72,7 @@ export const PrintOnlyReport: React.FC = ({ data={data.expenses ?? []} title={t('Expenses')} months={last12Months} + firstFutureMonthIndex={firstFutureMonthIndex} /> diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx index f8f63da95e..b7dda6aaf7 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx @@ -14,6 +14,7 @@ interface ScreenOnlyReportProps { data: AllData; subtitle: string; last12Months: string[]; + firstFutureMonthIndex?: number; currency: string; } @@ -21,6 +22,7 @@ export const ScreenOnlyReport: React.FC = ({ data, subtitle, last12Months, + firstFutureMonthIndex, currency, }) => { const { t } = useTranslation(); @@ -60,6 +62,7 @@ export const ScreenOnlyReport: React.FC = ({ title={t('Income')} subtitle={subtitle} months={last12Months} + firstFutureMonthIndex={firstFutureMonthIndex} /> @@ -77,6 +80,7 @@ export const ScreenOnlyReport: React.FC = ({ title={t('Expenses')} subtitle={subtitle} months={last12Months} + firstFutureMonthIndex={firstFutureMonthIndex} /> diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index de7c782b9d..74a5bb9cb9 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx @@ -57,34 +57,38 @@ export const MPGAIncomeExpensesReport: React.FC< const [filters, setFilters] = useState(null); - const specificYear = filters?.selectedYear ?? null; + const selectedYear = filters?.selectedYear ?? null; const isYearToDate = - specificYear === null && + selectedYear === null && filters?.selectedDateRange === DateRange.YearToDate; - const monthLabels = useGetLastTwelveMonths(locale, specificYear); + const effectiveYear = + selectedYear ?? (isYearToDate ? DateTime.now().year : null); + + const monthLabels = useGetLastTwelveMonths(locale, effectiveYear); const { startDate, endDate } = useMemo(() => { const now = DateTime.now(); - // If a specific year is selected, return start and end of that year - if (specificYear !== null) { - const yearStart = DateTime.fromObject({ year: specificYear }).startOf( + // If a year is selected, show the full year + if (effectiveYear !== null) { + const yearStart = DateTime.fromObject({ year: effectiveYear }).startOf( 'year', ); - return { startDate: yearStart, endDate: yearStart.endOf('year') }; - } - // If Year to Date is selected, return start of the year to today - if (isYearToDate) { - return { startDate: now.startOf('year'), endDate: now }; + const yearEnd = yearStart.endOf('year'); + return { startDate: yearStart, endDate: yearEnd < now ? yearEnd : now }; } + // If no year is selected, default to the last 12 months return { startDate: now.minus({ months: 11 }).startOf('month'), endDate: now, }; - }, [specificYear, isYearToDate]); + }, [effectiveYear]); + + // If year to date filter is selected, get first month index in the future to gray out future months in the table + const firstFutureMonthIndex = isYearToDate ? DateTime.now().month : undefined; const subtitle = useMemo(() => { - if (specificYear === null && !isYearToDate) { + if (selectedYear === null && !isYearToDate) { return t('Last 12 Months'); } return t('{{startMonth}} – {{endMonth}}', { @@ -103,7 +107,7 @@ export const MPGAIncomeExpensesReport: React.FC< true, ), }); - }, [specificYear, isYearToDate, startDate, endDate, locale, t]); + }, [selectedYear, isYearToDate, startDate, endDate, locale, t]); const defaultFilters: Filters = useMemo( () => ({ @@ -184,17 +188,21 @@ export const MPGAIncomeExpensesReport: React.FC< expenseBreakdown, }; } - // Year to Date queries only the current year, so each row has fewer months than the 12 columns - const padToColumns = (rows: DataFields[]): DataFields[] => - rows.map((row) => { - const missing = monthLabels.length - row.monthly.length; - return missing > 0 - ? { ...row, monthly: [...new Array(missing).fill(0), ...row.monthly] } - : row; - }); + + // Year to Date only has data through the current month so fill the remaining months + const addFutureData = (rows: DataFields[]): DataFields[] => + rows.map((row) => ({ + ...row, + monthly: monthLabels.map((_month, index) => + firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex + ? 0 + : (row.monthly?.[index] ?? 0), + ), + })); + return { - income: padToColumns(incomeData), - expenses: padToColumns(expenseData), + income: addFutureData(incomeData), + expenses: addFutureData(expenseData), incomeBreakdown, expenseBreakdown, }; @@ -204,7 +212,8 @@ export const MPGAIncomeExpensesReport: React.FC< incomeBreakdown, expenseBreakdown, isYearToDate, - monthLabels.length, + monthLabels, + firstFutureMonthIndex, ]); return ( @@ -291,6 +300,7 @@ export const MPGAIncomeExpensesReport: React.FC< data={allData} subtitle={subtitle} last12Months={monthLabels} + firstFutureMonthIndex={firstFutureMonthIndex} currency={currency} /> @@ -305,6 +315,7 @@ export const MPGAIncomeExpensesReport: React.FC< diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx index aabde122db..cd6730b9fe 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx @@ -24,6 +24,7 @@ export interface PrintTablesProps { data?: DataFields[]; title: string; months: string[]; + firstFutureMonthIndex?: number; } export const PrintTables: React.FC = ({ @@ -31,6 +32,7 @@ export const PrintTables: React.FC = ({ months, data, type, + firstFutureMonthIndex, }) => { const { t } = useTranslation(); const locale = useLocale(); @@ -39,6 +41,15 @@ export const PrintTables: React.FC = ({ const overallTotal = type === ReportTypeEnum.Income ? incomeTotal : expensesTotal; + const grayColor = theme.palette.text.disabled; + const isFutureMonth = (index: number) => + firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex; + const futureCellSx = { + backgroundColor: theme.palette.action.hover, + WebkitPrintColorAdjust: 'exact', + printColorAdjust: 'exact', + } as const; + const { monthCount, firstMonthFlags, getBorderColor } = useMonthHeaders( months, { @@ -63,34 +74,63 @@ export const PrintTables: React.FC = ({ - {monthCount?.map(({ year, count }, index) => { + {monthCount?.flatMap(({ year, count }, index) => { const borderColor = getBorderColor(index); const firstMonthInYear = firstMonthFlags.find( (month) => month.year === year && month.isFirstOfYear, ); - return ( - sum + group.count, 0); + + // Split the year underline into a past segment (year color) and + // a future segment (gray) at the exact column boundary, so it + // lines up with the columns regardless of their widths. + const futureStart = + firstFutureMonthIndex !== undefined + ? Math.max( + 0, + Math.min(count, firstFutureMonthIndex - monthOffset), + ) + : count; + const pastCount = futureStart; + const futureCount = count - pastCount; + + const yearLabel = firstMonthInYear ? ( + - {firstMonthInYear && ( - - {year} - - )} - - ); + {year} + + ) : null; + + return [ + pastCount > 0 ? ( + + {yearLabel} + + ) : null, + futureCount > 0 ? ( + + {pastCount === 0 ? yearLabel : null} + + ) : null, + ]; })} = ({ {t('Description')} - {months.map((month) => ( + {months.map((month, index) => ( - + {month.split(' ')[0]} @@ -142,7 +184,10 @@ export const PrintTables: React.FC = ({ {value.description} {value.monthly.map((amount, index) => ( - + {zeroAmountFormat(amount, locale)} @@ -175,7 +220,10 @@ export const PrintTables: React.FC = ({ {data[0].monthly.map((_, index) => ( - + {zeroAmountFormat( diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx index e12cad36f2..cb203e8a52 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx @@ -36,6 +36,8 @@ export interface TableCardProps { title: string; subtitle: string; months: string[]; + /** Month column index from which columns are grayed as future. Year to date filter only */ + firstFutureMonthIndex?: number; } // Visual styling for the grouped-column headers, matching the report's table @@ -45,10 +47,11 @@ const groupHeaderFontSize = '14px'; const groupHeaderUnderlineGap = '7px'; const groupHeaderUnderlineHeight = '2px'; -const GroupHeader: React.FC<{ label: string; color: string }> = ({ - label, - color, -}) => ( +const GroupHeader: React.FC<{ + label: string; + color: string; + underlineBackground?: string; +}> = ({ label, color, underlineBackground }) => ( = ({ sx={{ width: '100%', height: groupHeaderUnderlineHeight, - backgroundColor: color, + background: underlineBackground ?? color, }} /> @@ -96,6 +99,7 @@ export const TableCard: React.FC = ({ title, subtitle, months, + firstFutureMonthIndex, emptyPlaceholder, }) => { const { t } = useTranslation(); @@ -135,26 +139,32 @@ export const TableCard: React.FC = ({ const columns = useMemo[]>(() => { const monthColumns: GridColDef[] = months.map( - (month, index) => ({ - field: `month${index}`, - headerName: month.split(' ')[0], - width: monthWidth, - type: 'number', - valueGetter: (_value, row) => { - const v = row.monthly?.[index]; - return typeof v === 'number' ? v : null; - }, - renderCell: (params) => { - const formattedValue = zeroAmountFormat(params.value, locale); - return ( - - - {formattedValue} - - - ); - }, - }), + (month, index) => { + const isFutureMonth = + firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex; + return { + field: `month${index}`, + headerName: month.split(' ')[0], + width: monthWidth, + type: 'number', + headerClassName: isFutureMonth ? 'future-month-header' : undefined, + cellClassName: isFutureMonth ? 'future-month' : undefined, + valueGetter: (_value, row) => { + const v = row.monthly?.[index]; + return typeof v === 'number' ? v : null; + }, + renderCell: (params) => { + const formattedValue = zeroAmountFormat(params.value, locale); + return ( + + + {formattedValue} + + + ); + }, + }; + }, ); return [ @@ -182,7 +192,7 @@ export const TableCard: React.FC = ({ headerAlign: 'right', }, ]; - }, [months, locale, t, description, average, total]); + }, [months, firstFutureMonthIndex, locale, t, description, average, total]); const columnGroupingModel = useMemo(() => { const yearGroups = monthCount.map(({ year, count }, index) => { @@ -194,12 +204,31 @@ export const TableCard: React.FC = ({ field: `month${monthOffset + monthIndex}`, })); + // Gray the portion of the underline that sits over future months. + const grayColor = theme.palette.text.disabled; + let underlineBackground: string | undefined; + if (firstFutureMonthIndex !== undefined) { + const futureStart = firstFutureMonthIndex - monthOffset; + if (futureStart <= 0) { + underlineBackground = grayColor; + } else if (futureStart < count) { + const ratio = (futureStart / count) * 100; + underlineBackground = `linear-gradient(to right, ${color} ${ratio}%, ${grayColor} ${ratio}%)`; + } + } + return { groupId: year, headerName: year, headerAlign: 'left' as const, children, - renderHeaderGroup: () => , + renderHeaderGroup: () => ( + + ), }; }); @@ -225,7 +254,7 @@ export const TableCard: React.FC = ({ ), }, ]; - }, [monthCount, getBorderColor, t]); + }, [monthCount, getBorderColor, firstFutureMonthIndex, t]); return dataLoading ? ( @@ -247,6 +276,14 @@ export const TableCard: React.FC = ({ rows={cardTableRows} columns={columns} columnGroupingModel={columnGroupingModel} + sx={{ + '& .future-month-header .MuiDataGrid-columnHeaderTitle': { + color: theme.palette.text.disabled, + }, + '& .future-month': { + backgroundColor: theme.palette.action.hover, + }, + }} getRowId={(row) => row.id} sortingOrder={['desc', 'asc', null]} sortModel={sortModel} @@ -260,7 +297,11 @@ export const TableCard: React.FC = ({ disableColumnMenu /> - + diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx index bc84345f38..0a415bff49 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx @@ -4,6 +4,7 @@ import { GridColDef } from '@mui/x-data-grid'; import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; import { amountFormat, zeroAmountFormat } from 'src/lib/intlFormat'; +import theme from 'src/theme'; import { populateTotalRows } from '../Helper/createRows'; import { DataFields } from '../mockData'; import { StyledTotalRow } from '../styledComponents'; @@ -22,9 +23,14 @@ interface Row { interface TotalRowProps { data: DataFields[]; overallTotal: number | undefined; + firstFutureMonthIndex?: number; } -export const TotalRow: React.FC = ({ data, overallTotal }) => { +export const TotalRow: React.FC = ({ + data, + overallTotal, + firstFutureMonthIndex, +}) => { const { t } = useTranslation(); const locale = useLocale(); @@ -58,6 +64,10 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { filterable: false, disableColumnMenu: true, align: 'right', + cellClassName: + firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex + ? 'future-month' + : undefined, renderCell: ({ row }) => { const formattedValue = zeroAmountFormat( row.monthlyValue[index], @@ -105,7 +115,7 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { renderCell: overall, }, ]; - }, [monthlyTotals, avgSum, overallTotal]); + }, [monthlyTotals, avgSum, overallTotal, firstFutureMonthIndex]); return ( = ({ data, overallTotal }) => { '& .MuiDataGrid-virtualScroller': { marginTop: '0 !important' }, '& .MuiDataGrid-cell': { borderBottom: 'none' }, '& .MuiDataGrid-main': { border: 'none' }, + '& .future-month': { backgroundColor: theme.palette.action.hover }, }} /> ); From 5696ddb6cd004f5bc96253deb94726c54d677a09 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Wed, 22 Jul 2026 11:48:10 -0400 Subject: [PATCH 06/11] Move shared logic into context to avoid prop drilling --- .../reports/mpgaIncomeExpenses/index.page.tsx | 13 +- .../BreakdownModal/BreakdownModal.test.tsx | 19 +- .../BreakdownModal/BreakdownModal.tsx | 4 +- .../Card/CardSkeleton.test.tsx | 12 +- .../Card/CardSkeleton.tsx | 5 +- .../ExpensesPieChart.test.tsx | 49 +--- .../ExpensesPieChart/ExpensesPieChart.tsx | 4 +- .../MonthlySummaryChart.test.tsx | 90 ++---- .../MonthlySummaryChart.tsx | 20 +- .../SummaryBarChart/SummaryBarChart.test.tsx | 33 +-- .../SummaryBarChart/SummaryBarChart.tsx | 6 +- .../DisplayModes/PrintOnlyReport.test.tsx | 62 +--- .../DisplayModes/PrintOnlyReport.tsx | 32 +-- .../DisplayModes/ScreenOnlyReport.test.tsx | 69 ++--- .../DisplayModes/ScreenOnlyReport.tsx | 47 +--- .../ExportCsvButton/ExportCsvButton.test.tsx | 92 ++++-- .../ExportCsvButton/ExportCsvButton.tsx | 28 +- .../MPGAIncomeExpensesReport.test.tsx | 108 +------ .../MPGAIncomeExpensesReport.tsx | 175 +----------- .../MPGAIncomeExpensesReportTestWrapper.tsx | 133 +++++++++ .../ReportContext/ReportContext.test.tsx | 199 +++++++++++++ .../ReportContext/ReportContext.tsx | 266 ++++++++++++++++++ .../Tables/PrintTables.test.tsx | 136 ++++----- .../Tables/PrintTables.tsx | 17 +- .../Tables/TableCard.test.tsx | 148 ++++------ .../Tables/TableCard.tsx | 35 +-- .../Tables/TotalRow.test.tsx | 31 +- .../Tables/TotalRow.tsx | 15 +- .../TotalsContext/TotalsContext.test.tsx | 93 ------ .../TotalsContext/TotalsContext.tsx | 105 ------- .../SettingsDialog/SettingsDialog.test.tsx | 6 +- .../Shared/SettingsDialog/SettingsDialog.tsx | 10 +- 32 files changed, 967 insertions(+), 1095 deletions(-) create mode 100644 src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx create mode 100644 src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx create mode 100644 src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx delete mode 100644 src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.test.tsx delete mode 100644 src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.tsx diff --git a/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx b/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx index 5febe45dd1..c7ff814701 100644 --- a/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx +++ b/pages/accountLists/[accountListId]/reports/mpgaIncomeExpenses/index.page.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { blockImpersonatingNonDevelopers } from 'pages/api/utils/pagePropsHelpers'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import { MPGAIncomeExpensesReport } from 'src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport'; +import { ReportProvider } from 'src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext'; import { MultiPageMenu, NavTypeEnum, @@ -49,11 +50,13 @@ const MPGAReportPage: React.FC = () => { leftOpen={isNavListOpen} leftWidth="290px" mainContent={ - + + + } /> diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx index 292779768d..809f57aaf5 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.test.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; -import { DateTime } from 'luxon'; import { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; -import theme from 'src/theme'; -import { TotalsProvider } from '../TotalsContext/TotalsContext'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; import { mockBreakdownData } from '../mockData'; import { BreakdownModal, BreakdownModalProps } from './BreakdownModal'; +const mutationSpy = jest.fn(); + const defaultProps: BreakdownModalProps = { open: true, onClose: jest.fn(), @@ -16,15 +15,9 @@ const defaultProps: BreakdownModalProps = { }; const TestComponent: React.FC = (props) => ( - - - - - + + + ); describe('BreakdownModal', () => { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx index 1098224f21..ab4ae31032 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx @@ -20,7 +20,7 @@ import theme from 'src/theme'; import { DialogSkeleton } from '../../Shared/DialogSkeleton/DialogSkeleton'; import { getLocalizedCategory } from '../../Shared/Helpers/transformStaffExpenseEnums'; import { BreakdownAccordion } from '../BreakdownAccordion/BreakdownAccordion'; -import { useTotals } from '../TotalsContext/TotalsContext'; +import { useReport } from '../ReportContext/ReportContext'; import { TransactionBreakdown } from '../mockData'; export interface BreakdownModalProps { @@ -39,7 +39,7 @@ export const BreakdownModal: React.FC = ({ const { t } = useTranslation(); const locale = useLocale(); const currency = 'USD'; - const { startDate, endDate } = useTotals(); + const { startDate, endDate } = useReport(); const subcategoryBreakdown = useMemo(() => { const categoryBreakdown = breakdownData[category] ?? []; diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.test.tsx index 34aed51dd4..8c6e9b5c35 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.test.tsx @@ -1,20 +1,18 @@ -import { ThemeProvider } from '@emotion/react'; import { render } from '@testing-library/react'; -import theme from 'src/theme'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; import { CardSkeleton } from './CardSkeleton'; const title = 'Test Title'; -const subtitle = 'Test Subtitle'; describe('CardSkeleton', () => { it('should render card title and subtitle', () => { const { getByText } = render( - - - , + + + , ); expect(getByText(title)).toBeInTheDocument(); - expect(getByText(subtitle)).toBeInTheDocument(); + expect(getByText('Last 12 Months')).toBeInTheDocument(); }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.tsx index 6b2ea5b05f..51dddd9ab3 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Card/CardSkeleton.tsx @@ -6,20 +6,21 @@ import { SxProps, Theme, } from '@mui/material'; +import { useReport } from '../ReportContext/ReportContext'; export interface CardSkeletonProps { title: string; - subtitle?: string; children?: React.ReactNode; styling?: SxProps; } export const CardSkeleton: React.FC = ({ title, - subtitle, children, styling, }) => { + const { subtitle } = useReport(); + return ( diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.test.tsx index 3d4c2d26a5..b3671ce5a7 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.test.tsx @@ -1,27 +1,15 @@ import '../sharedRechartMock'; import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { render, waitFor, within } from '@testing-library/react'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import theme from 'src/theme'; -import { TotalsProvider } from '../../TotalsContext/TotalsContext'; -import { mockData } from '../../mockData'; +import { MPGAIncomeExpensesReportTestWrapper } from '../../MPGAIncomeExpensesReportTestWrapper'; import { ExpensesPieChart } from './ExpensesPieChart'; const mutationSpy = jest.fn(); const TestComponent: React.FC = () => ( - - - - - - - - - + + + ); describe('ExpensesPieChart', () => { @@ -79,20 +67,9 @@ describe('ExpensesPieChart', () => { it('shows a message when there is no data', async () => { const { findByText } = render( - - - - - - - - - , + + + , ); expect( @@ -101,17 +78,7 @@ describe('ExpensesPieChart', () => { }); it('renders a spinner instead of the chart while loading', () => { - const { getByTestId, queryByRole, queryByText } = render( - - - - - - - - - , - ); + const { getByTestId, queryByRole, queryByText } = render(); expect(getByTestId('loading-spinner')).toBeInTheDocument(); expect(queryByRole('region')).not.toBeInTheDocument(); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.tsx index 66af9f474a..0060a8adf8 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/ExpensesPieChart/ExpensesPieChart.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { Cell, Legend, Pie, PieChart } from 'recharts'; import theme from 'src/theme'; -import { useTotals } from '../../TotalsContext/TotalsContext'; +import { useReport } from '../../ReportContext/ReportContext'; import { ChartFrame } from '../ChartFrame'; import { ChartLegendContent } from '../ChartLegendContent/ChartLegendContent'; @@ -32,7 +32,7 @@ export const ExpensesPieChart: React.FC = ({ salaryTotal, otherTotal, dataLoading, - } = useTotals(); + } = useReport(); const data = [ { name: t('Ministry'), value: ministryTotal ?? 0 }, diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.test.tsx index 6f7e40aea8..1d813579a2 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.test.tsx @@ -1,39 +1,16 @@ import '../sharedRechartMock'; import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { render, waitFor, within } from '@testing-library/react'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import theme from 'src/theme'; -import { TotalsProvider } from '../../TotalsContext/TotalsContext'; -import { mockData, months } from '../../mockData'; +import { MPGAIncomeExpensesReportTestWrapper } from '../../MPGAIncomeExpensesReportTestWrapper'; import { MonthlySummaryChart } from './MonthlySummaryChart'; const mutationSpy = jest.fn(); -const currency = 'USD'; - -interface TestComponentProps { - loading?: boolean; -} - -const TestComponent: React.FC = ({ loading }) => ( - - - - - - - - - +const monthCount = 12; + +const TestComponent: React.FC = () => ( + + + ); describe('MonthlySummaryChart', () => { @@ -56,7 +33,7 @@ describe('MonthlySummaryChart', () => { const barShapes = region.querySelectorAll( '.recharts-bar-rectangle rect, .recharts-bar-rectangle path', ); - expect(barShapes.length).toBe(months.length * 2); + expect(barShapes.length).toBe(monthCount * 2); }); }); @@ -71,13 +48,13 @@ describe('MonthlySummaryChart', () => { ).map((node) => node.textContent); expect(netLabels).toEqual([ - '$5,809', - '$5,236', - '$4,755', - '$5,351', - '$6,894', - '$5,664', - '$5,956', + '$5,709', + '$5,136', + '$4,655', + '$5,251', + '$6,794', + '$5,564', + '$5,856', '$7,026', '$7,713', '$7,312', @@ -87,41 +64,6 @@ describe('MonthlySummaryChart', () => { }); }); - it('subtracts expenses from income, treating expenses as positive', async () => { - const buildRow = (description: string, monthly: number[]) => ({ - id: description, - description, - monthly, - average: 0, - total: monthly.reduce((sum, value) => sum + value, 0), - }); - - const { findByRole } = render( - - - - - , - ); - - const region = await findByRole('region'); - - await waitFor(() => { - const netLabels = Array.from( - region.querySelectorAll('.recharts-label-list text'), - ).map((node) => node.textContent); - - expect(netLabels).toEqual(['$100', '-$50']); - }); - }); - it('renders legend with correct labels', async () => { const { findByRole } = render(); @@ -162,7 +104,7 @@ describe('MonthlySummaryChart', () => { }); it('renders a spinner instead of the chart while loading', () => { - const { getByTestId, queryByRole } = render(); + const { getByTestId, queryByRole } = render(); expect(getByTestId('loading-spinner')).toBeInTheDocument(); expect(queryByRole('region')).not.toBeInTheDocument(); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx index 23e868d634..354cb529be 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx @@ -13,18 +13,13 @@ import { import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; -import { useTotals } from '../../TotalsContext/TotalsContext'; -import { DataFields } from '../../mockData'; +import { useReport } from '../../ReportContext/ReportContext'; import { ChartFrame } from '../ChartFrame'; import { ChartLegendContent } from '../ChartLegendContent/ChartLegendContent'; interface MonthlySummaryChartProps { - incomeData: DataFields[]; - expenseData: DataFields[]; - months: string[]; aspect: number; width: number; - currency: string; } interface MonthlyTotal { @@ -41,17 +36,20 @@ const chartColors = [ ]; export const MonthlySummaryChart: React.FC = ({ - incomeData, - expenseData, - months, aspect, width, - currency, }) => { const { t } = useTranslation(); const locale = useLocale(); - const { dataLoading } = useTotals(); + const { + allData: data, + dataLoading, + monthLabels: months, + currency, + } = useReport(); + const incomeData = data.income ?? []; + const expenseData = data.expenses ?? []; const monthlyTotals = useMemo(() => { return months.map((name, index) => { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.test.tsx index 92c11b4f0a..8ad206914f 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.test.tsx @@ -1,31 +1,18 @@ import '../sharedRechartMock'; import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { render, waitFor, within } from '@testing-library/react'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import theme from 'src/theme'; -import { TotalsProvider } from '../../TotalsContext/TotalsContext'; -import { mockData } from '../../mockData'; +import { MPGAIncomeExpensesReportTestWrapper } from '../../MPGAIncomeExpensesReportTestWrapper'; import { SummaryBarChart } from './SummaryBarChart'; const mutationSpy = jest.fn(); -const currency = 'USD'; const incomeTotal = 108_856; const expensesTotal = 20_969; const TestComponent: React.FC = () => ( - - - - - - - - - + + + ); describe('SummaryBarChart', () => { @@ -74,17 +61,7 @@ describe('SummaryBarChart', () => { }); it('renders a spinner instead of the chart while loading', () => { - const { getByTestId, queryByRole } = render( - - - - - - - - - , - ); + const { getByTestId, queryByRole } = render(); expect(getByTestId('loading-spinner')).toBeInTheDocument(); expect(queryByRole('region')).not.toBeInTheDocument(); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx index ff069a868e..d543f16f58 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx @@ -3,13 +3,12 @@ import { Bar, BarChart, Cell, LabelList, XAxis, YAxis } from 'recharts'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; -import { useTotals } from '../../TotalsContext/TotalsContext'; +import { useReport } from '../../ReportContext/ReportContext'; import { ChartFrame } from '../ChartFrame'; interface SummaryBarChartProps { aspect: number; width: number; - currency: string; } const chartColors = [ @@ -20,12 +19,11 @@ const chartColors = [ export const SummaryBarChart: React.FC = ({ aspect, width, - currency, }) => { const { t } = useTranslation(); const locale = useLocale(); - const { incomeTotal, expensesTotal, dataLoading } = useTotals(); + const { incomeTotal, expensesTotal, dataLoading, currency } = useReport(); const data = [ { name: t('Income'), total: incomeTotal }, diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.test.tsx index d999f46f57..770983f060 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.test.tsx @@ -1,36 +1,14 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { render } from '@testing-library/react'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import theme from 'src/theme'; -import { TotalsProvider } from '../TotalsContext/TotalsContext'; -import { mockData, months } from '../mockData'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; import { PrintOnlyReport } from './PrintOnlyReport'; const mutationSpy = jest.fn(); -const currency = 'USD'; - -const emptyData = { - income: [], - expenses: [], -}; const TestComponent: React.FC = () => ( - - - - - - - - - + + + ); const resizeObserverMock = () => ({ @@ -43,8 +21,8 @@ beforeAll(() => { }); describe('PrintOnlyReport', () => { - it('renders data correctly', () => { - const { getByRole, getAllByRole } = render(); + it('renders data correctly', async () => { + const { getByRole, findAllByRole } = render(); expect(getByRole('heading', { name: 'Summary' })).toBeInTheDocument(); expect( @@ -54,29 +32,19 @@ describe('PrintOnlyReport', () => { getByRole('heading', { name: 'Monthly Summary' }), ).toBeInTheDocument(); - expect(getAllByRole('table')).toHaveLength(2); - expect(getByRole('cell', { name: 'Contributions' })).toBeInTheDocument(); - expect(getByRole('cell', { name: 'Assessments' })).toBeInTheDocument(); + expect(await findAllByRole('table')).toHaveLength(2); + expect(getByRole('cell', { name: 'Donation' })).toBeInTheDocument(); + expect(getByRole('cell', { name: 'Assessment' })).toBeInTheDocument(); }); - it('displays the tables that should be showing', () => { - const { queryAllByRole, getByText } = render( - - - - - - - - - , + it('displays the tables that should be showing', async () => { + const { findAllByRole, getByText } = render( + + + , ); - expect(queryAllByRole('table')).toHaveLength(2); + expect(await findAllByRole('table')).toHaveLength(2); expect( getByText(/no income data available in the last 12 months/i), ).toBeInTheDocument(); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx index 8f8577bcd8..2656f4fcb3 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx @@ -4,23 +4,12 @@ import { ExpensesPieChart } from '../Charts/ExpensesPieChart/ExpensesPieChart'; import { MonthlySummaryChart } from '../Charts/MonthlySummaryChart/MonthlySummaryChart'; import { SummaryBarChart } from '../Charts/SummaryBarChart/SummaryBarChart'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; +import { useReport } from '../ReportContext/ReportContext'; import { PrintTables } from '../Tables/PrintTables'; -import { AllData } from '../mockData'; -interface PrintOnlyReportProps { - data: AllData; - last12Months: string[]; - firstFutureMonthIndex?: number; - currency: string; -} - -export const PrintOnlyReport: React.FC = ({ - data, - last12Months, - firstFutureMonthIndex, - currency, -}) => { +export const PrintOnlyReport: React.FC = () => { const { t } = useTranslation(); + const { allData: data } = useReport(); return ( <> @@ -47,7 +36,7 @@ export const PrintOnlyReport: React.FC = ({ {t('Summary')} - + @@ -62,8 +51,6 @@ export const PrintOnlyReport: React.FC = ({ type={ReportTypeEnum.Income} data={data.income ?? []} title={t('Income')} - months={last12Months} - firstFutureMonthIndex={firstFutureMonthIndex} /> @@ -71,22 +58,13 @@ export const PrintOnlyReport: React.FC = ({ type={ReportTypeEnum.Expenses} data={data.expenses ?? []} title={t('Expenses')} - months={last12Months} - firstFutureMonthIndex={firstFutureMonthIndex} /> {t('Monthly Summary')} - + diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx index 6cbcdc7f93..d86657f952 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx @@ -1,38 +1,14 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { render } from '@testing-library/react'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import theme from 'src/theme'; -import { TotalsProvider } from '../TotalsContext/TotalsContext'; -import { mockData, months } from '../mockData'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; import { ScreenOnlyReport } from './ScreenOnlyReport'; const mutationSpy = jest.fn(); -const subtitle = 'Last 12 Months'; -const currency = 'USD'; - -const emptyData = { - income: [{ ...mockData.income[0] }, { ...mockData.income[1] }], - expenses: [], -}; const TestComponent: React.FC = () => ( - - - - - - - - - + + + ); const resizeObserverMock = () => ({ @@ -45,34 +21,23 @@ beforeAll(() => { }); describe('ScreenOnlyReport', () => { - it('renders data correctly', () => { - const { getByRole, queryAllByRole } = render(); + it('renders data correctly', async () => { + const { getByRole, findAllByRole } = render(); - expect(queryAllByRole('grid')).toHaveLength(4); - expect( - getByRole('gridcell', { name: 'Contributions' }), - ).toBeInTheDocument(); - expect(getByRole('gridcell', { name: 'Assessments' })).toBeInTheDocument(); + expect(await findAllByRole('grid')).toHaveLength(4); + expect(getByRole('gridcell', { name: 'Donation' })).toBeInTheDocument(); + expect(getByRole('gridcell', { name: 'Assessment' })).toBeInTheDocument(); }); - it('displays the tables that should be showing', () => { - const { queryAllByRole } = render( - - - - - - - - - , + it('shows empty placeholders when there is no data', async () => { + const { findByText, getByText, queryAllByRole } = render( + + + , ); - expect(queryAllByRole('grid')).toHaveLength(2); + expect(await findByText('No Income data available')).toBeInTheDocument(); + expect(getByText('No Expenses data available')).toBeInTheDocument(); + expect(queryAllByRole('grid')).toHaveLength(0); }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx index b7dda6aaf7..ed9abe8601 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx @@ -7,25 +7,12 @@ import { ExpensesPieChart } from '../Charts/ExpensesPieChart/ExpensesPieChart'; import { MonthlySummaryChart } from '../Charts/MonthlySummaryChart/MonthlySummaryChart'; import { SummaryBarChart } from '../Charts/SummaryBarChart/SummaryBarChart'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; +import { useReport } from '../ReportContext/ReportContext'; import { TableCard } from '../Tables/TableCard'; -import { AllData } from '../mockData'; -interface ScreenOnlyReportProps { - data: AllData; - subtitle: string; - last12Months: string[]; - firstFutureMonthIndex?: number; - currency: string; -} - -export const ScreenOnlyReport: React.FC = ({ - data, - subtitle, - last12Months, - firstFutureMonthIndex, - currency, -}) => { +export const ScreenOnlyReport: React.FC = () => { const { t } = useTranslation(); + const { allData: data } = useReport(); return ( @@ -33,15 +20,12 @@ export const ScreenOnlyReport: React.FC = ({ - - + + - + @@ -50,7 +34,7 @@ export const ScreenOnlyReport: React.FC = ({ = ({ /> } title={t('Income')} - subtitle={subtitle} - months={last12Months} - firstFutureMonthIndex={firstFutureMonthIndex} /> @@ -78,21 +59,11 @@ export const ScreenOnlyReport: React.FC = ({ /> } title={t('Expenses')} - subtitle={subtitle} - months={last12Months} - firstFutureMonthIndex={firstFutureMonthIndex} /> - - + + diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx index a8f129a9dd..8f6790ae51 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx @@ -1,21 +1,30 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import theme from 'src/theme'; +import { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; import { exportToCsv } from '../CustomExport/CustomExport'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; -import { AllData, mockData, months } from '../mockData'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; +import { MpgaTransactionsQuery } from '../MPGATransactions.generated'; import { ExportCsvButton } from './ExportCsvButton'; +const mutationSpy = jest.fn(); + jest.mock('../CustomExport/CustomExport', () => ({ exportToCsv: jest.fn(), })); -const TestComponent: React.FC<{ data?: AllData }> = ({ data = mockData }) => ( - - - +const TestComponent: React.FC<{ + isEmpty?: boolean; + mocks?: MpgaTransactionsQuery; +}> = ({ isEmpty = false, mocks }) => ( + + + ); describe('ExportCsvButton', () => { @@ -46,12 +55,21 @@ describe('ExportCsvButton', () => { const { getByRole, findByRole } = render(); userEvent.click(getByRole('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); expect(exportToCsv).toHaveBeenCalledWith( - mockData.income, + expect.arrayContaining([ + expect.objectContaining({ + category: StaffExpenseCategoryEnum.Donation, + }), + ]), ReportTypeEnum.Income, - months, + expect.any(Array), 'en-US', ); }); @@ -60,36 +78,64 @@ describe('ExportCsvButton', () => { const { getByRole, findByRole } = render(); userEvent.click(getByRole('button', { name: 'Export CSV' })); - userEvent.click(await findByRole('menuitem', { name: 'Expenses Report' })); + + const expenses = await findByRole('menuitem', { name: 'Expenses Report' }); + await waitFor(() => + expect(expenses).not.toHaveAttribute('aria-disabled', 'true'), + ); + userEvent.click(expenses); expect(exportToCsv).toHaveBeenCalledWith( - mockData.expenses, + expect.arrayContaining([ + expect.objectContaining({ + category: StaffExpenseCategoryEnum.Assessment, + }), + ]), ReportTypeEnum.Expenses, - months, + expect.any(Array), 'en-US', ); }); it('disables an export option when its dataset is empty', async () => { + const incomeOnlyMock: MpgaTransactionsQuery = { + reportsStaffExpenses: { + funds: [ + { + fundType: 'Primary', + total: 5000, + categories: [ + { + category: StaffExpenseCategoryEnum.Donation, + averagePerMonth: 5000, + total: 5000, + breakdownByMonth: [{ month: '2019-02-01', total: 5000 }], + subcategories: [], + }, + ], + }, + ], + }, + }; + const { getByRole, findByRole } = render( - , + , ); userEvent.click(getByRole('button', { name: 'Export CSV' })); - expect( - await findByRole('menuitem', { name: 'Income Report' }), - ).not.toHaveAttribute('aria-disabled'); - const expenses = await findByRole('menuitem', { name: 'Expenses Report' }); expect(expenses).toHaveAttribute('aria-disabled', 'true'); + await waitFor(() => + expect( + getByRole('menuitem', { name: 'Income Report' }), + ).not.toHaveAttribute('aria-disabled'), + ); expect(exportToCsv).not.toHaveBeenCalled(); }); it('disables both export options when all datasets are empty', async () => { - const { getByRole, findByRole } = render( - , - ); + const { getByRole, findByRole } = render(); userEvent.click(getByRole('button', { name: 'Export CSV' })); @@ -103,9 +149,9 @@ describe('ExportCsvButton', () => { }); it('closes the menu after an export is selected', async () => { - const { getByRole, findByRole, queryByRole } = render(); + const { findByRole, queryByRole } = render(); - userEvent.click(getByRole('button', { name: 'Export CSV' })); + userEvent.click(await findByRole('button', { name: 'Export CSV' })); userEvent.click(await findByRole('menuitem', { name: 'Income Report' })); await waitFor(() => diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx index 445f04088a..36f96aa868 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx @@ -4,20 +4,14 @@ import { useLocale } from 'src/hooks/useLocale'; import { CsvExportMenu } from '../../Shared/CsvExportMenu/CsvExportMenu'; import { exportToCsv } from '../CustomExport/CustomExport'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; -import { AllData } from '../mockData'; +import { useReport } from '../ReportContext/ReportContext'; -interface ExportCsvButtonProps { - data: AllData; - months: string[]; -} - -export const ExportCsvButton: React.FC = ({ - data, - months, -}) => { +export const ExportCsvButton: React.FC = () => { const { t } = useTranslation(); const locale = useLocale(); + const { allData: data, monthLabels } = useReport(); + return ( = ({ label: t('Income Report'), disabled: !data.income.length, onClick: () => - exportToCsv(data.income, ReportTypeEnum.Income, months, locale), + exportToCsv( + data.income, + ReportTypeEnum.Income, + monthLabels, + locale, + ), }, { label: t('Expenses Report'), disabled: !data.expenses.length, onClick: () => - exportToCsv(data.expenses, ReportTypeEnum.Expenses, months, locale), + exportToCsv( + data.expenses, + ReportTypeEnum.Expenses, + monthLabels, + locale, + ), }, ]} /> diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx index 48ac6eb845..b7f3e4bb02 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx @@ -4,7 +4,6 @@ import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { DateTime } from 'luxon'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { StaffAccountQuery } from 'src/components/Shared/StaffAccount/StaffAccount.generated'; import { @@ -15,6 +14,7 @@ import theme from 'src/theme'; import { ReportsStaffExpensesQuery } from '../StaffExpenseReport/GetStaffExpense.generated'; import { MPGAIncomeExpensesReport } from './MPGAIncomeExpensesReport'; import { MpgaTransactionsQuery } from './MPGATransactions.generated'; +import { ReportProvider } from './ReportContext/ReportContext'; const mutationSpy = jest.fn(); const onNavListToggle = jest.fn(); @@ -104,11 +104,13 @@ const TestComponent: React.FC = () => ( mocks={mockData} onCall={mutationSpy} > - + + + @@ -274,64 +276,6 @@ describe('MPGAIncomeExpensesReport', () => { }); describe('Date range filtering', () => { - it('queries the full selected year when a year is applied', async () => { - const { getByRole, findByRole } = render(); - - await findByRole('gridcell', { name: 'Benefits' }); - mutationSpy.mockClear(); - - userEvent.click(getByRole('button', { name: 'Report Settings' })); - - const lastCompletedYear = DateTime.now().year - 1; - userEvent.click( - await findByRole('combobox', { name: 'Select Date Range' }), - ); - userEvent.click(getByRole('option', { name: String(lastCompletedYear) })); - - const applyButton = await findByRole('button', { name: 'Apply Filters' }); - await waitFor(() => expect(applyButton).not.toBeDisabled()); - userEvent.click(applyButton); - - await waitFor( - () => - expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { - startMonth: `${lastCompletedYear}-01-01`, - endMonth: `${lastCompletedYear}-12-31`, - fundTypes: ['Primary'], - }), - { timeout: 5000 }, - ); - }, 15000); - - it('queries the current year up to today for Year to Date', async () => { - const { getByRole, findByRole } = render(); - - await findByRole('gridcell', { name: 'Benefits' }); - mutationSpy.mockClear(); - - userEvent.click(getByRole('button', { name: 'Report Settings' })); - - userEvent.click( - await findByRole('combobox', { name: 'Select Date Range' }), - ); - userEvent.click(getByRole('option', { name: 'Year to Date' })); - - const applyButton = await findByRole('button', { name: 'Apply Filters' }); - await waitFor(() => expect(applyButton).not.toBeDisabled()); - userEvent.click(applyButton); - - const now = DateTime.now(); - await waitFor( - () => - expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { - startMonth: `${now.year}-01-01`, - endMonth: now.toISODate(), - fundTypes: ['Primary'], - }), - { timeout: 5000 }, - ); - }, 15000); - it('shows the Clear button after a year is applied', async () => { const { getByRole, findByRole, queryByRole } = render(); @@ -354,40 +298,4 @@ describe('MPGAIncomeExpensesReport', () => { ).toBeInTheDocument(); }, 15000); }); - - describe('Date Range subtitle', () => { - it('shows default subtitle', async () => { - const { findAllByText } = render(); - - expect((await findAllByText('Last 12 Months')).length).toBeGreaterThan(0); - }, 15000); - - it('shows the month range when a specific year is applied', async () => { - const { getByRole, findByRole, findAllByText } = render( - , - ); - - await findByRole('gridcell', { name: 'Benefits' }); - - userEvent.click(getByRole('button', { name: 'Report Settings' })); - - const lastCompletedYear = DateTime.now().year - 1; - userEvent.click( - await findByRole('combobox', { name: 'Select Date Range' }), - ); - userEvent.click(getByRole('option', { name: String(lastCompletedYear) })); - - const applyButton = await findByRole('button', { name: 'Apply Filters' }); - await waitFor(() => expect(applyButton).not.toBeDisabled()); - userEvent.click(applyButton); - - expect( - await findAllByText( - `January ${lastCompletedYear} – December ${lastCompletedYear}`, - undefined, - { timeout: 5000 }, - ), - ).not.toHaveLength(0); - }, 15000); - }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index 74a5bb9cb9..125a21f5c1 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx @@ -8,17 +8,12 @@ import { SvgIcon, Typography, } from '@mui/material'; -import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { HeaderTypeEnum, MultiPageHeader, } from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; import { useStaffAccountQuery } from 'src/components/Shared/StaffAccount/StaffAccount.generated'; -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 { AccountInfoBox } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBox'; import { AccountInfoBoxSkeleton } from '../../HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton'; import { SettingsButtonGroup } from '../Shared/SettingsButtonGroup/SettingsButtonGroup'; @@ -35,10 +30,8 @@ import { import { PrintOnlyReport } from './DisplayModes/PrintOnlyReport'; import { ScreenOnlyReport } from './DisplayModes/ScreenOnlyReport'; import { ExportCsvButton } from './ExportCsvButton/ExportCsvButton'; -import { FundTypes, Funds } from './Helper/MPGAReportEnum'; -import { useMpgaTransactionsQuery } from './MPGATransactions.generated'; -import { TotalsProvider } from './TotalsContext/TotalsContext'; -import { AllData, DataFields } from './mockData'; +import { FundTypes } from './Helper/MPGAReportEnum'; +import { useReport } from './ReportContext/ReportContext'; import { PrintOnly, StyledHeaderBox } from './styledComponents'; interface MPGAIncomeExpensesReportProps { @@ -51,63 +44,9 @@ export const MPGAIncomeExpensesReport: React.FC< MPGAIncomeExpensesReportProps > = ({ title, isNavListOpen, onNavListToggle }) => { const { t } = useTranslation(); - const locale = useLocale(); - const currency = 'USD'; const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [filters, setFilters] = useState(null); - - const selectedYear = filters?.selectedYear ?? null; - const isYearToDate = - selectedYear === null && - filters?.selectedDateRange === DateRange.YearToDate; - - const effectiveYear = - selectedYear ?? (isYearToDate ? DateTime.now().year : null); - - const monthLabels = useGetLastTwelveMonths(locale, effectiveYear); - - const { startDate, endDate } = useMemo(() => { - const now = DateTime.now(); - // If a year is selected, show the full year - if (effectiveYear !== null) { - const yearStart = DateTime.fromObject({ year: effectiveYear }).startOf( - 'year', - ); - const yearEnd = yearStart.endOf('year'); - return { startDate: yearStart, endDate: yearEnd < now ? yearEnd : now }; - } - // If no year is selected, default to the last 12 months - return { - startDate: now.minus({ months: 11 }).startOf('month'), - endDate: now, - }; - }, [effectiveYear]); - - // If year to date filter is selected, get first month index in the future to gray out future months in the table - const firstFutureMonthIndex = isYearToDate ? DateTime.now().month : undefined; - - const subtitle = useMemo(() => { - if (selectedYear === null && !isYearToDate) { - return t('Last 12 Months'); - } - return t('{{startMonth}} – {{endMonth}}', { - startMonth: monthYearFormat( - startDate.month, - startDate.year, - locale, - true, - true, - ), - endMonth: monthYearFormat( - endDate.month, - endDate.year, - locale, - true, - true, - ), - }); - }, [selectedYear, isYearToDate, startDate, endDate, locale, t]); + const { filters, setFilters, startDate, endDate, subtitle } = useReport(); const defaultFilters: Filters = useMemo( () => ({ @@ -141,81 +80,6 @@ export const MPGAIncomeExpensesReport: React.FC< const { data: staffAccountData, error } = useStaffAccountQuery(); - const { data: reportData, loading } = useMpgaTransactionsQuery({ - variables: { - fundTypes: [FundTypes.Primary], - startMonth: startDate.toISODate(), - endMonth: endDate.toISODate(), - }, - }); - - const transformedData: Funds[] = useMemo( - () => - (reportData?.reportsStaffExpenses?.funds ?? []).map((fund) => ({ - ...fund, - categories: (fund.categories ?? []).map((category) => ({ - ...category, - category: category.category, - breakdownByMonth: category.breakdownByMonth.map((month) => ({ - ...month, - })), - subcategories: (category.subcategories ?? []).map((subcategory) => ({ - ...subcategory, - subCategory: subcategory.subCategory, - breakdownByMonth: subcategory.breakdownByMonth.map((month) => ({ - ...month, - transactions: (month.transactions ?? []).map((transaction) => ({ - transactedAt: transaction.transactedAt, - description: transaction.description ?? '', - amount: transaction.amount, - })), - })), - })), - })), - })), - [reportData], - ); - - const { incomeData, expenseData, incomeBreakdown, expenseBreakdown } = - useFilteredFunds(transformedData, filters?.categories ?? null, t); - - const allData: AllData = useMemo(() => { - if (!isYearToDate) { - return { - income: incomeData, - expenses: expenseData, - incomeBreakdown, - expenseBreakdown, - }; - } - - // Year to Date only has data through the current month so fill the remaining months - const addFutureData = (rows: DataFields[]): DataFields[] => - rows.map((row) => ({ - ...row, - monthly: monthLabels.map((_month, index) => - firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex - ? 0 - : (row.monthly?.[index] ?? 0), - ), - })); - - return { - income: addFutureData(incomeData), - expenses: addFutureData(expenseData), - incomeBreakdown, - expenseBreakdown, - }; - }, [ - incomeData, - expenseData, - incomeBreakdown, - expenseBreakdown, - isYearToDate, - monthLabels, - firstFutureMonthIndex, - ]); - return ( <> - + @@ -290,35 +154,10 @@ export const MPGAIncomeExpensesReport: React.FC< - - - + - - - + {isSettingsOpen && ( @@ -337,7 +176,7 @@ export const MPGAIncomeExpensesReport: React.FC< setFilters(hasActiveFilter && newFilters ? newFilters : null); setIsSettingsOpen(false); }} - hideDateRange + isMpgaReport /> )} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx new file mode 100644 index 0000000000..0462aae332 --- /dev/null +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +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 { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; +import theme from 'src/theme'; +import { FundTypes } from './Helper/MPGAReportEnum'; +import { MpgaTransactionsQuery } from './MPGATransactions.generated'; +import { ReportProvider } from './ReportContext/ReportContext'; + +const toBreakdown = (values: number[]): { month: string; total: number }[] => + values.map((total, index) => ({ + month: `2019-${String(index + 1).padStart(2, '0')}-01`, + total, + })); + +export const mpgaTransactionsMock: MpgaTransactionsQuery = { + reportsStaffExpenses: { + funds: [ + { + fundType: FundTypes.Primary, + total: 108856, + categories: [ + { + category: StaffExpenseCategoryEnum.Donation, + averagePerMonth: 9013, + total: 108856, + breakdownByMonth: toBreakdown([ + 6770, 6090, 5770, 7355, 8035, 6575, 7556, 8239, 9799, 9729, 13020, + 19215, + ]), + subcategories: [], + }, + { + category: StaffExpenseCategoryEnum.Transfer, + averagePerMonth: -17, + total: -200, + breakdownByMonth: toBreakdown([ + 0, 0, -200, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + subcategories: [], + }, + { + category: StaffExpenseCategoryEnum.MinistryReimbursement, + averagePerMonth: -177, + total: -2124, + breakdownByMonth: toBreakdown([ + 0, 0, 0, 0, 0, 0, -565, 0, -488, -253, -818, 0, + ]), + subcategories: [], + }, + { + category: StaffExpenseCategoryEnum.HealthcareReimbursement, + averagePerMonth: -161, + total: -1933, + breakdownByMonth: toBreakdown([ + 0, 0, 0, -976, -55, 0, 0, 0, -194, -708, 0, 0, + ]), + subcategories: [], + }, + { + category: StaffExpenseCategoryEnum.Benefits, + averagePerMonth: -200, + total: -2400, + breakdownByMonth: toBreakdown([ + -200, -200, -200, -200, -200, -200, -200, -200, -200, -200, -200, + -200, + ]), + subcategories: [], + }, + { + category: StaffExpenseCategoryEnum.Salary, + averagePerMonth: -2, + total: -26, + breakdownByMonth: toBreakdown([ + -26, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + subcategories: [], + }, + { + category: StaffExpenseCategoryEnum.Other, + averagePerMonth: -42, + total: -507, + breakdownByMonth: toBreakdown([ + -23, -23, -23, -45, -22, -22, -28, -24, -28, -29, -186, -55, + ]), + subcategories: [], + }, + { + category: StaffExpenseCategoryEnum.Assessment, + averagePerMonth: -1148, + total: -13779, + breakdownByMonth: toBreakdown([ + -812, -731, -692, -883, -964, -789, -907, -989, -1176, -1227, + -2237, -2372, + ]), + subcategories: [], + }, + ], + }, + ], + }, +}; + +interface MPGAIncomeExpensesReportTestWrapperProps { + onCall?: MockLinkCallHandler; + isEmpty?: boolean; + mocks?: MpgaTransactionsQuery; + children?: React.ReactNode; +} + +export const MPGAIncomeExpensesReportTestWrapper: React.FC< + MPGAIncomeExpensesReportTestWrapperProps +> = ({ onCall, isEmpty, mocks, children }) => { + const mpgaTransactions = + mocks ?? + (isEmpty ? { reportsStaffExpenses: { funds: [] } } : mpgaTransactionsMock); + + return ( + + + + mocks={{ MPGATransactions: mpgaTransactions }} + onCall={onCall} + > + {children} + + + + ); +}; diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx new file mode 100644 index 0000000000..1133059cd9 --- /dev/null +++ b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '__tests__/util/testingLibraryReactMock'; +import { DateRange } from '../../StaffExpenseReport/Helpers/StaffReportEnum'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; +import { useReport } from './ReportContext'; + +const mutationSpy = jest.fn(); +const lastCompletedYear = 2019; + +beforeEach(() => { + mutationSpy.mockClear(); +}); + +function FailedConsumer() { + const context = useReport(); + return
{JSON.stringify(context)}
; +} + +function TestConsumer() { + const { + incomeTotal, + expensesTotal, + ministryTotal, + healthcareTotal, + assessmentTotal, + benefitsTotal, + salaryTotal, + otherTotal, + } = useReport(); + + return ( +
+
{incomeTotal}
+
{expensesTotal}
+
{ministryTotal}
+
{healthcareTotal}
+
{assessmentTotal}
+
{benefitsTotal}
+
{salaryTotal}
+
{otherTotal}
+
+ ); +} + +function FilterConsumer() { + const { setFilters, subtitle, firstFutureMonthIndex, incomeTotal, allData } = + useReport(); + + return ( +
+
{subtitle}
+
{incomeTotal}
+
+ {firstFutureMonthIndex ?? 'none'} +
+
+ {(allData.income[0]?.monthly ?? []).join(',')} +
+ + +
+ ); +} + +describe('ReportContext', () => { + it('throws an error when used outside of the provider', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow( + /Could not find ReportContext/i, + ); + spy.mockRestore(); + }); + + it('derives the correct totals from the MPGATransactions query', async () => { + const { getByTestId } = render( + + + , + ); + + await waitFor(() => + expect(getByTestId('income')).toHaveTextContent('108856'), + ); + expect(getByTestId('expenses')).toHaveTextContent('20969'); + expect(getByTestId('ministry')).toHaveTextContent('2124'); + expect(getByTestId('healthcare')).toHaveTextContent('1933'); + expect(getByTestId('assessment')).toHaveTextContent('13779'); + expect(getByTestId('benefits')).toHaveTextContent('2400'); + expect(getByTestId('salary')).toHaveTextContent('26'); + expect(getByTestId('other')).toHaveTextContent('707'); + }); + + it('renders children correctly', () => { + const { getByText } = render( + +
Test Children
+
, + ); + expect(getByText('Test Children')).toBeInTheDocument(); + }); + + describe('date range', () => { + it('queries the last 12 months by default with the default subtitle', async () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('subtitle')).toHaveTextContent('Last 12 Months'); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: '2019-02-01', + endMonth: '2020-01-01', + fundTypes: ['Primary'], + }), + ); + }); + + it('queries the full selected year and shows the month range', async () => { + const { getByTestId, getByRole } = render( + + + , + ); + + userEvent.click(getByRole('button', { name: 'Pick Year' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: `${lastCompletedYear}-01-01`, + endMonth: `${lastCompletedYear}-12-31`, + fundTypes: ['Primary'], + }), + ); + expect(getByTestId('subtitle')).toHaveTextContent( + `January ${lastCompletedYear} – December ${lastCompletedYear}`, + ); + }); + + it('queries the current year up to today for Year to Date and zero-fills future months', async () => { + const { getByTestId, getByRole } = render( + + + , + ); + + await waitFor(() => + expect(getByTestId('income')).toHaveTextContent('108856'), + ); + + userEvent.click(getByRole('button', { name: 'Year to Date' })); + + // Year to Date queries Jan 1 of the current year through the pinned + // "now" (Jan 1, 2020), so only January has data. + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: '2020-01-01', + endMonth: '2020-01-01', + fundTypes: ['Primary'], + }), + ); + + // January is the current month, so February onward (index >= 1) are + // future months that get zero-filled. + await waitFor(() => + expect(getByTestId('firstFutureMonthIndex')).toHaveTextContent('1'), + ); + await waitFor(() => + expect(getByTestId('incomeMonthly')).toHaveTextContent( + '6770,0,0,0,0,0,0,0,0,0,0,0', + ), + ); + }); + }); +}); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx new file mode 100644 index 0000000000..aed6082dd6 --- /dev/null +++ b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx @@ -0,0 +1,266 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +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 { Filters } from '../../Shared/SettingsDialog/SettingsDialog'; +import { DateRange } from '../../StaffExpenseReport/Helpers/StaffReportEnum'; +import { FundTypes, Funds } from '../Helper/MPGAReportEnum'; +import { useMpgaTransactionsQuery } from '../MPGATransactions.generated'; +import { AllData, DataFields } from '../mockData'; + +export type ReportContextType = { + currency: string; + + filters: Filters | null; + setFilters: React.Dispatch>; + firstFutureMonthIndex: number | undefined; + /** Whether the month column at `index` is in the future (grayed out). */ + isFutureMonth: (index: number) => boolean; + monthLabels: string[]; + + allData: AllData; + dataLoading: boolean; + startDate: DateTime; + endDate: DateTime; + + subtitle: string; + + /** Income and expenses totals */ + incomeTotal: number; + expensesTotal: number; + ministryTotal: number; + healthcareTotal: number; + assessmentTotal: number; + benefitsTotal: number; + salaryTotal: number; + otherTotal: number; +}; + +export const ReportContext = React.createContext( + null, +); + +export const useReport = (): ReportContextType => { + const context = React.useContext(ReportContext); + if (context === null) { + throw new Error( + 'Could not find ReportContext. Make sure that your component is inside .', + ); + } + return context; +}; + +interface ReportContextProps { + children?: React.ReactNode; +} + +const sum = (rows?: DataFields[]): number => { + return rows?.reduce((acc, item) => acc + item.total, 0) || 0; +}; + +export const ReportProvider: React.FC = ({ children }) => { + const { t } = useTranslation(); + const locale = useLocale(); + const currency = 'USD'; + + const [filters, setFilters] = useState(null); + + const now = useMemo(() => DateTime.now(), []); + + const selectedYear = filters?.selectedYear ?? null; + const isYearToDate = + selectedYear === null && + filters?.selectedDateRange === DateRange.YearToDate; + + const effectiveYear = selectedYear ?? (isYearToDate ? now.year : null); + + // If year to date filter is selected, get first month index in the future to gray out future months in the table + const firstFutureMonthIndex = isYearToDate ? now.month : undefined; + + const isFutureMonth = useCallback( + (index: number) => + firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex, + [firstFutureMonthIndex], + ); + + const { startDate, endDate } = useMemo(() => { + // If a year filter is selected, show the full year + if (effectiveYear !== null) { + const yearStart = DateTime.fromObject({ year: effectiveYear }).startOf( + 'year', + ); + const yearEnd = yearStart.endOf('year'); + return { startDate: yearStart, endDate: yearEnd < now ? yearEnd : now }; + } + // If no year is selected, default to the last 12 months + return { + startDate: now.minus({ months: 11 }).startOf('month'), + endDate: now, + }; + }, [effectiveYear, now]); + + const monthLabels = useGetLastTwelveMonths(locale, effectiveYear); + + const subtitle = useMemo(() => { + if (selectedYear === null && !isYearToDate) { + return t('Last 12 Months'); + } + return t('{{startMonth}} – {{endMonth}}', { + startMonth: monthYearFormat( + startDate.month, + startDate.year, + locale, + true, + true, + ), + endMonth: monthYearFormat( + endDate.month, + endDate.year, + locale, + true, + true, + ), + }); + }, [selectedYear, isYearToDate, startDate, endDate, locale, t]); + + const { data: reportData, loading } = useMpgaTransactionsQuery({ + variables: { + fundTypes: [FundTypes.Primary], + startMonth: startDate.toISODate(), + endMonth: endDate.toISODate(), + }, + }); + + const transformedData: Funds[] = useMemo( + () => + (reportData?.reportsStaffExpenses?.funds ?? []).map((fund) => ({ + ...fund, + categories: (fund.categories ?? []).map((category) => ({ + ...category, + category: category.category, + breakdownByMonth: category.breakdownByMonth.map((month) => ({ + ...month, + })), + subcategories: (category.subcategories ?? []).map((subcategory) => ({ + ...subcategory, + subCategory: subcategory.subCategory, + breakdownByMonth: subcategory.breakdownByMonth.map((month) => ({ + ...month, + transactions: (month.transactions ?? []).map((transaction) => ({ + transactedAt: transaction.transactedAt, + description: transaction.description ?? '', + amount: transaction.amount, + })), + })), + })), + })), + })), + [reportData], + ); + + const { incomeData, expenseData, incomeBreakdown, expenseBreakdown } = + useFilteredFunds(transformedData, filters?.categories ?? null, t); + + const allData: AllData = useMemo(() => { + if (!isYearToDate) { + return { + income: incomeData, + expenses: expenseData, + incomeBreakdown, + expenseBreakdown, + }; + } + + // Year to Date only has data through the current month, so fill the + // remaining (future) months with 0. + const addFutureData = (rows: DataFields[]): DataFields[] => + rows.map((row) => ({ + ...row, + monthly: monthLabels.map((_month, index) => + isFutureMonth(index) ? 0 : (row.monthly?.[index] ?? 0), + ), + })); + + return { + income: addFutureData(incomeData), + expenses: addFutureData(expenseData), + incomeBreakdown, + expenseBreakdown, + }; + }, [ + incomeData, + expenseData, + incomeBreakdown, + expenseBreakdown, + isYearToDate, + monthLabels, + isFutureMonth, + ]); + + const { + ministryTotal, + healthcareTotal, + assessmentTotal, + benefitsTotal, + salaryTotal, + otherTotal, + expensesTotal, + } = useExpenseCategories(allData.expenses); + + const incomeTotal = useMemo(() => sum(allData.income), [allData.income]); + + const contextValue: ReportContextType = useMemo( + () => ({ + currency, + filters, + setFilters, + firstFutureMonthIndex, + monthLabels, + isFutureMonth, + allData, + dataLoading: loading, + startDate, + endDate, + subtitle, + incomeTotal, + expensesTotal, + ministryTotal, + healthcareTotal, + assessmentTotal, + benefitsTotal, + salaryTotal, + otherTotal, + }), + [ + currency, + filters, + setFilters, + firstFutureMonthIndex, + monthLabels, + isFutureMonth, + allData, + loading, + startDate, + endDate, + subtitle, + incomeTotal, + expensesTotal, + ministryTotal, + healthcareTotal, + assessmentTotal, + benefitsTotal, + salaryTotal, + otherTotal, + ], + ); + + return ( + + {children} + + ); +}; diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.test.tsx index 861e8210da..22d45fcbb9 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.test.tsx @@ -1,12 +1,7 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { render } from '@testing-library/react'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import theme from 'src/theme'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; -import { TotalsProvider } from '../TotalsContext/TotalsContext'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; import { mockData, months } from '../mockData'; import { PrintTables } from './PrintTables'; @@ -14,35 +9,21 @@ const mutationSpy = jest.fn(); const title = 'Income'; -const emptyData = { - accountListId: '12345', - accountName: 'Test Account', - income: [], - expenses: [], -}; - const TestComponent: React.FC = () => ( - - - - - - - - - + + + ); describe('PrintTables', () => { - it('should renders with the correct data', () => { - const { getByRole } = render(); + it('should render with the correct data', async () => { + const { findByRole, getByRole } = render(); - expect(getByRole('heading', { name: title })).toBeInTheDocument(); + expect(await findByRole('heading', { name: title })).toBeInTheDocument(); expect(getByRole('table')).toBeInTheDocument(); expect( @@ -55,94 +36,81 @@ describe('PrintTables', () => { expect(getByRole('cell', { name: '108,156' })).toBeInTheDocument(); }); - it('should calculate and display totals correctly', () => { - const { getByRole } = render(); + it('should calculate and display totals correctly', async () => { + const { findByRole, getByRole } = render(); - expect(getByRole('cell', { name: '108,856' })).toBeInTheDocument(); + expect(await findByRole('cell', { name: '108,856' })).toBeInTheDocument(); expect(getByRole('cell', { name: '9,071' })).toBeInTheDocument(); }); - it('renders months in column headers for all months', () => { - const { getByRole } = render(); + it('renders months in column headers for all months', async () => { + const { findByRole, getByRole } = render(); + + await findByRole('table'); months.forEach((m) => { const month = m.split(' ')[0]; expect(getByRole('columnheader', { name: month })).toBeInTheDocument(); }); }); - it('should show "-" when there is no data for a month', () => { - const { getByRole } = render(); + it('should show "-" when there is no data for a month', async () => { + const { findByRole } = render(); - const row = getByRole('row', { name: /Fr Andre/i }); + const row = await findByRole('row', { name: /Fr Andre/i }); const dashes = Array.from(row.querySelectorAll('td')).filter( (td) => td.textContent?.trim() === '-', ); expect(dashes.length).toBe(5); }); - it('should show a loading spinner while data is being fetched', () => { - const { getByRole } = render( - - - - - - - - - , + it('should show a loading spinner while data is being fetched', async () => { + const { findByRole } = render( + + + , ); - expect(getByRole('progressbar')).toBeInTheDocument(); + expect(await findByRole('progressbar')).toBeInTheDocument(); }); - it('should display the correct years in the table', () => { - const { getByRole } = render(); + it('should display the correct years in the table', async () => { + const { findByRole, getByRole } = render(); - expect(getByRole('columnheader', { name: '2024' })).toBeInTheDocument(); - expect(getByRole('columnheader', { name: '2025' })).toBeInTheDocument(); + expect( + await findByRole('columnheader', { name: '2019' }), + ).toBeInTheDocument(); + expect(getByRole('columnheader', { name: '2020' })).toBeInTheDocument(); }); - it('should display empty message when no data is available', () => { - const { getByText } = render( - - - - - - - - - , + it('should display empty message when no data is available', async () => { + const { findByText } = render( + + + , ); - expect(getByText(/no income data available/i)).toBeInTheDocument(); + expect(await findByText(/no income data available/i)).toBeInTheDocument(); }); - it('should span the correct number of months per year', () => { - const { getAllByRole } = render(); + it('should span the correct number of months per year', async () => { + const { getAllByRole, findByRole } = render(); + await findByRole('table'); const headerRow = getAllByRole('row')[0]; const yearCells = headerRow.querySelectorAll('th[scope="col"] , td'); - const year2024 = Array.from(yearCells).find((cell) => - cell.textContent?.includes('2024'), + const year2019 = Array.from(yearCells).find((cell) => + cell.textContent?.includes('2019'), ) as HTMLTableCellElement; - const year2025 = Array.from(yearCells).find((cell) => - cell.textContent?.includes('2025'), + const year2020 = Array.from(yearCells).find((cell) => + cell.textContent?.includes('2020'), ) as HTMLTableCellElement; - expect(year2024?.colSpan).toBe(9); - expect(year2025?.colSpan).toBe(3); + expect(year2019?.colSpan).toBe(11); + expect(year2020?.colSpan).toBe(1); }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx index cd6730b9fe..5750bc5c99 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx @@ -15,7 +15,7 @@ import { zeroAmountFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; import { LoadingBox, LoadingIndicator } from '../../styledComponents'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; -import { useTotals } from '../TotalsContext/TotalsContext'; +import { useReport } from '../ReportContext/ReportContext'; import { DataFields } from '../mockData'; import { StyledRow, StyledTypography } from '../styledComponents'; @@ -23,27 +23,28 @@ export interface PrintTablesProps { type: ReportTypeEnum; data?: DataFields[]; title: string; - months: string[]; - firstFutureMonthIndex?: number; } export const PrintTables: React.FC = ({ title, - months, data, type, - firstFutureMonthIndex, }) => { const { t } = useTranslation(); const locale = useLocale(); - const { incomeTotal, expensesTotal, dataLoading } = useTotals(); + const { + incomeTotal, + expensesTotal, + isFutureMonth, + dataLoading, + monthLabels: months, + firstFutureMonthIndex, + } = useReport(); const overallTotal = type === ReportTypeEnum.Income ? incomeTotal : expensesTotal; const grayColor = theme.palette.text.disabled; - const isFutureMonth = (index: number) => - firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex; const futureCellSx = { backgroundColor: theme.palette.action.hover, WebkitPrintColorAdjust: 'exact', diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx index 7cc06d194c..63651ae61d 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx @@ -1,58 +1,40 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import theme from 'src/theme'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; -import { TotalsProvider } from '../TotalsContext/TotalsContext'; +import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; import { mockData, months } from '../mockData'; import { TableCard } from './TableCard'; const mutationSpy = jest.fn(); const title = 'Income'; -const subtitle = 'Last 12 Months'; const data = { income: mockData.income, expenses: [], }; -const emptyData = { - income: [], - expenses: [], -}; - const cellText = (element: HTMLElement) => element.textContent; const TestComponent: React.FC = () => ( - - - - - Empty Table} - title={title} - subtitle={subtitle} - months={months} - /> - - - - + + Empty Table} + title={title} + /> + ); describe('TableCard', () => { - it('should render with the correct data', () => { - const { getAllByRole, getByText } = render(); + it('should render with the correct data', async () => { + const { getAllByRole, findByText, getByText } = render(); - expect(getByText(title)).toBeInTheDocument(); + expect(await findByText(title)).toBeInTheDocument(); expect(getByText(/last 12 months/i)).toBeInTheDocument(); expect(getAllByRole('columnheader').map(cellText)).toEqual( @@ -63,17 +45,19 @@ describe('TableCard', () => { ); }); - it('should calculate and display totals correctly', () => { - const { getAllByRole } = render(); + it('should calculate and display totals correctly', async () => { + const { getAllByRole, findByText } = render(); + await findByText(title); expect(getAllByRole('gridcell').map(cellText)).toEqual( expect.arrayContaining(['108,856', '9,071']), ); }); - it('renders months in column headers for all months', () => { - const { getAllByRole } = render(); + it('renders months in column headers for all months', async () => { + const { getAllByRole, findByText } = render(); + await findByText(title); const headerNames = getAllByRole('columnheader').map(cellText); months.forEach((month) => { @@ -83,42 +67,35 @@ describe('TableCard', () => { it('should show a loading spinner while data is being fetched', () => { const { getByRole } = render( - - - - - Empty Table} - title={title} - subtitle={subtitle} - months={months} - /> - - - - , + + Empty Table} + title={title} + /> + , ); expect(getByRole('progressbar')).toBeInTheDocument(); }); - it('should display the correct years in the table', () => { - const { getAllByRole } = render(); + it('should display the correct years in the table', async () => { + const { getAllByRole, findByText } = render(); + await findByText(title); expect(getAllByRole('columnheader').map(cellText)).toEqual( - expect.arrayContaining(['2024', '2025']), + expect.arrayContaining(['2019', '2020']), ); }); - it('should apply the correct color to each group header', () => { - const { getByText } = render(); + it('should apply the correct color to each group header', async () => { + const { getByText, findByText } = render(); - expect(getByText('2024')).toHaveStyle({ + expect(await findByText('2019')).toHaveStyle({ color: theme.palette.primary.main, }); - expect(getByText('2025')).toHaveStyle({ + expect(getByText('2020')).toHaveStyle({ color: theme.palette.chartOrange.main, }); expect(getByText('Summary')).toHaveStyle({ @@ -126,41 +103,33 @@ describe('TableCard', () => { }); }); - it('should display empty placeholder when no data is available', () => { - const { getByText } = render( - - - - - Empty Table} - title={title} - subtitle={subtitle} - months={months} - /> - - - - , + it('should display empty placeholder when no data is available', async () => { + const { findByText } = render( + + Empty Table} + title={title} + /> + , ); - expect(getByText('Empty Table')).toBeInTheDocument(); + expect(await findByText('Empty Table')).toBeInTheDocument(); }); describe('breakdown modal', () => { - it('shows the icon only on rows that have breakdown data', () => { - const { getAllByRole } = render(); + it('shows the icon only on rows that have breakdown data', async () => { + const { findAllByRole } = render(); - const icons = getAllByRole('button', { name: 'View breakdown' }); + const icons = await findAllByRole('button', { name: 'View breakdown' }); expect(icons).toHaveLength(1); }); it('opens the modal for the clicked category', async () => { - const { getByRole, findByRole } = render(); + const { findByRole } = render(); - userEvent.click(getByRole('button', { name: 'View breakdown' })); + userEvent.click(await findByRole('button', { name: 'View breakdown' })); const dialog = await findByRole('dialog'); expect( @@ -169,9 +138,9 @@ describe('TableCard', () => { }); it('renders an accordion per subcategory with the overall total', async () => { - const { getByRole, findByRole } = render(); + const { findByRole } = render(); - userEvent.click(getByRole('button', { name: 'View breakdown' })); + userEvent.click(await findByRole('button', { name: 'View breakdown' })); const dialog = await findByRole('dialog'); expect(within(dialog).getByText('Donation')).toBeInTheDocument(); @@ -185,9 +154,9 @@ describe('TableCard', () => { }); it('closes the modal', async () => { - const { getByRole, findByRole, queryByRole } = render(); + const { findByRole, queryByRole } = render(); - userEvent.click(getByRole('button', { name: 'View breakdown' })); + userEvent.click(await findByRole('button', { name: 'View breakdown' })); const dialog = await findByRole('dialog'); userEvent.click(within(dialog).getByRole('button', { name: 'Close' })); @@ -199,8 +168,9 @@ describe('TableCard', () => { }); it('updates the sort order', async () => { - const { getAllByRole } = render(); + const { getAllByRole, findByText } = render(); + await findByText(title); const aprilHeader = getAllByRole('columnheader').find( (header) => header.getAttribute('data-field') === 'month0', ) as HTMLElement; @@ -208,7 +178,7 @@ describe('TableCard', () => { within(aprilHeader).getByTestId('ArrowDownwardIcon'), ).toBeInTheDocument(); - await userEvent.click(aprilHeader); + userEvent.click(aprilHeader); await waitFor(() => { const aprCells = getAllByRole('gridcell').filter( @@ -218,7 +188,7 @@ describe('TableCard', () => { expect(values).toEqual(['6,770', '100', '-']); }); - await userEvent.click(aprilHeader); + userEvent.click(aprilHeader); await waitFor(() => { const aprCells = getAllByRole('gridcell').filter( (cell) => cell.getAttribute('data-field') === 'month0', diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx index cb203e8a52..672764d272 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx @@ -17,7 +17,7 @@ import { CardSkeleton } from '../Card/CardSkeleton'; import { CustomToolbar } from '../CustomToolbar/CustomToolbar'; import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; import { populateCardTableRows } from '../Helper/createRows'; -import { useTotals } from '../TotalsContext/TotalsContext'; +import { useReport } from '../ReportContext/ReportContext'; import { DataFields, TransactionBreakdown } from '../mockData'; import { StyledGrid } from '../styledComponents'; import { TotalRow } from './TotalRow'; @@ -34,10 +34,6 @@ export interface TableCardProps { >; emptyPlaceholder: React.ReactElement; title: string; - subtitle: string; - months: string[]; - /** Month column index from which columns are grayed as future. Year to date filter only */ - firstFutureMonthIndex?: number; } // Visual styling for the grouped-column headers, matching the report's table @@ -97,14 +93,18 @@ export const TableCard: React.FC = ({ data, breakdownData = {}, title, - subtitle, - months, - firstFutureMonthIndex, emptyPlaceholder, }) => { const { t } = useTranslation(); const locale = useLocale(); - const { incomeTotal, expensesTotal, dataLoading } = useTotals(); + const { + monthLabels: months, + incomeTotal, + expensesTotal, + dataLoading, + firstFutureMonthIndex, + isFutureMonth, + } = useReport(); const [openBreakdownModal, setOpenBreakdownModal] = useState(null); @@ -140,15 +140,15 @@ export const TableCard: React.FC = ({ const columns = useMemo[]>(() => { const monthColumns: GridColDef[] = months.map( (month, index) => { - const isFutureMonth = - firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex; return { field: `month${index}`, headerName: month.split(' ')[0], width: monthWidth, type: 'number', - headerClassName: isFutureMonth ? 'future-month-header' : undefined, - cellClassName: isFutureMonth ? 'future-month' : undefined, + headerClassName: isFutureMonth(index) + ? 'future-month-header' + : undefined, + cellClassName: isFutureMonth(index) ? 'future-month' : undefined, valueGetter: (_value, row) => { const v = row.monthly?.[index]; return typeof v === 'number' ? v : null; @@ -268,7 +268,6 @@ export const TableCard: React.FC = ({ <> @@ -297,11 +296,7 @@ export const TableCard: React.FC = ({ disableColumnMenu /> - + @@ -315,7 +310,7 @@ export const TableCard: React.FC = ({ )} ) : ( - + { - it('renders correctly', async () => { + it('renders correctly', () => { const { getByRole, getByText } = render( - - - - - - - , + + + , ); expect(getByRole('grid')).toBeInTheDocument(); @@ -33,15 +24,11 @@ describe('TotalRow', () => { expect(getByRole('gridcell', { name: '108,856' })).toBeInTheDocument(); }); - it('should display - when no data available', async () => { + it('should display - when no data available', () => { const { getByRole } = render( - - - - - - - , + + + , ); const grid = getByRole('grid'); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx index 0a415bff49..f02fd48832 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx @@ -6,6 +6,7 @@ import { useLocale } from 'src/hooks/useLocale'; import { amountFormat, zeroAmountFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; import { populateTotalRows } from '../Helper/createRows'; +import { useReport } from '../ReportContext/ReportContext'; import { DataFields } from '../mockData'; import { StyledTotalRow } from '../styledComponents'; import { descriptionWidth, monthWidth, summaryWidth } from './TableCard'; @@ -23,17 +24,14 @@ interface Row { interface TotalRowProps { data: DataFields[]; overallTotal: number | undefined; - firstFutureMonthIndex?: number; } -export const TotalRow: React.FC = ({ - data, - overallTotal, - firstFutureMonthIndex, -}) => { +export const TotalRow: React.FC = ({ data, overallTotal }) => { const { t } = useTranslation(); const locale = useLocale(); + const { firstFutureMonthIndex, isFutureMonth } = useReport(); + const monthlyTotals = data[0]?.monthly ? data[0].monthly.map((_, monthIndex) => { const total = data.reduce( @@ -64,10 +62,7 @@ export const TotalRow: React.FC = ({ filterable: false, disableColumnMenu: true, align: 'right', - cellClassName: - firstFutureMonthIndex !== undefined && index >= firstFutureMonthIndex - ? 'future-month' - : undefined, + cellClassName: isFutureMonth(index) ? 'future-month' : undefined, renderCell: ({ row }) => { const formattedValue = zeroAmountFormat( row.monthlyValue[index], diff --git a/src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.test.tsx deleted file mode 100644 index 5baad80fba..0000000000 --- a/src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { ThemeProvider } from '@emotion/react'; -import { LocalizationProvider } from '@mui/x-date-pickers'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { render } from '__tests__/util/testingLibraryReactMock'; -import theme from 'src/theme'; -import { mockData } from '../mockData'; -import { TotalsProvider, useTotals } from './TotalsContext'; - -const mutationSpy = jest.fn(); - -const TestComponent: React.FC = () => ( - - - - - {
Test Children
} -
-
-
-
-); - -function FailedConsumer() { - const context = useTotals(); - return
{JSON.stringify(context)}
; -} - -function TestConsumer() { - const { - incomeTotal, - expensesTotal, - ministryTotal, - healthcareTotal, - assessmentTotal, - benefitsTotal, - salaryTotal, - otherTotal, - } = useTotals(); - - return ( -
-
{incomeTotal}
-
{expensesTotal}
-
{ministryTotal}
-
{healthcareTotal}
-
{assessmentTotal}
-
{benefitsTotal}
-
{salaryTotal}
-
{otherTotal}
-
- ); -} - -describe('TotalsContext', () => { - it('throws an error when used outside of the provider', () => { - const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - expect(() => render()).toThrow( - /Could not find TotalsContext/i, - ); - spy.mockRestore(); - }); - - it('provides the correct context values', () => { - const { getByTestId } = render( - - - - - - - - - , - ); - - expect(getByTestId('income')).toHaveTextContent('108856'); - expect(getByTestId('expenses')).toHaveTextContent('20969'); - - expect(getByTestId('ministry')).toHaveTextContent('2124'); - expect(getByTestId('healthcare')).toHaveTextContent('1933'); - expect(getByTestId('assessment')).toHaveTextContent('13779'); - expect(getByTestId('benefits')).toHaveTextContent('2400'); - expect(getByTestId('salary')).toHaveTextContent('26'); - expect(getByTestId('other')).toHaveTextContent('707'); - }); - - it('renders children correctly', () => { - const { getByText } = render(); - expect(getByText('Test Children')).toBeInTheDocument(); - }); -}); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.tsx b/src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.tsx deleted file mode 100644 index 184b2f5e9a..0000000000 --- a/src/components/Reports/MPGAIncomeExpensesReport/TotalsContext/TotalsContext.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useMemo } from 'react'; -import { DateTime } from 'luxon'; -import { useExpenseCategories } from 'src/hooks/useExpenseCategories'; -import { AllData, DataFields } from '../mockData'; - -export type TotalsType = { - incomeTotal: number; - expensesTotal: number; - ministryTotal: number; - healthcareTotal: number; - assessmentTotal: number; - benefitsTotal: number; - salaryTotal: number; - otherTotal: number; - dataLoading: boolean; - startDate: DateTime; - endDate: DateTime; -}; - -export const TotalsContext = React.createContext(null); - -export const useTotals = (): TotalsType => { - const context = React.useContext(TotalsContext); - if (context === null) { - throw new Error( - 'Could not find TotalsContext. Make sure that your component is inside .', - ); - } - return context; -}; - -interface TotalsContextProps { - data: AllData; - loading?: boolean; - startDate?: DateTime; - endDate?: DateTime; - children?: React.ReactNode; -} - -const sum = (rows?: DataFields[]): number => { - return rows?.reduce((acc, item) => acc + item.total, 0) || 0; -}; - -export const TotalsProvider: React.FC = ({ - children, - data, - loading = false, - startDate, - endDate, -}) => { - const { - ministryTotal, - healthcareTotal, - assessmentTotal, - benefitsTotal, - salaryTotal, - otherTotal, - expensesTotal, - } = useExpenseCategories(data.expenses); - - const incomeTotal = useMemo(() => sum(data.income), [data.income]); - - const { reportStartDate, reportEndDate } = useMemo(() => { - const now = DateTime.now(); - return { - reportStartDate: startDate ?? now.minus({ months: 11 }).startOf('month'), - reportEndDate: endDate ?? now, - }; - }, [startDate, endDate]); - - const contextValue: TotalsType = useMemo( - () => ({ - incomeTotal, - expensesTotal, - ministryTotal, - healthcareTotal, - assessmentTotal, - benefitsTotal, - salaryTotal, - otherTotal, - dataLoading: loading, - startDate: reportStartDate, - endDate: reportEndDate, - }), - [ - incomeTotal, - expensesTotal, - ministryTotal, - healthcareTotal, - assessmentTotal, - benefitsTotal, - salaryTotal, - otherTotal, - loading, - reportStartDate, - reportEndDate, - ], - ); - - return ( - - {children} - - ); -}; diff --git a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx index bbe3795251..206f7276ef 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx @@ -26,7 +26,7 @@ const TestComponent: React.FC< selectedFundType, time, onCallMock, - hideDateRange = false, + isMpgaReport = false, }) => ( @@ -93,7 +93,7 @@ const TestComponent: React.FC< selectedFilters={selectedFilters} selectedFundType={selectedFundType} time={time} - hideDateRange={hideDateRange} + isMpgaReport={isMpgaReport} /> @@ -515,7 +515,7 @@ describe('SettingsDialog', () => { isOpen: true, onClose: mutationSpy, selectedFundType: 'Primary', - hideDateRange: true, + isMpgaReport: true, }; it('displays the simplified date range dropdown and category checkboxes without custom date fields', async () => { diff --git a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx index b08aa06c99..4e901784b6 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx @@ -34,7 +34,7 @@ export interface SettingsDialogProps { selectedFundType: string | null; onClose: (filters?: Filters) => void; time?: DateTime; - hideDateRange?: boolean; + isMpgaReport?: boolean; } export interface Filters { @@ -158,7 +158,7 @@ export const SettingsDialog: React.FC = ({ selectedFilters, selectedFundType, time, - hideDateRange, + isMpgaReport, }) => { const { t } = useTranslation(); const [previewFilters, setPreviewFilters] = useState(null); @@ -311,7 +311,7 @@ export const SettingsDialog: React.FC = ({ }} > {t('None')} - {!hideDateRange && [ + {!isMpgaReport && [ {t('Week to Date')} , @@ -322,7 +322,7 @@ export const SettingsDialog: React.FC = ({ {t('Year to Date')} - {hideDateRange && + {isMpgaReport && completedYears.map((year) => ( {year} @@ -330,7 +330,7 @@ export const SettingsDialog: React.FC = ({ ))} - {!hideDateRange && ( + {!isMpgaReport && ( <> {t('Or enter a custom date range:')} From 13a4033be5d665b05fcab6c59429df59b13a1559 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Wed, 22 Jul 2026 13:19:07 -0400 Subject: [PATCH 07/11] Disallow clear button from appear when category selection changes --- .../MPGAIncomeExpensesReport.test.tsx | 49 ++++--------------- .../MPGAIncomeExpensesReport.tsx | 3 +- 2 files changed, 10 insertions(+), 42 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx index b7f3e4bb02..44bdc1c159 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx @@ -198,8 +198,10 @@ describe('MPGAIncomeExpensesReport', () => { ).toHaveLength(1); }, 15000); - it('does not show the Clear button until a category filter is applied', async () => { - const { getByRole, findByRole, queryByRole } = render(); + it('does not show the Clear button when only a category filter is applied', async () => { + const { getByRole, findByRole, findAllByText, queryByRole } = render( + , + ); expect(await findByRole('gridcell', { name: 'Benefits' })).toBeVisible(); expect(queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); @@ -216,8 +218,11 @@ describe('MPGAIncomeExpensesReport', () => { userEvent.click(applyButton); expect( - await findByRole('button', { name: 'Clear' }, { timeout: 5000 }), - ).toBeInTheDocument(); + await findAllByText('Benefits - Workers Compensation', undefined, { + timeout: 5000, + }), + ).toHaveLength(1); + expect(queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); }, 15000); it('does not show the Clear button when the dialog is cancelled', async () => { @@ -237,42 +242,6 @@ describe('MPGAIncomeExpensesReport', () => { ); expect(queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); }, 15000); - - it('resets category filtering when Clear is clicked', async () => { - const { getByRole, findByRole, findAllByText, queryByText } = render( - , - ); - - expect(await findByRole('gridcell', { name: 'Benefits' })).toBeVisible(); - - userEvent.click(getByRole('button', { name: 'Report Settings' })); - - const benefitsCheckbox = await findByRole('checkbox', { - name: 'Benefits', - }); - userEvent.click(benefitsCheckbox); - - const applyButton = await findByRole('button', { name: 'Apply Filters' }); - await waitFor(() => expect(applyButton).not.toBeDisabled()); - userEvent.click(applyButton); - - expect( - await findAllByText('Benefits - Workers Compensation', undefined, { - timeout: 5000, - }), - ).toHaveLength(1); - - userEvent.click( - await findByRole('button', { name: 'Clear' }, { timeout: 5000 }), - ); - - await waitFor(() => - expect( - queryByText('Benefits - Workers Compensation'), - ).not.toBeInTheDocument(), - ); - expect(await findByRole('gridcell', { name: 'Benefits' })).toBeVisible(); - }, 15000); }); describe('Date range filtering', () => { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index 125a21f5c1..553f8faad6 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx @@ -62,8 +62,7 @@ export const MPGAIncomeExpensesReport: React.FC< () => Boolean( filters && - (filters.categories || - filters.selectedDateRange === DateRange.YearToDate || + (filters.selectedDateRange === DateRange.YearToDate || (filters.selectedYear !== null && filters.selectedYear !== undefined)), ), From 590a073734dc677cfa96ee8c80d79b8be7c33361 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Wed, 22 Jul 2026 13:35:03 -0400 Subject: [PATCH 08/11] Fix up --- .../ReportContext/ReportContext.test.tsx | 4 ---- .../Tables/PrintTables.tsx | 14 +++++--------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx index 1133059cd9..d022db611a 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx @@ -174,8 +174,6 @@ describe('ReportContext', () => { userEvent.click(getByRole('button', { name: 'Year to Date' })); - // Year to Date queries Jan 1 of the current year through the pinned - // "now" (Jan 1, 2020), so only January has data. await waitFor(() => expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { startMonth: '2020-01-01', @@ -184,8 +182,6 @@ describe('ReportContext', () => { }), ); - // January is the current month, so February onward (index >= 1) are - // future months that get zero-filled. await waitFor(() => expect(getByTestId('firstFutureMonthIndex')).toHaveTextContent('1'), ); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx index 5750bc5c99..f277c289e0 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/PrintTables.tsx @@ -49,7 +49,7 @@ export const PrintTables: React.FC = ({ backgroundColor: theme.palette.action.hover, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', - } as const; + }; const { monthCount, firstMonthFlags, getBorderColor } = useMonthHeaders( months, @@ -85,9 +85,6 @@ export const PrintTables: React.FC = ({ .slice(0, index) .reduce((sum, group) => sum + group.count, 0); - // Split the year underline into a past segment (year color) and - // a future segment (gray) at the exact column boundary, so it - // lines up with the columns regardless of their widths. const futureStart = firstFutureMonthIndex !== undefined ? Math.max( @@ -95,8 +92,7 @@ export const PrintTables: React.FC = ({ Math.min(count, firstFutureMonthIndex - monthOffset), ) : count; - const pastCount = futureStart; - const futureCount = count - pastCount; + const futureCount = count - futureStart; const yearLabel = firstMonthInYear ? ( = ({ ) : null; return [ - pastCount > 0 ? ( + futureStart > 0 ? ( = ({ borderRight: '20px solid transparent', }} > - {pastCount === 0 ? yearLabel : null} + {futureStart === 0 ? yearLabel : null} ) : null, ]; From f103ae0fc57e12a7b82d75c3472f7ac9c3dcb6ca Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Wed, 22 Jul 2026 14:49:25 -0400 Subject: [PATCH 09/11] Claude review suggestions --- .../BreakdownModal/BreakdownModal.tsx | 1 - .../ReportContext/ReportContext.tsx | 2 +- .../Tables/TableCard.tsx | 2 +- .../Tables/TotalRow.tsx | 35 ++++++++++++------- .../Shared/SettingsDialog/SettingsDialog.tsx | 2 +- src/hooks/useGetLastTwelveMonths.test.ts | 11 +++--- src/hooks/useGetLastTwelveMonths.ts | 6 ++-- 7 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx index ab4ae31032..fcf647952e 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx @@ -78,7 +78,6 @@ export const BreakdownModal: React.FC = ({ > diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx index aed6082dd6..2eaca8ca6d 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx @@ -103,7 +103,7 @@ export const ReportProvider: React.FC = ({ children }) => { }; }, [effectiveYear, now]); - const monthLabels = useGetLastTwelveMonths(locale, effectiveYear); + const monthLabels = useGetLastTwelveMonths(locale, now, effectiveYear); const subtitle = useMemo(() => { if (selectedYear === null && !isYearToDate) { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx index 672764d272..dd79da1a12 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx @@ -192,7 +192,7 @@ export const TableCard: React.FC = ({ headerAlign: 'right', }, ]; - }, [months, firstFutureMonthIndex, locale, t, description, average, total]); + }, [months, isFutureMonth, locale, t, description, average, total]); const columnGroupingModel = useMemo(() => { const yearGroups = monthCount.map(({ year, count }, index) => { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx index f02fd48832..5d3c358c58 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx @@ -30,18 +30,24 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { const { t } = useTranslation(); const locale = useLocale(); - const { firstFutureMonthIndex, isFutureMonth } = useReport(); + const { isFutureMonth } = useReport(); - const monthlyTotals = data[0]?.monthly - ? data[0].monthly.map((_, monthIndex) => { - const total = data.reduce( - (acc, curr) => acc + (curr.monthly?.[monthIndex] ?? 0), - 0, - ); - return total; - }) - : []; - const avgSum = (data ?? []).reduce((sum, row) => sum + row.average, 0); + const monthlyTotals = useMemo( + () => + data[0]?.monthly + ? data[0].monthly.map((_, monthIndex) => + data.reduce( + (acc, curr) => acc + (curr.monthly?.[monthIndex] ?? 0), + 0, + ), + ) + : [], + [data], + ); + const avgSum = useMemo( + () => (data ?? []).reduce((sum, row) => sum + row.average, 0), + [data], + ); const totalRow = (): Row => ({ id: 'totals-row', @@ -51,7 +57,10 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { overall: overallTotal, }); - const { description, average, overall } = populateTotalRows(locale); + const { description, average, overall } = useMemo( + () => populateTotalRows(locale), + [locale], + ); const columns: GridColDef[] = useMemo(() => { const monthColumns: GridColDef[] = monthlyTotals.map((_, index) => ({ @@ -110,7 +119,7 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { renderCell: overall, }, ]; - }, [monthlyTotals, avgSum, overallTotal, firstFutureMonthIndex]); + }, [monthlyTotals, isFutureMonth, locale, description, average, overall]); return ( = ({ [time], ); - // TODO: Get list of all possible years + // TODO: MPDX-9872 Get list of all possible years const completedYears = useMemo(() => { const lastCompletedYear = currentTime.year - 1; return Array.from({ length: 5 }, (_, index) => lastCompletedYear - index); diff --git a/src/hooks/useGetLastTwelveMonths.test.ts b/src/hooks/useGetLastTwelveMonths.test.ts index a4b4cf1f39..df85131c39 100644 --- a/src/hooks/useGetLastTwelveMonths.test.ts +++ b/src/hooks/useGetLastTwelveMonths.test.ts @@ -3,12 +3,13 @@ import { DateTime } from 'luxon'; import { useGetLastTwelveMonths } from './useGetLastTwelveMonths'; const currency = 'en-US'; -const fixed: DateTime = DateTime.local(2025, 4, 1); -jest.spyOn(DateTime, 'now').mockReturnValue(fixed); +const endDate: DateTime = DateTime.local(2025, 4, 1); describe('useGetLastTwelveMonths', () => { it('should return the last twelve months', () => { - const { result } = renderHook(() => useGetLastTwelveMonths(currency)); + const { result } = renderHook(() => + useGetLastTwelveMonths(currency, endDate), + ); expect(result.current).toEqual([ 'May 2024', @@ -27,7 +28,9 @@ describe('useGetLastTwelveMonths', () => { }); it('should return January–December of a specific year when one is given', () => { - const { result } = renderHook(() => useGetLastTwelveMonths(currency, 2023)); + const { result } = renderHook(() => + useGetLastTwelveMonths(currency, endDate, 2023), + ); expect(result.current).toEqual([ 'Jan 2023', diff --git a/src/hooks/useGetLastTwelveMonths.ts b/src/hooks/useGetLastTwelveMonths.ts index ce44674dfd..fda811f2d9 100644 --- a/src/hooks/useGetLastTwelveMonths.ts +++ b/src/hooks/useGetLastTwelveMonths.ts @@ -11,6 +11,7 @@ import { monthYearFormat } from 'src/lib/intlFormat'; */ export const useGetLastTwelveMonths = ( locale: string, + endDate: DateTime, year?: number | null, ): string[] => { return useMemo(() => { @@ -23,11 +24,10 @@ export const useGetLastTwelveMonths = ( return result; } - const startDate = DateTime.now(); for (let i = 0; i < 12; i++) { - const date = startDate.minus({ months: i }); + const date = endDate.minus({ months: i }); result.push(monthYearFormat(date.month, date.year, locale, true)); } return result.reverse(); - }, [locale, year]); + }, [locale, year, endDate]); }; From aa25927ffe0da33696532cd04ec268a5647b5930 Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Wed, 22 Jul 2026 14:55:09 -0400 Subject: [PATCH 10/11] Update green/red to darker shades --- .../BreakdownAccordion/BreakdownAccordion.test.tsx | 6 +++--- .../BreakdownAccordion/BreakdownAccordion.tsx | 8 ++++---- .../BreakdownModal/BreakdownModal.tsx | 4 ++-- .../Charts/ChartLegendContent/ChartLegendContent.test.tsx | 8 ++++---- .../Charts/MonthlySummaryChart/MonthlySummaryChart.tsx | 5 +---- .../Charts/SummaryBarChart/SummaryBarChart.tsx | 5 +---- 6 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.test.tsx index 2bb2adda4b..4386f3cf19 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.test.tsx @@ -52,7 +52,7 @@ describe('BreakdownAccordion', () => { ); expect(getByText('$6,770.00')).toHaveStyle({ - color: theme.palette.chipRedDark.main, + color: theme.palette.error.main, }); }); @@ -117,7 +117,7 @@ describe('BreakdownAccordion', () => { userEvent.click(getByRole('button')); const amount = await findByRole('cell', { name: '($50.00)' }); - expect(amount).toHaveStyle({ color: theme.palette.chipRedDark.main }); + expect(amount).toHaveStyle({ color: theme.palette.error.main }); }); it('marks a positive transaction inside an expense accordion', async () => { @@ -139,7 +139,7 @@ describe('BreakdownAccordion', () => { userEvent.click(getByRole('button')); const amount = await findByRole('cell', { name: '($50.00)' }); - expect(amount).toHaveStyle({ color: theme.palette.statusSuccess.main }); + expect(amount).toHaveStyle({ color: theme.palette.success.main }); }); }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.tsx index 03f9fc620d..9abc1b62d4 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownAccordion/BreakdownAccordion.tsx @@ -70,8 +70,8 @@ export const BreakdownAccordion: React.FC = ({ sx={{ marginLeft: 'auto', color: isIncomeTotal - ? theme.palette.statusSuccess.main - : theme.palette.chipRedDark.main, + ? theme.palette.success.main + : theme.palette.error.main, }} > {currencyFormat(Math.abs(total), currency, locale, { @@ -130,9 +130,9 @@ export const BreakdownAccordion: React.FC = ({ align="right" sx={{ color: isStrayExpenseTransaction - ? theme.palette.chipRedDark.main + ? theme.palette.error.main : isStrayIncomeTransaction - ? theme.palette.statusSuccess.main + ? theme.palette.success.main : null, }} > diff --git a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx index fcf647952e..0a6226b81b 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/BreakdownModal/BreakdownModal.tsx @@ -156,8 +156,8 @@ export const BreakdownModal: React.FC = ({ sx={{ color: overallTotal >= 0 - ? theme.palette.statusSuccess.main - : theme.palette.chipRedDark.main, + ? theme.palette.success.main + : theme.palette.error.main, }} > {currencyFormat(Math.abs(overallTotal), currency, locale, { diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/ChartLegendContent/ChartLegendContent.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/ChartLegendContent/ChartLegendContent.test.tsx index 8b2dc74f92..10954419f8 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/ChartLegendContent/ChartLegendContent.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/ChartLegendContent/ChartLegendContent.test.tsx @@ -4,8 +4,8 @@ import theme from 'src/theme'; import { ChartLegendContent } from './ChartLegendContent'; const payload = [ - { value: 'Income', color: theme.palette.statusSuccess.main }, - { value: 'Expenses', color: theme.palette.chipRedDark.main }, + { value: 'Income', color: theme.palette.success.main }, + { value: 'Expenses', color: theme.palette.error.main }, ]; describe('ChartLegendContent', () => { @@ -25,10 +25,10 @@ describe('ChartLegendContent', () => { const boxes = getAllByRole('listitem').map((item) => item.firstChild); expect(boxes[0]).toHaveStyle({ - backgroundColor: theme.palette.statusSuccess.main, + backgroundColor: theme.palette.success.main, }); expect(boxes[1]).toHaveStyle({ - backgroundColor: theme.palette.chipRedDark.main, + backgroundColor: theme.palette.error.main, }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx index 354cb529be..657b70d35f 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/MonthlySummaryChart/MonthlySummaryChart.tsx @@ -30,10 +30,7 @@ interface MonthlyTotal { tallest: number; } -const chartColors = [ - theme.palette.statusSuccess.main, - theme.palette.chipRedDark.main, -]; +const chartColors = [theme.palette.success.main, theme.palette.error.main]; export const MonthlySummaryChart: React.FC = ({ aspect, diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx index d543f16f58..97e7d5c4c5 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx @@ -11,10 +11,7 @@ interface SummaryBarChartProps { width: number; } -const chartColors = [ - theme.palette.statusSuccess.main, - theme.palette.chipRedDark.main, -]; +const chartColors = [theme.palette.success.main, theme.palette.error.main]; export const SummaryBarChart: React.FC = ({ aspect, From 434ce62f628aa622739dd3a737e1cd75d5e1138d Mon Sep 17 00:00:00 2001 From: Katelyn Grimes Date: Thu, 23 Jul 2026 15:01:39 -0400 Subject: [PATCH 11/11] Add transaction years to filter dropdown options --- .../ExportCsvButton/ExportCsvButton.test.tsx | 1 + .../MPGAIncomeExpensesReport.tsx | 10 ++++- .../MPGAIncomeExpensesReportTestWrapper.tsx | 5 ++- .../MPGATransactions.graphql | 1 + .../ReportContext/ReportContext.test.tsx | 38 ++++++++++++++++++- .../ReportContext/ReportContext.tsx | 12 ++++++ .../SettingsDialog/SettingsDialog.test.tsx | 15 ++++---- .../Shared/SettingsDialog/SettingsDialog.tsx | 10 ++--- 8 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx index 8f6790ae51..f32812f470 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx @@ -100,6 +100,7 @@ describe('ExportCsvButton', () => { it('disables an export option when its dataset is empty', async () => { const incomeOnlyMock: MpgaTransactionsQuery = { reportsStaffExpenses: { + transactionYears: [], funds: [ { fundType: 'Primary', diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index 553f8faad6..4b6e2ed65e 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx @@ -46,7 +46,14 @@ export const MPGAIncomeExpensesReport: React.FC< const { t } = useTranslation(); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const { filters, setFilters, startDate, endDate, subtitle } = useReport(); + const { + filters, + setFilters, + startDate, + endDate, + subtitle, + transactionYears, + } = useReport(); const defaultFilters: Filters = useMemo( () => ({ @@ -176,6 +183,7 @@ export const MPGAIncomeExpensesReport: React.FC< setIsSettingsOpen(false); }} isMpgaReport + transactionYears={transactionYears ?? []} /> )} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx index 0462aae332..03dff453ca 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx @@ -18,6 +18,7 @@ const toBreakdown = (values: number[]): { month: string; total: number }[] => export const mpgaTransactionsMock: MpgaTransactionsQuery = { reportsStaffExpenses: { + transactionYears: [2017, 2018, 2019], funds: [ { fundType: FundTypes.Primary, @@ -116,7 +117,9 @@ export const MPGAIncomeExpensesReportTestWrapper: React.FC< > = ({ onCall, isEmpty, mocks, children }) => { const mpgaTransactions = mocks ?? - (isEmpty ? { reportsStaffExpenses: { funds: [] } } : mpgaTransactionsMock); + (isEmpty + ? { reportsStaffExpenses: { transactionYears: [], funds: [] } } + : mpgaTransactionsMock); return ( diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql b/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql index 41301cf684..ef49d59f25 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGATransactions.graphql @@ -8,6 +8,7 @@ query MPGATransactions( startMonth: $startMonth endMonth: $endMonth ) { + transactionYears funds { categories { averagePerMonth diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx index d022db611a..42563eaa0a 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { render } from '__tests__/util/testingLibraryReactMock'; import { DateRange } from '../../StaffExpenseReport/Helpers/StaffReportEnum'; import { MPGAIncomeExpensesReportTestWrapper } from '../MPGAIncomeExpensesReportTestWrapper'; +import { MpgaTransactionsQuery } from '../MPGATransactions.generated'; import { useReport } from './ReportContext'; const mutationSpy = jest.fn(); @@ -45,8 +46,14 @@ function TestConsumer() { } function FilterConsumer() { - const { setFilters, subtitle, firstFutureMonthIndex, incomeTotal, allData } = - useReport(); + const { + setFilters, + subtitle, + firstFutureMonthIndex, + incomeTotal, + allData, + transactionYears, + } = useReport(); return (
@@ -58,6 +65,7 @@ function FilterConsumer() {
{(allData.income[0]?.monthly ?? []).join(',')}
+
{transactionYears.join(',')}