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

@@ -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 };
};