mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 13:20:31 +00:00
Merge pull request #511 from bigcapitalhq/BIG-208
feat: Bank rules for uncategorized transactions
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AppContentShell';
|
||||
1
packages/webapp/src/components/AppShell/index.ts
Normal file
1
packages/webapp/src/components/AppShell/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './AppContentShell';
|
||||
24
packages/webapp/src/components/Aside/Aside.module.scss
Normal file
24
packages/webapp/src/components/Aside/Aside.module.scss
Normal 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;
|
||||
}
|
||||
40
packages/webapp/src/components/Aside/Aside.tsx
Normal file
40
packages/webapp/src/components/Aside/Aside.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
packages/webapp/src/components/TagsControl/TagsControl.tsx
Normal file
42
packages/webapp/src/components/TagsControl/TagsControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
packages/webapp/src/components/TagsControl/index.ts
Normal file
1
packages/webapp/src/components/TagsControl/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './TagsControl';
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -75,4 +75,5 @@ export enum DialogsName {
|
||||
GeneralLedgerPdfPreview = 'GeneralLedgerPdfPreview',
|
||||
SalesTaxLiabilitySummaryPdfPreview = 'SalesTaxLiabilitySummaryPdfPreview',
|
||||
Export = 'Export',
|
||||
BankRuleForm = 'BankRuleForm'
|
||||
}
|
||||
|
||||
@@ -458,6 +458,11 @@ export const SidebarMenu = [
|
||||
ability: CashflowAction.View,
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Rules',
|
||||
href: '/bank-rules',
|
||||
type: ISidebarMenuItemType.Link,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
20
packages/webapp/src/containers/Alerts/Expenses/_utils.ts
Normal file
20
packages/webapp/src/containers/Alerts/Expenses/_utils.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
11
packages/webapp/src/containers/Alerts/PaymentMades/_utils.ts
Normal file
11
packages/webapp/src/containers/Alerts/PaymentMades/_utils.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
.root{
|
||||
max-width: 600px;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { RulesList } from './RulesList';
|
||||
|
||||
export default RulesList;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MatchingTransactionFormValues {
|
||||
matched: Record<string, boolean>;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
15
packages/webapp/src/containers/CashFlow/withBanking.ts
Normal file
15
packages/webapp/src/containers/CashFlow/withBanking.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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({
|
||||
|
||||
447
packages/webapp/src/hooks/query/bank-rules.ts
Normal file
447
packages/webapp/src/hooks/query/bank-rules.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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: `/`,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user