Files
bigcapital/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx
2024-07-06 19:10:07 +02:00

363 lines
10 KiB
TypeScript

// @ts-nocheck
import { isEmpty } from 'lodash';
import * as R from 'ramda';
import { useEffect, useState } from 'react';
import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core';
import { FastField, FastFieldProps, Formik, useFormikContext } from 'formik';
import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components';
import {
MatchingTransactionBoot,
useMatchingTransactionBoot,
} from './MatchingTransactionBoot';
import {
MatchTransactionCheckbox,
MatchTransactionCheckboxProps,
} from './MatchTransactionCheckbox';
import { useMatchUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { MatchingTransactionFormValues } from './types';
import {
transformToReq,
useGetPendingAmountMatched,
useIsShowReconcileTransactionLink,
} from './utils';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import {
WithBankingActionsProps,
withBankingActions,
} from '../withBankingActions';
import styles from './CategorizeTransactionAside.module.scss';
import { MatchingReconcileTransactionForm } from './MatchingReconcileTransactionAside/MatchingReconcileTransactionForm';
import { withBanking } from '../withBanking';
const initialValues = {
matched: {},
};
/**
* Renders the bank transaction matching form.
* @returns {React.ReactNode}
*/
function MatchingBankTransactionRoot({
// #withBankingActions
closeMatchingTransactionAside,
}) {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction();
// Handles the form submitting.
const handleSubmit = (
values: MatchingTransactionFormValues,
{ setSubmitting }: FormikHelpers<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;
}
setSubmitting(true);
matchTransaction({ id: uncategorizedTransactionId, value: _values })
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The bank transaction has been matched successfully.',
});
setSubmitting(false);
closeMatchingTransactionAside();
})
.catch((err) => {
if (
err.response?.data.errors.find(
(e) => e.type === 'TOTAL_MATCHING_TRANSACTIONS_INVALID',
)
) {
AppToaster.show({
message: `The total amount does not equal the uncategorized transaction.`,
intent: Intent.DANGER,
});
return;
}
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
setSubmitting(false);
});
};
return (
<MatchingTransactionBoot
uncategorizedTransactionId={uncategorizedTransactionId}
>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<MatchingBankTransactionFormContent />
</Formik>
</MatchingTransactionBoot>
);
}
export const MatchingBankTransaction = R.compose(withBankingActions)(
MatchingBankTransactionRoot,
);
/**
* Matching bank transaction form content.
* @returns {React.ReactNode}
*/
const MatchingBankTransactionFormContent = R.compose(
withBankingActions,
withBanking(({ openReconcileMatchingTransaction }) => ({
openReconcileMatchingTransaction,
})),
)(
({
// #withBanking
openReconcileMatchingTransaction,
}) => {
const {
isMatchingTransactionsFetching,
isMatchingTransactionsSuccess,
matches,
} = useMatchingTransactionBoot();
const [pending, setPending] = useState<null | {
refId: number;
refType: string;
}>(null);
const { setFieldValue } = useFormikContext();
// This effect is responsible for automatically marking a transaction as matched
// when the matching process is successful and not currently fetching.
useEffect(() => {
if (
pending &&
isMatchingTransactionsSuccess &&
!isMatchingTransactionsFetching
) {
const foundMatch = matches?.find(
(m) =>
m.referenceType === pending?.refType &&
m.referenceId === pending?.refId,
);
if (foundMatch) {
setFieldValue(`matched.${pending.refType}-${pending.refId}`, true);
}
setPending(null);
}
}, [
isMatchingTransactionsFetching,
isMatchingTransactionsSuccess,
matches,
pending,
setFieldValue,
]);
const handleReconcileFormSubmitSuccess = (payload) => {
setPending({ refId: payload.id, refType: payload.type });
};
return (
<>
<MatchingBankTransactionContent />
{openReconcileMatchingTransaction && (
<MatchingReconcileTransactionForm
onSubmitSuccess={handleReconcileFormSubmitSuccess}
/>
)}
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
</>
);
},
);
function MatchingBankTransactionContent() {
return (
<Box className={styles.root}>
<PerfectMatchingTransactions />
<PossibleMatchingTransactions />
</Box>
);
}
/**
* Renders the perfect match transactions.
* @returns {React.ReactNode}
*/
function PerfectMatchingTransactions() {
const { perfectMatches, perfectMatchesCount } = useMatchingTransactionBoot();
// Can't continue if the perfect matches is empty.
if (isEmpty(perfectMatches)) {
return null;
}
return (
<>
<Box className={styles.matchBar}>
<Group spacing={6}>
<h2 className={styles.matchBarTitle}>Perfect Matchines</h2>
<Tag minimal round intent={Intent.SUCCESS}>
{perfectMatchesCount}
</Tag>
</Group>
</Box>
<Stack spacing={9} style={{ padding: '12px 15px' }}>
{perfectMatches.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 PossibleMatchingTransactions() {
const { possibleMatches } = useMatchingTransactionBoot();
// Can't continue if the possible matches is emoty.
if (isEmpty(possibleMatches)) {
return null;
}
return (
<>
<Box className={styles.matchBar}>
<Stack spacing={2}>
<h2 className={styles.matchBarTitle}>Possible Matches</h2>
</Stack>
</Box>
<Stack spacing={9} style={{ padding: '12px 15px' }}>
{possibleMatches.map((match, index) => (
<MatchTransactionField
key={index}
label={
<>
{`${match.transsactionTypeFormatted} for `}
<strong>{match.amountFormatted}</strong>
</>
}
date={match.dateFormatted}
transactionId={match.referenceId}
transactionType={match.referenceType}
/>
))}
</Stack>
</>
);
}
interface MatchTransactionFieldProps
extends Omit<
MatchTransactionCheckboxProps,
'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) => (
<MatchTransactionCheckbox
{...props}
active={!!value}
onChange={(state) => {
form.setFieldValue(name, state);
}}
/>
)}
</FastField>
);
}
interface MatchTransctionFooterProps extends WithBankingActionsProps {}
/**
* Renders the match transactions footer.
* @returns {React.ReactNode}
*/
const MatchTransactionFooter = R.compose(withBankingActions)(
({
closeMatchingTransactionAside,
openReconcileMatchingTransaction,
}: MatchTransctionFooterProps) => {
const { submitForm, isSubmitting } = useFormikContext();
const totalPending = useGetPendingAmountMatched();
const showReconcileLink = useIsShowReconcileTransactionLink();
const submitDisabled = totalPending !== 0;
const handleCancelBtnClick = () => {
closeMatchingTransactionAside();
};
const handleSubmitBtnClick = () => {
submitForm();
};
const handleReconcileTransaction = () => {
openReconcileMatchingTransaction(totalPending);
};
return (
<Box className={styles.footer}>
<Box className={styles.footerTotal}>
<Group position={'apart'}>
{showReconcileLink && (
<AnchorButton
small
minimal
intent={Intent.PRIMARY}
onClick={handleReconcileTransaction}
>
Add Reconcile Transaction +
</AnchorButton>
)}
<Text
style={{ fontSize: 14, marginLeft: 'auto', color: '#404854' }}
tagName="span"
>
Pending <FormatNumber value={totalPending} currency={'USD'} />
</Text>
</Group>
</Box>
<Box className={styles.footerActions}>
<Group spacing={10}>
<Button
intent={Intent.PRIMARY}
style={{ minWidth: 85 }}
onClick={handleSubmitBtnClick}
loading={isSubmitting}
disabled={submitDisabled}
>
Match
</Button>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
</Group>
</Box>
</Box>
);
},
);
MatchTransactionFooter.displayName = 'MatchTransactionFooter';