Merge pull request #270 from bigcapitalhq/big-44-auto-re-calculate-the-items-rate-once-changing-the-invoice

feat: Auto re-calculate the items rate once changing the invoice exchange rate.
This commit is contained in:
Ahmed Bouhuolia
2024-01-14 16:08:56 +02:00
committed by GitHub
48 changed files with 807 additions and 1065 deletions

View File

@@ -34,7 +34,7 @@ function CustomerSelectRoot({
<FSelect
items={items}
textAccessor={'display_name'}
labelAccessor={'code'}
labelAccessor={'currency_code'}
valueAccessor={'id'}
popoverProps={{ minimal: true, usePortal: true, inline: false }}
createNewItemRenderer={maybeCreateNewItemRenderer}

View File

@@ -3,8 +3,6 @@ import InviteUserDialog from '@/containers/Dialogs/InviteUserDialog';
import UserFormDialog from '@/containers/Dialogs/UserFormDialog';
import ItemCategoryDialog from '@/containers/Dialogs/ItemCategoryDialog';
import CurrencyFormDialog from '@/containers/Dialogs/CurrencyFormDialog';
import ExchangeRateFormDialog from '@/containers/Dialogs/ExchangeRateFormDialog';
import InventoryAdjustmentDialog from '@/containers/Dialogs/InventoryAdjustmentFormDialog';
import PaymentViaVoucherDialog from '@/containers/Dialogs/PaymentViaVoucherDialog';
import KeyboardShortcutsDialog from '@/containers/Dialogs/keyboardShortcutsDialog';
@@ -47,6 +45,7 @@ import ProjectInvoicingFormDialog from '@/containers/Projects/containers/Project
import ProjectBillableEntriesFormDialog from '@/containers/Projects/containers/ProjectBillableEntriesFormDialog';
import TaxRateFormDialog from '@/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog';
import { DialogsName } from '@/constants/dialogs';
import InvoiceExchangeRateChangeDialog from '@/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog';
import InvoiceMailDialog from '@/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog';
import EstimateMailDialog from '@/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog';
import ReceiptMailDialog from '@/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog';
@@ -62,7 +61,6 @@ export default function DialogsContainer() {
<CurrencyFormDialog dialogName={DialogsName.CurrencyForm} />
<InviteUserDialog dialogName={DialogsName.InviteForm} />
<UserFormDialog dialogName={DialogsName.UserForm} />
<ExchangeRateFormDialog dialogName={DialogsName.ExchangeRateForm} />
<ItemCategoryDialog dialogName={DialogsName.ItemCategoryForm} />
<InventoryAdjustmentDialog
dialogName={DialogsName.InventoryAdjustmentForm}
@@ -141,6 +139,9 @@ export default function DialogsContainer() {
dialogName={DialogsName.ProjectBillableEntriesForm}
/>
<TaxRateFormDialog dialogName={DialogsName.TaxRateForm} />
<InvoiceExchangeRateChangeDialog
dialogName={DialogsName.InvoiceExchangeRateChangeNotice}
/>
<InvoiceMailDialog dialogName={DialogsName.InvoiceMail} />
<EstimateMailDialog dialogName={DialogsName.EstimateMail} />
<ReceiptMailDialog dialogName={DialogsName.ReceiptMail} />

View File

@@ -1,30 +1,159 @@
// @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,
Position,
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;
withPopoverRecalcConfirm?: 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,
}) {
withPopoverRecalcConfirm = false,
onRecalcConfirm,
onCancel,
isConfirmPopoverOpen,
initialConfirmPopoverOpen,
onConfirmPopoverOpen,
}: ExchangeRateInputGroupProps) {
const [isOpen, handlePopoverOpen] = useUncontrolled<boolean>({
value: isConfirmPopoverOpen,
initialValue: initialConfirmPopoverOpen,
finalValue: false,
onChange: onConfirmPopoverOpen,
});
const { values, setFieldValue } = useFormikContext();
const [oldExchangeRate, setOldExchangeRate] = useState<string>('');
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 = (
<ExchangeRateField
allowDecimals={true}
allowNegativeValue={true}
asyncControl={true}
onChange={() => null}
onBlur={handleExchangeRateFieldBlur}
rightElement={isLoading && <Spinner size={16} />}
decimalsLimit={5}
{...inputGroupProps}
name={name}
/>
);
const popoverConfirmContent = (
<PopoverContent>
<p>
Are you want to re-calculate item prices based on this exchange rate.
</p>
<div
style={{
display: 'flex',
marginTop: 15,
}}
>
<Button
intent={Intent.WARNING}
className={Classes.POPOVER_DISMISS}
onClick={handleRecalcConfirmBtn}
small
>
Calculate
</Button>
<Button
className={Classes.POPOVER_DISMISS}
style={{ marginRight: 10 }}
onClick={handleCancelBtn}
small
minimal
>
Cancel
</Button>
</div>
</PopoverContent>
);
return (
<FFormGroup inline={true} {...formGroupProps} name={name}>
<ControlGroup>
<ExchangeRatePrepend>
<ExchangeFlagIcon currencyCode={fromCurrency} /> 1 {fromCurrency} =
</ExchangeRatePrepend>
<ExchangeRateField
allowDecimals={true}
allowNegativeValue={true}
{...inputGroupProps}
name={name}
/>
{withPopoverRecalcConfirm ? (
<Popover
isOpen={isOpen}
content={popoverConfirmContent}
position={Position.RIGHT}
>
{exchangeRateField}
</Popover>
) : (
exchangeRateField
)}
<ExchangeRateAppend>
<ExchangeFlagIcon currencyCode={toCurrency} /> {toCurrency}
</ExchangeRateAppend>
@@ -34,7 +163,7 @@ export function ExchangeRateInputGroup({
}
const ExchangeRateField = styled(FMoneyInputGroup)`
max-width: 75px;
max-width: 85px;
`;
const ExchangeRateSideIcon = styled.div`
@@ -57,3 +186,8 @@ const ExchangeFlagIcon = styled(FlagIcon)`
margin-left: 5px;
display: inline-block;
`;
const PopoverContent = styled('div')`
padding: 20px;
width: 300px;
`;

View File

@@ -48,6 +48,7 @@ export enum DialogsName {
ProjectBillableEntriesForm = 'project-billable-entries',
InvoiceNumberSettings = 'InvoiceNumberSettings',
TaxRateForm = 'tax-rate-form',
InvoiceExchangeRateChangeNotice = 'InvoiceExchangeRateChangeNotice',
InvoiceMail = 'invoice-mail',
EstimateMail = 'estimate-mail',
ReceiptMail = 'receipt-mail',

View File

@@ -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,
];

View File

@@ -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;

View File

@@ -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 (
<Formik
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={handleFormSubmit}
>
<ExchangeRateFormContent />
</Formik>
);
}
export default compose(withDialogActions)(ExchangeRateForm);

View File

@@ -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 (
<Form>
<ExchangeRateFormFields />
<ExchangeRateFormFooter />
</Form>
);
}

View File

@@ -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 (
<ExchangeRateFormProvider
dialogName={dialogName}
exchangeRate={exchangeRateId}
action={action}
>
<ExchangeRateForm />
</ExchangeRateFormProvider>
);
}

View File

@@ -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 (
<div className={Classes.DIALOG_BODY}>
{/* ----------- Date ----------- */}
<FastField name={'date'}>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'date'} />}
labelInfo={FieldRequiredHint}
className={classNames('form-group--select-list', Classes.FILL)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="date" />}
inline={true}
>
<DateInput
{...momentFormatter('YYYY/MM/DD')}
value={tansformDateValue(value)}
onChange={handleDateChange((formattedDate) => {
form.setFieldValue('date', formattedDate);
})}
popoverProps={{ position: Position.BOTTOM, minimal: true }}
disabled={action === 'edit'}
/>
</FormGroup>
)}
</FastField>
{/* ----------- Currency Code ----------- */}
<FastField name={'currency_code'}>
{({ form, field: { value }, meta: { error, touched } }) => (
<FormGroup
label={<T id={'currency_code'} />}
labelInfo={<FieldRequiredHint />}
className={classNames('form-group--currency', Classes.FILL)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="currency_code" />}
inline={true}
>
<CurrencySelectList
currenciesList={currencies}
selectedCurrencyCode={value}
onCurrencySelected={({ currency_code }) => {
form.setFieldValue('currency_code', currency_code);
}}
disabled={action === 'edit'}
/>
</FormGroup>
)}
</FastField>
{/*------------ Exchange Rate -----------*/}
<FastField name={'exchange_rate'}>
{({ form, field, meta: { error, touched } }) => (
<FormGroup
label={<T id={'exchange_rate'} />}
labelInfo={<FieldRequiredHint />}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="exchange_rate" />}
inline={true}
>
<InputGroup intent={inputIntent({ error, touched })} {...field} />
</FormGroup>
)}
</FastField>
</div>
);
}

View File

@@ -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 (
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={handleClose}>
<T id={'close'} />
</Button>
<Button intent={Intent.PRIMARY} type="submit" disabled={isSubmitting}>
{action === 'edit' ? <T id={'edit'} /> : <T id={'submit'} />}
</Button>
</div>
</div>
);
}
export default compose(withDialogActions)(ExchangeRateFormFooter);

View File

@@ -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 (
<DialogContent isLoading={isCurrenciesLoading} name={'exchange-rate-form'}>
<ExchangeRateFormContext.Provider value={provider} {...props} />
</DialogContent>
);
}
const useExchangeRateFromContext = () => useContext(ExchangeRateFormContext);
export { ExchangeRateFormProvider, useExchangeRateFromContext };

View File

@@ -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 (
<Dialog
name={dialogName}
title={
payload.action === 'edit' ? (
<T id={'edit_exchange_rate'} />
) : (
<T id={'new_exchange_rate'} />
)
}
className={'dialog--exchangeRate-form'}
isOpen={isOpen}
autoFocus={true}
canEscapeKeyClose={true}
>
<DialogSuspense>
<ExchangeRateFormDialogContent
dialogName={dialogName}
action={payload.action}
exchangeRateId={payload.exchangeRate}
/>
</DialogSuspense>
</Dialog>
);
}
export default compose(withDialogRedux())(ExchangeRateFormDialog);

View File

@@ -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<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: 0,
cacheTime: 0,
});
const value = {
autoExRateCurrency,
setAutoExRateCurrency,
isAutoExchangeRateLoading,
autoExchangeRate,
};
return (
<AutoExchangeRateContext.Provider value={value}>
{children}
</AutoExchangeRateContext.Provider>
);
}
const useAutoExRateContext = () => React.useContext(AutoExchangeRateContext);
export {
useAutoExRateContext,
AutoExchangeRateContext,
AutoExchangeRateProvider,
};

View File

@@ -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]);
};

View File

@@ -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 (
<Component
onRecalcConfirm={({ exchangeRate, oldExchangeRate }) => {
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 (
<Component
isLoading={isAutoExchangeRateLoading}
inputGroupProps={{
disabled: isAutoExchangeRateLoading,
}}
/>
);
};
/**
* 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;
};

View File

@@ -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 (
<DashboardActionsBar>
<NavbarGroup>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="plus" />}
text={<T id={'new_exchange_rate'} />}
onClick={onClickNewExchangeRate}
/>
<NavbarDivider />
<Popover
minimal={true}
// content={filterDropdown}
interactionKind={PopoverInteractionKind.CLICK}
position={Position.BOTTOM_LEFT}
>
<Button
className={classNames(Classes.MINIMAL, 'button--filter')}
text={
filterCount <= 0 ? (
<T id={'filter'} />
) : (
`${filterCount} ${intl.get('filters_applied')}`
)
}
icon={<Icon icon="filter-16" iconSize={16} />}
/>
</Popover>
<If condition={hasSelectedRows}>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="trash-16" iconSize={16} />}
text={<T id={'delete'} />}
intent={Intent.DANGER}
onClick={handelBulkDelete}
/>
</If>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="file-import-16" iconSize={16} />}
text={<T id={'import'} />}
/>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="file-export-16" iconSize={16} />}
text={<T id={'export'} />}
/>
</NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="refresh-16" iconSize={14} />}
onClick={handleRefreshBtnClick}
/>
</NavbarGroup>
</DashboardActionsBar>
);
}
const mapStateToProps = (state, props) => ({
resourceName: '',
});
const withExchangeRateActionBar = connect(mapStateToProps);
export default compose(
withExchangeRateActionBar,
withDialogActions,
withResourceDetail(({ resourceFields }) => ({
resourceFields,
})),
withExchangeRatesActions,
)(ExchangeRateActionsBar);

View File

@@ -1,110 +0,0 @@
// @ts-nocheck
import React, { useCallback } from 'react';
import {
DataTable,
TableSkeletonRows,
TableSkeletonHeader,
} from '@/components';
import { useExchangeRatesContext } from './ExchangeRatesProvider';
import { useExchangeRatesTableColumns, ActionMenuList } from './components';
import withExchangeRates from './withExchangeRates';
import withExchangeRatesActions from './withExchangeRatesActions';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import withAlertActions from '@/containers/Alert/withAlertActions';
import { compose } from '@/utils';
/**
* Exchange rates table.
*/
function ExchangeRateTable({
// #ownProps
tableProps,
// #withDialogActions.
openDialog,
// #withAlertActions
openAlert,
// #withExchangeRatesActions
setExchangeRateTableState,
// #withExchangeRates
exchangeRatesTableState,
}) {
const {
isExchangeRatesFetching,
isExchangeRatesLoading,
exchangesRates,
pagination,
} = useExchangeRatesContext();
// Table columns.
const columns = useExchangeRatesTableColumns();
// Handle delete exchange rate.
const handleDeleteExchangeRate = ({ id }) => {
openAlert('exchange-rate-delete', { exchangeRateId: id });
};
// Handle Edit exchange rate.
const handelEditExchangeRate = (exchangeRate) => {
openDialog('exchangeRate-form', {
action: 'edit',
exchangeRate: exchangeRate,
});
};
const handleFetchData = useCallback(
({ pageSize, pageIndex, sortBy }) => {
setExchangeRateTableState({
pageIndex,
pageSize,
sortBy,
});
},
[setExchangeRateTableState],
);
return (
<DataTable
noInitialFetch={true}
columns={columns}
data={exchangesRates}
initialState={exchangeRatesTableState}
loading={isExchangeRatesLoading}
headerLoading={isExchangeRatesLoading}
progressBarLoading={isExchangeRatesFetching}
selectionColumn={true}
expandable={true}
sticky={true}
manualSortBy={true}
onFetchData={handleFetchData}
pagination={true}
manualPagination={true}
pagesCount={pagination.pagesCount}
TableLoadingRenderer={TableSkeletonRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
ContextMenu={ActionMenuList}
payload={{
onDeleteExchangeRate: handleDeleteExchangeRate,
onEditExchangeRate: handelEditExchangeRate,
}}
{...tableProps}
/>
);
}
export default compose(
withDialogActions,
withAlertActions,
withExchangeRates(({ exchangeRatesTableState }) => ({
exchangeRatesTableState,
})),
withExchangeRatesActions,
)(ExchangeRateTable);

View File

@@ -1,10 +0,0 @@
// @ts-nocheck
import React from 'react';
const ExchangeRateDeleteAlert = React.lazy(
() => import('@/containers/Alerts/ExchangeRates/ExchangeRateDeleteAlert'),
);
export default [
{ name: 'exchange-rate-delete', component: ExchangeRateDeleteAlert },
];

View File

@@ -1,38 +0,0 @@
// @ts-nocheck
import React from 'react';
import { DashboardContentTable, DashboardPageContent } from '@/components';
import ExchangeRateTable from './ExchangeRateTable';
import ExchangeRateActionsBar from './ExchangeRateActionsBar';
import { ExchangeRatesProvider } from './ExchangeRatesProvider';
import { transformTableStateToQuery, compose } from '@/utils';
import withExchangeRates from './withExchangeRates';
/**
* Exchange Rates list.
*/
function ExchangeRatesList({
// #withExchangeRates
exchangeRatesTableState,
}) {
return (
<ExchangeRatesProvider
query={transformTableStateToQuery(exchangeRatesTableState)}
>
<ExchangeRateActionsBar />
<DashboardPageContent>
<DashboardContentTable>
<ExchangeRateTable />
</DashboardContentTable>
</DashboardPageContent>
</ExchangeRatesProvider>
);
}
export default compose(
withExchangeRates(({ exchangeRatesTableState }) => ({
exchangeRatesTableState,
})),
)(ExchangeRatesList);

View File

@@ -1,42 +0,0 @@
// @ts-nocheck
import React, { createContext } from 'react';
import { transformTableQueryToParams } from '@/utils';
import { DashboardInsider } from '@/components';
import { useExchangeRates } from '@/hooks/query';
const ExchangesRatesContext = createContext();
/**
* Exchanges rates list provider.
*/
function ExchangeRatesProvider({ query, ...props }) {
const {
data: { exchangesRates, pagination, filterMeta },
isFetching: isExchangeRatesFetching,
isLoading: isExchangeRatesLoading,
} = useExchangeRates(
{
...transformTableQueryToParams(query),
},
{ keepPreviousData: true },
);
const state = {
isExchangeRatesFetching,
isExchangeRatesLoading,
exchangesRates,
pagination,
};
return (
<DashboardInsider name={'exchange-rate'}>
<ExchangesRatesContext.Provider value={state} {...props} />
</DashboardInsider>
);
}
const useExchangeRatesContext = () => React.useContext(ExchangesRatesContext);
export { ExchangeRatesProvider, useExchangeRatesContext };

View File

@@ -1,91 +0,0 @@
// @ts-nocheck
import React, { useMemo } from 'react';
import moment from 'moment';
import intl from 'react-intl-universal';
import {
Menu,
Popover,
Button,
Position,
MenuItem,
MenuDivider,
Intent,
} from '@blueprintjs/core';
import { Icon, Money } from '@/components';
import { safeCallback } from '@/utils';
/**
* Row actions menu list.
*/
export function ActionMenuList({
row: { original },
payload: { onEditExchangeRate, onDeleteExchangeRate },
}) {
return (
<Menu>
<MenuItem
icon={<Icon icon="pen-18" />}
text={intl.get('edit_exchange_rate')}
onClick={safeCallback(onEditExchangeRate, original)}
/>
<MenuDivider />
<MenuItem
text={intl.get('delete_exchange_rate')}
intent={Intent.DANGER}
onClick={safeCallback(onDeleteExchangeRate, original)}
icon={<Icon icon="trash-16" iconSize={16} />}
/>
</Menu>
);
}
/**
* Table actions cell.
*/
export function TableActionsCell(props) {
return (
<Popover
content={<ActionMenuList {...props} />}
position={Position.RIGHT_TOP}
>
<Button icon={<Icon icon="more-h-16" iconSize={16} />} />
</Popover>
);
}
export function useExchangeRatesTableColumns() {
return useMemo(
() => [
{
id: 'date',
Header: intl.get('date'),
accessor: (r) => moment(r.date).format('YYYY MMM DD'),
width: 150,
},
{
id: 'currency_code',
Header: intl.get('currency_code'),
accessor: 'currency_code',
className: 'currency_code',
width: 150,
},
{
id: 'exchange_rate',
Header: intl.get('exchange_rate'),
accessor: (r) => (
<Money amount={r.exchange_rate} currency={r.currency_code} />
),
className: 'exchange_rate',
width: 150,
},
{
id: 'actions',
Header: '',
Cell: TableActionsCell,
className: 'actions',
width: 50,
},
],
[],
);
}

View File

@@ -1,9 +0,0 @@
// @ts-nocheck
import { connect } from 'react-redux';
import { getExchangeRateById } from '@/store/ExchangeRate/exchange.selector';
const mapStateToProps = (state, props) => ({
exchangeRate: getExchangeRateById(state, props),
});
export default connect(mapStateToProps);

View File

@@ -1,16 +0,0 @@
// @ts-nocheck
import { connect } from 'react-redux';
import { getExchangeRatesTableStateFactory } from '@/store/ExchangeRate/exchange.selector';
export default (mapState) => {
const getExchangeRatesTableState = getExchangeRatesTableStateFactory();
const mapStateToProps = (state, props) => {
const mapped = {
exchangeRatesTableState: getExchangeRatesTableState(state, props),
};
return mapState ? mapState(mapped, state, props) : mapped;
};
return connect(mapStateToProps);
};

View File

@@ -1,10 +0,0 @@
// @ts-nocheck
import { connect } from 'react-redux';
import { setExchangeRateTableState } from '@/store/ExchangeRate/exchange.actions';
export const mapDispatchToProps = (dispatch) => ({
setExchangeRateTableState: (queries) =>
dispatch(setExchangeRateTableState(queries)),
});
export default connect(null, mapDispatchToProps);

View File

@@ -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 --------*/}
<CreditNoteSyncIncrementSettingsToForm />
<CreditNoteExchangeRateSync />
</Form>
</Formik>
</div>

View File

@@ -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({}) {
<CreditNoteCustomersSelect />
{/* ----------- Exchange rate ----------- */}
<CreditNoteExchangeRateInputField
name={'exchange_rate'}
formGroupProps={{ label: ' ', inline: true }}
/>
<CreditNoteExchangeRateInputField />
{/* ----------- Credit note date ----------- */}
<FastField name={'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 (
<FFormGroup
@@ -110,10 +119,7 @@ function CreditNoteCustomersSelect() {
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}
popoverFill={true}
allowCreate={true}
fastField={true}

View File

@@ -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 (
<CreditNoteFormProvider creditNoteId={idAsInteger}>
<CreditNoteForm />
<AutoExchangeRateProvider>
<CreditNoteForm />
</AutoExchangeRateProvider>
</CreditNoteFormProvider>
);
}

View File

@@ -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 (
<ExchangeRateInputGroup
name={'exchange_rate'}
fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency}
formGroupProps={{ label: ' ', inline: true }}
withPopoverRecalcConfirm
{...props}
/>
);
}
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;
},
);

View File

@@ -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 -------*/}
<EstimateIncrementSyncSettingsToForm />
<EstimateSyncAutoExRateToForm />
</Form>
</Formik>
</div>

View File

@@ -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 (
<div className={classNames(CLASSES.PAGE_FORM_HEADER_FIELDS)}>
@@ -45,10 +44,8 @@ export default function EstimateFormHeader() {
<EstimateFormCustomerSelect />
{/* ----------- Exchange Rate ----------- */}
<EstimateExchangeRateInputField
name={'exchange_rate'}
formGroupProps={{ label: ' ', inline: true }}
/>
<EstimateExchangeRateInputField />
{/* ----------- Estimate Date ----------- */}
<FastField name={'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 (
<FFormGroup
label={<T id={'customer_name'} />}
@@ -165,10 +172,7 @@ function EstimateFormCustomerSelect() {
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}
popoverFill={true}
allowCreate={true}
fastField={true}

View File

@@ -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 (
<EstimateFormProvider estimateId={idInteger}>
<EstimateForm />
<AutoExchangeRateProvider>
<EstimateForm />
</AutoExchangeRateProvider>
</EstimateFormProvider>
);
}

View File

@@ -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 (
<ExchangeRateInputGroup
name={'exchange_rate'}
fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency}
formGroupProps={{ label: ' ', inline: true }}
withPopoverRecalcConfirm
{...props}
/>
);
}
/**
* 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;
},
);

View File

@@ -0,0 +1,56 @@
// @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,
isOpen,
// #withDialogActions
closeDialog,
}) {
const handleConfirm = () => {
closeDialog(dialogName);
};
return (
<Dialog
name={dialogName}
title={'Kindly take care of new rates'}
autoFocus={true}
canEscapeKeyClose={true}
isOpen={isOpen}
onClose={() => {}}
>
<DialogSuspense>
<div className={Classes.DIALOG_BODY}>
<p>
The item rates have been <strong>adjusted</strong> to the new
currency using realtime exchange rate.
</p>
<p style={{ marginBottom: '30px' }}>
Make sure to check that the item rates match the current exchange
rate of the newly selected currency before saving the transaction.
</p>
</div>
<div className={Classes.DIALOG_FOOTER}>
<Button onClick={handleConfirm} intent={Intent.PRIMARY} fill>
Ok
</Button>
</div>
</DialogSuspense>
</Dialog>
);
}
export default compose(
withDialogRedux(),
withDialogActions,
)(InvoiceExchangeRateChangeDialog);

View File

@@ -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;

View File

@@ -70,6 +70,7 @@ export default function InvoiceFloatingActions() {
history.goBack();
};
// Handle clear button click.
const handleClearBtnClick = (event) => {
resetForm();
};

View File

@@ -34,7 +34,7 @@ import {
transformValueToRequest,
resetFormState,
} from './utils';
import { InvoiceNoSyncSettingsToForm } from './components';
import { InvoiceExchangeRateSync, InvoiceNoSyncSettingsToForm } from './components';
/**
* Invoice form.
@@ -184,6 +184,7 @@ function InvoiceForm({
{/*---------- Effects ----------*/}
<InvoiceNoSyncSettingsToForm />
<InvoiceExchangeRateSync />
</Form>
</Formik>
</div>

View File

@@ -36,6 +36,7 @@ import {
ProjectBillableEntriesLink,
} from '@/containers/Projects/components';
import { Features } from '@/constants';
import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc';
/**
* Invoice form header fields.
@@ -51,10 +52,8 @@ export default function InvoiceFormHeaderFields() {
<InvoiceFormCustomerSelect />
{/* ----------- Exchange rate ----------- */}
<InvoiceExchangeRateInputField
name={'exchange_rate'}
formGroupProps={{ label: ' ', inline: true }}
/>
<InvoiceExchangeRateInputField />
<Row>
<Col xs={6}>
{/* ----------- Invoice date ----------- */}
@@ -161,8 +160,20 @@ export default function InvoiceFormHeaderFields() {
* @returns {React.ReactNode}
*/
function InvoiceFormCustomerSelect() {
const { customers } = useInvoiceFormContext();
const { values, setFieldValue } = useFormikContext();
const { customers } = useInvoiceFormContext();
const updateEntries = useCustomerUpdateExRate();
// Handles the customer item change.
const handleItemChange = (customer) => {
// 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);
}
updateEntries(customer);
};
return (
<FFormGroup
@@ -178,10 +189,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}

View File

@@ -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 (
<InvoiceFormProvider invoiceId={idAsInteger}>
<InvoiceForm />
<AutoExchangeRateProvider>
<InvoiceForm />
</AutoExchangeRateProvider>
</InvoiceFormProvider>
);
}

View File

@@ -1,24 +1,30 @@
// @ts-nocheck
import React 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 { useInvoiceIsForeignCustomer } from './utils';
import { useInvoiceIsForeignCustomer, useInvoiceTotal } from './utils';
import withSettings from '@/containers/Settings/withSettings';
import { useUpdateEffect } from '@/hooks';
import { transactionNumber } from '@/utils';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
import {
useSyncExRateToForm,
withExchangeRateFetchingLoading,
withExchangeRateItemEntriesPriceRecalc,
} from '@/containers/Entries/withExRateItemEntriesPriceRecalc';
/**
* Invoice exchange rate input field.
* @returns {JSX.Element}
*/
export function InvoiceExchangeRateInputField({ ...props }) {
const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => {
const currentOrganization = useCurrentOrganization();
const { values } = useFormikContext();
const isForeignCustomer = useInvoiceIsForeignCustomer();
// Can't continue if the customer is not foreign.
@@ -27,12 +33,24 @@ export function InvoiceExchangeRateInputField({ ...props }) {
}
return (
<ExchangeRateInputGroup
name={'exchange_rate'}
fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency}
formGroupProps={{ label: ' ', inline: true }}
withPopoverRecalcConfirm
{...props}
/>
);
}
};
/**
* Invoice exchange rate input field.
* @returns {JSX.Element}
*/
export const InvoiceExchangeRateInputField = R.compose(
withExchangeRateFetchingLoading,
withExchangeRateItemEntriesPriceRecalc,
)(InvoiceExchangeRateInputFieldRoot);
/**
* Invoice project select.
@@ -66,3 +84,28 @@ export const InvoiceNoSyncSettingsToForm = R.compose(
return null;
});
/**
* 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 total = useInvoiceTotal();
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;
},
);

View File

@@ -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: '',

View File

@@ -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 ---------*/}
<ReceiptSyncIncrementSettingsToForm />
<ReceiptSyncAutoExRateToForm />
</Form>
</Formik>
</div>

View File

@@ -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() {
<ReceiptFormCustomerSelect />
{/* ----------- Exchange rate ----------- */}
<ReceiptExchangeRateInputField
name={'exchange_rate'}
formGroupProps={{ label: ' ', inline: true }}
/>
<ReceiptExchangeRateInputField />
{/* ----------- Deposit account ----------- */}
<FFormGroup
@@ -148,6 +146,16 @@ function ReceiptFormCustomerSelect() {
const { setFieldValue, values } = useFormikContext();
const { customers } = useReceiptFormContext();
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 (
<FFormGroup
name={'customer_id'}
@@ -162,10 +170,7 @@ function ReceiptFormCustomerSelect() {
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}
popoverFill={true}
allowCreate={true}
fastField={true}

View File

@@ -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 (
<ReceiptFormProvider receiptId={idInt}>
<ReceiptFrom />
<AutoExchangeRateProvider>
<ReceiptFrom />
</AutoExchangeRateProvider>
</ReceiptFormProvider>
);
}

View File

@@ -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 (
<ExchangeRateInputGroup
name={'exchange_rate'}
fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency}
formGroupProps={{ label: ' ', inline: true }}
withPopoverRecalcConfirm
{...props}
/>
);
}
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;
},
);

View File

@@ -1,102 +1,34 @@
// @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];
}
function delay(t, val) {
return new Promise((resolve) => setTimeout(resolve, t, val));
}
/**
* Edits the exchange rate.
* Retrieves tax rates.
* @param {number} customerId - Customer id.
*/
export function useEdiExchangeRate(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
export function useExchangeRate(
fromCurrency: string,
toCurrency: string,
props,
) {
return useQuery(
[QUERY_TYPES.EXCHANGE_RATE, fromCurrency, toCurrency],
async () => {
await delay(100);
return useMutation(
([id, values]) => apiRequest.post(`exchange_rates/${id}`, values),
{
onSuccess: () => {
queryClient.invalidateQueries('EXCHANGES_RATES');
},
...props,
return {
from_currency: fromCurrency,
to_currency: toCurrency,
exchange_rate: 1.00,
};
},
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,
}),
...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');
},
};
}

View File

@@ -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 = {
@@ -226,12 +226,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,
@@ -266,5 +271,6 @@ export default {
...BRANCHES,
...DASHBOARD,
...ORGANIZATION,
...TAX_RATES
...TAX_RATES,
...EXCHANGE_RATE,
};

View File

@@ -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`,