feat: bulk categorize and match bank transactions

This commit is contained in:
Ahmed Bouhuolia
2024-07-18 19:41:23 +02:00
parent 449390143d
commit 6fb02f9869
11 changed files with 238 additions and 134 deletions

View File

@@ -12,6 +12,8 @@ import {
PopoverInteractionKind, PopoverInteractionKind,
Position, Position,
Intent, Intent,
Switch,
Tooltip,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { import {
@@ -39,9 +41,9 @@ import { withBanking } from '../withBanking';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { import {
useExcludeUncategorizedTransactions, useExcludeUncategorizedTransactions,
useUnexcludeUncategorizedTransaction,
useUnexcludeUncategorizedTransactions, useUnexcludeUncategorizedTransactions,
} from '@/hooks/query/bank-rules'; } from '@/hooks/query/bank-rules';
import { withBankingActions } from '../withBankingActions';
function AccountTransactionsActionsBar({ function AccountTransactionsActionsBar({
// #withDialogActions // #withDialogActions
@@ -56,6 +58,9 @@ function AccountTransactionsActionsBar({
// #withBanking // #withBanking
uncategorizedTransationsIdsSelected, uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
// #withBankingActions
enableMultipleCategorization,
}) { }) {
const history = useHistory(); const history = useHistory();
const { accountId } = useAccountTransactionsContext(); const { accountId } = useAccountTransactionsContext();
@@ -148,6 +153,10 @@ function AccountTransactionsActionsBar({
}); });
}; };
const handleMultipleCategorizingSwitch = (event) => {
enableMultipleCategorization(event.currentTarget.checked);
};
return ( return (
<DashboardActionsBar> <DashboardActionsBar>
<NavbarGroup> <NavbarGroup>
@@ -201,7 +210,6 @@ function AccountTransactionsActionsBar({
disable={isExcludingLoading} disable={isExcludingLoading}
/> />
)} )}
{!isEmpty(excludedTransactionsIdsSelected) && ( {!isEmpty(excludedTransactionsIdsSelected) && (
<Button <Button
icon={<Icon icon="disable" iconSize={16} />} icon={<Icon icon="disable" iconSize={16} />}
@@ -215,6 +223,20 @@ function AccountTransactionsActionsBar({
</NavbarGroup> </NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}> <NavbarGroup align={Alignment.RIGHT}>
<Tooltip
content={
'Enables to categorize or matching multiple bank transactions into one transaction.'
}
position={Position.BOTTOM}
minimal
>
<Switch
label={'Multi Select'}
inline
onChange={handleMultipleCategorizingSwitch}
/>
</Tooltip>
<NavbarDivider />
<Popover <Popover
minimal={true} minimal={true}
interactionKind={PopoverInteractionKind.CLICK} interactionKind={PopoverInteractionKind.CLICK}
@@ -256,4 +278,5 @@ export default compose(
excludedTransactionsIdsSelected, excludedTransactionsIdsSelected,
}), }),
), ),
withBankingActions,
)(AccountTransactionsActionsBar); )(AccountTransactionsActionsBar);

View File

@@ -0,0 +1,15 @@
.table :global .td.categorize_include,
.table :global .th.categorize_include {
display: none;
}
.table.showCategorizeColumn :global .td.categorize_include,
.table.showCategorizeColumn :global .th.categorize_include {
display: flex;
}
.categorizeCheckbox:global(.bp4-checkbox) :global .bp4-control-indicator {
border-radius: 20px;
}

View File

@@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import clsx from 'classnames';
import styled from 'styled-components'; import styled from 'styled-components';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import { import {
@@ -12,17 +13,19 @@ import {
AppToaster, AppToaster,
} from '@/components'; } from '@/components';
import { TABLES } from '@/constants/tables'; import { TABLES } from '@/constants/tables';
import { ActionsMenu } from './UncategorizedTransactions/components'; import { ActionsMenu } from './components';
import withSettings from '@/containers/Settings/withSettings'; import withSettings from '@/containers/Settings/withSettings';
import { withBankingActions } from '../withBankingActions'; import { withBankingActions } from '../../withBankingActions';
import { useMemorizedColumnsWidths } from '@/hooks'; import { useMemorizedColumnsWidths } from '@/hooks';
import { useAccountUncategorizedTransactionsColumns } from './components'; import { useAccountUncategorizedTransactionsContext } from '../AllTransactionsUncategorizedBoot';
import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot';
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { useAccountUncategorizedTransactionsColumns } from './hooks';
import { compose } from '@/utils'; import { compose } from '@/utils';
import { withBanking } from '../../withBanking';
import styles from './AccountTransactionsUncategorizedTable.module.scss';
/** /**
* Account transactions data table. * Account transactions data table.
@@ -31,6 +34,9 @@ function AccountTransactionsDataTable({
// #withSettings // #withSettings
cashflowTansactionsTableSize, cashflowTansactionsTableSize,
// #withBanking
openMatchingTransactionAside,
// #withBankingActions // #withBankingActions
setUncategorizedTransactionIdForMatching, setUncategorizedTransactionIdForMatching,
setUncategorizedTransactionsSelected, setUncategorizedTransactionsSelected,
@@ -66,7 +72,7 @@ function AccountTransactionsDataTable({
message: 'The bank transaction has been excluded successfully.', message: 'The bank transaction has been excluded successfully.',
}); });
}) })
.catch((error) => { .catch(() => {
AppToaster.show({ AppToaster.show({
intent: Intent.DANGER, intent: Intent.DANGER,
message: 'Something went wrong.', message: 'Something went wrong.',
@@ -106,12 +112,14 @@ function AccountTransactionsDataTable({
noResults={ noResults={
'There is no uncategorized transactions in the current account.' 'There is no uncategorized transactions in the current account.'
} }
className="table-constrant"
onSelectedRowsChange={handleSelectedRowsChange} onSelectedRowsChange={handleSelectedRowsChange}
payload={{ payload={{
onExclude: handleExcludeTransaction, onExclude: handleExcludeTransaction,
onCategorize: handleCategorizeBtnClick, onCategorize: handleCategorizeBtnClick,
}} }}
className={clsx('table-constrant', styles.table, {
[styles.showCategorizeColumn]: openMatchingTransactionAside,
})}
/> />
); );
} }
@@ -121,6 +129,9 @@ export default compose(
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
})), })),
withBankingActions, withBankingActions,
withBanking(({ openMatchingTransactionAside }) => ({
openMatchingTransactionAside,
})),
)(AccountTransactionsDataTable); )(AccountTransactionsDataTable);
const DashboardConstrantTable = styled(DataTable)` const DashboardConstrantTable = styled(DataTable)`

View File

@@ -1,6 +1,6 @@
import * as R from 'ramda'; import * as R from 'ramda';
import { useEffect } from 'react'; import { useEffect } from 'react';
import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable'; import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable';
import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot'; import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot';
import { AccountTransactionsCard } from './AccountTransactionsCard'; import { AccountTransactionsCard } from './AccountTransactionsCard';
import { import {

View File

@@ -0,0 +1,143 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import {
Checkbox,
Intent,
PopoverInteractionKind,
Position,
Tag,
Tooltip,
} from '@blueprintjs/core';
import {
useAddTransactionsToCategorizeSelected,
useRemoveTransactionsToCategorizeSelected,
} from '@/hooks/state/banking';
import { Box, Icon } from '@/components';
import styles from './AccountTransactionsUncategorizedTable.module.scss';
function statusAccessor(transaction) {
return transaction.is_recognized ? (
<Tooltip
// compact
interactionKind={PopoverInteractionKind.HOVER}
position={Position.RIGHT}
content={
<Box>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</Box>
}
>
<Box>
<Tag intent={Intent.SUCCESS} interactive>
Recognized
</Tag>
</Box>
</Tooltip>
) : null;
}
/**
* Retrieve account uncategorized transctions table columns.
*/
export function useAccountUncategorizedTransactionsColumns() {
const addTransactionsToCategorizeSelected =
useAddTransactionsToCategorizeSelected();
const removeTransactionsToCategorizeSelected =
useRemoveTransactionsToCategorizeSelected();
const handleChange = (value) => (event) => {
if (event.currentTarget.checked) {
addTransactionsToCategorizeSelected(value.id);
} else {
removeTransactionsToCategorizeSelected(value.id);
}
};
return React.useMemo(
() => [
{
id: 'date',
Header: intl.get('date'),
accessor: 'formatted_date',
width: 40,
clickable: true,
textOverview: true,
},
{
id: 'description',
Header: 'Description',
accessor: 'description',
width: 160,
textOverview: true,
clickable: true,
},
{
id: 'payee',
Header: 'Payee',
accessor: 'payee',
width: 60,
clickable: true,
textOverview: true,
},
{
id: 'reference_number',
Header: 'Ref.#',
accessor: 'reference_no',
width: 50,
clickable: true,
textOverview: true,
},
{
id: 'status',
Header: 'Status',
accessor: statusAccessor,
},
{
id: 'deposit',
Header: intl.get('cash_flow.label.deposit'),
accessor: 'formatted_deposit_amount',
width: 40,
className: 'deposit',
textOverview: true,
align: 'right',
clickable: true,
},
{
id: 'withdrawal',
Header: intl.get('cash_flow.label.withdrawal'),
accessor: 'formatted_withdrawal_amount',
className: 'withdrawal',
width: 40,
textOverview: true,
align: 'right',
clickable: true,
},
{
id: 'categorize_include',
Header: '',
accessor: (value) => (
<Checkbox
large
onChange={handleChange(value)}
className={styles.categorizeCheckbox}
/>
),
width: 10,
minWidth: 10,
maxWidth: 10,
align: 'right',
className: 'categorize_include',
},
],
[],
);
}

View File

@@ -9,15 +9,10 @@ import {
PopoverInteractionKind, PopoverInteractionKind,
Position, Position,
Tooltip, Tooltip,
Checkbox,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components'; import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { safeCallback } from '@/utils'; import { safeCallback } from '@/utils';
import {
useAddTransactionsToCategorizeSelected,
useRemoveTransactionsToCategorizeSelected,
} from '@/hooks/state/banking';
export function ActionsMenu({ export function ActionsMenu({
payload: { onUncategorize, onUnmatch }, payload: { onUncategorize, onUnmatch },
@@ -155,122 +150,3 @@ export function AccountTransactionsProgressBar() {
<MaterialProgressBar /> <MaterialProgressBar />
) : null; ) : null;
} }
function statusAccessor(transaction) {
return transaction.is_recognized ? (
<Tooltip
compact
interactionKind={PopoverInteractionKind.HOVER}
position={Position.RIGHT}
content={
<Box>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</Box>
}
>
<Box>
<Tag intent={Intent.SUCCESS} interactive>
Recognized
</Tag>
</Box>
</Tooltip>
) : null;
}
/**
* Retrieve account uncategorized transctions table columns.
*/
export function useAccountUncategorizedTransactionsColumns() {
const addTransactionsToCategorizeSelected =
useAddTransactionsToCategorizeSelected();
const removeTransactionsToCategorizeSelected =
useRemoveTransactionsToCategorizeSelected();
const handleChange = (value) => (event) => {
if (event.currentTarget.checked) {
addTransactionsToCategorizeSelected(value.id);
} else {
removeTransactionsToCategorizeSelected(value.id);
}
};
return React.useMemo(
() => [
{
id: 'date',
Header: intl.get('date'),
accessor: 'formatted_date',
width: 40,
clickable: true,
textOverview: true,
},
{
id: 'description',
Header: 'Description',
accessor: 'description',
width: 160,
textOverview: true,
clickable: true,
},
{
id: 'payee',
Header: 'Payee',
accessor: 'payee',
width: 60,
clickable: true,
textOverview: true,
},
{
id: 'reference_number',
Header: 'Ref.#',
accessor: 'reference_no',
width: 50,
clickable: true,
textOverview: true,
},
{
id: 'status',
Header: 'Status',
accessor: statusAccessor,
},
{
id: 'deposit',
Header: intl.get('cash_flow.label.deposit'),
accessor: 'formatted_deposit_amount',
width: 40,
className: 'deposit',
textOverview: true,
align: 'right',
clickable: true,
},
{
id: 'withdrawal',
Header: intl.get('cash_flow.label.withdrawal'),
accessor: 'formatted_withdrawal_amount',
className: 'withdrawal',
width: 40,
textOverview: true,
align: 'right',
clickable: true,
},
{
id: 'categorize_include',
Header: 'Include',
accessor: (value) => <Checkbox large onChange={handleChange(value)} />,
width: 10,
minWidth: 10,
maxWidth: 10,
align: 'right',
},
],
[],
);
}

View File

@@ -18,6 +18,7 @@ export const withBanking = (mapState) => {
state.plaid.uncategorizedTransactionsSelected, state.plaid.uncategorizedTransactionsSelected,
excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected, excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected,
isMultipleCategorization: state.plaid.isMultipleCategorization,
}; };
return mapState ? mapState(mapped, state, props) : mapped; return mapState ? mapState(mapped, state, props) : mapped;
}; };

View File

@@ -10,6 +10,7 @@ import {
setExcludedTransactionsSelected, setExcludedTransactionsSelected,
resetTransactionsToCategorizeSelected, resetTransactionsToCategorizeSelected,
setTransactionsToCategorizeSelected, setTransactionsToCategorizeSelected,
enableMultipleCategorization,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps { export interface WithBankingActionsProps {
@@ -28,6 +29,8 @@ export interface WithBankingActionsProps {
setTransactionsToCategorizeSelected: (ids: Array<string | number>) => void; setTransactionsToCategorizeSelected: (ids: Array<string | number>) => void;
resetTransactionsToCategorizeSelected: () => void; resetTransactionsToCategorizeSelected: () => void;
enableMultipleCategorization: (enable: boolean) => void;
} }
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -66,6 +69,9 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
dispatch(setTransactionsToCategorizeSelected({ ids })), dispatch(setTransactionsToCategorizeSelected({ ids })),
resetTransactionsToCategorizeSelected: () => resetTransactionsToCategorizeSelected: () =>
dispatch(resetTransactionsToCategorizeSelected()), dispatch(resetTransactionsToCategorizeSelected()),
enableMultipleCategorization: (enable) =>
dispatch(enableMultipleCategorization({ enable })),
}); });
export const withBankingActions = connect< export const withBankingActions = connect<

View File

@@ -9,6 +9,7 @@ import {
getTransactionsToCategorizeSelected, getTransactionsToCategorizeSelected,
addTransactionsToCategorizeSelected, addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected, removeTransactionsToCategorizeSelected,
getOpenMatchingTransactionAside,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
export const useSetBankingPlaidToken = () => { export const useSetBankingPlaidToken = () => {
@@ -82,3 +83,14 @@ export const useResetTransactionsToCategorizeSelected = () => {
dispatch(resetTransactionsToCategorizeSelected()); dispatch(resetTransactionsToCategorizeSelected());
}, [dispatch]); }, [dispatch]);
}; };
export const useGetOpenMatchingTransactionAside = () => {
const openMatchingTransactionAside = useSelector(
getOpenMatchingTransactionAside,
);
return useMemo(
() => openMatchingTransactionAside,
[openMatchingTransactionAside],
);
};

View File

@@ -10,6 +10,8 @@ interface StorePlaidState {
uncategorizedTransactionsSelected: Array<number | string>; uncategorizedTransactionsSelected: Array<number | string>;
excludedTransactionsSelected: Array<number | string>; excludedTransactionsSelected: Array<number | string>;
transactionsToCategorizeSelected: Array<number | string>; transactionsToCategorizeSelected: Array<number | string>;
enableMultipleCategorization: boolean;
} }
export const PlaidSlice = createSlice({ export const PlaidSlice = createSlice({
@@ -25,6 +27,7 @@ export const PlaidSlice = createSlice({
uncategorizedTransactionsSelected: [], uncategorizedTransactionsSelected: [],
excludedTransactionsSelected: [], excludedTransactionsSelected: [],
transactionsToCategorizeSelected: [], transactionsToCategorizeSelected: [],
enableMultipleCategorization: false,
} as StorePlaidState, } as StorePlaidState,
reducers: { reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => { setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -113,6 +116,13 @@ export const PlaidSlice = createSlice({
resetTransactionsToCategorizeSelected: (state: StorePlaidState) => { resetTransactionsToCategorizeSelected: (state: StorePlaidState) => {
state.transactionsToCategorizeSelected = []; state.transactionsToCategorizeSelected = [];
}, },
enableMultipleCategorization: (
state: StorePlaidState,
action: PayloadAction<{ enable: boolean }>,
) => {
state.enableMultipleCategorization = action.payload.enable;
},
}, },
}); });
@@ -131,8 +141,15 @@ export const {
addTransactionsToCategorizeSelected, addTransactionsToCategorizeSelected,
removeTransactionsToCategorizeSelected, removeTransactionsToCategorizeSelected,
resetTransactionsToCategorizeSelected, resetTransactionsToCategorizeSelected,
enableMultipleCategorization,
} = PlaidSlice.actions; } = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken; export const getPlaidToken = (state: any) => state.plaid.plaidToken;
export const getTransactionsToCategorizeSelected = (state: any) => export const getTransactionsToCategorizeSelected = (state: any) =>
state.plaid.transactionsToCategorizeSelected; state.plaid.transactionsToCategorizeSelected;
export const getOpenMatchingTransactionAside = (state: any) =>
state.plaid.openMatchingTransactionAside;
export const isMultipleCategorization = (state: any) =>
state.plaid.enableMultipleCategorization;

View File

@@ -124,7 +124,7 @@
} }
} }
.bp4-control.bp4-checkbox .bp4-control-indicator { .bp4-control.bp4-checkbox:not(.bp4-large) .bp4-control-indicator {
cursor: auto; cursor: auto;
&, &,