diff --git a/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts index 880bb4705..79a6c7336 100644 --- a/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts +++ b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts @@ -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} + */ + private async getExcludedBankTransactions( + req: Request, + res: Response, + next: NextFunction + ): Promise { + 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); + } + } } diff --git a/packages/server/src/api/controllers/Banking/RecognizedTransactionsController.ts b/packages/server/src/api/controllers/Banking/RecognizedTransactionsController.ts index 4ed3ee15d..8d0655756 100644 --- a/packages/server/src/api/controllers/Banking/RecognizedTransactionsController.ts +++ b/packages/server/src/api/controllers/Banking/RecognizedTransactionsController.ts @@ -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); } diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index 7d427b998..cabdea423 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -164,3 +164,10 @@ export interface IGetUncategorizedTransactionsQuery { page?: number; pageSize?: number; } + + +export interface IGetRecognizedTransactionsQuery { + page?: number; + pageSize?: number; + accountId?: number; +} \ No newline at end of file diff --git a/packages/server/src/models/RecognizedBankTransaction.ts b/packages/server/src/models/RecognizedBankTransaction.ts index f5f65bb5b..32798445e 100644 --- a/packages/server/src/models/RecognizedBankTransaction.ts +++ b/packages/server/src/models/RecognizedBankTransaction.ts @@ -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', + }, + }, }; } } diff --git a/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts b/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts index 3ee664a64..a87b63815 100644 --- a/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts +++ b/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts @@ -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 + ); + } } diff --git a/packages/server/src/services/Banking/Exclude/GetExcludedBankTransactions.ts b/packages/server/src/services/Banking/Exclude/GetExcludedBankTransactions.ts new file mode 100644 index 000000000..4964a0fe8 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/GetExcludedBankTransactions.ts @@ -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 }; + } +} diff --git a/packages/server/src/services/Banking/Exclude/_types.ts b/packages/server/src/services/Banking/Exclude/_types.ts new file mode 100644 index 000000000..d8a5188a7 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/_types.ts @@ -0,0 +1,6 @@ + +export interface ExcludedBankTransactionsQuery { + page?: number; + pageSize?: number; + accountId?: number; +} \ No newline at end of file diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts index 77055ad34..5582652d8 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -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); diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts index f7487badd..217542bfc 100644 --- a/packages/server/src/services/Cashflow/CashflowApplication.ts +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -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 + ); + } } diff --git a/packages/server/src/services/Cashflow/GetRecognizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/GetRecognizedTransactionTransformer.ts new file mode 100644 index 000000000..dc4ecaf00 --- /dev/null +++ b/packages/server/src/services/Cashflow/GetRecognizedTransactionTransformer.ts @@ -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} + */ + 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; + } +} diff --git a/packages/server/src/services/Cashflow/GetRecongizedTransactions.ts b/packages/server/src/services/Cashflow/GetRecongizedTransactions.ts new file mode 100644 index 000000000..3bc6ac4fc --- /dev/null +++ b/packages/server/src/services/Cashflow/GetRecongizedTransactions.ts @@ -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 }; + } +} diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts index 85d1a1fbb..5328b8e70 100644 --- a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -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, diff --git a/packages/webapp/src/constants/tables.tsx b/packages/webapp/src/constants/tables.tsx index 33ad65b3e..c66849665 100644 --- a/packages/webapp/src/constants/tables.tsx +++ b/packages/webapp/src/constants/tables.tsx @@ -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 = { diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx index b7ac12623..82ff0be1c 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx @@ -28,6 +28,7 @@ export function AccountTransactionsUncategorizeFilter() { All (2) + Recognized (0) diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountUncategorizedTransactionsFilterProvider.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountUncategorizedTransactionsFilterProvider.tsx new file mode 100644 index 000000000..2909f3b75 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountUncategorizedTransactionsFilterProvider.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +interface UncategorizedTransactionsFilterValue {} + +const UncategorizedTransactionsFilterContext = + React.createContext( + {} as UncategorizedTransactionsFilterValue, + ); + +interface UncategorizedTransactionsFilterProviderProps { + children: React.ReactNode; +} + +/** + * + */ +function UncategorizedTransactionsFilterProvider({ + ...props +}: UncategorizedTransactionsFilterProviderProps) { + // Provider payload. + const provider = {}; + + return ( + + ); +} + +const useUncategorizedTransactionsFilter = () => + React.useContext(UncategorizedTransactionsFilterContext); + +export { + UncategorizedTransactionsFilterProvider, + useUncategorizedTransactionsFilter, +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx index c598e4bdc..298ea845d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx @@ -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 ( - - diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx index 182dd7541..807044d40 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -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 ( - + - - - + + + + + + + + + {/* + + + + */} + + {/* + + + + */} - + ); } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/ExcludedTransactionsTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/ExcludedTransactionsTable.tsx new file mode 100644 index 000000000..7fb0b9ac1 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/ExcludedTransactionsTable.tsx @@ -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 ( + } + 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; + } + } + } +`; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/ExcludedTransactionsTableBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/ExcludedTransactionsTableBoot.tsx new file mode 100644 index 000000000..9fd86a240 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/ExcludedTransactionsTableBoot.tsx @@ -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; +} + +const ExcludedTransactionsContext = + React.createContext( + {} 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 ( + + {children} + + + ); +} + +const useExcludedTransactionsBoot = () => + React.useContext(ExcludedTransactionsContext); + +export { ExcludedBankTransactionsTableBoot, useExcludedTransactionsBoot }; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/_components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/_components.tsx new file mode 100644 index 000000000..1d622b585 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/_components.tsx @@ -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 ( + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/_utils.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/_utils.tsx new file mode 100644 index 000000000..d82f902bb --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/ExcludedTransactions/_utils.tsx @@ -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 {transaction.description}; +}; + +/** + * 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, + }, + ], + [], + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/RecognizedTransactionsTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/RecognizedTransactionsTable.tsx new file mode 100644 index 000000000..96fdee7c7 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/RecognizedTransactionsTable.tsx @@ -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 ( + } + 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; + } + } + } +`; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/RecognizedTransactionsTableBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/RecognizedTransactionsTableBoot.tsx new file mode 100644 index 000000000..bd9a88507 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/RecognizedTransactionsTableBoot.tsx @@ -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; +} + +const RecognizedTransactionsContext = + React.createContext( + {} 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 ( + + {children} + + + ); +} + +const useRecognizedTransactionsBoot = () => + React.useContext(RecognizedTransactionsContext); + +export { RecognizedTransactionsTableBoot, useRecognizedTransactionsBoot }; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/_components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/_components.tsx new file mode 100644 index 000000000..9780a5b19 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/_components.tsx @@ -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 ( + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/_utils.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/_utils.tsx new file mode 100644 index 000000000..9cf05787f --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/RecognizedTransactions/_utils.tsx @@ -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 ( + <> + {transaction.assigned_category_formatted} + + {transaction.assigned_account_name} + + ); +}; + +const descriptionAccessor = (transaction) => { + return {transaction.description}; +}; + +/** + * 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, + }, + ], + [], + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx index 4458a26a5..e96d4f142 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx @@ -44,7 +44,7 @@ export function MatchTransaction({ onClick={handleClick} > - {label} + {label} Date: {date} diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index 15f47e87e..e642e6a14 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -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, + }, + ); +} diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index 26bef2707..a1f8e1d1d 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -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', + }, };