feat: wip multi-select transactions to categorization and matching

This commit is contained in:
Ahmed Bouhuolia
2024-08-03 22:01:21 +02:00
parent 5ce11f192f
commit d74337fb94
29 changed files with 476 additions and 155 deletions

View File

@@ -36,10 +36,14 @@ function AccountTransactionsDataTable({
// #withBanking
openMatchingTransactionAside,
enableMultipleCategorization,
// #withBankingActions
setUncategorizedTransactionIdForMatching,
setUncategorizedTransactionsSelected,
addTransactionsToCategorizeSelected,
setTransactionsToCategorizeSelected,
}) {
// Retrieve table columns.
const columns = useAccountUncategorizedTransactionsColumns();
@@ -57,7 +61,11 @@ function AccountTransactionsDataTable({
// Handle cell click.
const handleCellClick = (cell) => {
setUncategorizedTransactionIdForMatching(cell.row.original.id);
if (enableMultipleCategorization) {
addTransactionsToCategorizeSelected(cell.row.original.id);
} else {
setTransactionsToCategorizeSelected(cell.row.original.id);
}
};
// Handles categorize button click.
const handleCategorizeBtnClick = (transaction) => {
@@ -80,12 +88,6 @@ function AccountTransactionsDataTable({
});
};
// Handle selected rows change.
const handleSelectedRowsChange = (selected) => {
const _selectedIds = selected?.map((row) => row.original.id);
setUncategorizedTransactionsSelected(_selectedIds);
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
@@ -112,13 +114,12 @@ function AccountTransactionsDataTable({
noResults={
'There is no uncategorized transactions in the current account.'
}
onSelectedRowsChange={handleSelectedRowsChange}
payload={{
onExclude: handleExcludeTransaction,
onCategorize: handleCategorizeBtnClick,
}}
className={clsx('table-constrant', styles.table, {
[styles.showCategorizeColumn]: openMatchingTransactionAside,
[styles.showCategorizeColumn]: enableMultipleCategorization,
})}
/>
);
@@ -129,9 +130,12 @@ export default compose(
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
})),
withBankingActions,
withBanking(({ openMatchingTransactionAside }) => ({
openMatchingTransactionAside,
})),
withBanking(
({ openMatchingTransactionAside, enableMultipleCategorization }) => ({
openMatchingTransactionAside,
enableMultipleCategorization,
}),
),
)(AccountTransactionsDataTable);
const DashboardConstrantTable = styled(DataTable)`

View File

@@ -131,9 +131,9 @@ export function useAccountUncategorizedTransactionsColumns() {
className={styles.categorizeCheckbox}
/>
),
width: 10,
minWidth: 10,
maxWidth: 10,
width: 20,
minWidth: 20,
maxWidth: 20,
align: 'right',
className: 'categorize_include',
},

View File

@@ -6,10 +6,13 @@ import { useAccounts, useBranches } from '@/hooks/query';
import { useFeatureCan } from '@/hooks/state';
import { Features } from '@/constants';
import { Spinner } from '@blueprintjs/core';
import { useGetRecognizedBankTransaction } from '@/hooks/query/bank-rules';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
import {
GetAutofillCategorizeTransaction,
useGetAutofillCategorizeTransaction,
} from '@/hooks/query/bank-rules';
interface CategorizeTransactionBootProps {
uncategorizedTransactionsIds: Array<number>;
children: React.ReactNode;
}
@@ -19,8 +22,8 @@ interface CategorizeTransactionBootValue {
isBranchesLoading: boolean;
isAccountsLoading: boolean;
primaryBranch: any;
recognizedTranasction: any;
isRecognizedTransactionLoading: boolean;
autofillCategorizeValues: null | GetAutofillCategorizeTransaction;
isAutofillCategorizeValuesLoading: boolean;
}
const CategorizeTransactionBootContext =
@@ -32,11 +35,9 @@ const CategorizeTransactionBootContext =
* Categorize transcation boot.
*/
function CategorizeTransactionBoot({
uncategorizedTransactionsIds,
...props
}: CategorizeTransactionBootProps) {
const { uncategorizedTransaction, uncategorizedTransactionId } =
useCategorizeTransactionTabsBoot();
// Detarmines whether the feature is enabled.
const { featureCan } = useFeatureCan();
const isBranchFeatureCan = featureCan(Features.Branches);
@@ -49,13 +50,11 @@ function CategorizeTransactionBoot({
{},
{ enabled: isBranchFeatureCan },
);
// Fetches the recognized transaction.
// Fetches the autofill values of categorize transaction.
const {
data: recognizedTranasction,
isLoading: isRecognizedTransactionLoading,
} = useGetRecognizedBankTransaction(uncategorizedTransactionId, {
enabled: !!uncategorizedTransaction.is_recognized,
});
data: autofillCategorizeValues,
isLoading: isAutofillCategorizeValuesLoading,
} = useGetAutofillCategorizeTransaction(uncategorizedTransactionsIds, {});
// Retrieves the primary branch.
const primaryBranch = useMemo(
@@ -69,11 +68,11 @@ function CategorizeTransactionBoot({
isBranchesLoading,
isAccountsLoading,
primaryBranch,
recognizedTranasction,
isRecognizedTransactionLoading,
autofillCategorizeValues,
isAutofillCategorizeValuesLoading,
};
const isLoading =
isBranchesLoading || isAccountsLoading || isRecognizedTransactionLoading;
isBranchesLoading || isAccountsLoading || isAutofillCategorizeValuesLoading;
if (isLoading) {
<Spinner size={30} />;

View File

@@ -1,15 +1,16 @@
// @ts-nocheck
import styled from 'styled-components';
import * as R from 'ramda';
import { CategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { CategorizeTransactionForm } from './CategorizeTransactionForm';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
export function CategorizeTransactionContent() {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
import { withBanking } from '@/containers/CashFlow/withBanking';
function CategorizeTransactionContentRoot({
transactionsToCategorizeIdsSelected,
}) {
return (
<CategorizeTransactionBoot
uncategorizedTransactionId={uncategorizedTransactionId}
uncategorizedTransactionsIds={transactionsToCategorizeIdsSelected}
>
<CategorizeTransactionDrawerBody>
<CategorizeTransactionForm />
@@ -18,6 +19,12 @@ export function CategorizeTransactionContent() {
);
}
export const CategorizeTransactionContent = R.compose(
withBanking(({ transactionsToCategorizeIdsSelected }) => ({
transactionsToCategorizeIdsSelected,
})),
)(CategorizeTransactionContentRoot);
const CategorizeTransactionDrawerBody = styled.div`
display: flex;
flex-direction: column;

View File

@@ -22,7 +22,7 @@ function CategorizeTransactionFormRoot({
// #withBankingActions
closeMatchingTransactionAside,
}) {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
const { uncategorizedTransactionIds } = useCategorizeTransactionTabsBoot();
const { mutateAsync: categorizeTransaction } = useCategorizeTransaction();
// Form initial values in create and edit mode.
@@ -30,10 +30,10 @@ function CategorizeTransactionFormRoot({
// Callbacks handles form submit.
const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
const transformedValues = tranformToRequest(values);
const _values = tranformToRequest(values, uncategorizedTransactionIds);
setSubmitting(true);
categorizeTransaction([uncategorizedTransactionId, transformedValues])
categorizeTransaction(_values)
.then(() => {
setSubmitting(false);

View File

@@ -6,6 +6,7 @@ import { Box, FFormGroup, FSelect } from '@/components';
import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants';
import { useFormikContext } from 'formik';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
// Retrieves the add money in button options.
const MoneyInOptions = getAddMoneyInOptions();
@@ -18,16 +19,18 @@ const Title = styled('h3')`
`;
export function CategorizeTransactionFormContent() {
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
const { autofillCategorizeValues } = useCategorizeTransactionBoot();
const transactionTypes = uncategorizedTransaction?.is_deposit_transaction
const transactionTypes = autofillCategorizeValues?.isDepositTransaction
? MoneyInOptions
: MoneyOutOptions;
const formattedAmount = autofillCategorizeValues?.formattedAmount;
return (
<Box style={{ flex: 1, margin: 20 }}>
<FormGroup label={'Amount'} inline>
<Title>{uncategorizedTransaction.formatted_amount}</Title>
<Title>{formattedAmount}</Title>
</FormGroup>
<FFormGroup name={'category'} label={'Category'} fastField inline>

View File

@@ -1,8 +1,8 @@
// @ts-nocheck
import * as R from 'ramda';
import { transformToForm, transfromToSnakeCase } from '@/utils';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { GetAutofillCategorizeTransaction } from '@/hooks/query/bank-rules';
// Default initial form values.
export const defaultInitialValues = {
@@ -18,48 +18,28 @@ export const defaultInitialValues = {
};
export const transformToCategorizeForm = (
uncategorizedTransaction: any,
recognizedTransaction?: any,
autofillCategorizeTransaction: GetAutofillCategorizeTransaction,
) => {
let defaultValues = {
debitAccountId: uncategorizedTransaction.account_id,
transactionType: uncategorizedTransaction.is_deposit_transaction
? 'other_income'
: 'other_expense',
amount: uncategorizedTransaction.amount,
date: uncategorizedTransaction.date,
};
if (recognizedTransaction) {
const recognizedDefaults = getRecognizedTransactionDefaultValues(
recognizedTransaction,
);
defaultValues = R.merge(defaultValues, recognizedDefaults);
}
return transformToForm(defaultValues, defaultInitialValues);
return transformToForm(autofillCategorizeTransaction, defaultInitialValues);
};
export const getRecognizedTransactionDefaultValues = (
recognizedTransaction: any,
export const tranformToRequest = (
formValues: Record<string, any>,
uncategorizedTransactionIds: Array<number>,
) => {
return {
creditAccountId: recognizedTransaction.assignedAccountId || '',
// transactionType: recognizedTransaction.assignCategory,
referenceNo: recognizedTransaction.referenceNo || '',
uncategorized_transaction_ids: uncategorizedTransactionIds,
...transfromToSnakeCase(formValues),
};
};
export const tranformToRequest = (formValues: Record<string, any>) => {
return transfromToSnakeCase(formValues);
};
/**
* Categorize transaction form initial values.
* @returns
*/
export const useCategorizeTransactionFormInitialValues = () => {
const { primaryBranch, recognizedTranasction } =
const { primaryBranch, autofillCategorizeValues } =
useCategorizeTransactionBoot();
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
return {
...defaultInitialValues,
@@ -68,10 +48,7 @@ export const useCategorizeTransactionFormInitialValues = () => {
* values such as `notes` come back from the API as null, so remove those
* as well.
*/
...transformToCategorizeForm(
uncategorizedTransaction,
recognizedTranasction,
),
...transformToCategorizeForm(autofillCategorizeValues),
/** Assign the primary branch id as default value. */
branchId: primaryBranch?.id || null,

View File

@@ -43,9 +43,8 @@ function CategorizeTransactionAsideRoot({
const handleClose = () => {
closeMatchingTransactionAside();
};
const uncategorizedTransactionId = selectedUncategorizedTransactionId;
}
// Cannot continue if there is no selected transactions.;
if (!selectedUncategorizedTransactionId) {
return null;
}
@@ -53,7 +52,7 @@ function CategorizeTransactionAsideRoot({
<Aside title={'Categorize Bank Transaction'} onClose={handleClose}>
<Aside.Body>
<CategorizeTransactionTabsBoot
uncategorizedTransactionId={uncategorizedTransactionId}
uncategorizedTransactionId={selectedUncategorizedTransactionId}
>
<CategorizeTransactionTabs />
</CategorizeTransactionTabsBoot>
@@ -64,7 +63,7 @@ function CategorizeTransactionAsideRoot({
export const CategorizeTransactionAside = R.compose(
withBankingActions,
withBanking(({ selectedUncategorizedTransactionId }) => ({
selectedUncategorizedTransactionId,
withBanking(({ transactionsToCategorizeIdsSelected }) => ({
selectedUncategorizedTransactionId: transactionsToCategorizeIdsSelected,
})),
)(CategorizeTransactionAsideRoot);

View File

@@ -2,14 +2,10 @@
import { Tab, Tabs } from '@blueprintjs/core';
import { MatchingBankTransaction } from './MatchingTransaction';
import { CategorizeTransactionContent } from '../CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import styles from './CategorizeTransactionTabs.module.scss';
export function CategorizeTransactionTabs() {
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
const defaultSelectedTabId = uncategorizedTransaction?.is_recognized
? 'categorize'
: 'matching';
const defaultSelectedTabId = 'categorize';
return (
<Tabs

View File

@@ -1,16 +1,13 @@
// @ts-nocheck
import React from 'react';
import { Spinner } from '@blueprintjs/core';
import { useUncategorizedTransaction } from '@/hooks/query';
import React, { useMemo } from 'react';
import { castArray, uniq } from 'lodash';
interface CategorizeTransactionTabsValue {
uncategorizedTransactionId: number;
isUncategorizedTransactionLoading: boolean;
uncategorizedTransaction: any;
uncategorizedTransactionIds: Array<number>;
}
interface CategorizeTransactionTabsBootProps {
uncategorizedTransactionId: number;
uncategorizedTransactionIds: number | Array<number>;
children: React.ReactNode;
}
@@ -26,28 +23,23 @@ export function CategorizeTransactionTabsBoot({
uncategorizedTransactionId,
children,
}: CategorizeTransactionTabsBootProps) {
const {
data: uncategorizedTransaction,
isLoading: isUncategorizedTransactionLoading,
} = useUncategorizedTransaction(uncategorizedTransactionId);
const uncategorizedTransactionIds = useMemo(
() => uniq(castArray(uncategorizedTransactionId)),
[uncategorizedTransactionId],
);
const provider = {
uncategorizedTransactionId,
uncategorizedTransaction,
isUncategorizedTransactionLoading,
uncategorizedTransactionIds,
};
const isLoading = isUncategorizedTransactionLoading;
// Use a key prop to force re-render of children when uncategorizedTransactionId changes
// Use a key prop to force re-render of children when `uncategorizedTransactionIds` changes
const childrenPerKey = React.useMemo(() => {
return React.Children.map(children, (child) =>
React.cloneElement(child, { key: uncategorizedTransactionId }),
React.cloneElement(child, {
key: uncategorizedTransactionIds?.join(','),
}),
);
}, [children, uncategorizedTransactionId]);
}, [children, uncategorizedTransactionIds]);
if (isLoading) {
return <Spinner size={30} />;
}
return (
<CategorizeTransactionTabsBootContext.Provider value={provider}>
{childrenPerKey}

View File

@@ -1,8 +1,7 @@
// @ts-nocheck
import { isEmpty } from 'lodash';
import * as R from 'ramda';
import { useEffect, useState, useMemo } from 'react';
import { uniq } from 'lodash';
import { useEffect, useState } from 'react';
import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core';
import { FastField, FastFieldProps, Formik, useFormikContext } from 'formik';
import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components';
@@ -45,24 +44,15 @@ function MatchingBankTransactionRoot({
// #withBanking
transactionsToCategorizeIdsSelected,
}) {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
const { uncategorizedTransactionIds } = useCategorizeTransactionTabsBoot();
const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction();
const selectedTransactionsIds = useMemo(
() =>
uniq([
...transactionsToCategorizeIdsSelected,
uncategorizedTransactionId,
]),
[uncategorizedTransactionId, transactionsToCategorizeIdsSelected],
);
// Handles the form submitting.
const handleSubmit = (
values: MatchingTransactionFormValues,
{ setSubmitting }: FormikHelpers<MatchingTransactionFormValues>,
) => {
const _values = transformToReq(values);
const _values = transformToReq(values, uncategorizedTransactionIds);
if (_values.matchedTransactions?.length === 0) {
AppToaster.show({
@@ -72,7 +62,7 @@ function MatchingBankTransactionRoot({
return;
}
setSubmitting(true);
matchTransaction({ id: uncategorizedTransactionId, value: _values })
matchTransaction(_values)
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
@@ -91,7 +81,7 @@ function MatchingBankTransactionRoot({
message: `The total amount does not equal the uncategorized transaction.`,
intent: Intent.DANGER,
});
setSubmitting(false);
return;
}
AppToaster.show({
@@ -104,7 +94,7 @@ function MatchingBankTransactionRoot({
return (
<MatchingTransactionBoot
uncategorizedTransactionsIds={selectedTransactionsIds}
uncategorizedTransactionsIds={uncategorizedTransactionIds}
>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<MatchingBankTransactionFormContent />

View File

@@ -10,6 +10,7 @@ interface MatchingTransactionBootValues {
possibleMatches: Array<any>;
perfectMatchesCount: number;
perfectMatches: Array<any>;
totalPending: number;
matches: Array<any>;
}
@@ -36,6 +37,7 @@ function MatchingTransactionBoot({
const possibleMatches = defaultTo(matchingTransactions?.possibleMatches, []);
const perfectMatchesCount = matchingTransactions?.perfectMatches?.length || 0;
const perfectMatches = defaultTo(matchingTransactions?.perfectMatches, []);
const totalPending = defaultTo(matchingTransactions?.totalPending, 0);
const matches = R.concat(perfectMatches, possibleMatches);
@@ -46,6 +48,7 @@ function MatchingTransactionBoot({
possibleMatches,
perfectMatchesCount,
perfectMatches,
totalPending,
matches,
} as MatchingTransactionBootValues;

View File

@@ -4,7 +4,10 @@ import { useMatchingTransactionBoot } from './MatchingTransactionBoot';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import { useMemo } from 'react';
export const transformToReq = (values: MatchingTransactionFormValues) => {
export const transformToReq = (
values: MatchingTransactionFormValues,
uncategorizedTransactions: Array<number>,
) => {
const matchedTransactions = Object.entries(values.matched)
.filter(([key, value]) => value)
.map(([key]) => {
@@ -12,14 +15,13 @@ export const transformToReq = (values: MatchingTransactionFormValues) => {
return { reference_type, reference_id: parseInt(reference_id, 10) };
});
return { matchedTransactions };
return { matchedTransactions, uncategorizedTransactions };
};
export const useGetPendingAmountMatched = () => {
const { values } = useFormikContext<MatchingTransactionFormValues>();
const { perfectMatches, possibleMatches } = useMatchingTransactionBoot();
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
const { perfectMatches, possibleMatches, totalPending } =
useMatchingTransactionBoot();
return useMemo(() => {
const matchedItems = [...perfectMatches, ...possibleMatches].filter(
@@ -34,11 +36,10 @@ export const useGetPendingAmountMatched = () => {
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
0,
);
const amount = uncategorizedTransaction.amount;
const pendingAmount = amount - totalMatchedAmount;
const pendingAmount = totalPending - totalMatchedAmount;
return pendingAmount;
}, [uncategorizedTransaction, perfectMatches, possibleMatches, values]);
}, [totalPending, perfectMatches, possibleMatches, values]);
};
export const useAtleastOneMatchedSelected = () => {

View File

@@ -18,7 +18,7 @@ export const withBanking = (mapState) => {
state.plaid.uncategorizedTransactionsSelected,
excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected,
isMultipleCategorization: state.plaid.isMultipleCategorization,
enableMultipleCategorization: state.plaid.enableMultipleCategorization,
transactionsToCategorizeIdsSelected:
state.plaid.transactionsToCategorizeSelected,

View File

@@ -11,6 +11,8 @@ import {
resetTransactionsToCategorizeSelected,
setTransactionsToCategorizeSelected,
enableMultipleCategorization,
addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected,
} from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps {
@@ -28,6 +30,8 @@ export interface WithBankingActionsProps {
resetExcludedTransactionsSelected: () => void;
setTransactionsToCategorizeSelected: (ids: Array<string | number>) => void;
addTransactionsToCategorizeSelected: (id: string | number) => void;
removeTransactionsToCategorizeSelected: (id: string | number) => void;
resetTransactionsToCategorizeSelected: () => void;
enableMultipleCategorization: (enable: boolean) => void;
@@ -88,6 +92,22 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
setTransactionsToCategorizeSelected: (ids: Array<string | number>) =>
dispatch(setTransactionsToCategorizeSelected({ ids })),
/**
* Adds selected transactions to categorize.
* @param {string | number} id
* @returns
*/
addTransactionsToCategorizeSelected: (id: string | number) =>
dispatch(addTransactionsToCategorizeSelected({ id })),
/**
* Removes the selected transactions.
* @param {string | number} id
* @returns
*/
removeTransactionsToCategorizeSelected: (id: string | number) =>
dispatch(removeTransactionsToCategorizeSelected({ id })),
/**
* Resets the selected transactions to categorize or match.
*/