diff --git a/src/common/moreVertOptions.js b/src/common/moreVertOptions.js new file mode 100644 index 000000000..d3a2564c8 --- /dev/null +++ b/src/common/moreVertOptions.js @@ -0,0 +1,12 @@ +import intl from 'react-intl-universal'; + +export const moreVertOptions = [ + { + name: intl.get('bad_debt.dialog.bad_debt'), + value: 'bad debt', + }, + { + name: intl.get('bad_debt.dialog.cancel_bad_debt'), + value: 'cancel bad debt', + }, +]; diff --git a/src/components/DialogsContainer.js b/src/components/DialogsContainer.js index 07c00ca6f..1a31006dc 100644 --- a/src/components/DialogsContainer.js +++ b/src/components/DialogsContainer.js @@ -19,6 +19,7 @@ import EstimatePdfPreviewDialog from 'containers/Dialogs/EstimatePdfPreviewDialo import ReceiptPdfPreviewDialog from '../containers/Dialogs/ReceiptPdfPreviewDialog'; import MoneyInDialog from '../containers/Dialogs/MoneyInDialog'; import MoneyOutDialog from '../containers/Dialogs/MoneyOutDialog'; +import BadDebtDialog from '../containers/Dialogs/BadDebtDialog'; /** * Dialogs container. @@ -44,6 +45,7 @@ export default function DialogsContainer() { + ); } diff --git a/src/components/MoreVertMenutItems.js b/src/components/MoreVertMenutItems.js new file mode 100644 index 000000000..9282f33c1 --- /dev/null +++ b/src/components/MoreVertMenutItems.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { + Button, + PopoverInteractionKind, + MenuItem, + Position, +} from '@blueprintjs/core'; + +import { Select } from '@blueprintjs/select'; +import { Icon } from 'components'; + +function MoreVertMenutItems({ text, items, onItemSelect, buttonProps }) { + // Menu items renderer. + const itemsRenderer = (item, { handleClick, modifiers, query }) => ( + + ); + const handleMenuSelect = (type) => { + onItemSelect && onItemSelect(type); + }; + + return ( + + ); +} + +export default MoreVertMenutItems; diff --git a/src/components/index.js b/src/components/index.js index 34388e246..aa156307e 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -61,6 +61,7 @@ import Card from './Card'; import AvaterCell from './AvaterCell'; import { ItemsMultiSelect } from './Items'; +import MoreVertMenutItems from './MoreVertMenutItems'; export * from './Menu'; export * from './AdvancedFilter/AdvancedFilterDropdown'; @@ -85,6 +86,7 @@ export * from './BankAccounts'; export * from './IntersectionObserver' export * from './Datatable/CellForceWidth'; export * from './Button'; +export * from './IntersectionObserver'; const Hint = FieldHint; @@ -156,4 +158,5 @@ export { ItemsMultiSelect, Card, AvaterCell, + MoreVertMenutItems, }; diff --git a/src/containers/Alerts/Invoices/CancelBadDebtAlert.js b/src/containers/Alerts/Invoices/CancelBadDebtAlert.js new file mode 100644 index 000000000..2e1a914cd --- /dev/null +++ b/src/containers/Alerts/Invoices/CancelBadDebtAlert.js @@ -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 ( + } + confirmButtonText={} + intent={Intent.WARNING} + isOpen={isOpen} + onCancel={handleCancel} + onConfirm={handleConfirm} + loading={isLoading} + > +

+ +

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, +)(CancelBadDebtAlert); diff --git a/src/containers/Dialogs/BadDebtDialog/BadDebtDialogContent.js b/src/containers/Dialogs/BadDebtDialog/BadDebtDialogContent.js new file mode 100644 index 000000000..1e4b205b0 --- /dev/null +++ b/src/containers/Dialogs/BadDebtDialog/BadDebtDialogContent.js @@ -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 ( + + + + ); +} diff --git a/src/containers/Dialogs/BadDebtDialog/BadDebtForm.js b/src/containers/Dialogs/BadDebtDialog/BadDebtForm.js new file mode 100644 index 000000000..8e521e6f2 --- /dev/null +++ b/src/containers/Dialogs/BadDebtDialog/BadDebtForm.js @@ -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 ( + + ); +} + +export default compose( + withDialogActions, + withCurrentOrganization(), +)(BadDebtForm); diff --git a/src/containers/Dialogs/BadDebtDialog/BadDebtForm.schema.js b/src/containers/Dialogs/BadDebtDialog/BadDebtForm.schema.js new file mode 100644 index 000000000..dc3d00f61 --- /dev/null +++ b/src/containers/Dialogs/BadDebtDialog/BadDebtForm.schema.js @@ -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; diff --git a/src/containers/Dialogs/BadDebtDialog/BadDebtFormContent.js b/src/containers/Dialogs/BadDebtDialog/BadDebtFormContent.js new file mode 100644 index 000000000..568c04a8c --- /dev/null +++ b/src/containers/Dialogs/BadDebtDialog/BadDebtFormContent.js @@ -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 ( +
+ + + + ); +} diff --git a/src/containers/Dialogs/BadDebtDialog/BadDebtFormFields.js b/src/containers/Dialogs/BadDebtDialog/BadDebtFormFields.js new file mode 100644 index 000000000..a5bc69d07 --- /dev/null +++ b/src/containers/Dialogs/BadDebtDialog/BadDebtFormFields.js @@ -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 ( +
+ +

+ +

+
+ + {/*------------ Written-off amount -----------*/} + + {({ + form: { values, setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + labelInfo={} + className={classNames('form-group--amount', CLASSES.FILL)} + intent={inputIntent({ error, touched })} + helperText={} + > + + + + { + setFieldValue('amount', amount); + }} + intent={inputIntent({ error, touched })} + disabled={amountfieldRef} + /> + + + )} + + {/*------------ Expense account -----------*/} + + {({ form, field: { value }, meta: { error, touched } }) => ( + } + className={classNames( + 'form-group--expense_account_id', + 'form-group--select-list', + CLASSES.FILL, + )} + labelInfo={} + intent={inputIntent({ error, touched })} + helperText={} + > + + form.setFieldValue('expense_account_id', id) + } + filterByTypes={[ACCOUNT_TYPE.EXPENSE]} + /> + + )} + + {/*------------ reason -----------*/} + + {({ field, meta: { error, touched } }) => ( + } + labelInfo={} + className={'form-group--reason'} + intent={inputIntent({ error, touched })} + helperText={} + > +