diff --git a/pages/api/Schema/reports/fourteenMonth/datahandler.ts b/pages/api/Schema/reports/fourteenMonth/datahandler.ts index a3cbf53ca..13408bbe9 100644 --- a/pages/api/Schema/reports/fourteenMonth/datahandler.ts +++ b/pages/api/Schema/reports/fourteenMonth/datahandler.ts @@ -78,7 +78,7 @@ export const mapFourteenMonthReport = ( salaryCurrency: data.attributes.salary_currency, currencyGroups: Object.entries(data.attributes.currency_groups).map( ([currency, currencyGroup]) => ({ - currency, + currency: currency.toUpperCase(), totals: { year: Number( isSalaryType diff --git a/pages/api/Schema/reports/fourteenMonth/fourteenMonth.graphql b/pages/api/Schema/reports/fourteenMonth/fourteenMonth.graphql index 8d890578a..82879d4b5 100644 --- a/pages/api/Schema/reports/fourteenMonth/fourteenMonth.graphql +++ b/pages/api/Schema/reports/fourteenMonth/fourteenMonth.graphql @@ -41,7 +41,7 @@ type FourteenMonthReportContact { total: Float! average: Float! minimum: Float! - months: [FourteenMonthReportContactMonths!] + months: [FourteenMonthReportContactMonths!]! accountNumbers: [String!]! lateBy30Days: Boolean! lateBy60Days: Boolean! diff --git a/src/components/Constants/LoadConstants.graphql b/src/components/Constants/LoadConstants.graphql index f582e7882..9a0b4a940 100644 --- a/src/components/Constants/LoadConstants.graphql +++ b/src/components/Constants/LoadConstants.graphql @@ -29,9 +29,11 @@ query LoadConstants { # value # } pledgeCurrency { + id codeSymbolString name code + symbol } pledgeFrequency { id diff --git a/src/components/Dashboard/DonationHistories/DonationHistories.tsx b/src/components/Dashboard/DonationHistories/DonationHistories.tsx index 581685c8b..a8e2164dc 100644 --- a/src/components/Dashboard/DonationHistories/DonationHistories.tsx +++ b/src/components/Dashboard/DonationHistories/DonationHistories.tsx @@ -320,11 +320,9 @@ const DonationHistories = ({ offset={0} angle={-90} > - { - t('Amount ({{ currencyCode }})', { - currencyCode, - }) as string - } + {t('Amount ({{ currencyCode }})', { + currencyCode, + })} </Text> } /> diff --git a/src/components/Reports/FourteenMonthReports/FourteenMonthReport.test.tsx b/src/components/Reports/FourteenMonthReports/FourteenMonthReport.test.tsx index 6193e13d5..17b9fd9bf 100644 --- a/src/components/Reports/FourteenMonthReports/FourteenMonthReport.test.tsx +++ b/src/components/Reports/FourteenMonthReports/FourteenMonthReport.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { ThemeProvider } from '@mui/material/styles'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { FourteenMonthReportCurrencyType } from 'src/graphql/types.generated'; @@ -21,6 +21,7 @@ const defaultProps = { title, onNavListToggle, onSelectContact, + isNavListOpen: false, }; const mocks = { @@ -47,8 +48,7 @@ const mocks = { }, ], month: '2020-10-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -60,8 +60,7 @@ const mocks = { }, ], month: '2020-11-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -73,8 +72,7 @@ const mocks = { }, ], month: '2020-12-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -86,8 +84,7 @@ const mocks = { }, ], month: '2021-1-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, ], name: 'test name', @@ -115,8 +112,7 @@ const mocks = { }, ], month: '2020-10-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -128,8 +124,7 @@ const mocks = { }, ], month: '2020-11-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -141,8 +136,7 @@ const mocks = { }, ], month: '2020-12-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -154,8 +148,7 @@ const mocks = { }, ], month: '2021-1-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, ], name: 'test name', @@ -166,7 +159,7 @@ const mocks = { total: 1290, }, ], - currency: 'cad', + currency: 'CAD', totals: { months: [ { @@ -208,8 +201,7 @@ const mocks = { }, ], month: '2020-10-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -221,8 +213,7 @@ const mocks = { }, ], month: '2020-11-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -234,8 +225,7 @@ const mocks = { }, ], month: '2020-12-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, { donations: [ @@ -247,8 +237,7 @@ const mocks = { }, ], month: '2021-1-01', - salaryCurrencyTotal: 50, - total: 35, + total: 50, }, ], name: 'test name', @@ -259,7 +248,7 @@ const mocks = { total: 1290, }, ], - currency: 'usd', + currency: 'USD', totals: { months: [ { @@ -299,7 +288,7 @@ const errorMock: MockedResponse = { describe('FourteenMonthReport', () => { it('salary report loading', async () => { - const { queryByTestId, queryByText } = render( + const { getByTestId, getByText, queryByTestId } = render( <ThemeProvider theme={theme}> <GqlMockedProvider> <FourteenMonthReport @@ -314,13 +303,13 @@ describe('FourteenMonthReport', () => { </ThemeProvider>, ); - expect(queryByText(title)).toBeInTheDocument(); - expect(queryByTestId('LoadingFourteenMonthReport')).toBeInTheDocument(); - expect(queryByTestId('Notification')).toBeNull(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByTestId('LoadingFourteenMonthReport')).toBeInTheDocument(); + expect(queryByTestId('Notification')).not.toBeInTheDocument(); }); it('salary report loaded', async () => { - const { getAllByTestId, getByTestId, queryByTestId, getByRole } = render( + const { getAllByTestId, queryByTestId, getAllByRole } = render( <ThemeProvider theme={theme}> <GqlMockedProvider<{ FourteenMonthReport: FourteenMonthReportQuery }> mocks={mocks} @@ -343,13 +332,13 @@ describe('FourteenMonthReport', () => { ).not.toBeInTheDocument(); }); - expect(getByRole('table')).toBeInTheDocument(); - expect(getAllByTestId('FourteenMonthReportTableRow').length).toBe(3); - expect(getByTestId('FourteenMonthReport')).toBeInTheDocument(); + expect(getAllByRole('table')).toHaveLength(2); + expect(getAllByTestId('FourteenMonthReportTableRow')).toHaveLength(3); + expect(getAllByTestId('FourteenMonthReport')).toHaveLength(2); }); it('partner report loading', async () => { - const { queryByTestId, queryByText } = render( + const { getByTestId, getByText, queryByTestId } = render( <ThemeProvider theme={theme}> <GqlMockedProvider> <FourteenMonthReport @@ -364,13 +353,13 @@ describe('FourteenMonthReport', () => { </ThemeProvider>, ); - expect(queryByText(title)).toBeInTheDocument(); - expect(queryByTestId('LoadingFourteenMonthReport')).toBeInTheDocument(); - expect(queryByTestId('Notification')).toBeNull(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByTestId('LoadingFourteenMonthReport')).toBeInTheDocument(); + expect(queryByTestId('Notification')).not.toBeInTheDocument(); }); it('partner report loaded', async () => { - const { getByTestId, queryByTestId, queryByText } = render( + const { getAllByTestId, queryByTestId, getByText } = render( <ThemeProvider theme={theme}> <GqlMockedProvider<{ FourteenMonthReport: FourteenMonthReportQuery }> mocks={mocks} @@ -393,12 +382,12 @@ describe('FourteenMonthReport', () => { ).not.toBeInTheDocument(); }); - expect(queryByText(title)).toBeInTheDocument(); - expect(getByTestId('FourteenMonthReport')).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); + expect(getAllByTestId('FourteenMonthReport')).toHaveLength(2); }); it('salary report error', async () => { - const { queryByTestId, getByTestId, queryByText } = render( + const { queryByTestId, getByTestId, getByText } = render( <ThemeProvider theme={theme}> <MockedProvider mocks={[errorMock]}> <FourteenMonthReport @@ -419,12 +408,12 @@ describe('FourteenMonthReport', () => { ).not.toBeInTheDocument(); }); - expect(queryByText(title)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); expect(getByTestId('Notification')).toBeInTheDocument(); }); it('partner report error', async () => { - const { queryByTestId, getByTestId, queryByText } = render( + const { queryByTestId, getByTestId, getByText } = render( <ThemeProvider theme={theme}> <MockedProvider mocks={[errorMock]}> <FourteenMonthReport @@ -445,12 +434,12 @@ describe('FourteenMonthReport', () => { ).not.toBeInTheDocument(); }); - expect(queryByText(title)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); expect(getByTestId('Notification')).toBeInTheDocument(); }); it('nav list closed', async () => { - const { getByTestId, queryByTestId, queryByText } = render( + const { getAllByTestId, getByText, queryByTestId } = render( <ThemeProvider theme={theme}> <GqlMockedProvider<{ FourteenMonthReport: FourteenMonthReportQuery }> mocks={mocks} @@ -473,9 +462,9 @@ describe('FourteenMonthReport', () => { ).not.toBeInTheDocument(); }); - expect(queryByText(title)).toBeInTheDocument(); - expect(getByTestId('FourteenMonthReport')).toBeInTheDocument(); - expect(queryByTestId('MultiPageMenu')).toBeNull(); + expect(getByText(title)).toBeInTheDocument(); + expect(getAllByTestId('FourteenMonthReport')).toHaveLength(2); + expect(queryByTestId('MultiPageMenu')).not.toBeInTheDocument(); }); it('filters report by designation account', async () => { @@ -500,13 +489,8 @@ describe('FourteenMonthReport', () => { ); await waitFor(() => - expect(mutationSpy.mock.calls[1][0]).toMatchObject({ - operation: { - operationName: 'FourteenMonthReport', - variables: { - designationAccountIds: ['account-1'], - }, - }, + expect(mutationSpy).toHaveGraphqlOperation('FourteenMonthReport', { + designationAccountIds: ['account-1'], }), ); }); @@ -532,13 +516,8 @@ describe('FourteenMonthReport', () => { ); await waitFor(() => - expect(mutationSpy.mock.calls[1][0]).toMatchObject({ - operation: { - operationName: 'FourteenMonthReport', - variables: { - designationAccountIds: null, - }, - }, + expect(mutationSpy).toHaveGraphqlOperation('FourteenMonthReport', { + designationAccountIds: null, }), ); }); @@ -570,37 +549,53 @@ describe('FourteenMonthReport', () => { expect(onSelectContact).toHaveBeenCalledWith('contact-1'); }); - it('should calulate totals correctly', async () => { - const mutationSpy = jest.fn(); - const { getAllByTestId, queryByTestId } = render( - <ThemeProvider theme={theme}> - <GqlMockedProvider<FourteenMonthReportQuery> - mocks={mocks} - onCall={mutationSpy} - > - <FourteenMonthReport - {...defaultProps} - isNavListOpen={true} - currencyType={FourteenMonthReportCurrencyType.Donor} - /> - </GqlMockedProvider> - </ThemeProvider>, - ); + describe('partner report', () => { + it('should render one table for each partner currency', async () => { + const { findAllByRole } = render( + <ThemeProvider theme={theme}> + <GqlMockedProvider<FourteenMonthReportQuery> mocks={mocks}> + <FourteenMonthReport + {...defaultProps} + currencyType={FourteenMonthReportCurrencyType.Donor} + /> + </GqlMockedProvider> + </ThemeProvider>, + ); + + const tables = await findAllByRole('table', { + name: 'Fourteen month report table', + }); + expect(tables).toHaveLength(2); - await waitFor(() => { expect( - queryByTestId('LoadingFourteenMonthReport'), - ).not.toBeInTheDocument(); - }); + within(tables[0]).getByRole('heading', { name: 'CAD' }), + ).toBeInTheDocument(); + const table1MonthlyTotals = within(tables[0]).getAllByTestId( + 'monthlyTotals', + ); + // There are 2 contacts in this table who each gave 50 + expect(table1MonthlyTotals[0]).toHaveTextContent('100'); + expect(table1MonthlyTotals[1]).toHaveTextContent('100'); + expect(table1MonthlyTotals[2]).toHaveTextContent('100'); + expect(table1MonthlyTotals[3]).toHaveTextContent('100'); + expect(within(tables[0]).getByTestId('overallTotal')).toHaveTextContent( + '400', + ); - const contactTotal = getAllByTestId('monthlyTotals'); - // 50 * 3 contacts with different currencies - expect(contactTotal[0].innerHTML).toEqual('150'); - expect(contactTotal[1].innerHTML).toEqual('150'); - expect(contactTotal[2].innerHTML).toEqual('150'); - expect(contactTotal[3].innerHTML).toEqual('150'); - - // 50 * 12 (All dontions from all currecnies) - expect(getAllByTestId('overallTotal')[0].innerHTML).toEqual('600'); + expect( + within(tables[1]).getByRole('heading', { name: 'USD' }), + ).toBeInTheDocument(); + const table2MonthlyTotals = within(tables[1]).getAllByTestId( + 'monthlyTotals', + ); + // There is 1 contact in this table who gave 50 + expect(table2MonthlyTotals[0]).toHaveTextContent('50'); + expect(table2MonthlyTotals[1]).toHaveTextContent('50'); + expect(table2MonthlyTotals[2]).toHaveTextContent('50'); + expect(table2MonthlyTotals[3]).toHaveTextContent('50'); + expect(within(tables[1]).getByTestId('overallTotal')).toHaveTextContent( + '200', + ); + }); }); }); diff --git a/src/components/Reports/FourteenMonthReports/FourteenMonthReport.tsx b/src/components/Reports/FourteenMonthReports/FourteenMonthReport.tsx index d9671d83e..5d06877af 100644 --- a/src/components/Reports/FourteenMonthReports/FourteenMonthReport.tsx +++ b/src/components/Reports/FourteenMonthReports/FourteenMonthReport.tsx @@ -1,18 +1,25 @@ import React, { useMemo, useState } from 'react'; import { Box, CircularProgress, useMediaQuery } from '@mui/material'; import { Theme } from '@mui/material/styles'; -import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; -import { useApiConstants } from 'src/components/Constants/UseApiConstants'; import { Notification } from 'src/components/Notification/Notification'; import { EmptyReport } from 'src/components/Reports/EmptyReport/EmptyReport'; import { FourteenMonthReportCurrencyType } from 'src/graphql/types.generated'; -import { useLocale } from 'src/hooks/useLocale'; import { useFourteenMonthReportQuery } from './GetFourteenMonthReport.generated'; import { FourteenMonthReportHeader as Header } from './Layout/Header/Header'; -import { FourteenMonthReportTable as Table } from './Layout/Table/Table'; +import { + FourteenMonthReportTable as Table, + FourteenMonthReportTableProps as TableProps, +} from './Layout/Table/Table'; +import { calculateTotals, sortContacts } from './Layout/Table/helpers'; +import { useCsvData } from './useCsvData'; import type { Order } from '../Reports.type'; -import type { Contact, OrderBy } from './Layout/Table/TableHead/TableHead'; +import type { OrderBy } from './Layout/Table/TableHead/TableHead'; + +export interface CurrencyTable + extends Pick<TableProps, 'totals' | 'orderedContacts'> { + currency: string; +} interface Props { accountListId: string; @@ -23,7 +30,8 @@ interface Props { currencyType: FourteenMonthReportCurrencyType; onSelectContact: (contactId: string) => void; } -export interface Totals { + +export interface MonthTotal { total: number; month: string; } @@ -39,17 +47,14 @@ export const FourteenMonthReport: React.FC<Props> = ({ }) => { const [isExpanded, setExpanded] = useState<boolean>(false); const [order, setOrder] = useState<Order>('asc'); - const [orderBy, setOrderBy] = useState<OrderBy | number | null>(null); + const [orderBy, setOrderBy] = useState<OrderBy | null>(null); const { t } = useTranslation(); - const locale = useLocale(); const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'), ); - const apiConstants = useApiConstants(); - - const { data, loading, error } = useFourteenMonthReportQuery({ + const { data, error } = useFourteenMonthReportQuery({ variables: { accountListId, designationAccountIds: designationAccounts?.length @@ -59,34 +64,16 @@ export const FourteenMonthReport: React.FC<Props> = ({ }, }); - const contacts = useMemo(() => { - return data?.fourteenMonthReport?.currencyGroups?.flatMap( - (currencyGroup) => [...currencyGroup?.contacts], - ); - }, [data?.fourteenMonthReport.currencyGroups]); - - const orderedContacts = useMemo(() => { - if (contacts && orderBy !== null) { - const getSortValue = (contact: Contact) => - (typeof orderBy === 'number' - ? contact['months']?.[orderBy]['total'].toString() - : contact[orderBy]?.toString()) ?? contact.name; - - return contacts.sort((a, b) => { - const compare = getSortValue(a)?.localeCompare( - getSortValue(b), - undefined, - { - numeric: true, - }, - ); - - return order === 'asc' ? compare : -compare; - }); - } else { - return contacts; - } - }, [contacts, order, orderBy]); + // Generate a table for each currency group in the report + const currencyTables = useMemo<CurrencyTable[]>( + () => + data?.fourteenMonthReport.currencyGroups.map((currencyGroup) => ({ + currency: currencyGroup.currency, + orderedContacts: sortContacts(currencyGroup.contacts, orderBy, order), + totals: calculateTotals(currencyGroup.contacts), + })) ?? [], + [data, orderBy, order], + ); const handleExpandToggle = (): void => { setExpanded((prevExpanded) => !prevExpanded); @@ -96,161 +83,14 @@ export const FourteenMonthReport: React.FC<Props> = ({ const handleRequestSort = ( event: React.MouseEvent<unknown>, - property: OrderBy | number, + property: OrderBy, ) => { const isAsc = orderBy === property && order === 'asc'; setOrder(isAsc ? 'desc' : 'asc'); setOrderBy(property); }; - const formatMonth = (month: string) => - DateTime.fromISO(month).toJSDate().toLocaleDateString(locale, { - month: 'numeric', - year: '2-digit', - }); - - const csvData = useMemo(() => { - if (!contacts) { - return []; - } - - const months = - data?.fourteenMonthReport.currencyGroups[0]?.totals.months ?? []; - - const csvHeaders = [ - [t('Currency'), data?.fourteenMonthReport.salaryCurrency], - [ - t('Partner'), - t('Status'), - t('Commitment Amount'), - t('Commitment Currency'), - t('Commitment Frequency'), - t('Committed Monthly Equivalent'), - t('In Hand Monthly Equivalent'), - t('Missing In Hand Monthly Equivalent'), - t('In Hand Special Gifts'), - t('In Hand Date Range'), - ...months.map(({ month }) => month), - t('Total (last month excluded from total)'), - ], - ]; - - const csvBody = [ - ...contacts.map((contact) => { - const numMonthsforMonthlyEquivalent = Math.max( - 4, - parseInt(contact.pledgeFrequency ?? '4'), - ); - - const pledgedMonthlyEquivalent = - contact.status === 'Partner - Financial' && - contact.pledgeAmount && - contact.pledgeFrequency - ? Math.round( - contact.pledgeAmount / parseFloat(contact.pledgeFrequency), - ) - : ''; - - const inHandMonths = contact.months?.slice( - 15 - numMonthsforMonthlyEquivalent - 1, - 15 - 1, - ); - - const inHandMonthlyEquivalent = - contact.status === 'Partner - Financial' && - contact.pledgeFrequency && - inHandMonths - ? Math.round( - inHandMonths.reduce((sum, month) => sum + month.total, 0) / - numMonthsforMonthlyEquivalent, - ) - : ''; - - const inHandDateRange = - inHandMonths && inHandMonthlyEquivalent - ? `${formatMonth(inHandMonths[0].month)} - ${formatMonth( - inHandMonths[inHandMonths.length - 1].month, - )}` - : ''; - - return [ - contact.name, - contact.status ?? '', - contact.pledgeAmount ?? '', - contact.pledgeCurrency ?? '', - apiConstants?.pledgeFrequency?.find( - ({ key }) => key === contact.pledgeFrequency, - )?.value ?? '', - pledgedMonthlyEquivalent, - inHandMonthlyEquivalent !== '' && pledgedMonthlyEquivalent !== '' - ? Math.min(pledgedMonthlyEquivalent, inHandMonthlyEquivalent) - : '', - inHandMonthlyEquivalent !== '' && pledgedMonthlyEquivalent !== '' - ? -Math.max(0, pledgedMonthlyEquivalent - inHandMonthlyEquivalent) - : '', - inHandMonthlyEquivalent !== '' && pledgedMonthlyEquivalent !== '' - ? Math.max(0, inHandMonthlyEquivalent - pledgedMonthlyEquivalent) * - numMonthsforMonthlyEquivalent - : Math.round(contact.total), - inHandDateRange, - ...(contact?.months?.map((month) => Math.round(month.total)) || []), - Math.round(contact.total), - ]; - }), - ]; - - const csvTotals = [ - t('Totals'), - '', - '', - '', - '', - csvBody.reduce( - (sum, row) => sum + (typeof row[5] === 'number' ? row[5] : 0), - 0, - ), - csvBody.reduce( - (sum, row) => sum + (typeof row[6] === 'number' ? row[6] : 0), - 0, - ), - csvBody.reduce( - (sum, row) => sum + (typeof row[7] === 'number' ? row[7] : 0), - 0, - ), - csvBody.reduce( - (sum, row) => sum + (typeof row[8] === 'number' ? row[8] : 0), - 0, - ), - '', - ...months.map(({ total }) => Math.round(total)), - months - .map(({ total }) => Math.round(total)) - .reduce((sum, monthTotal) => sum + monthTotal, 0), - ]; - - return [...csvHeaders, ...csvBody, csvTotals]; - }, [apiConstants, contacts]); - - const totals: Totals[] = useMemo(() => { - const totals: Totals[] = []; - data?.fourteenMonthReport.currencyGroups.forEach((current) => { - current.contacts.forEach((contact) => { - if (contact?.months) { - contact.months.forEach((month, idx) => { - if (!totals[idx]?.total && totals[idx]?.total !== 0) { - totals.push({ - month: month.month, - total: month.salaryCurrencyTotal, - }); - } else { - totals[idx].total = totals[idx].total + month.salaryCurrencyTotal; - } - }); - } - }); - }); - return totals; - }, [data?.fourteenMonthReport]); + const csvData = useCsvData(currencyTables); return ( <Box> @@ -265,7 +105,7 @@ export const FourteenMonthReport: React.FC<Props> = ({ onPrint={handlePrint} title={title} /> - {loading ? ( + {!data && !error ? ( <Box display="flex" justifyContent="center" @@ -276,17 +116,22 @@ export const FourteenMonthReport: React.FC<Props> = ({ </Box> ) : error ? ( <Notification type="error" message={error.toString()} /> - ) : contacts && contacts.length > 0 ? ( - <Table - isExpanded={isExpanded} - onSelectContact={onSelectContact} - onRequestSort={handleRequestSort} - order={order} - orderBy={orderBy} - orderedContacts={orderedContacts} - salaryCurrency={data?.fourteenMonthReport.salaryCurrency} - totals={totals} - /> + ) : currencyTables.length > 0 ? ( + <Box display="flex" flexDirection="column" gap={4}> + {currencyTables.map(({ currency, orderedContacts, totals }) => ( + <Table + key={currency} + isExpanded={isExpanded} + onSelectContact={onSelectContact} + onRequestSort={handleRequestSort} + order={order} + orderBy={orderBy} + orderedContacts={orderedContacts} + salaryCurrency={currency} + totals={totals} + /> + ))} + </Box> ) : ( <EmptyReport title={t( diff --git a/src/components/Reports/FourteenMonthReports/GetFourteenMonthReport.graphql b/src/components/Reports/FourteenMonthReports/GetFourteenMonthReport.graphql index 41de4bc38..33c6aed3b 100644 --- a/src/components/Reports/FourteenMonthReports/GetFourteenMonthReport.graphql +++ b/src/components/Reports/FourteenMonthReports/GetFourteenMonthReport.graphql @@ -22,30 +22,33 @@ query FourteenMonthReport( minimum } contacts { - id - name - total - average - minimum - months { - month - total - salaryCurrencyTotal - donations { - amount - currency - date - paymentMethod - } - } - accountNumbers - lateBy30Days - lateBy60Days - pledgeAmount - pledgeCurrency - pledgeFrequency - status + ...FourteenMonthReportContact } } } } + +fragment FourteenMonthReportContact on FourteenMonthReportContact { + id + name + total + average + minimum + months { + month + total + donations { + amount + currency + date + paymentMethod + } + } + accountNumbers + lateBy30Days + lateBy60Days + pledgeAmount + pledgeCurrency + pledgeFrequency + status +} diff --git a/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx b/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx index c5bdebac3..f8a4f4b51 100644 --- a/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx +++ b/src/components/Reports/FourteenMonthReports/Layout/Header/Actions/Actions.tsx @@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next'; import { FourteenMonthReportCurrencyType } from 'src/graphql/types.generated'; interface FourteenMonthReportActionsProps { - csvData: ((string | undefined)[] | (string | number)[])[]; + csvData: (string | number)[][]; currencyType: FourteenMonthReportCurrencyType; isExpanded: boolean; isMobile: boolean; diff --git a/src/components/Reports/FourteenMonthReports/Layout/Header/Header.tsx b/src/components/Reports/FourteenMonthReports/Layout/Header/Header.tsx index 8f8c67988..638aace5a 100644 --- a/src/components/Reports/FourteenMonthReports/Layout/Header/Header.tsx +++ b/src/components/Reports/FourteenMonthReports/Layout/Header/Header.tsx @@ -8,7 +8,7 @@ import theme from 'src/theme'; import { FourteenMonthReportActions } from './Actions/Actions'; interface FourteenMonthReportHeaderProps { - csvData: ((string | undefined)[] | (string | number)[])[]; + csvData: (string | number)[][]; currencyType: FourteenMonthReportCurrencyType; isExpanded: boolean; isMobile: boolean; diff --git a/src/components/Reports/FourteenMonthReports/Layout/Table/Table.test.tsx b/src/components/Reports/FourteenMonthReports/Layout/Table/Table.test.tsx index ee655da4e..af0d53b5e 100644 --- a/src/components/Reports/FourteenMonthReports/Layout/Table/Table.test.tsx +++ b/src/components/Reports/FourteenMonthReports/Layout/Table/Table.test.tsx @@ -32,8 +32,7 @@ const mocks = { }, ], month: '2020-10-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, { donations: [ @@ -45,8 +44,7 @@ const mocks = { }, ], month: '2020-11-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, { donations: [ @@ -58,8 +56,7 @@ const mocks = { }, ], month: '2020-12-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, { donations: [ @@ -71,8 +68,7 @@ const mocks = { }, ], month: '2021-1-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, ], minimum: 255, @@ -98,8 +94,7 @@ const mocks = { }, ], month: '2020-10-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, { donations: [ @@ -111,8 +106,7 @@ const mocks = { }, ], month: '2020-11-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, { donations: [ @@ -124,8 +118,7 @@ const mocks = { }, ], month: '2020-12-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, { donations: [ @@ -137,8 +130,7 @@ const mocks = { }, ], month: '2021-1-01', - salaryCurrencyTotal: 255, - total: 35, + total: 255, }, ], minimum: 255, @@ -230,10 +222,10 @@ describe('FourteenMonthReportTable', () => { }); expect(getByRole('table')).toBeInTheDocument(); - expect(getAllByTestId('FourteenMonthReportTableRow').length).toBe(2); + expect(getAllByTestId('FourteenMonthReportTableRow')).toHaveLength(2); expect(queryByTestId('FourteenMonthReport')).toBeInTheDocument(); const contactTotal = getAllByTestId('totalGivenByContact'); - expect(contactTotal[0].innerHTML).toEqual('1,020'); + expect(contactTotal[0]).toHaveTextContent('3,366'); }); it('should order by name', async () => { @@ -269,7 +261,7 @@ describe('FourteenMonthReportTable', () => { const fourteenMonthReportRow = getAllByTestId( 'FourteenMonthReportTableRow', ); - expect(fourteenMonthReportRow.length).toBe(2); + expect(fourteenMonthReportRow).toHaveLength(2); expect(fourteenMonthReportRow[0]).toHaveTextContent('test name'); expect(fourteenMonthReportRow[1]).toHaveTextContent('name again'); expect(queryByTestId('FourteenMonthReport')).toBeInTheDocument(); @@ -308,77 +300,10 @@ describe('FourteenMonthReportTable', () => { userEvent.click(getByText('name again')); expect(onSelectContact).toHaveBeenCalledWith('contact-2'); await waitFor(() => { - expect(getAllByTestId('pledgeAmount')[1].innerHTML).toEqual('16 USD '); + expect(getAllByTestId('pledgeAmount')[1]).toHaveTextContent('16 USD'); }); }); - it('should return 0 if no months', async () => { - const newMocks = { - FourteenMonthReport: { - fourteenMonthReport: { - currencyGroups: [ - { - contacts: [ - { - accountNumbers: ['11609'], - average: 258, - id: 'contact-1', - lateBy30Days: false, - lateBy60Days: false, - months: null, - minimum: 255, - name: 'name again', - pledgeAmount: null, - status: null, - total: 3366, - }, - ], - currency: 'cad', - totals: { - average: 1831, - minimum: 1583, - months: null, - year: 24613, - }, - }, - ], - }, - }, - }; - - const { queryByTestId, getAllByTestId } = render( - <ThemeProvider theme={theme}> - <GqlMockedProvider> - <FourteenMonthReportTable - isExpanded={true} - order="asc" - orderBy="name" - orderedContacts={ - newMocks.FourteenMonthReport.fourteenMonthReport.currencyGroups[0] - .contacts - } - salaryCurrency={ - newMocks.FourteenMonthReport.fourteenMonthReport.currencyGroups[0] - .currency - } - onRequestSort={onRequestSort} - onSelectContact={onSelectContact} - totals={totals} - /> - </GqlMockedProvider> - </ThemeProvider>, - ); - - await waitFor(() => { - expect( - queryByTestId('LoadingFourteenMonthReport'), - ).not.toBeInTheDocument(); - }); - - const contactTotal = getAllByTestId('totalGivenByContact'); - expect(contactTotal[0].innerHTML).toEqual('0'); - }); - it('should calculate the correct monthly totals', async () => { const { queryByTestId, getByTestId, getAllByTestId } = render( <ThemeProvider theme={theme}> @@ -410,14 +335,13 @@ describe('FourteenMonthReportTable', () => { }); const contactTotal = getAllByTestId('monthlyTotals'); - expect(contactTotal[0].innerHTML).toEqual('1,836'); - expect(contactTotal[1].innerHTML).toEqual('1,487'); - expect(contactTotal[2].innerHTML).toEqual('1,836'); - expect(contactTotal[3].innerHTML).toEqual('1,836'); - - expect(getAllByTestId('overallTotal')[0].innerHTML).toEqual('6,996'); + expect(contactTotal[0]).toHaveTextContent('1,836'); + expect(contactTotal[1]).toHaveTextContent('1,487'); + expect(contactTotal[2]).toHaveTextContent('1,836'); + expect(contactTotal[3]).toHaveTextContent('1,836'); expect(getByTestId('averageTotal')).toHaveTextContent('516'); expect(getByTestId('minimumTotal')).toHaveTextContent('510'); + expect(getAllByTestId('overallTotal')[0]).toHaveTextContent('6,996'); }); }); diff --git a/src/components/Reports/FourteenMonthReports/Layout/Table/Table.tsx b/src/components/Reports/FourteenMonthReports/Layout/Table/Table.tsx index 2903fea27..f7bcc6b0b 100644 --- a/src/components/Reports/FourteenMonthReports/Layout/Table/Table.tsx +++ b/src/components/Reports/FourteenMonthReports/Layout/Table/Table.tsx @@ -16,18 +16,18 @@ import { useLocale } from 'src/hooks/useLocale'; import theme from 'src/theme'; import { numberFormat } from '../../../../../lib/intlFormat'; import { useApiConstants } from '../../../../Constants/UseApiConstants'; -import { Totals } from '../../FourteenMonthReport'; +import { MonthTotal } from '../../FourteenMonthReport'; import { StyledTableCell } from './StyledComponents'; import { FourteenMonthReportTableHead as TableHead, FourteenMonthReportTableHeadProps as TableHeadProps, } from './TableHead/TableHead'; -import type { Contact, Month } from './TableHead/TableHead'; +import type { Contact } from './TableHead/TableHead'; -interface FourteenMonthReportTableProps extends TableHeadProps { +export interface FourteenMonthReportTableProps extends TableHeadProps { isExpanded: boolean; - orderedContacts: Contact[] | undefined; - totals: Totals[]; + orderedContacts: Contact[]; + totals: MonthTotal[]; onSelectContact: (contactId: string) => void; } @@ -44,29 +44,18 @@ const NameTypography = styled(Typography, { }, })); -const PrintableContainer = styled(TableContainer)(() => ({ - // First style set size as landscape - height: 'calc(100vh - 160px)', +const PrintableContainer = styled(TableContainer)({ + // First style sets size as landscape '@media print': { - ['@page']: { size: 'landscape' }, - overflow: 'auto', - height: '100%', + '@page': { size: 'landscape' }, }, -})); - -const StickyTable = styled(Table)(({}) => ({ - height: 'calc(100vh - 96px)', - '@media print': { - overflow: 'auto', - height: '100%', - }, -})); +}); -const StyledInfoIcon = styled(InfoIcon)(({}) => ({ +const StyledInfoIcon = styled(InfoIcon)({ '@media print': { display: 'none', }, -})); +}); const StyledTotalsRow = styled(TableRow)({ '.MuiTableCell-root': { @@ -113,7 +102,7 @@ export const FourteenMonthReportTable: React.FC< return ( <PrintableContainer className="fourteen-month-report"> - <StickyTable + <Table stickyHeader={true} aria-label={t('Fourteen month report table')} data-testid="FourteenMonthReport" @@ -127,82 +116,67 @@ export const FourteenMonthReportTable: React.FC< onRequestSort={onRequestSort} /> <TableBody> - {orderedContacts?.map((contact) => { - const totalDonated = useMemo(() => { - if (contact?.months) { - return contact.months.reduce((partialSum, month) => { - return partialSum + month.salaryCurrencyTotal; - }, 0); - } else { - return 0; - } - }, [contact]); - return ( - <TableRow - key={contact.id} - hover - data-testid="FourteenMonthReportTableRow" - > - <StyledTableCell> - <Box display="flex" flexDirection="column"> - <Box display="flex" alignItems="center"> - {!isExpanded && <StyledInfoIcon fontSize="small" />} - <NameTypography variant="body1" expanded={isExpanded}> - <Link - onClick={() => onSelectContact(contact.id)} - onMouseEnter={preloadContactsRightPanel} - underline="hover" - > - {contact.name} - </Link> - </NameTypography> - </Box> - {isExpanded && ( - <Typography variant="body2" color="textSecondary"> - {contact.accountNumbers.join(', ')} - </Typography> - )} + {orderedContacts?.map((contact) => ( + <TableRow + key={contact.id} + hover + data-testid="FourteenMonthReportTableRow" + > + <StyledTableCell> + <Box display="flex" flexDirection="column"> + <Box display="flex" alignItems="center"> + {!isExpanded && <StyledInfoIcon fontSize="small" />} + <NameTypography variant="body1" expanded={isExpanded}> + <Link + onClick={() => onSelectContact(contact.id)} + onMouseEnter={preloadContactsRightPanel} + underline="hover" + > + {contact.name} + </Link> + </NameTypography> </Box> - </StyledTableCell> - {isExpanded && ( - <> - <StyledTableCell>{contact.status}</StyledTableCell> - <StyledTableCell data-testid="pledgeAmount"> - {contact.pledgeAmount && - `${numberFormat( - Math.round(contact.pledgeAmount), - locale, - )} ${contact.pledgeCurrency} ${ - apiConstants?.pledgeFrequency?.find( - ({ key }) => key === contact.pledgeFrequency, - )?.value ?? '' - }`} - </StyledTableCell> - <StyledTableCell> - {numberFormat(Math.round(contact.average), locale)} - </StyledTableCell> - <StyledTableCell> - {numberFormat(Math.round(contact.minimum), locale)} - </StyledTableCell> - </> - )} - {contact.months?.map((month: Month) => ( - <StyledTableCell key={month?.month} align="center"> - {month?.salaryCurrencyTotal && - numberFormat( - Math.round(month?.salaryCurrencyTotal), + {isExpanded && ( + <Typography variant="body2" color="textSecondary"> + {contact.accountNumbers.join(', ')} + </Typography> + )} + </Box> + </StyledTableCell> + {isExpanded && ( + <> + <StyledTableCell>{contact.status}</StyledTableCell> + <StyledTableCell data-testid="pledgeAmount"> + {contact.pledgeAmount && + `${numberFormat( + Math.round(contact.pledgeAmount), locale, - )} + )} ${contact.pledgeCurrency} ${ + apiConstants?.pledgeFrequency?.find( + ({ key }) => key === contact.pledgeFrequency, + )?.value ?? '' + }`} + </StyledTableCell> + <StyledTableCell> + {numberFormat(Math.round(contact.average), locale)} </StyledTableCell> - ))} - <StyledTableCell align="right"> - <strong data-testid="totalGivenByContact"> - {numberFormat(Math.round(totalDonated), locale)} - </strong> + <StyledTableCell> + {numberFormat(Math.round(contact.minimum), locale)} + </StyledTableCell> + </> + )} + {contact.months.map((month) => ( + <StyledTableCell key={month.month} align="center"> + {numberFormat(Math.round(month.total), locale)} </StyledTableCell> - </TableRow> - ); - })} + ))} + <StyledTableCell align="right"> + <strong data-testid="totalGivenByContact"> + {numberFormat(Math.round(contact.total), locale)} + </strong> + </StyledTableCell> + </TableRow> + ))} <StyledTotalsRow> <StyledTableCell>{t('Totals')}</StyledTableCell> {isExpanded && ( @@ -236,7 +210,7 @@ export const FourteenMonthReportTable: React.FC< </StyledTableCell> </StyledTotalsRow> </TableBody> - </StickyTable> + </Table> </PrintableContainer> ); }; diff --git a/src/components/Reports/FourteenMonthReports/Layout/Table/TableHead/TableHead.tsx b/src/components/Reports/FourteenMonthReports/Layout/Table/TableHead/TableHead.tsx index 0f60af9e0..a72c9d0c1 100644 --- a/src/components/Reports/FourteenMonthReports/Layout/Table/TableHead/TableHead.tsx +++ b/src/components/Reports/FourteenMonthReports/Layout/Table/TableHead/TableHead.tsx @@ -4,31 +4,27 @@ import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; -import { Totals } from '../../../FourteenMonthReport'; -import { FourteenMonthReportQuery } from '../../../GetFourteenMonthReport.generated'; +import { MonthTotal } from '../../../FourteenMonthReport'; +import { + FourteenMonthReportContactFragment, + FourteenMonthReportQuery, +} from '../../../GetFourteenMonthReport.generated'; import { StyledTableCell } from '../StyledComponents'; import { TableHeadCell } from './TableHeadCell/TableHeadCell'; -import type { Order, Unarray } from '../../../../Reports.type'; +import type { Order } from '../../../../Reports.type'; -export type Contacts = - FourteenMonthReportQuery['fourteenMonthReport']['currencyGroups'][0]['contacts']; -export type Contact = Contacts[0]; -export type Months = Contact['months']; -export type Month = Unarray<Months>; -export type OrderBy = keyof Contact | keyof Unarray<Months>; +export type Contact = FourteenMonthReportContactFragment; +export type OrderBy = keyof FourteenMonthReportContactFragment | number; // numbers mean sorting by a specific month index export interface FourteenMonthReportTableHeadProps { isExpanded: boolean; - totals: Totals[] | undefined; + totals: MonthTotal[] | undefined; salaryCurrency: | FourteenMonthReportQuery['fourteenMonthReport']['salaryCurrency'] | undefined; - onRequestSort: ( - event: React.MouseEvent<unknown>, - property: OrderBy | number, - ) => void; + onRequestSort: (event: React.MouseEvent<unknown>, property: OrderBy) => void; order: Order; - orderBy: string | number | null; + orderBy: OrderBy | null; } const YearTableCell = styled(TableCell)(({}) => ({ @@ -46,7 +42,7 @@ export const FourteenMonthReportTableHead: FC< const locale = useLocale(); const createSortHandler = - (property: OrderBy | number) => (event: React.MouseEvent<unknown>) => { + (property: OrderBy) => (event: React.MouseEvent<unknown>) => { onRequestSort(event, property); }; diff --git a/src/components/Reports/FourteenMonthReports/Layout/Table/helpers.test.ts b/src/components/Reports/FourteenMonthReports/Layout/Table/helpers.test.ts new file mode 100644 index 000000000..c513170c3 --- /dev/null +++ b/src/components/Reports/FourteenMonthReports/Layout/Table/helpers.test.ts @@ -0,0 +1,100 @@ +import { ErgonoMockShape } from 'graphql-ergonomock'; +import { DeepPartial } from 'ts-essentials'; +import { gqlMock } from '__tests__/util/graphqlMocking'; +import { + FourteenMonthReportContactFragment, + FourteenMonthReportContactFragmentDoc, +} from '../../GetFourteenMonthReport.generated'; +import { calculateTotals, extractSortKey, sortContacts } from './helpers'; + +const mockContact = ( + mocks: ErgonoMockShape & DeepPartial<FourteenMonthReportContactFragment>, +) => + gqlMock<FourteenMonthReportContactFragment>( + FourteenMonthReportContactFragmentDoc, + { + mocks, + }, + ); + +describe('extractSortKey', () => { + it('extracts string values from the contact', () => { + const contact = mockContact({ + status: 'Partner', + }); + + expect(extractSortKey(contact, 'status')).toBe('Partner'); + }); + + it('converts number values to strings', () => { + const contact = mockContact({ + total: 1000, + }); + + expect(extractSortKey(contact, 'total')).toBe('1000'); + }); + + it('extracts month totals from the contact', () => { + const contact = mockContact({ + months: [{}, { total: 100 }], + }); + + expect(extractSortKey(contact, 1)).toBe('100'); + }); + + it('defaults to the contact name', () => { + const contact = mockContact({ + name: 'Contact', + status: null, + }); + + expect(extractSortKey(contact, 'status')).toBe('Contact'); + }); +}); + +describe('sortContacts', () => { + const contacts = [100, 200, 150, 1000].map((total) => mockContact({ total })); + + it('sorts contacts ascending', () => { + expect( + sortContacts(contacts, 'total', 'asc').map((contact) => contact.total), + ).toEqual([100, 150, 200, 1000]); + }); + + it('sorts contacts descending', () => { + expect( + sortContacts(contacts, 'total', 'desc').map((contact) => contact.total), + ).toEqual([1000, 200, 150, 100]); + }); + + it('ignores null order by', () => { + expect( + sortContacts(contacts, null, 'asc').map((contact) => contact.total), + ).toEqual([100, 200, 150, 1000]); + }); +}); + +describe('calculateTotals', () => { + const makeContact = (month1: number, month2: number, month3: number) => + mockContact({ + months: [ + { month: 'Jan', total: month1 }, + { month: 'Feb', total: month2 }, + { month: 'Mar', total: month3 }, + ], + }); + + it('sums totals for each month across contacts', () => { + expect( + calculateTotals([ + makeContact(100, 200, 150), + makeContact(70, 90, 40), + makeContact(400, 250, 125), + ]), + ).toEqual([ + { month: 'Jan', total: 570 }, + { month: 'Feb', total: 540 }, + { month: 'Mar', total: 315 }, + ]); + }); +}); diff --git a/src/components/Reports/FourteenMonthReports/Layout/Table/helpers.ts b/src/components/Reports/FourteenMonthReports/Layout/Table/helpers.ts new file mode 100644 index 000000000..af1afe36a --- /dev/null +++ b/src/components/Reports/FourteenMonthReports/Layout/Table/helpers.ts @@ -0,0 +1,65 @@ +import { Order } from '../../../Reports.type'; +import { MonthTotal } from '../../FourteenMonthReport'; +import { Contact, OrderBy } from './TableHead/TableHead'; + +// Given a contact and a sorting field, calculate a sort key for the contact that can be +// compared against other contacts' sort keys +export const extractSortKey = ( + contact: Contact, + sortField: OrderBy, +): string => { + const sortKey = + typeof sortField === 'number' + ? contact.months[sortField].total + : contact[sortField]; + return sortKey?.toString() ?? contact.name; +}; + +/** + * Sort the contacts array by the orderBy field using the direction order. + * + * @param contacts Contacts to sort + * @param orderBy Field to sort by + * @param order Sort direction + * @returns Sorted contacts + */ +export const sortContacts = ( + contacts: Contact[], + orderBy: OrderBy | null, + order: Order, +): Contact[] => { + if (!contacts || orderBy === null) { + return contacts; + } + + return [...contacts].sort((a, b) => { + const compare = extractSortKey(a, orderBy)?.localeCompare( + extractSortKey(b, orderBy), + undefined, + { + numeric: true, + }, + ); + + return order === 'asc' ? compare : -compare; + }); +}; + +// Calculate the total amounts given by the contacts in each month +// Returns an array of the total for each month in the report +export const calculateTotals = (contacts: Contact[]): MonthTotal[] => { + const totals: MonthTotal[] = []; + contacts.forEach((contact) => { + contact.months.forEach((month, idx) => { + if (!totals[idx]) { + totals.push({ + month: month.month, + total: month.total, + }); + } else { + totals[idx].total += month.total; + } + }); + }); + return totals; +}; diff --git a/src/components/Reports/FourteenMonthReports/useCsvData.test.ts b/src/components/Reports/FourteenMonthReports/useCsvData.test.ts new file mode 100644 index 000000000..2716dd1f5 --- /dev/null +++ b/src/components/Reports/FourteenMonthReports/useCsvData.test.ts @@ -0,0 +1,260 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { ErgonoMockShape } from 'graphql-ergonomock'; +import { DeepPartial } from 'ts-essentials'; +import { gqlMock } from '__tests__/util/graphqlMocking'; +import { + LoadConstantsDocument, + LoadConstantsQuery, +} from 'src/components/Constants/LoadConstants.generated'; +import { useApiConstants } from 'src/components/Constants/UseApiConstants'; +import { CurrencyTable } from './FourteenMonthReport'; +import { + FourteenMonthReportContactFragment, + FourteenMonthReportContactFragmentDoc, +} from './GetFourteenMonthReport.generated'; +import { useCsvData } from './useCsvData'; + +jest.mock('src/components/Constants/UseApiConstants.tsx'); + +// Mock useApiConstants to make the data available synchronously instead of having to wait for the GraphQL call +(useApiConstants as jest.MockedFn<typeof useApiConstants>).mockReturnValue( + gqlMock<LoadConstantsQuery>(LoadConstantsDocument, { + mocks: { + constant: { + pledgeCurrency: [ + { + code: 'USD', + symbol: '$', + }, + { + code: 'CAD', + symbol: '$', + }, + ], + pledgeFrequency: [ + { + key: '3.0', + value: 'Quarterly', + }, + { + key: '12.0', + value: 'Annual', + }, + ], + }, + }, + }).constant, +); + +const mockContact = ( + mocks?: ErgonoMockShape & DeepPartial<FourteenMonthReportContactFragment>, +) => + gqlMock<FourteenMonthReportContactFragment>( + FourteenMonthReportContactFragmentDoc, + { + mocks, + }, + ); + +describe('useCsvData', () => { + it('generates CSV data', async () => { + const tables: CurrencyTable[] = [ + { + currency: 'USD', + totals: [ + { total: 100, month: '2020-12-01' }, + { total: 200, month: '2020-11-01' }, + { total: 150, month: '2020-10-01' }, + { total: 100, month: '2020-09-01' }, + { total: 200, month: '2020-08-01' }, + { total: 150, month: '2020-07-01' }, + { total: 100, month: '2020-06-01' }, + { total: 200, month: '2020-05-01' }, + { total: 150, month: '2020-04-01' }, + { total: 100, month: '2020-03-01' }, + { total: 200, month: '2020-02-01' }, + { total: 150, month: '2020-01-01' }, + ], + orderedContacts: [ + // Partner pledged for $450/quarter but who gave $275/month average over the last quarter + mockContact({ + name: 'Quarterly Partner', + status: 'Partner - Financial', + pledgeAmount: 450, + pledgeCurrency: 'USD', + pledgeFrequency: '3.0', + months: [ + { total: 0, month: '2020-12-01' }, + { total: 0, month: '2020-11-01' }, + { total: 0, month: '2020-10-01' }, + { total: 0, month: '2020-09-01' }, + { total: 0, month: '2020-08-01' }, + { total: 0, month: '2020-07-01' }, + { total: 0, month: '2020-06-01' }, + { total: 0, month: '2020-05-01' }, + { total: 350, month: '2020-04-01' }, + { total: 300, month: '2020-03-01' }, + { total: 250, month: '2020-02-01' }, + { total: 200, month: '2020-01-01' }, + ], + }), + + // Partner pledged for $1200/year but who gave $75/month average over the last quarter + mockContact({ + name: 'Annual Partner', + status: 'Partner - Financial', + pledgeAmount: 1200, + pledgeCurrency: 'USD', + pledgeFrequency: '12.0', + months: [ + { total: 0, month: '2020-12-01' }, + { total: 100, month: '2020-11-01' }, + { total: 0, month: '2020-10-01' }, + { total: 200, month: '2020-09-01' }, + { total: 0, month: '2020-08-01' }, + { total: 100, month: '2020-07-01' }, + { total: 0, month: '2020-06-01' }, + { total: 200, month: '2020-05-01' }, + { total: 0, month: '2020-04-01' }, + { total: 100, month: '2020-03-01' }, + { total: 0, month: '2020-02-01' }, + { total: 200, month: '2020-01-01' }, + ], + }), + ], + }, + { + currency: 'CAD', + totals: [ + { total: 100, month: '2020-03-01' }, + { total: 200, month: '2020-02-01' }, + { total: 150, month: '2020-01-01' }, + ], + orderedContacts: [], + }, + ]; + const { result } = renderHook(() => useCsvData(tables), {}); + + expect(result.current).toEqual([ + ['Currency', 'USD', '$'], + [ + 'Partner', + 'Status', + 'Commitment Amount', + 'Commitment Currency', + 'Commitment Frequency', + 'Committed Monthly Equivalent', + 'In Hand Monthly Equivalent', + 'Missing In Hand Monthly Equivalent', + 'In Hand Special Gifts', + 'In Hand Date Range', + 'Dec 20', + 'Nov 20', + 'Oct 20', + 'Sep 20', + 'Aug 20', + 'Jul 20', + 'Jun 20', + 'May 20', + 'Apr 20', + 'Mar 20', + 'Feb 20', + 'Jan 20', + 'Total (last month excluded from total)', + ], + [ + 'Quarterly Partner', + 'Partner - Financial', + 450, + 'USD', + 'Quarterly', + 150, + 150, + -0, + 500, + 'Jan 20 - Apr 20', + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 350, + 300, + 250, + 200, + 84, + ], + [ + 'Annual Partner', + 'Partner - Financial', + 1200, + 'USD', + 'Annual', + 100, + 75, + -25, + 0, + 'Jan 20 - Dec 20', + 0, + 100, + 0, + 200, + 0, + 100, + 0, + 200, + 0, + 100, + 0, + 200, + 84, + ], + [ + 'Totals', + '', + '', + '', + '', + 250, + 225, + -25, + 500, + '', + 100, + 200, + 150, + 100, + 200, + 150, + 100, + 200, + 150, + 100, + 200, + 150, + 1800, + ], + ['Currency', 'CAD', '$'], + [ + 'Partner', + 'Status', + 'Commitment Amount', + 'Commitment Currency', + 'Commitment Frequency', + 'Committed Monthly Equivalent', + 'In Hand Monthly Equivalent', + 'Missing In Hand Monthly Equivalent', + 'In Hand Special Gifts', + 'In Hand Date Range', + 'Mar 20', + 'Feb 20', + 'Jan 20', + 'Total (last month excluded from total)', + ], + ['Totals', '', '', '', '', 0, 0, 0, 0, '', 100, 200, 150, 450], + ]); + }); +}); diff --git a/src/components/Reports/FourteenMonthReports/useCsvData.ts b/src/components/Reports/FourteenMonthReports/useCsvData.ts new file mode 100644 index 000000000..235648796 --- /dev/null +++ b/src/components/Reports/FourteenMonthReports/useCsvData.ts @@ -0,0 +1,149 @@ +import { useMemo } from 'react'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +import { useApiConstants } from 'src/components/Constants/UseApiConstants'; +import { useLocale } from 'src/hooks/useLocale'; +import type { CurrencyTable } from './FourteenMonthReport'; + +export type CsvData = (string | number)[][]; + +const formatMonth = (month: string, locale: string): string => + DateTime.fromISO(month).toJSDate().toLocaleDateString(locale, { + month: 'short', + year: '2-digit', + }); + +/* + * Return the data for a CSV export of the provided currency tables. + */ +export const useCsvData = (currencyTables: CurrencyTable[]): CsvData => { + const { t } = useTranslation(); + const locale = useLocale(); + const apiConstants = useApiConstants(); + + const csvData = useMemo( + () => + currencyTables.flatMap(({ currency, orderedContacts, totals }) => { + // Each table starts with two rows of headers + const csvHeaders = [ + [ + t('Currency'), + currency, + apiConstants?.pledgeCurrency?.find(({ code }) => code === currency) + ?.symbol ?? '', + ], + [ + t('Partner'), + t('Status'), + t('Commitment Amount'), + t('Commitment Currency'), + t('Commitment Frequency'), + t('Committed Monthly Equivalent'), + t('In Hand Monthly Equivalent'), + t('Missing In Hand Monthly Equivalent'), + t('In Hand Special Gifts'), + t('In Hand Date Range'), + ...totals.map(({ month }) => formatMonth(month, locale)), + t('Total (last month excluded from total)'), + ], + ]; + + // Then one row for each contact + const csvBody = orderedContacts.map((contact) => { + const numMonthsForMonthlyEquivalent = Math.max( + 4, + parseInt(contact.pledgeFrequency ?? '4'), + ); + + const pledgedMonthlyEquivalent = + contact.status === 'Partner - Financial' && + contact.pledgeAmount && + contact.pledgeFrequency + ? Math.round( + contact.pledgeAmount / parseFloat(contact.pledgeFrequency), + ) + : ''; + + const inHandMonths = contact.months.slice( + -numMonthsForMonthlyEquivalent, + ); + + const inHandMonthlyEquivalent = + contact.status === 'Partner - Financial' && contact.pledgeFrequency + ? Math.round( + inHandMonths.reduce((sum, month) => sum + month.total, 0) / + numMonthsForMonthlyEquivalent, + ) + : ''; + + const inHandDateRange = inHandMonthlyEquivalent + ? `${formatMonth( + inHandMonths[inHandMonths.length - 1].month, + locale, + )} - ${formatMonth(inHandMonths[0].month, locale)}` + : ''; + + return [ + contact.name, + contact.status ?? '', + contact.pledgeAmount ? Math.round(contact.pledgeAmount) : 0, + contact.pledgeCurrency ?? '', + apiConstants?.pledgeFrequency?.find( + ({ key }) => key === contact.pledgeFrequency, + )?.value ?? '', + pledgedMonthlyEquivalent, + inHandMonthlyEquivalent !== '' && pledgedMonthlyEquivalent !== '' + ? Math.min(pledgedMonthlyEquivalent, inHandMonthlyEquivalent) + : '', + inHandMonthlyEquivalent !== '' && pledgedMonthlyEquivalent !== '' + ? -Math.max(0, pledgedMonthlyEquivalent - inHandMonthlyEquivalent) + : '', + inHandMonthlyEquivalent !== '' && pledgedMonthlyEquivalent !== '' + ? Math.max( + 0, + inHandMonthlyEquivalent - pledgedMonthlyEquivalent, + ) * numMonthsForMonthlyEquivalent + : Math.round(contact.total), + inHandDateRange, + ...contact.months.map((month) => Math.round(month.total)), + Math.round(contact.total), + ]; + }); + + const roundedTotals = totals.map(({ total }) => Math.round(total)); + + // Then one row of totals + const csvTotals = [ + t('Totals'), + '', + '', + '', + '', + csvBody.reduce( + (sum, row) => sum + (typeof row[5] === 'number' ? row[5] : 0), + 0, + ), + csvBody.reduce( + (sum, row) => sum + (typeof row[6] === 'number' ? row[6] : 0), + 0, + ), + csvBody.reduce( + (sum, row) => sum + (typeof row[7] === 'number' ? row[7] : 0), + 0, + ), + csvBody.reduce( + (sum, row) => sum + (typeof row[8] === 'number' ? row[8] : 0), + 0, + ), + '', + ...roundedTotals, + roundedTotals.reduce((sum, monthTotal) => sum + monthTotal, 0), + ]; + + return [...csvHeaders, ...csvBody, csvTotals]; + }), + [currencyTables, apiConstants], + ); + + return csvData; +}; diff --git a/src/lib/apollo/cache.ts b/src/lib/apollo/cache.ts index 2e962a634..5011dd80c 100644 --- a/src/lib/apollo/cache.ts +++ b/src/lib/apollo/cache.ts @@ -64,6 +64,9 @@ export const createCache = () => }, }, }, + // Disable cache normalization for 14 month report contacts because a contact in one currency group should not be + // merged a contact with the same id in a different currency group + FourteenMonthReportContact: { keyFields: false }, // Disable cache normalization for tags because a tag like { id: 'abc', count: 3 } in one period should not be // merged with a tag like { id: 'def', count 2 } in another period Tag: { keyFields: false },