Merge pull request #511 from bigcapitalhq/BIG-208

feat: Bank rules for uncategorized transactions
This commit is contained in:
Ahmed Bouhuolia
2024-07-03 19:43:28 +02:00
committed by GitHub
180 changed files with 8249 additions and 289 deletions

View File

@@ -0,0 +1,27 @@
:root {
--aside-topbar-offset: 60px;
--aside-width: 34%;
--aside-min-width: 400px;
}
.main{
flex: 1 0;
height: inherit;
overflow: auto;
}
.aside{
width: var(--aside-width);
min-width: var(--aside-min-width);
height: 100dvh;
border-left: 1px solid rgba(17, 20, 24, 0.15);
height: inherit;
overflow: auto;
display: flex;
flex-direction: column;
}
.root {
display: flex;
height: calc(100dvh - var(--aside-topbar-offset));
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { AppShellProvider, useAppShellContext } from './AppContentShellProvider';
import { Box, BoxProps } from '../../Layout';
import styles from './AppContentShell.module.scss';
interface AppContentShellProps {
topbarOffset?: number;
mainProps?: BoxProps;
asideProps?: BoxProps;
children: React.ReactNode;
hideAside?: boolean;
hideMain?: boolean;
}
export function AppContentShell({
asideProps,
mainProps,
topbarOffset = 0,
hideAside = false,
hideMain = false,
...restProps
}: AppContentShellProps) {
return (
<AppShellProvider
mainProps={mainProps}
asideProps={asideProps}
topbarOffset={topbarOffset}
hideAside={hideAside}
hideMain={hideMain}
>
<Box {...restProps} className={styles.root} />
</AppShellProvider>
);
}
interface AppContentShellMainProps extends BoxProps {}
function AppContentShellMain({ ...props }: AppContentShellMainProps) {
const { hideMain } = useAppShellContext();
if (hideMain === true) {
return null;
}
return <Box {...props} className={styles.main} />;
}
interface AppContentShellAsideProps extends BoxProps {
children: React.ReactNode;
}
function AppContentShellAside({ ...props }: AppContentShellAsideProps) {
const { hideAside } = useAppShellContext();
if (hideAside === true) {
return null;
}
return <Box {...props} className={styles.aside} />;
}
AppContentShell.Main = AppContentShellMain;
AppContentShell.Aside = AppContentShellAside;

View File

@@ -0,0 +1,40 @@
// @ts-nocheck
import React, { createContext } from 'react';
interface ContentShellCommonValue {
mainProps: any;
asideProps: any;
topbarOffset: number;
hideAside: boolean;
hideMain: boolean;
}
interface AppShellContextValue extends ContentShellCommonValue {
topbarOffset: number;
}
const AppShellContext = createContext<AppShellContextValue>(
{} as AppShellContextValue,
);
interface AppShellProviderProps extends ContentShellCommonValue {
children: React.ReactNode;
}
export function AppShellProvider({
topbarOffset,
hideAside,
hideMain,
...props
}: AppShellProviderProps) {
const provider = {
topbarOffset,
hideAside,
hideMain,
} as AppShellContextValue;
return <AppShellContext.Provider value={provider} {...props} />;
}
export const useAppShellContext = () =>
React.useContext<AppShellContextValue>(AppShellContext);

View File

@@ -0,0 +1 @@
export * from './AppContentShell';

View File

@@ -0,0 +1 @@
export * from './AppContentShell';

View File

@@ -0,0 +1,24 @@
.root{
display: flex;
flex-direction: column;
flex: 1 1 auto;
}
.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;
font-weight: 600;
}
.content{
display: flex;
flex-direction: column;
flex: 1 1 auto;
background-color: #fff;
}

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 className={styles.root}>
<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 className={styles.content}>{children}</Box>
</Box>
);
}

View File

@@ -3,7 +3,15 @@ import React from 'react';
import clsx from 'classnames';
import { Navbar } from '@blueprintjs/core';
export function DashboardActionsBar({ className, children, name }) {
interface DashboardActionsBarProps {
children?: React.ReactNode;
}
export function DashboardActionsBar({
className,
children,
name,
}: DashboardActionsBarProps) {
return (
<div
className={clsx(

View File

@@ -51,6 +51,7 @@ import EstimateMailDialog from '@/containers/Sales/Estimates/EstimateMailDialog/
import ReceiptMailDialog from '@/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog';
import PaymentMailDialog from '@/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog';
import { ExportDialog } from '@/containers/Dialogs/ExportDialog';
import { RuleFormDialog } from '@/containers/Banking/Rules/RuleFormDialog/RuleFormDialog';
/**
* Dialogs container.
@@ -147,6 +148,7 @@ export default function DialogsContainer() {
<ReceiptMailDialog dialogName={DialogsName.ReceiptMail} />
<PaymentMailDialog dialogName={DialogsName.PaymentMail} />
<ExportDialog dialogName={DialogsName.Export} />
<RuleFormDialog dialogName={DialogsName.BankRuleForm} />
</div>
);
}

View File

@@ -1,18 +1,28 @@
// @ts-nocheck
import React from 'react';
import classNames from 'classnames';
import clsx from 'classnames';
import Style from '@/style/components/DataTable/DataTableEmptyStatus.module.scss';
/**
* Datatable empty status.
*/
export function EmptyStatus({ title, description, action, children }) {
export function EmptyStatus({
title,
description,
action,
children,
classNames,
}) {
return (
<div className={classNames(Style.root)}>
<h1 className={classNames(Style.root_title)}>{title}</h1>
<div className={classNames(Style.root_desc)}>{description}</div>
<div className={classNames(Style.root_actions)}>{action}</div>
<div className={clsx(Style.root, classNames?.root)}>
<h1 className={clsx(Style.root_title, classNames?.title)}>{title}</h1>
<div className={clsx(Style.root_desc, classNames?.description)}>
{description}
</div>
<div className={clsx(Style.root_actions, classNames?.actions)}>
{action}
</div>
{children}
</div>
);

View File

@@ -0,0 +1,19 @@
.root{
display: flex;
flex-direction: row;
gap: 10px;
margin-bottom: 14px;
}
.tag{
min-height: 26px;
&:global(.bp4-minimal:not([class*='bp4-intent-'])) {
background: #fff;
border: 1px solid #e1e2e8;
&:global(.bp4-interactive:hover) {
background-color: rgba(143, 153, 168, 0.05);
}
}
}

View File

@@ -0,0 +1,42 @@
import { Tag } from '@blueprintjs/core';
import { useUncontrolled } from '@/hooks/useUncontrolled';
import { Box } from '../Layout';
import styles from './TagsControl.module.scss';
interface TagsControProps {
options: Array<{ label: string; value: string }>;
initialValue?: string;
value?: string;
onValueChange?: (value: string) => void;
}
export function TagsControl({
options,
initialValue,
value,
onValueChange,
}: TagsControProps) {
const [_value, handleChange] = useUncontrolled<string>({
initialValue,
value,
onChange: onValueChange,
finalValue: '',
});
return (
<Box className={styles.root}>
{options.map((option, index) => (
<Tag
key={index}
round
interactive
onClick={() => handleChange(option.value)}
minimal={option.value !== _value}
className={styles.tag}
>
{option.label}
</Tag>
))}
</Box>
);
}

View File

@@ -0,0 +1 @@
export * from './TagsControl';

View File

@@ -20,8 +20,9 @@ export const AbilitySubject = {
SubscriptionBilling: 'SubscriptionBilling',
CreditNote: 'CreditNote',
VendorCredit: 'VendorCredit',
Project:'Project',
Project: 'Project',
TaxRate: 'TaxRate',
BankRule: 'BankRule',
};
export const ItemAction = {
@@ -188,10 +189,16 @@ export const SubscriptionBillingAbility = {
Payment: 'payment',
};
export const TaxRateAction = {
View: 'View',
Create: 'Create',
Edit: 'Edit',
Delete: 'Delete',
};
export const BankRuleAction = {
View: 'View',
Create: 'Create',
Edit: 'Edit',
Delete: 'Delete',
};

View File

@@ -75,4 +75,5 @@ export enum DialogsName {
GeneralLedgerPdfPreview = 'GeneralLedgerPdfPreview',
SalesTaxLiabilitySummaryPdfPreview = 'SalesTaxLiabilitySummaryPdfPreview',
Export = 'Export',
BankRuleForm = 'BankRuleForm'
}

View File

@@ -458,6 +458,11 @@ export const SidebarMenu = [
ability: CashflowAction.View,
},
},
{
text: 'Rules',
href: '/bank-rules',
type: ISidebarMenuItemType.Link,
},
],
},
{

View File

@@ -22,6 +22,8 @@ export const TABLES = {
PROJECTS: 'projects',
TIMESHEETS: 'timesheets',
PROJECT_TASKS: 'project_tasks',
UNCATEGORIZED_ACCOUNT_TRANSACTIONS: 'UNCATEGORIZED_ACCOUNT_TRANSACTIONS',
EXCLUDED_BANK_TRANSACTIONS: 'EXCLUDED_BANK_TRANSACTIONS'
};
export const TABLE_SIZE = {

View File

@@ -11,6 +11,7 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { useDeleteExpense } from '@/hooks/query';
import { compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
import { handleDeleteErrors } from './_utils';
/**
* Expense delete alert.
@@ -51,16 +52,7 @@ function ExpenseDeleteAlert({
data: { errors },
},
}) => {
if (
errors.find((e) => e.type === 'EXPENSE_HAS_ASSOCIATED_LANDED_COST')
) {
AppToaster.show({
intent: Intent.DANGER,
message: intl.get(
'couldn_t_delete_expense_transaction_has_associated_located_landed_cost_transaction',
),
});
}
handleDeleteErrors(errors);
},
)
.finally(() => {

View File

@@ -0,0 +1,20 @@
import { Intent } from '@blueprintjs/core';
import intl from 'react-intl-universal';
import { AppToaster } from '@/components';
export const handleDeleteErrors = (errors: any) => {
if (errors.find((e: any) => e.type === 'EXPENSE_HAS_ASSOCIATED_LANDED_COST')) {
AppToaster.show({
intent: Intent.DANGER,
message: intl.get(
'couldn_t_delete_expense_transaction_has_associated_located_landed_cost_transaction',
),
});
}
if (errors.find((e: any) => e.type === 'CANNOT_DELETE_TRANSACTION_MATCHED')) {
AppToaster.show({
intent: Intent.DANGER,
message: 'Cannot delete a transaction matched with a bank transaction.',
});
}
};

View File

@@ -11,6 +11,7 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
import { handleDeleteErrors } from './_utils';
/**
* Journal delete alert.
@@ -48,9 +49,16 @@ function JournalDeleteAlert({
closeAlert(name);
closeDrawer(DRAWERS.JOURNAL_DETAILS);
})
.catch(() => {
closeAlert(name);
});
.catch(
({
response: {
data: { errors },
},
}) => {
handleDeleteErrors(errors);
closeAlert(name);
},
);
};
return (

View File

@@ -0,0 +1,11 @@
import { AppToaster } from '@/components';
import { Intent } from '@blueprintjs/core';
export const handleDeleteErrors = (errors: any) => {
if (errors.find((e: any) => e.type === 'CANNOT_DELETE_TRANSACTION_MATCHED')) {
AppToaster.show({
intent: Intent.DANGER,
message: 'Cannot delete a transaction matched with a bank transaction.',
});
}
};

View File

@@ -12,6 +12,7 @@ import { useDeletePaymentMade } from '@/hooks/query';
import { compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
import { handleDeleteErrors } from './_utils';
/**
* Payment made delete alert.
@@ -47,6 +48,15 @@ function PaymentMadeDeleteAlert({
});
closeDrawer(DRAWERS.PAYMENT_MADE_DETAILS);
})
.catch(
({
response: {
data: { errors },
},
}) => {
handleDeleteErrors(errors);
},
)
.finally(() => {
closeAlert(name);
});

View File

@@ -0,0 +1,11 @@
import { AppToaster } from '@/components';
import { Intent } from '@blueprintjs/core';
export const handleDeleteErrors = (errors: any) => {
if (errors.find((e: any) => e.type === 'CANNOT_DELETE_TRANSACTION_MATCHED')) {
AppToaster.show({
intent: Intent.DANGER,
message: 'Cannot delete a transaction matched with a bank transaction.',
});
}
};

View File

@@ -14,6 +14,7 @@ import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect';
import withAlertActions from '@/containers/Alert/withAlertActions';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { handleDeleteErrors } from './_utils';
import { compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
@@ -53,7 +54,15 @@ function PaymentReceiveDeleteAlert({
});
closeDrawer(DRAWERS.PAYMENT_RECEIVE_DETAILS);
})
.catch(() => {})
.catch(
({
response: {
data: { errors },
},
}) => {
handleDeleteErrors(errors);
},
)
.finally(() => {
closeAlert(name);
});

View File

@@ -0,0 +1,11 @@
import { Intent } from '@blueprintjs/core';
import { AppToaster } from '@/components';
export const handleDeleteErrors = (errors: any) => {
if (errors.find((e: any) => e.type === 'CANNOT_DELETE_TRANSACTION_MATCHED')) {
AppToaster.show({
intent: Intent.DANGER,
message: 'Cannot delete a transaction matched with a bank transaction.',
});
}
};

View File

@@ -26,6 +26,7 @@ import BranchesAlerts from '@/containers/Preferences/Branches/BranchesAlerts';
import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts';
import TaxRatesAlerts from '@/containers/TaxRates/alerts';
import { CashflowAlerts } from '../CashFlow/CashflowAlerts';
import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts';
export default [
...AccountsAlerts,
@@ -55,4 +56,5 @@ export default [
...ProjectAlerts,
...TaxRatesAlerts,
...CashflowAlerts,
...BankRulesAlerts
];

View File

@@ -0,0 +1,57 @@
import React, { createContext } from 'react';
import { DialogContent } from '@/components';
import { useBankRule } from '@/hooks/query/bank-rules';
import { useAccounts } from '@/hooks/query';
interface RuleFormBootValues {
bankRule?: null;
bankRuleId?: null;
isBankRuleLoading: boolean;
isEditMode: boolean;
isNewMode: boolean;
}
const RuleFormBootContext = createContext<RuleFormBootValues>(
{} as RuleFormBootValues,
);
interface RuleFormBootProps {
bankRuleId?: number;
children: React.ReactNode;
}
function RuleFormBoot({ bankRuleId, ...props }: RuleFormBootProps) {
const { data: bankRule, isLoading: isBankRuleLoading } = useBankRule(
bankRuleId as number,
{
enabled: !!bankRuleId,
},
);
const { data: accounts, isLoading: isAccountsLoading } = useAccounts({}, {});
const isNewMode = !bankRuleId;
const isEditMode = !isNewMode;
const provider = {
bankRuleId,
bankRule,
accounts,
isBankRuleLoading,
isAccountsLoading,
isEditMode,
isNewMode,
} as RuleFormBootValues;
const isLoading = isBankRuleLoading || isAccountsLoading;
return (
<DialogContent isLoading={isLoading}>
<RuleFormBootContext.Provider value={provider} {...props} />
</DialogContent>
);
}
const useRuleFormDialogBoot = () =>
React.useContext<RuleFormBootValues>(RuleFormBootContext);
export { RuleFormBoot, useRuleFormDialogBoot };

View File

@@ -0,0 +1,21 @@
import { Classes } from '@blueprintjs/core';
import { RuleFormBoot } from './RuleFormBoot';
import { RuleFormContentForm } from './RuleFormContentForm';
interface RuleFormContentProps {
dialogName: string;
bankRuleId?: number;
}
export default function RuleFormContent({
dialogName,
bankRuleId,
}: RuleFormContentProps) {
return (
<RuleFormBoot bankRuleId={bankRuleId}>
<div className={Classes.DIALOG_BODY}>
<RuleFormContentForm />
</div>
</RuleFormBoot>
);
}

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import * as Yup from 'yup';
const Schema = Yup.object().shape({
name: Yup.string().required().label('Rule name'),
applyIfAccountId: Yup.number().required().label('Apply to account'),
applyIfTransactionType: Yup.string()
.required()
.label('Apply to transaction type'),
conditionsType: Yup.string().required().label('Condition type'),
assignCategory: Yup.string().required().label('Assign to category'),
assignAccountId: Yup.string().required().label('Assign to account'),
conditions: Yup.array().of(
Yup.object().shape({
value: Yup.string().required().label('Value'),
comparator: Yup.string().required().label('Comparator'),
field: Yup.string().required().label('Field'),
}),
),
});
export const CreateRuleFormSchema = Schema;

View File

@@ -0,0 +1,286 @@
// @ts-nocheck
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Button, Classes, Intent, Radio, Tag } from '@blueprintjs/core';
import * as R from 'ramda';
import { CreateRuleFormSchema } from './RuleFormContentForm.schema';
import {
AccountsSelect,
AppToaster,
Box,
FFormGroup,
FInputGroup,
FRadioGroup,
FSelect,
Group,
Stack,
} from '@/components';
import { useCreateBankRule, useEditBankRule } from '@/hooks/query/bank-rules';
import {
AssignTransactionTypeOptions,
FieldCondition,
Fields,
RuleFormValues,
TransactionTypeOptions,
initialValues,
} from './_utils';
import { useRuleFormDialogBoot } from './RuleFormBoot';
import {
transformToCamelCase,
transformToForm,
transfromToSnakeCase,
} from '@/utils';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
function RuleFormContentFormRoot({
// #withDialogActions
closeDialog,
}) {
const { accounts, bankRule, isEditMode, bankRuleId } =
useRuleFormDialogBoot();
const { mutateAsync: createBankRule } = useCreateBankRule();
const { mutateAsync: editBankRule } = useEditBankRule();
const validationSchema = CreateRuleFormSchema;
const _initialValues = {
...initialValues,
...transformToForm(transformToCamelCase(bankRule), initialValues),
};
// Handles the form submitting.
const handleSubmit = (
values: RuleFormValues,
{ setSubmitting }: FormikHelpers<RuleFormValues>,
) => {
const _values = transfromToSnakeCase(values);
setSubmitting(true);
const handleSuccess = () => {
setSubmitting(false);
closeDialog(DialogsName.BankRuleForm);
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The bank rule has been created successfully.',
});
};
const handleError = (error) => {
setSubmitting(false);
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong!',
});
};
if (isEditMode) {
editBankRule({ id: bankRuleId, value: _values })
.then(handleSuccess)
.catch(handleError);
} else {
createBankRule(_values).then(handleSuccess).catch(handleError);
}
};
return (
<Formik<RuleFormValues>
initialValues={_initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}
>
<Form>
<FFormGroup
name={'name'}
label={'Rule Name'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
>
<FInputGroup name={'name'} />
</FFormGroup>
<FFormGroup
name={'applyIfAccountId'}
label={'Apply the rule to account'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 350 }}
>
<AccountsSelect
name={'applyIfAccountId'}
items={accounts}
filterByTypes={['cash', 'bank']}
/>
</FFormGroup>
<FFormGroup
name={'applyIfTransactionType'}
label={'Apply to transactions are'}
style={{ maxWidth: 350 }}
>
<FSelect
name={'applyIfTransactionType'}
items={TransactionTypeOptions}
popoverProps={{ minimal: true, inline: false }}
/>
</FFormGroup>
<FFormGroup
name={'conditionsType'}
label={'Categorize the transactions when'}
>
<FRadioGroup name={'conditionsType'}>
<Radio value={'and'} label={'All the following criteria matches'} />
<Radio
value={'or'}
label={'Any one of the following criteria matches'}
/>
</FRadioGroup>
</FFormGroup>
<RuleFormConditions />
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: '0.8rem' }}>
Then Assign
</h3>
<FFormGroup
name={'assignCategory'}
label={'Transaction type'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
>
<FSelect
name={'assignCategory'}
items={AssignTransactionTypeOptions}
popoverProps={{ minimal: true, inline: false }}
/>
</FFormGroup>
<FFormGroup
name={'assignAccountId'}
label={'Account category'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
>
<AccountsSelect name={'assignAccountId'} items={accounts} />
</FFormGroup>
<FFormGroup
name={'assignRef'}
label={'Reference'}
style={{ maxWidth: 300 }}
>
<FInputGroup name={'assignRef'} />
</FFormGroup>
<RuleFormActions />
</Form>
</Formik>
);
}
export const RuleFormContentForm = R.compose(withDialogActions)(
RuleFormContentFormRoot,
);
/**
* Rule form conditions stack.
* @returns {React.ReactNode}
*/
function RuleFormConditions() {
const { values, setFieldValue } = useFormikContext<RuleFormValues>();
const handleAddConditionBtnClick = () => {
const _conditions = [
...values.conditions,
{ field: '', comparator: '', value: '' },
];
setFieldValue('conditions', _conditions);
};
return (
<Box style={{ marginBottom: 15 }}>
<Stack spacing={15}>
{values?.conditions?.map((condition, index) => (
<Group key={index} style={{ width: 500 }}>
<FFormGroup
name={`conditions[${index}].field`}
label={'Field'}
style={{ marginBottom: 0, flex: '1 0' }}
>
<FSelect
name={`conditions[${index}].field`}
items={Fields}
popoverProps={{ minimal: true, inline: false }}
/>
</FFormGroup>
<FFormGroup
name={`conditions[${index}].comparator`}
label={'Condition'}
style={{ marginBottom: 0, flex: '1 0' }}
>
<FSelect
name={`conditions[${index}].comparator`}
items={FieldCondition}
popoverProps={{ minimal: true, inline: false }}
/>
</FFormGroup>
<FFormGroup
name={`conditions[${index}].value`}
label={'Value'}
style={{ marginBottom: 0, flex: '1 0 ', width: '40%' }}
>
<FInputGroup name={`conditions[${index}].value`} />
</FFormGroup>
</Group>
))}
</Stack>
<Button
minimal
small
intent={Intent.PRIMARY}
type={'button'}
onClick={handleAddConditionBtnClick}
style={{ marginTop: 8 }}
>
Add Condition
</Button>
</Box>
);
}
/**
* Rule form actions buttons.
* @returns {React.ReactNode}
*/
function RuleFormActionsRoot({
// #withDialogActions
closeDialog,
}) {
const { isSubmitting, submitForm } = useFormikContext<RuleFormValues>();
const handleSaveBtnClick = () => {
submitForm();
};
const handleCancelBtnClick = () => {
closeDialog(DialogsName.BankRuleForm);
};
return (
<Box className={Classes.DIALOG_FOOTER}>
<Box className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button
type="submit"
intent={Intent.PRIMARY}
loading={isSubmitting}
onClick={handleSaveBtnClick}
style={{ minWidth: 100 }}
>
Save
</Button>
</Box>
</Box>
);
}
const RuleFormActions = R.compose(withDialogActions)(RuleFormActionsRoot);

View File

@@ -0,0 +1,35 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const RuleFormContent = React.lazy(() => import('./RuleFormContent'));
/**
* Payment mail dialog.
*/
function RuleFormDialogRoot({
dialogName,
payload: { bankRuleId = null },
isOpen,
}) {
return (
<Dialog
name={dialogName}
title={bankRuleId ? 'Edit Bank Rule' : 'New Bank Rule'}
isOpen={isOpen}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 600 }}
>
<DialogSuspense>
<RuleFormContent dialogName={dialogName} bankRuleId={bankRuleId} />
</DialogSuspense>
</Dialog>
);
}
export const RuleFormDialog = compose(withDialogRedux())(RuleFormDialogRoot);
RuleFormDialog.displayName = 'RuleFormDialog';

View File

@@ -0,0 +1,49 @@
export const initialValues = {
name: '',
order: 0,
applyIfAccountId: '',
applyIfTransactionType: '',
conditionsType: 'and',
conditions: [
{
field: 'description',
comparator: 'contains',
value: '',
},
],
assignCategory: '',
assignAccountId: '',
};
export interface RuleFormValues {
name: string;
order: number;
applyIfAccountId: string;
applyIfTransactionType: string;
conditionsType: string;
conditions: Array<{
field: string;
comparator: string;
value: string;
}>;
assignCategory: string;
assignAccountId: string;
}
export const TransactionTypeOptions = [
{ value: 'deposit', text: 'Deposit' },
{ value: 'withdrawal', text: 'Withdrawal' },
];
export const Fields = [
{ value: 'description', text: 'Description' },
{ value: 'amount', text: 'Amount' },
{ value: 'payee', text: 'Payee' },
];
export const FieldCondition = [
{ value: 'contains', text: 'Contains' },
{ value: 'equals', text: 'Equals' },
{ value: 'not_contains', text: 'Not Contains' },
];
export const AssignTransactionTypeOptions = [
{ value: 'expense', text: 'Expense' },
];

View File

@@ -0,0 +1,16 @@
// @ts-nocheck
import React from 'react';
const DeleteBankRuleAlert = React.lazy(
() => import('./alerts/DeleteBankRuleAlert'),
);
/**
* Cashflow alerts.
*/
export const BankRulesAlerts = [
{
name: 'bank-rule-delete',
component: DeleteBankRuleAlert,
},
];

View File

@@ -0,0 +1,3 @@
.root{
max-width: 600px;
}

View File

@@ -0,0 +1,51 @@
// @ts-nocheck
import * as R from 'ramda';
import { Button, Intent } from '@blueprintjs/core';
import { EmptyStatus, Can, FormattedMessage as T } from '@/components';
import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
import styles from './BankRulesLandingEmptyState.module.scss';
function BankRulesLandingEmptyStateRoot({
// #withDialogAction
openDialog,
}) {
const handleNewBtnClick = () => {
openDialog(DialogsName.BankRuleForm);
};
return (
<EmptyStatus
title={'Create rules to categorize bank transactions automatically'}
description={
<p>
Bank rules will run automatically to categorize the incoming bank
transactions under the conditions you set up.
</p>
}
action={
<>
<Can I={BankRuleAction.Create} a={AbilitySubject.BankRule}>
<Button
intent={Intent.PRIMARY}
large={true}
onClick={handleNewBtnClick}
>
New Bank Rule
</Button>
<Button intent={Intent.NONE} large={true}>
<T id={'learn_more'} />
</Button>
</Can>
</>
}
classNames={{ root: styles.root }}
/>
);
}
export const BankRulesLandingEmptyState = R.compose(withDialogActions)(
BankRulesLandingEmptyStateRoot,
);

View File

@@ -0,0 +1,3 @@
import { RulesList } from './RulesList';
export default RulesList;

View File

@@ -0,0 +1,24 @@
// @ts-nocheck
import { DashboardPageContent } from '@/components';
import { RulesListBoot } from './RulesListBoot';
import { RulesListActionsBar } from './RulesListActionsBar';
import { BankRulesTable } from './RulesTable';
import React from 'react';
/**
* Renders the rules landing page.
* @returns {React.ReactNode}
*/
export function RulesList() {
return (
<RulesListBoot>
<RulesListActionsBar />
<DashboardPageContent>
<RulesListBoot>
<BankRulesTable />
</RulesListBoot>
</DashboardPageContent>
</RulesListBoot>
);
}

View File

@@ -0,0 +1,35 @@
// @ts-nocheck
import { Button, Classes, NavbarGroup } from '@blueprintjs/core';
import * as R from 'ramda';
import { Can, DashboardActionsBar, Icon } from '@/components';
import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
function RulesListActionsBarRoot({
// #withDialogActions
openDialog,
}) {
const handleCreateBtnClick = () => {
openDialog(DialogsName.BankRuleForm);
};
return (
<DashboardActionsBar>
<NavbarGroup>
<Can I={BankRuleAction.Create} a={AbilitySubject.BankRule}>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="plus" />}
text={'New Bank Rule'}
onClick={handleCreateBtnClick}
/>
</Can>
</NavbarGroup>
</DashboardActionsBar>
);
}
export const RulesListActionsBar = R.compose(withDialogActions)(
RulesListActionsBarRoot,
);

View File

@@ -0,0 +1,38 @@
import React, { createContext } from 'react';
import { DialogContent } from '@/components';
import { useBankRules } from '@/hooks/query/bank-rules';
import { isEmpty } from 'lodash';
interface RulesListBootValues {
bankRules: any;
isBankRulesLoading: boolean;
isEmptyState: boolean;
}
const RulesListBootContext = createContext<RulesListBootValues>(
{} as RulesListBootValues,
);
interface RulesListBootProps {
children: React.ReactNode;
}
function RulesListBoot({ ...props }: RulesListBootProps) {
const { data: bankRules, isLoading: isBankRulesLoading } = useBankRules();
const isEmptyState = !isBankRulesLoading && isEmpty(bankRules);
const isLoading = isBankRulesLoading;
const provider = { bankRules, isBankRulesLoading, isEmptyState } as RulesListBootValues;
return (
<DialogContent isLoading={isLoading}>
<RulesListBootContext.Provider {...props} value={provider} />
</DialogContent>
);
}
const useRulesListBoot = () =>
React.useContext<RulesListBootValues>(RulesListBootContext);
export { RulesListBoot, useRulesListBoot };

View File

@@ -0,0 +1,82 @@
// @ts-nocheck
import * as R from 'ramda';
import {
DataTable,
DashboardContentTable,
TableSkeletonHeader,
TableSkeletonRows,
} from '@/components';
import withAlertsActions from '@/containers/Alert/withAlertActions';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { useBankRulesTableColumns } from './hooks';
import { BankRulesTableActionsMenu } from './_components';
import { BankRulesLandingEmptyState } from './BankRulesLandingEmptyState';
import { useRulesListBoot } from './RulesListBoot';
import { DialogsName } from '@/constants/dialogs';
/**
* Retrieves the rules table.
* @returns {React.ReactNode}
*/
function RulesTable({
// #withAlertsActions
openAlert,
// #withDialogAction
openDialog,
}) {
// Invoices table columns.
const columns = useBankRulesTableColumns();
const { bankRules, isEmptyState } = useRulesListBoot();
// Handle edit bank rule.
const handleDeleteBankRule = ({ id }) => {
openAlert('bank-rule-delete', { id });
};
// Handle delete bank rule.
const handleEditBankRule = ({ id }) => {
openDialog(DialogsName.BankRuleForm, { bankRuleId: id });
};
// Display invoice empty status instead of the table.
if (isEmptyState) {
return <BankRulesLandingEmptyState />;
}
return (
<DashboardContentTable>
<DataTable
columns={columns}
data={bankRules}
loading={false}
headerLoading={false}
progressBarLoading={false}
manualSortBy={false}
selectionColumn={false}
noInitialFetch={true}
sticky={true}
pagination={false}
manualPagination={false}
autoResetSortBy={false}
autoResetPage={false}
TableLoadingRenderer={TableSkeletonRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
ContextMenu={BankRulesTableActionsMenu}
// onCellClick={handleCellClick}
size={'medium'}
payload={{
onDelete: handleDeleteBankRule,
onEdit: handleEditBankRule,
}}
/>
</DashboardContentTable>
);
}
export const BankRulesTable = R.compose(
withAlertsActions,
withDialogActions,
)(RulesTable);

View File

@@ -0,0 +1,36 @@
// @ts-nocheck
import React from 'react';
import { Intent, Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
import { Can, Icon } from '@/components';
import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption';
import { safeCallback } from '@/utils';
/**
* Tax rates table actions menu.
* @returns {JSX.Element}
*/
export function BankRulesTableActionsMenu({
payload: { onEdit, onDelete },
row: { original },
}) {
return (
<Menu>
<Can I={BankRuleAction.Edit} a={AbilitySubject.BankRule}>
<MenuItem
icon={<Icon icon="pen-18" />}
text={'Edit Rule'}
onClick={safeCallback(onEdit, original)}
/>
</Can>
<Can I={BankRuleAction.Delete} a={AbilitySubject.BankRule}>
<MenuDivider />
<MenuItem
text={'Delete Rule'}
intent={Intent.DANGER}
onClick={safeCallback(onDelete, original)}
icon={<Icon icon="trash-16" iconSize={16} />}
/>
</Can>
</Menu>
);
}

View File

@@ -0,0 +1,80 @@
// @ts-nocheck
import React from 'react';
import { Intent, Alert } from '@blueprintjs/core';
import { FormattedMessage as T } from '@/components';
import { AppToaster } from '@/components';
import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect';
import withAlertActions from '@/containers/Alert/withAlertActions';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { useDeleteBankRule } from '@/hooks/query/bank-rules';
import { compose } from '@/utils';
/**
* Project delete alert.
*/
function BankRuleDeleteAlert({
name,
// #withAlertStoreConnect
isOpen,
payload: { id },
// #withAlertActions
closeAlert,
// #withDrawerActions
closeDrawer,
}) {
const { mutateAsync: deleteBankRule, isLoading } = useDeleteBankRule();
// handle cancel delete project alert.
const handleCancelDeleteAlert = () => {
closeAlert(name);
};
// handleConfirm delete project
const handleConfirmBtnClick = () => {
deleteBankRule(id)
.then(() => {
AppToaster.show({
message: 'The bank rule has deleted successfully.',
intent: Intent.SUCCESS,
});
closeAlert(name);
})
.catch(
({
response: {
data: { errors },
},
}) => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
},
);
};
return (
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={'Delete'}
intent={Intent.DANGER}
isOpen={isOpen}
onCancel={handleCancelDeleteAlert}
onConfirm={handleConfirmBtnClick}
loading={isLoading}
>
<p>Are you sure want to delete the bank rule?</p>
</Alert>
);
}
export default compose(
withAlertStoreConnect(),
withAlertActions,
withDrawerActions,
)(BankRuleDeleteAlert);

View File

@@ -0,0 +1,55 @@
// @ts-nocheck
import { useMemo } from 'react';
import { Intent, Tag } from '@blueprintjs/core';
const applyToTypeAccessor = (rule) => {
return rule.apply_if_transaction_type === 'deposit' ? (
<Tag round intent={Intent.SUCCESS}>
Deposits
</Tag>
) : (
<Tag round intent={Intent.DANGER}>
Withdrawals
</Tag>
);
};
const conditionsAccessor = (rule) => (
<span style={{ fontSize: 12, color: '#5F6B7C' }}>
{rule.conditions_formatted}
</span>
);
const applyToAccessor = (rule) => (
<Tag intent={Intent.NONE} minimal>
{rule.assign_account_name}
</Tag>
);
export const useBankRulesTableColumns = () => {
return useMemo(
() => [
{
Header: 'Apply to',
accessor: applyToTypeAccessor,
},
{
Header: 'Rule Name',
accessor: 'name',
},
{
Header: 'Categorize As',
accessor: 'assign_category_formatted',
},
{
Header: 'Apply To',
accessor: applyToAccessor,
},
{
Header: 'Conditions',
accessor: conditionsAccessor,
},
],
[],
);
};

View File

@@ -6,6 +6,11 @@ import {
Classes,
NavbarDivider,
Alignment,
Popover,
Menu,
MenuItem,
PopoverInteractionKind,
Position,
} from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import {
@@ -39,18 +44,20 @@ function AccountTransactionsActionsBar({
// #withSettingsActions
addSetting,
}) {
// Handle table row size change.
const handleTableRowSizeChange = (size) => {
addSetting('cashflowTransactions', 'tableSize', size);
};
const history = useHistory();
const { accountId } = useAccountTransactionsContext();
// Refresh cashflow infinity transactions hook.
const { refresh } = useRefreshCashflowTransactionsInfinity();
// Retrieves the money in/out buttons options.
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
const history = useHistory();
// Handle table row size change.
const handleTableRowSizeChange = (size) => {
addSetting('cashflowTransactions', 'tableSize', size);
};
// Handle money in form
const handleMoneyInFormTransaction = (account) => {
openDialog('money-in', {
@@ -71,10 +78,10 @@ function AccountTransactionsActionsBar({
const handleImportBtnClick = () => {
history.push(`/cashflow-accounts/${accountId}/import`);
};
// Refresh cashflow infinity transactions hook.
const { refresh } = useRefreshCashflowTransactionsInfinity();
// Handle bank rules click.
const handleBankRulesClick = () => {
history.push(`/bank-rules?accountId=${accountId}`);
};
// Handle the refresh button click.
const handleRefreshBtnClick = () => {
refresh();
@@ -125,6 +132,22 @@ function AccountTransactionsActionsBar({
</NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}>
<Popover
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_RIGHT}
modifiers={{
offset: { offset: '0, 4' },
}}
content={
<Menu>
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
</Menu>
}
>
<Button icon={<Icon icon="cog-16" iconSize={16} />} minimal={true} />
</Popover>
<NavbarDivider />
<Button
className={Classes.MINIMAL}
icon={<Icon icon="refresh-16" iconSize={14} />}

View File

@@ -18,10 +18,10 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { useMemorizedColumnsWidths } from '@/hooks';
import { useAccountTransactionsColumns, ActionsMenu } from './components';
import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot';
import { handleCashFlowTransactionType } from './utils';
import { compose } from '@/utils';
import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot';
/**
* Account transactions data table.
@@ -106,6 +106,9 @@ const DashboardConstrantTable = styled(DataTable)`
.thead {
.th {
background: #fff;
letter-spacing: 1px;
text-transform: uppercase;
font-size: 13px;
}
}

View File

@@ -1,5 +1,6 @@
// @ts-nocheck
import React, { Suspense } from 'react';
import * as R from 'ramda';
import { Spinner } from '@blueprintjs/core';
import '@/style/pages/CashFlow/AccountTransactions/List.scss';
@@ -14,29 +15,50 @@ import {
import { AccountTransactionsDetailsBar } from './AccountTransactionsDetailsBar';
import { AccountTransactionsProgressBar } from './components';
import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs';
import { AppContentShell } from '@/components/AppShell';
import { CategorizeTransactionAside } from '../CategorizeTransactionAside/CategorizeTransactionAside';
import { withBanking } from '../withBanking';
/**
* Account transactions list.
*/
function AccountTransactionsList() {
function AccountTransactionsListRoot({
// #withBanking
openMatchingTransactionAside,
}) {
return (
<AccountTransactionsProvider>
<AccountTransactionsActionsBar />
<AccountTransactionsDetailsBar />
<AccountTransactionsProgressBar />
<AppContentShell hideAside={!openMatchingTransactionAside}>
<AppContentShell.Main>
<AccountTransactionsActionsBar />
<AccountTransactionsDetailsBar />
<AccountTransactionsProgressBar />
<DashboardPageContent>
<AccountTransactionsFilterTabs />
<DashboardPageContent>
<AccountTransactionsFilterTabs />
<Suspense fallback={<Spinner size={30} />}>
<AccountTransactionsContent />
</Suspense>
</DashboardPageContent>
<Suspense fallback={<Spinner size={30} />}>
<AccountTransactionsContent />
</Suspense>
</DashboardPageContent>
</AppContentShell.Main>
<AppContentShell.Aside>
<CategorizeTransactionAside />
</AppContentShell.Aside>
</AppContentShell>
</AccountTransactionsProvider>
);
}
export default AccountTransactionsList;
export default R.compose(
withBanking(
({ selectedUncategorizedTransactionId, openMatchingTransactionAside }) => ({
selectedUncategorizedTransactionId,
openMatchingTransactionAside,
}),
),
)(AccountTransactionsListRoot);
const AccountsTransactionsAll = React.lazy(
() => import('./AccountsTransactionsAll'),

View File

@@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
import { DashboardInsider } from '@/components';
import { useCashflowAccounts, useAccount } from '@/hooks/query';
import { useAppQueryString } from '@/hooks';
import { useGetBankAccountSummaryMeta } from '@/hooks/query/bank-rules';
const AccountTransactionsContext = React.createContext();
@@ -20,31 +21,38 @@ function AccountTransactionsProvider({ query, ...props }) {
const setFilterTab = (value: string) => {
setLocationQuery({ filter: value });
};
// Fetch cashflow accounts.
// Retrieves cashflow accounts.
const {
data: cashflowAccounts,
isFetching: isCashFlowAccountsFetching,
isLoading: isCashFlowAccountsLoading,
} = useCashflowAccounts(query, { keepPreviousData: true });
// Retrieve specific account details.
// Retrieves specific account details.
const {
data: currentAccount,
isFetching: isCurrentAccountFetching,
isLoading: isCurrentAccountLoading,
} = useAccount(accountId, { keepPreviousData: true });
// Retrieves the bank account meta summary.
const {
data: bankAccountMetaSummary,
isLoading: isBankAccountMetaSummaryLoading,
} = useGetBankAccountSummaryMeta(accountId);
// Provider payload.
const provider = {
accountId,
cashflowAccounts,
currentAccount,
bankAccountMetaSummary,
isCashFlowAccountsFetching,
isCashFlowAccountsLoading,
isCurrentAccountFetching,
isCurrentAccountLoading,
isBankAccountMetaSummaryLoading,
filterTab,
setFilterTab,

View File

@@ -1,36 +1,50 @@
// @ts-nocheck
import styled from 'styled-components';
import { Tag } from '@blueprintjs/core';
const Root = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
margin-bottom: 18px;
`;
const FilterTag = styled(Tag)`
min-height: 26px;
&.bp4-minimal:not([class*='bp4-intent-']) {
background: #fff;
border: 1px solid #e1e2e8;
&.bp4-interactive:hover {
background-color: rgba(143, 153, 168, 0.05);
}
}
`;
import { useAppQueryString } from '@/hooks';
import { Group } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { TagsControl } from '@/components/TagsControl';
export function AccountTransactionsUncategorizeFilter() {
const { bankAccountMetaSummary } = useAccountTransactionsContext();
const [locationQuery, setLocationQuery] = useAppQueryString();
const totalUncategorized =
bankAccountMetaSummary?.totalUncategorizedTransactions;
const totalRecognized = bankAccountMetaSummary?.totalRecognizedTransactions;
const handleTabsChange = (value) => {
setLocationQuery({ uncategorizedFilter: value });
};
return (
<Root>
<FilterTag round interactive>
All <strong>(2)</strong>
</FilterTag>
<FilterTag round minimal interactive>
Recognized <strong>(0)</strong>
</FilterTag>
</Root>
<Group position={'apart'}>
<TagsControl
options={[
{
value: 'all',
label: (
<>
All <strong>({totalUncategorized})</strong>
</>
),
},
{
value: 'recognized',
label: (
<>
Recognized <strong>({totalRecognized})</strong>
</>
),
},
]}
value={locationQuery?.uncategorizedFilter || 'all'}
onValueChange={handleTabsChange}
/>
<TagsControl
options={[{ value: 'excluded', label: 'Excluded' }]}
value={locationQuery?.uncategorizedFilter || 'all'}
onValueChange={handleTabsChange}
/>
</Group>
);
}

View File

@@ -1,7 +1,7 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import { Intent } from '@blueprintjs/core';
import {
DataTable,
TableFastCell,
@@ -9,11 +9,12 @@ import {
TableSkeletonHeader,
TableVirtualizedListRows,
FormattedMessage as T,
AppToaster,
} from '@/components';
import { TABLES } from '@/constants/tables';
import withSettings from '@/containers/Settings/withSettings';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { withBankingActions } from '../withBankingActions';
import { useMemorizedColumnsWidths } from '@/hooks';
import {
@@ -23,7 +24,7 @@ import {
import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot';
import { compose } from '@/utils';
import { DRAWERS } from '@/constants/drawers';
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
/**
* Account transactions data table.
@@ -32,8 +33,8 @@ function AccountTransactionsDataTable({
// #withSettings
cashflowTansactionsTableSize,
// #withDrawerActions
openDrawer,
// #withBankingActions
setUncategorizedTransactionIdForMatching,
}) {
// Retrieve table columns.
const columns = useAccountUncategorizedTransactionsColumns();
@@ -42,15 +43,36 @@ function AccountTransactionsDataTable({
const { uncategorizedTransactions, isUncategorizedTransactionsLoading } =
useAccountUncategorizedTransactionsContext();
const { mutateAsync: excludeTransaction } =
useExcludeUncategorizedTransaction();
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_CASHFLOW_TRANSACTION);
// Handle cell click.
const handleCellClick = (cell, event) => {
openDrawer(DRAWERS.CATEGORIZE_TRANSACTION, {
uncategorizedTransactionId: cell.row.original.id,
});
const handleCellClick = (cell) => {
setUncategorizedTransactionIdForMatching(cell.row.original.id);
};
// Handles categorize button click.
const handleCategorizeBtnClick = (transaction) => {
setUncategorizedTransactionIdForMatching(transaction.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 (
@@ -75,8 +97,14 @@ function AccountTransactionsDataTable({
vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing}
noResults={<T id={'cash_flow.account_transactions.no_results'} />}
noResults={
'There is no uncategorized transactions in the current account.'
}
className="table-constrant"
payload={{
onExclude: handleExcludeTransaction,
onCategorize: handleCategorizeBtnClick,
}}
/>
);
}
@@ -85,7 +113,7 @@ export default compose(
withSettings(({ cashflowTransactionsSettings }) => ({
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
})),
withDrawerActions,
withBankingActions,
)(AccountTransactionsDataTable);
const DashboardConstrantTable = styled(DataTable)`
@@ -93,6 +121,10 @@ const DashboardConstrantTable = styled(DataTable)`
.thead {
.th {
background: #fff;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 13px;i
font-weight: 500;
}
}

View File

@@ -4,7 +4,6 @@ import styled from 'styled-components';
import '@/style/pages/CashFlow/AccountTransactions/List.scss';
import AccountTransactionsDataTable from './AccountTransactionsDataTable';
import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter';
import { AccountTransactionsAllProvider } from './AccountTransactionsAllBoot';
const Box = styled.div`
@@ -23,8 +22,6 @@ export default function AccountTransactionsAll() {
return (
<AccountTransactionsAllProvider>
<Box>
<AccountTransactionsUncategorizeFilter />
<CashflowTransactionsTableCard>
<AccountTransactionsDataTable />
</CashflowTransactionsTableCard>

View File

@@ -1,31 +1,76 @@
// @ts-nocheck
import { useEffect, lazy } from 'react';
import styled from 'styled-components';
import * as R from 'ramda';
import '@/style/pages/CashFlow/AccountTransactions/List.scss';
import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable';
import { AccountUncategorizedTransactionsBoot } from './AllTransactionsUncategorizedBoot';
import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter';
import {
WithBankingActionsProps,
withBankingActions,
} from '../withBankingActions';
import { useAppQueryString } from '@/hooks';
const Box = styled.div`
margin: 30px 15px;
`;
const CashflowTransactionsTableCard = styled.div`
border: 2px solid #f0f0f0;
border-radius: 10px;
padding: 30px 18px;
background: #fff;
flex: 0 1;
`;
interface AllTransactionsUncategorizedProps extends WithBankingActionsProps {}
function AllTransactionsUncategorizedRoot({
// #withBankingActions
closeMatchingTransactionAside,
}: AllTransactionsUncategorizedProps) {
// Close the match aside once leaving the page.
useEffect(
() => () => {
closeMatchingTransactionAside();
},
[closeMatchingTransactionAside],
);
export default function AllTransactionsUncategorized() {
return (
<AccountUncategorizedTransactionsBoot>
<Box>
<CashflowTransactionsTableCard>
<AccountTransactionsUncategorizedTable />
</CashflowTransactionsTableCard>
</Box>
</AccountUncategorizedTransactionsBoot>
<Box>
<AccountTransactionsUncategorizeFilter />
<AccountTransactionsSwitcher />
</Box>
);
}
const AccountExcludedTransactins = lazy(() =>
import('./UncategorizedTransactions/AccountExcludedTransactions').then(
(module) => ({ default: module.AccountExcludedTransactions }),
),
);
const AccountRecognizedTransactions = lazy(() =>
import('./UncategorizedTransactions/AccountRecgonizedTranasctions').then(
(module) => ({ default: module.AccountRecognizedTransactions }),
),
);
const AccountUncategorizedTransactions = lazy(() =>
import(
'./UncategorizedTransactions/AccountUncategorizedTransactionsAll'
).then((module) => ({ default: module.AccountUncategorizedTransactionsAll })),
);
/**
* Switches between the account transactions tables.
* @returns {React.ReactNode}
*/
function AccountTransactionsSwitcher() {
const [locationQuery] = useAppQueryString();
const uncategorizedTab = locationQuery?.uncategorizedFilter;
switch (uncategorizedTab) {
case 'excluded':
return <AccountExcludedTransactins />;
case 'recognized':
return <AccountRecognizedTransactions />;
case 'all':
default:
return <AccountUncategorizedTransactions />;
}
}
export default R.compose(withBankingActions)(AllTransactionsUncategorizedRoot);

View File

@@ -0,0 +1,127 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import { Intent } from '@blueprintjs/core';
import {
DataTable,
TableFastCell,
TableSkeletonRows,
TableSkeletonHeader,
TableVirtualizedListRows,
AppToaster,
} from '@/components';
import { TABLES } from '@/constants/tables';
import { useMemorizedColumnsWidths } from '@/hooks';
import { useExcludedTransactionsColumns } from './_utils';
import { useExcludedTransactionsBoot } from './ExcludedTransactionsTableBoot';
import { ActionsMenu } from './_components';
import { useUnexcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
/**
* Renders the recognized account transactions datatable.
*/
export function ExcludedTransactionsTable() {
const { excludedBankTransactions } = useExcludedTransactionsBoot();
const { mutateAsync: unexcludeBankTransaction } =
useUnexcludeUncategorizedTransaction();
// Retrieve table columns.
const columns = useExcludedTransactionsColumns();
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_ACCOUNT_TRANSACTIONS);
// Handle cell click.
const handleCellClick = (cell, event) => {};
// Handle restore button click.
const handleRestoreClick = (transaction) => {
unexcludeBankTransaction(transaction.id)
.then(() => {
AppToaster.show({
message: 'The excluded bank transaction has been restored.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
columns={columns}
data={excludedBankTransactions}
sticky={true}
loading={false}
headerLoading={false}
expandColumnSpace={1}
expandToggleColumn={2}
selectionColumnWidth={45}
TableCellRenderer={TableFastCell}
TableLoadingRenderer={TableSkeletonRows}
TableRowsRenderer={TableVirtualizedListRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
ContextMenu={ActionsMenu}
onCellClick={handleCellClick}
// #TableVirtualizedListRows props.
vListrowHeight={'small' == 'small' ? 32 : 40}
vListrowHeight={40}
vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing}
noResults={'There is no excluded bank transactions.'}
className="table-constrant"
payload={{
onRestore: handleRestoreClick,
}}
/>
);
}
const DashboardConstrantTable = styled(DataTable)`
.table {
.thead {
.th {
background: #fff;
letter-spacing: 1px;
text-transform: uppercase;
font-size: 13px;
}
}
.tbody {
.tr:last-child .td {
border-bottom: 0;
}
}
}
`;
const CashflowTransactionsTable = styled(DashboardConstrantTable)`
.table .tbody {
.tbody-inner .tr.no-results {
.td {
padding: 2rem 0;
font-size: 14px;
color: #888;
font-weight: 400;
border-bottom: 0;
}
}
.tbody-inner {
.tr .td {
border-bottom: 1px solid #e6e6e6;
}
}
}
`;

View File

@@ -0,0 +1,90 @@
// @ts-nocheck
import React from 'react';
import { flatten, map } from 'lodash';
import { IntersectionObserver } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useExcludedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
interface ExcludedBankTransactionsContextValue {
isExcludedTransactionsLoading: boolean;
isExcludedTransactionsFetching: boolean;
excludedBankTransactions: Array<any>;
}
const ExcludedTransactionsContext =
React.createContext<ExcludedBankTransactionsContextValue>(
{} as ExcludedBankTransactionsContextValue,
);
function flattenInfinityPagesData(data) {
return flatten(map(data.pages, (page) => page.data));
}
interface ExcludedBankTransactionsTableBootProps {
children: React.ReactNode;
}
/**
* Account uncategorized transctions provider.
*/
function ExcludedBankTransactionsTableBoot({
children,
}: ExcludedBankTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext();
// Fetches the uncategorized transactions.
const {
data: recognizedTransactionsPage,
isFetching: isExcludedTransactionsFetching,
isLoading: isExcludedTransactionsLoading,
isSuccess: isRecognizedTransactionsSuccess,
isFetchingNextPage: isUncategorizedTransactionFetchNextPage,
fetchNextPage: fetchNextrecognizedTransactionsPage,
hasNextPage: hasUncategorizedTransactionsNextPage,
} = useExcludedBankTransactionsInfinity({
page_size: 50,
account_id: accountId,
});
// Memorized the cashflow account transactions.
const excludedBankTransactions = React.useMemo(
() =>
isRecognizedTransactionsSuccess
? flattenInfinityPagesData(recognizedTransactionsPage)
: [],
[recognizedTransactionsPage, isRecognizedTransactionsSuccess],
);
// Handle the observer ineraction.
const handleObserverInteract = React.useCallback(() => {
if (
!isExcludedTransactionsFetching &&
hasUncategorizedTransactionsNextPage
) {
fetchNextrecognizedTransactionsPage();
}
}, [
isExcludedTransactionsFetching,
hasUncategorizedTransactionsNextPage,
fetchNextrecognizedTransactionsPage,
]);
// Provider payload.
const provider = {
excludedBankTransactions,
isExcludedTransactionsFetching,
isExcludedTransactionsLoading,
};
return (
<ExcludedTransactionsContext.Provider value={provider}>
{children}
<IntersectionObserver
onIntersect={handleObserverInteract}
enabled={!isUncategorizedTransactionFetchNextPage}
/>
</ExcludedTransactionsContext.Provider>
);
}
const useExcludedTransactionsBoot = () =>
React.useContext(ExcludedTransactionsContext);
export { ExcludedBankTransactionsTableBoot, useExcludedTransactionsBoot };

View File

@@ -0,0 +1,16 @@
// @ts-nocheck
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
import { safeCallback } from '@/utils';
import { Icon } from '@/components';
export function ActionsMenu({ payload: { onRestore }, row: { original } }) {
return (
<Menu>
<MenuItem
text={'Restore'}
icon={<Icon icon="redo" iconSize={16} />}
onClick={safeCallback(onRestore, original)}
/>
</Menu>
);
}

View File

@@ -0,0 +1,66 @@
// @ts-nocheck
import React from 'react';
import { getColumnWidth } from '@/utils';
import { useExcludedTransactionsBoot } from './ExcludedTransactionsTableBoot';
const getReportColWidth = (data, accessor, headerText) => {
return getColumnWidth(
data,
accessor,
{ magicSpacing: 10, minWidth: 100 },
headerText,
);
};
const descriptionAccessor = (transaction) => {
return <span style={{ color: '#5F6B7C' }}>{transaction.description}</span>;
};
/**
* Retrieve excluded transactions columns table.
*/
export function useExcludedTransactionsColumns() {
const { excludedBankTransactions: data } = useExcludedTransactionsBoot();
const withdrawalWidth = getReportColWidth(
data,
'formatted_withdrawal_amount',
'Withdrawal',
);
const depositWidth = getReportColWidth(
data,
'formatted_deposit_amount',
'Deposit',
);
return React.useMemo(
() => [
{
Header: 'Date',
accessor: 'formatted_date',
width: 110,
},
{
Header: 'Description',
accessor: descriptionAccessor,
},
{
Header: 'Payee',
accessor: 'payee',
},
{
Header: 'Deposit',
accessor: 'formatted_deposit_amount',
align: 'right',
width: depositWidth,
},
{
Header: 'Withdrawal',
accessor: 'formatted_withdrawal_amount',
align: 'right',
width: withdrawalWidth,
},
],
[],
);
}

View File

@@ -0,0 +1,20 @@
.emptyState{
text-align: center;
font-size: 15px;
color: #738091;
:global ul{
list-style: inside;
li{
margin-bottom: 12px;
&::marker{
color: #C5CBD3;
font-size: 12px;
}
}
}
}

View File

@@ -0,0 +1,179 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import { Intent, Text } from '@blueprintjs/core';
import {
DataTable,
TableFastCell,
TableSkeletonRows,
TableSkeletonHeader,
TableVirtualizedListRows,
AppToaster,
Stack,
} from '@/components';
import { TABLES } from '@/constants/tables';
import { useMemorizedColumnsWidths } from '@/hooks';
import { useUncategorizedTransactionsColumns } from './_utils';
import { useRecognizedTransactionsBoot } from './RecognizedTransactionsTableBoot';
import { ActionsMenu } from './_components';
import { compose } from '@/utils';
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
import styles from './RecognizedTransactionsTable.module.scss';
interface RecognizedTransactionsTableProps extends WithBankingActionsProps {}
/**
* Renders the recognized account transactions datatable.
*/
function RecognizedTransactionsTableRoot({
// #withBanking
setUncategorizedTransactionIdForMatching,
}: RecognizedTransactionsTableProps) {
const { mutateAsync: excludeBankTransaction } =
useExcludeUncategorizedTransaction();
const { recognizedTransactions, isRecongizedTransactionsLoading } =
useRecognizedTransactionsBoot();
// Retrieve table columns.
const columns = useUncategorizedTransactionsColumns();
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_ACCOUNT_TRANSACTIONS);
// Handle cell click.
const handleCellClick = (cell, event) => {
setUncategorizedTransactionIdForMatching(
cell.row.original.uncategorized_transaction_id,
);
};
// Handle exclude button click.
const handleExcludeClick = (transaction) => {
excludeBankTransaction(transaction.uncategorized_transaction_id)
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The bank transaction has been excluded.',
});
})
.catch(() => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
// Handles categorize button click.
const handleCategorizeClick = (transaction) => {
setUncategorizedTransactionIdForMatching(
transaction.uncategorized_transaction_id,
);
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
columns={columns}
data={recognizedTransactions}
sticky={true}
loading={isRecongizedTransactionsLoading}
headerLoading={isRecongizedTransactionsLoading}
expandColumnSpace={1}
expandToggleColumn={2}
selectionColumnWidth={45}
TableCellRenderer={TableFastCell}
TableLoadingRenderer={TableSkeletonRows}
TableRowsRenderer={TableVirtualizedListRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
ContextMenu={ActionsMenu}
onCellClick={handleCellClick}
// #TableVirtualizedListRows props.
vListrowHeight={'small' == 'small' ? 32 : 40}
vListrowHeight={40}
vListOverscanRowCount={0}
initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing}
noResults={<RecognizedTransactionsTableNoResults />}
className="table-constrant"
payload={{
onExclude: handleExcludeClick,
onCategorize: handleCategorizeClick,
}}
/>
);
}
export const RecognizedTransactionsTable = compose(withBankingActions)(
RecognizedTransactionsTableRoot,
);
const DashboardConstrantTable = styled(DataTable)`
.table {
.thead {
.th {
background: #fff;
letter-spacing: 1px;
text-transform: uppercase;
font-size: 13px;
}
}
.tbody {
.tr:last-child .td {
border-bottom: 0;
}
}
}
`;
const CashflowTransactionsTable = styled(DashboardConstrantTable)`
.table .tbody {
.tbody-inner .tr.no-results {
.td {
padding: 2rem 0;
font-size: 14px;
color: #888;
font-weight: 400;
border-bottom: 0;
}
}
.tbody-inner {
.tr .td {
border-bottom: 1px solid #e6e6e6;
}
}
}
`;
function RecognizedTransactionsTableNoResults() {
return (
<Stack spacing={12} className={styles.emptyState}>
<Text>
There are no Recognized transactions due to one of the following
reasons:
</Text>
<ul>
<li>
Transaction Rules have not yet been created. Transactions are
recognized based on the rule criteria.
</li>
<li>
The transactions in your bank do not satisfy the criteria in any of
your transaction rule(s).
</li>
</ul>
</Stack>
);
}

View File

@@ -0,0 +1,89 @@
// @ts-nocheck
import React from 'react';
import { flatten, map } from 'lodash';
import { IntersectionObserver } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useRecognizedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
interface RecognizedTransactionsContextValue {
isRecongizedTransactionsLoading: boolean;
isRecognizedTransactionsFetching: boolean;
recognizedTransactions: Array<any>;
}
const RecognizedTransactionsContext =
React.createContext<RecognizedTransactionsContextValue>(
{} as RecognizedTransactionsContextValue,
);
function flattenInfinityPagesData(data) {
return flatten(map(data.pages, (page) => page.data));
}
interface RecognizedTransactionsTableBootProps {
children: React.ReactNode;
}
/**
* Account uncategorized transctions provider.
*/
function RecognizedTransactionsTableBoot({
children,
}: RecognizedTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext();
// Fetches the uncategorized transactions.
const {
data: recognizedTransactionsPage,
isFetching: isRecognizedTransactionsFetching,
isLoading: isRecongizedTransactionsLoading,
isSuccess: isRecognizedTransactionsSuccess,
isFetchingNextPage: isUncategorizedTransactionFetchNextPage,
fetchNextPage: fetchNextrecognizedTransactionsPage,
hasNextPage: hasUncategorizedTransactionsNextPage,
} = useRecognizedBankTransactionsInfinity({
page_size: 50,
});
// Memorized the cashflow account transactions.
const recognizedTransactions = React.useMemo(
() =>
isRecognizedTransactionsSuccess
? flattenInfinityPagesData(recognizedTransactionsPage)
: [],
[recognizedTransactionsPage, isRecognizedTransactionsSuccess],
);
// Handle the observer ineraction.
const handleObserverInteract = React.useCallback(() => {
if (
!isRecognizedTransactionsFetching &&
hasUncategorizedTransactionsNextPage
) {
fetchNextrecognizedTransactionsPage();
}
}, [
isRecognizedTransactionsFetching,
hasUncategorizedTransactionsNextPage,
fetchNextrecognizedTransactionsPage,
]);
// Provider payload.
const provider = {
recognizedTransactions,
isRecognizedTransactionsFetching,
isRecongizedTransactionsLoading,
};
return (
<RecognizedTransactionsContext.Provider value={provider}>
{children}
<IntersectionObserver
onIntersect={handleObserverInteract}
enabled={!isUncategorizedTransactionFetchNextPage}
/>
</RecognizedTransactionsContext.Provider>
);
}
const useRecognizedTransactionsBoot = () =>
React.useContext(RecognizedTransactionsContext);
export { RecognizedTransactionsTableBoot, useRecognizedTransactionsBoot };

View File

@@ -0,0 +1,25 @@
// @ts-nocheck
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
import { safeCallback } from '@/utils';
import { Icon } from '@/components';
export function ActionsMenu({
payload: { onCategorize, onExclude },
row: { original },
}) {
return (
<Menu>
<MenuItem
text={'Categorize'}
icon={<Icon icon="reader-18" />}
onClick={safeCallback(onCategorize, original)}
/>
<MenuDivider />
<MenuItem
text={'Exclude'}
onClick={safeCallback(onExclude, original)}
icon={<Icon icon="disable" iconSize={16} />}
/>
</Menu>
);
}

View File

@@ -0,0 +1,95 @@
// @ts-nocheck
import { Group, Icon } from '@/components';
import { getColumnWidth } from '@/utils';
import React from 'react';
import { useRecognizedTransactionsBoot } from './RecognizedTransactionsTableBoot';
const getReportColWidth = (data, accessor, headerText) => {
return getColumnWidth(
data,
accessor,
{ magicSpacing: 10, minWidth: 100 },
headerText,
);
};
const recognizeAccessor = (transaction) => {
return (
<>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</>
);
};
const descriptionAccessor = (transaction) => {
return <span style={{ color: '#5F6B7C' }}>{transaction.description}</span>;
};
/**
* Retrieve uncategorized transactions columns table.
*/
export function useUncategorizedTransactionsColumns() {
const { recognizedTransactions: data } = useRecognizedTransactionsBoot();
const withdrawalWidth = getReportColWidth(
data,
'formatted_withdrawal_amount',
'Withdrawal',
);
const depositWidth = getReportColWidth(
data,
'formatted_deposit_amount',
'Deposit',
);
return React.useMemo(
() => [
{
Header: 'Date',
accessor: 'formatted_date',
width: 110,
textOverview: true,
},
{
Header: 'Description',
accessor: descriptionAccessor,
textOverview: true,
},
{
Header: 'Payee',
accessor: 'payee',
textOverview: true,
},
{
Header: 'Recognize',
accessor: recognizeAccessor,
textOverview: true,
},
{
Header: 'Rule',
accessor: 'bank_rule_name',
textOverview: true,
},
{
Header: 'Deposit',
accessor: 'formatted_deposit_amount',
align: 'right',
width: depositWidth,
},
{
Header: 'Withdrawal',
accessor: 'formatted_withdrawal_amount',
align: 'right',
width: withdrawalWidth,
},
],
[],
);
}

View File

@@ -0,0 +1,13 @@
import { ExcludedTransactionsTable } from "../ExcludedTransactions/ExcludedTransactionsTable";
import { ExcludedBankTransactionsTableBoot } from "../ExcludedTransactions/ExcludedTransactionsTableBoot";
import { AccountTransactionsCard } from "./AccountTransactionsCard";
export function AccountExcludedTransactions() {
return (
<ExcludedBankTransactionsTableBoot>
<AccountTransactionsCard>
<ExcludedTransactionsTable />
</AccountTransactionsCard>
</ExcludedBankTransactionsTableBoot>
);
}

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { RecognizedTransactionsTableBoot } from '../RecognizedTransactions/RecognizedTransactionsTableBoot';
import { RecognizedTransactionsTable } from '../RecognizedTransactions/RecognizedTransactionsTable';
import { AccountTransactionsCard } from './AccountTransactionsCard';
export function AccountRecognizedTransactions() {
return (
<RecognizedTransactionsTableBoot>
<AccountTransactionsCard>
<RecognizedTransactionsTable />
</AccountTransactionsCard>
</RecognizedTransactionsTableBoot>
);
}

View File

@@ -0,0 +1,10 @@
import styled from 'styled-components';
export const AccountTransactionsCard = styled.div`
border: 2px solid #f0f0f0;
border-radius: 10px;
padding: 30px 18px;
background: #fff;
flex: 0 1;
`;

View File

@@ -0,0 +1,13 @@
import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable';
import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot';
import { AccountTransactionsCard } from './AccountTransactionsCard';
export function AccountUncategorizedTransactionsAll() {
return (
<AccountUncategorizedTransactionsBoot>
<AccountTransactionsCard>
<AccountTransactionsUncategorizedTable />
</AccountTransactionsCard>
</AccountUncategorizedTransactionsBoot>
);
}

View File

@@ -1,41 +1,44 @@
// @ts-nocheck
import React from 'react';
import intl from 'react-intl-universal';
import { Intent, Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
import {
Intent,
Menu,
MenuItem,
MenuDivider,
Tag,
Popover,
PopoverInteractionKind,
Position,
Tooltip,
} from '@blueprintjs/core';
import {
Box,
Can,
FormatDateCell,
If,
Icon,
MaterialProgressBar,
} from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { TRANSACRIONS_TYPE } from '@/constants/cashflowOptions';
import { AbilitySubject, CashflowAction } from '@/constants/abilityOption';
import { safeCallback } from '@/utils';
export function ActionsMenu({
payload: { onDelete, onViewDetails },
payload: { onCategorize, onExclude },
row: { original },
}) {
return (
<Menu>
<MenuItem
icon={<Icon icon="reader-18" />}
text={intl.get('view_details')}
onClick={safeCallback(onViewDetails, original)}
text={'Categorize'}
onClick={safeCallback(onCategorize, original)}
/>
<MenuDivider />
<MenuItem
text={'Exclude'}
onClick={safeCallback(onExclude, original)}
icon={<Icon icon="disable" iconSize={16} />}
/>
<Can I={CashflowAction.Delete} a={AbilitySubject.Cashflow}>
<If condition={TRANSACRIONS_TYPE.includes(original.reference_type)}>
<MenuDivider />
<MenuItem
text={intl.get('delete_transaction')}
intent={Intent.DANGER}
onClick={safeCallback(onDelete, original)}
icon={<Icon icon="trash-16" iconSize={16} />}
/>
</If>
</Can>
</Menu>
);
}
@@ -141,6 +144,34 @@ export function AccountTransactionsProgressBar() {
) : null;
}
function statusAccessor(transaction) {
return transaction.is_recognized ? (
<Tooltip
compact
interactionKind={PopoverInteractionKind.HOVER}
position={Position.RIGHT}
content={
<Box>
<span>{transaction.assigned_category_formatted}</span>
<Icon
icon={'arrowRight'}
color={'#8F99A8'}
iconSize={12}
style={{ marginLeft: 8, marginRight: 8 }}
/>
<span>{transaction.assigned_account_name}</span>
</Box>
}
>
<Box>
<Tag intent={Intent.SUCCESS} interactive>
Recognized
</Tag>
</Box>
</Tooltip>
) : null;
}
/**
* Retrieve account uncategorized transctions table columns.
*/
@@ -180,10 +211,15 @@ export function useAccountUncategorizedTransactionsColumns() {
clickable: true,
textOverview: true,
},
{
id: 'status',
Header: 'Status',
accessor: statusAccessor,
},
{
id: 'deposit',
Header: intl.get('cash_flow.label.deposit'),
accessor: 'formattet_deposit_amount',
accessor: 'formatted_deposit_amount',
width: 40,
className: 'deposit',
textOverview: true,

View File

@@ -1,22 +1,42 @@
// @ts-nocheck
import React, { useMemo } from 'react';
import { first } from 'lodash';
import { DrawerHeaderContent, DrawerLoading } from '@/components';
import { DRAWERS } from '@/constants/drawers';
import {
useAccounts,
useBranches,
useUncategorizedTransaction,
} from '@/hooks/query';
import { DrawerLoading } from '@/components';
import { useAccounts, useBranches } from '@/hooks/query';
import { useFeatureCan } from '@/hooks/state';
import { Features } from '@/constants';
import { Spinner } from '@blueprintjs/core';
import { useGetRecognizedBankTransaction } from '@/hooks/query/bank-rules';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
const CategorizeTransactionBootContext = React.createContext();
interface CategorizeTransactionBootProps {
children: React.ReactNode;
}
interface CategorizeTransactionBootValue {
branches: any;
accounts: any;
isBranchesLoading: boolean;
isAccountsLoading: boolean;
primaryBranch: any;
recognizedTranasction: any;
isRecognizedTransactionLoading: boolean;
}
const CategorizeTransactionBootContext =
React.createContext<CategorizeTransactionBootValue>(
{} as CategorizeTransactionBootValue,
);
/**
* Categorize transcation boot.
*/
function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) {
function CategorizeTransactionBoot({
...props
}: CategorizeTransactionBootProps) {
const { uncategorizedTransaction, uncategorizedTransactionId } =
useCategorizeTransactionTabsBoot();
// Detarmines whether the feature is enabled.
const { featureCan } = useFeatureCan();
const isBranchFeatureCan = featureCan(Features.Branches);
@@ -29,11 +49,13 @@ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) {
{},
{ enabled: isBranchFeatureCan },
);
// Retrieves the uncategorized transaction.
// Fetches the recognized transaction.
const {
data: uncategorizedTransaction,
isLoading: isUncategorizedTransactionLoading,
} = useUncategorizedTransaction(uncategorizedTransactionId);
data: recognizedTranasction,
isLoading: isRecognizedTransactionLoading,
} = useGetRecognizedBankTransaction(uncategorizedTransactionId, {
enabled: !!uncategorizedTransaction.is_recognized,
});
// Retrieves the primary branch.
const primaryBranch = useMemo(
@@ -42,30 +64,30 @@ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) {
);
const provider = {
uncategorizedTransactionId,
uncategorizedTransaction,
isUncategorizedTransactionLoading,
branches,
accounts,
isBranchesLoading,
isAccountsLoading,
primaryBranch,
recognizedTranasction,
isRecognizedTransactionLoading,
};
const isLoading =
isBranchesLoading || isUncategorizedTransactionLoading || isAccountsLoading;
isBranchesLoading || isAccountsLoading || isRecognizedTransactionLoading;
if (isLoading) {
<Spinner size={30} />;
}
return (
<DrawerLoading loading={isLoading}>
<DrawerHeaderContent
name={DRAWERS.CATEGORIZE_TRANSACTION}
title={'Categorize Transaction'}
/>
<CategorizeTransactionBootContext.Provider value={provider} {...props} />
</DrawerLoading>
);
}
const useCategorizeTransactionBoot = () =>
React.useContext(CategorizeTransactionBootContext);
React.useContext<CategorizeTransactionBootValue>(
CategorizeTransactionBootContext,
);
export { CategorizeTransactionBoot, useCategorizeTransactionBoot };

View File

@@ -1,12 +1,12 @@
// @ts-nocheck
import styled from 'styled-components';
import { DrawerBody } from '@/components';
import { CategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { CategorizeTransactionForm } from './CategorizeTransactionForm';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
export function CategorizeTransactionContent() {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
export default function CategorizeTransactionContent({
uncategorizedTransactionId,
}) {
return (
<CategorizeTransactionBoot
uncategorizedTransactionId={uncategorizedTransactionId}
@@ -18,7 +18,8 @@ export default function CategorizeTransactionContent({
);
}
export const CategorizeTransactionDrawerBody = styled(DrawerBody)`
padding: 20px;
background-color: #fff;
const CategorizeTransactionDrawerBody = styled.div`
display: flex;
flex-direction: column;
flex: 1 1 auto;
`;

View File

@@ -1,36 +1,33 @@
// @ts-nocheck
import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core';
import styled from 'styled-components';
import { CreateCategorizeTransactionSchema } from './CategorizeTransactionForm.schema';
import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent';
import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter';
import { useCategorizeTransaction } from '@/hooks/query';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { DRAWERS } from '@/constants/drawers';
import {
transformToCategorizeForm,
defaultInitialValues,
tranformToRequest,
useCategorizeTransactionFormInitialValues,
} from './_utils';
import { compose } from '@/utils';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { withBankingActions } from '@/containers/CashFlow/withBankingActions';
import { AppToaster } from '@/components';
import { Intent } from '@blueprintjs/core';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
import { compose } from '@/utils';
/**
* Categorize cashflow transaction form dialog content.
*/
function CategorizeTransactionFormRoot({
// #withDrawerActions
closeDrawer,
// #withBankingActions
closeMatchingTransactionAside,
}) {
const {
uncategorizedTransactionId,
uncategorizedTransaction,
primaryBranch,
} = useCategorizeTransactionBoot();
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
const { mutateAsync: categorizeTransaction } = useCategorizeTransaction();
// Form initial values in create and edit mode.
const initialValues = useCategorizeTransactionFormInitialValues();
// Callbacks handles form submit.
const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
const transformedValues = tranformToRequest(values);
@@ -39,12 +36,12 @@ function CategorizeTransactionFormRoot({
categorizeTransaction([uncategorizedTransactionId, transformedValues])
.then(() => {
setSubmitting(false);
closeDrawer(DRAWERS.CATEGORIZE_TRANSACTION);
AppToaster.show({
message: 'The uncategorized transaction has been categorized.',
intent: Intent.SUCCESS,
});
closeMatchingTransactionAside();
})
.catch((err) => {
setSubmitting(false);
@@ -64,41 +61,30 @@ function CategorizeTransactionFormRoot({
}
});
};
// Form initial values in create and edit mode.
const initialValues = {
...defaultInitialValues,
/**
* We only care about the fields in the form. Previously unfilled optional
* values such as `notes` come back from the API as null, so remove those
* as well.
*/
...transformToCategorizeForm(uncategorizedTransaction),
/** Assign the primary branch id as default value. */
branchId: primaryBranch?.id || null,
};
return (
<DivRoot>
<Formik
validationSchema={CreateCategorizeTransactionSchema}
initialValues={initialValues}
onSubmit={handleFormSubmit}
>
<Form>
<CategorizeTransactionFormContent />
<CategorizeTransactionFormFooter />
</Form>
</Formik>
</DivRoot>
<Formik
validationSchema={CreateCategorizeTransactionSchema}
initialValues={initialValues}
onSubmit={handleFormSubmit}
>
<FormRoot>
<CategorizeTransactionFormContent />
<CategorizeTransactionFormFooter />
</FormRoot>
</Formik>
);
}
export const CategorizeTransactionForm = compose(withDrawerActions)(
export const CategorizeTransactionForm = compose(withBankingActions)(
CategorizeTransactionFormRoot,
);
const DivRoot = styled.div`
const FormRoot = styled(Form)`
display: flex;
flex-direction: column;
flex: 1 1 auto;
.bp4-form-group .bp4-form-content {
flex: 1 0;
}

View File

@@ -2,10 +2,10 @@
import React from 'react';
import styled from 'styled-components';
import { FormGroup } from '@blueprintjs/core';
import { FFormGroup, FSelect, } from '@/components';
import { Box, FFormGroup, FSelect } from '@/components';
import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants';
import { useFormikContext } from 'formik';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
// Retrieves the add money in button options.
const MoneyInOptions = getAddMoneyInOptions();
@@ -18,14 +18,14 @@ const Title = styled('h3')`
`;
export function CategorizeTransactionFormContent() {
const { uncategorizedTransaction } = useCategorizeTransactionBoot();
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
const transactionTypes = uncategorizedTransaction?.is_deposit_transaction
? MoneyInOptions
: MoneyOutOptions;
return (
<>
<Box style={{ flex: 1, margin: 20 }}>
<FormGroup label={'Amount'} inline>
<Title>{uncategorizedTransaction.formatted_amount}</Title>
</FormGroup>
@@ -42,7 +42,7 @@ export function CategorizeTransactionFormContent() {
</FFormGroup>
<CategorizeTransactionFormSubContent />
</>
</Box>
);
}

View File

@@ -1,56 +1,50 @@
// @ts-nocheck
import * as R from 'ramda';
import { Button, Classes, Intent } from '@blueprintjs/core';
import { Button, Intent } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import styled from 'styled-components';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
import { Group } from '@/components';
import { withBankingActions } from '@/containers/CashFlow/withBankingActions';
function CategorizeTransactionFormFooterRoot({
// #withDrawerActions
closeDrawer,
// #withBankingActions
closeMatchingTransactionAside,
}) {
const { isSubmitting } = useFormikContext();
const handleClose = () => {
closeDrawer(DRAWERS.CATEGORIZE_TRANSACTION);
closeMatchingTransactionAside();
};
return (
<Root>
<div className={Classes.DRAWER_FOOTER}>
<Group spacing={10}>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: '75px' }}
type="submit"
>
Save
</Button>
<Group spacing={10}>
<Button
intent={Intent.PRIMARY}
style={{ minWidth: '85px' }}
loading={isSubmitting}
type="submit"
>
Save
</Button>
<Button
disabled={isSubmitting}
onClick={handleClose}
style={{ minWidth: '75px' }}
>
Close
</Button>
</Group>
</div>
<Button
disabled={isSubmitting}
onClick={handleClose}
style={{ minWidth: '75px' }}
>
Close
</Button>
</Group>
</Root>
);
}
export const CategorizeTransactionFormFooter = R.compose(withDrawerActions)(
export const CategorizeTransactionFormFooter = R.compose(withBankingActions)(
CategorizeTransactionFormFooterRoot,
);
const Root = styled.div`
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid #c7d5db;
padding: 14px 20px;
`;

View File

@@ -58,7 +58,7 @@ export default function CategorizeTransactionOtherIncome() {
</FFormGroup>
<FFormGroup name={'referenceNo'} label={'Reference No.'} fastField inline>
<FInputGroup name={'reference_no'} fill />
<FInputGroup name={'referenceNo'} fill />
</FFormGroup>
<FFormGroup name={'description'} label={'Description'} fastField inline>

View File

@@ -1,5 +1,8 @@
// @ts-nocheck
import * as R from 'ramda';
import { transformToForm, transfromToSnakeCase } from '@/utils';
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
// Default initial form values.
export const defaultInitialValues = {
@@ -14,8 +17,11 @@ export const defaultInitialValues = {
branchId: '',
};
export const transformToCategorizeForm = (uncategorizedTransaction) => {
const defaultValues = {
export const transformToCategorizeForm = (
uncategorizedTransaction: any,
recognizedTransaction?: any,
) => {
let defaultValues = {
debitAccountId: uncategorizedTransaction.account_id,
transactionType: uncategorizedTransaction.is_deposit_transaction
? 'other_income'
@@ -23,10 +29,51 @@ export const transformToCategorizeForm = (uncategorizedTransaction) => {
amount: uncategorizedTransaction.amount,
date: uncategorizedTransaction.date,
};
if (recognizedTransaction) {
const recognizedDefaults = getRecognizedTransactionDefaultValues(
recognizedTransaction,
);
defaultValues = R.merge(defaultValues, recognizedDefaults);
}
return transformToForm(defaultValues, defaultInitialValues);
};
export const getRecognizedTransactionDefaultValues = (
recognizedTransaction: any,
) => {
return {
creditAccountId: recognizedTransaction.assignedAccountId || '',
// transactionType: recognizedTransaction.assignCategory,
referenceNo: recognizedTransaction.referenceNo || '',
};
};
export const tranformToRequest = (formValues) => {
export const tranformToRequest = (formValues: Record<string, any>) => {
return transfromToSnakeCase(formValues);
};
};
/**
* Categorize transaction form initial values.
* @returns
*/
export const useCategorizeTransactionFormInitialValues = () => {
const { primaryBranch, recognizedTranasction } =
useCategorizeTransactionBoot();
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
return {
...defaultInitialValues,
/**
* We only care about the fields in the form. Previously unfilled optional
* values such as `notes` come back from the API as null, so remove those
* as well.
*/
...transformToCategorizeForm(
uncategorizedTransaction,
recognizedTranasction,
),
/** Assign the primary branch id as default value. */
branchId: primaryBranch?.id || null,
};
};

View File

@@ -0,0 +1,40 @@
.root {
flex: 1 1 auto;
overflow: auto;
padding-bottom: 60px;
}
.transaction {
}
.matchBar{
padding: 12px 14px;
background: #fff;
border-bottom: 1px solid #E1E2E9;
&:not(:first-of-type) {
border-top: 1px solid #E1E2E9;
}
}
.matchBarTitle {
font-size: 14px;
font-weight: 500;
}
.footer {
background-color: #fff;
}
.footerActions {
padding: 14px 16px;
border-top: 1px solid #d8d9d9;
}
.footerTotal {
padding: 8px 16px;
border-top: 1px solid #d8d9d9;
line-height: 24px;
}

View File

@@ -0,0 +1,45 @@
// @ts-nocheck
import * as R from 'ramda';
import { Aside } from '@/components/Aside/Aside';
import { CategorizeTransactionTabs } from './CategorizeTransactionTabs';
import {
WithBankingActionsProps,
withBankingActions,
} from '../withBankingActions';
import { CategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import { withBanking } from '../withBanking';
interface CategorizeTransactionAsideProps extends WithBankingActionsProps {}
function CategorizeTransactionAsideRoot({
// #withBankingActions
closeMatchingTransactionAside,
// #withBanking
selectedUncategorizedTransactionId,
}: CategorizeTransactionAsideProps) {
const handleClose = () => {
closeMatchingTransactionAside();
};
const uncategorizedTransactionId = selectedUncategorizedTransactionId;
if (!selectedUncategorizedTransactionId) {
return null;
}
return (
<Aside title={'Categorize Bank Transaction'} onClose={handleClose}>
<CategorizeTransactionTabsBoot
uncategorizedTransactionId={uncategorizedTransactionId}
>
<CategorizeTransactionTabs />
</CategorizeTransactionTabsBoot>
</Aside>
);
}
export const CategorizeTransactionAside = R.compose(
withBankingActions,
withBanking(({ selectedUncategorizedTransactionId }) => ({
selectedUncategorizedTransactionId,
})),
)(CategorizeTransactionAsideRoot);

View File

@@ -0,0 +1,23 @@
.tabs :global .bp4-tab-panel{
margin-top: 0;
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: calc(100dvh - 144px);
}
.tabs :global .bp4-tab-list{
background: #fff;
border-bottom: 1px solid #c7d5db;
padding: 0 22px;
}
.tabs :global .bp4-large > .bp4-tab{
font-size: 14px;
}
.tabs {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,33 @@
// @ts-nocheck
import { Tab, Tabs } from '@blueprintjs/core';
import { MatchingBankTransaction } from './MatchingTransaction';
import { CategorizeTransactionContent } from '../CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import styles from './CategorizeTransactionTabs.module.scss';
export function CategorizeTransactionTabs() {
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
const defaultSelectedTabId = uncategorizedTransaction?.is_recognized
? 'categorize'
: 'matching';
return (
<Tabs
large
renderActiveTabPanelOnly
defaultSelectedTabId={defaultSelectedTabId}
className={styles.tabs}
>
<Tab
id="categorize"
title="Categorize Transaction"
panel={<CategorizeTransactionContent />}
/>
<Tab
id="matching"
title="Matching Transaction"
panel={<MatchingBankTransaction />}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,61 @@
// @ts-nocheck
import React from 'react';
import { Spinner } from '@blueprintjs/core';
import { useUncategorizedTransaction } from '@/hooks/query';
interface CategorizeTransactionTabsValue {
uncategorizedTransactionId: number;
isUncategorizedTransactionLoading: boolean;
uncategorizedTransaction: any;
}
interface CategorizeTransactionTabsBootProps {
uncategorizedTransactionId: number;
children: React.ReactNode;
}
const CategorizeTransactionTabsBootContext =
React.createContext<CategorizeTransactionTabsValue>(
{} as CategorizeTransactionTabsValue,
);
/**
* Categorize transcation tabs boot.
*/
export function CategorizeTransactionTabsBoot({
uncategorizedTransactionId,
children,
}: CategorizeTransactionTabsBootProps) {
const {
data: uncategorizedTransaction,
isLoading: isUncategorizedTransactionLoading,
} = useUncategorizedTransaction(uncategorizedTransactionId);
const provider = {
uncategorizedTransactionId,
uncategorizedTransaction,
isUncategorizedTransactionLoading,
};
const isLoading = isUncategorizedTransactionLoading;
// Use a key prop to force re-render of children when uncategorizedTransactionId changes
const childrenPerKey = React.useMemo(() => {
return React.Children.map(children, (child) =>
React.cloneElement(child, { key: uncategorizedTransactionId }),
);
}, [children, uncategorizedTransactionId]);
if (isLoading) {
return <Spinner size={30} />;
}
return (
<CategorizeTransactionTabsBootContext.Provider value={provider}>
{childrenPerKey}
</CategorizeTransactionTabsBootContext.Provider>
);
}
export const useCategorizeTransactionTabsBoot = () =>
React.useContext<CategorizeTransactionTabsValue>(
CategorizeTransactionTabsBootContext,
);

View File

@@ -0,0 +1,45 @@
.root{
background: #fff;
border-radius: 5px;
border: 1px solid #D6DBE3;
padding: 10px 16px;
cursor: pointer;
&.active{
border-color: #88ABDB;
box-shadow: 0 0 0 2px rgba(136, 171, 219, 0.2);
.label,
.date {
color: rgb(21, 82, 200),
}
}
&:hover:not(.active){
border-color: #c0c0c0;
}
}
.checkbox:global(.bp4-control.bp4-checkbox){
margin: 0;
}
.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{
border-color: #CBCBCB;
}
.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{
margin-right: 4px;
margin-left: 0;
height: 16px;
width: 16px;
}
.label {
color: #10161A;
font-size: 15px;
}
.date {
font-size: 12px;
color: #5C7080;
}

View File

@@ -0,0 +1,58 @@
// @ts-nocheck
import clsx from 'classnames';
import { Checkbox, Text } from '@blueprintjs/core';
import { useUncontrolled } from '@/hooks/useUncontrolled';
import { Group, Stack } from '@/components';
import styles from './MatchTransactionCheckbox.module.scss';
export interface MatchTransactionCheckboxProps {
active?: boolean;
initialActive?: boolean;
onChange?: (state: boolean) => void;
label: string;
date: string;
}
export function MatchTransactionCheckbox({
active,
initialActive,
onChange,
label,
date,
}: MatchTransactionCheckboxProps) {
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 className={styles.label}>{label}</span>
<Text className={styles.date}>Date: {date}</Text>
</Stack>
<Checkbox
checked={_active as boolean}
className={styles.checkbox}
onChange={handleCheckboxChange}
/>
</Group>
);
}

View File

@@ -0,0 +1,266 @@
// @ts-nocheck
import { isEmpty } from 'lodash';
import * as R from 'ramda';
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';
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) => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
setSubmitting(false);
});
};
return (
<MatchingTransactionBoot
uncategorizedTransactionId={uncategorizedTransactionId}
>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<>
<MatchingBankTransactionContent />
<MatchTransactionFooter />
</>
</Formik>
</MatchingTransactionBoot>
);
}
export const MatchingBankTransaction = R.compose(withBankingActions)(
MatchingBankTransactionRoot,
);
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>
<Text style={{ fontSize: 12, color: '#5C7080' }}>
Transactions up to 20 Aug 2019
</Text>
</Stack>
</Box>
<Stack spacing={9} style={{ padding: '12px 15px' }}>
{possibleMatches.map((match, index) => (
<MatchTransactionField
key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
date={match.dateFormatted}
transactionId={match.transactionId}
transactionType={match.transactionType}
/>
))}
</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 }: MatchTransctionFooterProps) => {
const { submitForm, isSubmitting } = useFormikContext();
const totalPending = useGetPendingAmountMatched();
const showReconcileLink = useIsShowReconcileTransactionLink();
const submitDisabled = totalPending !== 0;
const handleCancelBtnClick = () => {
closeMatchingTransactionAside();
};
const handleSubmitBtnClick = () => {
submitForm();
};
return (
<Box className={styles.footer}>
<Box className={styles.footerTotal}>
<Group position={'apart'}>
{showReconcileLink && (
<AnchorButton small minimal intent={Intent.PRIMARY}>
Add Reconcile Transaction +
</AnchorButton>
)}
<Text
style={{ fontSize: 14, marginLeft: 'auto', color: '#5F6B7C' }}
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';

View File

@@ -0,0 +1,44 @@
import { defaultTo } from 'lodash';
import React, { createContext } from 'react';
import { useGetBankTransactionsMatches } from '@/hooks/query/bank-rules';
interface MatchingTransactionBootValues {
isMatchingTransactionsLoading: boolean;
possibleMatches: Array<any>;
perfectMatchesCount: number;
perfectMatches: Array<any>;
matches: Array<any>;
}
const RuleFormBootContext = createContext<MatchingTransactionBootValues>(
{} as MatchingTransactionBootValues,
);
interface RuleFormBootProps {
uncategorizedTransactionId: number;
children: React.ReactNode;
}
function MatchingTransactionBoot({
uncategorizedTransactionId,
...props
}: RuleFormBootProps) {
const {
data: matchingTransactions,
isLoading: isMatchingTransactionsLoading,
} = useGetBankTransactionsMatches(uncategorizedTransactionId);
const provider = {
isMatchingTransactionsLoading,
possibleMatches: defaultTo(matchingTransactions?.possibleMatches, []),
perfectMatchesCount: matchingTransactions?.perfectMatches?.length || 0,
perfectMatches: defaultTo(matchingTransactions?.perfectMatches, []),
} 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,56 @@
import { useFormikContext } from 'formik';
import { MatchingTransactionFormValues } from './types';
import { useMatchingTransactionBoot } from './MatchingTransactionBoot';
import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
import { useMemo } from 'react';
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 };
};
export const useGetPendingAmountMatched = () => {
const { values } = useFormikContext<MatchingTransactionFormValues>();
const { perfectMatches, possibleMatches } = useMatchingTransactionBoot();
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
return useMemo(() => {
const matchedItems = [...perfectMatches, ...possibleMatches].filter(
(match) => {
const key = `${match.transactionType}-${match.transactionId}`;
return values.matched[key];
},
);
const totalMatchedAmount = matchedItems.reduce(
(total, item) => total + parseFloat(item.amount),
0,
);
const amount = uncategorizedTransaction.amount;
const pendingAmount = amount - totalMatchedAmount;
return pendingAmount;
}, [uncategorizedTransaction, perfectMatches, possibleMatches, values]);
};
export const useAtleastOneMatchedSelected = () => {
const { values } = useFormikContext<MatchingTransactionFormValues>();
return useMemo(() => {
const matchedCount = Object.values(values.matched).filter(Boolean).length;
return matchedCount > 0;
}, [values]);
};
export const useIsShowReconcileTransactionLink = () => {
const pendingAmount = useGetPendingAmountMatched();
const atleastOneSelected = useAtleastOneMatchedSelected();
return atleastOneSelected && pendingAmount !== 0;
};

View File

@@ -0,0 +1,15 @@
// @ts-nocheck
import { connect } from 'react-redux';
export const withBanking = (mapState) => {
const mapStateToProps = (state, props) => {
const mapped = {
openMatchingTransactionAside: state.plaid.openMatchingTransactionAside,
selectedUncategorizedTransactionId:
state.plaid.uncategorizedTransactionIdForMatching,
};
return mapState ? mapState(mapped, state, props) : mapped;
};
return connect(mapStateToProps);
};

View File

@@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import {
closeMatchingTransactionAside,
setUncategorizedTransactionIdForMatching,
} from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps {
closeMatchingTransactionAside: () => void;
setUncategorizedTransactionIdForMatching: (
uncategorizedTransactionId: number,
) => void;
}
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
closeMatchingTransactionAside: () =>
dispatch(closeMatchingTransactionAside()),
setUncategorizedTransactionIdForMatching: (
uncategorizedTransactionId: number,
) =>
dispatch(
setUncategorizedTransactionIdForMatching(uncategorizedTransactionId),
),
});
export const withBankingActions = connect<
null,
WithBankingActionsProps,
{},
any
>(null, mapDipatchToProps);

View File

@@ -117,6 +117,12 @@ export const handleDeleteErrors = (errors) => {
intent: Intent.DANGER,
});
}
if (errors.find((e) => e.type === 'CANNOT_DELETE_TRANSACTION_MATCHED')) {
AppToaster.show({
intent: Intent.DANGER,
message: 'Cannot delete a transaction matched with a bank transaction.',
});
}
};
export function ActionsMenu({

View File

@@ -0,0 +1,447 @@
// @ts-nocheck
import {
QueryClient,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
import t from './types';
const QUERY_KEY = {
BANK_RULES: 'BANK_RULE',
BANK_TRANSACTION_MATCHES: 'BANK_TRANSACTION_MATCHES',
RECOGNIZED_BANK_TRANSACTION: 'RECOGNIZED_BANK_TRANSACTION',
EXCLUDED_BANK_TRANSACTIONS_INFINITY: 'EXCLUDED_BANK_TRANSACTIONS_INFINITY',
RECOGNIZED_BANK_TRANSACTIONS_INFINITY:
'RECOGNIZED_BANK_TRANSACTIONS_INFINITY',
BANK_ACCOUNT_SUMMARY_META: 'BANK_ACCOUNT_SUMMARY_META',
};
const commonInvalidateQueries = (query: QueryClient) => {
query.invalidateQueries(QUERY_KEY.BANK_RULES);
query.invalidateQueries(QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY);
};
interface CreateBankRuleValues {
value: any;
}
interface CreateBankRuleResponse {}
/**
* Creates a new bank rule.
* @param {UseMutationOptions<CreateBankRuleValues, Error, CreateBankRuleValues>} options -
* @returns {UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues>}
*/
export function useCreateBankRule(
options?: UseMutationOptions<
CreateBankRuleValues,
Error,
CreateBankRuleValues
>,
): UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<CreateBankRuleValues, Error, CreateBankRuleValues>(
(values) =>
apiRequest.post(`/banking/rules`, values).then((res) => res.data),
{
...options,
onSuccess: () => {
commonInvalidateQueries(queryClient);
},
},
);
}
interface EditBankRuleValues {
id: number;
value: any;
}
interface EditBankRuleResponse {}
/**
* Edits the given bank rule.
* @param {UseMutationOptions<EditBankRuleResponse, Error, EditBankRuleValues>} options -
* @returns
*/
export function useEditBankRule(
options?: UseMutationOptions<EditBankRuleResponse, Error, EditBankRuleValues>,
): UseMutationResult<EditBankRuleResponse, Error, EditBankRuleValues> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<EditBankRuleResponse, Error, EditBankRuleValues>(
({ id, value }) => apiRequest.post(`/banking/rules/${id}`, value),
{
...options,
onSuccess: () => {
commonInvalidateQueries(queryClient);
},
},
);
}
interface DeleteBankRuleResponse {}
type DeleteBankRuleValue = number;
/**
* Deletes the given bank rule.
* @param {UseMutationOptions<DeleteBankRuleResponse, Error, DeleteBankRuleValue>} options
* @returns {UseMutationResult<DeleteBankRuleResponse, Error, DeleteBankRuleValue}
*/
export function useDeleteBankRule(
options?: UseMutationOptions<
DeleteBankRuleResponse,
Error,
DeleteBankRuleValue
>,
): UseMutationResult<DeleteBankRuleResponse, Error, DeleteBankRuleValue> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(
(id: number) => apiRequest.delete(`/banking/rules/${id}`),
{
onSuccess: (res, id) => {
commonInvalidateQueries(queryClient);
queryClient.invalidateQueries(
QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries([
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
]);
},
...options,
},
);
}
interface BankRulesResponse {}
/**
* Retrieves all bank rules.
* @param {UseQueryOptions<BankRulesResponse, Error>} params -
* @returns {UseQueryResult<BankRulesResponse, Error>}
*/
export function useBankRules(
options?: UseQueryOptions<BankRulesResponse, Error>,
): UseQueryResult<BankRulesResponse, Error> {
const apiRequest = useApiRequest();
return useQuery<BankRulesResponse, Error>(
[QUERY_KEY.BANK_RULES],
() => apiRequest.get('/banking/rules').then((res) => res.data.bank_rules),
{ ...options },
);
}
interface GetBankRuleRes {}
/**
* Retrieve the given bank rule.
* @param {number} bankRuleId -
* @param {UseQueryOptions<GetBankRuleRes, Error>} options -
* @returns {UseQueryResult<GetBankRuleRes, Error>}
*/
export function useBankRule(
bankRuleId: number,
options?: UseQueryOptions<GetBankRuleRes, Error>,
): UseQueryResult<GetBankRuleRes, Error> {
const apiRequest = useApiRequest();
return useQuery<GetBankRuleRes, Error>(
[QUERY_KEY.BANK_RULES, bankRuleId],
() =>
apiRequest
.get(`/banking/rules/${bankRuleId}`)
.then((res) => res.data.bank_rule),
{ ...options },
);
}
type GetBankTransactionsMatchesValue = number;
interface GetBankTransactionsMatchesResponse {
perfectMatches: Array<any>;
possibleMatches: Array<any>;
}
/**
* Retrieves the bank transactions matches.
* @param {UseQueryOptions<GetBankTransactionsMatchesResponse, Error>} params -
* @returns {UseQueryResult<GetBankTransactionsMatchesResponse, Error>}
*/
export function useGetBankTransactionsMatches(
uncategorizedTransactionId: number,
options?: UseQueryOptions<GetBankTransactionsMatchesResponse, Error>,
): UseQueryResult<GetBankTransactionsMatchesResponse, Error> {
const apiRequest = useApiRequest();
return useQuery<GetBankTransactionsMatchesResponse, Error>(
[QUERY_KEY.BANK_TRANSACTION_MATCHES, uncategorizedTransactionId],
() =>
apiRequest
.get(`/cashflow/transactions/${uncategorizedTransactionId}/matches`)
.then((res) => transformToCamelCase(res.data)),
options,
);
}
type ExcludeUncategorizedTransactionValue = number;
interface ExcludeUncategorizedTransactionRes {}
/**
* Excludes the given uncategorized transaction.
* @param {UseMutationOptions<ExcludeUncategorizedTransactionRes, Error, ExcludeUncategorizedTransactionValue>}
* @returns {UseMutationResult<ExcludeUncategorizedTransactionRes, Error, ExcludeUncategorizedTransactionValue> }
*/
export function useExcludeUncategorizedTransaction(
options?: UseMutationOptions<
ExcludeUncategorizedTransactionRes,
Error,
ExcludeUncategorizedTransactionValue
>,
): UseMutationResult<
ExcludeUncategorizedTransactionRes,
Error,
ExcludeUncategorizedTransactionValue
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
ExcludeUncategorizedTransactionRes,
Error,
ExcludeUncategorizedTransactionValue
>(
(uncategorizedTransactionId: number) =>
apiRequest.put(
`/cashflow/transactions/${uncategorizedTransactionId}/exclude`,
),
{
onSuccess: (res, id) => {
// Invalidate queries.
queryClient.invalidateQueries(
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
},
...options,
},
);
}
type ExcludeBankTransactionValue = number;
interface ExcludeBankTransactionResponse {}
/**
* Excludes the uncategorized bank transaction.
* @param {UseMutationResult<ExcludeBankTransactionResponse, Error, ExcludeBankTransactionValue>} options
* @returns {UseMutationResult<ExcludeBankTransactionResponse, Error, ExcludeBankTransactionValue>}
*/
export function useUnexcludeUncategorizedTransaction(
options?: UseMutationOptions<
ExcludeBankTransactionResponse,
Error,
ExcludeBankTransactionValue
>,
): UseMutationResult<
ExcludeBankTransactionResponse,
Error,
ExcludeBankTransactionValue
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
ExcludeBankTransactionResponse,
Error,
ExcludeBankTransactionValue
>(
(uncategorizedTransactionId: number) =>
apiRequest.put(
`/cashflow/transactions/${uncategorizedTransactionId}/unexclude`,
),
{
onSuccess: (res, id) => {
// Invalidate queries.
queryClient.invalidateQueries(
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
},
...options,
},
);
}
interface MatchUncategorizedTransactionValues {
id: number;
value: any;
}
interface MatchUncategorizedTransactionRes {}
/**
* Matchess the given uncateogrized transaction.
* @param props
* @returns
*/
export function useMatchUncategorizedTransaction(
props?: UseMutationOptions<
MatchUncategorizedTransactionRes,
Error,
MatchUncategorizedTransactionValues
>,
): UseMutationResult<
MatchUncategorizedTransactionRes,
Error,
MatchUncategorizedTransactionValues
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
MatchUncategorizedTransactionRes,
Error,
MatchUncategorizedTransactionValues
>(({ id, value }) => apiRequest.post(`/banking/matches/${id}`, value), {
onSuccess: (res, id) => {
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
},
...props,
});
}
interface GetRecognizedBankTransactionRes {}
/**
* REtrieves the given recognized bank transaction.
* @param {number} uncategorizedTransactionId
* @param {UseQueryOptions<GetRecognizedBankTransactionRes, Error>} options
* @returns {UseQueryResult<GetRecognizedBankTransactionRes, Error>}
*/
export function useGetRecognizedBankTransaction(
uncategorizedTransactionId: number,
options?: UseQueryOptions<GetRecognizedBankTransactionRes, Error>,
): UseQueryResult<GetRecognizedBankTransactionRes, Error> {
const apiRequest = useApiRequest();
return useQuery<GetRecognizedBankTransactionRes, Error>(
[QUERY_KEY.RECOGNIZED_BANK_TRANSACTION, uncategorizedTransactionId],
() =>
apiRequest
.get(`/banking/recognized/transactions/${uncategorizedTransactionId}`)
.then((res) => transformToCamelCase(res.data?.data)),
options,
);
}
interface GetBankAccountSummaryMetaRes {
name: string;
totalUncategorizedTransactions: number;
totalRecognizedTransactions: number;
}
/**
* Get the given bank account meta summary.
* @param {number} bankAccountId
* @param {UseQueryOptions<GetBankAccountSummaryMetaRes, Error>} options
* @returns {UseQueryResult<GetBankAccountSummaryMetaRes, Error>}
*/
export function useGetBankAccountSummaryMeta(
bankAccountId: number,
options?: UseQueryOptions<GetBankAccountSummaryMetaRes, Error>,
): UseQueryResult<GetBankAccountSummaryMetaRes, Error> {
const apiRequest = useApiRequest();
return useQuery<GetBankAccountSummaryMetaRes, Error>(
[QUERY_KEY.BANK_ACCOUNT_SUMMARY_META, bankAccountId],
() =>
apiRequest
.get(`/banking/bank_accounts/${bankAccountId}/meta`)
.then((res) => transformToCamelCase(res.data?.data)),
{ ...options },
);
}
/**
* @returns
*/
export function useRecognizedBankTransactionsInfinity(
query,
infinityProps,
axios,
) {
const apiRequest = useApiRequest();
return useInfiniteQuery(
[QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY, query],
async ({ pageParam = 1 }) => {
const response = await apiRequest.http({
...axios,
method: 'get',
url: `/api/banking/recognized`,
params: { page: pageParam, ...query },
});
return response.data;
},
{
getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1,
getNextPageParam: (lastPage) => {
const { pagination } = lastPage;
return pagination.total > pagination.page_size * pagination.page
? lastPage.pagination.page + 1
: undefined;
},
...infinityProps,
},
);
}
export function useExcludedBankTransactionsInfinity(
query,
infinityProps,
axios,
) {
const apiRequest = useApiRequest();
return useInfiniteQuery(
[QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY, query],
async ({ pageParam = 1 }) => {
const response = await apiRequest.http({
...axios,
method: 'get',
url: `/api/cashflow/excluded`,
params: { page: pageParam, ...query },
});
return response.data;
},
{
getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1,
getNextPageParam: (lastPage) => {
const { pagination } = lastPage;
return pagination.total > pagination.page_size * pagination.page
? lastPage.pagination.page + 1
: undefined;
},
...infinityProps,
},
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
interface UseUncontrolledInput<T> {
/** Value for controlled state */
@@ -19,7 +19,7 @@ export function useUncontrolled<T>({
initialValue,
finalValue,
onChange = () => {},
}: UseUncontrolledInput<T>) {
}: UseUncontrolledInput<T>): [T, (value: T) => void, boolean] {
const [uncontrolledValue, setUncontrolledValue] = useState(
initialValue !== undefined ? initialValue : finalValue,
);

View File

@@ -1221,6 +1221,16 @@ export const getDashboardRoutes = () => [
pageTitle: 'Tax Rates',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
// Bank Rules
{
path: '/bank-rules',
component: lazy(
() => import('@/containers/Banking/Rules/RulesList/RulesLandingPage'),
),
pageTitle: 'Bank Rules',
breadcrumb: 'Bank Rules',
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
// Homepage
{
path: `/`,

View File

@@ -605,4 +605,28 @@ export default {
],
viewBox: '0 0 16 16',
},
smallCross: {
path: [
'M9.41,8l3.29-3.29C12.89,4.53,13,4.28,13,4c0-0.55-0.45-1-1-1c-0.28,0-0.53,0.11-0.71,0.29L8,6.59L4.71,3.29C4.53,3.11,4.28,3,4,3C3.45,3,3,3.45,3,4c0,0.28,0.11,0.53,0.29,0.71L6.59,8l-3.29,3.29C3.11,11.47,3,11.72,3,12c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29L8,9.41l3.29,3.29C11.47,12.89,11.72,13,12,13c0.55,0,1-0.45,1-1c0-0.28-0.11-0.53-0.29-0.71L9.41,8z',
],
viewBox: '0 0 16 16',
},
arrowRight: {
path: [
'M14.7,7.29l-5-5C9.52,2.1,9.27,1.99,8.99,1.99c-0.55,0-1,0.45-1,1c0,0.28,0.11,0.53,0.29,0.71l3.29,3.29H1.99c-0.55,0-1,0.45-1,1s0.45,1,1,1h9.59l-3.29,3.29c-0.18,0.18-0.29,0.43-0.29,0.71c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l5-5c0.18-0.18,0.29-0.43,0.29-0.71S14.88,7.47,14.7,7.29z',
],
viewBox: '0 0 16 16',
},
disable: {
path: [
'M7.99-0.01c-4.42,0-8,3.58-8,8s3.58,8,8,8s8-3.58,8-8S12.41-0.01,7.99-0.01zM1.99,7.99c0-3.31,2.69-6,6-6c1.3,0,2.49,0.42,3.47,1.12l-8.35,8.35C2.41,10.48,1.99,9.29,1.99,7.99z M7.99,13.99c-1.3,0-2.49-0.42-3.47-1.12l8.35-8.35c0.7,0.98,1.12,2.17,1.12,3.47C13.99,11.31,11.31,13.99,7.99,13.99z',
],
viewBox: '0 0 16 16',
},
redo: {
path: [
'M4,11c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S5.1,11,4,11z M11,4H3.41l1.29-1.29C4.89,2.53,5,2.28,5,2c0-0.55-0.45-1-1-1C3.72,1,3.47,1.11,3.29,1.29l-3,3C0.11,4.47,0,4.72,0,5c0,0.28,0.11,0.53,0.29,0.71l3,3C3.47,8.89,3.72,9,4,9c0.55,0,1-0.45,1-1c0-0.28-0.11-0.53-0.29-0.71L3.41,6H11c1.66,0,3,1.34,3,3s-1.34,3-3,3H7v2h4c2.76,0,5-2.24,5-5S13.76,4,11,4z',
],
viewBox: '0 0 16 16',
},
};

View File

@@ -2,22 +2,46 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
interface StorePlaidState {
plaidToken: string;
openMatchingTransactionAside: boolean;
uncategorizedTransactionIdForMatching: number | null;
}
export const PlaidSlice = createSlice({
name: 'plaid',
initialState: {
plaidToken: '',
openMatchingTransactionAside: false,
uncategorizedTransactionIdForMatching: null,
} as StorePlaidState,
reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
state.plaidToken = action.payload;
},
resetPlaidId: (state: StorePlaidState) => {
state.plaidToken = '';
}
},
setUncategorizedTransactionIdForMatching: (
state: StorePlaidState,
action: PayloadAction<number>,
) => {
state.openMatchingTransactionAside = true;
state.uncategorizedTransactionIdForMatching = action.payload;
},
closeMatchingTransactionAside: (state: StorePlaidState) => {
state.openMatchingTransactionAside = false;
state.uncategorizedTransactionIdForMatching = null;
},
},
});
export const { setPlaidId, resetPlaidId } = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken;
export const {
setPlaidId,
resetPlaidId,
setUncategorizedTransactionIdForMatching,
closeMatchingTransactionAside,
} = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken;