feat: get bank account meta summary

This commit is contained in:
Ahmed Bouhuolia
2024-07-02 19:21:26 +02:00
parent 8a09de9771
commit 91730d204e
22 changed files with 476 additions and 69 deletions

View File

@@ -0,0 +1,49 @@
import { Inject, Service } from 'typedi';
import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
@Service()
export class BankAccountsController extends BaseController {
@Inject()
private getBankAccountSummaryService: GetBankAccountSummary;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
return router;
}
/**
* Retrieves the bank account meta summary.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
async getBankAccountSummary(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
const data =
await this.getBankAccountSummaryService.getBankAccountSummary(
tenantId,
bankAccountId
);
return res.status(200).send({ data });
} catch (error) {
next(error);
}
}
}

View File

@@ -5,6 +5,7 @@ import { PlaidBankingController } from './PlaidBankingController';
import { BankingRulesController } from './BankingRulesController'; import { BankingRulesController } from './BankingRulesController';
import { BankTransactionsMatchingController } from './BankTransactionsMatchingController'; import { BankTransactionsMatchingController } from './BankTransactionsMatchingController';
import { RecognizedTransactionsController } from './RecognizedTransactionsController'; import { RecognizedTransactionsController } from './RecognizedTransactionsController';
import { BankAccountsController } from './BankAccountsController';
@Service() @Service()
export class BankingController extends BaseController { export class BankingController extends BaseController {
@@ -24,7 +25,10 @@ export class BankingController extends BaseController {
'/recognized', '/recognized',
Container.get(RecognizedTransactionsController).router() Container.get(RecognizedTransactionsController).router()
); );
router.use(
'/bank_accounts',
Container.get(BankAccountsController).router()
);
return router; return router;
} }
} }

View File

@@ -0,0 +1,53 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Server } from 'socket.io';
import { Inject, Service } from 'typedi';
@Service()
export class GetBankAccountSummary {
@Inject()
private tenancy: HasTenancyService;
/**
* Retrieves the bank account meta summary
* @param {number} tenantId
* @param {number} bankAccountId
* @returns
*/
public async getBankAccountSummary(tenantId: number, bankAccountId: number) {
const {
Account,
UncategorizedCashflowTransaction,
RecognizedBankTransaction,
} = this.tenancy.models(tenantId);
const bankAccount = await Account.query()
.findById(bankAccountId)
.throwIfNotFound();
const uncategorizedTranasctionsCount =
await UncategorizedCashflowTransaction.query()
.where('accountId', bankAccountId)
.count('id as total')
.first();
const recognizedTransactionsCount = await RecognizedBankTransaction.query()
.whereExists(
UncategorizedCashflowTransaction.query().where(
'accountId',
bankAccountId
)
)
.count('id as total')
.first();
const totalUncategorizedTransactions =
uncategorizedTranasctionsCount?.total;
const totalRecognizedTransactions = recognizedTransactionsCount?.total;
return {
name: bankAccount.name,
totalUncategorizedTransactions,
totalRecognizedTransactions,
};
}
}

View File

@@ -32,11 +32,16 @@ export class GetUncategorizedTransactions {
}; };
const { results, pagination } = const { results, pagination } =
await UncategorizedCashflowTransaction.query() await UncategorizedCashflowTransaction.query()
.where('accountId', accountId) .onBuild((q) => {
.where('categorized', false) q.where('accountId', accountId);
.modify('notExcluded') q.where('categorized', false);
.withGraphFetched('account') q.modify('notExcluded');
.orderBy('date', 'DESC')
q.withGraphFetched('account');
q.withGraphFetched('recognizedTransaction.assignAccount');
q.orderBy('date', 'DESC');
})
.pagination(_query.page - 1, _query.pageSize); .pagination(_query.page - 1, _query.pageSize);
const data = await this.transformer.transform( const data = await this.transformer.transform(

View File

@@ -12,9 +12,25 @@ export class UncategorizedTransactionTransformer extends Transformer {
'formattedDate', 'formattedDate',
'formattedDepositAmount', 'formattedDepositAmount',
'formattedWithdrawalAmount', 'formattedWithdrawalAmount',
'assignedAccountId',
'assignedAccountName',
'assignedAccountCode',
'assignedPayee',
'assignedMemo',
'assignedCategory',
'assignedCategoryFormatted',
]; ];
}; };
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['recognizedTransaction'];
};
/** /**
* Formattes the transaction date. * Formattes the transaction date.
* @param transaction * @param transaction
@@ -62,4 +78,69 @@ export class UncategorizedTransactionTransformer extends Transformer {
} }
return ''; return '';
} }
// --------------------------------------------------------
// # Recgonized transaction
// --------------------------------------------------------
/**
* Get the assigned account ID of the transaction.
* @param {object} transaction
* @returns {number}
*/
public assignedAccountId(transaction: any): number {
return transaction.recognizedTransaction?.assignedAccountId;
}
/**
* Get the assigned account name of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedAccountName(transaction: any): string {
return transaction.recognizedTransaction?.assignAccount?.name;
}
/**
* Get the assigned account code of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedAccountCode(transaction: any): string {
return transaction.recognizedTransaction?.assignAccount?.code;
}
/**
* Get the assigned payee of the transaction.
* @param {object} transaction
* @returns {string}
*/
public getAssignedPayee(transaction: any): string {
return transaction.recognizedTransaction?.assignedPayee;
}
/**
* Get the assigned memo of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedMemo(transaction: any): string {
return transaction.recognizedTransaction?.assignedMemo;
}
/**
* Get the assigned category of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedCategory(transaction: any): string {
return transaction.recognizedTransaction?.assignedCategory;
}
/**
* Get the assigned formatted category.
* @returns {string}
*/
public assignedCategoryFormatted() {
return 'Other Income';
}
} }

View File

@@ -1,18 +1,28 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import clsx from 'classnames';
import Style from '@/style/components/DataTable/DataTableEmptyStatus.module.scss'; import Style from '@/style/components/DataTable/DataTableEmptyStatus.module.scss';
/** /**
* Datatable empty status. * Datatable empty status.
*/ */
export function EmptyStatus({ title, description, action, children }) { export function EmptyStatus({
title,
description,
action,
children,
classNames,
}) {
return ( return (
<div className={classNames(Style.root)}> <div className={clsx(Style.root, classNames?.root)}>
<h1 className={classNames(Style.root_title)}>{title}</h1> <h1 className={clsx(Style.root_title, classNames?.title)}>{title}</h1>
<div className={classNames(Style.root_desc)}>{description}</div> <div className={clsx(Style.root_desc, classNames?.description)}>
<div className={classNames(Style.root_actions)}>{action}</div> {description}
</div>
<div className={clsx(Style.root_actions, classNames?.actions)}>
{action}
</div>
{children} {children}
</div> </div>
); );

View File

@@ -0,0 +1,3 @@
.root{
max-width: 600px;
}

View File

@@ -4,32 +4,44 @@ import { Button, Intent } from '@blueprintjs/core';
import { EmptyStatus, Can, FormattedMessage as T } from '@/components'; import { EmptyStatus, Can, FormattedMessage as T } from '@/components';
import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption'; import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption';
import withDialogActions from '@/containers/Dialog/withDialogActions'; import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
import styles from './BankRulesLandingEmptyState.module.scss';
function BankRulesLandingEmptyStateRoot({ function BankRulesLandingEmptyStateRoot({
// #withDialogAction // #withDialogAction
openDialog, openDialog,
}) { }) {
const handleNewBtnClick = () => {
openDialog(DialogsName.BankRuleForm);
};
return ( return (
<EmptyStatus <EmptyStatus
title={"The organization doesn't have taxes, yet!"} title={'Create rules to categorize bank transactions automatically'}
description={ description={
<p> <p>
Setup the organization taxes to start tracking taxes on sales Bank rules will run automatically to categorize the incoming bank
transactions. transactions under the conditions you set up.
</p> </p>
} }
action={ action={
<> <>
<Can I={BankRuleAction.Create} a={AbilitySubject.BankRule}> <Can I={BankRuleAction.Create} a={AbilitySubject.BankRule}>
<Button intent={Intent.PRIMARY} large={true} onClick={() => {}}> <Button
New tax rate intent={Intent.PRIMARY}
large={true}
onClick={handleNewBtnClick}
>
New Bank Rule
</Button> </Button>
<Button intent={Intent.NONE} large={true}> <Button intent={Intent.NONE} large={true}>
<T id={'learn_more'} /> <T id={'learn_more'} />
</Button> </Button>
</Can> </Can>
</> </>
} }
classNames={{ root: styles.root }}
/> />
); );
} }

View File

@@ -1,10 +1,12 @@
import React, { createContext } from 'react'; import React, { createContext } from 'react';
import { DialogContent } from '@/components'; import { DialogContent } from '@/components';
import { useBankRules } from '@/hooks/query/bank-rules'; import { useBankRules } from '@/hooks/query/bank-rules';
import { isEmpty } from 'lodash';
interface RulesListBootValues { interface RulesListBootValues {
bankRules: any; bankRules: any;
isBankRulesLoading: boolean; isBankRulesLoading: boolean;
isEmptyState: boolean;
} }
const RulesListBootContext = createContext<RulesListBootValues>( const RulesListBootContext = createContext<RulesListBootValues>(
@@ -18,9 +20,11 @@ interface RulesListBootProps {
function RulesListBoot({ ...props }: RulesListBootProps) { function RulesListBoot({ ...props }: RulesListBootProps) {
const { data: bankRules, isLoading: isBankRulesLoading } = useBankRules(); const { data: bankRules, isLoading: isBankRulesLoading } = useBankRules();
const provider = { bankRules, isBankRulesLoading } as RulesListBootValues; const isEmptyState = !isBankRulesLoading && isEmpty(bankRules);
const isLoading = isBankRulesLoading; const isLoading = isBankRulesLoading;
const provider = { bankRules, isBankRulesLoading, isEmptyState } as RulesListBootValues;
return ( return (
<DialogContent isLoading={isLoading}> <DialogContent isLoading={isLoading}>
<RulesListBootContext.Provider {...props} value={provider} /> <RulesListBootContext.Provider {...props} value={provider} />

View File

@@ -33,7 +33,7 @@ function RulesTable({
}) { }) {
// Invoices table columns. // Invoices table columns.
const columns = useBankRulesTableColumns(); const columns = useBankRulesTableColumns();
const { bankRules } = useRulesListBoot(); const { bankRules, isEmptyState } = useRulesListBoot();
// Handle edit bank rule. // Handle edit bank rule.
const handleDeleteBankRule = ({ id }) => { const handleDeleteBankRule = ({ id }) => {
@@ -45,8 +45,6 @@ function RulesTable({
openDialog(DialogsName.BankRuleForm, { bankRuleId: id }); openDialog(DialogsName.BankRuleForm, { bankRuleId: id });
}; };
const isEmptyState = false;
// Display invoice empty status instead of the table. // Display invoice empty status instead of the table.
if (isEmptyState) { if (isEmptyState) {
return <BankRulesLandingEmptyState />; return <BankRulesLandingEmptyState />;

View File

@@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
import { DashboardInsider } from '@/components'; import { DashboardInsider } from '@/components';
import { useCashflowAccounts, useAccount } from '@/hooks/query'; import { useCashflowAccounts, useAccount } from '@/hooks/query';
import { useAppQueryString } from '@/hooks'; import { useAppQueryString } from '@/hooks';
import { useGetBankAccountSummaryMeta } from '@/hooks/query/bank-rules';
const AccountTransactionsContext = React.createContext(); const AccountTransactionsContext = React.createContext();
@@ -20,31 +21,38 @@ function AccountTransactionsProvider({ query, ...props }) {
const setFilterTab = (value: string) => { const setFilterTab = (value: string) => {
setLocationQuery({ filter: value }); setLocationQuery({ filter: value });
}; };
// Fetch cashflow accounts. // Retrieves cashflow accounts.
const { const {
data: cashflowAccounts, data: cashflowAccounts,
isFetching: isCashFlowAccountsFetching, isFetching: isCashFlowAccountsFetching,
isLoading: isCashFlowAccountsLoading, isLoading: isCashFlowAccountsLoading,
} = useCashflowAccounts(query, { keepPreviousData: true }); } = useCashflowAccounts(query, { keepPreviousData: true });
// Retrieve specific account details. // Retrieves specific account details.
const { const {
data: currentAccount, data: currentAccount,
isFetching: isCurrentAccountFetching, isFetching: isCurrentAccountFetching,
isLoading: isCurrentAccountLoading, isLoading: isCurrentAccountLoading,
} = useAccount(accountId, { keepPreviousData: true }); } = useAccount(accountId, { keepPreviousData: true });
// Retrieves the bank account meta summary.
const {
data: bankAccountMetaSummary,
isLoading: isBankAccountMetaSummaryLoading,
} = useGetBankAccountSummaryMeta(accountId);
// Provider payload. // Provider payload.
const provider = { const provider = {
accountId, accountId,
cashflowAccounts, cashflowAccounts,
currentAccount, currentAccount,
bankAccountMetaSummary,
isCashFlowAccountsFetching, isCashFlowAccountsFetching,
isCashFlowAccountsLoading, isCashFlowAccountsLoading,
isCurrentAccountFetching, isCurrentAccountFetching,
isCurrentAccountLoading, isCurrentAccountLoading,
isBankAccountMetaSummaryLoading,
filterTab, filterTab,
setFilterTab, setFilterTab,

View File

@@ -4,6 +4,7 @@ import { Tag } from '@blueprintjs/core';
import { useAppQueryString } from '@/hooks'; import { useAppQueryString } from '@/hooks';
import { useUncontrolled } from '@/hooks/useUncontrolled'; import { useUncontrolled } from '@/hooks/useUncontrolled';
import { Group } from '@/components'; import { Group } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
const Root = styled.div` const Root = styled.div`
display: flex; display: flex;
@@ -26,8 +27,13 @@ const FilterTag = styled(Tag)`
`; `;
export function AccountTransactionsUncategorizeFilter() { export function AccountTransactionsUncategorizeFilter() {
const { bankAccountMetaSummary } = useAccountTransactionsContext();
const [locationQuery, setLocationQuery] = useAppQueryString(); const [locationQuery, setLocationQuery] = useAppQueryString();
const totalUncategorized =
bankAccountMetaSummary?.totalUncategorizedTransactions;
const totalRecognized = bankAccountMetaSummary?.totalRecognizedTransactions;
const handleTabsChange = (value) => { const handleTabsChange = (value) => {
setLocationQuery({ uncategorizedFilter: value }); setLocationQuery({ uncategorizedFilter: value });
}; };
@@ -36,8 +42,22 @@ export function AccountTransactionsUncategorizeFilter() {
<Group position={'apart'}> <Group position={'apart'}>
<SegmentedTabs <SegmentedTabs
options={[ options={[
{ value: 'all', label: 'All' }, {
{ value: 'recognized', label: 'Recognized' }, value: 'all',
label: (
<>
All <strong>({totalUncategorized})</strong>
</>
),
},
{
value: 'recognized',
label: (
<>
Recognized <strong>({totalRecognized})</strong>
</>
),
},
]} ]}
value={locationQuery?.uncategorizedFilter || 'all'} value={locationQuery?.uncategorizedFilter || 'all'}
onValueChange={handleTabsChange} onValueChange={handleTabsChange}
@@ -66,8 +86,9 @@ function SegmentedTabs({ options, initialValue, value, onValueChange }) {
}); });
return ( return (
<Root> <Root>
{options.map((option) => ( {options.map((option, index) => (
<FilterTag <FilterTag
key={index}
round round
interactive interactive
onClick={() => handleChange(option.value)} onClick={() => handleChange(option.value)}

View File

@@ -102,7 +102,7 @@ function AccountTransactionsDataTable({
vListOverscanRowCount={0} vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths} initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing} onColumnResizing={handleColumnResizing}
noResults={<T id={'cash_flow.account_transactions.no_results'} />} noResults={'There is no uncategorized transactions in the current account.'}
className="table-constrant" className="table-constrant"
payload={{ payload={{
onExclude: handleExcludeTransaction, onExclude: handleExcludeTransaction,

View File

@@ -81,7 +81,7 @@ function ExcludedTransactionsTableRoot() {
vListOverscanRowCount={0} vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths} initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing} onColumnResizing={handleColumnResizing}
// noResults={<T id={'cash_flow.account_transactions.no_results'} />} noResults={'There is no excluded bank transactions.'}
className="table-constrant" className="table-constrant"
payload={{ payload={{
onRestore: handleRestoreClick, onRestore: handleRestoreClick,

View File

@@ -0,0 +1,20 @@
.emptyState{
text-align: center;
font-size: 15px;
color: #738091;
:global ul{
list-style: inside;
li{
margin-bottom: 12px;
&::marker{
color: #C5CBD3;
font-size: 12px;
}
}
}
}

View File

@@ -10,6 +10,7 @@ import {
TableVirtualizedListRows, TableVirtualizedListRows,
FormattedMessage as T, FormattedMessage as T,
AppToaster, AppToaster,
Stack,
} from '@/components'; } from '@/components';
import { TABLES } from '@/constants/tables'; import { TABLES } from '@/constants/tables';
@@ -24,11 +25,12 @@ import { useRecognizedTransactionsBoot } from './RecognizedTransactionsTableBoot
import { ActionsMenu } from './_components'; import { ActionsMenu } from './_components';
import { compose } from '@/utils'; import { compose } from '@/utils';
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { Intent } from '@blueprintjs/core'; import { Intent, Text } from '@blueprintjs/core';
import { import {
WithBankingActionsProps, WithBankingActionsProps,
withBankingActions, withBankingActions,
} from '../../withBankingActions'; } from '../../withBankingActions';
import styles from './RecognizedTransactionsTable.module.scss';
interface RecognizedTransactionsTableProps extends WithBankingActionsProps {} interface RecognizedTransactionsTableProps extends WithBankingActionsProps {}
@@ -114,7 +116,7 @@ function RecognizedTransactionsTableRoot({
vListOverscanRowCount={0} vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths} initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing} onColumnResizing={handleColumnResizing}
noResults={<T id={'cash_flow.account_transactions.no_results'} />} noResults={<RecognizedTransactionsTableNoResults />}
className="table-constrant" className="table-constrant"
payload={{ payload={{
onExclude: handleExcludeClick, onExclude: handleExcludeClick,
@@ -168,3 +170,26 @@ const CashflowTransactionsTable = styled(DashboardConstrantTable)`
} }
} }
`; `;
function RecognizedTransactionsTableNoResults() {
return (
<Stack spacing={12} className={styles.emptyState}>
<Text>
There are no Recognized transactions due to one of the following
reasons:
</Text>
<ul>
<li>
Transaction Rules have not yet been created. Transactions are
recognized based on the rule criteria.
</li>
<li>
The transactions in your bank do not satisfy the criteria in any of
your transaction rule(s).
</li>
</ul>
</Stack>
);
}

View File

@@ -1,8 +1,24 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { Intent, Menu, MenuItem, MenuDivider, Tag } from '@blueprintjs/core'; import {
import { Can, FormatDateCell, Icon, MaterialProgressBar } from '@/components'; Intent,
Menu,
MenuItem,
MenuDivider,
Tag,
Popover,
PopoverInteractionKind,
Position,
Tooltip,
} from '@blueprintjs/core';
import {
Box,
Can,
FormatDateCell,
Icon,
MaterialProgressBar,
} from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { safeCallback } from '@/utils'; import { safeCallback } from '@/utils';
@@ -128,6 +144,34 @@ export function AccountTransactionsProgressBar() {
) : null; ) : null;
} }
function statusAccessor(transaction) {
return transaction.is_recognized ? (
<Tooltip
compact
interactionKind={PopoverInteractionKind.HOVER}
position={Position.RIGHT}
content={
<Box>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</Box>
}
>
<Box>
<Tag intent={Intent.SUCCESS} interactive>
Recognized
</Tag>
</Box>
</Tooltip>
) : null;
}
/** /**
* Retrieve account uncategorized transctions table columns. * Retrieve account uncategorized transctions table columns.
*/ */
@@ -170,16 +214,7 @@ export function useAccountUncategorizedTransactionsColumns() {
{ {
id: 'status', id: 'status',
Header: 'Status', Header: 'Status',
accessor: () => accessor: statusAccessor,
false ? (
<Tag intent={Intent.SUCCESS} interactive>
1 Matches
</Tag>
) : (
<Tag intent={Intent.SUCCESS} interactive>
Recognized
</Tag>
),
}, },
{ {
id: 'deposit', id: 'deposit',

View File

@@ -2,6 +2,7 @@
.root { .root {
flex: 1 1 auto; flex: 1 1 auto;
overflow: auto; overflow: auto;
padding-bottom: 60px;
} }
.transaction { .transaction {
@@ -35,4 +36,5 @@
.footerTotal { .footerTotal {
padding: 8px 16px; padding: 8px 16px;
border-top: 1px solid #d8d9d9; border-top: 1px solid #d8d9d9;
line-height: 24px;
} }

View File

@@ -1,11 +1,23 @@
// @ts-nocheck
import { Tab, Tabs } from '@blueprintjs/core'; import { Tab, Tabs } from '@blueprintjs/core';
import { MatchingBankTransaction } from './MatchingTransaction'; import { MatchingBankTransaction } from './MatchingTransaction';
import styles from './CategorizeTransactionTabs.module.scss';
import { CategorizeTransactionContent } from '../CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent'; import { CategorizeTransactionContent } from '../CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import styles from './CategorizeTransactionTabs.module.scss';
export function CategorizeTransactionTabs() { export function CategorizeTransactionTabs() {
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
const defaultSelectedTabId = uncategorizedTransaction?.is_recognized
? 'categorize'
: 'matching';
return ( return (
<Tabs large renderActiveTabPanelOnly className={styles.tabs}> <Tabs
large
renderActiveTabPanelOnly
defaultSelectedTabId={defaultSelectedTabId}
className={styles.tabs}
>
<Tab <Tab
id="categorize" id="categorize"
title="Categorize Transaction" title="Categorize Transaction"

View File

@@ -2,22 +2,26 @@
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import * as R from 'ramda'; import * as R from 'ramda';
import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core'; import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core';
import { FastField, FastFieldProps, Formik } from 'formik';
import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components'; import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components';
import { import {
MatchingTransactionBoot, MatchingTransactionBoot,
useMatchingTransactionBoot, useMatchingTransactionBoot,
} from './MatchingTransactionBoot'; } from './MatchingTransactionBoot';
import { MatchTransaction, MatchTransactionProps } from './MatchTransaction'; import { MatchTransaction, MatchTransactionProps } from './MatchTransaction';
import styles from './CategorizeTransactionAside.module.scss';
import { FastField, FastFieldProps, Form, Formik } from 'formik';
import { useMatchUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { useMatchUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { MatchingTransactionFormValues } from './types'; import { MatchingTransactionFormValues } from './types';
import { transformToReq, useGetPendingAmountMatched } from './utils'; import {
transformToReq,
useGetPendingAmountMatched,
useIsShowReconcileTransactionLink,
} from './utils';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot'; import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import { import {
WithBankingActionsProps, WithBankingActionsProps,
withBankingActions, withBankingActions,
} from '../withBankingActions'; } from '../withBankingActions';
import styles from './CategorizeTransactionAside.module.scss';
const initialValues = { const initialValues = {
matched: {}, matched: {},
@@ -189,20 +193,26 @@ interface MatchTransctionFooterProps extends WithBankingActionsProps {}
*/ */
const MatchTransactionFooter = R.compose(withBankingActions)( const MatchTransactionFooter = R.compose(withBankingActions)(
({ closeMatchingTransactionAside }: MatchTransctionFooterProps) => { ({ closeMatchingTransactionAside }: MatchTransctionFooterProps) => {
const totalPending = useGetPendingAmountMatched();
const showReconcileLink = useIsShowReconcileTransactionLink();
const handleCancelBtnClick = () => { const handleCancelBtnClick = () => {
closeMatchingTransactionAside(); closeMatchingTransactionAside();
}; };
const totalPending = useGetPendingAmountMatched();
return ( return (
<Box className={styles.footer}> <Box className={styles.footer}>
<Box className={styles.footerTotal}> <Box className={styles.footerTotal}>
<Group position={'apart'}> <Group position={'apart'}>
{showReconcileLink && (
<AnchorButton small minimal intent={Intent.PRIMARY}> <AnchorButton small minimal intent={Intent.PRIMARY}>
Add Reconcile Transaction + Add Reconcile Transaction +
</AnchorButton> </AnchorButton>
)}
<Text style={{ fontSize: 13 }} tagName="span"> <Text
style={{ fontSize: 14, marginLeft: 'auto', color: '#5F6B7C' }}
tagName="span"
>
Pending <FormatNumber value={totalPending} currency={'USD'} /> Pending <FormatNumber value={totalPending} currency={'USD'} />
</Text> </Text>
</Group> </Group>

View File

@@ -1,6 +1,8 @@
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { MatchingTransactionFormValues } from './types'; import { MatchingTransactionFormValues } from './types';
import { useMatchingTransactionBoot } from './MatchingTransactionBoot'; import { useMatchingTransactionBoot } from './MatchingTransactionBoot';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import { useMemo } from 'react';
export const transformToReq = (values: MatchingTransactionFormValues) => { export const transformToReq = (values: MatchingTransactionFormValues) => {
const matchedTransactions = Object.entries(values.matched) const matchedTransactions = Object.entries(values.matched)
@@ -17,7 +19,9 @@ export const transformToReq = (values: MatchingTransactionFormValues) => {
export const useGetPendingAmountMatched = () => { export const useGetPendingAmountMatched = () => {
const { values } = useFormikContext<MatchingTransactionFormValues>(); const { values } = useFormikContext<MatchingTransactionFormValues>();
const { perfectMatches, possibleMatches } = useMatchingTransactionBoot(); const { perfectMatches, possibleMatches } = useMatchingTransactionBoot();
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
return useMemo(() => {
const matchedItems = [...perfectMatches, ...possibleMatches].filter( const matchedItems = [...perfectMatches, ...possibleMatches].filter(
(match) => { (match) => {
const key = `${match.transactionType}-${match.transactionId}`; const key = `${match.transactionType}-${match.transactionId}`;
@@ -28,7 +32,25 @@ export const useGetPendingAmountMatched = () => {
(total, item) => total + parseFloat(item.amount), (total, item) => total + parseFloat(item.amount),
0, 0,
); );
const pendingAmount = 0 - totalMatchedAmount; const amount = uncategorizedTransaction.amount;
const pendingAmount = amount - totalMatchedAmount;
return pendingAmount; return pendingAmount;
}, [uncategorizedTransaction, perfectMatches, possibleMatches, values]);
};
export const useAtleastOneMatchedSelected = () => {
const { values } = useFormikContext<MatchingTransactionFormValues>();
return useMemo(() => {
const matchedCount = Object.values(values.matched).filter(Boolean).length;
return matchedCount > 0;
}, [values]);
};
export const useIsShowReconcileTransactionLink = () => {
const pendingAmount = useGetPendingAmountMatched();
const atleastOneSelected = useAtleastOneMatchedSelected();
return atleastOneSelected && pendingAmount !== 0;
}; };

View File

@@ -21,6 +21,7 @@ const QUERY_KEY = {
EXCLUDED_BANK_TRANSACTIONS_INFINITY: 'EXCLUDED_BANK_TRANSACTIONS_INFINITY', EXCLUDED_BANK_TRANSACTIONS_INFINITY: 'EXCLUDED_BANK_TRANSACTIONS_INFINITY',
RECOGNIZED_BANK_TRANSACTIONS_INFINITY: RECOGNIZED_BANK_TRANSACTIONS_INFINITY:
'RECOGNIZED_BANK_TRANSACTIONS_INFINITY', 'RECOGNIZED_BANK_TRANSACTIONS_INFINITY',
BANK_ACCOUNT_SUMMARY_META: 'BANK_ACCOUNT_SUMMARY_META',
}; };
const commonInvalidateQueries = (query: QueryClient) => { const commonInvalidateQueries = (query: QueryClient) => {
@@ -111,6 +112,10 @@ export function useDeleteBankRule(
{ {
onSuccess: (res, id) => { onSuccess: (res, id) => {
commonInvalidateQueries(queryClient); commonInvalidateQueries(queryClient);
queryClient.invalidateQueries(
QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY,
);
}, },
...options, ...options,
}, },
@@ -343,6 +348,34 @@ export function useGetRecognizedBankTransaction(
); );
} }
interface GetBankAccountSummaryMetaRes {
name: string;
totalUncategorizedTransactions: number;
totalRecognizedTransactions: number;
}
/**
* Get the given bank account meta summary.
* @param {number} bankAccountId
* @param {UseQueryOptions<GetBankAccountSummaryMetaRes, Error>} options
* @returns {UseQueryResult<GetBankAccountSummaryMetaRes, Error>}
*/
export function useGetBankAccountSummaryMeta(
bankAccountId: number,
options?: UseQueryOptions<GetBankAccountSummaryMetaRes, Error>,
): UseQueryResult<GetBankAccountSummaryMetaRes, Error> {
const apiRequest = useApiRequest();
return useQuery<GetBankAccountSummaryMetaRes, Error>(
[QUERY_KEY.BANK_ACCOUNT_SUMMARY_META, bankAccountId],
() =>
apiRequest
.get(`/banking/bank_accounts/${bankAccountId}/meta`)
.then((res) => transformToCamelCase(res.data?.data)),
{ ...options },
);
}
/** /**
* @returns * @returns
*/ */