Merge branch 'feature/BadDebt'

This commit is contained in:
elforjani13
2021-11-02 15:00:16 +02:00
26 changed files with 725 additions and 13 deletions

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { FormattedMessage as T } from 'components';
import intl from 'react-intl-universal';
import { Intent, Alert } from '@blueprintjs/core';
import { AppToaster } from 'components';
import { useCancelBadDebt } from 'hooks/query';
import withAlertStoreConnect from 'containers/Alert/withAlertStoreConnect';
import withAlertActions from 'containers/Alert/withAlertActions';
import { compose } from 'utils';
/**
* Cancel bad debt alert.
*/
function CancelBadDebtAlert({
name,
// #withAlertStoreConnect
isOpen,
payload: { invoiceId },
// #withAlertActions
closeAlert,
}) {
// handle cancel alert.
const handleCancel = () => {
closeAlert(name);
};
const { mutateAsync: cancelBadDebtMutate, isLoading } = useCancelBadDebt();
// handleConfirm alert.
const handleConfirm = () => {
cancelBadDebtMutate(invoiceId)
.then(() => {
AppToaster.show({
message: intl.get('bad_debt.cancel_alert.success_message'),
intent: Intent.SUCCESS,
});
})
.catch(() => {})
.finally(() => {
closeAlert(name);
});
};
return (
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={<T id={'save'} />}
intent={Intent.WARNING}
isOpen={isOpen}
onCancel={handleCancel}
onConfirm={handleConfirm}
loading={isLoading}
>
<p>
<T id={'bad_debt.cancel_alert.message'} />
</p>
</Alert>
);
}
export default compose(
withAlertStoreConnect(),
withAlertActions,
)(CancelBadDebtAlert);

View File

@@ -0,0 +1,20 @@
import React from 'react';
import 'style/pages/BadDebt/BadDebtDialog.scss';
import { BadDebtFormProvider } from './BadDebtFormProvider';
import BadDebtForm from './BadDebtForm';
/**
* Bad debt dialog content.
*/
export default function BadDebtDialogContent({
// #ownProps
dialogName,
invoice,
}) {
return (
<BadDebtFormProvider invoiceId={invoice} dialogName={dialogName}>
<BadDebtForm />
</BadDebtFormProvider>
);
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import { Intent } from '@blueprintjs/core';
import { omit } from 'lodash';
import { AppToaster } from 'components';
import { CreateBadDebtFormSchema } from './BadDebtForm.schema';
import { transformErrors } from './utils';
import BadDebtFormContent from './BadDebtFormContent';
import withDialogActions from 'containers/Dialog/withDialogActions';
import withCurrentOrganization from 'containers/Organization/withCurrentOrganization';
import { useBadDebtContext } from './BadDebtFormProvider';
import { compose } from 'utils';
const defaultInitialValues = {
expense_account_id: '',
reason: '',
amount: '',
};
function BadDebtForm({
// #withDialogActions
closeDialog,
// #withCurrentOrganization
organization: { base_currency },
}) {
const { invoice, dialogName, createBadDebtMutate, cancelBadDebtMutate } =
useBadDebtContext();
// Initial form values
const initialValues = {
...defaultInitialValues,
currency_code: base_currency,
amount: invoice.due_amount,
};
// Handles the form submit.
const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
const form = {
...omit(values, ['currency_code']),
};
// Handle request response success.
const onSuccess = (response) => {
AppToaster.show({
message: intl.get('bad_debt.dialog.success_message'),
intent: Intent.SUCCESS,
});
closeDialog(dialogName);
};
// Handle request response errors.
const onError = ({
response: {
data: { errors },
},
}) => {
if (errors) {
transformErrors(errors, { setErrors });
}
setSubmitting(false);
};
createBadDebtMutate([invoice.id, form]).then(onSuccess).catch(onError);
};
return (
<Formik
validationSchema={CreateBadDebtFormSchema}
initialValues={initialValues}
onSubmit={handleFormSubmit}
component={BadDebtFormContent}
/>
);
}
export default compose(
withDialogActions,
withCurrentOrganization(),
)(BadDebtForm);

View File

@@ -0,0 +1,17 @@
import * as Yup from 'yup';
import intl from 'react-intl-universal';
import { DATATYPES_LENGTH } from 'common/dataTypes';
const Schema = Yup.object().shape({
expense_account_id: Yup.number()
.required()
.label(intl.get('expense_account_id')),
amount: Yup.number().required().label(intl.get('amount')),
reason: Yup.string()
.required()
.min(3)
.max(DATATYPES_LENGTH.TEXT)
.label(intl.get('reason')),
});
export const CreateBadDebtFormSchema = Schema;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Form } from 'formik';
import BadDebtFormFields from './BadDebtFormFields';
import BadDebtFormFloatingActions from './BadDebtFormFloatingActions';
/**
* Bad debt form content.
*/
export default function BadDebtFormContent() {
return (
<Form>
<BadDebtFormFields />
<BadDebtFormFloatingActions />
</Form>
);
}

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { FastField, ErrorMessage } from 'formik';
import { FormattedMessage as T } from 'components';
import { useAutofocus } from 'hooks';
import {
Classes,
FormGroup,
TextArea,
ControlGroup,
Callout,
Intent,
} from '@blueprintjs/core';
import classNames from 'classnames';
import { CLASSES } from 'common/classes';
import { ACCOUNT_TYPE } from 'common/accountTypes';
import { inputIntent } from 'utils';
import {
AccountsSuggestField,
InputPrependText,
MoneyInputGroup,
FieldRequiredHint,
} from 'components';
import { useBadDebtContext } from './BadDebtFormProvider';
/**
* Bad debt form fields.
*/
function BadDebtFormFields() {
const amountfieldRef = useAutofocus();
const { accounts } = useBadDebtContext();
return (
<div className={Classes.DIALOG_BODY}>
<Callout intent={Intent.PRIMARY}>
<p>
<T id={' bad_debt.dialog.header_note'} />
</p>
</Callout>
{/*------------ Written-off amount -----------*/}
<FastField name={'amount'}>
{({
form: { values, setFieldValue },
field: { value },
meta: { error, touched },
}) => (
<FormGroup
label={<T id={'bad_debt.dialog.written_off_amount'} />}
labelInfo={<FieldRequiredHint />}
className={classNames('form-group--amount', CLASSES.FILL)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="amount" />}
>
<ControlGroup>
<InputPrependText text={values.currency_code} />
<MoneyInputGroup
value={value}
minimal={true}
onChange={(amount) => {
setFieldValue('amount', amount);
}}
intent={inputIntent({ error, touched })}
disabled={amountfieldRef}
/>
</ControlGroup>
</FormGroup>
)}
</FastField>
{/*------------ Expense account -----------*/}
<FastField name={'expense_account_id'}>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'expense_account_id'} />}
className={classNames(
'form-group--expense_account_id',
'form-group--select-list',
CLASSES.FILL,
)}
labelInfo={<FieldRequiredHint />}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name={'expense_account_id'} />}
>
<AccountsSuggestField
selectedAccountId={value}
accounts={accounts}
onAccountSelected={({ id }) =>
form.setFieldValue('expense_account_id', id)
}
filterByTypes={[ACCOUNT_TYPE.EXPENSE]}
/>
</FormGroup>
)}
</FastField>
{/*------------ reason -----------*/}
<FastField name={'reason'}>
{({ field, meta: { error, touched } }) => (
<FormGroup
label={<T id={'reason'} />}
labelInfo={<FieldRequiredHint />}
className={'form-group--reason'}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name={'reason'} />}
>
<TextArea
growVertically={true}
large={true}
intent={inputIntent({ error, touched })}
{...field}
/>
</FormGroup>
)}
</FastField>
</div>
);
}
export default BadDebtFormFields;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Intent, Button, Classes } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import { FormattedMessage as T } from 'components';
import { useBadDebtContext } from './BadDebtFormProvider';
import withDialogActions from 'containers/Dialog/withDialogActions';
import { compose } from 'utils';
/**
* Bad bebt form floating actions.
*/
function BadDebtFormFloatingActions({
// #withDialogActions
closeDialog,
}) {
// bad debt invoice dialog context.
const { dialogName } = useBadDebtContext();
// Formik context.
const { isSubmitting } = useFormikContext();
// Handle close button click.
const handleCancelBtnClick = () => {
closeDialog(dialogName);
};
return (
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={handleCancelBtnClick} style={{ minWidth: '75px' }}>
<T id={'cancel'} />
</Button>
<Button
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: '75px' }}
type="submit"
>
{<T id={'save'} />}
</Button>
</div>
</div>
);
}
export default compose(withDialogActions)(BadDebtFormFloatingActions);

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { DialogContent } from 'components';
import {
useAccounts,
useInvoice,
useCreateBadDebt,
useCancelBadDebt,
} from 'hooks/query';
const BadDebtContext = React.createContext();
/**
* Bad debt provider.
*/
function BadDebtFormProvider({ invoiceId, dialogName, ...props }) {
// Handle fetch accounts data.
const { data: accounts, isLoading: isAccountsLoading } = useAccounts();
// Handle fetch invoice data.
const { data: invoice, isLoading: isInvoiceLoading } = useInvoice(invoiceId, {
enabled: !!invoiceId,
});
// Create and cancel bad debt mutations.
const { mutateAsync: createBadDebtMutate } = useCreateBadDebt();
// State provider.
const provider = {
accounts,
invoice,
invoiceId,
dialogName,
createBadDebtMutate,
};
return (
<DialogContent isLoading={isAccountsLoading || isInvoiceLoading}>
<BadDebtContext.Provider value={provider} {...props} />
</DialogContent>
);
}
const useBadDebtContext = () => React.useContext(BadDebtContext);
export { BadDebtFormProvider, useBadDebtContext };

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { FormattedMessage as T } from 'components';
import { Dialog, DialogSuspense } from 'components';
import withDialogRedux from 'components/DialogReduxConnect';
import { compose } from 'redux';
const BadDebtDialogContent = React.lazy(() => import('./BadDebtDialogContent'));
/**
* Bad debt dialog.
*/
function BadDebtDialog({ dialogName, payload: { invoiceId = null }, isOpen }) {
return (
<Dialog
name={dialogName}
title={<T id={'bad_debt.dialog.bad_debt'} />}
isOpen={isOpen}
canEscapeJeyClose={true}
autoFocus={true}
className={'dialog--bad-debt'}
>
<DialogSuspense>
<BadDebtDialogContent dialogName={dialogName} invoice={invoiceId} />
</DialogSuspense>
</Dialog>
);
}
export default compose(withDialogRedux())(BadDebtDialog);

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Intent } from '@blueprintjs/core';
import { AppToaster } from 'components';
/**
* Transformes the response errors types.
*/
export const transformErrors = (errors, { setErrors }) => {
if (errors.some(({ type }) => type === 'SALE_INVOICE_ALREADY_WRITTEN_OFF')) {
AppToaster.show({
message: 'SALE_INVOICE_ALREADY_WRITTEN_OFF',
// message: intl.get(''),
intent: Intent.DANGER,
});
}
};

View File

@@ -38,6 +38,11 @@ export default function BillDrawerDetails() {
id={'landed_cost'}
panel={<LocatedLandedCostTable />}
/>
{/* <Tab
title={intl.get('payment_transactions')}
id={'payment_transactions'}
// panel={}
/> */}
</DrawerMainTabs>
</div>
);

View File

@@ -30,6 +30,11 @@ export default function InvoiceDetail() {
id={'journal_entries'}
panel={<JournalEntriesTable transactions={transactions} />}
/>
{/* <Tab
title={intl.get('payment_transactions')}
id={'payment_transactions'}
// panel={}
/> */}
</DrawerMainTabs>
</div>
);

View File

@@ -6,20 +6,33 @@ import {
NavbarGroup,
Classes,
NavbarDivider,
Popover,
PopoverInteractionKind,
Position,
Intent,
MenuItem,
Menu,
} from '@blueprintjs/core';
import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar';
import { useInvoiceDetailDrawerContext } from './InvoiceDetailDrawerProvider';
import { moreVertOptions } from '../../../common/moreVertOptions';
import withDialogActions from 'containers/Dialog/withDialogActions';
import withAlertsActions from 'containers/Alert/withAlertActions';
import withDrawerActions from 'containers/Drawer/withDrawerActions';
import { If, Icon, FormattedMessage as T } from 'components';
import {
If,
Icon,
FormattedMessage as T,
// MoreVertMenutItems,
} from 'components';
import { compose } from 'utils';
import { BadDebtMenuItem } from './utils';
/**
* Invoice details action bar.
*/
@@ -59,6 +72,16 @@ function InvoiceDetailActionsBar({
openDialog('quick-payment-receive', { invoiceId });
};
// Handle write-off invoice.
const handleBadDebtInvoice = () => {
openDialog('write-off-bad-debt', { invoiceId });
};
// Handle cancele write-off invoice.
const handleCancelBadDebtInvoice = () => {
openAlert('cancel-bad-debt', { invoiceId });
};
return (
<DashboardActionsBar>
<NavbarGroup>
@@ -91,6 +114,12 @@ function InvoiceDetailActionsBar({
intent={Intent.DANGER}
onClick={handleDeleteInvoice}
/>
<NavbarDivider />
<BadDebtMenuItem
invoice={invoice}
onAlert={handleCancelBadDebtInvoice}
onDialog={handleBadDebtInvoice}
/>
</NavbarGroup>
</DashboardActionsBar>
);

View File

@@ -1,5 +1,15 @@
import React from 'react';
import intl from 'react-intl-universal';
import {
Button,
Popover,
PopoverInteractionKind,
Position,
MenuItem,
Menu,
} from '@blueprintjs/core';
import { If, Icon, FormattedMessage as T, Choose } from 'components';
import { FormatNumberCell } from '../../../components';
/**
@@ -48,3 +58,37 @@ export const useInvoiceReadonlyEntriesColumns = () =>
],
[],
);
export const BadDebtMenuItem = ({ invoice, onDialog, onAlert }) => {
return (
<Popover
minimal={true}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
modifiers={{
offset: { offset: '0, 4' },
}}
content={
<Menu>
<Choose>
<Choose.When condition={!invoice.is_writtenoff}>
<MenuItem
text={<T id={'bad_debt.dialog.bad_debt'} />}
onClick={onDialog}
/>
</Choose.When>
<Choose.When condition={invoice.is_writtenoff}>
<MenuItem
onClick={onAlert}
text={<T id={'bad_debt.dialog.cancel_bad_debt'} />}
/>
</Choose.When>
</Choose>
</Menu>
}
position={Position.BOTTOM}
>
<Button icon={<Icon icon="more-vert" iconSize={16} />} minimal={true} />
</Popover>
);
};

View File

@@ -9,7 +9,7 @@ const columnsMapper = (data, index, column) => ({
id: column.key,
key: column.key,
Header: column.label,
Cell: CellForceWidth,
// Cell: CellForceWidth,
accessor: `cells[${index}].value`,
forceWidthAccess: `cells[0].value`,
className: column.key,

View File

@@ -7,10 +7,15 @@ const InvoiceDeliverAlert = React.lazy(() =>
import('../../Alerts/Invoices/InvoiceDeliverAlert'),
);
const CancelBadDebtAlert = React.lazy(() =>
import('../../Alerts/Invoices/CancelBadDebtAlert'),
);
/**
* Invoices alert.
*/
export default [
{ name: 'invoice-delete', component: InvoiceDeleteAlert },
{ name: 'invoice-deliver', component: InvoiceDeliverAlert },
{ name: 'cancel-bad-debt', component: CancelBadDebtAlert },
];