diff --git a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx index 6f5bcdcec..caf2f3d58 100644 --- a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx +++ b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx @@ -28,7 +28,7 @@ interface ExchangeRateInputGroupProps { inputGroupProps?: any; formGroupProps?: any; - popoverRecalcConfirm?: boolean; + withPopoverRecalcConfirm?: boolean; onRecalcConfirm: (bag: ExchangeRateValuesBag) => void; onCancel: (bag: ExchangeRateValuesBag) => void; @@ -47,7 +47,7 @@ export function ExchangeRateInputGroup({ inputGroupProps, formGroupProps, - popoverRecalcConfirm = false, + withPopoverRecalcConfirm = false, onRecalcConfirm, onCancel, @@ -97,6 +97,7 @@ export function ExchangeRateInputGroup({ onChange={() => null} onBlur={handleExchangeRateFieldBlur} rightElement={isLoading && } + decimalsLimit={5} {...inputGroupProps} name={name} /> @@ -142,7 +143,7 @@ export function ExchangeRateInputGroup({ 1 {fromCurrency} = - {popoverRecalcConfirm ? ( + {withPopoverRecalcConfirm ? ( {exchangeRateField} diff --git a/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx b/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx new file mode 100644 index 000000000..6554b85a1 --- /dev/null +++ b/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx @@ -0,0 +1,52 @@ +import { useExchangeRate } from '@/hooks/query'; +import { useCurrentOrganization } from '@/hooks/state'; +import React from 'react'; + +interface AutoExchangeRateProviderProps { + children: React.ReactNode; +} + +interface AutoExchangeRateProviderValue { + autoExRateCurrency: string; + isAutoExchangeRateLoading: boolean; +} + +const AutoExchangeRateContext = React.createContext( + {} as AutoExchangeRateProviderValue, +); + +function AutoExchangeRateProvider({ children }: AutoExchangeRateProviderProps) { + const [autoExRateCurrency, setAutoExRateCurrency] = + React.useState(''); + 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: 0, + cacheTime: 0, + }); + + const value = { + autoExRateCurrency, + setAutoExRateCurrency, + isAutoExchangeRateLoading, + autoExchangeRate, + }; + + return ( + + {children} + + ); +} + +const useAutoExRateContext = () => React.useContext(AutoExchangeRateContext); + +export { + useAutoExRateContext, + AutoExchangeRateContext, + AutoExchangeRateProvider, +}; diff --git a/packages/webapp/src/containers/Entries/useUpdateEntriesOnExchangeRateChange.ts b/packages/webapp/src/containers/Entries/useUpdateEntriesOnExchangeRateChange.ts new file mode 100644 index 000000000..a4e42877c --- /dev/null +++ b/packages/webapp/src/containers/Entries/useUpdateEntriesOnExchangeRateChange.ts @@ -0,0 +1,88 @@ +// @ts-nocheck +import React from 'react'; +import { useFormikContext } from 'formik'; +import { round } from 'lodash'; +import * as R from 'ramda'; +import { updateItemsEntriesTotal } from './utils'; + +/** + * 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, + ), + })); + }, +); + +/** + * Updates items entries on exchange rate change. + * @returns {(oldExchangeRate: number, newExchangeRate: number) => IItemEntry[]} + */ +export const useUpdateEntriesOnExchangeRateChange = () => { + 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]); +}; diff --git a/packages/webapp/src/containers/Entries/withExRateItemEntriesPriceRecalc.tsx b/packages/webapp/src/containers/Entries/withExRateItemEntriesPriceRecalc.tsx new file mode 100644 index 000000000..490ef20b5 --- /dev/null +++ b/packages/webapp/src/containers/Entries/withExRateItemEntriesPriceRecalc.tsx @@ -0,0 +1,121 @@ +// @ts-nocheck +import { useFormikContext } from 'formik'; +import { useUpdateEntriesOnExchangeRateChange } from './useUpdateEntriesOnExchangeRateChange'; +import { useAutoExRateContext } from './AutoExchangeProvider'; +import { useCallback, useEffect } from 'react'; +import { useCurrentOrganization } from '@/hooks/state'; + +/** + * Re-calculate the item entries prices based on the old exchange rate. + * @param {InvoiceExchangeRateInputFieldRoot} Component + * @returns {JSX.Element} + */ +export const withExchangeRateItemEntriesPriceRecalc = + (Component) => (props) => { + const { setFieldValue } = useFormikContext(); + const updateChangeExRate = useUpdateEntriesOnExchangeRateChange(); + + return ( + { + setFieldValue( + 'entries', + updateChangeExRate(oldExchangeRate, exchangeRate), + ); + }} + {...props} + /> + ); + }; + +/** + * Injects the loading props to the exchange rate field. + * @param Component + * @returns {} + */ +export const withExchangeRateFetchingLoading = (Component) => (props) => { + const { isAutoExchangeRateLoading } = useAutoExRateContext(); + + return ( + + ); +}; + +/** + * Updates the customer currency code and exchange rate once you update the customer + * then change the state to fetch the realtime exchange rate of the new selected currency. + */ +export const useCustomerUpdateExRate = () => { + const { setFieldValue, values } = useFormikContext(); + const { setAutoExRateCurrency } = useAutoExRateContext(); + + const updateEntriesOnExChange = useUpdateEntriesOnExchangeRateChange(); + const currentCompany = useCurrentOrganization(); + + const DEFAULT_EX_RATE = 1; + + return useCallback( + (customer) => { + // Reset the auto exchange rate currency cycle. + setAutoExRateCurrency(null); + + // If the customer's currency code equals the same base currency. + if (customer.currency_code === currentCompany.base_currency) { + setFieldValue('exchange_rate', DEFAULT_EX_RATE + ''); + setFieldValue( + 'entries', + updateEntriesOnExChange(values.exchange_rate, DEFAULT_EX_RATE), + ); + } else { + // Sets the currency code to fetch exchange rate of the given currency code. + setAutoExRateCurrency(customer?.currency_code); + } + }, + [ + currentCompany.base_currency, + setAutoExRateCurrency, + setFieldValue, + updateEntriesOnExChange, + values.exchange_rate, + ], + ); +}; + +interface UseSyncExRateToFormProps { + onSynced?: () => void; +} + +/** + * Syncs the realtime exchange rate to the Formik form and then re-calculates + * the entries rate based on the given new and old ex. rate. + * @param {UseSyncExRateToFormProps} props - + * @returns {React.ReactNode} + */ +export const useSyncExRateToForm = ({ onSynced }: UseSyncExRateToFormProps) => { + const { setFieldValue, values } = useFormikContext(); + const { autoExRateCurrency, autoExchangeRate } = useAutoExRateContext(); + const updateEntriesOnExChange = useUpdateEntriesOnExchangeRateChange(); + + // Sync the fetched real-time exchanage rate to the form. + useEffect(() => { + if (autoExchangeRate?.exchange_rate && autoExRateCurrency) { + setFieldValue('exchange_rate', autoExchangeRate?.exchange_rate + ''); + setFieldValue( + 'entries', + updateEntriesOnExChange( + values.exchange_rate, + autoExchangeRate?.exchange_rate, + ), + ); + onSynced?.(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoExchangeRate?.exchange_rate, autoExRateCurrency]); + + return null; +}; diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx index 1d51ecbc6..a52afcac4 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx @@ -38,7 +38,10 @@ import { import withSettings from '@/containers/Settings/withSettings'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; -import { CreditNoteSyncIncrementSettingsToForm } from './components'; +import { + CreditNoteExchangeRateSync, + CreditNoteSyncIncrementSettingsToForm, +} from './components'; /** * Credit note form. @@ -169,6 +172,7 @@ function CreditNoteForm({ {/*-------- Effects --------*/} + diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx index cd2fc1d1a..3c89f2f04 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx @@ -26,6 +26,7 @@ import { inputIntent, handleDateChange, } from '@/utils'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Credit note form header fields. @@ -37,10 +38,8 @@ export default function CreditNoteFormHeaderFields({}) { {/* ----------- Exchange rate ----------- */} - + + {/* ----------- Credit note date ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( @@ -93,8 +92,18 @@ export default function CreditNoteFormHeaderFields({}) { */ function CreditNoteCustomersSelect() { // Credit note form context. - const { customers } = useCreditNoteFormContext(); const { setFieldValue, values } = useFormikContext(); + const { customers } = useCreditNoteFormContext(); + + const updateEntries = useCustomerUpdateExRate(); + + // Handles item change. + const handleItemChange = (customer) => { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; return ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx index bb4c7a2cd..872c2f053 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/CreditNote/PageForm.scss'; import CreditNoteForm from './CreditNoteForm'; import { CreditNoteFormProvider } from './CreditNoteFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Credit note form page. @@ -16,7 +17,9 @@ export default function CreditNoteFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx index 2902299fa..afe139f48 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx @@ -1,21 +1,27 @@ // @ts-nocheck -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useFormikContext } from 'formik'; import * as R from 'ramda'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useCreditNoteIsForeignCustomer } from './utils'; +import { useCreditNoteIsForeignCustomer, useCreditNoteTotals } from './utils'; import withSettings from '@/containers/Settings/withSettings'; import { transactionNumber } from '@/utils'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** - * credit exchange rate input field. + * Credit note exchange rate input field. * @returns {JSX.Element} */ -export function CreditNoteExchangeRateInputField({ ...props }) { +function CreditNoteExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const isForeignCustomer = useCreditNoteIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -24,13 +30,21 @@ export function CreditNoteExchangeRateInputField({ ...props }) { } return ( ); } +export const CreditNoteExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(CreditNoteExchangeRateInputFieldRoot); + /** * Syncs credit note auto-increment settings to form. * @return {React.ReactNode} @@ -56,3 +70,28 @@ export const CreditNoteSyncIncrementSettingsToForm = R.compose( return null; }); + +/** + * Syncs the realtime exchange rate to the credit note form and shows up popup to the user + * as an indication the entries rates have been re-calculated. + * @returns {React.ReactNode} + */ +export const CreditNoteExchangeRateSync = R.compose(withDialogActions)( + ({ openDialog }) => { + const { total } = useCreditNoteTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // 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.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx index f9cd24673..aff0888d6 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React, { useMemo } from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; @@ -19,7 +18,10 @@ import EstimateFloatingActions from './EstimateFloatingActions'; import EstimateFormFooter from './EstimateFormFooter'; import EstimateFormDialogs from './EstimateFormDialogs'; import EstimtaeFormTopBar from './EstimtaeFormTopBar'; -import { EstimateIncrementSyncSettingsToForm } from './components'; +import { + EstimateIncrementSyncSettingsToForm, + EstimateSyncAutoExRateToForm, +} from './components'; import withSettings from '@/containers/Settings/withSettings'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; @@ -170,6 +172,7 @@ function EstimateForm({ {/*------- Effects -------*/} + diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx index e3bfabb9a..eba6eb5da 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import styled from 'styled-components'; import classNames from 'classnames'; import { FormGroup, InputGroup, Position, Classes } from '@blueprintjs/core'; @@ -24,7 +23,6 @@ import { import { customersFieldShouldUpdate } from './utils'; import { CLASSES } from '@/constants/classes'; import { Features } from '@/constants'; - import { ProjectsSelect } from '@/containers/Projects/components'; import { EstimateExchangeRateInputField, @@ -32,12 +30,13 @@ import { } from './components'; import { EstimateFormEstimateNumberField } from './EstimateFormEstimateNumberField'; import { useEstimateFormContext } from './EstimateFormProvider'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Estimate form header. */ export default function EstimateFormHeader() { - const { customers, projects } = useEstimateFormContext(); + const { projects } = useEstimateFormContext(); return (
@@ -45,10 +44,8 @@ export default function EstimateFormHeader() { {/* ----------- Exchange Rate ----------- */} - + + {/* ----------- Estimate Date ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( @@ -151,6 +148,16 @@ function EstimateFormCustomerSelect() { const { setFieldValue, values } = useFormikContext(); const { customers } = useEstimateFormContext(); + const updateEntries = useCustomerUpdateExRate(); + + // Handles the customer item change. + const handleItemChange = (customer) => { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; + return ( } @@ -165,10 +172,7 @@ function EstimateFormCustomerSelect() { name={'customer_id'} items={customers} placeholder={} - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx index 6f02669db..0ca5d4e37 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleEstimate/PageForm.scss'; import EstimateForm from './EstimateForm'; import { EstimateFormProvider } from './EstimateFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Estimate form page. @@ -16,7 +17,9 @@ export default function EstimateFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx index 65a1c9cd2..3cace61f2 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx @@ -1,24 +1,30 @@ // @ts-nocheck -import React, { useEffect } from 'react'; +import React, { useRef } from 'react'; import intl from 'react-intl-universal'; import { Button } from '@blueprintjs/core'; import * as R from 'ramda'; import { useFormikContext } from 'formik'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useEstimateIsForeignCustomer } from './utils'; -import withSettings from '@/containers/Settings/withSettings'; +import { useEstimateIsForeignCustomer, useEstimateTotals } from './utils'; import { transactionNumber } from '@/utils'; import { useUpdateEffect } from '@/hooks'; +import withSettings from '@/containers/Settings/withSettings'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimate exchange rate input field. * @returns {JSX.Element} */ -export function EstimateExchangeRateInputField({ ...props }) { +function EstimateExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const isForeignCustomer = useEstimateIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -27,13 +33,26 @@ export function EstimateExchangeRateInputField({ ...props }) { } return ( ); } +/** + * Renders the estimate exchange rate input field with exchange rate + * with item entries price re-calc once exchange rate change. + * @returns {JSX.Element} + */ +export const EstimateExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(EstimateExchangeRateInputFieldRoot); + /** * Estimate project select. * @returns {JSX.Element} @@ -72,3 +91,32 @@ export const EstimateIncrementSyncSettingsToForm = R.compose( return null; }); + +/** + * Syncs the auto exchange rate to the estimate form and shows up popup to user + * as an indication the entries rates have been changed. + * @returns {React.ReactNode} + */ +export const EstimateSyncAutoExRateToForm = R.compose(withDialogActions)( + ({ + // #withDialogActions + openDialog, + }) => { + const { total } = useEstimateTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // 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.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx index 00a86b326..50771d913 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx @@ -22,7 +22,6 @@ function InvoiceExchangeRateChangeDialog({ return (

- You have changed customers's currency after adding items to the + You have changed customer's currency after adding items to the Invoice.

@@ -41,14 +40,14 @@ function InvoiceExchangeRateChangeDialog({ rate feeds.

-

+

Before saving the transaction, ensure that the item rates align with the current exchange rate of the newly selected currency.

-
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx index 3de699979..e212c8b7d 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx @@ -70,6 +70,7 @@ export default function InvoiceFloatingActions() { history.goBack(); }; + // Handle clear button click. const handleClearBtnClick = (event) => { resetForm(); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx index 51ba306fa..17bd197e5 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx @@ -23,10 +23,7 @@ import { handleDateChange, } from '@/utils'; import { CLASSES } from '@/constants/classes'; -import { - customerNameFieldShouldUpdate, - useInvoiceEntriesOnExchangeRateChange, -} from './utils'; +import { customerNameFieldShouldUpdate } from './utils'; import { useInvoiceFormContext } from './InvoiceFormProvider'; import { @@ -39,7 +36,7 @@ import { ProjectBillableEntriesLink, } from '@/containers/Projects/components'; import { Features } from '@/constants'; -import { useCurrentOrganization } from '@/hooks/state'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Invoice form header fields. @@ -55,10 +52,8 @@ export default function InvoiceFormHeaderFields() { {/* ----------- Exchange rate ----------- */} - + + {/* ----------- Invoice date ----------- */} @@ -166,27 +161,18 @@ export default function InvoiceFormHeaderFields() { */ function InvoiceFormCustomerSelect() { const { values, setFieldValue } = useFormikContext(); - const { customers, setAutoExRateCurrency } = useInvoiceFormContext(); - const currentComapny = useCurrentOrganization(); - const composeEntriesOnExChange = useInvoiceEntriesOnExchangeRateChange(); + const { customers } = useInvoiceFormContext(); + + const updateEntries = useCustomerUpdateExRate(); // 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); - } + updateEntries(customer); }; return ( diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx index eba22308e..42190b42a 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleInvoice/PageForm.scss'; import InvoiceForm from './InvoiceForm'; import { InvoiceFormProvider } from './InvoiceFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Invoice form page. @@ -16,7 +17,9 @@ export default function InvoiceFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx index 1969abd61..fd1b3b0b2 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx @@ -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 { useCurrentOrganization, useFeatureCan } from '@/hooks/state'; +import { useFeatureCan } from '@/hooks/state'; import { DashboardInsider } from '@/components/Dashboard'; import { transformToEditForm, ITEMS_FILTER_ROLES_QUERY } from './utils'; import { @@ -16,7 +16,6 @@ import { useEditInvoice, useSettingsInvoices, useEstimate, - useExchangeRate, } from '@/hooks/query'; import { useProjects } from '@/containers/Projects/hooks'; import { useTaxRates } from '@/hooks/query/taxRates'; @@ -94,18 +93,6 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) { // Handle fetching settings. const { isLoading: isSettingsLoading } = useSettingsInvoices(); - const [autoExRateCurrency, setAutoExRateCurrency] = useState(''); - 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(); @@ -132,7 +119,6 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) { warehouses, projects, taxRates, - autoExchangeRate, isInvoiceLoading, isItemsLoading, @@ -149,10 +135,6 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) { editInvoiceMutate, setSubmitPayload, isNewMode, - - autoExRateCurrency, - setAutoExRateCurrency, - isAutoExchangeRateLoading, }; return ( diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx index e25b389d7..788cd2c5c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx @@ -1,44 +1,22 @@ // @ts-nocheck -import { useEffect, useRef } from 'react'; +import { 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 { - useInvoiceEntriesOnExchangeRateChange, - useInvoiceIsForeignCustomer, - useInvoiceTotal, -} from './utils'; +import { 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 ( - { - setFieldValue( - 'entries', - composeChangeExRate(oldExchangeRate, exchangeRate), - ); - }} - {...props} - /> - ); -}; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Invoice exchange rate input field. @@ -47,8 +25,6 @@ const withExchangeRateItemEntriesPriceRecalc = (Component) => (props) => { const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const { isAutoExchangeRateLoading } = useInvoiceFormContext(); - const isForeignCustomer = useInvoiceIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -60,7 +36,8 @@ const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => { name={'exchange_rate'} fromCurrency={values.currency_code} toCurrency={currentOrganization.base_currency} - isLoading={isAutoExchangeRateLoading} + formGroupProps={{ label: ' ', inline: true }} + withPopoverRecalcConfirm {...props} /> ); @@ -71,6 +48,7 @@ const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => { * @returns {JSX.Element} */ export const InvoiceExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, withExchangeRateItemEntriesPriceRecalc, )(InvoiceExchangeRateInputFieldRoot); @@ -108,40 +86,26 @@ export const InvoiceNoSyncSettingsToForm = R.compose( }); /** - * Syncs the fetched real-time exchange rate to the form. - * @returns {JSX.Element} + * Syncs the realtime exchange rate to the invoice form and shows up popup to the user + * as an indication the entries rates have been re-calculated. + * @returns {React.ReactNode} */ 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, - ), - ); + useSyncExRateToForm({ + onSynced: () => { // 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); + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); }, 500); } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoExchangeRate?.exchange_rate, autoExRateCurrency]); - + }, + }); return null; }, ); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index f77bfce9d..8c32f017c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -398,85 +398,3 @@ 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]); -}; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx index ca7dd26f6..62206bdbf 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx @@ -34,7 +34,7 @@ import { transformFormValuesToRequest, resetFormState, } from './utils'; -import { ReceiptSyncIncrementSettingsToForm } from './components'; +import { ReceiptSyncAutoExRateToForm, ReceiptSyncIncrementSettingsToForm } from './components'; /** * Receipt form. @@ -171,6 +171,7 @@ function ReceiptForm({ {/*---------- Effects ---------*/} +
diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx index 92ef297cb..1536c9785 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx @@ -33,6 +33,7 @@ import { ReceiptProjectSelectButton, } from './components'; import { ReceiptFormReceiptNumberField } from './ReceiptFormReceiptNumberField'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Receipt form header fields. @@ -46,10 +47,7 @@ export default function ReceiptFormHeader() { {/* ----------- Exchange rate ----------- */} - + {/* ----------- Deposit account ----------- */} { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; + return ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx index dddd093ab..da66da72b 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleReceipt/PageForm.scss'; import ReceiptFrom from './ReceiptForm'; import { ReceiptFormProvider } from './ReceiptFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Receipt form page. @@ -16,7 +17,9 @@ export default function ReceiptFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx index 937a4f937..7d47998e3 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useRef } from 'react'; import intl from 'react-intl-universal'; import { Button } from '@blueprintjs/core'; import { useFormikContext } from 'formik'; @@ -7,20 +7,26 @@ import * as R from 'ramda'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useReceiptIsForeignCustomer } from './utils'; +import { useReceiptIsForeignCustomer, useReceiptTotals } from './utils'; import { useUpdateEffect } from '@/hooks'; -import withSettings from '@/containers/Settings/withSettings'; import { transactionNumber } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** * Receipt exchange rate input field. * @returns {JSX.Element} */ -export function ReceiptExchangeRateInputField({ ...props }) { +function ReceiptExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); - const { values } = useFormikContext(); - const isForeignCustomer = useReceiptIsForeignCustomer(); + const { values } = useFormikContext(); // Can't continue if the customer is not foreign. if (!isForeignCustomer) { @@ -28,13 +34,21 @@ export function ReceiptExchangeRateInputField({ ...props }) { } return ( ); } +export const ReceiptExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(ReceiptExchangeRateInputFieldRoot); + /** * Receipt project select. * @returns {JSX.Element} @@ -73,3 +87,31 @@ export const ReceiptSyncIncrementSettingsToForm = R.compose( return null; }); + +/** + * Syncs the auto exchange rate to the receipt form and shows up popup to user + * as an indication the entries rates have been changed. + * @returns {React.ReactNode} + */ +export const ReceiptSyncAutoExRateToForm = R.compose(withDialogActions)( + ({ + // #withDialogActions + openDialog, + }) => { + const { total } = useReceiptTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // 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.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + return null; + }, +); diff --git a/packages/webapp/src/hooks/query/exchangeRates.tsx b/packages/webapp/src/hooks/query/exchangeRates.tsx index b861d7c7d..f56958040 100644 --- a/packages/webapp/src/hooks/query/exchangeRates.tsx +++ b/packages/webapp/src/hooks/query/exchangeRates.tsx @@ -6,7 +6,9 @@ function getRandomItemFromArray(arr) { const randomIndex = Math.floor(Math.random() * arr.length); return arr[randomIndex]; } - +function delay(t, val) { + return new Promise((resolve) => setTimeout(resolve, t, val)); +} /** * Retrieves tax rates. * @param {number} customerId - Customer id. @@ -18,12 +20,15 @@ export function useExchangeRate( ) { return useQuery( [QUERY_TYPES.EXCHANGE_RATE, fromCurrency, toCurrency], - () => - Promise.resolve({ + async () => { + await delay(100); + + return { from_currency: fromCurrency, to_currency: toCurrency, - exchange_rate: getRandomItemFromArray([4.231, 2.231]), - }), + exchange_rate: 1.00, + }; + }, props, ); }