diff --git a/pages/accountLists/[accountListId]/reports/financialAccounts/Wrapper.tsx b/pages/accountLists/[accountListId]/reports/financialAccounts/Wrapper.tsx new file mode 100644 index 000000000..fc80f44df --- /dev/null +++ b/pages/accountLists/[accountListId]/reports/financialAccounts/Wrapper.tsx @@ -0,0 +1,88 @@ +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react'; +import { FinancialAccountProvider } from 'src/components/Reports/FinancialAccountsReport/Context/FinancialAccountsContext'; +import { + DateRangeInput, + InputMaybe, + Scalars, +} from 'src/graphql/types.generated'; + +interface Props { + children?: React.ReactNode; +} + +export enum FinancialAccountPageEnum { + FinancialAccountPage = 'FinancialAccountPage', + AccountSummaryPage = 'AccountSummaryPage', + AccountTransactionsPage = 'AccountTransactionsPage', +} + +export interface FinancialAccountTransactionFilters { + dateRange?: InputMaybe; + categoryId?: InputMaybe; +} + +export const FinancialAccountsWrapper: React.FC = ({ children }) => { + const router = useRouter(); + const { query, replace, pathname, isReady } = router; + + const urlFilters = + query?.filters && JSON.parse(decodeURI(query.filters as string)); + + const [activeFilters, setActiveFilters] = + useState(urlFilters ?? {}); + + const [page, setPage] = useState( + FinancialAccountPageEnum.FinancialAccountPage, + ); + const [financialAccountId, setFinancialAccountId] = useState< + string | undefined + >(undefined); + + const { financialAccount, searchTerm, accountListId } = query; + + useEffect(() => { + if (!financialAccount) { + setPage(FinancialAccountPageEnum.FinancialAccountPage); + return; + } + const length = financialAccount.length; + setFinancialAccountId(financialAccount[0]); + if (length === 1) { + setPage(FinancialAccountPageEnum.AccountSummaryPage); + } else if (length > 1) { + setPage(FinancialAccountPageEnum.AccountTransactionsPage); + } + }, [financialAccount, accountListId]); + + useEffect(() => { + if (!isReady) { + return; + } + + const { filters: _, ...oldQuery } = query; + replace({ + pathname, + query: { + ...oldQuery, + ...(Object.keys(activeFilters).length + ? { filters: encodeURI(JSON.stringify(activeFilters)) } + : undefined), + }, + }); + }, [activeFilters, isReady]); + + return ( + + {children} + + ); +}; diff --git a/pages/accountLists/[accountListId]/reports/financialAccounts/[[...financialAccount]].page.tsx b/pages/accountLists/[accountListId]/reports/financialAccounts/[[...financialAccount]].page.tsx new file mode 100644 index 000000000..e71d76eba --- /dev/null +++ b/pages/accountLists/[accountListId]/reports/financialAccounts/[[...financialAccount]].page.tsx @@ -0,0 +1,173 @@ +import Head from 'next/head'; +import React, { ReactElement, useContext, useMemo } from 'react'; +import { Box } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { loadSession } from 'pages/api/utils/pagePropsHelpers'; +import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; +import Loading from 'src/components/Loading'; +import { + FinancialAccountContext, + FinancialAccountType, +} from 'src/components/Reports/FinancialAccountsReport/Context/FinancialAccountsContext'; +import { MainContent } from 'src/components/Reports/FinancialAccountsReport/MainContent/MainContent'; +import { DynamicFilterPanel } from 'src/components/Shared/Filters/DynamicFilterPanel'; +import { + ContextTypesEnum, + FilterInput, +} from 'src/components/Shared/Filters/FilterPanel'; +import { headerHeight } from 'src/components/Shared/Header/ListHeader'; +import { + MultiPageMenu, + NavTypeEnum, +} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { Panel } from '../helpers'; +import { + FinancialAccountTransactionFilters, + FinancialAccountsWrapper, +} from './Wrapper'; + +const FinancialAccounts = (): ReactElement => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { appName } = useGetAppSettings(); + + const { + isNavListOpen, + designationAccounts, + setDesignationAccounts, + handleNavListToggle, + financialAccountsQuery, + activeFilters, + panelOpen, + setPanelOpen, + setActiveFilters, + setSearchTerm, + } = useContext(FinancialAccountContext) as FinancialAccountType; + + const { data } = financialAccountsQuery; + + const filterGroups = useMemo(() => { + const categoryOptions = + data?.financialAccount.categories.nodes + .map((category) => { + const name = category?.name ?? category?.code ?? ''; + return { + __typename: 'FilterOption' as const, + value: category.id, + name, + placeholder: name, + }; + }) + .sort((a, b) => + a.name && b.name ? a.name.localeCompare(b.name) : 0, + ) ?? []; + + return [ + { + __typename: 'FilterGroup' as const, + name: 'Transaction Date', + featured: true, + filters: [ + { + __typename: 'DaterangeFilter' as const, + title: 'Transaction Date', + filterKey: 'dateRange', + options: [], + }, + ], + }, + { + __typename: 'FilterGroup' as const, + name: 'Category', + featured: true, + filters: [ + { + __typename: 'RadioFilter' as const, + title: 'Category', + filterKey: 'categoryId', + defaultSelection: '', + options: [ + { + __typename: 'FilterOption' as const, + value: 'all-categories', + name: 'All Categories', + placeholder: 'All Categories', + }, + ...categoryOptions, + ], + }, + ], + }, + ]; + }, [data]); + + const handleSelectedFiltersChanged = (selectedFilters: FilterInput) => { + setActiveFilters(selectedFilters as FinancialAccountTransactionFilters); + }; + + const handleClearSearch = () => { + setSearchTerm(''); + }; + + return ( + <> + + + {appName} | {t('Reports - Responsibility Centers')} + + + + {accountListId ? ( + + } + leftPanel={ + panelOpen === Panel.Navigation ? ( + + ) : panelOpen === Panel.Filters ? ( + setPanelOpen(null)} + onSelectedFiltersChanged={handleSelectedFiltersChanged} + onHandleClearSearch={handleClearSearch} + contextType={ContextTypesEnum.FinancialAccountReport} + showSaveButton={false} + /> + ) : undefined + } + /> + + ) : ( + + )} + + ); +}; + +const FinancialAccountsPage: React.FC = () => ( + + + +); + +export const getServerSideProps = loadSession; + +export default FinancialAccountsPage; diff --git a/pages/accountLists/[accountListId]/reports/helpers.ts b/pages/accountLists/[accountListId]/reports/helpers.ts new file mode 100644 index 000000000..2aafc9d11 --- /dev/null +++ b/pages/accountLists/[accountListId]/reports/helpers.ts @@ -0,0 +1,4 @@ +export enum Panel { + Navigation = 'Navigation', + Filters = 'Filters', +} diff --git a/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx b/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx index eb1ac7e87..ce97cbd07 100644 --- a/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx +++ b/pages/accountLists/[accountListId]/reports/partnerGivingAnalysis/[[...contactId]].page.tsx @@ -10,7 +10,6 @@ import { DynamicContactsRightPanel } from 'src/components/Contacts/ContactsRight import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import Loading from 'src/components/Loading'; import { - Panel, PartnerGivingAnalysisReport, PartnerGivingAnalysisReportRef, } from 'src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport'; @@ -25,6 +24,7 @@ import useGetAppSettings from 'src/hooks/useGetAppSettings'; import { getQueryParam } from 'src/utils/queryParam'; import { useContactFiltersQuery } from '../../contacts/Contacts.generated'; import { ContactsWrapper } from '../../contacts/ContactsWrapper'; +import { Panel } from '../helpers'; // The order here is also the sort order and the display order const reportFilters = [ diff --git a/pages/accountLists/[accountListId]/reports/responsibilityCenters.page.tsx b/pages/accountLists/[accountListId]/reports/responsibilityCenters.page.tsx deleted file mode 100644 index b5d0044ce..000000000 --- a/pages/accountLists/[accountListId]/reports/responsibilityCenters.page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import Head from 'next/head'; -import React, { useState } from 'react'; -import { Box } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { useTranslation } from 'react-i18next'; -import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; -import Loading from 'src/components/Loading'; -import { ResponsibilityCentersReport } from 'src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport'; -import { - MultiPageMenu, - NavTypeEnum, -} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; -import { useAccountListId } from 'src/hooks/useAccountListId'; -import useGetAppSettings from 'src/hooks/useGetAppSettings'; - -const ResponsibilityCentersReportPageWrapper = styled(Box)(({ theme }) => ({ - backgroundColor: theme.palette.common.white, -})); - -const ResponsibilityCentersReportPage: React.FC = () => { - const { t } = useTranslation(); - const accountListId = useAccountListId(); - const { appName } = useGetAppSettings(); - const [isNavListOpen, setNavListOpen] = useState(false); - const [designationAccounts, setDesignationAccounts] = useState([]); - - const handleNavListToggle = () => { - setNavListOpen(!isNavListOpen); - }; - - return ( - <> - - - {appName} | {t('Reports - Responsibility Centers')} - - - {accountListId ? ( - - - } - leftOpen={isNavListOpen} - leftWidth="290px" - mainContent={ - - } - /> - - ) : ( - - )} - - ); -}; - -export const getServerSideProps = loadSession; - -export default ResponsibilityCentersReportPage; diff --git a/pages/api/Schema/index.ts b/pages/api/Schema/index.ts index 4ccf32fe1..8f53c05b9 100644 --- a/pages/api/Schema/index.ts +++ b/pages/api/Schema/index.ts @@ -39,6 +39,10 @@ import { EntryHistoriesResolvers } from './reports/entryHistories/resolvers'; import ExpectedMonthlyTotalReportTypeDefs from './reports/expectedMonthlyTotal/expectedMonthlyTotal.graphql'; import { ExpectedMonthlyTotalReportResolvers } from './reports/expectedMonthlyTotal/resolvers'; import FinancialAccountsTypeDefs from './reports/financialAccounts/financialAccounts.graphql'; +import FinancialAccountSummaryTypeDefs from './reports/financialAccounts/financialAccounts/financialAccounts.graphql'; +import { FinancialAccountSummaryResolvers } from './reports/financialAccounts/financialAccounts/resolvers'; +import FinancialAccountEntriesTypeDefs from './reports/financialAccounts/financialEntries/financialEntries.graphql'; +import { financialAccountEntriesResolvers } from './reports/financialAccounts/financialEntries/resolvers'; import { FinancialAccountsResolvers } from './reports/financialAccounts/resolvers'; import FourteenMonthReportTypeDefs from './reports/fourteenMonth/fourteenMonth.graphql'; import { FourteenMonthReportResolvers } from './reports/fourteenMonth/resolvers'; @@ -127,6 +131,14 @@ const schema = buildSubgraphSchema([ typeDefs: DeleteTagsTypeDefs, resolvers: DeleteTagsResolvers, }, + { + typeDefs: FinancialAccountSummaryTypeDefs, + resolvers: FinancialAccountSummaryResolvers, + }, + { + typeDefs: FinancialAccountEntriesTypeDefs, + resolvers: financialAccountEntriesResolvers, + }, ...integrationSchema, ...organizationSchema, ...preferencesSchema, diff --git a/pages/api/Schema/reports/entryHistories/datahandler.ts b/pages/api/Schema/reports/entryHistories/datahandler.ts index 12ac36492..4d79bb710 100644 --- a/pages/api/Schema/reports/entryHistories/datahandler.ts +++ b/pages/api/Schema/reports/entryHistories/datahandler.ts @@ -36,13 +36,16 @@ export const createEntryHistoriesGroup = ( entryHistories: data // The last entry is the total for the whole year, so ignore it .slice(0, -1) - .map((entryHistory) => createEntryHistory(entryHistory)), + .map((entryHistory) => + createEntryHistory(entryHistory, financialAccountId), + ), }); const createEntryHistory = ( history: EntryHistoriesResponse, + financialAccountId: string, ): EntryHistoryRest => ({ closingBalance: Number(history.attributes.closing_balance), endDate: history.attributes.end_date, - id: history.id, + id: `${history.id}-${financialAccountId}`, }); diff --git a/pages/api/Schema/reports/financialAccounts/financialAccounts/datahandler.ts b/pages/api/Schema/reports/financialAccounts/financialAccounts/datahandler.ts new file mode 100644 index 000000000..8aeaa5a24 --- /dev/null +++ b/pages/api/Schema/reports/financialAccounts/financialAccounts/datahandler.ts @@ -0,0 +1,38 @@ +import { fetchAllData } from 'src/lib/deserializeJsonApi'; +import { FinancialAccountSummaryResponse } from '../../../../graphql-rest.page.generated'; + +export interface FinancialAccountSummaryRest { + id: string; + attributes: { + closing_balance: number; + credits: number; + debits: number; + difference: number; + end_date: string; + opening_balance: number; + start_date: string; + }; + relationships: { + credit_by_categories: { data: IdType[] }; + debit_by_categories: { data: IdType[] }; + }; +} + +interface IdType { + id: string; + type: string; +} + +export const financialAccountSummaryHandler = ({ + data, + included, +}: { + data: FinancialAccountSummaryRest[]; + included: unknown[]; +}): FinancialAccountSummaryResponse[] => { + const financialAccountSummary = data.map((item) => { + return fetchAllData(item, included); + }) as FinancialAccountSummaryResponse[]; + + return financialAccountSummary; +}; diff --git a/pages/api/Schema/reports/financialAccounts/financialAccounts/financialAccounts.graphql b/pages/api/Schema/reports/financialAccounts/financialAccounts/financialAccounts.graphql new file mode 100644 index 000000000..fcff761c4 --- /dev/null +++ b/pages/api/Schema/reports/financialAccounts/financialAccounts/financialAccounts.graphql @@ -0,0 +1,35 @@ +extend type Query { + financialAccountSummary( + input: FinancialAccountSummaryInput! + ): [FinancialAccountSummaryResponse]! +} + +input FinancialAccountSummaryInput { + accountListId: ID! + financialAccountId: ID! +} + +type FinancialAccountSummaryResponse { + id: ID! + closingBalance: String + credits: String + debits: String + difference: String + endDate: String! + openingBalance: String + startDate: String! + creditByCategories: [FinancialAccountSummaryCategory]! + debitByCategories: [FinancialAccountSummaryCategory]! +} + +type FinancialAccountSummaryCategory { + id: ID! + amount: String + category: FinancialAccountCategory +} + +type FinancialAccountCategory { + id: ID! + code: String + name: String +} diff --git a/pages/api/Schema/reports/financialAccounts/financialAccounts/resolvers.ts b/pages/api/Schema/reports/financialAccounts/financialAccounts/resolvers.ts new file mode 100644 index 000000000..dcc43de60 --- /dev/null +++ b/pages/api/Schema/reports/financialAccounts/financialAccounts/resolvers.ts @@ -0,0 +1,16 @@ +import { Resolvers } from '../../../../graphql-rest.page.generated'; + +export const FinancialAccountSummaryResolvers: Resolvers = { + Query: { + financialAccountSummary: ( + _source, + { input: { accountListId, financialAccountId } }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.financialAccountSummary( + accountListId, + financialAccountId, + ); + }, + }, +}; diff --git a/pages/api/Schema/reports/financialAccounts/financialEntries/datahandler.ts b/pages/api/Schema/reports/financialAccounts/financialEntries/datahandler.ts new file mode 100644 index 000000000..6eee86886 --- /dev/null +++ b/pages/api/Schema/reports/financialAccounts/financialEntries/datahandler.ts @@ -0,0 +1,73 @@ +import { + FinancialAccountEntry, + FinancialAccountMetaData, +} from 'src/graphql/types.generated'; +import { fetchAllData } from 'src/lib/deserializeJsonApi'; +import { snakeToCamel } from 'src/lib/snakeToCamel'; +import { FinancialAccountEntriesResponse } from '../../../../graphql-rest.page.generated'; + +export interface FinancialAccountEntriesRest { + id: string; + attributes: { + amount: string; + code: string; + currency: string; + description: string; + entry_date: string; + type: string; + }; + relationships: { + category: { data: IdType[] }; + }; +} + +export interface FinancialAccountMetaRest { + id: string; + pagination: { + page: string; + per_page: number; + total_count: number; + total_pages: number; + }; + sort: string; + filter: string; + credits: string; + debits: string; + difference: string; + currency: string; + closing_balance: string; + opening_balance: string; +} + +interface IdType { + id: string; + type: string; +} + +export const financialAccountEntriesHandler = ({ + data, + included, + meta, +}: { + data: FinancialAccountEntriesRest[]; + included: unknown[]; + meta: FinancialAccountMetaRest; +}): FinancialAccountEntriesResponse => { + const entries = data.map((item) => { + return fetchAllData(item, included); + }) as FinancialAccountEntry[]; + + // Remove pagination as it's not needed since we get all transactions + const metaData = {} as FinancialAccountMetaData; + Object.keys(meta).forEach((key) => { + if (key === 'pagination') { + return; + } + metaData[snakeToCamel(key)] = meta[key]; + }); + + return { + entries, + metaData, + }; +}; diff --git a/pages/api/Schema/reports/financialAccounts/financialEntries/financialEntries.graphql b/pages/api/Schema/reports/financialAccounts/financialEntries/financialEntries.graphql new file mode 100644 index 000000000..7be1a7bce --- /dev/null +++ b/pages/api/Schema/reports/financialAccounts/financialEntries/financialEntries.graphql @@ -0,0 +1,40 @@ +extend type Query { + financialAccountEntries( + input: FinancialAccountEntriesInput! + ): FinancialAccountEntriesResponse! +} + +input FinancialAccountEntriesInput { + accountListId: ID! + financialAccountId: ID! + dateRange: String! + categoryId: ID + wildcardSearch: String +} + +type FinancialAccountEntriesResponse { + entries: [FinancialAccountEntry!]! + metaData: FinancialAccountMetaData! +} + +type FinancialAccountEntry { + id: ID! + amount: String! + currency: String! + code: String! + description: String! + entryDate: ISO8601Date! + type: String! + category: FinancialAccountCategory! +} + +type FinancialAccountMetaData { + sort: String + filter: String + credits: String + debits: String + difference: String + currency: String + closingBalance: String + openingBalance: String +} diff --git a/pages/api/Schema/reports/financialAccounts/financialEntries/resolvers.ts b/pages/api/Schema/reports/financialAccounts/financialEntries/resolvers.ts new file mode 100644 index 000000000..879b442be --- /dev/null +++ b/pages/api/Schema/reports/financialAccounts/financialEntries/resolvers.ts @@ -0,0 +1,27 @@ +import { Resolvers } from '../../../../graphql-rest.page.generated'; + +export const financialAccountEntriesResolvers: Resolvers = { + Query: { + financialAccountEntries: ( + _source, + { + input: { + accountListId, + financialAccountId, + dateRange, + categoryId, + wildcardSearch, + }, + }, + { dataSources }, + ) => { + return dataSources.mpdxRestApi.financialAccountEntries( + accountListId, + financialAccountId, + dateRange, + categoryId, + wildcardSearch, + ); + }, + }, +}; diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 37cc299dc..6026f8e77 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -109,6 +109,8 @@ import { FinancialAccountResponse, setActiveFinancialAccount, } from './Schema/reports/financialAccounts/datahandler'; +import { financialAccountSummaryHandler } from './Schema/reports/financialAccounts/financialAccounts/datahandler'; +import { financialAccountEntriesHandler } from './Schema/reports/financialAccounts/financialEntries/datahandler'; import { FourteenMonthReportResponse, mapFourteenMonthReport, @@ -758,11 +760,50 @@ class MpdxRestApi extends RESTDataSource { return setActiveFinancialAccount(data); } - async deleteComment(taskId: string, commentId: string) { - const { data }: { data: DeleteCommentResponse } = await this.delete( - `tasks/${taskId}/comments/${commentId}`, + // + // Financial Account Report -- Start + + async financialAccountSummary( + accountListId: string, + financialAccountId: string, + ) { + const include = + 'credit_by_categories,debit_by_categories,credit_by_categories.category,debit_by_categories.category'; + const filters = `filter[account_list_id]=${accountListId}&filter[financial_account_id]=${financialAccountId}`; + const fields = + 'fields[financial_account_entry_by_categories]=amount,category&fields[financial_account_entry_categories]=name,code' + + '&fields[reports_entry_histories_periods]=closing_balance,opening_balance,start_date,end_date,credits,debits,difference,credit_by_categories,debit_by_categories'; + + const data = await this.get( + `reports/entry_histories?${fields}&${filters}&include=${include}`, ); - return DeleteComment({ ...data, id: commentId }); + + return financialAccountSummaryHandler(data); + } + + async financialAccountEntries( + accountListId: string, + financialAccountId: string, + dateRange: string, + categoryId?: string | null, + wildcardSearch?: string | null, + ) { + const fields = + 'fields[financial_account_entry_categories]=name,code&fields[financial_account_entry_credits]=amount,code,currency,description,entry_date,category,type' + + '&fields[financial_account_entry_debits]=amount,code,currency,description,entry_date,category,type'; + + let filters = `filter[entryDate]=${dateRange}&filter[financialAccountId]=${financialAccountId}`; + if (categoryId) { + filters += `&filter[categoryId]=${categoryId}`; + } + if (wildcardSearch) { + filters += `&filter[wildcardSearch]=${wildcardSearch}`; + } + const data = await this.get( + `account_lists/${accountListId}/entries?${fields}&${filters}&include=category&per_page=10000&sort=-entry_date`, + ); + + return financialAccountEntriesHandler(data); } async getEntryHistories( @@ -782,6 +823,16 @@ class MpdxRestApi extends RESTDataSource { }); } + // + // Financial Account Report -- End + + async deleteComment(taskId: string, commentId: string) { + const { data }: { data: DeleteCommentResponse } = await this.delete( + `tasks/${taskId}/comments/${commentId}`, + ); + return DeleteComment({ ...data, id: commentId }); + } + async updateComment(taskId: string, commentId: string, body: string) { const { data }: { data: UpdateCommentResponse } = await this.put( `tasks/${taskId}/comments/${commentId}`, diff --git a/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchDialog.tsx b/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchDialog.tsx index 2cdf9cc7a..094f7957c 100644 --- a/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchDialog.tsx +++ b/src/components/Layouts/Primary/TopBar/Items/SearchMenu/SearchDialog.tsx @@ -144,7 +144,7 @@ export const SearchDialog: React.FC = ({ handleClose }) => { { name: t('Reports - Responsibility Centers'), icon: , - link: `/accountLists/${accountListId}/reports/responsibilityCenters`, + link: `/accountLists/${accountListId}/reports/financialAccounts`, }, { name: t('Reports - Expected Monthly Total'), diff --git a/src/components/Reports/AccountsListLayout/List/List.test.tsx b/src/components/Reports/AccountsListLayout/List/List.test.tsx index 42ea76537..1a83f3b5e 100644 --- a/src/components/Reports/AccountsListLayout/List/List.test.tsx +++ b/src/components/Reports/AccountsListLayout/List/List.test.tsx @@ -2,13 +2,17 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import TestRouter from '__tests__/util/TestRouter'; import theme from 'src/theme'; import { AccountsList as List } from './List'; const onCheckToggle = jest.fn(); - +const accountListId = 'accountListId'; +const router = { + query: { accountListId }, + isReady: true, +}; const orgName = 'test org'; - const accounts = [ { active: false, @@ -21,31 +25,27 @@ const accounts = [ }, ]; +const Components = () => ( + + + + + +); + describe('AccountsGroupList', () => { it('default', async () => { - const { queryByTestId } = render( - - - , - ); + const { queryByTestId } = render(); expect(queryByTestId('AccountsGroupList')).toBeInTheDocument(); }); it('should be check event called', async () => { - const { queryByTestId, getByRole } = render( - - - , - ); + const { queryByTestId, getByRole } = render(); expect(queryByTestId('AccountsGroupList')).toBeInTheDocument(); userEvent.click(getByRole('checkbox')); diff --git a/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx b/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx index 0744f424e..6c55ccb51 100644 --- a/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx +++ b/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.test.tsx @@ -2,16 +2,21 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import TestRouter from '__tests__/util/TestRouter'; import { afterTestResizeObserver, beforeTestResizeObserver, } from '__tests__/util/windowResizeObserver'; import theme from 'src/theme'; -import { AccountListItem as ListItem } from './ListItem'; +import { Account, AccountListItem as ListItem } from './ListItem'; const onCheckToggle = jest.fn(); - -const account = { +const accountListId = 'accountListId'; +const router = { + query: { accountListId }, + isReady: true, +}; +const defaultAccount: Account = { active: false, id: 'test-id-111', balance: 3500, @@ -21,35 +26,31 @@ const account = { name: 'Test Account', }; +const Components = ({ account = defaultAccount }: { account?: Account }) => ( + + + + + +); + describe('AccountItem', () => { it('default', async () => { - const { getByRole, getByText } = render( - - - , - ); + const { getByRole, getByText } = render(); expect(getByRole('checkbox')).not.toBeChecked(); - expect(getByText(account.name)).toBeInTheDocument(); + expect(getByText(defaultAccount.name as string)).toBeInTheDocument(); }); it('should be check event called', async () => { - const { getByRole } = render( - - - , - ); + const { getByRole } = render(); userEvent.click(getByRole('checkbox')); expect(onCheckToggle).toHaveBeenCalled(); }); it('should not render chart', async () => { - const { queryByTestId } = render( - - - , - ); + const { queryByTestId } = render(); expect(queryByTestId('AccountItemChart')).not.toBeInTheDocument(); }); @@ -73,16 +74,13 @@ describe('AccountItem', () => { ]; const { queryByTestId } = render( - - - , + , ); expect(queryByTestId('AccountItemChart')).toBeInTheDocument(); diff --git a/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.tsx b/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.tsx index dcf6a2944..44d469835 100644 --- a/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.tsx +++ b/src/components/Reports/AccountsListLayout/List/ListItem/ListItem.tsx @@ -1,8 +1,10 @@ +import NextLink from 'next/link'; import React, { FC, useMemo } from 'react'; import { Box, Checkbox, Divider, + Link, ListItem, ListItemText, Typography, @@ -11,7 +13,8 @@ import { styled } from '@mui/material/styles'; import { Maybe } from 'graphql/jsutils/Maybe'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; -import { EntryHistoriesQuery } from 'src/components/Reports/ResponsibilityCentersReport/GetEntryHistories.generated'; +import { EntryHistoriesQuery } from 'src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.generated'; +import { useAccountListId } from 'src/hooks/useAccountListId'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat, dateFormat } from 'src/lib/intlFormat'; import { Unarray } from '../../../Reports.type'; @@ -51,6 +54,7 @@ export const AccountListItem: FC = ({ onCheckToggle, }) => { const { t } = useTranslation(); + const accountListId = useAccountListId() ?? ''; const locale = useLocale(); const average = useMemo(() => { @@ -109,16 +113,25 @@ export const AccountListItem: FC = ({ : '' }`} - - {/* {hasFinancial && ( - - // Used to link to be a handoff link to /reports/financial_accounts/${account.id} - Summary + {hasFinancial && ( + + + {t('Summary')} + {' · '} - // Used to link to be a handoff link to /reports/financial_accounts/${account.id}/entries - Transactions + + {t('Transactions')} + - )} */} + )} {currencyFormat( diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.test.tsx b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.test.tsx new file mode 100644 index 000000000..d9e90fe9c --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.test.tsx @@ -0,0 +1,261 @@ +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 { SnackbarProvider } from 'notistack'; +import { I18nextProvider } from 'react-i18next'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { FinancialAccountsWrapper } from 'pages/accountLists/[accountListId]/reports/financialAccounts/Wrapper'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../Context/FinancialAccountsContext'; +import { + AccountSummary, + AppendCategoryToCategoriesArray, + appendCategoryToCategoriesArray, +} from './AccountSummary'; +import { + creditByCategories, + defaultFinancialAccount, + defaultFinancialAccountSummary, +} from './AccountSummaryMock'; +import { FinancialAccountSummaryQuery } from './financialAccountSummary.generated'; + +const accountListId = 'accountListId'; +const financialAccountId = 'financialAccountId'; +const router = { + query: { accountListId }, + isReady: true, +}; +const startDate = '2024-01-01'; +const endDate = '2024-01-31'; +const mutationSpy = jest.fn(); + +const Component = () => ( + + + + + + + mocks={{ + FinancialAccountSummary: defaultFinancialAccountSummary, + }} + onCall={mutationSpy} + > + + + + + + + + + + + +); + +describe('AccountSummary', () => { + it('regular', async () => { + const { getByText, findByText } = render(); + + expect(await findByText('Category')).toBeInTheDocument(); + expect(getByText('Total')).toBeInTheDocument(); + expect(getByText('Opening Balance')).toBeInTheDocument(); + expect(getByText('Income')).toBeInTheDocument(); + expect(getByText('Total Income')).toBeInTheDocument(); + expect(getByText('Expenses')).toBeInTheDocument(); + expect(getByText('Total Expenses')).toBeInTheDocument(); + expect(getByText('Surplus/Deficit')).toBeInTheDocument(); + expect(getByText('Balance')).toBeInTheDocument(); + }); + + it('should append the categories to the array', async () => { + const categoryArray: AppendCategoryToCategoriesArray['categoryArray'] = []; + appendCategoryToCategoriesArray({ + categories: + creditByCategories as AppendCategoryToCategoriesArray['categories'], + categoryArray, + startDate, + endDate, + index: 0, + }); + + expect(categoryArray).toHaveLength(2); + expect(categoryArray).toEqual([ + { + id: '111', + name: 'Category 1', + months: [ + { + amount: 5000, + endDate, + startDate, + }, + ], + }, + { + id: '222', + name: 'Code 2', + months: [ + { + amount: 5000, + endDate, + startDate, + }, + ], + }, + ]); + + appendCategoryToCategoriesArray({ + categories: [ + { + ...creditByCategories[0], + amount: '-3000', + }, + { + ...creditByCategories[1], + amount: '-6000', + }, + ] as AppendCategoryToCategoriesArray['categories'], + categoryArray, + startDate: '2024-02-01', + endDate: '2024-02-29', + index: 1, + }); + + expect(categoryArray).toHaveLength(2); + expect(categoryArray).toEqual([ + { + id: '111', + name: 'Category 1', + months: [ + { + amount: 5000, + endDate, + startDate, + }, + { + amount: 3000, + startDate: '2024-02-01', + endDate: '2024-02-29', + }, + ], + }, + { + id: '222', + name: 'Code 2', + months: [ + { + amount: 5000, + endDate, + startDate, + }, + { + amount: 6000, + startDate: '2024-02-01', + endDate: '2024-02-29', + }, + ], + }, + ]); + }); + + it('should show correct months', async () => { + const { getByText, findByText, queryByText } = render(); + + expect(await findByText('Jan 24')).toBeInTheDocument(); + expect(getByText('Feb 24')).toBeInTheDocument(); + expect(getByText('Mar 24')).toBeInTheDocument(); + expect(queryByText('Apr 24')).not.toBeInTheDocument(); + }); + + it('should format number to correctly', async () => { + const { getByText, findByText } = render(); + // Turning '-10001.25' into '10,001' + expect(await findByText('10,002')).toBeInTheDocument(); + + // Turning '5684' to '-5,684' + expect(getByText('-5,684')).toBeInTheDocument(); + }); + + it('should show correct data for Opening Balance', async () => { + const { getByText, findByText } = render(); + + expect(await findByText('Opening Balance')).toBeInTheDocument(); + expect(getByText('10,002')).toBeInTheDocument(); + expect(getByText('9,005')).toBeInTheDocument(); + expect(getByText('12,000')).toBeInTheDocument(); + expect(getByText('31,000')).toBeInTheDocument(); + }); + + it('should render the categories', async () => { + const { getByText, findByText } = render(); + + expect(await findByText('Category 1')).toBeInTheDocument(); + // Should show category code if name is null/undefined + expect(getByText('Code 2')).toBeInTheDocument(); + expect(getByText('Negative Category 1')).toBeInTheDocument(); + // Should show category code if name is null/undefined + expect(getByText('Negative Code 2')).toBeInTheDocument(); + }); + + it('should show correct data for Total Income', async () => { + const { getByText, findByText } = render(); + + expect(await findByText('Total Income')).toBeInTheDocument(); + expect(getByText('5,555')).toBeInTheDocument(); + expect(getByText('6,666')).toBeInTheDocument(); + expect(getByText('3,333')).toBeInTheDocument(); + expect(getByText('14,444')).toBeInTheDocument(); + }); + + it('should show correct data for Total Expenses', async () => { + const { getByText, findByText } = render(); + + expect(await findByText('Total Expenses')).toBeInTheDocument(); + expect(getByText('2,895')).toBeInTheDocument(); + expect(getByText('1,689')).toBeInTheDocument(); + expect(getByText('2,689')).toBeInTheDocument(); + expect(getByText('5,689')).toBeInTheDocument(); + }); + + it('should show correct data for Surplus/Deficit', async () => { + const { getByText, findByText } = render(); + + expect(await findByText('Surplus/Deficit')).toBeInTheDocument(); + expect(getByText('-5,684')).toBeInTheDocument(); + expect(getByText('-1,864')).toBeInTheDocument(); + expect(getByText('-3,864')).toBeInTheDocument(); + expect(getByText('-6,864')).toBeInTheDocument(); + }); + + it('should show correct data for Balance', async () => { + const { getByText, findByText } = render(); + + expect(await findByText('Balance')).toBeInTheDocument(); + expect(getByText('7,000')).toBeInTheDocument(); + expect(getByText('8,000')).toBeInTheDocument(); + expect(getByText('10,000')).toBeInTheDocument(); + expect(getByText('25,000')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.tsx b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.tsx new file mode 100644 index 000000000..f013b1b47 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummary.tsx @@ -0,0 +1,393 @@ +import NextLink from 'next/link'; +import React, { useContext, useMemo } from 'react'; +import { + Box, + CircularProgress, + Divider, + Link, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +import { Maybe } from 'src/graphql/types.generated'; +import { useLocale } from 'src/hooks/useLocale'; +import { monthYearFormat, numberFormat } from 'src/lib/intlFormat'; +import theme from 'src/theme'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../Context/FinancialAccountsContext'; +import { FinancialAccountHeader } from '../Header/Header'; +import { AccountSummaryCategory } from './AccountSummaryCategory/AccountSummaryCategory'; +import { + Category, + createTransactionsUrl, + formatNumber, +} from './AccountSummaryHelper'; +import { + FinancialAccountCategoriesFragment, + useFinancialAccountSummaryQuery, +} from './financialAccountSummary.generated'; + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, +})); + +interface Periods { + startDateFormatted: string; + startDate: string; + endDate: string; +} + +/** + * Converts the "amount" string to a number. + * If the value is 0 or isExpense is true, it returns the value as is. + * Otherwise, it removes the '-' character if present, or prepends it if absent. + */ +const formatAmount = (amount?: string | null, isExpense?: boolean): number => { + if (!amount) { + return 0; + } + + if (amount === '0' || isExpense) { + return formatNumber(amount, false); + } + const formattedAmount = + amount?.[0] === '-' ? amount.substring(1) : `-${amount}`; + return formatNumber(formattedAmount, false); +}; + +export interface AppendCategoryToCategoriesArray { + categories: Maybe[]; + categoryArray: Category[]; + startDate: string; + endDate: string; + index: number; + isExpenses?: boolean; +} + +export const appendCategoryToCategoriesArray = ({ + categories, + categoryArray, + startDate, + endDate, + index, + isExpenses = false, +}: AppendCategoryToCategoriesArray) => { + categories.forEach((category) => { + const id = category?.category?.id ?? ''; + const name = category?.category?.name ?? category?.category?.code ?? ''; + const amount = formatAmount(category?.amount, isExpenses); + if (index === 0) { + categoryArray.push({ + id, + name, + months: [{ amount, startDate, endDate }], + }); + } else { + const existingCategory = categoryArray.find((c) => c.id === id); + if (existingCategory) { + existingCategory.months.push({ amount, startDate, endDate }); + } + } + }); +}; + +export const AccountSummary: React.FC = () => { + const { t } = useTranslation(); + const locale = useLocale(); + const { accountListId, financialAccountId } = useContext( + FinancialAccountContext, + ) as FinancialAccountType; + + const { data } = useFinancialAccountSummaryQuery({ + variables: { + accountListId, + financialAccountId: financialAccountId ?? '', + }, + }); + + const tableData = useMemo(() => { + const credits: number[] = []; + const creditsCategories: Category[] = []; + const closingBalances: number[] = []; + const debits: number[] = []; + const debitsCategories: Category[] = []; + const openingBalances: number[] = []; + const periods: Periods[] = []; + const surplus: number[] = []; + + data?.financialAccountSummary.forEach((item, idx) => { + if (!item) { + return; + } + + // Credits + credits.push(formatNumber(item.credits)); + + // Closing Balances + openingBalances.push(formatNumber(item.openingBalance)); + + // Debits + debits.push(formatNumber(item.debits)); + + // Closing Balances + closingBalances.push(formatNumber(item.closingBalance)); + + // Surplus + const difference = formatAmount(item.difference); + surplus.push(difference); + + // Periods + const startDateFormatted = monthYearFormat( + DateTime.fromISO(item?.startDate ?? '').month, + DateTime.fromISO(item?.startDate ?? '').year, + locale, + false, + ); + periods.push({ + startDateFormatted: + idx === data.financialAccountSummary.length - 1 + ? 'Total' + : startDateFormatted, + startDate: item?.startDate ?? '', + endDate: item?.endDate ?? '', + }); + + // Credits Categories + appendCategoryToCategoriesArray({ + categories: item.creditByCategories, + categoryArray: creditsCategories, + startDate: item?.startDate ?? '', + endDate: item?.endDate ?? '', + index: idx, + }); + + // Debits Categories + appendCategoryToCategoriesArray({ + categories: item.debitByCategories, + categoryArray: debitsCategories, + startDate: item?.startDate ?? '', + endDate: item?.endDate ?? '', + index: idx, + isExpenses: true, + }); + }); + + return { + closingBalances, + credits, + creditsCategories: creditsCategories.sort((a, b) => + a.name.localeCompare(b.name), + ), + debits, + debitsCategories: debitsCategories.sort((a, b) => + a.name.localeCompare(b.name), + ), + openingBalances, + periods, + surplus, + }; + }, [data]); + + return ( + <> + + + {!data && ( + + + + )} + + {data && ( + + + + + + + {t('Category')} + + {tableData.periods?.map((period, idx) => { + const monthStart = DateTime.fromISO(period.startDate) + .startOf('month') + .toISODate(); + const monthEnd = DateTime.fromISO(period.endDate) + .endOf('month') + .toISODate(); + const url = createTransactionsUrl({ + accountListId, + financialAccountId: financialAccountId ?? '', + startDate: monthStart ?? '', + endDate: monthEnd ?? '', + }); + return ( + + + {period.startDateFormatted} + + + ); + })} + + + + + {/* Opening Balance */} + + + + {t('Opening Balance')} + + + {tableData.openingBalances?.map((balance, idx) => { + return ( + + {numberFormat(balance, locale)} + + ); + })} + + {/* Income header */} + + + {t('Income')} + + {tableData.periods?.map((_, idx) => { + return ( + + ); + })} + + + {/* Income Categories */} + {tableData.creditsCategories?.map((category) => ( + + ))} + + {/* Total Income */} + + + + {t('Total Income')} + + + {tableData.credits?.map((credit, idx) => { + return ( + + + {numberFormat(credit, locale)} + + + ); + })} + + + {/* Expenses header */} + + + {t('Expenses')} + + {tableData.periods?.map((_, idx) => { + return ( + + ); + })} + + + {/* Expenses Categories */} + {tableData.debitsCategories?.map((category) => ( + + ))} + + {/* Total Expenses */} + + + + {t('Total Expenses')} + + + {tableData.debits?.map((credit, idx) => { + return ( + + + {numberFormat(credit, locale)} + + + ); + })} + + + {/* Surplus */} + + + + {t('Surplus/Deficit')} + + + {tableData.surplus?.map((value, idx) => { + return ( + + + {numberFormat(value, locale)} + + + ); + })} + + + {/* Closing Balance */} + + + + {t('Balance')} + + + {tableData.closingBalances?.map((balance, idx) => { + return ( + + + {numberFormat(balance, locale)} + + + ); + })} + + +
+
+
+ )} + + ); +}; diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryCategory/AccountSummaryCategory.tsx b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryCategory/AccountSummaryCategory.tsx new file mode 100644 index 000000000..e20ba7657 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryCategory/AccountSummaryCategory.tsx @@ -0,0 +1,61 @@ +import NextLink from 'next/link'; +import { Link, TableRow, Typography } from '@mui/material'; +import { useLocale } from 'src/hooks/useLocale'; +import { numberFormat } from 'src/lib/intlFormat'; +import { + Category, + createTransactionsUrl, + oneYearAgoDate, + todaysDate, +} from '../AccountSummaryHelper'; +import { StyledTableCell } from '../styledComponents'; + +interface CategoriesProps { + category: Category; + accountListId: string; + financialAccountId: string; +} +export const AccountSummaryCategory: React.FC = ({ + category, + accountListId, + financialAccountId, +}) => { + const locale = useLocale(); + + const transactionsUrl = createTransactionsUrl({ + accountListId, + financialAccountId, + startDate: oneYearAgoDate, + endDate: todaysDate, + categoryId: category.id, + }); + return ( + + + + + {category.name} + + + + + {category.months.map((month, idx) => { + const url = createTransactionsUrl({ + accountListId, + financialAccountId, + startDate: month.startDate, + endDate: month.endDate, + categoryId: category.id, + }); + + return ( + + + {numberFormat(month.amount, locale)} + + + ); + })} + + ); +}; diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryHelper.ts b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryHelper.ts new file mode 100644 index 000000000..37ff59ec2 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryHelper.ts @@ -0,0 +1,68 @@ +import { DateTime } from 'luxon'; + +interface CreateFiltersProps { + accountListId: string; + financialAccountId: string; + startDate?: string; + endDate?: string; + categoryId?: string; +} +interface Filters { + dateRange?: { + min?: string; + max?: string; + }; + categoryId?: string; +} + +export interface Category { + id: string; + name: string; + months: { + amount: number; + startDate: string; + endDate: string; + }[]; +} + +export const oneYearAgoDate = DateTime.local() + .minus({ years: 1 }) + .plus({ days: 1 }) + .toISODate(); +export const todaysDate = DateTime.local().toISODate(); + +export const createTransactionsUrl = ({ + accountListId, + financialAccountId, + startDate, + endDate, + categoryId, +}: CreateFiltersProps) => { + const transactionsUrl = `/accountLists/${accountListId}/reports/financialAccounts/${financialAccountId}/transactions`; + if (!startDate && !endDate && !categoryId) { + return transactionsUrl; + } + + const filters: Filters = {}; + if (startDate && endDate) { + filters['dateRange'] = { min: startDate, max: endDate }; + } + if (categoryId) { + filters['categoryId'] = categoryId; + } + + return `${transactionsUrl}?filters=${encodeURIComponent( + JSON.stringify(filters), + )}`; +}; + +export const formatNumber = ( + numberAsString?: string | number | null, + makeAbsolute = true, +) => { + const number = + typeof numberAsString === 'string' + ? Number(numberAsString) + : numberAsString ?? 0; + return Math.ceil(makeAbsolute ? Math.abs(number) : number); +}; diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryMock.ts b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryMock.ts new file mode 100644 index 000000000..c5c6e44cf --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/AccountSummaryMock.ts @@ -0,0 +1,177 @@ +import { FinancialAccountQuery } from '../Context/FinancialAccount.generated'; + +export const defaultFinancialAccount: FinancialAccountQuery = { + financialAccount: { + id: 'abc123', + code: 'accountCode', + name: 'Account 1', + balance: { + convertedAmount: 1000, + convertedCurrency: 'USD', + }, + categories: { + nodes: [ + { + id: '1', + code: '100', + entryType: 'CREDIT', + name: 'Category 1', + }, + { + id: '2', + code: '200', + entryType: 'DEBIT', + name: 'Category 2', + }, + ], + }, + organization: { + id: '1', + name: 'Organization 1', + }, + }, +}; + +export const creditByCategories = [ + { + amount: '-5000', + category: { + id: '111', + name: 'Category 1', + code: 'Code 1', + }, + }, + { + amount: '-5000', + category: { + id: '222', + name: null, + code: 'Code 2', + }, + }, +]; +const debitByCategories = [ + { + amount: '9000', + category: { + id: '333', + name: 'Negative Category 1', + code: 'Negative Code 1', + }, + }, + { + amount: '4000', + category: { + id: '444', + name: null, + code: 'Negative Code 2', + }, + }, +]; + +export const defaultFinancialAccountSummary = { + financialAccountSummary: [ + { + id: '1', + closingBalance: '-7000', + credits: '-5555', + debits: '2895', + difference: '5684', + endDate: '2024-01-31', + openingBalance: '-10001.25', + startDate: '2024-01-01', + creditByCategories, + debitByCategories, + }, + { + id: '2', + closingBalance: '-8000', + credits: '-6666', + debits: '1689', + difference: '1864', + endDate: '2024-02-29', + openingBalance: '-9005', + startDate: '2024-02-01', + creditByCategories: [ + { + ...creditByCategories[0], + amount: '-3000', + }, + { + ...creditByCategories[1], + amount: '-6000', + }, + ], + debitByCategories: [ + { + ...debitByCategories[0], + amount: '4000', + }, + { + ...debitByCategories[1], + amount: '1000', + }, + ], + }, + { + id: '3', + closingBalance: '-10000', + credits: '-3333', + debits: '2689', + difference: '3864', + endDate: '2024-03-31', + openingBalance: '-12000', + startDate: '2024-03-01', + creditByCategories: [ + { + ...creditByCategories[0], + amount: '-4500', + }, + { + ...creditByCategories[1], + amount: '-3000', + }, + ], + debitByCategories: [ + { + ...debitByCategories[0], + amount: '2000', + }, + { + ...debitByCategories[1], + amount: '4000', + }, + ], + }, + { + id: '4', + closingBalance: '-25000', + credits: '-14444', + debits: '5689', + difference: '6864', + endDate: '2024-03-31', + openingBalance: '-31000', + startDate: '2024-01-01', + creditByCategories: [ + { + ...creditByCategories[0], + amount: '-19000', + }, + { + ...creditByCategories[1], + amount: '-23000', + }, + ], + debitByCategories: [ + { + ...debitByCategories[0], + amount: '19500', + }, + { + ...debitByCategories[1], + amount: '23000', + }, + ], + }, + ], +}; diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/DynamicAccountSummary.tsx b/src/components/Reports/FinancialAccountsReport/AccountSummary/DynamicAccountSummary.tsx new file mode 100644 index 000000000..6d3ce67c5 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/DynamicAccountSummary.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const preloadAccountSummary = () => + import(/* webpackChunkName: "AccountSummary" */ './AccountSummary').then( + ({ AccountSummary }) => AccountSummary, + ); + +export const DynamicAccountSummary = dynamic(preloadAccountSummary, { + loading: DynamicComponentPlaceholder, +}); diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/financialAccountSummary.graphql b/src/components/Reports/FinancialAccountsReport/AccountSummary/financialAccountSummary.graphql new file mode 100644 index 000000000..643dabb73 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/financialAccountSummary.graphql @@ -0,0 +1,33 @@ +query FinancialAccountSummary($accountListId: ID!, $financialAccountId: ID!) { + financialAccountSummary( + input: { + accountListId: $accountListId + financialAccountId: $financialAccountId + } + ) { + id + closingBalance + credits + debits + difference + endDate + openingBalance + startDate + creditByCategories { + ...FinancialAccountCategories + } + debitByCategories { + ...FinancialAccountCategories + } + } +} + +fragment FinancialAccountCategories on FinancialAccountSummaryCategory { + id + amount + category { + id + code + name + } +} diff --git a/src/components/Reports/FinancialAccountsReport/AccountSummary/styledComponents.tsx b/src/components/Reports/FinancialAccountsReport/AccountSummary/styledComponents.tsx new file mode 100644 index 000000000..03c0ef92e --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountSummary/styledComponents.tsx @@ -0,0 +1,6 @@ +import { TableCell } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const StyledTableCell = styled(TableCell)(({ theme }) => ({ + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, +})); diff --git a/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactionTable/AccountTransactionTable.tsx b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactionTable/AccountTransactionTable.tsx new file mode 100644 index 000000000..64d7a8def --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactionTable/AccountTransactionTable.tsx @@ -0,0 +1,357 @@ +import React, { useContext, useMemo, useState } from 'react'; +import { + Box, + Table, + TableBody, + TableCell, + TableRow, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DataGrid, GridColDef, GridSortModel } from '@mui/x-data-grid'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat, dateFormatShort } from 'src/lib/intlFormat'; +import { formatNumber } from '../../AccountSummary/AccountSummaryHelper'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../../Context/FinancialAccountsContext'; +import { FinancialAccountEntriesQuery } from '../financialAccountTransactions.generated'; + +const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ + '.MuiDataGrid-row:nth-of-type(2n + 1):not(:hover)': { + backgroundColor: theme.palette.cruGrayLight.main, + }, + '.MuiDataGrid-cell': { + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + + '.MuiDataGrid-main .MuiDataGrid-row': { + minHeight: '60px !important', + }, + + '.MuiDataGrid-main .MuiDataGrid-row[data-id="openingBalanceRow"], .MuiDataGrid-main .MuiDataGrid-row[data-id="closingBalanceRow"]': + { + backgroundColor: theme.palette.cruGrayMedium.main, + fontWeight: 'bold', + }, +})); + +const TotalsTable = styled(Table)(({ theme }) => ({ + marginBottom: theme.spacing(8), + '.MuiTableCell-root': { + fontWeight: 'bold', + }, + + '.MuiTableRow-root .MuiTableCell-root': { + textAlign: 'right', + }, +})); + +export enum FinancialAccountEntryTypeEnum { + Credit = 'FinancialAccount::Entry::Credit', + Debit = 'FinancialAccount::Entry::Debit', +} + +type RenderCell = GridColDef['renderCell']; + +interface TransactionRow { + id: string; + code: string; + description: string; + type: string; + categoryName: string; + categoryCode: string; + currency: string; + expenseAmount?: string; + incomeAmount?: string; + entryDate: DateTime; +} + +const createTransactionRow = ( + entry: FinancialAccountEntriesQuery['financialAccountEntries']['entries'][0], +): TransactionRow => { + const { category: _category, amount, ...rest } = entry; + const amounts = + entry.type === FinancialAccountEntryTypeEnum.Debit + ? { expenseAmount: amount } + : { incomeAmount: amount }; + return { + ...rest, + ...amounts, + categoryName: entry.category.name ?? entry.category.code ?? '', + categoryCode: entry.category.code ?? '', + entryDate: DateTime.fromISO(entry.entryDate), + }; +}; + +interface CreateBalanceRowProps { + id: string; + description: string; + currency?: string | null; + incomeAmount?: string | null; + expenseAmount?: string | null; + entryDate: DateTime; +} + +const createBalanceRow = ({ + id, + description, + currency, + incomeAmount = '', + expenseAmount = '', + entryDate, +}: CreateBalanceRowProps): TransactionRow => { + return { + id, + code: '', + description, + type: FinancialAccountEntryTypeEnum.Credit, + categoryName: '', + categoryCode: '', + currency: currency ?? 'USD', + incomeAmount: incomeAmount ?? '0', + expenseAmount: expenseAmount ?? '0', + entryDate, + }; +}; + +const isBalanceRow = (id: string) => + id === 'openingBalanceRow' || id === 'closingBalanceRow'; + +interface TableProps { + financialAccountEntries: FinancialAccountEntriesQuery['financialAccountEntries']; + defaultStartDate: string; + defaultEndDate: string; +} + +export const AccountTransactionTable: React.FC = ({ + financialAccountEntries, + defaultStartDate, + defaultEndDate, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + const [pageSize, setPageSize] = useState(25); + const [sortModel, setSortModel] = useState([ + { field: 'date', sort: 'desc' }, + ]); + + const { activeFilters } = useContext( + FinancialAccountContext, + ) as FinancialAccountType; + + const { entries, metaData } = financialAccountEntries; + const { + credits, + debits, + difference, + currency, + openingBalance, + closingBalance, + } = metaData; + + const transactions = useMemo(() => { + const transactionRows = entries.map((entry) => createTransactionRow(entry)); + + if (!!activeFilters.categoryId) { + return transactionRows; + } else { + return [ + createBalanceRow({ + id: 'closingBalanceRow', + currency, + description: t('Closing Balance'), + incomeAmount: closingBalance, + entryDate: DateTime.fromISO( + activeFilters.dateRange?.max ?? defaultEndDate, + ), + }), + ...transactionRows, + createBalanceRow({ + id: 'openingBalanceRow', + currency, + description: t('Opening Balance'), + incomeAmount: openingBalance, + entryDate: DateTime.fromISO( + activeFilters.dateRange?.min ?? defaultStartDate, + ), + }), + ]; + } + }, [entries, currency, openingBalance, closingBalance, activeFilters]); + + const Date: RenderCell = ({ row }) => ( + {dateFormatShort(row.entryDate, locale)} + ); + const Category: RenderCell = ({ row }) => ( + + {row.categoryName} + {row.categoryCode} + + ); + const Details: RenderCell = ({ row }) => ( + + + {row.description} + + {row.code} + + ); + const Expenses: RenderCell = ({ row }) => { + if (row.type === FinancialAccountEntryTypeEnum.Debit) { + return ( + + {currencyFormat( + formatNumber(row.expenseAmount), + row.currency, + locale, + )} + + ); + } + return ''; + }; + const Income: RenderCell = ({ row }) => { + if (row.type === FinancialAccountEntryTypeEnum.Credit) { + return ( + + {currencyFormat(formatNumber(row.incomeAmount), row.currency, locale)} + + ); + } + return ''; + }; + + const columns: GridColDef[] = [ + { + field: 'entryDate', + headerName: t('Date'), + flex: 1, + minWidth: 80, + renderCell: Date, + }, + { + field: 'categoryName', + headerName: t('Category'), + flex: 2, + minWidth: 200, + renderCell: Category, + }, + { + field: 'description', + headerName: t('Details'), + flex: 3, + minWidth: 300, + renderCell: Details, + }, + { + field: 'expenseAmount', + headerName: t('Expenses'), + flex: 1, + minWidth: 120, + renderCell: Expenses, + }, + { + field: 'incomeAmount', + headerName: t('Income'), + flex: 1, + minWidth: 120, + renderCell: Income, + align: 'right', + }, + ]; + + return ( + <> + setPageSize(pageSize)} + rowsPerPageOptions={[25, 50, 100]} + pagination + sortModel={sortModel} + onSortModelChange={(sortModel) => setSortModel(sortModel)} + disableSelectionOnClick + disableVirtualization + disableColumnMenu + rowHeight={70} + autoHeight + getRowHeight={() => 'auto'} + /> + + + + + + + + + + + + + ); +}; + +interface TotalTableRowProps { + title: string; + amount?: string | null; + currency?: string | null; + locale?: string; +} + +const TotalTableRow: React.FC = ({ + title, + amount, + currency, + locale, +}) => ( + + + + {title} + + + + {amount && + currency && + locale && + currencyFormat(formatNumber(amount), currency, locale)} + + +); diff --git a/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx new file mode 100644 index 000000000..617e55035 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx @@ -0,0 +1,195 @@ +import { useRouter } from 'next/router'; +import React, { useContext, useEffect, useMemo } from 'react'; +import { Box, CircularProgress } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +import { Panel } from 'pages/accountLists/[accountListId]/reports/helpers'; +import { headerHeight } from 'src/components/Shared/Header/ListHeader'; +import { useDebouncedValue } from 'src/hooks/useDebounce'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat, dateFormatShort } from 'src/lib/intlFormat'; +import { formatNumber } from '../AccountSummary/AccountSummaryHelper'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../Context/FinancialAccountsContext'; +import { FinancialAccountHeader } from '../Header/Header'; +import { + AccountTransactionTable, + FinancialAccountEntryTypeEnum, +} from './AccountTransactionTable/AccountTransactionTable'; +import { useFinancialAccountEntriesQuery } from './financialAccountTransactions.generated'; + +const Container = styled(Box)(() => ({ + height: `calc(100vh - ${headerHeight})`, + overflowY: 'auto', +})); + +const formatDateRange = (startDate?: DateTime, endDate?: DateTime) => { + if (!startDate) { + startDate = DateTime.local().minus({ months: 1 }).plus({ days: 1 }); + } + if (!endDate) { + endDate = DateTime.local(); + } + return `${startDate.toISODate()}..${endDate.toISODate()}`; +}; + +const defaultDateRange = formatDateRange(); +const defaultStartDate = defaultDateRange.split('..')[0]; +const defaultEndDate = defaultDateRange.split('..')[1]; + +export const AccountTransactions: React.FC = () => { + const { query } = useRouter(); + const { t } = useTranslation(); + const locale = useLocale(); + const { + accountListId, + financialAccountId, + activeFilters, + setActiveFilters, + hasActiveFilters, + searchTerm, + setPanelOpen, + } = useContext(FinancialAccountContext) as FinancialAccountType; + const { appName } = useGetAppSettings(); + + useEffect(() => { + // On loading the transactions page, open the filters panel and reset the active filters. + // We need to reset the active filters to ensure the date range is set to the date range in the URL if it exists. + const urlFilters = + query?.filters && JSON.parse(decodeURI(query.filters as string)); + setPanelOpen(Panel.Filters); + setActiveFilters(urlFilters ?? {}); + return () => { + setPanelOpen(null); + setActiveFilters({}); + }; + }, [query?.filters]); + + useEffect(() => { + if (!hasActiveFilters && !query?.filters) { + setActiveFilters({ + dateRange: { + min: defaultStartDate, + max: defaultEndDate, + }, + }); + } + }, [hasActiveFilters]); + + const dateRange = useMemo(() => { + if (!activeFilters?.dateRange?.min || !activeFilters?.dateRange?.max) { + return defaultDateRange; + } + return formatDateRange( + DateTime.fromISO(activeFilters.dateRange.min), + DateTime.fromISO(activeFilters.dateRange.max), + ); + }, [activeFilters]); + const categoryId = + activeFilters?.categoryId && activeFilters?.categoryId !== 'all-categories' + ? activeFilters?.categoryId + : ''; + const wildcardSearch = useDebouncedValue(searchTerm, 500); + + const { data, loading } = useFinancialAccountEntriesQuery({ + variables: { + input: { + accountListId, + financialAccountId: financialAccountId ?? '', + dateRange, + categoryId, + wildcardSearch, + }, + }, + }); + + const handleExportCSV = () => { + if (!data) { + return; + } + const columnHeaders = [ + t('Date'), + t('Payee'), + t('Memo'), + t('Outflow'), + t('Inflow'), + ]; + const convertDataToArray = data.financialAccountEntries.entries.map( + (entry) => [ + dateFormatShort(DateTime.fromISO(entry.entryDate), locale), + entry.description ?? '', + entry.category?.name ?? entry.category?.code ?? '', + entry.type === FinancialAccountEntryTypeEnum.Debit + ? currencyFormat( + formatNumber(entry.amount), + entry.currency, + locale, + ).replace(/[\xA0\u2000-\u200B\uFEFF]/g, ' ') + : '', + entry.type === FinancialAccountEntryTypeEnum.Credit + ? currencyFormat( + formatNumber(entry.amount), + entry.currency, + locale, + ).replace(/[\xA0\u2000-\u200B\uFEFF]/g, ' ') + : '', + ], + [columnHeaders], + ); + + // Convert Array to CSV format + const csvContent = + 'data:text/csv;charset=utf-8,' + + convertDataToArray + .map((row) => + row + .map((field) => `"${String(field).replace(/"/g, '""')}"`) + .join(','), + ) + .join('\n'); + + // Create a link and trigger download + const encodedUri = encodeURI(csvContent); + const link = document.createElement('a'); + link.setAttribute('href', encodedUri); + link.setAttribute('download', `${appName}-entries-export${dateRange}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( + + + + {!data && ( + + + + )} + + {data && ( + + + + )} + + ); +}; diff --git a/src/components/Reports/FinancialAccountsReport/AccountTransactions/DynamicAccountTransactions.tsx b/src/components/Reports/FinancialAccountsReport/AccountTransactions/DynamicAccountTransactions.tsx new file mode 100644 index 000000000..361468c6c --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/DynamicAccountTransactions.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const preloadAccountTransactions = () => + import( + /* webpackChunkName: "AccountTransactions" */ './AccountTransactions' + ).then(({ AccountTransactions }) => AccountTransactions); + +export const DynamicAccountTransactions = dynamic(preloadAccountTransactions, { + loading: DynamicComponentPlaceholder, +}); diff --git a/src/components/Reports/FinancialAccountsReport/AccountTransactions/financialAccountTransactions.graphql b/src/components/Reports/FinancialAccountsReport/AccountTransactions/financialAccountTransactions.graphql new file mode 100644 index 000000000..5ed803669 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/financialAccountTransactions.graphql @@ -0,0 +1,26 @@ +query FinancialAccountEntries($input: FinancialAccountEntriesInput!) { + financialAccountEntries(input: $input) { + entries { + id + amount + currency + code + description + entryDate + type + category { + id + code + name + } + } + metaData { + credits + debits + difference + currency + closingBalance + openingBalance + } + } +} diff --git a/src/components/Reports/FinancialAccountsReport/Context/FinancialAccount.graphql b/src/components/Reports/FinancialAccountsReport/Context/FinancialAccount.graphql new file mode 100644 index 000000000..c696b2327 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/Context/FinancialAccount.graphql @@ -0,0 +1,23 @@ +query FinancialAccount($financialAccountId: ID!, $accountListId: ID!) { + financialAccount(id: $financialAccountId, accountListId: $accountListId) { + id + code + name + balance { + convertedAmount + convertedCurrency + } + categories { + nodes { + id + code + entryType + name + } + } + organization { + id + name + } + } +} diff --git a/src/components/Reports/FinancialAccountsReport/Context/FinancialAccountsContext.tsx b/src/components/Reports/FinancialAccountsReport/Context/FinancialAccountsContext.tsx new file mode 100644 index 000000000..44759d3a4 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/Context/FinancialAccountsContext.tsx @@ -0,0 +1,159 @@ +import { ParsedUrlQueryInput } from 'querystring'; +import { useRouter } from 'next/router'; +import React, { Dispatch, SetStateAction, useState } from 'react'; +import { + FinancialAccountPageEnum, + FinancialAccountTransactionFilters, +} from 'pages/accountLists/[accountListId]/reports/financialAccounts/Wrapper'; +import { Panel } from 'pages/accountLists/[accountListId]/reports/helpers'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { useFinancialAccountQuery } from './FinancialAccount.generated'; + +interface SetFinancialAccountProps { + id?: string; + viewTransactions?: boolean; + transactionFilters?: ParsedUrlQueryInput; +} + +export type SetFinancialAccountFunction = ({ + id, + viewTransactions, + transactionFilters, +}: SetFinancialAccountProps) => void; + +export interface FinancialAccountType { + accountListId: string; + financialAccountId: string | undefined; + searchTerm: string; + setSearchTerm: Dispatch>; + financialAccountsQuery: ReturnType; + setFinancialAccount: SetFinancialAccountFunction; + activeFilters: FinancialAccountTransactionFilters; + hasActiveFilters: boolean; + setActiveFilters: Dispatch< + SetStateAction + >; + urlFilters: any; + page: FinancialAccountPageEnum | undefined; + setPage: Dispatch>; + isNavListOpen: boolean; + designationAccounts: string[]; + setDesignationAccounts: Dispatch>; + panelOpen: Panel | null; + setPanelOpen: Dispatch>; + handleNavListToggle: () => void; + handleFilterListToggle: () => void; + handleClearAll: () => void; +} + +export const FinancialAccountContext = + React.createContext(null); + +interface FinancialAccountProviderProps { + children?: React.ReactNode; + urlFilters?: any; + activeFilters: FinancialAccountTransactionFilters; + setActiveFilters: Dispatch< + SetStateAction + >; + financialAccountId: string | undefined; + search: string | string[] | undefined; + page: FinancialAccountPageEnum; + setPage: Dispatch>; +} + +export const FinancialAccountProvider: React.FC< + FinancialAccountProviderProps +> = ({ + children, + urlFilters, + activeFilters, + setActiveFilters, + financialAccountId, + search, + page, + setPage, +}) => { + const accountListId = useAccountListId() ?? ''; + const router = useRouter(); + const { push } = router; + + const [designationAccounts, setDesignationAccounts] = useState([]); + const [panelOpen, setPanelOpen] = useState(null); + const [searchTerm, setSearchTerm] = useState( + typeof search === 'string' ? search : '', + ); + + const handleNavListToggle = () => { + setPanelOpen(panelOpen === Panel.Navigation ? null : Panel.Navigation); + }; + + const handleFilterListToggle = () => { + setPanelOpen(panelOpen === Panel.Filters ? null : Panel.Filters); + }; + + const handleClearAll = () => { + setSearchTerm(''); + }; + + const financialAccountsQuery = useFinancialAccountQuery({ + variables: { + accountListId, + financialAccountId: financialAccountId ?? '', + }, + skip: !financialAccountId, + }); + + const setFinancialAccount: SetFinancialAccountFunction = ({ + id, + viewTransactions, + transactionFilters, + }: SetFinancialAccountProps) => { + let pathname = ''; + pathname = `/accountLists/${accountListId}/reports/financialAccounts`; + + if (id) { + pathname += `/${id}`; + if (viewTransactions) { + pathname += `/entries`; + } + } + + push({ + pathname, + query: transactionFilters ?? {}, + }); + }; + + const hasActiveFilters = !!Object.keys(activeFilters).length; + const isNavListOpen = !!panelOpen; + + return ( + + {children} + + ); +}; diff --git a/src/components/Reports/FinancialAccountsReport/FinancialAccounts/DynamicFinancialAccounts.tsx b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/DynamicFinancialAccounts.tsx new file mode 100644 index 000000000..f4224d9a9 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/DynamicFinancialAccounts.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const preloadFinancialAccounts = () => + import( + /* webpackChunkName: "FinancialAccounts" */ './FinancialAccounts' + ).then(({ FinancialAccounts }) => FinancialAccounts); + +export const DynamicFinancialAccounts = dynamic(preloadFinancialAccounts, { + loading: DynamicComponentPlaceholder, +}); diff --git a/src/components/Reports/ResponsibilityCentersReport/GetFinancialAccounts.graphql b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.graphql similarity index 54% rename from src/components/Reports/ResponsibilityCentersReport/GetFinancialAccounts.graphql rename to src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.graphql index 95f44bf0b..808cd6d70 100644 --- a/src/components/Reports/ResponsibilityCentersReport/GetFinancialAccounts.graphql +++ b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.graphql @@ -1,3 +1,17 @@ +query EntryHistories($accountListId: ID!, $financialAccountIds: [ID!]!) { + entryHistories( + accountListId: $accountListId + financialAccountIds: $financialAccountIds + ) { + financialAccountId + entryHistories { + closingBalance + endDate + id + } + } +} + query FinancialAccounts( $accountListId: ID! $designationAccountIds: [ID!] @@ -30,3 +44,10 @@ query FinancialAccounts( } } } + +mutation SetActiveFinancialAccount($input: SetActiveFinancialAccountInput!) { + setActiveFinancialAccount(input: $input) { + active + id + } +} diff --git a/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.test.tsx b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.test.tsx new file mode 100644 index 000000000..9edb1681f --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.test.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ApolloErgonoMockMap } from 'graphql-ergonomock'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import theme from 'src/theme'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../Context/FinancialAccountsContext'; +import { FinancialAccounts } from './FinancialAccounts'; +import { + FinancialAccountsDocument, + FinancialAccountsQuery, +} from './FinancialAccounts.generated'; + +jest.mock('next/router', () => ({ + useRouter: () => { + return { + query: { accountListId: 'abc' }, + isReady: true, + }; + }, +})); + +const accountListId = '111'; +const onNavListToggle = jest.fn(); +const mutationSpy = jest.fn(); + +const mocks = { + FinancialAccounts: { + financialAccounts: { + nodes: [ + { + active: true, + balance: { + conversionDate: '2021-02-02', + convertedAmount: 3500, + convertedCurrency: 'CAD', + }, + code: '13212', + id: 'test-id-111', + name: 'Test Account', + organization: { + id: '111-2222-3333', + name: 'test org 01', + }, + updatedAt: '2021-02-02', + }, + ], + }, + }, +}; + +const errorMock = { + request: { + query: FinancialAccountsDocument, + }, + error: { name: 'error', message: 'Error loading data. Try again.' }, +}; + +const emptyMocks = { + FinancialAccounts: { + financialAccounts: { + nodes: [], + }, + }, +}; + +interface ComponentsProps { + mocks?: ApolloErgonoMockMap; + designationAccounts?: string[]; + useErrorMockedProvider?: boolean; +} +const Components: React.FC = ({ + mocks, + designationAccounts = [], + useErrorMockedProvider = false, +}) => ( + + + {useErrorMockedProvider ? ( + + + + ) : ( + + mocks={mocks} + onCall={mutationSpy} + > + + + )} + + +); + +describe('FinancialAccounts', () => { + beforeEach(() => { + onNavListToggle.mockClear(); + }); + it('default', async () => { + const { getByText, getByTestId, queryByTestId } = render( + , + ); + + await waitFor(() => { + expect(queryByTestId('LoadingFinancialAccounts')).not.toBeInTheDocument(); + }); + + expect(getByText('Responsibility Centers')).toBeInTheDocument(); + expect(getByText('-CA$3,500')).toBeInTheDocument(); + expect(queryByTestId('Notification')).not.toBeInTheDocument(); + expect(getByTestId('AccountsGroupList')).toBeInTheDocument(); + expect(getByTestId('FinancialAccountsScrollBox')).toBeInTheDocument(); + }); + + it('renders nav list icon and onclick triggers onNavListToggle', async () => { + const { getByTestId } = render(); + + expect(getByTestId('ReportsFilterIcon')).toBeInTheDocument(); + userEvent.click(getByTestId('ReportsFilterIcon')); + await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); + }); + + it('loading', async () => { + const { queryByTestId, getByText } = render(); + + expect(getByText('Responsibility Centers')).toBeInTheDocument(); + expect(queryByTestId('LoadingFinancialAccounts')).toBeInTheDocument(); + expect(queryByTestId('Notification')).not.toBeInTheDocument(); + }); + + it('error', async () => { + const { queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('LoadingFinancialAccounts')).not.toBeInTheDocument(); + }); + + expect(queryByTestId('Notification')).toBeInTheDocument(); + }); + + it('empty', async () => { + const { queryByTestId } = render(); + + await waitFor(() => { + expect(queryByTestId('LoadingFinancialAccounts')).not.toBeInTheDocument(); + }); + + expect(queryByTestId('EmptyReport')).toBeInTheDocument(); + }); + + it('filters report by designation account', async () => { + render(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('FinancialAccounts', { + designationAccountIds: ['account-1'], + }), + ); + }); + + it('does not filter report by designation account', async () => { + render(); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('FinancialAccounts', { + designationAccountIds: null, + }), + ); + }); +}); diff --git a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.tsx b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.tsx similarity index 81% rename from src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.tsx rename to src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.tsx index d3419400f..37d43ec99 100644 --- a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.tsx +++ b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.tsx @@ -1,11 +1,5 @@ import React, { useMemo } from 'react'; -import { - Alert, - Box, - CircularProgress, - Divider, - Typography, -} from '@mui/material'; +import { Box, CircularProgress, Divider, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { Notification } from 'src/components/Notification/Notification'; @@ -17,43 +11,40 @@ import { import { useFetchAllPages } from 'src/hooks/useFetchAllPages'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; -import { AccountsList as List } from '../AccountsListLayout/List/List'; -import { useEntryHistoriesQuery } from './GetEntryHistories.generated'; +import { AccountsList as List } from '../../AccountsListLayout/List/List'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../Context/FinancialAccountsContext'; import { FinancialAccountsDocument, FinancialAccountsQuery, + useEntryHistoriesQuery, useFinancialAccountsQuery, -} from './GetFinancialAccounts.generated'; -import { useSetActiveFinancialAccountMutation } from './SetActiveFinancialAccount.generated'; -import type { Account } from '../AccountsListLayout/List/ListItem/ListItem'; + useSetActiveFinancialAccountMutation, +} from './FinancialAccounts.generated'; +import type { Account } from '../../AccountsListLayout/List/ListItem/ListItem'; import type { FinancialAccountsGroup, PreFinancialAccountsGroup, -} from './ResponsibilityCentersReport.type'; - -interface Props { - accountListId: string; - designationAccounts?: string[]; - isNavListOpen: boolean; - onNavListToggle: () => void; - title: string; -} +} from './FinancialAccounts.type'; const ScrollBox = styled(Box)(({}) => ({ height: 'calc(100vh - 160px)', overflowY: 'auto', })); -export const ResponsibilityCentersReport: React.FC = ({ - accountListId, - designationAccounts, - isNavListOpen, - onNavListToggle, - title, -}) => { +export const FinancialAccounts: React.FC = () => { const { t } = useTranslation(); const locale = useLocale(); + const { + accountListId, + isNavListOpen, + designationAccounts, + handleNavListToggle, + } = React.useContext(FinancialAccountContext) as FinancialAccountType; + const financialAccountsQueryVariables = { accountListId, designationAccountIds: designationAccounts?.length @@ -89,7 +80,9 @@ export const ResponsibilityCentersReport: React.FC = ({ preFinancialAccountsGroup, ).map(([organizationName, financialAccounts]) => ({ organizationName, - financialAccounts, + financialAccounts: financialAccounts.sort((a, b) => + (a?.name ?? '').localeCompare(b?.name ?? ''), + ), })); return financialAccountsGroup; @@ -171,6 +164,7 @@ export const ResponsibilityCentersReport: React.FC = ({ accountListId, financialAccountIds: activeFinancialAccountIds, }, + skip: !activeFinancialAccountIds.length, }); const balanceNode = @@ -187,8 +181,8 @@ export const ResponsibilityCentersReport: React.FC = ({ @@ -199,35 +193,30 @@ export const ResponsibilityCentersReport: React.FC = ({ alignItems="center" height="100%" > - + ) : error ? ( ) : data?.financialAccounts.nodes.length === 0 ? ( ) : ( - - - {t( - 'The Responsibility Centers page has some features that are not working. Developers are prioritizing this issue and it should be ready in the next few days. Please check back later.', - )} - + {financialAccountsGroups?.map((financialAccountGroup) => { const accounts: Account[] = financialAccountGroup.financialAccounts.map((account) => ({ - active: account?.active, + active: account?.active ?? false, balance: -(account?.balance.convertedAmount ?? 0), code: account?.code, currency: account?.balance.convertedCurrency ?? '', id: account?.id, - lastSyncDate: account?.balance.conversionDate, + lastSyncDate: account?.updatedAt, name: account?.name, entryHistories: entryHistoriesResponse?.data?.entryHistories?.find( diff --git a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.type.ts b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.type.ts similarity index 80% rename from src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.type.ts rename to src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.type.ts index d97ffbd22..ec2301195 100644 --- a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.type.ts +++ b/src/components/Reports/FinancialAccountsReport/FinancialAccounts/FinancialAccounts.type.ts @@ -1,4 +1,4 @@ -import { FinancialAccountsQuery } from './GetFinancialAccounts.generated'; +import { FinancialAccountsQuery } from './FinancialAccounts.generated'; export type FinancialAccount = | FinancialAccountsQuery['financialAccounts']['nodes'][0] diff --git a/src/components/Reports/FinancialAccountsReport/Header/Header.tsx b/src/components/Reports/FinancialAccountsReport/Header/Header.tsx new file mode 100644 index 000000000..42cf1394c --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/Header/Header.tsx @@ -0,0 +1,232 @@ +import NextLink from 'next/link'; +import React, { useContext } from 'react'; +import { ChevronLeft, FilterList, Menu } from '@mui/icons-material'; +import { + Box, + Button, + Divider, + Grid, + IconButton, + Link, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useTranslation } from 'react-i18next'; +import { FilterButton } from 'src/components/Shared/Header/styledComponents'; +import { SearchBox } from 'src/components/common/SearchBox/SearchBox'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat } from 'src/lib/intlFormat'; +import { formatNumber } from '../AccountSummary/AccountSummaryHelper'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../Context/FinancialAccountsContext'; + +const StickyHeader = styled(Box, { + shouldForwardProp: (prop) => prop !== 'onTransactionPage', +})<{ onTransactionPage?: boolean }>(({ theme, onTransactionPage }) => ({ + backgroundColor: theme.palette.common.white, + position: 'sticky', + top: 0, + height: onTransactionPage ? 185 : 96, + '@media print': { + paddingTop: '0', + }, + zIndex: 500, +})); + +const GoBackAction = styled(Box)(() => ({ + width: '50px', +})); + +const HeaderFilterAction = styled(Box)(() => ({ + width: '50px', +})); +const HeaderTitle = styled(Typography)(({}) => ({ + lineHeight: 1.1, +})); +const MenuIcon = styled(Menu)(({ theme }) => ({ + width: 24, + height: 24, + color: theme.palette.primary.dark, +})); +const FilterIcon = styled(FilterList)(({ theme }) => ({ + width: 24, + height: 24, + color: theme.palette.primary.dark, +})); +const Header = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + paddingTop: theme.spacing(2), +})); +const HeaderActions = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: theme.spacing(2), + width: 'calc(100% - 150px)', +})); + +interface FinancialAccountHeaderProps { + onTransactionPage?: boolean; + disableExportCSV?: boolean; + handleExportCSV?: () => void; +} +export const FinancialAccountHeader: React.FC = ({ + onTransactionPage = false, + disableExportCSV = false, + handleExportCSV, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + + const { + accountListId, + financialAccountId, + financialAccountsQuery, + handleNavListToggle, + hasActiveFilters, + handleFilterListToggle, + searchTerm, + setSearchTerm, + } = useContext(FinancialAccountContext) as FinancialAccountType; + + const financialAccount = financialAccountsQuery.data?.financialAccount; + + const handleSearchTermChange = (search: string) => { + setSearchTerm(search); + }; + + return ( + + {financialAccount && ( + + + + + + + + + + + + + + + + + + + + + + + {financialAccount.name} + + + {`${financialAccount.code ?? ''} - ${ + financialAccount.organization?.name + }`} + + {financialAccountId && ( + + + {t('Summary')} + + {' · '} + + {t('Transactions')} + + + )} + + + + + + + + {currencyFormat( + formatNumber(financialAccount.balance.convertedAmount), + financialAccount.balance.convertedCurrency, + locale, + )} + + + + )} + + {onTransactionPage && ( +
+ + + + + + + {handleExportCSV && ( + + )} + + + +
+ )} +
+ ); +}; diff --git a/src/components/Reports/FinancialAccountsReport/MainContent/MainContent.tsx b/src/components/Reports/FinancialAccountsReport/MainContent/MainContent.tsx new file mode 100644 index 000000000..e36546866 --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/MainContent/MainContent.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { FinancialAccountPageEnum } from 'pages/accountLists/[accountListId]/reports/financialAccounts/Wrapper'; +import { DynamicAccountSummary } from '../AccountSummary/DynamicAccountSummary'; +import { DynamicAccountTransactions } from '../AccountTransactions/DynamicAccountTransactions'; +import { + FinancialAccountContext, + FinancialAccountType, +} from '../Context/FinancialAccountsContext'; +import { DynamicFinancialAccounts } from '../FinancialAccounts/DynamicFinancialAccounts'; + +export const MainContent: React.FC = () => { + const { page } = React.useContext( + FinancialAccountContext, + ) as FinancialAccountType; + + return page === FinancialAccountPageEnum.AccountSummaryPage ? ( + + ) : page === FinancialAccountPageEnum.AccountTransactionsPage ? ( + + ) : ( + + ); +}; diff --git a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx index 9edb9e11b..16371d21a 100644 --- a/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx +++ b/src/components/Reports/PartnerGivingAnalysisReport/PartnerGivingAnalysisReport.tsx @@ -6,6 +6,7 @@ import React, { } from 'react'; import { Box, CircularProgress, TablePagination } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { Panel } from 'pages/accountLists/[accountListId]/reports/helpers'; import { EmptyReport } from 'src/components/Reports/EmptyReport/EmptyReport'; import { ListHeader, PageEnum } from 'src/components/Shared/Header/ListHeader'; import { @@ -25,11 +26,6 @@ import { useGetPartnerGivingAnalysisReportQuery } from './PartnerGivingAnalysisR import { PartnerGivingAnalysisReportTable as Table } from './Table/Table'; import type { Order } from '../Reports.type'; -export enum Panel { - Navigation = 'Navigation', - Filters = 'Filters', -} - interface Props { accountListId: string; panelOpen: Panel | null; diff --git a/src/components/Reports/ResponsibilityCentersReport/GetEntryHistories.graphql b/src/components/Reports/ResponsibilityCentersReport/GetEntryHistories.graphql deleted file mode 100644 index 232ad5ce8..000000000 --- a/src/components/Reports/ResponsibilityCentersReport/GetEntryHistories.graphql +++ /dev/null @@ -1,13 +0,0 @@ -query EntryHistories($accountListId: ID!, $financialAccountIds: [ID!]!) { - entryHistories( - accountListId: $accountListId - financialAccountIds: $financialAccountIds - ) { - financialAccountId - entryHistories { - closingBalance - endDate - id - } - } -} diff --git a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.test.tsx b/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.test.tsx deleted file mode 100644 index 93737a490..000000000 --- a/src/components/Reports/ResponsibilityCentersReport/ResponsibilityCentersReport.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -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 userEvent from '@testing-library/user-event'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import theme from 'src/theme'; -import { - FinancialAccountsDocument, - FinancialAccountsQuery, -} from './GetFinancialAccounts.generated'; -import { ResponsibilityCentersReport } from './ResponsibilityCentersReport'; - -jest.mock('next/router', () => ({ - useRouter: () => { - return { - query: { accountListId: 'abc' }, - isReady: true, - }; - }, -})); - -const accountListId = '111'; -const title = 'test title'; -const onNavListToggle = jest.fn(); - -const mocks = { - FinancialAccounts: { - financialAccounts: { - nodes: [ - { - active: true, - balance: { - conversionDate: '2021-02-02', - convertedAmount: 3500, - convertedCurrency: 'CAD', - }, - code: '13212', - id: 'test-id-111', - name: 'Test Account', - organization: { - id: '111-2222-3333', - name: 'test org 01', - }, - updatedAt: '2021-02-02', - }, - ], - }, - }, -}; - -const errorMock: MockedResponse = { - request: { - query: FinancialAccountsDocument, - }, - error: { name: 'error', message: 'Error loading data. Try again.' }, -}; - -const emptyMocks = { - FinancialAccounts: { - financialAccounts: { - nodes: [], - }, - }, -}; - -describe('ResponsibilityCentersReport', () => { - beforeEach(() => { - onNavListToggle.mockClear(); - }); - it('default', async () => { - const { getByText, getByTestId, queryByTestId } = render( - - - mocks={mocks} - > - - - , - ); - - await waitFor(() => { - expect( - queryByTestId('LoadingResponsibilityCenters'), - ).not.toBeInTheDocument(); - }); - - expect(getByText(title)).toBeInTheDocument(); - expect(getByText('-CA$3,500')).toBeInTheDocument(); - expect(queryByTestId('Notification')).not.toBeInTheDocument(); - expect(getByTestId('AccountsGroupList')).toBeInTheDocument(); - expect(getByTestId('ResponsibilityCentersScrollBox')).toBeInTheDocument(); - }); - - it('renders nav list icon and onclick triggers onNavListToggle', async () => { - const { getByTestId } = render( - - - mocks={mocks} - > - - - , - ); - - expect(getByTestId('ReportsFilterIcon')).toBeInTheDocument(); - userEvent.click(getByTestId('ReportsFilterIcon')); - await waitFor(() => expect(onNavListToggle).toHaveBeenCalled()); - }); - - it('loading', async () => { - const { queryByTestId, getByText } = render( - - - - - , - ); - - expect(getByText(title)).toBeInTheDocument(); - expect(queryByTestId('LoadingResponsibilityCenters')).toBeInTheDocument(); - expect(queryByTestId('Notification')).not.toBeInTheDocument(); - }); - - it('error', async () => { - const { queryByTestId } = render( - - - - - , - ); - - await waitFor(() => { - expect( - queryByTestId('LoadingResponsibilityCenters'), - ).not.toBeInTheDocument(); - }); - - expect(queryByTestId('Notification')).toBeInTheDocument(); - }); - - it('empty', async () => { - const { queryByTestId, getByText } = render( - - - mocks={emptyMocks} - > - - - , - ); - - await waitFor(() => { - expect( - queryByTestId('LoadingResponsibilityCenters'), - ).not.toBeInTheDocument(); - }); - - expect(getByText(title)).toBeInTheDocument(); - expect(queryByTestId('EmptyReport')).toBeInTheDocument(); - }); - - it('filters report by designation account', async () => { - const mutationSpy = jest.fn(); - render( - - - mocks={mocks} - onCall={mutationSpy} - > - - - , - ); - - await waitFor(() => - expect(mutationSpy.mock.calls[0][0]).toMatchObject({ - operation: { - operationName: 'FinancialAccounts', - variables: { - designationAccountIds: ['account-1'], - }, - }, - }), - ); - }); - - it('does not filter report by designation account', async () => { - const mutationSpy = jest.fn(); - render( - - - mocks={mocks} - onCall={mutationSpy} - > - - - , - ); - - await waitFor(() => - expect(mutationSpy.mock.calls[0][0]).toMatchObject({ - operation: { - operationName: 'FinancialAccounts', - variables: { - designationAccountIds: null, - }, - }, - }), - ); - }); -}); diff --git a/src/components/Reports/ResponsibilityCentersReport/SetActiveFinancialAccount.graphql b/src/components/Reports/ResponsibilityCentersReport/SetActiveFinancialAccount.graphql deleted file mode 100644 index 384177d76..000000000 --- a/src/components/Reports/ResponsibilityCentersReport/SetActiveFinancialAccount.graphql +++ /dev/null @@ -1,6 +0,0 @@ -mutation SetActiveFinancialAccount($input: SetActiveFinancialAccountInput!) { - setActiveFinancialAccount(input: $input) { - active - id - } -} diff --git a/src/components/Shared/Filters/FilterPanel.tsx b/src/components/Shared/Filters/FilterPanel.tsx index a224b699c..37d474bce 100644 --- a/src/components/Shared/Filters/FilterPanel.tsx +++ b/src/components/Shared/Filters/FilterPanel.tsx @@ -21,6 +21,10 @@ import { styled, useTheme } from '@mui/material/styles'; import { filter } from 'lodash'; import { useTranslation } from 'react-i18next'; import { useApiConstants } from 'src/components/Constants/UseApiConstants'; +import { + FinancialAccountContext, + FinancialAccountType, +} from 'src/components/Reports/FinancialAccountsReport/Context/FinancialAccountsContext'; import { AppealsContext, AppealsType, @@ -102,13 +106,14 @@ const FlatAccordion = styled(Accordion)(({ theme }) => ({ }, })); -type FilterInput = ContactFilterSetInput & +export type FilterInput = ContactFilterSetInput & TaskFilterSetInput & ReportContactFilterSetInput; export enum ContextTypesEnum { Contacts = 'contacts', Appeals = 'appeals', + FinancialAccountReport = 'financialAccountReport', } export interface FilterPanelProps { filters: FilterPanelGroupFragment[]; @@ -119,6 +124,7 @@ export interface FilterPanelProps { onSelectedFiltersChanged: (selectedFilters: FilterInput) => void; onHandleClearSearch?: () => void; contextType?: ContextTypesEnum; + showSaveButton?: boolean; } export const FilterPanel: React.FC = ({ @@ -130,6 +136,7 @@ export const FilterPanel: React.FC = ({ onSelectedFiltersChanged, onHandleClearSearch, contextType = ContextTypesEnum.Contacts, + showSaveButton = true, ...boxProps }) => { const theme = useTheme(); @@ -142,10 +149,18 @@ export const FilterPanel: React.FC = ({ const [filterToBeDeleted, setFilterToBeDeleted] = useState(null); + const contactsContext = React.useContext(ContactsContext) as ContactsType; + const appealsContext = React.useContext(AppealsContext) as AppealsType; + const financialAccountContext = React.useContext( + FinancialAccountContext, + ) as FinancialAccountType; + const handleClearAll = contextType === ContextTypesEnum.Contacts - ? (React.useContext(ContactsContext) as ContactsType).handleClearAll - : (React.useContext(AppealsContext) as AppealsType).handleClearAll; + ? contactsContext.handleClearAll + : contextType === ContextTypesEnum.Appeals + ? appealsContext.handleClearAll + : financialAccountContext.handleClearAll; const updateSelectedFilter = (name: FilterKey, value?: FilterValue) => { if (value && (!Array.isArray(value) || value.length > 0)) { @@ -612,15 +627,19 @@ export const FilterPanel: React.FC = ({ + {showSaveButton && ( + setSaveFilterModalOpen(true)} + > + {t('Save')} + + )} setSaveFilterModalOpen(true)} - > - {t('Save')} - - diff --git a/src/components/Shared/Header/ListHeader.tsx b/src/components/Shared/Header/ListHeader.tsx index 4ed1e8c56..60224339e 100644 --- a/src/components/Shared/Header/ListHeader.tsx +++ b/src/components/Shared/Header/ListHeader.tsx @@ -1,7 +1,7 @@ import React, { ReactElement } from 'react'; import FilterList from '@mui/icons-material/FilterList'; import ViewList from '@mui/icons-material/ViewList'; -import { Box, Checkbox, Hidden, IconButton } from '@mui/material'; +import { Box, Checkbox, Hidden } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { @@ -13,6 +13,7 @@ import { SearchBox } from '../../common/SearchBox/SearchBox'; import { ContactsMassActionsDropdown } from '../MassActions/ContactsMassActionsDropdown'; import { TasksMassActionsDropdown } from '../MassActions/TasksMassActionsDropdown'; import { StarFilterButton } from './StarFilterButton/StarFilterButton'; +import { FilterButton } from './styledComponents'; export const headerHeight = theme.spacing(12); @@ -43,17 +44,6 @@ const StyledCheckbox = styled(Checkbox)(({ theme }) => ({ }, })); -const FilterButton = styled(IconButton, { - shouldForwardProp: (prop) => prop !== 'activeFilters' && prop !== 'panelOpen', -})<{ activeFilters?: boolean; panelOpen?: boolean }>( - ({ theme, activeFilters }) => ({ - marginRight: theme.spacing(2), - backgroundColor: activeFilters - ? theme.palette.cruYellow.main - : 'transparent', - }), -); - const FilterIcon = styled(FilterList)(({ theme }) => ({ width: 24, height: 24, @@ -152,11 +142,7 @@ export const ListHeader: React.FC = ({ {page === PageEnum.Appeal && leftButtonGroup && ( {leftButtonGroup} )} - + {contactsView === TableViewModeEnum.Map ? ( ) : ( diff --git a/src/components/Shared/Header/styledComponents.ts b/src/components/Shared/Header/styledComponents.ts index 952af4c54..f01913079 100644 --- a/src/components/Shared/Header/styledComponents.ts +++ b/src/components/Shared/Header/styledComponents.ts @@ -1,4 +1,4 @@ -import { Box } from '@mui/material'; +import { Box, IconButton } from '@mui/material'; import { styled } from '@mui/material/styles'; export const StickyBox = styled(Box)(({ theme }) => ({ @@ -33,3 +33,10 @@ export const StickyButtonHeaderBox = styled(Box)(({ theme }) => ({ top: '56px', }, })); + +export const FilterButton = styled(IconButton, { + shouldForwardProp: (prop) => prop !== 'activeFilters', +})<{ activeFilters?: boolean }>(({ theme, activeFilters }) => ({ + marginRight: theme.spacing(2), + backgroundColor: activeFilters ? theme.palette.cruYellow.main : 'transparent', +})); diff --git a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts index eff89f729..7440bf0eb 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts +++ b/src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenuItems.ts @@ -27,7 +27,7 @@ export const reportNavItems: NavItems[] = [ title: 'Designation Accounts', }, { - id: 'responsibilityCenters', + id: 'financialAccounts', title: 'Responsibility Centers', }, { diff --git a/src/components/common/SearchBox/SearchBox.tsx b/src/components/common/SearchBox/SearchBox.tsx index 4309e79a6..cd43fdbaa 100644 --- a/src/components/common/SearchBox/SearchBox.tsx +++ b/src/components/common/SearchBox/SearchBox.tsx @@ -14,6 +14,7 @@ export interface SearchBoxProps { placeholder?: string; showContactSearchIcon: boolean; page?: PageEnum; + toolTipText?: string; } const SearchInput = styled(TextField)(() => ({ @@ -28,6 +29,7 @@ export const SearchBox: React.FC = ({ placeholder, showContactSearchIcon, page, + toolTipText, }) => { const { t } = useTranslation(); const [currentSearchTerm, setSearchTerm] = useState(searchTerm ?? ''); @@ -47,7 +49,9 @@ export const SearchBox: React.FC = ({ new Intl.DateTimeFormat(locale, { month: 'short', - year: 'numeric', + year: fullYear ? 'numeric' : '2-digit', }).format(DateTime.local(year, month, 1).toJSDate()); export const dateFormat = (date: DateTime | null, locale: string): string => {