Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
45ad4aa1f1 feat: reconcile matching transactions 2024-07-04 19:18:17 +02:00
14 changed files with 440 additions and 19 deletions

View File

@@ -21,4 +21,5 @@
flex-direction: column;
flex: 1 1 auto;
background-color: #fff;
overflow-y: auto;
}

View File

@@ -1,13 +1,16 @@
import { Button, Classes } from '@blueprintjs/core';
import { Box, Group } from '../Layout';
import clsx from 'classnames';
import { Box, BoxProps, Group } from '../Layout';
import { Icon } from '../Icon';
import styles from './Aside.module.scss';
interface AsideProps {
interface AsideProps extends BoxProps {
title?: string;
onClose?: () => void;
children?: React.ReactNode;
hideCloseButton?: boolean;
classNames?: Record<string, string>;
className?: string;
}
export function Aside({
@@ -15,13 +18,15 @@ export function Aside({
onClose,
children,
hideCloseButton,
classNames,
className
}: AsideProps) {
const handleClose = () => {
onClose && onClose();
};
return (
<Box className={styles.root}>
<Group position="apart" className={styles.title}>
<Box className={clsx(styles.root, className, classNames?.root)}>
<Group position="apart" className={clsx(styles.title, classNames?.title)}>
{title}
{hideCloseButton !== true && (
@@ -34,7 +39,23 @@ export function Aside({
/>
)}
</Group>
<Box className={styles.content}>{children}</Box>
{children}
</Box>
);
}
}
interface AsideContentProps extends BoxProps {}
function AsideContent({ ...props }: AsideContentProps) {
return <Box {...props} className={clsx(styles.content, props?.className)} />;
}
interface AsideFooterProps extends BoxProps {}
function AsideFooter({ ...props }: AsideFooterProps) {
return <Box {...props} />;
}
Aside.Body = AsideContent;
Aside.Footer = AsideFooter;

View File

@@ -19,6 +19,12 @@ const ContentTabItemRoot = styled.button<ContentTabItemRootProps>`
text-align: left;
cursor: pointer;
${(props) =>
props.small &&
`
padding: 8px 10px;
`}
${(props) =>
props.active &&
`
@@ -55,6 +61,8 @@ interface ContentTabsItemProps {
title?: React.ReactNode;
description?: React.ReactNode;
active?: boolean;
className?: string;
small?: booean;
}
const ContentTabsItem = ({
@@ -62,11 +70,18 @@ const ContentTabsItem = ({
description,
active,
onClick,
small,
className,
}: ContentTabsItemProps) => {
return (
<ContentTabItemRoot active={active} onClick={onClick}>
<ContentTabItemRoot
active={active}
onClick={onClick}
className={className}
small={small}
>
<ContentTabTitle>{title}</ContentTabTitle>
<ContentTabDesc>{description}</ContentTabDesc>
{description && <ContentTabDesc>{description}</ContentTabDesc>}
</ContentTabItemRoot>
);
};
@@ -77,6 +92,7 @@ interface ContentTabsProps {
onChange?: (value: string) => void;
children?: React.ReactNode;
className?: string;
small?: boolean;
}
export function ContentTabs({
@@ -85,6 +101,7 @@ export function ContentTabs({
onChange,
children,
className,
small,
}: ContentTabsProps) {
const [localValue, handleItemChange] = useUncontrolled<string>({
initialValue,
@@ -102,6 +119,7 @@ export function ContentTabs({
{...tab.props}
active={localValue === tab.props.id}
onClick={() => handleItemChange(tab.props?.id)}
small={small}
/>
))}
</ContentTabsRoot>

View File

@@ -28,11 +28,13 @@ function CategorizeTransactionAsideRoot({
}
return (
<Aside title={'Categorize Bank Transaction'} onClose={handleClose}>
<CategorizeTransactionTabsBoot
uncategorizedTransactionId={uncategorizedTransactionId}
>
<CategorizeTransactionTabs />
</CategorizeTransactionTabsBoot>
<Aside.Body>
<CategorizeTransactionTabsBoot
uncategorizedTransactionId={uncategorizedTransactionId}
>
<CategorizeTransactionTabs />
</CategorizeTransactionTabsBoot>
</Aside.Body>
</Aside>
);
}

View File

@@ -0,0 +1,43 @@
import { useAccounts, useBranches } from '@/hooks/query';
import { Spinner } from '@blueprintjs/core';
import React from 'react';
interface MatchingReconcileTransactionBootProps {
children: React.ReactNode;
}
interface MatchingReconcileTransactionBootValue {}
const MatchingReconcileTransactionBootContext =
React.createContext<MatchingReconcileTransactionBootValue>(
{} as MatchingReconcileTransactionBootValue,
);
export function MatchingReconcileTransactionBoot({
children,
}: MatchingReconcileTransactionBootProps) {
const { data: accounts, isLoading: isAccountsLoading } = useAccounts({}, {});
const { data: branches, isLoading: isBranchesLoading } = useBranches({}, {});
const provider = {
accounts,
branches,
isAccountsLoading,
isBranchesLoading,
};
const isLoading = isAccountsLoading || isBranchesLoading;
if (isLoading) {
return <Spinner size={20} />;
}
return (
<MatchingReconcileTransactionBootContext.Provider value={provider}>
{children}
</MatchingReconcileTransactionBootContext.Provider>
);
}
export const useMatchingReconcileTransactionBoot = () =>
React.useContext<MatchingReconcileTransactionBootValue>(
MatchingReconcileTransactionBootContext,
);

View File

@@ -0,0 +1,43 @@
.content{
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.footer {
padding: 11px 20px;
border-top: 1px solid #ced4db;
}
.form{
display: flex;
flex-direction: column;
flex: 1 1 0;
:global .bp4-form-group{
margin-bottom: 0;
}
:global .bp4-input {
line-height: 30px;
height: 30px;
}
}
.asideContent{
background: #F6F7F9;
height: 335px;
}
.asideRoot {
flex: 1 1 0;
box-shadow: 0 0 0 1px rgba(17,20,24,.1),0 1px 1px rgba(17,20,24,.2),0 2px 6px rgba(17,20,24,.2);
}
.asideFooter {
background: #F6F7F9;
}

View File

@@ -0,0 +1,10 @@
import * as Yup from 'yup';
export const MatchingReconcileFormSchema = Yup.object().shape({
type: Yup.string().required().label('Type'),
date: Yup.string().required().label('Date'),
amount: Yup.string().required().label('Amount'),
memo: Yup.string().required().label('Memo'),
referenceNo: Yup.string().label('Refernece #'),
category: Yup.string().required().label('Categogry'),
});

View File

@@ -0,0 +1,199 @@
// @ts-nocheck
import * as R from 'ramda';
import { Button, Intent, Position, Tag } from '@blueprintjs/core';
import { Form, Formik, FormikValues, useFormikContext } from 'formik';
import {
AccountsSelect,
AppToaster,
Box,
BranchSelect,
FDateInput,
FFormGroup,
FInputGroup,
FMoneyInputGroup,
Group,
} from '@/components';
import { Aside } from '@/components/Aside/Aside';
import { momentFormatter } from '@/utils';
import styles from './MatchingReconcileTransactionForm.module.scss';
import { ContentTabs } from '@/components/ContentTabs';
import { withBankingActions } from '../../withBankingActions';
import {
MatchingReconcileTransactionBoot,
useMatchingReconcileTransactionBoot,
} from './MatchingReconcileTransactionBoot';
import { useCreateCashflowTransaction } from '@/hooks/query';
import { useAccountTransactionsContext } from '../../AccountTransactions/AccountTransactionsProvider';
import { MatchingReconcileFormSchema } from './MatchingReconcileTransactionForm.schema';
import { initialValues } from './_utils';
function MatchingReconcileTransactionFormRoot({
closeReconcileMatchingTransaction,
}) {
// Mutation create cashflow transaction.
const { mutateAsync: createCashflowTransactionMutate } =
useCreateCashflowTransaction();
const { accountId } = useAccountTransactionsContext();
const handleAsideClose = () => {
closeReconcileMatchingTransaction();
};
const handleSubmit = (
values: MatchingReconcileTransactionValues,
{ setSubmitting }: FormikValues<MatchingReconcileTransactionValues>,
) => {
setSubmitting(true);
const _values = transformToReq(values, accountId);
createCashflowTransactionMutate(_values)
.then(() => {
setSubmitting(false);
AppToaster.show({
message: 'The transaction has been created.',
intent: Intent.SUCCESS,
});
closeReconcileMatchingTransaction();
})
.catch((error) => {
setSubmitting(false);
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
return (
<Aside
title={'Create Reconcile Transactions'}
className={styles.asideRoot}
onClose={handleAsideClose}
>
<MatchingReconcileTransactionBoot>
<Formik
onSubmit={handleSubmit}
initialValues={initialValues}
validationSchema={MatchingReconcileFormSchema}
>
<Form className={styles.form}>
<Aside.Body className={styles.asideContent}>
<CreateReconcileTransactionContent />
</Aside.Body>
<Aside.Footer className={styles.asideFooter}>
<MatchingReconcileTransactionFooter />
</Aside.Footer>
</Form>
</Formik>
</MatchingReconcileTransactionBoot>
</Aside>
);
}
export const MatchingReconcileTransactionForm = R.compose(withBankingActions)(
MatchingReconcileTransactionFormRoot,
);
export function MatchingReconcileTransactionFooter() {
const { isSubmitting } = useFormikContext();
return (
<Box className={styles.footer}>
<Group>
<Button
fill
type={'submit'}
intent={Intent.PRIMARY}
loading={isSubmitting}
>
Submit
</Button>
</Group>
</Box>
);
}
function ReconcileMatchingType() {
const { setFieldValue, values } =
useFormikContext<MatchingReconcileFormValues>();
const handleChange = (value: string) => {
setFieldValue('type', value);
};
return (
<ContentTabs
value={values?.type || 'deposit'}
onChange={handleChange}
small
>
<ContentTabs.Tab id={'deposit'} title={'Deposit'} />
<ContentTabs.Tab id={'withdrawal'} title={'Withdrawal'} />
</ContentTabs>
);
}
export function CreateReconcileTransactionContent() {
const { accounts, branches } = useMatchingReconcileTransactionBoot();
return (
<Box className={styles.content}>
<ReconcileMatchingType />
<FFormGroup label={'Date'} name={'date'}>
<FDateInput
{...momentFormatter('YYYY/MM/DD')}
name={'date'}
formatDate={(date) => date.toLocaleString()}
popoverProps={{
position: Position.LEFT,
}}
inputProps={{ fill: true }}
fill
/>
</FFormGroup>
<FFormGroup
label={'Amount'}
name={'amount'}
labelInfo={<Tag minimal>Required</Tag>}
>
<FMoneyInputGroup name={'amount'} />
</FFormGroup>
<FFormGroup
label={'Category'}
name={'category'}
labelInfo={<Tag minimal>Required</Tag>}
>
<AccountsSelect
name={'category'}
items={accounts}
popoverProps={{ minimal: false, position: Position.LEFT }}
/>
</FFormGroup>
<FFormGroup
label={'Memo'}
name={'memo'}
labelInfo={<Tag minimal>Required</Tag>}
>
<FInputGroup name={'memo'} />
</FFormGroup>
<FFormGroup label={'Reference No.'} name={'reference_no'}>
<FInputGroup name={'reference_no'} />
</FFormGroup>
<FFormGroup name={'branchId'} label={'Branch'}>
<BranchSelect
name={'branchId'}
branches={branches}
popoverProps={{ minimal: true }}
/>
</FFormGroup>
</Box>
);
}

View File

@@ -0,0 +1,9 @@
export interface MatchingReconcileTransactionValues {
type: string;
date: string;
amount: string;
memo: string;
referenceNo: string;
category: string;
branchId: string;
}

View File

@@ -0,0 +1,30 @@
import { MatchingReconcileTransactionValues } from './_types';
export const transformToReq = (
values: MatchingReconcileTransactionValues,
bankAccountId: number,
) => {
return {
date: values.date,
reference_no: values.referenceNo,
transaction_type:
values.type === 'deposit' ? 'other_income' : 'other_expense',
description: values.memo,
amount: values.amount,
credit_account_id: values.category,
cashflow_account_id: bankAccountId,
branch_id: values.branchId,
publish: true,
};
};
export const initialValues = {
type: 'deposit',
date: '',
amount: '',
memo: '',
referenceNo: '',
category: '',
branchId: '',
};

View File

@@ -25,6 +25,8 @@ import {
withBankingActions,
} from '../withBankingActions';
import styles from './CategorizeTransactionAside.module.scss';
import { MatchingReconcileTransactionForm } from './MatchingReconcileTransactionAside/MatchingReconcileTransactionForm';
import { withBanking } from '../withBanking';
const initialValues = {
matched: {},
@@ -37,6 +39,9 @@ const initialValues = {
function MatchingBankTransactionRoot({
// #withBankingActions
closeMatchingTransactionAside,
// #withBanking
openReconcileMatchingTransaction,
}) {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction();
@@ -81,16 +86,23 @@ function MatchingBankTransactionRoot({
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<>
<MatchingBankTransactionContent />
<MatchTransactionFooter />
{openReconcileMatchingTransaction && (
<MatchingReconcileTransactionForm />
)}
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
</>
</Formik>
</MatchingTransactionBoot>
);
}
export const MatchingBankTransaction = R.compose(withBankingActions)(
MatchingBankTransactionRoot,
);
export const MatchingBankTransaction = R.compose(
withBankingActions,
withBanking(({ openReconcileMatchingTransaction }) => ({
openReconcileMatchingTransaction,
})),
)(MatchingBankTransactionRoot);
function MatchingBankTransactionContent() {
return (
@@ -212,7 +224,10 @@ interface MatchTransctionFooterProps extends WithBankingActionsProps {}
* @returns {React.ReactNode}
*/
const MatchTransactionFooter = R.compose(withBankingActions)(
({ closeMatchingTransactionAside }: MatchTransctionFooterProps) => {
({
closeMatchingTransactionAside,
openReconcileMatchingTransaction,
}: MatchTransctionFooterProps) => {
const { submitForm, isSubmitting } = useFormikContext();
const totalPending = useGetPendingAmountMatched();
const showReconcileLink = useIsShowReconcileTransactionLink();
@@ -224,13 +239,21 @@ const MatchTransactionFooter = R.compose(withBankingActions)(
const handleSubmitBtnClick = () => {
submitForm();
};
const handleReconcileTransaction = () => {
openReconcileMatchingTransaction();
};
return (
<Box className={styles.footer}>
<Box className={styles.footerTotal}>
<Group position={'apart'}>
{showReconcileLink && (
<AnchorButton small minimal intent={Intent.PRIMARY}>
<AnchorButton
small
minimal
intent={Intent.PRIMARY}
onClick={handleReconcileTransaction}
>
Add Reconcile Transaction +
</AnchorButton>
)}

View File

@@ -8,6 +8,8 @@ export const withBanking = (mapState) => {
openMatchingTransactionAside: state.plaid.openMatchingTransactionAside,
selectedUncategorizedTransactionId:
state.plaid.uncategorizedTransactionIdForMatching,
openReconcileMatchingTransaction:
state.plaid.openReconcileMatchingTransaction,
};
return mapState ? mapState(mapped, state, props) : mapped;
};

View File

@@ -2,6 +2,8 @@ import { connect } from 'react-redux';
import {
closeMatchingTransactionAside,
setUncategorizedTransactionIdForMatching,
openReconcileMatchingTransaction,
closeReconcileMatchingTransaction,
} from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps {
@@ -9,6 +11,8 @@ export interface WithBankingActionsProps {
setUncategorizedTransactionIdForMatching: (
uncategorizedTransactionId: number,
) => void;
openReconcileMatchingTransaction: () => void;
closeReconcileMatchingTransaction: () => void;
}
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -20,6 +24,10 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
dispatch(
setUncategorizedTransactionIdForMatching(uncategorizedTransactionId),
),
openReconcileMatchingTransaction: () =>
dispatch(openReconcileMatchingTransaction()),
closeReconcileMatchingTransaction: () =>
dispatch(closeReconcileMatchingTransaction()),
});
export const withBankingActions = connect<

View File

@@ -4,6 +4,7 @@ interface StorePlaidState {
plaidToken: string;
openMatchingTransactionAside: boolean;
uncategorizedTransactionIdForMatching: number | null;
openReconcileMatchingTransaction: boolean;
}
export const PlaidSlice = createSlice({
@@ -12,6 +13,7 @@ export const PlaidSlice = createSlice({
plaidToken: '',
openMatchingTransactionAside: false,
uncategorizedTransactionIdForMatching: null,
openReconcileMatchingTransaction: false,
} as StorePlaidState,
reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -34,6 +36,14 @@ export const PlaidSlice = createSlice({
state.openMatchingTransactionAside = false;
state.uncategorizedTransactionIdForMatching = null;
},
openReconcileMatchingTransaction: (state: StorePlaidState) => {
state.openReconcileMatchingTransaction = true;
},
closeReconcileMatchingTransaction: (state: StorePlaidState) => {
state.openReconcileMatchingTransaction = false;
},
},
});
@@ -42,6 +52,8 @@ export const {
resetPlaidId,
setUncategorizedTransactionIdForMatching,
closeMatchingTransactionAside,
openReconcileMatchingTransaction,
closeReconcileMatchingTransaction,
} = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken;