diff --git a/pages/accountLists/[accountListId]/reports/financialAccounts/[[...financialAccount]].page.tsx b/pages/accountLists/[accountListId]/reports/financialAccounts/[[...financialAccount]].page.tsx index 771a78b68c..b5192c6261 100644 --- a/pages/accountLists/[accountListId]/reports/financialAccounts/[[...financialAccount]].page.tsx +++ b/pages/accountLists/[accountListId]/reports/financialAccounts/[[...financialAccount]].page.tsx @@ -139,7 +139,10 @@ const FinancialAccounts = (): ReactElement => { /> ) : panelOpen === Panel.Filters ? ( setPanelOpen(null)} diff --git a/pages/api/Schema/index.ts b/pages/api/Schema/index.ts index aa8aaffc29..8f53c05b9b 100644 --- a/pages/api/Schema/index.ts +++ b/pages/api/Schema/index.ts @@ -41,6 +41,8 @@ import { ExpectedMonthlyTotalReportResolvers } from './reports/expectedMonthlyTo 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'; @@ -133,6 +135,10 @@ const schema = buildSubgraphSchema([ typeDefs: FinancialAccountSummaryTypeDefs, resolvers: FinancialAccountSummaryResolvers, }, + { + typeDefs: FinancialAccountEntriesTypeDefs, + resolvers: financialAccountEntriesResolvers, + }, ...integrationSchema, ...organizationSchema, ...preferencesSchema, 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 0000000000..6eee86886c --- /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 0000000000..7be1a7bcea --- /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 0000000000..879b442be7 --- /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 ef5803ba4a..e36cc83f14 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -110,6 +110,7 @@ import { 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, @@ -779,6 +780,30 @@ class MpdxRestApi extends RESTDataSource { 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( 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 0000000000..467ea1760d --- /dev/null +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactionTable/AccountTransactionTable.tsx @@ -0,0 +1,342 @@ +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/AccountSummary'; +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( + () => [ + createBalanceRow({ + id: 'openingBalanceRow', + currency, + description: t('Opening Balance'), + incomeAmount: openingBalance, + entryDate: DateTime.fromISO( + activeFilters.dateRange?.min ?? defaultStartDate, + ), + }), + ...entries.map((entry) => createTransactionRow(entry)), + createBalanceRow({ + id: 'closingBalanceRow', + currency, + description: t('Closing Balance'), + incomeAmount: closingBalance, + entryDate: DateTime.fromISO( + activeFilters.dateRange?.max ?? defaultEndDate, + ), + }), + ], + [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 index 7fc1ee6e91..da67d8ce9a 100644 --- a/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx +++ b/src/components/Reports/FinancialAccountsReport/AccountTransactions/AccountTransactions.tsx @@ -1,6 +1,171 @@ -import React from 'react'; -import { Box } from '@mui/material'; +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 { 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/AccountSummary'; +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 = () => { - return AccountTransactions; + const { t } = useTranslation(); + const locale = useLocale(); + const { + accountListId, + financialAccountId, + activeFilters, + setActiveFilters, + hasActiveFilters, + searchTerm, + } = useContext(FinancialAccountContext) as FinancialAccountType; + const { appName } = useGetAppSettings(); + + useEffect(() => { + if (!hasActiveFilters) { + 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 ?? ''; + const wildcardSearch = useDebouncedValue(searchTerm, 500); + + const { data } = useFinancialAccountEntriesQuery({ + variables: { + input: { + accountListId, + financialAccountId: financialAccountId ?? '', + dateRange, + categoryId, + wildcardSearch, + }, + }, + }); + + const handleExportCSV = () => { + if (!data) { + // Alert user there is no data + // TODO: Add alert + return; + } + const columnHeaders = [ + t('Date'), + t('Payee'), + t('Memo'), + t('Outflow'), + t('Inflow'), + ]; + const convertDataToArray = data.financialAccountEntries.entries.reduce( + (acc, entry) => { + acc.push([ + 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) + : '', + entry.type === FinancialAccountEntryTypeEnum.Credit + ? currencyFormat(formatNumber(entry.amount), entry.currency, locale) + : '', + ]); + return acc; + }, + [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/financialAccountTransactions.graphql b/src/components/Reports/FinancialAccountsReport/AccountTransactions/financialAccountTransactions.graphql new file mode 100644 index 0000000000..5ed803669f --- /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 + } + } +}