mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-19 14:20:31 +00:00
feat: Auto re-calculate the items rate once changing the invoice exchange rate.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
// @ts-nocheck
|
||||
import { Dialog, DialogSuspense, FormattedMessage as T } from '@/components';
|
||||
import withDialogRedux from '@/components/DialogReduxConnect';
|
||||
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||
import { compose } from '@/utils';
|
||||
import { Button, Classes, Intent } from '@blueprintjs/core';
|
||||
|
||||
/**
|
||||
* Invoice number dialog.
|
||||
*/
|
||||
function InvoiceExchangeRateChangeDialog({
|
||||
dialogName,
|
||||
payload: { initialFormValues },
|
||||
isOpen,
|
||||
onConfirm,
|
||||
// #withDialogActions
|
||||
closeDialog,
|
||||
}) {
|
||||
const handleConfirm = () => {
|
||||
closeDialog(dialogName);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={'Please take care of the following'}
|
||||
name={dialogName}
|
||||
autoFocus={true}
|
||||
canEscapeKeyClose={true}
|
||||
isOpen={isOpen}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<DialogSuspense>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<p>
|
||||
You have changed customers's currency after adding items to the
|
||||
Invoice.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The item rates have been adjusted to the new currency using exchange
|
||||
rate feeds.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Before saving the transaction, ensure that the item rates align with
|
||||
the current exchange rate of the newly selected currency.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<Button onClick={handleConfirm} intent={Intent.PRIMARY}>
|
||||
Ok
|
||||
</Button>
|
||||
</div>
|
||||
</DialogSuspense>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withDialogRedux(),
|
||||
withDialogActions,
|
||||
)(InvoiceExchangeRateChangeDialog);
|
||||
@@ -0,0 +1,16 @@
|
||||
// @ts-nocheck
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
import React from 'react';
|
||||
|
||||
const InvoiceExchangeRateChangeAlert = React.lazy(
|
||||
() => import('./InvoiceExchangeRateChangeDialog'),
|
||||
);
|
||||
|
||||
const Dialogs = [
|
||||
{
|
||||
name: DialogsName.InvoiceExchangeRateChangeNotice,
|
||||
component: InvoiceExchangeRateChangeAlert,
|
||||
},
|
||||
];
|
||||
|
||||
export default Dialogs;
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
transformValueToRequest,
|
||||
resetFormState,
|
||||
} from './utils';
|
||||
import { InvoiceNoSyncSettingsToForm } from './components';
|
||||
import { InvoiceExchangeRateSync, InvoiceNoSyncSettingsToForm } from './components';
|
||||
|
||||
/**
|
||||
* Invoice form.
|
||||
@@ -180,6 +180,7 @@ function InvoiceForm({
|
||||
|
||||
{/*---------- Effects ----------*/}
|
||||
<InvoiceNoSyncSettingsToForm />
|
||||
<InvoiceExchangeRateSync />
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
handleDateChange,
|
||||
} from '@/utils';
|
||||
import { CLASSES } from '@/constants/classes';
|
||||
import { customerNameFieldShouldUpdate } from './utils';
|
||||
import {
|
||||
customerNameFieldShouldUpdate,
|
||||
useInvoiceEntriesOnExchangeRateChange,
|
||||
} from './utils';
|
||||
|
||||
import { useInvoiceFormContext } from './InvoiceFormProvider';
|
||||
import {
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
ProjectBillableEntriesLink,
|
||||
} from '@/containers/Projects/components';
|
||||
import { Features } from '@/constants';
|
||||
import { useCurrentOrganization } from '@/hooks/state';
|
||||
|
||||
/**
|
||||
* Invoice form header fields.
|
||||
@@ -161,8 +165,29 @@ export default function InvoiceFormHeaderFields() {
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
function InvoiceFormCustomerSelect() {
|
||||
const { customers } = useInvoiceFormContext();
|
||||
const { values, setFieldValue } = useFormikContext();
|
||||
const { customers, setAutoExRateCurrency } = useInvoiceFormContext();
|
||||
const currentComapny = useCurrentOrganization();
|
||||
const composeEntriesOnExChange = useInvoiceEntriesOnExchangeRateChange();
|
||||
|
||||
// Handles the customer item change.
|
||||
const handleItemChange = (customer) => {
|
||||
setAutoExRateCurrency(null);
|
||||
|
||||
// If the customer id has changed change the customer id and currency code.
|
||||
if (values.customer_id !== customer.id) {
|
||||
setFieldValue('customer_id', customer.id);
|
||||
setFieldValue('currency_code', customer?.currency_code);
|
||||
}
|
||||
// If the customer's currency code is the same the base currency.
|
||||
if (customer?.currency_code === currentComapny.base_currency) {
|
||||
setFieldValue('exchange_rate', '1');
|
||||
setFieldValue('entries', composeEntriesOnExChange(values.exchange_rate, 1));
|
||||
} else {
|
||||
// Sets the currency code to fetch auto-exchange rate.
|
||||
setAutoExRateCurrency(customer?.currency_code);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FFormGroup
|
||||
@@ -178,10 +203,7 @@ function InvoiceFormCustomerSelect() {
|
||||
name={'customer_id'}
|
||||
items={customers}
|
||||
placeholder={<T id={'select_customer_account'} />}
|
||||
onItemChange={(customer) => {
|
||||
setFieldValue('customer_id', customer.id);
|
||||
setFieldValue('currency_code', customer?.currency_code);
|
||||
}}
|
||||
onItemChange={handleItemChange}
|
||||
allowCreate={true}
|
||||
fastField={true}
|
||||
shouldUpdate={customerNameFieldShouldUpdate}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { createContext, useState } from 'react';
|
||||
import { isEmpty, pick } from 'lodash';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Features } from '@/constants';
|
||||
import { useFeatureCan } from '@/hooks/state';
|
||||
import { useCurrentOrganization, useFeatureCan } from '@/hooks/state';
|
||||
import { DashboardInsider } from '@/components/Dashboard';
|
||||
import { transformToEditForm, ITEMS_FILTER_ROLES_QUERY } from './utils';
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useEditInvoice,
|
||||
useSettingsInvoices,
|
||||
useEstimate,
|
||||
useExchangeRate,
|
||||
} from '@/hooks/query';
|
||||
import { useProjects } from '@/containers/Projects/hooks';
|
||||
import { useTaxRates } from '@/hooks/query/taxRates';
|
||||
@@ -93,6 +94,18 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
|
||||
// Handle fetching settings.
|
||||
const { isLoading: isSettingsLoading } = useSettingsInvoices();
|
||||
|
||||
const [autoExRateCurrency, setAutoExRateCurrency] = useState<string>('');
|
||||
const currentOrganization = useCurrentOrganization();
|
||||
|
||||
// Retrieves the exchange rate.
|
||||
const { data: autoExchangeRate, isLoading: isAutoExchangeRateLoading } =
|
||||
useExchangeRate(autoExRateCurrency, currentOrganization.base_currency, {
|
||||
enabled: Boolean(currentOrganization.base_currency && autoExRateCurrency),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
cacheTime: Infinity,
|
||||
});
|
||||
|
||||
// Create and edit invoice mutations.
|
||||
const { mutateAsync: createInvoiceMutate } = useCreateInvoice();
|
||||
const { mutateAsync: editInvoiceMutate } = useEditInvoice();
|
||||
@@ -119,6 +132,7 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
|
||||
warehouses,
|
||||
projects,
|
||||
taxRates,
|
||||
autoExchangeRate,
|
||||
|
||||
isInvoiceLoading,
|
||||
isItemsLoading,
|
||||
@@ -135,6 +149,10 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
|
||||
editInvoiceMutate,
|
||||
setSubmitPayload,
|
||||
isNewMode,
|
||||
|
||||
autoExRateCurrency,
|
||||
setAutoExRateCurrency,
|
||||
isAutoExchangeRateLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import intl from 'react-intl-universal';
|
||||
import * as R from 'ramda';
|
||||
import { Button } from '@blueprintjs/core';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { ExchangeRateInputGroup } from '@/components';
|
||||
import { useCurrentOrganization } from '@/hooks/state';
|
||||
import { useInvoiceIsForeignCustomer } from './utils';
|
||||
import {
|
||||
useInvoiceEntriesOnExchangeRateChange,
|
||||
useInvoiceIsForeignCustomer,
|
||||
useInvoiceTotal,
|
||||
} from './utils';
|
||||
import withSettings from '@/containers/Settings/withSettings';
|
||||
import { useUpdateEffect } from '@/hooks';
|
||||
import { transactionNumber } from '@/utils';
|
||||
import { useInvoiceFormContext } from './InvoiceFormProvider';
|
||||
import withDialogActions from '@/containers/Dialog/withDialogActions';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
|
||||
/**
|
||||
* Re-calculate the item entries prices based on the old exchange rate.
|
||||
* @param {InvoiceExchangeRateInputFieldRoot} Component
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const withExchangeRateItemEntriesPriceRecalc = (Component) => (props) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const composeChangeExRate = useInvoiceEntriesOnExchangeRateChange();
|
||||
|
||||
return (
|
||||
<Component
|
||||
onRecalcConfirm={({ exchangeRate, oldExchangeRate }) => {
|
||||
setFieldValue(
|
||||
'entries',
|
||||
composeChangeExRate(oldExchangeRate, exchangeRate),
|
||||
);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoice exchange rate input field.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export function InvoiceExchangeRateInputField({ ...props }) {
|
||||
const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => {
|
||||
const currentOrganization = useCurrentOrganization();
|
||||
const { values } = useFormikContext();
|
||||
const { isAutoExchangeRateLoading } = useInvoiceFormContext();
|
||||
|
||||
const isForeignCustomer = useInvoiceIsForeignCustomer();
|
||||
|
||||
@@ -27,12 +57,22 @@ export function InvoiceExchangeRateInputField({ ...props }) {
|
||||
}
|
||||
return (
|
||||
<ExchangeRateInputGroup
|
||||
name={'exchange_rate'}
|
||||
fromCurrency={values.currency_code}
|
||||
toCurrency={currentOrganization.base_currency}
|
||||
isLoading={isAutoExchangeRateLoading}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoice exchange rate input field.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export const InvoiceExchangeRateInputField = R.compose(
|
||||
withExchangeRateItemEntriesPriceRecalc,
|
||||
)(InvoiceExchangeRateInputFieldRoot);
|
||||
|
||||
/**
|
||||
* Invoice project select.
|
||||
@@ -66,3 +106,42 @@ export const InvoiceNoSyncSettingsToForm = R.compose(
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Syncs the fetched real-time exchange rate to the form.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export const InvoiceExchangeRateSync = R.compose(withDialogActions)(
|
||||
({ openDialog }) => {
|
||||
const { setFieldValue, values } = useFormikContext();
|
||||
const { autoExRateCurrency, autoExchangeRate } = useInvoiceFormContext();
|
||||
const composeEntriesOnExChange = useInvoiceEntriesOnExchangeRateChange();
|
||||
|
||||
const total = useInvoiceTotal();
|
||||
const timeout = useRef();
|
||||
|
||||
// Sync the fetched real-time exchanage rate to the form.
|
||||
useEffect(() => {
|
||||
if (autoExchangeRate?.exchange_rate && autoExRateCurrency) {
|
||||
setFieldValue('exchange_rate', autoExchangeRate?.exchange_rate + '');
|
||||
setFieldValue(
|
||||
'entries',
|
||||
composeEntriesOnExChange(
|
||||
values.exchange_rate,
|
||||
autoExchangeRate?.exchange_rate,
|
||||
),
|
||||
);
|
||||
// If the total bigger then zero show alert to the user after adjusting entries.
|
||||
if (total > 0) {
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
openDialog(DialogsName.InvoiceExchangeRateChange);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoExchangeRate?.exchange_rate, autoExRateCurrency]);
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import intl from 'react-intl-universal';
|
||||
import moment from 'moment';
|
||||
import * as R from 'ramda';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
import { omit, first, sumBy } from 'lodash';
|
||||
import { omit, first, sumBy, round } from 'lodash';
|
||||
import {
|
||||
compose,
|
||||
transformToForm,
|
||||
@@ -57,7 +57,7 @@ export const defaultInvoice = {
|
||||
reference_no: '',
|
||||
invoice_message: '',
|
||||
terms_conditions: '',
|
||||
exchange_rate: 1,
|
||||
exchange_rate: '1',
|
||||
currency_code: '',
|
||||
branch_id: '',
|
||||
warehouse_id: '',
|
||||
@@ -398,3 +398,85 @@ export const useIsInvoiceTaxExclusive = () => {
|
||||
|
||||
return values.inclusive_exclusive_tax === TaxType.Exclusive;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert the given rate to the local currency.
|
||||
* @param {number} rate
|
||||
* @param {number} exchangeRate
|
||||
* @returns {number}
|
||||
*/
|
||||
export const convertToForeignCurrency = (
|
||||
rate: number,
|
||||
exchangeRate: number,
|
||||
) => {
|
||||
return rate * exchangeRate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the given rate to the base currency.
|
||||
* @param {number} rate
|
||||
* @param {number} exchangeRate
|
||||
* @returns {number}
|
||||
*/
|
||||
export const covertToBaseCurrency = (rate: number, exchangeRate: number) => {
|
||||
return rate / exchangeRate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts the given rate from the old exchange rate and covert it to the new
|
||||
* currency based on the given new exchange rate.
|
||||
* @param {number} rate -
|
||||
* @param {number} oldExchangeRate - Old exchange rate.
|
||||
* @param {number} newExchangeRate - New exchange rate.
|
||||
* @returns {number}
|
||||
*/
|
||||
const revertAndConvertExchangeRate = (
|
||||
rate: number,
|
||||
oldExchangeRate: number,
|
||||
newExchangeRate: number,
|
||||
) => {
|
||||
const oldValue = convertToForeignCurrency(rate, oldExchangeRate);
|
||||
const newValue = covertToBaseCurrency(oldValue, newExchangeRate);
|
||||
|
||||
return round(newValue, 3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assign the new item entry rate after converting to the new exchange rate.
|
||||
* @params {number} oldExchangeRate -
|
||||
* @params {number} newExchangeRate -
|
||||
* @params {IItemEntry} entries -
|
||||
*/
|
||||
const assignRateRevertAndCovertExchangeRate = R.curry(
|
||||
(oldExchangeRate: number, newExchangeRate: number, entries: IItemEntry[]) => {
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
rate: revertAndConvertExchangeRate(
|
||||
entry.rate,
|
||||
oldExchangeRate,
|
||||
newExchangeRate,
|
||||
),
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Compose invoice entries on exchange rate change.
|
||||
* @returns {(oldExchangeRate: number, newExchangeRate: number) => IItemEntry[]}
|
||||
*/
|
||||
export const useInvoiceEntriesOnExchangeRateChange = () => {
|
||||
const {
|
||||
values: { entries },
|
||||
} = useFormikContext();
|
||||
|
||||
return React.useMemo(() => {
|
||||
return R.curry((oldExchangeRate: number, newExchangeRate: number) => {
|
||||
return R.compose(
|
||||
// Updates entries total.
|
||||
updateItemsEntriesTotal,
|
||||
// Assign a new rate of the given new exchange rate from the old exchange rate.
|
||||
assignRateRevertAndCovertExchangeRate(oldExchangeRate, newExchangeRate),
|
||||
)(entries);
|
||||
});
|
||||
}, [entries]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user