feat: excluded bank transactions

This commit is contained in:
Ahmed Bouhuolia
2024-06-27 22:14:53 +02:00
parent fab22c9820
commit 978ce6c441
28 changed files with 1301 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
import { Inject, Service } from 'typedi';
import { param } from 'express-validator';
import { NextFunction, Request, Response, Router } from 'express';
import { NextFunction, Request, Response, Router, query } from 'express';
import BaseController from '../BaseController';
import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication';
@@ -27,6 +27,12 @@ export class ExcludeBankTransactionsController extends BaseController {
this.validationResult,
this.unexcludeBankTransaction.bind(this)
);
router.get(
'/excluded',
[],
this.validationResult,
this.getExcludedBankTransactions.bind(this)
);
return router;
}
@@ -87,4 +93,32 @@ export class ExcludeBankTransactionsController extends BaseController {
next(error);
}
}
/**
* Retrieves the excluded uncategorized bank transactions.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
private async getExcludedBankTransactions(
req: Request,
res: Response,
next: NextFunction
): Promise<Response | void> {
const { tenantId } = req;
const filter = this.matchedBodyData(req);
console.log('123');
try {
const data =
await this.excludeBankTransactionApp.getExcludedBankTransactions(
tenantId,
filter
);
return res.status(200).send(data);
} catch (error) {
next(error);
}
}
}

View File

@@ -14,14 +14,11 @@ export class RecognizedTransactionsController extends BaseController {
router() {
const router = Router();
router.get(
'/accounts/:accountId',
this.getRecognizedTransactions.bind(this)
);
router.get('/', this.getRecognizedTransactions.bind(this));
return router;
}
k;
/**
* Retrieves the recognized bank transactions.
* @param {Request} req
@@ -34,15 +31,15 @@ export class RecognizedTransactionsController extends BaseController {
res: Response,
next: NextFunction
) {
const { accountId } = req.params;
const filter = this.matchedQueryData(req);
const { tenantId } = req;
try {
const data = await this.cashflowApplication.getRecognizedTransactions(
tenantId,
accountId
filter
);
return res.status(200).send({ data });
return res.status(200).send(data);
} catch (error) {
next(error);
}

View File

@@ -164,3 +164,10 @@ export interface IGetUncategorizedTransactionsQuery {
page?: number;
pageSize?: number;
}
export interface IGetRecognizedTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}

View File

@@ -29,6 +29,7 @@ export class RecognizedBankTransaction extends TenantModel {
static get relationMappings() {
const UncategorizedCashflowTransaction = require('./UncategorizedCashflowTransaction');
const Account = require('./Account');
const { BankRule } = require('./BankRule');
return {
/**
@@ -54,6 +55,18 @@ export class RecognizedBankTransaction extends TenantModel {
to: 'accounts.id',
},
},
/**
* Recognized bank transaction may belongs to bank rule.
*/
bankRule: {
relation: Model.BelongsToOneRelation,
modelClass: BankRule,
join: {
from: 'recognized_bank_transactions.bankRuleId',
to: 'bank_rules.id',
},
},
};
}
}

View File

@@ -1,6 +1,8 @@
import { Inject, Service } from 'typedi';
import { ExcludeBankTransaction } from './ExcludeBankTransaction';
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
import { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions';
import { ExcludedBankTransactionsQuery } from './_types';
@Service()
export class ExcludeBankTransactionsApplication {
@@ -10,6 +12,9 @@ export class ExcludeBankTransactionsApplication {
@Inject()
private unexcludeBankTransactionService: UnexcludeBankTransaction;
@Inject()
private getExcludedBankTransactionsService: GetExcludedBankTransactionsService;
/**
* Marks a bank transaction as excluded.
* @param {number} tenantId - The ID of the tenant.
@@ -35,4 +40,20 @@ export class ExcludeBankTransactionsApplication {
bankTransactionId
);
}
/**
* Retrieves the excluded bank transactions.
* @param {number} tenantId
* @param {ExcludedBankTransactionsQuery} filter
* @returns {}
*/
public getExcludedBankTransactions(
tenantId: number,
filter: ExcludedBankTransactionsQuery
) {
return this.getExcludedBankTransactionsService.getExcludedBankTransactions(
tenantId,
filter
);
}
}

View File

@@ -0,0 +1,52 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { ExcludedBankTransactionsQuery } from './_types';
import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service()
export class GetExcludedBankTransactionsService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the excluded uncategorized bank transactions.
* @param {number} tenantId
* @param {ExcludedBankTransactionsQuery} filter
* @returns
*/
public async getExcludedBankTransactions(
tenantId: number,
filter: ExcludedBankTransactionsQuery
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
// Parsed query with default values.
const _query = {
page: 1,
pageSize: 20,
...filter,
};
const { results, pagination } =
await UncategorizedCashflowTransaction.query()
.onBuild((q) => {
q.where('excluded', true);
q.orderBy('date', 'DESC');
if (_query.accountId) {
q.where('account_id', _query.accountId);
}
})
.pagination(_query.page - 1, _query.pageSize);
const data = await this.transformer.transform(
tenantId,
results,
new UncategorizedTransactionTransformer()
);
return { data, pagination };
}
}

View File

@@ -0,0 +1,6 @@
export interface ExcludedBankTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}

View File

@@ -65,7 +65,6 @@ export class RecognizeTranasctionsService {
if (batch) query.where('batch', batch);
});
const bankRules = await BankRule.query().withGraphFetched('conditions');
const bankRulesByAccountId = transformToMapBy(
bankRules,
@@ -92,7 +91,7 @@ export class RecognizeTranasctionsService {
);
}
};
await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
.for(uncategorizedTranasctions)
.process((transaction: UncategorizedCashflowTransaction, index, pool) => {
return regonizeTransaction(transaction);

View File

@@ -9,6 +9,7 @@ import {
ICashflowAccountsFilter,
ICashflowNewCommandDTO,
ICategorizeCashflowTransactioDTO,
IGetRecognizedTransactionsQuery,
IGetUncategorizedTransactionsQuery,
} from '@/interfaces';
import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense';
@@ -18,6 +19,7 @@ import { GetUncategorizedTransaction } from './GetUncategorizedTransaction';
import NewCashflowTransactionService from './NewCashflowTransactionService';
import GetCashflowAccountsService from './GetCashflowAccountsService';
import { GetCashflowTransactionService } from './GetCashflowTransactionsService';
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
@Service()
export class CashflowApplication {
@@ -51,6 +53,9 @@ export class CashflowApplication {
@Inject()
private createUncategorizedTransactionService: CreateUncategorizedTransaction;
@Inject()
private getRecognizedTranasctionsService: GetRecognizedTransactionsService;
/**
* Creates a new cashflow transaction.
* @param {number} tenantId
@@ -213,4 +218,20 @@ export class CashflowApplication {
uncategorizedTransactionId
);
}
/**
* Retrieves the recognized bank transactions.
* @param {number} tenantId
* @param {number} accountId
* @returns
*/
public getRecognizedTransactions(
tenantId: number,
filter?: IGetRecognizedTransactionsQuery
) {
return this.getRecognizedTranasctionsService.getRecognizedTranactions(
tenantId,
filter
);
}
}

View File

@@ -0,0 +1,262 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from '@/utils';
export class GetRecognizedTransactionTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'uncategorizedTransactionId',
'referenceNo',
'description',
'payee',
'amount',
'formattedAmount',
'date',
'formattedDate',
'assignedAccountId',
'assignedAccountName',
'assignedAccountCode',
'assignedPayee',
'assignedMemo',
'assignedCategory',
'assignedCategoryFormatted',
'withdrawal',
'deposit',
'isDepositTransaction',
'isWithdrawalTransaction',
'formattedDepositAmount',
'formattedWithdrawalAmount',
'bankRuleId',
'bankRuleName',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Get the uncategorized transaction id.
* @param transaction
* @returns {number}
*/
public uncategorizedTransactionId = (transaction): number => {
return transaction.id;
}
/**
* Get the reference number of the transaction.
* @param {object} transaction
* @returns {string}
*/
public referenceNo(transaction: any): string {
return transaction.referenceNo;
}
/**
* Get the description of the transaction.
* @param {object} transaction
* @returns {string}
*/
public description(transaction: any): string {
return transaction.description;
}
/**
* Get the payee of the transaction.
* @param {object} transaction
* @returns {string}
*/
public payee(transaction: any): string {
return transaction.payee;
}
/**
* Get the amount of the transaction.
* @param {object} transaction
* @returns {number}
*/
public amount(transaction: any): number {
return transaction.amount;
}
/**
* Get the formatted amount of the transaction.
* @param {object} transaction
* @returns {string}
*/
public formattedAmount(transaction: any): string {
return this.formatNumber(transaction.formattedAmount, {
money: true,
});
}
/**
* Get the date of the transaction.
* @param {object} transaction
* @returns {string}
*/
public date(transaction: any): string {
return transaction.date;
}
/**
* Get the formatted date of the transaction.
* @param {object} transaction
* @returns {string}
*/
public formattedDate(transaction: any): string {
return this.formatDate(transaction.date);
}
/**
* 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;
}
/**
*
* @returns {string}
*/
public assignedCategoryFormatted() {
return 'Other Income'
}
/**
* Check if the transaction is a withdrawal.
* @param {object} transaction
* @returns {boolean}
*/
public isWithdrawal(transaction: any): boolean {
return transaction.withdrawal;
}
/**
* Check if the transaction is a deposit.
* @param {object} transaction
* @returns {boolean}
*/
public isDeposit(transaction: any): boolean {
return transaction.deposit;
}
/**
* Check if the transaction is a deposit transaction.
* @param {object} transaction
* @returns {boolean}
*/
public isDepositTransaction(transaction: any): boolean {
return transaction.isDepositTransaction;
}
/**
* Check if the transaction is a withdrawal transaction.
* @param {object} transaction
* @returns {boolean}
*/
public isWithdrawalTransaction(transaction: any): boolean {
return transaction.isWithdrawalTransaction;
}
/**
* Get formatted deposit amount.
* @param {any} transaction
* @returns {string}
*/
protected formattedDepositAmount(transaction) {
if (transaction.isDepositTransaction) {
return formatNumber(transaction.deposit, {
currencyCode: transaction.currencyCode,
});
}
return '';
}
/**
* Get formatted withdrawal amount.
* @param transaction
* @returns {string}
*/
protected formattedWithdrawalAmount(transaction) {
if (transaction.isWithdrawalTransaction) {
return formatNumber(transaction.withdrawal, {
currencyCode: transaction.currencyCode,
});
}
return '';
}
/**
* Get the transaction bank rule id.
* @param transaction
* @returns {string}
*/
protected bankRuleId(transaction) {
return transaction.recognizedTransaction.bankRuleId;
}
/**
* Get the transaction bank rule name.
* @param transaction
* @returns {string}
*/
protected bankRuleName(transaction) {
return transaction.recognizedTransaction.bankRule.name;
}
}

View File

@@ -0,0 +1,51 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer';
import { IGetRecognizedTransactionsQuery } from '@/interfaces';
@Service()
export class GetRecognizedTransactionsService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the recognized transactions of the given account.
* @param {number} tenantId
* @param {IGetRecognizedTransactionsQuery} filter -
*/
async getRecognizedTranactions(
tenantId: number,
filter?: IGetRecognizedTransactionsQuery
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const _filter = {
page: 1,
pageSize: 20,
...filter,
};
const { results, pagination } =
await UncategorizedCashflowTransaction.query()
.onBuild((q) => {
q.withGraphFetched('recognizedTransaction.assignAccount');
q.withGraphFetched('recognizedTransaction.bankRule');
q.whereNotNull('recognizedTransactionId');
if (_filter.accountId) {
q.where('accountId', _filter.accountId);
}
})
.pagination(_filter.page - 1, _filter.pageSize);
const data = await this.transformer.transform(
tenantId,
results,
new GetRecognizedTransactionTransformer()
);
return { data, pagination };
}
}

View File

@@ -10,7 +10,7 @@ export class UncategorizedTransactionTransformer extends Transformer {
return [
'formattedAmount',
'formattedDate',
'formattetDepositAmount',
'formattedDepositAmount',
'formattedWithdrawalAmount',
];
};
@@ -40,7 +40,7 @@ export class UncategorizedTransactionTransformer extends Transformer {
* @param transaction
* @returns {string}
*/
protected formattetDepositAmount(transaction) {
protected formattedDepositAmount(transaction) {
if (transaction.isDepositTransaction) {
return formatNumber(transaction.deposit, {
currencyCode: transaction.currencyCode,

View File

@@ -22,6 +22,8 @@ export const TABLES = {
PROJECTS: 'projects',
TIMESHEETS: 'timesheets',
PROJECT_TASKS: 'project_tasks',
UNCATEGORIZED_ACCOUNT_TRANSACTIONS: 'UNCATEGORIZED_ACCOUNT_TRANSACTIONS',
EXCLUDED_BANK_TRANSACTIONS: 'EXCLUDED_BANK_TRANSACTIONS'
};
export const TABLE_SIZE = {

View File

@@ -28,6 +28,7 @@ export function AccountTransactionsUncategorizeFilter() {
<FilterTag round interactive>
All <strong>(2)</strong>
</FilterTag>
<FilterTag round minimal interactive>
Recognized <strong>(0)</strong>
</FilterTag>

View File

@@ -0,0 +1,37 @@
import React from 'react';
interface UncategorizedTransactionsFilterValue {}
const UncategorizedTransactionsFilterContext =
React.createContext<UncategorizedTransactionsFilterValue>(
{} as UncategorizedTransactionsFilterValue,
);
interface UncategorizedTransactionsFilterProviderProps {
children: React.ReactNode;
}
/**
*
*/
function UncategorizedTransactionsFilterProvider({
...props
}: UncategorizedTransactionsFilterProviderProps) {
// Provider payload.
const provider = {};
return (
<UncategorizedTransactionsFilterContext.Provider
value={provider}
{...props}
/>
);
}
const useUncategorizedTransactionsFilter = () =>
React.useContext(UncategorizedTransactionsFilterContext);
export {
UncategorizedTransactionsFilterProvider,
useUncategorizedTransactionsFilter,
};

View File

@@ -4,7 +4,6 @@ import styled from 'styled-components';
import '@/style/pages/CashFlow/AccountTransactions/List.scss';
import AccountTransactionsDataTable from './AccountTransactionsDataTable';
import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter';
import { AccountTransactionsAllProvider } from './AccountTransactionsAllBoot';
const Box = styled.div`
@@ -23,8 +22,6 @@ export default function AccountTransactionsAll() {
return (
<AccountTransactionsAllProvider>
<Box>
<AccountTransactionsUncategorizeFilter />
<CashflowTransactionsTableCard>
<AccountTransactionsDataTable />
</CashflowTransactionsTableCard>

View File

@@ -1,16 +1,22 @@
// @ts-nocheck
import { useEffect } from 'react';
import styled from 'styled-components';
import * as R from 'ramda';
import '@/style/pages/CashFlow/AccountTransactions/List.scss';
import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable';
import { AccountUncategorizedTransactionsBoot } from './AllTransactionsUncategorizedBoot';
import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter';
import { UncategorizedTransactionsFilterProvider } from './AccountUncategorizedTransactionsFilterProvider';
import { RecognizedTransactionsTableBoot } from './RecognizedTransactions/RecognizedTransactionsTableBoot';
import { RecognizedTransactionsTable } from './RecognizedTransactions/RecognizedTransactionsTable';
import {
WithBankingActionsProps,
withBankingActions,
} from '../withBankingActions';
import { useEffect } from 'react';
import { AccountUncategorizedTransactionsBoot } from './AllTransactionsUncategorizedBoot';
import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable';
import { ExcludedBankTransactionsTableBoot } from './ExcludedTransactions/ExcludedTransactionsTableBoot';
import { ExcludedTransactionsTable } from './ExcludedTransactions/ExcludedTransactionsTable';
const Box = styled.div`
margin: 30px 15px;
@@ -36,14 +42,31 @@ function AllTransactionsUncategorizedRoot({
},
[closeMatchingTransactionAside],
);
return (
<AccountUncategorizedTransactionsBoot>
<UncategorizedTransactionsFilterProvider>
<Box>
<CashflowTransactionsTableCard>
<AccountTransactionsUncategorizedTable />
</CashflowTransactionsTableCard>
<AccountTransactionsUncategorizeFilter />
<ExcludedBankTransactionsTableBoot>
<CashflowTransactionsTableCard>
<ExcludedTransactionsTable />
</CashflowTransactionsTableCard>
</ExcludedBankTransactionsTableBoot>
{/* <RecognizedTransactionsTableBoot>
<CashflowTransactionsTableCard>
<RecognizedTransactionsTable />
</CashflowTransactionsTableCard>
</RecognizedTransactionsTableBoot> */}
{/* <AccountUncategorizedTransactionsBoot>
<CashflowTransactionsTableCard>
<AccountTransactionsUncategorizedTable />
</CashflowTransactionsTableCard>
</AccountUncategorizedTransactionsBoot> */}
</Box>
</AccountUncategorizedTransactionsBoot>
</UncategorizedTransactionsFilterProvider>
);
}

View File

@@ -0,0 +1,137 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import {
DataTable,
TableFastCell,
TableSkeletonRows,
TableSkeletonHeader,
TableVirtualizedListRows,
AppToaster,
} from '@/components';
import { TABLES } from '@/constants/tables';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { useMemorizedColumnsWidths } from '@/hooks';
import { useExcludedTransactionsColumns } from './_utils';
import { useExcludedTransactionsBoot } from './ExcludedTransactionsTableBoot';
import { compose } from '@/utils';
import { ActionsMenu } from './_components';
import { useUnexcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { Intent } from '@blueprintjs/core';
interface ExcludedTransactionsTableProps {}
/**
* Renders the recognized account transactions datatable.
*/
function ExcludedTransactionsTableRoot({}: ExcludedTransactionsTableProps) {
const { excludedBankTransactions } = useExcludedTransactionsBoot();
const { mutateAsync: unexcludeBankTransaction } =
useUnexcludeUncategorizedTransaction();
// Retrieve table columns.
const columns = useExcludedTransactionsColumns();
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_ACCOUNT_TRANSACTIONS);
// Handle cell click.
const handleCellClick = (cell, event) => {};
// Handle restore button click.
const handleRestoreClick = (transaction) => {
unexcludeBankTransaction(transaction.id)
.then(() => {
AppToaster.show({
message: 'The excluded bank transaction has been restored.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
columns={columns}
data={excludedBankTransactions}
sticky={true}
loading={false}
headerLoading={false}
expandColumnSpace={1}
expandToggleColumn={2}
selectionColumnWidth={45}
TableCellRenderer={TableFastCell}
TableLoadingRenderer={TableSkeletonRows}
TableRowsRenderer={TableVirtualizedListRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
ContextMenu={ActionsMenu}
onCellClick={handleCellClick}
// #TableVirtualizedListRows props.
vListrowHeight={'small' == 'small' ? 32 : 40}
vListrowHeight={40}
vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing}
// noResults={<T id={'cash_flow.account_transactions.no_results'} />}
className="table-constrant"
payload={{
onRestore: handleRestoreClick,
}}
/>
);
}
export const ExcludedTransactionsTable = compose(withDrawerActions)(
ExcludedTransactionsTableRoot,
);
const DashboardConstrantTable = styled(DataTable)`
.table {
.thead {
.th {
background: #fff;
letter-spacing: 1px;
text-transform: uppercase;
font-weight: 500;
font-size: 13px;
}
}
.tbody {
.tr:last-child .td {
border-bottom: 0;
}
}
}
`;
const CashflowTransactionsTable = styled(DashboardConstrantTable)`
.table .tbody {
.tbody-inner .tr.no-results {
.td {
padding: 2rem 0;
font-size: 14px;
color: #888;
font-weight: 400;
border-bottom: 0;
}
}
.tbody-inner {
.tr .td {
border-bottom: 1px solid #e6e6e6;
}
}
}
`;

View File

@@ -0,0 +1,90 @@
// @ts-nocheck
import React from 'react';
import { flatten, map } from 'lodash';
import { IntersectionObserver } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useExcludedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
interface ExcludedBankTransactionsContextValue {
isExcludedTransactionsLoading: boolean;
isExcludedTransactionsFetching: boolean;
excludedBankTransactions: Array<any>;
}
const ExcludedTransactionsContext =
React.createContext<ExcludedBankTransactionsContextValue>(
{} as ExcludedBankTransactionsContextValue,
);
function flattenInfinityPagesData(data) {
return flatten(map(data.pages, (page) => page.data));
}
interface ExcludedBankTransactionsTableBootProps {
children: React.ReactNode;
}
/**
* Account uncategorized transctions provider.
*/
function ExcludedBankTransactionsTableBoot({
children,
}: ExcludedBankTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext();
// Fetches the uncategorized transactions.
const {
data: recognizedTransactionsPage,
isFetching: isExcludedTransactionsFetching,
isLoading: isExcludedTransactionsLoading,
isSuccess: isRecognizedTransactionsSuccess,
isFetchingNextPage: isUncategorizedTransactionFetchNextPage,
fetchNextPage: fetchNextrecognizedTransactionsPage,
hasNextPage: hasUncategorizedTransactionsNextPage,
} = useExcludedBankTransactionsInfinity({
page_size: 50,
account_id: accountId,
});
// Memorized the cashflow account transactions.
const excludedBankTransactions = React.useMemo(
() =>
isRecognizedTransactionsSuccess
? flattenInfinityPagesData(recognizedTransactionsPage)
: [],
[recognizedTransactionsPage, isRecognizedTransactionsSuccess],
);
// Handle the observer ineraction.
const handleObserverInteract = React.useCallback(() => {
if (
!isExcludedTransactionsFetching &&
hasUncategorizedTransactionsNextPage
) {
fetchNextrecognizedTransactionsPage();
}
}, [
isExcludedTransactionsFetching,
hasUncategorizedTransactionsNextPage,
fetchNextrecognizedTransactionsPage,
]);
// Provider payload.
const provider = {
excludedBankTransactions,
isExcludedTransactionsFetching,
isExcludedTransactionsLoading,
};
return (
<ExcludedTransactionsContext.Provider value={provider}>
{children}
<IntersectionObserver
onIntersect={handleObserverInteract}
enabled={!isUncategorizedTransactionFetchNextPage}
/>
</ExcludedTransactionsContext.Provider>
);
}
const useExcludedTransactionsBoot = () =>
React.useContext(ExcludedTransactionsContext);
export { ExcludedBankTransactionsTableBoot, useExcludedTransactionsBoot };

View File

@@ -0,0 +1,11 @@
// @ts-nocheck
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
import { safeCallback } from '@/utils';
export function ActionsMenu({ payload: { onRestore }, row: { original } }) {
return (
<Menu>
<MenuItem text={'Restore'} onClick={safeCallback(onRestore, original)} />
</Menu>
);
}

View File

@@ -0,0 +1,66 @@
// @ts-nocheck
import React from 'react';
import { getColumnWidth } from '@/utils';
import { useExcludedTransactionsBoot } from './ExcludedTransactionsTableBoot';
const getReportColWidth = (data, accessor, headerText) => {
return getColumnWidth(
data,
accessor,
{ magicSpacing: 10, minWidth: 100 },
headerText,
);
};
const descriptionAccessor = (transaction) => {
return <span style={{ color: '#5F6B7C' }}>{transaction.description}</span>;
};
/**
* Retrieve excluded transactions columns table.
*/
export function useExcludedTransactionsColumns() {
const { excludedBankTransactions: data } = useExcludedTransactionsBoot();
const withdrawalWidth = getReportColWidth(
data,
'formatted_withdrawal_amount',
'Withdrawal',
);
const depositWidth = getReportColWidth(
data,
'formatted_deposit_amount',
'Deposit',
);
return React.useMemo(
() => [
{
Header: 'Date',
accessor: 'formatted_date',
width: 110,
},
{
Header: 'Description',
accessor: descriptionAccessor,
},
{
Header: 'Payee',
accessor: 'payee',
},
{
Header: 'Deposit',
accessor: 'formatted_deposit_amount',
align: 'right',
width: depositWidth,
},
{
Header: 'Withdrawal',
accessor: 'formatted_withdrawal_amount',
align: 'right',
width: withdrawalWidth,
},
],
[],
);
}

View File

@@ -0,0 +1,170 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import {
DataTable,
TableFastCell,
TableSkeletonRows,
TableSkeletonHeader,
TableVirtualizedListRows,
FormattedMessage as T,
AppToaster,
} from '@/components';
import { TABLES } from '@/constants/tables';
import withSettings from '@/containers/Settings/withSettings';
import withAlertsActions from '@/containers/Alert/withAlertActions';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { useMemorizedColumnsWidths } from '@/hooks';
import { useUncategorizedTransactionsColumns } from './_utils';
import { useRecognizedTransactionsBoot } from './RecognizedTransactionsTableBoot';
import { ActionsMenu } from './_components';
import { compose } from '@/utils';
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { Intent } from '@blueprintjs/core';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
interface RecognizedTransactionsTableProps extends WithBankingActionsProps {}
/**
* Renders the recognized account transactions datatable.
*/
function RecognizedTransactionsTableRoot({
// #withSettings
cashflowTansactionsTableSize,
// #withAlertsActions
openAlert,
// #withDrawerActions
openDrawer,
// #withBanking
setUncategorizedTransactionIdForMatching,
}: RecognizedTransactionsTableProps) {
const { mutateAsync: excludeBankTransaction } =
useExcludeUncategorizedTransaction();
const { recognizedTransactions } = useRecognizedTransactionsBoot();
// Retrieve table columns.
const columns = useUncategorizedTransactionsColumns();
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_ACCOUNT_TRANSACTIONS);
// Handle cell click.
const handleCellClick = (cell, event) => {
setUncategorizedTransactionIdForMatching(
cell.row.original.uncategorized_transaction_id,
);
};
// Handle exclude button click.
const handleExcludeClick = (transaction) => {
excludeBankTransaction(transaction.uncategorized_transaction_id)
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The bank transaction has been excluded.',
});
})
.catch(() => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
//
const handleCategorizeClick = (transaction) => {
setUncategorizedTransactionIdForMatching(
transaction.uncategorized_transaction_id,
);
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
columns={columns}
data={recognizedTransactions}
sticky={true}
loading={false}
headerLoading={false}
expandColumnSpace={1}
expandToggleColumn={2}
selectionColumnWidth={45}
TableCellRenderer={TableFastCell}
TableLoadingRenderer={TableSkeletonRows}
TableRowsRenderer={TableVirtualizedListRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
ContextMenu={ActionsMenu}
onCellClick={handleCellClick}
// #TableVirtualizedListRows props.
vListrowHeight={'small' == 'small' ? 32 : 40}
vListrowHeight={40}
vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing}
noResults={<T id={'cash_flow.account_transactions.no_results'} />}
className="table-constrant"
payload={{
onExclude: handleExcludeClick,
onCategorize: handleCategorizeClick,
}}
/>
);
}
export const RecognizedTransactionsTable = compose(
withAlertsActions,
withDrawerActions,
withBankingActions,
)(RecognizedTransactionsTableRoot);
const DashboardConstrantTable = styled(DataTable)`
.table {
.thead {
.th {
background: #fff;
letter-spacing: 1px;
text-transform: uppercase;
font-weight: 500;
font-size: 13px;
}
}
.tbody {
.tr:last-child .td {
border-bottom: 0;
}
}
}
`;
const CashflowTransactionsTable = styled(DashboardConstrantTable)`
.table .tbody {
.tbody-inner .tr.no-results {
.td {
padding: 2rem 0;
font-size: 14px;
color: #888;
font-weight: 400;
border-bottom: 0;
}
}
.tbody-inner {
.tr .td {
border-bottom: 1px solid #e6e6e6;
}
}
}
`;

View File

@@ -0,0 +1,89 @@
// @ts-nocheck
import React from 'react';
import { flatten, map } from 'lodash';
import { IntersectionObserver } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useRecognizedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
interface RecognizedTransactionsContextValue {
isRecongizedTransactionsLoading: boolean;
isRecognizedTransactionsFetching: boolean;
recognizedTransactions: Array<any>;
}
const RecognizedTransactionsContext =
React.createContext<RecognizedTransactionsContextValue>(
{} as RecognizedTransactionsContextValue,
);
function flattenInfinityPagesData(data) {
return flatten(map(data.pages, (page) => page.data));
}
interface RecognizedTransactionsTableBootProps {
children: React.ReactNode;
}
/**
* Account uncategorized transctions provider.
*/
function RecognizedTransactionsTableBoot({
children,
}: RecognizedTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext();
// Fetches the uncategorized transactions.
const {
data: recognizedTransactionsPage,
isFetching: isRecognizedTransactionsFetching,
isLoading: isRecongizedTransactionsLoading,
isSuccess: isRecognizedTransactionsSuccess,
isFetchingNextPage: isUncategorizedTransactionFetchNextPage,
fetchNextPage: fetchNextrecognizedTransactionsPage,
hasNextPage: hasUncategorizedTransactionsNextPage,
} = useRecognizedBankTransactionsInfinity({
page_size: 50,
});
// Memorized the cashflow account transactions.
const recognizedTransactions = React.useMemo(
() =>
isRecognizedTransactionsSuccess
? flattenInfinityPagesData(recognizedTransactionsPage)
: [],
[recognizedTransactionsPage, isRecognizedTransactionsSuccess],
);
// Handle the observer ineraction.
const handleObserverInteract = React.useCallback(() => {
if (
!isRecognizedTransactionsFetching &&
hasUncategorizedTransactionsNextPage
) {
fetchNextrecognizedTransactionsPage();
}
}, [
isRecognizedTransactionsFetching,
hasUncategorizedTransactionsNextPage,
fetchNextrecognizedTransactionsPage,
]);
// Provider payload.
const provider = {
recognizedTransactions,
isRecognizedTransactionsFetching,
isRecongizedTransactionsLoading,
};
return (
<RecognizedTransactionsContext.Provider value={provider}>
{children}
<IntersectionObserver
onIntersect={handleObserverInteract}
enabled={!isUncategorizedTransactionFetchNextPage}
/>
</RecognizedTransactionsContext.Provider>
);
}
const useRecognizedTransactionsBoot = () =>
React.useContext(RecognizedTransactionsContext);
export { RecognizedTransactionsTableBoot, useRecognizedTransactionsBoot };

View File

@@ -0,0 +1,19 @@
// @ts-nocheck
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
import { safeCallback } from '@/utils';
export function ActionsMenu({
payload: { onCategorize, onExclude },
row: { original },
}) {
return (
<Menu>
<MenuItem
text={'Categorize'}
onClick={safeCallback(onCategorize, original)}
/>
<MenuDivider />
<MenuItem text={'Exclude'} onClick={safeCallback(onExclude, original)} />
</Menu>
);
}

View File

@@ -0,0 +1,91 @@
// @ts-nocheck
import { Group, Icon } from '@/components';
import { getColumnWidth } from '@/utils';
import React from 'react';
import { useRecognizedTransactionsBoot } from './RecognizedTransactionsTableBoot';
const getReportColWidth = (data, accessor, headerText) => {
return getColumnWidth(
data,
accessor,
{ magicSpacing: 10, minWidth: 100 },
headerText,
);
};
const recognizeAccessor = (transaction) => {
return (
<>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</>
);
};
const descriptionAccessor = (transaction) => {
return <span style={{ color: '#5F6B7C' }}>{transaction.description}</span>;
};
/**
* Retrieve uncategorized transactions columns table.
*/
export function useUncategorizedTransactionsColumns() {
const { recognizedTransactions: data } = useRecognizedTransactionsBoot();
const withdrawalWidth = getReportColWidth(
data,
'formatted_withdrawal_amount',
'Withdrawal',
);
const depositWidth = getReportColWidth(
data,
'formatted_deposit_amount',
'Deposit',
);
return React.useMemo(
() => [
{
Header: 'Date',
accessor: 'formatted_date',
width: 110,
},
{
Header: 'Description',
accessor: descriptionAccessor,
},
{
Header: 'Payee',
accessor: 'payee',
},
{
Header: 'Recognize',
accessor: recognizeAccessor,
textOverview: true,
},
{
Header: 'Rule',
accessor: 'bank_rule_name',
},
{
Header: 'Deposit',
accessor: 'formatted_deposit_amount',
align: 'right',
width: depositWidth,
},
{
Header: 'Withdrawal',
accessor: 'formatted_withdrawal_amount',
align: 'right',
width: withdrawalWidth,
},
],
[],
);
}

View File

@@ -44,7 +44,7 @@ export function MatchTransaction({
onClick={handleClick}
>
<Stack spacing={3}>
<span>{label}</span>
<span className={styles.label}>{label}</span>
<Text style={{ fontSize: 12, color: '#5C7080' }}>Date: {date}</Text>
</Stack>

View File

@@ -1,5 +1,10 @@
// @ts-nocheck
import { useMutation, useQuery, useQueryClient } from 'react-query';
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
@@ -118,7 +123,7 @@ export function useUnexcludeUncategorizedTransaction(props) {
return useMutation(
(uncategorizedTransactionId: number) =>
apiRequest.post(
apiRequest.put(
`/cashflow/transactions/${uncategorizedTransactionId}/unexclude`,
),
{
@@ -145,3 +150,70 @@ export function useMatchTransaction(props?: any) {
},
);
}
/**
* @returns
*/
export function useRecognizedBankTransactionsInfinity(
query,
infinityProps,
axios,
) {
const apiRequest = useApiRequest();
return useInfiniteQuery(
['RECOGNIZED_BANK_TRANSACTIONS_INFINITY', query],
async ({ pageParam = 1 }) => {
const response = await apiRequest.http({
...axios,
method: 'get',
url: `/api/banking/recognized`,
params: { page: pageParam, ...query },
});
return response.data;
},
{
getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1,
getNextPageParam: (lastPage) => {
const { pagination } = lastPage;
return pagination.total > pagination.page_size * pagination.page
? lastPage.pagination.page + 1
: undefined;
},
...infinityProps,
},
);
}
export function useExcludedBankTransactionsInfinity(
query,
infinityProps,
axios,
) {
const apiRequest = useApiRequest();
return useInfiniteQuery(
['EXCLUDED_BANK_TRANSACTIONS_INFINITY', query],
async ({ pageParam = 1 }) => {
const response = await apiRequest.http({
...axios,
method: 'get',
url: `/api/cashflow/excluded`,
params: { page: pageParam, ...query },
});
return response.data;
},
{
getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1,
getNextPageParam: (lastPage) => {
const { pagination } = lastPage;
return pagination.total > pagination.page_size * pagination.page
? lastPage.pagination.page + 1
: undefined;
},
...infinityProps,
},
);
}

View File

@@ -611,4 +611,10 @@ export default {
],
viewBox: '0 0 16 16',
},
arrowRight: {
path: [
'M14.7,7.29l-5-5C9.52,2.1,9.27,1.99,8.99,1.99c-0.55,0-1,0.45-1,1c0,0.28,0.11,0.53,0.29,0.71l3.29,3.29H1.99c-0.55,0-1,0.45-1,1s0.45,1,1,1h9.59l-3.29,3.29c-0.18,0.18-0.29,0.43-0.29,0.71c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l5-5c0.18-0.18,0.29-0.43,0.29-0.71S14.88,7.47,14.7,7.29z',
],
viewBox: '0 0 16 16',
},
};