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-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
background-color: #fff; background-color: #fff;
overflow-y: auto;
} }

View File

@@ -1,13 +1,16 @@
import { Button, Classes } from '@blueprintjs/core'; 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 { Icon } from '../Icon';
import styles from './Aside.module.scss'; import styles from './Aside.module.scss';
interface AsideProps { interface AsideProps extends BoxProps {
title?: string; title?: string;
onClose?: () => void; onClose?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
hideCloseButton?: boolean; hideCloseButton?: boolean;
classNames?: Record<string, string>;
className?: string;
} }
export function Aside({ export function Aside({
@@ -15,13 +18,15 @@ export function Aside({
onClose, onClose,
children, children,
hideCloseButton, hideCloseButton,
classNames,
className
}: AsideProps) { }: AsideProps) {
const handleClose = () => { const handleClose = () => {
onClose && onClose(); onClose && onClose();
}; };
return ( return (
<Box className={styles.root}> <Box className={clsx(styles.root, className, classNames?.root)}>
<Group position="apart" className={styles.title}> <Group position="apart" className={clsx(styles.title, classNames?.title)}>
{title} {title}
{hideCloseButton !== true && ( {hideCloseButton !== true && (
@@ -34,7 +39,23 @@ export function Aside({
/> />
)} )}
</Group> </Group>
<Box className={styles.content}>{children}</Box>
{children}
</Box> </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; text-align: left;
cursor: pointer; cursor: pointer;
${(props) =>
props.small &&
`
padding: 8px 10px;
`}
${(props) => ${(props) =>
props.active && props.active &&
` `
@@ -55,6 +61,8 @@ interface ContentTabsItemProps {
title?: React.ReactNode; title?: React.ReactNode;
description?: React.ReactNode; description?: React.ReactNode;
active?: boolean; active?: boolean;
className?: string;
small?: booean;
} }
const ContentTabsItem = ({ const ContentTabsItem = ({
@@ -62,11 +70,18 @@ const ContentTabsItem = ({
description, description,
active, active,
onClick, onClick,
small,
className,
}: ContentTabsItemProps) => { }: ContentTabsItemProps) => {
return ( return (
<ContentTabItemRoot active={active} onClick={onClick}> <ContentTabItemRoot
active={active}
onClick={onClick}
className={className}
small={small}
>
<ContentTabTitle>{title}</ContentTabTitle> <ContentTabTitle>{title}</ContentTabTitle>
<ContentTabDesc>{description}</ContentTabDesc> {description && <ContentTabDesc>{description}</ContentTabDesc>}
</ContentTabItemRoot> </ContentTabItemRoot>
); );
}; };
@@ -77,6 +92,7 @@ interface ContentTabsProps {
onChange?: (value: string) => void; onChange?: (value: string) => void;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
small?: boolean;
} }
export function ContentTabs({ export function ContentTabs({
@@ -85,6 +101,7 @@ export function ContentTabs({
onChange, onChange,
children, children,
className, className,
small,
}: ContentTabsProps) { }: ContentTabsProps) {
const [localValue, handleItemChange] = useUncontrolled<string>({ const [localValue, handleItemChange] = useUncontrolled<string>({
initialValue, initialValue,
@@ -102,6 +119,7 @@ export function ContentTabs({
{...tab.props} {...tab.props}
active={localValue === tab.props.id} active={localValue === tab.props.id}
onClick={() => handleItemChange(tab.props?.id)} onClick={() => handleItemChange(tab.props?.id)}
small={small}
/> />
))} ))}
</ContentTabsRoot> </ContentTabsRoot>

View File

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

View File

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

View File

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

View File

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