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/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.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 0af2f35d75..0a6226b81b 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] ?? []; @@ -76,11 +76,20 @@ export const BreakdownModal: React.FC = ({ open={open} onClose={onClose} > - + {t('Category')} @@ -120,10 +129,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}`, }, }} > @@ -148,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/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/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/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..657b70d35f 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 { @@ -35,23 +30,23 @@ 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 = ({ - 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..97e7d5c4c5 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Charts/SummaryBarChart/SummaryBarChart.tsx @@ -3,29 +3,24 @@ 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 = [ - theme.palette.statusSuccess.main, - theme.palette.chipRedDark.main, -]; +const chartColors = [theme.palette.success.main, theme.palette.error.main]; 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 92f782a716..2656f4fcb3 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/PrintOnlyReport.tsx @@ -4,21 +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[]; - currency: string; -} - -export const PrintOnlyReport: React.FC = ({ - data, - last12Months, - currency, -}) => { +export const PrintOnlyReport: React.FC = () => { const { t } = useTranslation(); + const { allData: data } = useReport(); return ( <> @@ -45,7 +36,7 @@ export const PrintOnlyReport: React.FC = ({ {t('Summary')} - + @@ -60,7 +51,6 @@ export const PrintOnlyReport: React.FC = ({ type={ReportTypeEnum.Income} data={data.income ?? []} title={t('Income')} - months={last12Months} /> @@ -68,21 +58,13 @@ export const PrintOnlyReport: React.FC = ({ type={ReportTypeEnum.Expenses} data={data.expenses ?? []} title={t('Expenses')} - months={last12Months} /> {t('Monthly Summary')} - + diff --git a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx index 402b535859..d86657f952 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.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 { ScreenOnlyReport } from './ScreenOnlyReport'; const mutationSpy = jest.fn(); -const currency = 'USD'; - -const emptyData = { - income: [{ ...mockData.income[0] }, { ...mockData.income[1] }], - expenses: [], -}; const TestComponent: React.FC = () => ( - - - - - - - - - + + + ); const resizeObserverMock = () => ({ @@ -43,33 +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 06b66fca1b..ed9abe8601 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/DisplayModes/ScreenOnlyReport.tsx @@ -7,21 +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; - last12Months: string[]; - currency: string; -} - -export const ScreenOnlyReport: React.FC = ({ - data, - last12Months, - currency, -}) => { +export const ScreenOnlyReport: React.FC = () => { const { t } = useTranslation(); + const { allData: data } = useReport(); return ( @@ -29,15 +20,12 @@ export const ScreenOnlyReport: React.FC = ({ - - + + - + @@ -46,7 +34,7 @@ export const ScreenOnlyReport: React.FC = ({ = ({ /> } title={t('Income')} - months={last12Months} /> @@ -72,22 +59,11 @@ export const ScreenOnlyReport: React.FC = ({ /> } title={t('Expenses')} - months={last12Months} /> - - + + diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx index a8f129a9dd..f32812f470 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,65 @@ 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: { + transactionYears: [], + 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 +150,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 d62c3fc6a1..44bdc1c159 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx @@ -14,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(); @@ -103,11 +104,13 @@ const TestComponent: React.FC = () => ( mocks={mockData} onCall={mutationSpy} > - + + + @@ -158,7 +161,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 () => { @@ -184,9 +187,84 @@ 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); + }, 15000); + + 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(); + + 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); - expect(await findAllByText('Benefits - Program Based')).toHaveLength(1); + expect(queryByRole('button', { name: 'Clear' })).not.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); + }); + + describe('Date range filtering', () => { + 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' }, { timeout: 5000 }), + ).toBeInTheDocument(); }, 15000); }); }); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index 9659b2d02c..4b6e2ed65e 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, @@ -9,24 +8,20 @@ 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 { 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, @@ -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 } from './mockData'; +import { FundTypes } from './Helper/MPGAReportEnum'; +import { useReport } from './ReportContext/ReportContext'; import { PrintOnly, StyledHeaderBox } from './styledComponents'; interface MPGAIncomeExpensesReportProps { @@ -51,32 +44,37 @@ export const MPGAIncomeExpensesReport: React.FC< MPGAIncomeExpensesReportProps > = ({ title, isNavListOpen, onNavListToggle }) => { const { t } = useTranslation(); - const locale = useLocale(); - const currency = 'USD'; const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const last12Months = useGetLastTwelveMonths(locale, true); + const { + filters, + setFilters, + startDate, + endDate, + subtitle, + transactionYears, + } = useReport(); - const { startDate, endDate } = useMemo(() => { - const now = DateTime.now(); - return { - startDate: now.minus({ months: 11 }).startOf('month'), - endDate: now, - }; - }, []); - - 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.selectedDateRange === DateRange.YearToDate || + (filters.selectedYear !== null && + filters.selectedYear !== undefined)), + ), + [filters], + ); const handleSettingsClick = () => { setIsSettingsOpen(true); @@ -88,53 +86,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(() => { - return { - income: incomeData, - expenses: expenseData, - incomeBreakdown: incomeBreakdown, - expenseBreakdown: expenseBreakdown, - }; - }, [incomeData, expenseData, incomeBreakdown, expenseBreakdown]); - return ( <> - {t('Income & Expenses Analysis: Last 12 Months')} + {t('Income & Expenses Analysis: {{subtitle}}', { subtitle })} button': { ml: 0 } }} > - } - size="small" - onClick={handleSettingsClick} - > - {t('Report Settings')} - + - + @@ -212,46 +160,30 @@ 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 + isMpgaReport + transactionYears={transactionYears ?? []} /> )} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx new file mode 100644 index 0000000000..03dff453ca --- /dev/null +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReportTestWrapper.tsx @@ -0,0 +1,136 @@ +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: { + transactionYears: [2017, 2018, 2019], + 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: { transactionYears: [], funds: [] } } + : mpgaTransactionsMock); + + return ( + + + + mocks={{ MPGATransactions: mpgaTransactions }} + onCall={onCall} + > + {children} + + + + ); +}; 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 new file mode 100644 index 0000000000..42563eaa0a --- /dev/null +++ b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.test.tsx @@ -0,0 +1,229 @@ +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 { MpgaTransactionsQuery } from '../MPGATransactions.generated'; +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, + transactionYears, + } = useReport(); + + return ( +
+
{subtitle}
+
{incomeTotal}
+
+ {firstFutureMonthIndex ?? 'none'} +
+
+ {(allData.income[0]?.monthly ?? []).join(',')} +
+
{transactionYears.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' })); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('MPGATransactions', { + startMonth: '2020-01-01', + endMonth: '2020-01-01', + fundTypes: ['Primary'], + }), + ); + + 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', + ), + ); + }); + }); + + describe('transaction years', () => { + it('excludes the current year from the list', async () => { + const yearsMock: MpgaTransactionsQuery = { + reportsStaffExpenses: { + transactionYears: [2018, 2019, 2020], + funds: [], + }, + }; + + const { getByTestId } = render( + + + , + ); + + await waitFor(() => + expect(getByTestId('transactionYears')).toHaveTextContent( + /^2018,2019$/, + ), + ); + }); + }); +}); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx new file mode 100644 index 0000000000..ccf7382813 --- /dev/null +++ b/src/components/Reports/MPGAIncomeExpensesReport/ReportContext/ReportContext.tsx @@ -0,0 +1,278 @@ +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; + transactionYears: number[]; + + 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, now, 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(), + }, + }); + + // Filter out the current year since we only want to show previous years in filter dropdown + const transactionYears = useMemo( + () => + (reportData?.reportsStaffExpenses?.transactionYears ?? []).filter( + (year) => year < now.year, + ), + [reportData, now.year], + ); + + 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, + transactionYears, + subtitle, + incomeTotal, + expensesTotal, + ministryTotal, + healthcareTotal, + assessmentTotal, + benefitsTotal, + salaryTotal, + otherTotal, + }), + [ + currency, + filters, + setFilters, + firstFutureMonthIndex, + monthLabels, + isFutureMonth, + allData, + loading, + startDate, + endDate, + transactionYears, + 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 aabde122db..f277c289e0 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,22 +23,34 @@ export interface PrintTablesProps { type: ReportTypeEnum; data?: DataFields[]; title: string; - months: string[]; } export const PrintTables: React.FC = ({ title, - months, data, type, }) => { 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 futureCellSx = { + backgroundColor: theme.palette.action.hover, + WebkitPrintColorAdjust: 'exact', + printColorAdjust: 'exact', + }; + const { monthCount, firstMonthFlags, getBorderColor } = useMonthHeaders( months, { @@ -63,34 +75,59 @@ 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); + + const futureStart = + firstFutureMonthIndex !== undefined + ? Math.max( + 0, + Math.min(count, firstFutureMonthIndex - monthOffset), + ) + : count; + const futureCount = count - futureStart; + + const yearLabel = firstMonthInYear ? ( + - {firstMonthInYear && ( - - {year} - - )} - - ); + {year} +
+ ) : null; + + return [ + futureStart > 0 ? ( + + {yearLabel} + + ) : null, + futureCount > 0 ? ( + + {futureStart === 0 ? yearLabel : null} + + ) : null, + ]; })} = ({ {t('Description')} - {months.map((month) => ( + {months.map((month, index) => ( - + {month.split(' ')[0]} @@ -142,7 +181,10 @@ export const PrintTables: React.FC = ({ {value.description}
{value.monthly.map((amount, index) => ( - + {zeroAmountFormat(amount, locale)} @@ -175,7 +217,10 @@ export const PrintTables: React.FC = ({ {data[0].monthly.map((_, index) => ( - + {zeroAmountFormat( diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx index 8a21ce6ce3..63651ae61d 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.test.tsx @@ -1,13 +1,9 @@ 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'; @@ -20,37 +16,25 @@ const data = { expenses: [], }; -const emptyData = { - income: [], - expenses: [], -}; - const cellText = (element: HTMLElement) => element.textContent; const TestComponent: React.FC = () => ( - - - - - Empty Table} - title={title} - 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( @@ -61,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) => { @@ -81,41 +67,35 @@ describe('TableCard', () => { it('should show a loading spinner while data is being fetched', () => { const { getByRole } = render( - - - - - Empty Table} - title={title} - 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({ @@ -123,40 +103,33 @@ describe('TableCard', () => { }); }); - it('should display empty placeholder when no data is available', () => { - const { getByText } = render( - - - - - Empty Table} - title={title} - 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( @@ -165,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(); @@ -181,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' })); @@ -195,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; @@ -204,7 +178,7 @@ describe('TableCard', () => { within(aprilHeader).getByTestId('ArrowDownwardIcon'), ).toBeInTheDocument(); - await userEvent.click(aprilHeader); + userEvent.click(aprilHeader); await waitFor(() => { const aprCells = getAllByRole('gridcell').filter( @@ -214,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 dbc78616d3..dd79da1a12 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,7 +34,6 @@ export interface TableCardProps { >; emptyPlaceholder: React.ReactElement; title: string; - months: string[]; } // Visual styling for the grouped-column headers, matching the report's table @@ -44,10 +43,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, }} /> @@ -93,12 +93,18 @@ export const TableCard: React.FC = ({ data, breakdownData = {}, title, - months, 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); @@ -133,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) => { + return { + field: `month${index}`, + headerName: month.split(' ')[0], + width: monthWidth, + type: 'number', + 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; + }, + renderCell: (params) => { + const formattedValue = zeroAmountFormat(params.value, locale); + return ( + + + {formattedValue} + + + ); + }, + }; + }, ); return [ @@ -180,7 +192,7 @@ export const TableCard: React.FC = ({ headerAlign: 'right', }, ]; - }, [months, locale, t, description, average, total]); + }, [months, isFutureMonth, locale, t, description, average, total]); const columnGroupingModel = useMemo(() => { const yearGroups = monthCount.map(({ year, count }, index) => { @@ -192,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: () => ( + + ), }; }); @@ -223,7 +254,7 @@ export const TableCard: React.FC = ({ ), }, ]; - }, [monthCount, getBorderColor, t]); + }, [monthCount, getBorderColor, firstFutureMonthIndex, t]); return dataLoading ? ( @@ -237,7 +268,6 @@ export const TableCard: React.FC = ({ <> @@ -245,6 +275,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} @@ -272,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 bc84345f38..5d3c358c58 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TotalRow.tsx @@ -4,7 +4,9 @@ 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 { useReport } from '../ReportContext/ReportContext'; import { DataFields } from '../mockData'; import { StyledTotalRow } from '../styledComponents'; import { descriptionWidth, monthWidth, summaryWidth } from './TableCard'; @@ -28,16 +30,24 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { const { t } = useTranslation(); const locale = useLocale(); - 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 { isFutureMonth } = useReport(); + + 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', @@ -47,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) => ({ @@ -58,6 +71,7 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { filterable: false, disableColumnMenu: true, align: 'right', + cellClassName: isFutureMonth(index) ? 'future-month' : undefined, renderCell: ({ row }) => { const formattedValue = zeroAmountFormat( row.monthlyValue[index], @@ -105,7 +119,7 @@ export const TotalRow: React.FC = ({ data, overallTotal }) => { renderCell: overall, }, ]; - }, [monthlyTotals, avgSum, overallTotal]); + }, [monthlyTotals, isFutureMonth, locale, description, average, overall]); return ( = ({ data, overallTotal }) => { '& .MuiDataGrid-virtualScroller': { marginTop: '0 !important' }, '& .MuiDataGrid-cell': { borderBottom: 'none' }, '& .MuiDataGrid-main': { border: 'none' }, + '& .future-month': { backgroundColor: theme.palette.action.hover }, }} /> ); 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 c8f60ad8ac..69b90065cb 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.test.tsx @@ -26,7 +26,8 @@ const TestComponent: React.FC< selectedFundType, time, onCallMock, - hideDateRange = false, + isMpgaReport = false, + transactionYears, }) => ( @@ -93,7 +94,8 @@ const TestComponent: React.FC< selectedFilters={selectedFilters} selectedFundType={selectedFundType} time={time} - hideDateRange={hideDateRange} + isMpgaReport={isMpgaReport} + transactionYears={transactionYears} /> @@ -104,6 +106,7 @@ const baseFilters: Filters = { selectedDateRange: null, startDate: null, endDate: null, + selectedYear: null, categories: [], }; @@ -151,6 +154,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( , @@ -500,16 +517,23 @@ describe('SettingsDialog', () => { isOpen: true, onClose: mutationSpy, selectedFundType: 'Primary', - hideDateRange: true, + isMpgaReport: true, + transactionYears: [2018, 2019, 2020], }; - 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( - '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(); @@ -517,7 +541,21 @@ 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: 'Year to Date' })).toBeInTheDocument(); + const lastCompletedYear = DateTime.now().year - 1; + expect( + getByRole('option', { name: String(lastCompletedYear) }), + ).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(); }); @@ -542,5 +580,61 @@ describe('SettingsDialog', () => { }); }); }); + + it('lists the transaction years in the date range dropdown', () => { + const { getByLabelText, getByRole } = render( + , + ); + + userEvent.click(getByLabelText('Select Date Range')); + + expect(getByRole('option', { name: '2018' })).toBeInTheDocument(); + expect(getByRole('option', { name: '2019' })).toBeInTheDocument(); + expect(getByRole('option', { name: '2020' })).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 960ced2261..46f92d0399 100644 --- a/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx +++ b/src/components/Reports/Shared/SettingsDialog/SettingsDialog.tsx @@ -34,13 +34,19 @@ export interface SettingsDialogProps { selectedFundType: string | null; onClose: (filters?: Filters) => void; time?: DateTime; - hideDateRange?: boolean; + isMpgaReport?: boolean; + transactionYears?: number[]; } 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 +133,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; @@ -143,7 +159,8 @@ export const SettingsDialog: React.FC = ({ selectedFilters, selectedFundType, time, - hideDateRange, + isMpgaReport, + transactionYears, }) => { const { t } = useTranslation(); const [previewFilters, setPreviewFilters] = useState(null); @@ -210,6 +227,7 @@ export const SettingsDialog: React.FC = ({ selectedFilters?.selectedDateRange === null ? selectedFilters?.endDate : null, + selectedYear: selectedFilters?.selectedYear ?? null, categories: selectedFilters?.categories ?? null, }; @@ -249,52 +267,67 @@ export const SettingsDialog: React.FC = ({ setFieldValue('categories', newCategories); }; + const dropdownValue = + values.selectedYear !== null && values.selectedYear !== undefined + ? String(values.selectedYear) + : (values.selectedDateRange ?? ''); + return (
- {!hideDateRange && ( - <> - { - 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')} - - {t('Week to Date')} - - - {t('Month to Date')} - - - {t('Year to Date')} + { + 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, + selectedYear: yearValue, + selectedDateRange: rangeValue, + startDate: null, + endDate: null, + }); + }} + > + {t('None')} + {!isMpgaReport && [ + + {t('Week to Date')} + , + + {t('Month to Date')} + , + ]} + + {t('Year to Date')} + + {isMpgaReport && + (transactionYears ?? []).map((year) => ( + + {year} - + ))} + + {!isMpgaReport && ( + <> {t('Or enter a custom date range:')} @@ -354,16 +387,10 @@ 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..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, true)); + const { result } = renderHook(() => + useGetLastTwelveMonths(currency, endDate), + ); expect(result.current).toEqual([ 'May 2024', @@ -25,4 +26,25 @@ describe('useGetLastTwelveMonths', () => { 'Apr 2025', ]); }); + + it('should return January–December of a specific year when one is given', () => { + const { result } = renderHook(() => + useGetLastTwelveMonths(currency, endDate, 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..fda811f2d9 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, + endDate: DateTime, + 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; + } + for (let i = 0; i < 12; i++) { - const date = startDate.minus({ months: i }); - result.push(monthYearFormat(date.month, date.year, locale, fullYear)); + const date = endDate.minus({ months: i }); + result.push(monthYearFormat(date.month, date.year, locale, true)); } return result.reverse(); - }, [locale, fullYear]); + }, [locale, year, endDate]); };