feat: hook up the matching form to the server

This commit is contained in:
Ahmed Bouhuolia
2024-06-26 17:39:12 +02:00
parent d2d37820f5
commit 7a9c7209bc
16 changed files with 538 additions and 177 deletions

View File

@@ -0,0 +1,10 @@
.title{
align-items: center;
background: #fff;
border-bottom: 1px solid #E1E2E9;
display: flex;
flex: 0 0 auto;
min-height: 40px;
padding: 5px 5px 5px 15px;
z-index: 0;
}

View File

@@ -0,0 +1,40 @@
import { Button, Classes } from '@blueprintjs/core';
import { Box, Group } from '../Layout';
import { Icon } from '../Icon';
import styles from './Aside.module.scss';
interface AsideProps {
title?: string;
onClose?: () => void;
children?: React.ReactNode;
hideCloseButton?: boolean;
}
export function Aside({
title,
onClose,
children,
hideCloseButton,
}: AsideProps) {
const handleClose = () => {
onClose && onClose();
};
return (
<Box>
<Group position="apart" className={styles.title}>
{title}
{hideCloseButton !== true && (
<Button
aria-label="Close"
className={Classes.DIALOG_CLOSE_BUTTON}
icon={<Icon icon={'smallCross'} color={'#000'} />}
minimal={true}
onClick={handleClose}
/>
)}
</Group>
<Box>{children}</Box>
</Box>
);
}

View File

@@ -33,7 +33,6 @@ function RulesTable({
}) {
// Invoices table columns.
const columns = useBankRulesTableColumns();
const { bankRules } = useRulesListBoot();
// Handle edit bank rule.

View File

@@ -9,6 +9,7 @@ import {
TableSkeletonHeader,
TableVirtualizedListRows,
FormattedMessage as T,
AppToaster,
} from '@/components';
import { TABLES } from '@/constants/tables';
@@ -24,6 +25,8 @@ import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUnc
import { compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { Intent } from '@blueprintjs/core';
/**
* Account transactions data table.
@@ -42,6 +45,9 @@ function AccountTransactionsDataTable({
const { uncategorizedTransactions, isUncategorizedTransactionsLoading } =
useAccountUncategorizedTransactionsContext();
const { mutateAsync: excludeTransaction } =
useExcludeUncategorizedTransaction();
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_CASHFLOW_TRANSACTION);
@@ -52,6 +58,22 @@ function AccountTransactionsDataTable({
uncategorizedTransactionId: cell.row.original.id,
});
};
// Handle exclude transaction.
const handleExcludeTransaction = (transaction) => {
excludeTransaction(transaction.id)
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The bank transaction has been excluded successfully.',
});
})
.catch((error) => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
return (
<CashflowTransactionsTable
@@ -77,6 +99,9 @@ function AccountTransactionsDataTable({
onColumnResizing={handleColumnResizing}
noResults={<T id={'cash_flow.account_transactions.no_results'} />}
className="table-constrant"
payload={{
onExclude: handleExcludeTransaction,
}}
/>
);
}

View File

@@ -15,7 +15,7 @@ import { AbilitySubject, CashflowAction } from '@/constants/abilityOption';
import { safeCallback } from '@/utils';
export function ActionsMenu({
payload: { onDelete, onViewDetails },
payload: { onDelete, onViewDetails, onExclude },
row: { original },
}) {
return (
@@ -36,6 +36,12 @@ export function ActionsMenu({
/>
</If>
</Can>
<MenuDivider />
<MenuItem
text={'Exclude'}
onClick={safeCallback(onExclude, original)}
// icon={<Icon icon="trash-16" iconSize={16} />}
/>
</Menu>
);
}

View File

@@ -1,47 +1,25 @@
.transaction {
background: #fff;
border-radius: 5px;
border: 1px solid #D6DBE3;
padding: 12px 16px;
}
.asideHeader {
align-items: center;
background: #fff;
border-bottom: 1px solid #E1E2E9;
display: flex;
flex: 0 0 auto;
min-height: 40px;
padding: 5px 5px 5px 15px;
z-index: 0;
}
.tabs :global .bp4-tab-panel{
margin-top: 0;
}
.tabs :global .bp4-tab-list{
background: #fff;
border-bottom: 1px solid #c7d5db;
padding: 0 22px;
}
.tabs :global .bp4-large > .bp4-tab{
font-size: 14px;
}
.matchBar{
padding: 16px 18px;
padding: 12px 18px;
background: #fff;
border-bottom: 1px solid #E1E2E9;
border-top: 1px solid #E1E2E9;
}
.matchBarTitle {
font-size: 14px;
font-weight: 500;
}
.footer {
background-color: #fff;
}
.footerActions {
padding: 14px 16px;
border-top: 1px solid #E1E2E9;
@@ -51,11 +29,3 @@
padding: 8px 16px;
border: 1px solid #E1E2E9;
}
.checkbox:global(.bp4-control.bp4-checkbox){
margin: 0;
}
.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{
border-color: #CBCBCB;
}

View File

@@ -1,145 +1,10 @@
import { Box, Group, Icon, Stack } from '@/components';
import {
AnchorButton,
Button,
Checkbox,
Classes,
Intent,
Tab,
Tabs,
Tag,
Text,
} from '@blueprintjs/core';
import styles from './CategorizeTransactionAside.module.scss';
interface AsideProps {
title?: string;
onClose?: () => void;
children?: React.ReactNode;
}
function Aside({ title, onClose, children }: AsideProps) {
const handleClose = () => {
onClose && onClose();
};
return (
<Box>
<Group position="apart" className={styles.asideHeader}>
{title}
<Button
aria-label="Close"
className={Classes.DIALOG_CLOSE_BUTTON}
icon={<Icon icon={'smallCross'} color={'#000'} />}
minimal={true}
onClick={handleClose}
/>
</Group>
<Box>{children}</Box>
</Box>
);
}
import { Aside } from '@/components/Aside/Aside';
import { CategorizeTransactionTabs } from './CategorizeTransactionTabs';
export function CategorizeTransactionAside() {
return (
<Aside title={'Categorize Bank Transaction'}>
<Tabs large className={styles.tabs}>
<Tab
id="categorize"
title="Categorize Transaction"
panel={<CategorizeBankTransactionContent />}
/>
<Tab
id="matching"
title="Matching Transaction"
panel={<MatchingBankTransactionContent />}
/>
</Tabs>
<CategorizeTransactionTabs />
</Aside>
);
}
export function MatchingBankTransactionContent() {
return (
<Box>
<Box className={styles.matchBar}>
<Group spacing={6}>
<h2 className={styles.matchBarTitle}>Perfect Matchines</h2>
<Tag minimal intent={Intent.SUCCESS}>
2
</Tag>
</Group>
</Box>
<Stack spacing={9} style={{ padding: 15 }}>
<MatchTransaction label={''} date={''} />
<MatchTransaction label={''} date={''} />
<MatchTransaction label={''} date={''} />
<MatchTransaction label={''} date={''} />
</Stack>
<Box className={styles.matchBar}>
<Stack spacing={2}>
<h2 className={styles.matchBarTitle}>Perfect Matches</h2>
<Text style={{ fontSize: 12, color: '#5C7080' }}>
Transactions up to 20 Aug 2019
</Text>
</Stack>
</Box>
<Stack spacing={9} style={{ padding: 15 }}>
<MatchTransaction label={''} date={''} />
<MatchTransaction label={''} date={''} />
<MatchTransaction label={''} date={''} />
<MatchTransaction label={''} date={''} />
</Stack>
<MatchTransactionFooter />
</Box>
);
}
export function CategorizeBankTransactionContent() {
return <h1>Categorizing</h1>;
}
interface MatchTransactionProps {
label: string;
date: string;
}
function MatchTransaction({ label, date }: MatchTransactionProps) {
return (
<Group className={styles.transaction} position="apart">
<Stack spacing={3}>
<span>Expense for $10,000</span>
<Text style={{ fontSize: 12, color: '#5C7080' }}>
Date: 02/02/2020{' '}
</Text>
</Stack>
<Checkbox className={styles.checkbox} />
</Group>
);
}
function MatchTransactionFooter() {
return (
<Box>
<Box className={styles.footerTotal}>
<Group>
<AnchorButton small minimal intent={Intent.PRIMARY}>
Add Reconcile Transaction +
</AnchorButton>
<Text>Pending $10,000</Text>
</Group>
</Box>
<Box className={styles.footerActions}>
<Group>
<Button intent={Intent.PRIMARY}>Match</Button>
<Button>Cancel</Button>
</Group>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,13 @@
.tabs :global .bp4-tab-panel{
margin-top: 0;
}
.tabs :global .bp4-tab-list{
background: #fff;
border-bottom: 1px solid #c7d5db;
padding: 0 22px;
}
.tabs :global .bp4-large > .bp4-tab{
font-size: 14px;
}

View File

@@ -0,0 +1,23 @@
import { Tab, Tabs } from '@blueprintjs/core';
import {
CategorizeBankTransactionContent,
MatchingBankTransaction,
} from './MatchingTransaction';
import styles from './CategorizeTransactionTabs.module.scss';
export function CategorizeTransactionTabs() {
return (
<Tabs large className={styles.tabs}>
<Tab
id="categorize"
title="Categorize Transaction"
panel={<CategorizeBankTransactionContent />}
/>
<Tab
id="matching"
title="Matching Transaction"
panel={<MatchingBankTransaction />}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,22 @@
.root{
background: #fff;
border-radius: 5px;
border: 1px solid #D6DBE3;
padding: 12px 16px;
cursor: pointer;
&.active{
border-color: #88ABDB;
}
}
.checkbox:global(.bp4-control.bp4-checkbox){
margin: 0;
}
.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{
border-color: #CBCBCB;
}

View File

@@ -0,0 +1,58 @@
// @ts-nocheck
import clsx from 'classnames';
import { Group, Stack } from '@/components';
import { Checkbox, Text } from '@blueprintjs/core';
import styles from './MatchTransaction.module.scss';
import { useUncontrolled } from '@/hooks/useUncontrolled';
export interface MatchTransactionProps {
active?: boolean;
initialActive?: boolean;
onChange?: (state: boolean) => void;
label: string;
date: string;
}
export function MatchTransaction({
active,
initialActive,
onChange,
label,
date,
}: MatchTransactionProps) {
const [_active, handleChange] = useUncontrolled<boolean>({
value: active,
initialValue: initialActive,
finalValue: false,
onChange,
});
const handleClick = () => {
handleChange(!_active);
};
const handleCheckboxChange = (event) => {
handleChange(!event.target.checked);
};
return (
<Group
className={clsx(styles.root, {
[styles.active]: _active,
})}
position="apart"
onClick={handleClick}
>
<Stack spacing={3}>
<span>{label}</span>
<Text style={{ fontSize: 12, color: '#5C7080' }}>Date: {date}</Text>
</Stack>
<Checkbox
checked={_active as boolean}
className={styles.checkbox}
onChange={handleCheckboxChange}
/>
</Group>
);
}

View File

@@ -0,0 +1,204 @@
// @ts-nocheck
import { isEmpty } from 'lodash';
import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core';
import { AppToaster, Box, Group, Stack } from '@/components';
import {
MatchingTransactionBoot,
useMatchingTransactionBoot,
} from './MatchingTransactionBoot';
import { MatchTransaction, MatchTransactionProps } from './MatchTransaction';
import styles from './CategorizeTransactionAside.module.scss';
import { FastField, FastFieldProps, Form, Formik } from 'formik';
import { useMatchTransaction } from '@/hooks/query/bank-rules';
import { MatchingTransactionFormValues } from './types';
import { transformToReq } from './utils';
const initialValues = {
matched: {},
};
export function MatchingBankTransaction() {
const uncategorizedTransactionId = 1;
const { mutateAsync: matchTransaction } = useMatchTransaction();
// Handles the form submitting.
const handleSubmit = (values: MatchingTransactionFormValues) => {
const _values = transformToReq(values);
if (_values.matchedTransactions?.length === 0) {
AppToaster.show({
message: 'You should select at least one transaction for matching.',
intent: Intent.DANGER,
});
return;
}
matchTransaction([uncategorizedTransactionId, _values])
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The bank transaction has been matched successfully.',
});
})
.catch((err) => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
return (
<MatchingTransactionBoot>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form>
<MatchingBankTransactionContent />
</Form>
</Formik>
</MatchingTransactionBoot>
);
}
function MatchingBankTransactionContent() {
return (
<Box>
<PerfectMatchingTransactions />
<GoodMatchingTransactions />
<MatchTransactionFooter />
</Box>
);
}
/**
* Renders the perfect match transactions.
* @returns {React.ReactNode}
*/
function PerfectMatchingTransactions() {
const { matchingTransactions } = useMatchingTransactionBoot();
// Can't continue if the perfect matches is empty.
if (isEmpty(matchingTransactions)) {
return null;
}
return (
<>
<Box className={styles.matchBar}>
<Group spacing={6}>
<h2 className={styles.matchBarTitle}>Perfect Matchines</h2>
<Tag minimal intent={Intent.SUCCESS}>
2
</Tag>
</Group>
</Box>
<Stack spacing={9} style={{ padding: 15 }}>
{matchingTransactions.map((match, index) => (
<MatchTransactionField
key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
date={match.dateFormatted}
transactionId={match.transactionId}
transactionType={match.transactionType}
/>
))}
</Stack>
</>
);
}
/**
* Renders the possible match transactions.
* @returns {React.ReactNode}
*/
function GoodMatchingTransactions() {
const { matchingTransactions } = useMatchingTransactionBoot();
// Can't continue if the possible matches is emoty.
if (isEmpty(matchingTransactions)) {
return null;
}
return (
<>
<Box className={styles.matchBar}>
<Stack spacing={2}>
<h2 className={styles.matchBarTitle}>Possible Matches</h2>
<Text style={{ fontSize: 12, color: '#5C7080' }}>
Transactions up to 20 Aug 2019
</Text>
</Stack>
</Box>
<Stack spacing={9} style={{ padding: 15 }}>
{matchingTransactions.map((match, index) => (
<MatchTransaction
key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
date={match.dateFormatted}
/>
))}
</Stack>
</>
);
}
interface MatchTransactionFieldProps
extends Omit<MatchTransactionProps, 'onChange' | 'active' | 'initialActive'> {
transactionId: number;
transactionType: string;
}
function MatchTransactionField({
transactionId,
transactionType,
...props
}: MatchTransactionFieldProps) {
const name = `matched.${transactionType}-${transactionId}`;
return (
<FastField name={name}>
{({ form, field: { value } }: FastFieldProps) => (
<MatchTransaction
{...props}
active={!!value}
onChange={(state) => {
form.setFieldValue(name, state);
}}
/>
)}
</FastField>
);
}
export function CategorizeBankTransactionContent() {
return <h1>Categorizing</h1>;
}
/**
* Renders the match transactions footer.
* @returns {React.ReactNode}
*/
function MatchTransactionFooter() {
return (
<Box className={styles.footer}>
<Box className={styles.footerTotal}>
<Group position={'apart'}>
<AnchorButton small minimal intent={Intent.PRIMARY}>
Add Reconcile Transaction +
</AnchorButton>
<Text style={{ fontSize: 13 }}>Pending $10,000</Text>
</Group>
</Box>
<Box className={styles.footerActions}>
<Group spacing={10}>
<Button
intent={Intent.PRIMARY}
style={{ minWidth: 85 }}
type="submit"
>
Match
</Button>
<Button>Cancel</Button>
</Group>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,40 @@
import { useMatchingTransactions } from '@/hooks/query/bank-rules';
import React, { createContext } from 'react';
interface MatchingTransactionBootValues {
isMatchingTransactionsLoading: boolean;
matchingTransactions: Array<any>;
perfectMatchesCount: number;
perfectMatches: Array<any>;
matches: Array<any>;
}
const RuleFormBootContext = createContext<MatchingTransactionBootValues>(
{} as MatchingTransactionBootValues,
);
interface RuleFormBootProps {
children: React.ReactNode;
}
function MatchingTransactionBoot({ ...props }: RuleFormBootProps) {
const {
data: matchingTransactions,
isLoading: isMatchingTransactionsLoading,
} = useMatchingTransactions();
const provider = {
isMatchingTransactionsLoading,
matchingTransactions,
perfectMatchesCount: 2,
perfectMatches: [],
matches: [],
} as MatchingTransactionBootValues;
return <RuleFormBootContext.Provider value={provider} {...props} />;
}
const useMatchingTransactionBoot = () =>
React.useContext<MatchingTransactionBootValues>(RuleFormBootContext);
export { MatchingTransactionBoot, useMatchingTransactionBoot };

View File

@@ -0,0 +1,3 @@
export interface MatchingTransactionFormValues {
matched: Record<string, boolean>;
}

View File

@@ -0,0 +1,13 @@
import { MatchingTransactionFormValues } from './types';
export const transformToReq = (values: MatchingTransactionFormValues) => {
const matchedTransactions = Object.entries(values.matched)
.filter(([key, value]) => value)
.map(([key]) => {
const [reference_type, reference_id] = key.split('-');
return { reference_type, reference_id: parseInt(reference_id, 10) };
});
return { matchedTransactions };
};

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
import { useMutation, useQuery, useQueryClient } from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
/**
*
@@ -75,3 +76,72 @@ export function useBankRule(bankRuleId: number, props) {
props,
);
}
/**
*
* @returns
*/
export function useMatchingTransactions(props?: any) {
const apiRequest = useApiRequest();
return useQuery(
['MATCHING_TRANSACTION'],
() =>
apiRequest
.get(`/banking/matches`)
.then((res) => transformToCamelCase(res.data.data)),
props,
);
}
export function useExcludeUncategorizedTransaction(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(
(uncategorizedTransactionId: number) =>
apiRequest.put(
`/cashflow/transactions/${uncategorizedTransactionId}/exclude`,
),
{
onSuccess: (res, id) => {
// Invalidate queries.
},
...props,
},
);
}
export function useUnexcludeUncategorizedTransaction(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(
(uncategorizedTransactionId: number) =>
apiRequest.post(
`/cashflow/transactions/${uncategorizedTransactionId}/unexclude`,
),
{
onSuccess: (res, id) => {
// Invalidate queries.
},
...props,
},
);
}
export function useMatchTransaction(props?: any) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(
([uncategorizedTransactionId, values]) =>
apiRequest.post(`/banking/matches/${uncategorizedTransactionId}`, values),
{
onSuccess: (res, id) => {
// Invalidate queries.
},
...props,
},
);
}