diff --git a/packages/webapp/src/components/Customers/CustomersSelect.tsx b/packages/webapp/src/components/Customers/CustomersSelect.tsx index 60e030314..60feeebe0 100644 --- a/packages/webapp/src/components/Customers/CustomersSelect.tsx +++ b/packages/webapp/src/components/Customers/CustomersSelect.tsx @@ -34,7 +34,7 @@ function CustomerSelectRoot({ - + ); } diff --git a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx index 9877f5dee..6f5bcdcec 100644 --- a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx +++ b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx @@ -1,30 +1,154 @@ // @ts-nocheck -import React from 'react'; +import { useState } from 'react'; import styled from 'styled-components'; -import { ControlGroup } from '@blueprintjs/core'; - +import { useFormikContext } from 'formik'; +import { + Button, + Classes, + ControlGroup, + Intent, + Popover, + Spinner, +} from '@blueprintjs/core'; import { FlagIcon } from '../Tags'; import { FMoneyInputGroup, FFormGroup } from '../Forms'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +interface ExchangeRateValuesBag { + oldExchangeRate: string; + exchangeRate: string; +} + +interface ExchangeRateInputGroupProps { + name: string; + fromCurrency: string; + toCurrency: string; + isLoading?: boolean; + + inputGroupProps?: any; + formGroupProps?: any; + + popoverRecalcConfirm?: boolean; + + onRecalcConfirm: (bag: ExchangeRateValuesBag) => void; + onCancel: (bag: ExchangeRateValuesBag) => void; + + isConfirmPopoverOpen?: boolean; + initialConfirmPopoverOpen?: boolean; + onConfirmPopoverOpen?: (isOpen: boolean) => void; +} export function ExchangeRateInputGroup({ + name, fromCurrency, toCurrency, + isLoading, + inputGroupProps, formGroupProps, - name, -}) { + + popoverRecalcConfirm = false, + + onRecalcConfirm, + onCancel, + + isConfirmPopoverOpen, + initialConfirmPopoverOpen, + onConfirmPopoverOpen, +}: ExchangeRateInputGroupProps) { + const [isOpen, handlePopoverOpen] = useUncontrolled({ + value: isConfirmPopoverOpen, + initialValue: initialConfirmPopoverOpen, + finalValue: false, + onChange: onConfirmPopoverOpen, + }); + const { values, setFieldValue } = useFormikContext(); + const [oldExchangeRate, setOldExchangeRate] = useState(''); + + const exchangeRate = values[name]; + const exchangeRateValuesBag: ExchangeRateValuesBag = { + exchangeRate, + oldExchangeRate, + }; + // Handle re-calc confirm button click. + const handleRecalcConfirmBtn = () => { + handlePopoverOpen(false); + onRecalcConfirm && onRecalcConfirm(exchangeRateValuesBag); + }; + // Handle cancel button click. + const handleCancelBtn = () => { + handlePopoverOpen(false); + onCancel && onCancel(exchangeRateValuesBag); + }; + // Handle exchange rate field blur. + const handleExchangeRateFieldBlur = (value: string) => { + if (value !== values[name]) { + handlePopoverOpen(true); + setFieldValue(name, value); + setOldExchangeRate(values[name]); + } + }; + + const exchangeRateField = ( + null} + onBlur={handleExchangeRateFieldBlur} + rightElement={isLoading && } + {...inputGroupProps} + name={name} + /> + ); + + const popoverConfirmContent = ( + +

+ Are you want to re-calculate item prices based on this exchange rate +

+
+ + +
+
+ ); + return ( 1 {fromCurrency} = - + + {popoverRecalcConfirm ? ( + + {exchangeRateField} + + ) : ( + exchangeRateField + )} {toCurrency} @@ -34,7 +158,7 @@ export function ExchangeRateInputGroup({ } const ExchangeRateField = styled(FMoneyInputGroup)` - max-width: 75px; + max-width: 85px; `; const ExchangeRateSideIcon = styled.div` @@ -57,3 +181,8 @@ const ExchangeFlagIcon = styled(FlagIcon)` margin-left: 5px; display: inline-block; `; + +const PopoverContent = styled('div')` + padding: 20px; + width: 300px; +`; diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts index 115c25af2..e9c82bdd4 100644 --- a/packages/webapp/src/constants/dialogs.ts +++ b/packages/webapp/src/constants/dialogs.ts @@ -48,4 +48,5 @@ export enum DialogsName { ProjectBillableEntriesForm = 'project-billable-entries', InvoiceNumberSettings = 'InvoiceNumberSettings', TaxRateForm = 'tax-rate-form', + InvoiceExchangeRateChangeNotice = 'InvoiceExchangeRateChangeNotice' } diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index 417583f60..d68666253 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -12,7 +12,6 @@ import PaymentMadesAlerts from '@/containers/Purchases/PaymentMades/PaymentMades import CustomersAlerts from '@/containers/Customers/CustomersAlerts'; import VendorsAlerts from '@/containers/Vendors/VendorsAlerts'; import ManualJournalsAlerts from '@/containers/Accounting/JournalsLanding/ManualJournalsAlerts'; -import ExchangeRatesAlerts from '@/containers/ExchangeRates/ExchangeRatesAlerts'; import ExpensesAlerts from '@/containers/Expenses/ExpensesAlerts'; import AccountTransactionsAlerts from '@/containers/CashFlow/AccountTransactions/AccountTransactionsAlerts'; import UsersAlerts from '@/containers/Preferences/Users/UsersAlerts'; @@ -41,7 +40,6 @@ export default [ ...CustomersAlerts, ...VendorsAlerts, ...ManualJournalsAlerts, - ...ExchangeRatesAlerts, ...ExpensesAlerts, ...AccountTransactionsAlerts, ...UsersAlerts, @@ -54,5 +52,5 @@ export default [ ...WarehousesTransfersAlerts, ...BranchesAlerts, ...ProjectAlerts, - ...TaxRatesAlerts + ...TaxRatesAlerts, ]; diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.schema.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.schema.tsx deleted file mode 100644 index 7ff0ff63d..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.schema.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -import * as Yup from 'yup'; -import intl from 'react-intl-universal'; -import { DATATYPES_LENGTH } from '@/constants/dataTypes'; - -const Schema = Yup.object().shape({ - exchange_rate: Yup.number() - .required() - .label(intl.get('exchange_rate_')), - currency_code: Yup.string() - .max(3) - .required(intl.get('currency_code_')), - date: Yup.date() - .required() - .label(intl.get('date')), -}); - -export const CreateExchangeRateFormSchema = Schema; -export const EditExchangeRateFormSchema = Schema; diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.tsx deleted file mode 100644 index b0cb40518..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// @ts-nocheck -import React, { useMemo } from 'react'; -import intl from 'react-intl-universal'; -import moment from 'moment'; -import { Intent } from '@blueprintjs/core'; -import { Formik } from 'formik'; -import { AppToaster } from '@/components'; -import { - CreateExchangeRateFormSchema, - EditExchangeRateFormSchema, -} from './ExchangeRateForm.schema'; -import ExchangeRateFormContent from './ExchangeRateFormContent'; -import { useExchangeRateFromContext } from './ExchangeRateFormProvider'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; - -import { compose, transformToForm } from '@/utils'; - -const defaultInitialValues = { - exchange_rate: '', - currency_code: '', - date: moment(new Date()).format('YYYY-MM-DD'), -}; - -/** - * Exchange rate form. - */ -function ExchangeRateForm({ - // #withDialogActions - closeDialog, -}) { - const { - createExchangeRateMutate, - editExchangeRateMutate, - isNewMode, - dialogName, - exchangeRate, - } = useExchangeRateFromContext(); - - // Form validation schema in create and edit mode. - const validationSchema = isNewMode - ? CreateExchangeRateFormSchema - : EditExchangeRateFormSchema; - const initialValues = useMemo( - () => ({ - ...defaultInitialValues, - ...transformToForm(exchangeRate, defaultInitialValues), - }), - [], - ); - - // Transformers response errors. - const transformErrors = (errors, { setErrors }) => { - if ( - errors.find((error) => error.type === 'EXCHANGE.RATE.DATE.PERIOD.DEFINED') - ) { - setErrors({ - exchange_rate: intl.get( - 'there_is_exchange_rate_in_this_date_with_the_same_currency', - ), - }); - } - }; - - // Handle the form submit. - const handleFormSubmit = (values, { setSubmitting, setErrors }) => { - setSubmitting(true); - - // Handle close the dialog after success response. - const afterSubmit = () => { - closeDialog(dialogName); - }; - const onSuccess = ({ response }) => { - AppToaster.show({ - message: intl.get( - !isNewMode - ? 'the_exchange_rate_has_been_edited_successfully' - : 'the_exchange_rate_has_been_created_successfully', - ), - intent: Intent.SUCCESS, - }); - afterSubmit(response); - }; - // Handle the response error. - const onError = (error) => { - const { - response: { - data: { errors }, - }, - } = error; - - transformErrors(errors, { setErrors }); - setSubmitting(false); - }; - if (isNewMode) { - createExchangeRateMutate(values).then(onSuccess).catch(onError); - } else { - editExchangeRateMutate([exchangeRate.id, values]) - .then(onSuccess) - .catch(onError); - } - }; - - return ( - - - - ); -} - -export default compose(withDialogActions)(ExchangeRateForm); diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormContent.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormContent.tsx deleted file mode 100644 index 07ecc1ebc..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormContent.tsx +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { Form } from 'formik'; -import ExchangeRateFormFields from './ExchangeRateFormFields'; -import ExchangeRateFormFooter from './ExchangeRateFormFooter'; - -export default function ExchangeRateFormContent() { - return ( -
- - - - ); -} diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormDialogContent.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormDialogContent.tsx deleted file mode 100644 index 2cad6e0c3..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormDialogContent.tsx +++ /dev/null @@ -1,27 +0,0 @@ -// @ts-nocheck -import React from 'react'; - -import ExchangeRateForm from './ExchangeRateForm'; -import { ExchangeRateFormProvider } from './ExchangeRateFormProvider'; - -import '@/style/pages/ExchangeRate/ExchangeRateDialog.scss'; - -/** - * Exchange rate form content. - */ -export default function ExchangeRateFormDialogContent({ - // #ownProp - action, - exchangeRateId, - dialogName, -}) { - return ( - - - - ); -} diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFields.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFields.tsx deleted file mode 100644 index 58c3eb262..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFields.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { Classes, FormGroup, InputGroup, Position } from '@blueprintjs/core'; -import { FastField } from 'formik'; -import { DateInput } from '@blueprintjs/datetime'; -import { FormattedMessage as T } from '@/components'; -import classNames from 'classnames'; -import { - momentFormatter, - tansformDateValue, - handleDateChange, - inputIntent, -} from '@/utils'; -import { - ErrorMessage, - FieldRequiredHint, - CurrencySelectList, -} from '@/components'; -import { useExchangeRateFromContext } from './ExchangeRateFormProvider'; - - -export default function ExchangeRateFormFields() { - const { action, currencies } = useExchangeRateFromContext(); - - return ( -
- {/* ----------- Date ----------- */} - - {({ form, field: { value }, meta: { error, touched } }) => ( - } - labelInfo={FieldRequiredHint} - className={classNames('form-group--select-list', Classes.FILL)} - intent={inputIntent({ error, touched })} - helperText={} - inline={true} - > - { - form.setFieldValue('date', formattedDate); - })} - popoverProps={{ position: Position.BOTTOM, minimal: true }} - disabled={action === 'edit'} - /> - - )} - - {/* ----------- Currency Code ----------- */} - - {({ form, field: { value }, meta: { error, touched } }) => ( - } - labelInfo={} - className={classNames('form-group--currency', Classes.FILL)} - intent={inputIntent({ error, touched })} - helperText={} - inline={true} - > - { - form.setFieldValue('currency_code', currency_code); - }} - disabled={action === 'edit'} - /> - - )} - - - {/*------------ Exchange Rate -----------*/} - - {({ form, field, meta: { error, touched } }) => ( - } - labelInfo={} - intent={inputIntent({ error, touched })} - helperText={} - inline={true} - > - - - )} - -
- ); -} diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFooter.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFooter.tsx deleted file mode 100644 index ef66f7674..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFooter.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { useFormikContext } from 'formik'; - -import { Button, Classes, Intent } from '@blueprintjs/core'; -import { FormattedMessage as T } from '@/components'; -import { useExchangeRateFromContext } from './ExchangeRateFormProvider'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; -import { compose } from '@/utils'; - -function ExchangeRateFormFooter({ - // #withDialogActions - closeDialog, -}) { - const { isSubmitting } = useFormikContext(); - const { dialogName, action } = useExchangeRateFromContext(); - - const handleClose = () => { - closeDialog(dialogName); - }; - - return ( -
-
- - -
-
- ); -} - -export default compose(withDialogActions)(ExchangeRateFormFooter); diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormProvider.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormProvider.tsx deleted file mode 100644 index 90bdf8e96..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormProvider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-nocheck -import React, { createContext, useContext } from 'react'; -import { - useCreateExchangeRate, - useEdiExchangeRate, - useCurrencies, - useExchangeRates, -} from '@/hooks/query'; -import { DialogContent } from '@/components'; - -const ExchangeRateFormContext = createContext(); - -/** - * Exchange rate Form page provider. - */ -function ExchangeRateFormProvider({ - exchangeRate, - action, - dialogName, - ...props -}) { - // Create and edit exchange rate mutations. - const { mutateAsync: createExchangeRateMutate } = useCreateExchangeRate(); - const { mutateAsync: editExchangeRateMutate } = useEdiExchangeRate(); - - // Load Currencies list. - const { data: currencies, isFetching: isCurrenciesLoading } = useCurrencies(); - const { isFetching: isExchangeRatesLoading } = useExchangeRates(); - - const isNewMode = !exchangeRate; - - // Provider state. - const provider = { - createExchangeRateMutate, - editExchangeRateMutate, - dialogName, - exchangeRate, - action, - currencies, - isExchangeRatesLoading, - isNewMode, - }; - - return ( - - - - ); -} - -const useExchangeRateFromContext = () => useContext(ExchangeRateFormContext); - -export { ExchangeRateFormProvider, useExchangeRateFromContext }; diff --git a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/index.tsx b/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/index.tsx deleted file mode 100644 index 3bbc71954..000000000 --- a/packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// @ts-nocheck -import React, { lazy } from 'react'; -import { Dialog, DialogSuspense, FormattedMessage as T } from '@/components'; -import withDialogRedux from '@/components/DialogReduxConnect'; -import { compose } from '@/utils'; - -const ExchangeRateFormDialogContent = lazy( - () => import('./ExchangeRateFormDialogContent'), -); - -/** - * Exchange rate form dialog. - */ -function ExchangeRateFormDialog({ - dialogName, - payload = { action: '', id: null, exchangeRate: '' }, - isOpen, -}) { - return ( - - ) : ( - - ) - } - className={'dialog--exchangeRate-form'} - isOpen={isOpen} - autoFocus={true} - canEscapeKeyClose={true} - > - - - - - ); -} - -export default compose(withDialogRedux())(ExchangeRateFormDialog); diff --git a/packages/webapp/src/containers/ExchangeRates/ExchangeRateActionsBar.tsx b/packages/webapp/src/containers/ExchangeRates/ExchangeRateActionsBar.tsx deleted file mode 100644 index e2fadd8b4..000000000 --- a/packages/webapp/src/containers/ExchangeRates/ExchangeRateActionsBar.tsx +++ /dev/null @@ -1,147 +0,0 @@ -// @ts-nocheck -import React, { useCallback, useState, useMemo } from 'react'; -import intl from 'react-intl-universal'; -import classNames from 'classnames'; -import { - NavbarGroup, - NavbarDivider, - Button, - Classes, - Intent, - Popover, - Position, - PopoverInteractionKind, - Alignment, -} from '@blueprintjs/core'; -import { - Icon, - If, - DashboardActionsBar, - FormattedMessage as T, -} from '@/components'; -import { connect } from 'react-redux'; - -import { useRefreshExchangeRate } from '@/hooks/query/exchangeRates'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; -import withResourceDetail from '@/containers/Resources/withResourceDetails'; -import withExchangeRatesActions from './withExchangeRatesActions'; -import { compose } from '@/utils'; - -/** - * Exchange rate actions bar. - */ -function ExchangeRateActionsBar({ - // #withDialogActions. - openDialog, - - // #withResourceDetail - resourceFields, - - //#withExchangeRatesActions - addExchangeRatesTableQueries, - - // #ownProps - selectedRows = [], - onDeleteExchangeRate, - onFilterChanged, - onBulkDelete, -}) { - const [filterCount, setFilterCount] = useState(0); - - const onClickNewExchangeRate = () => { - openDialog('exchangeRate-form', {}); - }; - - // Exchange rates refresh action. - const { refresh } = useRefreshExchangeRate(); - - // Handle click a refresh sale estimates - const handleRefreshBtnClick = () => { - refresh(); - }; - - const hasSelectedRows = useMemo( - () => selectedRows.length > 0, - [selectedRows], - ); - - const handelBulkDelete = useCallback(() => { - onBulkDelete && onBulkDelete(selectedRows.map((r) => r.id)); - }, [onBulkDelete, selectedRows]); - - return ( - - - + + + + ); +} + +export default compose( + withDialogRedux(), + withDialogActions, +)(InvoiceExchangeRateChangeDialog); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/index.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/index.ts new file mode 100644 index 000000000..cda7f24dc --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/index.ts @@ -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; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx index a8463619b..7a975551d 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx @@ -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 ----------*/} + diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx index 8697e52bc..51ba306fa 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx @@ -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 ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} allowCreate={true} fastField={true} shouldUpdate={customerNameFieldShouldUpdate} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx index fd1b3b0b2..1969abd61 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 { 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(''); + 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 ( diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx index 0020a7e8d..e25b389d7 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx @@ -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 ( + { + 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 ( ); -} +}; + +/** + * 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; + }, +); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index 4186cbc63..b059a48fa 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -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]); +}; diff --git a/packages/webapp/src/hooks/query/exchangeRates.tsx b/packages/webapp/src/hooks/query/exchangeRates.tsx index f38b66737..b861d7c7d 100644 --- a/packages/webapp/src/hooks/query/exchangeRates.tsx +++ b/packages/webapp/src/hooks/query/exchangeRates.tsx @@ -1,102 +1,29 @@ // @ts-nocheck -import { useMutation, useQueryClient } from 'react-query'; -import { defaultTo } from 'lodash'; -import { useQueryTenant } from '../useQueryRequest'; -import { transformPagination } from '@/utils'; -import useApiRequest from '../useRequest'; +import { useQuery } from 'react-query'; +import QUERY_TYPES from './types'; -const defaultPagination = { - pageSize: 20, - page: 0, - pagesCount: 0, -}; -/** - * Creates a new exchange rate. - */ -export function useCreateExchangeRate(props) { - const queryClient = useQueryClient(); - const apiRequest = useApiRequest(); - - return useMutation((values) => apiRequest.post('exchange_rates', values), { - onSuccess: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - ...props, - }); +function getRandomItemFromArray(arr) { + const randomIndex = Math.floor(Math.random() * arr.length); + return arr[randomIndex]; } /** - * Edits the exchange rate. + * Retrieves tax rates. + * @param {number} customerId - Customer id. */ -export function useEdiExchangeRate(props) { - const queryClient = useQueryClient(); - const apiRequest = useApiRequest(); - - return useMutation( - ([id, values]) => apiRequest.post(`exchange_rates/${id}`, values), - { - onSuccess: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - ...props, - }, - ); -} - -/** - * Deletes the exchange rate. - */ -export function useDeleteExchangeRate(props) { - const queryClient = useQueryClient(); - const apiRequest = useApiRequest(); - - return useMutation((id) => apiRequest.delete(`exchange_rates/${id}`), { - onSuccess: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - ...props, - }); -} - -/** - * Retrieve the exchange rate list. - */ -export function useExchangeRates(query, props) { - const apiRequest = useApiRequest(); - - const states = useQueryTenant( - ['EXCHANGES_RATES', query], - () => apiRequest.get('exchange_rates', { params: query }), - { - select: (res) => ({ - exchangesRates: res.data.exchange_rates.results, - pagination: transformPagination(res.data.exchange_rates.pagination), - filterMeta: res.data.filter_meta, +export function useExchangeRate( + fromCurrency: string, + toCurrency: string, + props, +) { + return useQuery( + [QUERY_TYPES.EXCHANGE_RATE, fromCurrency, toCurrency], + () => + Promise.resolve({ + from_currency: fromCurrency, + to_currency: toCurrency, + exchange_rate: getRandomItemFromArray([4.231, 2.231]), }), - ...props, - }, + props, ); - - return { - ...states, - data: defaultTo(states.data, { - exchangesRates: [], - pagination: { - page: 1, - pageSize: 20, - total: 0, - }, - filterMeta: {}, - }), - }; -} - -export function useRefreshExchangeRate() { - const queryClient = useQueryClient(); - - return { - refresh: () => { - queryClient.invalidateQueries('EXCHANGES_RATES'); - }, - }; } diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index c0173cab7..fbce84ae3 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -32,7 +32,7 @@ const FINANCIAL_REPORTS = { REALIZED_GAIN_OR_LOSS: 'REALIZED_GAIN_OR_LOSS', UNREALIZED_GAIN_OR_LOSS: 'UNREALIZED_GAIN_OR_LOSS', PROJECT_PROFITABILITY_SUMMARY: 'PROJECT_PROFITABILITY_SUMMARY', - SALES_TAX_LIABILITY_SUMMARY: 'SALES_TAX_LIABILITY_SUMMARY' + SALES_TAX_LIABILITY_SUMMARY: 'SALES_TAX_LIABILITY_SUMMARY', }; const BILLS = { @@ -222,12 +222,17 @@ const DASHBOARD = { }; const ORGANIZATION = { - ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES: 'ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES', + ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES: + 'ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES', }; export const TAX_RATES = { TAX_RATES: 'TAX_RATES', -} +}; + +export const EXCHANGE_RATE = { + EXCHANGE_RATE: 'EXCHANGE_RATE', +}; export default { ...Authentication, @@ -262,5 +267,6 @@ export default { ...BRANCHES, ...DASHBOARD, ...ORGANIZATION, - ...TAX_RATES + ...TAX_RATES, + ...EXCHANGE_RATE, }; diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index 5193c63f8..c1137bc5b 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -473,16 +473,6 @@ export const getDashboardRoutes = () => [ pageTitle: intl.get('all_financial_reports'), subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], }, - // Exchange Rates - // { - // path: `/exchange-rates`, - // component: lazy( - // () => import('@/containers/ExchangeRates/ExchangeRatesList'), - // ), - // breadcrumb: intl.get('exchange_rates_list'), - // pageTitle: intl.get('exchange_rates_list'), - // subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], - // }, // Expenses. { path: `/expenses/new`,