diff --git a/src/components/DialogsContainer.js b/src/components/DialogsContainer.js index 8ea35efde..656c7d9d8 100644 --- a/src/components/DialogsContainer.js +++ b/src/components/DialogsContainer.js @@ -29,6 +29,7 @@ import TransactionsLockingDialog from '../containers/Dialogs/TransactionsLocking import RefundCreditNoteDialog from '../containers/Dialogs/RefundCreditNoteDialog'; import RefundVendorCreditDialog from '../containers/Dialogs/RefundVendorCreditDialog'; import ReconcileCreditNoteDialog from '../containers/Dialogs/ReconcileCreditNoteDialog'; +import ReconcileVendorCreditDialog from '../containers/Dialogs/ReconcileVendorCreditDialog'; /** * Dialogs container. @@ -66,6 +67,7 @@ export default function DialogsContainer() { + ); } diff --git a/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteForm.js b/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteForm.js index a238911a3..6811e2170 100644 --- a/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteForm.js +++ b/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteForm.js @@ -51,7 +51,7 @@ function ReconcileCreditNoteForm({ // Filters the entries. const entries = values.entries - .filter((entry) => entry.id && entry.amount) + .filter((entry) => entry.invoice_id && entry.amount) .map((entry) => transformToForm(entry, defaultInitialValues.entries[0])); const form = { diff --git a/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteFormFloatingActions.js b/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteFormFloatingActions.js index e885d0325..9fb415500 100644 --- a/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteFormFloatingActions.js +++ b/src/containers/Dialogs/ReconcileCreditNoteDialog/ReconcileCreditNoteFormFloatingActions.js @@ -32,7 +32,7 @@ function ReconcileCreditNoteFormFloatingActions({ + + + + ); +} +export default compose(withDialogActions)(ReconcileVendorCreditFloatingActions); diff --git a/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditForm.js b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditForm.js new file mode 100644 index 000000000..175e0ccb3 --- /dev/null +++ b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditForm.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; +import intl from 'react-intl-universal'; + +import '../../../style/pages/ReconcileVendorCredit/ReconcileVendorCreditForm.scss'; + +import { AppToaster } from 'components'; +import { CreateReconcileVendorCreditFormSchema } from './ReconcileVendorCreditForm.schema'; +import { useReconcileVendorCreditContext } from './ReconcileVendorCreditFormProvider'; +import ReconcileVendorCreditFormContent from './ReconcileVendorCreditFormContent'; +import withDialogActions from 'containers/Dialog/withDialogActions'; +import { compose, transformToForm } from 'utils'; + +// Default form initial values. +const defaultInitialValues = { + entries: [ + { + bill_id: '', + amount: '', + }, + ], +}; + +/** + * Reconcile vendor credit form. + */ +function ReconcileVendorCreditForm({ + // #withDialogActions + closeDialog, +}) { + const { + dialogName, + reconcileVendorCredits, + createReconcileVendorCreditMutate, + vendorCredit, + } = useReconcileVendorCreditContext(); + + // Initial form values. + const initialValues = { + entries: reconcileVendorCredits.map((entry) => ({ + ...entry, + bill_id: entry.id, + amount: '', + })), + }; + + // Handle form submit. + const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + setSubmitting(false); + + // Filters the entries. + const entries = values.entries + .filter((entry) => entry.bill_id && entry.amount) + .map((entry) => transformToForm(entry, defaultInitialValues.entries[0])); + + const form = { + ...values, + entries: entries, + }; + + // Handle the request success. + const onSuccess = (response) => { + AppToaster.show({ + message: intl.get('reconcile_vendor_credit.dialog.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + closeDialog(dialogName); + }; + + // Handle the request error. + const onError = ({ + response: { + data: { errors }, + }, + }) => { + // if (errors) { + // transformErrors(errors, { setErrors }); + // } + setSubmitting(false); + }; + + createReconcileVendorCreditMutate([vendorCredit.id, form]) + .then(onSuccess) + .catch(onError); + }; + + return ( + + ); +} +export default compose(withDialogActions)(ReconcileVendorCreditForm); diff --git a/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditForm.schema.js b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditForm.schema.js new file mode 100644 index 000000000..d15b472d9 --- /dev/null +++ b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditForm.schema.js @@ -0,0 +1,12 @@ +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + entries: Yup.array().of( + Yup.object().shape({ + bill_id: Yup.number().required(), + amount: Yup.number().nullable(), + }), + ), +}); + +export const CreateReconcileVendorCreditFormSchema = Schema; diff --git a/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormContent.js b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormContent.js new file mode 100644 index 000000000..c88b1de20 --- /dev/null +++ b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormContent.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { Form } from 'formik'; +import { Choose } from 'components'; + +import { EmptyStatuCallout } from './utils'; +import ReconcileVendorCreditFormFields from './ReconcileVendorCreditFormFields'; +import ReconcileVendorCreditFloatingActions from './ReconcileVendorCreditFloatingActions'; +import { useReconcileVendorCreditContext } from './ReconcileVendorCreditFormProvider'; + +export default function ReconcileVendorCreditFormContent() { + const { isEmptyStatus } = useReconcileVendorCreditContext(); + + return ( + + + + + +
+ + + +
+
+ ); +} diff --git a/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormFields.js b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormFields.js new file mode 100644 index 000000000..531e18b82 --- /dev/null +++ b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormFields.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { FastField, useFormikContext } from 'formik'; +import { Classes } from '@blueprintjs/core'; +import { T, TotalLines, TotalLine } from 'components'; +import { getEntriesTotal } from 'containers/Entries/utils'; +import ReconcileVendorCreditEntriesTable from './ReconcileVendorCreditEntriesTable'; +import { useReconcileVendorCreditContext } from './ReconcileVendorCreditFormProvider'; +import { formattedAmount } from 'utils'; + +export default function ReconcileVendorCreditFormFields() { + const { vendorCredit } = useReconcileVendorCreditContext(); + + const { values } = useFormikContext(); + + // Calculate the total amount. + const totalAmount = React.useMemo( + () => getEntriesTotal(values.entries), + [values.entries], + ); + + return ( +
+ + {({ + form: { setFieldValue, values }, + field: { value }, + meta: { error, touched }, + }) => ( + { + setFieldValue('entries', newEntries); + }} + /> + )} + +
+ + + } + value={formattedAmount(totalAmount, vendorCredit.currency_code)} + /> + + } + value={vendorCredit.formatted_credits_remaining} + /> + +
+
+ ); +} diff --git a/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormProvider.js b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormProvider.js new file mode 100644 index 000000000..0b85497f7 --- /dev/null +++ b/src/containers/Dialogs/ReconcileVendorCreditDialog/ReconcileVendorCreditFormProvider.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { DialogContent } from 'components'; +import { + useVendorCredit, + useReconcileVendorCredit, + useCreateReconcileVendorCredit, +} from 'hooks/query'; +import { isEmpty } from 'lodash'; + +const ReconcileVendorCreditFormContext = React.createContext(); + +/** + * Reconcile vendor credit provider. + */ +function ReconcileVendorCreditFormProvider({ + vendorCreditId, + dialogName, + ...props +}) { + + // Handle fetch reconcile + const { + isLoading: isReconcileVendorCreditLoading, + data: reconcileVendorCredits, + } = useReconcileVendorCredit(vendorCreditId, { + enabled: !!vendorCreditId, + }); + + // Handle fetch vendor credit details. + const { data: vendorCredit, isLoading: isVendorCreditLoading } = + useVendorCredit(vendorCreditId, { + enabled: !!vendorCreditId, + }); + + // Create reconcile vendor credit mutations. + const { mutateAsync: createReconcileVendorCreditMutate } = + useCreateReconcileVendorCredit(); + + // Detarmines the datatable empty status. + const isEmptyStatus = isEmpty(reconcileVendorCredits); + + // provider. + const provider = { + dialogName, + reconcileVendorCredits, + createReconcileVendorCreditMutate, + isEmptyStatus, + vendorCredit, + }; + + return ( + + + + ); +} + +const useReconcileVendorCreditContext = () => + React.useContext(ReconcileVendorCreditFormContext); + +export { ReconcileVendorCreditFormProvider, useReconcileVendorCreditContext }; diff --git a/src/containers/Dialogs/ReconcileVendorCreditDialog/index.js b/src/containers/Dialogs/ReconcileVendorCreditDialog/index.js new file mode 100644 index 000000000..8dc713917 --- /dev/null +++ b/src/containers/Dialogs/ReconcileVendorCreditDialog/index.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { FormattedMessage as T, Dialog, DialogSuspense } from 'components'; +import withDialogRedux from 'components/DialogReduxConnect'; +import { compose } from 'utils'; + +const ReconcileVendorCreditDialogContent = React.lazy(() => + import('./ReconcileVendorCreditDialogContent'), +); + +/** + * Reconcile vendor credit dialog. + */ +function ReconcileVendorCreditDialog({ + dialogName, + payload: { vendorCreditId }, + isOpen, +}) { + return ( + } + canEscapeKeyClose={true} + isOpen={isOpen} + className="dialog--reconcile-vendor-credit-form" + > + + + + + ); +} + +export default compose(withDialogRedux())(ReconcileVendorCreditDialog); diff --git a/src/containers/Dialogs/ReconcileVendorCreditDialog/utils.js b/src/containers/Dialogs/ReconcileVendorCreditDialog/utils.js new file mode 100644 index 000000000..8439dd634 --- /dev/null +++ b/src/containers/Dialogs/ReconcileVendorCreditDialog/utils.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { Callout, Intent, Classes } from '@blueprintjs/core'; +import { AppToaster, T } from 'components'; + +export const transformErrors = (errors, { setErrors }) => {}; + +export function EmptyStatuCallout() { + return ( +
+ +

+ +

+
+
+ ); +} diff --git a/src/containers/Purchases/CreditNotes/CreditNotesLanding/VendorsCreditNoteDataTable.js b/src/containers/Purchases/CreditNotes/CreditNotesLanding/VendorsCreditNoteDataTable.js index bc61bb221..297cca23a 100644 --- a/src/containers/Purchases/CreditNotes/CreditNotesLanding/VendorsCreditNoteDataTable.js +++ b/src/containers/Purchases/CreditNotes/CreditNotesLanding/VendorsCreditNoteDataTable.js @@ -104,6 +104,12 @@ function VendorsCreditNoteDataTable({ const handleOpenCreditNote = ({ id }) => { openAlert('vendor-credit-open', { vendorCreditId: id }); }; + + // Handle reconcile credit note. + const handleReconcileVendorCredit = ({ id }) => { + openDialog('reconcile-vendor-credit', { vendorCreditId: id }); + }; + return ( diff --git a/src/containers/Purchases/CreditNotes/CreditNotesLanding/components.js b/src/containers/Purchases/CreditNotes/CreditNotesLanding/components.js index 92974bc8b..196287682 100644 --- a/src/containers/Purchases/CreditNotes/CreditNotesLanding/components.js +++ b/src/containers/Purchases/CreditNotes/CreditNotesLanding/components.js @@ -17,7 +17,7 @@ import { safeCallback } from 'utils'; * Actions menu. */ export function ActionsMenu({ - payload: { onEdit, onDelete, onOpen, onRefund, onViewDetails }, + payload: { onEdit, onDelete, onOpen, onRefund, onReconcile, onViewDetails }, row: { original }, }) { return ( @@ -47,6 +47,12 @@ export function ActionsMenu({ onClick={safeCallback(onOpen, original)} /> + } + // text={intl.get('credit_note.action.refund_credit_note')} + onClick={safeCallback(onReconcile, original)} + /> { queryClient.invalidateQueries(t.REFUND_CREDIT_NOTE); // Invalidate reconcile. + queryClient.invalidateQueries(t.RECONCILE_CREDIT_NOTE); queryClient.invalidateQueries(t.RECONCILE_CREDIT_NOTES); // Invalidate financial reports. diff --git a/src/hooks/query/vendorCredit.js b/src/hooks/query/vendorCredit.js index 05df98147..45fddee27 100644 --- a/src/hooks/query/vendorCredit.js +++ b/src/hooks/query/vendorCredit.js @@ -27,6 +27,10 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate refund vendor credit queryClient.invalidateQueries(t.REFUND_VENDOR_CREDIT); + // Invalidate reconcile vendor credit. + queryClient.invalidateQueries(t.RECONCILE_VENDOR_CREDIT); + queryClient.invalidateQueries(t.RECONCILE_VENDOR_CREDITS); + // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); }; @@ -241,3 +245,87 @@ export function useOpenVendorCredit(props) { }, ); } + +/** + * Create Reconcile vendor credit. + */ +export function useCreateReconcileVendorCredit(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => + apiRequest.post(`purchases/vendor-credit/${id}/apply-to-bills`, values), + { + onSuccess: (res, [id, values]) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + + // Invalidate credit note query. + queryClient.invalidateQueries([t.VENDOR_CREDIT, id]); + }, + ...props, + }, + ); +} + +/** + * Retrieve reconcile vendor credit of the given id. + * @param {number} id + * + */ +export function useReconcileVendorCredit(id, props, requestProps) { + return useRequestQuery( + [t.RECONCILE_VENDOR_CREDIT, id], + { + method: 'get', + url: `purchases/vendor-credit/${id}/apply-to-bills`, + ...requestProps, + }, + { + select: (res) => res.data.data, + defaultData: [], + ...props, + }, + ); +} + +/** + * Retrieve reconcile credit notes. + */ +export function useReconcileVendorCredits(id, props, requestProps) { + return useRequestQuery( + [t.RECONCILE_VENDOR_CREDITS, id], + { + method: 'get', + url: `purchases/vendor-credit/${id}/applied-bills`, + ...requestProps, + }, + { + select: (res) => res.data.data, + defaultData: {}, + ...props, + }, + ); +} +/** + * Delete the given reconcile vendor credit. + */ +export function useDeleteReconcileVendorCredit(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (id) => apiRequest.delete(`purchases/vendor-credit/applied-to-bills/${id}`), + { + onSuccess: (res, id) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + + // Invalidate vendor credit query. + queryClient.invalidateQueries([t.VENDOR_CREDIT, id]); + }, + ...props, + }, + ); +} diff --git a/src/lang/en/index.json b/src/lang/en/index.json index 5edb33ffe..f556c159e 100644 --- a/src/lang/en/index.json +++ b/src/lang/en/index.json @@ -1579,5 +1579,13 @@ "reconcile_credit_note.once_you_delete_this_reconcile_credit_note": "Once you delete this reconcile credit note, you won't be able to restore it later. Are you sure you want to delete this reconcile credit note?", "credit_note.error.you_couldn_t_delete_credit_note_that_has_associated_refund": "You couldn't delete credit note that has associated refund transactions.", "credit_note.error.you_couldn_t_delete_credit_note_that_has_associated_invoice": "You couldn't delete credit note that has associated invoice reconcile transactions.", - "invoices.error.you_couldn_t_delete_sale_invoice_that_has_reconciled": "You couldn't delete sale invoice that has reconciled with credit note transaction." + "invoices.error.you_couldn_t_delete_sale_invoice_that_has_reconciled": "You couldn't delete sale invoice that has reconciled with credit note transaction.", + "reconcile_vendor_credit.dialog.label": "Reconcile Credit Note with Bills", + "reconcile_vendor_credit.dialog.success_message": "The vendor credit has been applied to the given bills successfully", + "reconcile_vendor_credit.alert.there_is_no_open_bills":"There is no open bills associated to credit note vendor.", + "reconcile_vendor_credit.dialog.total_amount_to_credit":"Total amount to credit", + "reconcile_vendor_credit.dialog.remaining_credits":"Remaining amount", + "reconcile_vendor_credit.column.bill_number":"Bill #", + "reconcile_vendor_credit.column.remaining_amount":"Remaining amount", + "reconcile_vendor_credit.column.amount_to_credit":"Amount to credit" } \ No newline at end of file diff --git a/src/style/pages/ReconcileVendorCredit/ReconcileVendorCreditForm.scss b/src/style/pages/ReconcileVendorCredit/ReconcileVendorCreditForm.scss new file mode 100644 index 000000000..c04f7bc27 --- /dev/null +++ b/src/style/pages/ReconcileVendorCredit/ReconcileVendorCreditForm.scss @@ -0,0 +1,72 @@ +.dialog--reconcile-vendor-credit-form { + width: 800px; + + .bp3-dialog-body { + .footer { + display: flex; + margin-top: 40px; + + .total_lines { + margin-left: auto; + + &_line { + border-bottom: none; + .title { + font-weight: 600; + } + .amount, + .title { + padding: 8px 0px; + width: 165px; + } + .amount { + text-align: right; + } + } + } + } + } + + .bigcapital-datatable { + .table { + border: 1px solid #d1dee2; + min-width: auto; + + .tbody, + .tbody-inner { + height: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + .tbody { + .tr .td { + padding: 0.4rem; + margin-left: -1px; + border-left: 1px solid #ececec; + } + + .bp3-form-group { + margin-bottom: 0; + + &:not(.bp3-intent-danger) .bp3-input { + border: 1px solid #d0dfe2; + + &:focus { + box-shadow: 0 0 0 1px #116cd0; + border-color: #116cd0; + } + } + } + } + } + } + + .bp3-callout { + font-size: 14px; + } + .bp3-dialog-footer { + padding-top: 10px; + } +}