From 9531730d7a584ff9f4ec72314bd85cfe88684f78 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 16 Oct 2023 19:14:27 +0200 Subject: [PATCH 001/148] feat: Auto re-calculate the items rate once changing the invoice exchange rate. --- .../components/Customers/CustomersSelect.tsx | 2 +- .../src/components/DialogsContainer.tsx | 7 +- .../ExchangeRate/ExchangeRateInput.tsx | 153 ++++++++++++++++-- packages/webapp/src/constants/dialogs.ts | 1 + .../containers/AlertsContainer/registered.tsx | 4 +- .../ExchangeRateForm.schema.tsx | 19 --- .../ExchangeRateForm.tsx | 114 ------------- .../ExchangeRateFormContent.tsx | 14 -- .../ExchangeRateFormDialogContent.tsx | 27 ---- .../ExchangeRateFormFields.tsx | 89 ---------- .../ExchangeRateFormFooter.tsx | 36 ----- .../ExchangeRateFormProvider.tsx | 53 ------ .../Dialogs/ExchangeRateFormDialog/index.tsx | 45 ------ .../ExchangeRates/ExchangeRateActionsBar.tsx | 147 ----------------- .../ExchangeRates/ExchangeRateTable.tsx | 110 ------------- .../ExchangeRates/ExchangeRatesAlerts.tsx | 10 -- .../ExchangeRates/ExchangeRatesList.tsx | 38 ----- .../ExchangeRates/ExchangeRatesProvider.tsx | 42 ----- .../containers/ExchangeRates/components.tsx | 91 ----------- .../ExchangeRates/withExchangeRateDetail.tsx | 9 -- .../ExchangeRates/withExchangeRates.tsx | 16 -- .../withExchangeRatesActions.tsx | 10 -- .../InvoiceExchangeRateChangeDialog.tsx | 63 ++++++++ .../Invoices/InvoiceForm/Dialogs/index.ts | 16 ++ .../Invoices/InvoiceForm/InvoiceForm.tsx | 3 +- .../InvoiceForm/InvoiceFormHeaderFields.tsx | 34 +++- .../InvoiceForm/InvoiceFormProvider.tsx | 20 ++- .../Sales/Invoices/InvoiceForm/components.tsx | 87 +++++++++- .../Sales/Invoices/InvoiceForm/utils.tsx | 86 +++++++++- .../webapp/src/hooks/query/exchangeRates.tsx | 113 +++---------- packages/webapp/src/hooks/query/types.tsx | 14 +- packages/webapp/src/routes/dashboard.tsx | 10 -- 32 files changed, 473 insertions(+), 1010 deletions(-) delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.schema.tsx delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateForm.tsx delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormContent.tsx delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormDialogContent.tsx delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFields.tsx delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormFooter.tsx delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/ExchangeRateFormProvider.tsx delete mode 100644 packages/webapp/src/containers/Dialogs/ExchangeRateFormDialog/index.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/ExchangeRateActionsBar.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/ExchangeRateTable.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/ExchangeRatesAlerts.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/ExchangeRatesList.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/ExchangeRatesProvider.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/components.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/withExchangeRateDetail.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/withExchangeRates.tsx delete mode 100644 packages/webapp/src/containers/ExchangeRates/withExchangeRatesActions.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/index.ts 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`, From 33e5d1a979a95d0310f23a0b9ecf816c35c88355 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 14 Dec 2023 11:01:25 +0200 Subject: [PATCH 002/148] feat: Add default customer message and terms and conditions to the transactions. --- .../webapp/src/constants/preferencesMenu.tsx | 4 + .../CreditNotes/PreferencesCreditNotes.tsx | 14 ++++ .../PreferencesCreditNotesForm.schema.ts | 9 +++ .../PreferencesCreditNotesForm.tsx | 80 ++++++++++++++++++ .../PreferencesCreditNotesFormBoot.tsx | 41 ++++++++++ .../PreferencesCreditNotesFormPage.tsx | 69 ++++++++++++++++ .../Estimates/PreferencesEstimates.tsx | 14 ++++ .../PreferencesEstimatesForm.schema.ts | 9 +++ .../Estimates/PreferencesEstimatesForm.tsx | 80 ++++++++++++++++++ .../PreferencesEstimatesFormBoot.tsx | 41 ++++++++++ .../PreferencesEstimatesFormPage.tsx | 69 ++++++++++++++++ .../Invoices/PreferencesInvoiceForm.schema.ts | 9 +++ .../Invoices/PreferencesInvoiceFormBoot.tsx | 41 ++++++++++ .../Invoices/PreferencesInvoiceFormPage.tsx | 67 +++++++++++++++ .../Invoices/PreferencesInvoices.tsx | 14 ++++ .../Invoices/PreferencesInvoicesForm.tsx | 81 +++++++++++++++++++ packages/webapp/src/lang/en/index.json | 5 +- packages/webapp/src/routes/preferences.tsx | 12 +++ 18 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx create mode 100644 packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts create mode 100644 packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx create mode 100644 packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormBoot.tsx create mode 100644 packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx create mode 100644 packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx create mode 100644 packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.schema.ts create mode 100644 packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx create mode 100644 packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx create mode 100644 packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx create mode 100644 packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceForm.schema.ts create mode 100644 packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx create mode 100644 packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx create mode 100644 packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoices.tsx create mode 100644 packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx diff --git a/packages/webapp/src/constants/preferencesMenu.tsx b/packages/webapp/src/constants/preferencesMenu.tsx index f90c7ed04..562cce1b1 100644 --- a/packages/webapp/src/constants/preferencesMenu.tsx +++ b/packages/webapp/src/constants/preferencesMenu.tsx @@ -12,6 +12,10 @@ export default [ text: , href: '/preferences/users', }, + { + text: 'Invoices', + href: '/preferences/invoices', + }, { text: , href: '/preferences/currencies', diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx new file mode 100644 index 000000000..ee1753398 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import { PreferencesCreditNotesBoot } from './PreferencesCreditNotesFormBoot'; +import PreferencesInvoiceFormPage from './PreferencesCreditNotesFormPage'; + +/** + * items preferences. + */ +export default function PreferencesCreditNotes() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts new file mode 100644 index 000000000..b6cf3eab6 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + termsConditions: Yup.string().optional(), + customerNotes: Yup.string().optional(), +}); + +export const PreferencesEstimatesFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx new file mode 100644 index 000000000..17fc18c0d --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx @@ -0,0 +1,80 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Form } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { + FieldRequiredHint, + FormattedMessage as T, + FFormGroup, + FTextArea, +} from '@/components'; + +/** + * Preferences estimates form. + */ +export function PreferencesCreditNotesForm({ isSubmitting }) { + const history = useHistory(); + + // Handle close click. + const handleCloseClick = () => { + history.go(-1); + }; + + return ( +
+ {/* ---------- Terms & Conditions ---------- */} + } + labelInfo={} + fastField={true} + > + + + + {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + + + + + +
+ ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormBoot.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormBoot.tsx new file mode 100644 index 000000000..c416f69c4 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormBoot.tsx @@ -0,0 +1,41 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; + +const PreferencesCreditNotesFormContext = React.createContext(); + +function PreferencesCreditNotesBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + organization: {}, + }; + + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ {isLoading ? ( + + ) : ( + + )} +
+ ); +} + +const usePreferencesCreditNotesFormContext = () => + React.useContext(PreferencesCreditNotesFormContext); + +export { PreferencesCreditNotesBoot, usePreferencesCreditNotesFormContext }; diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx new file mode 100644 index 000000000..045ffbbc5 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; + +import { AppToaster } from '@/components'; +import { PreferencesCreditNotesFormSchema } from './PreferencesCreditNotesForm.schema'; +import { usePreferencesInvoiceFormContext } from './PreferencesCreditNotesFormBoot'; +import { PreferencesCreditNotesForm } from './PreferencesCreditNotesForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { compose, transformToForm } from '@/utils'; + +const defaultValues = { + termsConditions: '', + customerNotes: '', +}; + +/** + * Preferences - . + */ +function PreferencesCreditNotesFormPageRoot({ + // #withDashboardActions + changePreferencesPageTitle, +}) { + const { organization } = usePreferencesInvoiceFormContext(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.estimates')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(organization.metadata, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + // Handle request success. + const onSuccess = (response) => { + AppToaster.show({ + message: intl.get('preferences.estimates.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + // updateOrganization({ ...values }) + // .then(onSuccess) + // .catch(onError); + }; + + return ( + + ); +} + +export const PreferencesCreditNotesFormPage = compose(withDashboardActions)( + PreferencesCreditNotesFormPageRoot, +); diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx new file mode 100644 index 000000000..2b5af6bdf --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import { PreferencesEstimatesBoot } from './PreferencesEstimatesFormBoot'; +import PreferencesInvoiceFormPage from './PreferencesEstimatesFormPage'; + +/** + * items preferences. + */ +export default function PreferencesEstimates() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.schema.ts b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.schema.ts new file mode 100644 index 000000000..b6cf3eab6 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + termsConditions: Yup.string().optional(), + customerNotes: Yup.string().optional(), +}); + +export const PreferencesEstimatesFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx new file mode 100644 index 000000000..426797f4d --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx @@ -0,0 +1,80 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Form } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { + FieldRequiredHint, + FormattedMessage as T, + FFormGroup, + FTextArea, +} from '@/components'; + +/** + * Preferences estimates form. + */ +export function PreferencesEstimatesForm({ isSubmitting }) { + const history = useHistory(); + + // Handle close click. + const handleCloseClick = () => { + history.go(-1); + }; + + return ( +
+ {/* ---------- Terms & Conditions ---------- */} + } + labelInfo={} + fastField={true} + > + + + + {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + + + + + +
+ ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx new file mode 100644 index 000000000..e444c5998 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx @@ -0,0 +1,41 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; + +const PreferencesEstimatesFormContext = React.createContext(); + +function PreferencesEstimatesBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + organization: {}, + }; + + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ {isLoading ? ( + + ) : ( + + )} +
+ ); +} + +const usePreferencesEstimatesFormContext = () => + React.useContext(PreferencesEstimatesFormContext); + +export { PreferencesEstimatesBoot, usePreferencesEstimatesFormContext }; diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx new file mode 100644 index 000000000..b74279741 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; + +import { AppToaster } from '@/components'; +import { PreferencesEstimatesFormSchema } from './PreferencesEstimatesForm.schema'; +import { usePreferencesInvoiceFormContext } from './PreferencesEstimatesFormBoot'; +import { PreferencesEstimatesForm } from './PreferencesEstimatesForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { compose, transformToForm } from '@/utils'; + +const defaultValues = { + termsConditions: '', + customerNotes: '', +}; + +/** + * Preferences - . + */ +function PreferencesEstimatesFormPageRoot({ + // #withDashboardActions + changePreferencesPageTitle, +}) { + const { organization } = usePreferencesInvoiceFormContext(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.estimates')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(organization.metadata, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + // Handle request success. + const onSuccess = (response) => { + AppToaster.show({ + message: intl.get('preferences.estimates.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + // updateOrganization({ ...values }) + // .then(onSuccess) + // .catch(onError); + }; + + return ( + + ); +} + +export const PreferencesEstimatesFormPage = compose(withDashboardActions)( + PreferencesEstimatesFormPageRoot, +); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceForm.schema.ts b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceForm.schema.ts new file mode 100644 index 000000000..be7bead85 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + termsConditions: Yup.string().optional(), + customerNotes: Yup.string().optional(), +}); + +export const PreferencesInvoiceFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx new file mode 100644 index 000000000..b4983531a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx @@ -0,0 +1,41 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; + +const PreferencesInvoiceFormContext = React.createContext(); + +function PreferencesInvoicesBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + organization: {}, + }; + + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ {isLoading ? ( + + ) : ( + + )} +
+ ); +} + +const usePreferencesInvoiceFormContext = () => + React.useContext(PreferencesInvoiceFormContext); + +export { PreferencesInvoicesBoot, usePreferencesInvoiceFormContext }; diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx new file mode 100644 index 000000000..ddf74b02f --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx @@ -0,0 +1,67 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; + +import { AppToaster } from '@/components'; +import { PreferencesInvoiceFormSchema } from './PreferencesInvoiceForm.schema'; +import { usePreferencesInvoiceFormContext } from './PreferencesInvoiceFormBoot'; +import { PreferencesGeneralForm } from './PreferencesInvoicesForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { compose, transformToForm } from '@/utils'; + +const defaultValues = { + termsConditions: '', + customerNotes: '', +}; + +/** + * Preferences - Invoices. + */ +function PreferencesInvoiceFormPage({ + // #withDashboardActions + changePreferencesPageTitle, +}) { + const { organization } = usePreferencesInvoiceFormContext(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.invoices')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(organization.metadata, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + // Handle request success. + const onSuccess = (response) => { + AppToaster.show({ + message: intl.get('preferences.invoices.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + // updateOrganization({ ...values }) + // .then(onSuccess) + // .catch(onError); + }; + + return ( + + ); +} + +export default compose(withDashboardActions)(PreferencesInvoiceFormPage); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoices.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoices.tsx new file mode 100644 index 000000000..da349ea9a --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoices.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import { PreferencesInvoicesBoot } from './PreferencesInvoiceFormBoot'; +import PreferencesInvoiceFormPage from './PreferencesInvoiceFormPage'; + +/** + * items preferences. + */ +export default function PreferencesInvoices() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx new file mode 100644 index 000000000..0e2b44f51 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx @@ -0,0 +1,81 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Form } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { + FieldRequiredHint, + FormattedMessage as T, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; + +/** + * Preferences general form. + */ +export function PreferencesGeneralForm({ isSubmitting }) { + const history = useHistory(); + + // Handle close click. + const handleCloseClick = () => { + history.go(-1); + }; + + return ( +
+ {/* ---------- Terms & Conditions ---------- */} + } + labelInfo={} + fastField={true} + > + + + + {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + + + + + +
+ ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/lang/en/index.json b/packages/webapp/src/lang/en/index.json index 4a78b4e76..f67a6f478 100644 --- a/packages/webapp/src/lang/en/index.json +++ b/packages/webapp/src/lang/en/index.json @@ -2290,5 +2290,8 @@ "sidebar.new_project": "New Project", "sidebar.new_time_entry": "New Time Entry", "sidebar.project_profitability_summary": "Project Profitability Summary", - "global_error.too_many_requests": "Too many requests" + "global_error.too_many_requests": "Too many requests", + + "pref.invoices.termsConditions.field": "Terms & Conditions", + "pref.invoices.customerNotes.field": "Customer Notes" } diff --git a/packages/webapp/src/routes/preferences.tsx b/packages/webapp/src/routes/preferences.tsx index 775efcf82..5fdcb13e6 100644 --- a/packages/webapp/src/routes/preferences.tsx +++ b/packages/webapp/src/routes/preferences.tsx @@ -9,6 +9,8 @@ import SMSIntegration from '../containers/Preferences/SMSIntegration'; import DefaultRoute from '../containers/Preferences/DefaultRoute'; import Warehouses from '../containers/Preferences/Warehouses'; import Branches from '../containers/Preferences/Branches'; +import Invoices from '../containers/Preferences/Invoices/PreferencesInvoices'; + const BASE_URL = '/preferences'; @@ -23,6 +25,16 @@ export default [ component: Users, exact: true, }, + { + path: `${BASE_URL}/invoices`, + component: Invoices, + exact: true, + }, + { + path: `${BASE_URL}/credit-notes`, + component: CreditNotes, + exact: true, + }, { path: `${BASE_URL}/roles`, component: Roles, From 217321380a579d8f9cca8cfd4e7e44530527023b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 14 Dec 2023 20:47:52 +0200 Subject: [PATCH 003/148] feat: wip default message to sales transactions. --- .../webapp/src/constants/preferencesMenu.tsx | 12 +++ .../CreditNotes/PreferencesCreditNotes.tsx | 6 +- .../PreferencesCreditNotesForm.schema.ts | 2 +- .../PreferencesCreditNotesForm.tsx | 4 +- .../PreferencesCreditNotesFormBoot.tsx | 26 +++++-- .../PreferencesCreditNotesFormPage.tsx | 8 +- .../Estimates/PreferencesEstimates.tsx | 8 +- .../PreferencesEstimatesFormPage.tsx | 4 +- .../Invoices/PreferencesInvoiceFormBoot.tsx | 22 ++++-- .../Invoices/PreferencesInvoiceFormPage.tsx | 4 +- .../Invoices/PreferencesInvoicesForm.tsx | 5 +- .../Receipts/PreferencesReceipts.tsx | 14 ++++ .../PreferencesReceiptsForm.schema.ts | 9 +++ .../Receipts/PreferencesReceiptsForm.tsx | 78 +++++++++++++++++++ .../Receipts/PreferencesReceiptsFormBoot.tsx | 56 +++++++++++++ .../Receipts/PreferencesReceiptsFormPage.tsx | 69 ++++++++++++++++ packages/webapp/src/lang/en/index.json | 20 ++++- packages/webapp/src/routes/preferences.tsx | 16 +++- 18 files changed, 325 insertions(+), 38 deletions(-) create mode 100644 packages/webapp/src/containers/Preferences/Receipts/PreferencesReceipts.tsx create mode 100644 packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.schema.ts create mode 100644 packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.tsx create mode 100644 packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormBoot.tsx create mode 100644 packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormPage.tsx diff --git a/packages/webapp/src/constants/preferencesMenu.tsx b/packages/webapp/src/constants/preferencesMenu.tsx index 562cce1b1..2a7377bf0 100644 --- a/packages/webapp/src/constants/preferencesMenu.tsx +++ b/packages/webapp/src/constants/preferencesMenu.tsx @@ -12,10 +12,22 @@ export default [ text: , href: '/preferences/users', }, + { + text: 'Estimates', + href: '/preferences/estimates', + }, { text: 'Invoices', href: '/preferences/invoices', }, + { + text: 'Receipts', + href: '/preferences/receipts', + }, + { + text: 'Credit Notes', + href: '/preferences/credit-notes', + }, { text: , href: '/preferences/currencies', diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx index ee1753398..d5d43c86a 100644 --- a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotes.tsx @@ -1,14 +1,14 @@ // @ts-nocheck import { PreferencesCreditNotesBoot } from './PreferencesCreditNotesFormBoot'; -import PreferencesInvoiceFormPage from './PreferencesCreditNotesFormPage'; +import { PreferencesCreditNotesFormPage } from './PreferencesCreditNotesFormPage'; /** * items preferences. */ -export default function PreferencesCreditNotes() { +export function PreferencesCreditNotes() { return ( - + ); } diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts index b6cf3eab6..0e3901591 100644 --- a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.schema.ts @@ -6,4 +6,4 @@ const Schema = Yup.object().shape({ customerNotes: Yup.string().optional(), }); -export const PreferencesEstimatesFormSchema = Schema; +export const PreferencesCreditNotesFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx index 17fc18c0d..2796a04a0 100644 --- a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx @@ -27,7 +27,7 @@ export function PreferencesCreditNotesForm({ isSubmitting }) { {/* ---------- Terms & Conditions ---------- */} } + label={} labelInfo={} fastField={true} > @@ -42,7 +42,7 @@ export function PreferencesCreditNotesForm({ isSubmitting }) { {/* ---------- Customer Notes ---------- */} } + label={} fastField={true} > - {isLoading ? ( - - ) : ( - - )} + + {isLoading ? ( + + ) : ( + + )} + ); } +const PreferencesCreditNotesCard = styled(Card)` + padding: 25px; + + .bp4-form-group { + max-width: 600px; + } +`; + const usePreferencesCreditNotesFormContext = () => React.useContext(PreferencesCreditNotesFormContext); diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx index 045ffbbc5..4121c7d98 100644 --- a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx @@ -6,7 +6,7 @@ import { Intent } from '@blueprintjs/core'; import { AppToaster } from '@/components'; import { PreferencesCreditNotesFormSchema } from './PreferencesCreditNotesForm.schema'; -import { usePreferencesInvoiceFormContext } from './PreferencesCreditNotesFormBoot'; +import { usePreferencesCreditNotesFormContext } from './PreferencesCreditNotesFormBoot'; import { PreferencesCreditNotesForm } from './PreferencesCreditNotesForm'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; @@ -18,16 +18,16 @@ const defaultValues = { }; /** - * Preferences - . + * Preferences - Credit Notes. */ function PreferencesCreditNotesFormPageRoot({ // #withDashboardActions changePreferencesPageTitle, }) { - const { organization } = usePreferencesInvoiceFormContext(); + const { organization } = usePreferencesCreditNotesFormContext(); useEffect(() => { - changePreferencesPageTitle(intl.get('preferences.estimates')); + changePreferencesPageTitle(intl.get('preferences.creditNotes')); }, [changePreferencesPageTitle]); // Initial values. diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx index 2b5af6bdf..d7a8b484d 100644 --- a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimates.tsx @@ -1,14 +1,14 @@ // @ts-nocheck import { PreferencesEstimatesBoot } from './PreferencesEstimatesFormBoot'; -import PreferencesInvoiceFormPage from './PreferencesEstimatesFormPage'; +import { PreferencesEstimatesFormPage } from './PreferencesEstimatesFormPage'; /** - * items preferences. + * Estimates preferences. */ -export default function PreferencesEstimates() { +export function PreferencesEstimates() { return ( - + ); } diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx index b74279741..6dedd912b 100644 --- a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx @@ -6,7 +6,7 @@ import { Intent } from '@blueprintjs/core'; import { AppToaster } from '@/components'; import { PreferencesEstimatesFormSchema } from './PreferencesEstimatesForm.schema'; -import { usePreferencesInvoiceFormContext } from './PreferencesEstimatesFormBoot'; +import { usePreferencesEstimatesFormContext } from './PreferencesEstimatesFormBoot'; import { PreferencesEstimatesForm } from './PreferencesEstimatesForm'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; @@ -24,7 +24,7 @@ function PreferencesEstimatesFormPageRoot({ // #withDashboardActions changePreferencesPageTitle, }) { - const { organization } = usePreferencesInvoiceFormContext(); + const { organization } = usePreferencesEstimatesFormContext(); useEffect(() => { changePreferencesPageTitle(intl.get('preferences.estimates')); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx index b4983531a..03d9a31a4 100644 --- a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx @@ -1,9 +1,11 @@ // @ts-nocheck import React from 'react'; import classNames from 'classnames'; +import styled from 'styled-components'; import { CLASSES } from '@/constants/classes'; import { useSettings } from '@/hooks/query'; import PreferencesPageLoader from '../PreferencesPageLoader'; +import { Card } from '@/components'; const PreferencesInvoiceFormContext = React.createContext(); @@ -26,15 +28,25 @@ function PreferencesInvoicesBoot({ ...props }) { CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT, )} > - {isLoading ? ( - - ) : ( - - )} + + {isLoading ? ( + + ) : ( + + )} + ); } +const PreferencesInvoicesCard = styled(Card)` + padding: 25px; + + .bp4-form-group{ + max-width: 600px; + } +`; + const usePreferencesInvoiceFormContext = () => React.useContext(PreferencesInvoiceFormContext); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx index ddf74b02f..6c364cb7e 100644 --- a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx @@ -7,7 +7,7 @@ import { Intent } from '@blueprintjs/core'; import { AppToaster } from '@/components'; import { PreferencesInvoiceFormSchema } from './PreferencesInvoiceForm.schema'; import { usePreferencesInvoiceFormContext } from './PreferencesInvoiceFormBoot'; -import { PreferencesGeneralForm } from './PreferencesInvoicesForm'; +import { PreferencesInvoicesForm } from './PreferencesInvoicesForm'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; import { compose, transformToForm } from '@/utils'; @@ -59,7 +59,7 @@ function PreferencesInvoiceFormPage({ initialValues={initialValues} validationSchema={PreferencesInvoiceFormSchema} onSubmit={handleFormSubmit} - component={PreferencesGeneralForm} + component={PreferencesInvoicesForm} /> ); } diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx index 0e2b44f51..5beec5e0c 100644 --- a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx @@ -5,17 +5,15 @@ import { Button, Intent } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; import { - FieldRequiredHint, FormattedMessage as T, FFormGroup, - FInputGroup, FTextArea, } from '@/components'; /** * Preferences general form. */ -export function PreferencesGeneralForm({ isSubmitting }) { +export function PreferencesInvoicesForm({ isSubmitting }) { const history = useHistory(); // Handle close click. @@ -29,7 +27,6 @@ export function PreferencesGeneralForm({ isSubmitting }) { } - labelInfo={} fastField={true} > + + + ); +} diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.schema.ts b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.schema.ts new file mode 100644 index 000000000..f28cc9407 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + termsConditions: Yup.string().optional(), + customerNotes: Yup.string().optional(), +}); + +export const PreferencesReceiptsFormSchema = Schema; diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.tsx b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.tsx new file mode 100644 index 000000000..05004c3c5 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsForm.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Form } from 'formik'; +import { Button, Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; + +import { + FormattedMessage as T, + FFormGroup, + FTextArea, +} from '@/components'; + +/** + * Preferences general form. + */ +export function PreferencesReceiptsForm({ isSubmitting }) { + const history = useHistory(); + + // Handle close click. + const handleCloseClick = () => { + history.go(-1); + }; + + return ( +
+ {/* ---------- Terms & Conditions ---------- */} + } + fastField={true} + > + + + + {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + + + + + +
+ ); +} + +const CardFooterActions = styled.div` + padding-top: 16px; + border-top: 1px solid #e0e7ea; + margin-top: 30px; + + .bp4-button { + min-width: 70px; + + + .bp4-button { + margin-left: 10px; + } + } +`; diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormBoot.tsx b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormBoot.tsx new file mode 100644 index 000000000..1c2244eda --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormBoot.tsx @@ -0,0 +1,56 @@ +// @ts-nocheck +import React from 'react'; +import classNames from 'classnames'; +import styled from 'styled-components'; +import { CLASSES } from '@/constants/classes'; +import { useSettings } from '@/hooks/query'; +import PreferencesPageLoader from '../PreferencesPageLoader'; +import { Card } from '@/components'; + +const PreferencesReceiptsFormContext = React.createContext(); + +function PreferencesReceiptsBoot({ ...props }) { + // Fetches organization settings. + const { isLoading: isSettingsLoading } = useSettings(); + + // Provider state. + const provider = { + organization: {}, + }; + + // Detarmines whether if any query is loading. + const isLoading = isSettingsLoading; + + return ( +
+ + {isLoading ? ( + + ) : ( + + )} + +
+ ); +} + +const PreferencesReceiptsCard = styled(Card)` + padding: 25px; + + .bp4-form-group { + max-width: 600px; + } +`; + +const usePreferencesReceiptsFormContext = () => + React.useContext(PreferencesReceiptsFormContext); + +export { PreferencesReceiptsBoot, usePreferencesReceiptsFormContext }; diff --git a/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormPage.tsx b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormPage.tsx new file mode 100644 index 000000000..0033f1187 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Receipts/PreferencesReceiptsFormPage.tsx @@ -0,0 +1,69 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import intl from 'react-intl-universal'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; + +import { AppToaster } from '@/components'; +import { PreferencesReceiptsFormSchema } from './PreferencesReceiptsForm.schema'; +import { usePreferencesReceiptsFormContext } from './PreferencesReceiptsFormBoot'; +import { PreferencesReceiptsForm } from './PreferencesReceiptsForm'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { compose, transformToForm } from '@/utils'; + +const defaultValues = { + termsConditions: '', + customerNotes: '', +}; + +/** + * Preferences - Receipts. + */ +function PreferencesReceiptsFormPageRoot({ + // #withDashboardActions + changePreferencesPageTitle, +}) { + const { organization } = usePreferencesReceiptsFormContext(); + + useEffect(() => { + changePreferencesPageTitle(intl.get('preferences.receipts')); + }, [changePreferencesPageTitle]); + + // Initial values. + const initialValues = { + ...defaultValues, + ...transformToForm(organization.metadata, defaultValues), + }; + // Handle the form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + // Handle request success. + const onSuccess = (response) => { + AppToaster.show({ + message: intl.get('preferences.receipts.success_message'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + }; + // Handle request error. + const onError = () => { + setSubmitting(false); + }; + // updateOrganization({ ...values }) + // .then(onSuccess) + // .catch(onError); + }; + + return ( + + ); +} + +export const PreferencesReceiptsFormPage = compose(withDashboardActions)( + PreferencesReceiptsFormPageRoot, +); diff --git a/packages/webapp/src/lang/en/index.json b/packages/webapp/src/lang/en/index.json index f67a6f478..1832b3a5f 100644 --- a/packages/webapp/src/lang/en/index.json +++ b/packages/webapp/src/lang/en/index.json @@ -2070,7 +2070,7 @@ "project_task.dialog.edit_success_message": "The task has been edited successfully.", "project_task.action.edit_task": "Edit Task", "project_task.action.delete_task": "Delete Task", -"project_task.rate": "{rate} / hour", + "project_task.rate": "{rate} / hour", "project_task.fixed_price": "Fixed price", "project_task.non_chargable": "Non-chargeable", "project_task.estimate_hours": "• {estimate_hours}h 0m estimated", @@ -2293,5 +2293,19 @@ "global_error.too_many_requests": "Too many requests", "pref.invoices.termsConditions.field": "Terms & Conditions", - "pref.invoices.customerNotes.field": "Customer Notes" -} + "pref.invoices.customerNotes.field": "Customer Notes", + + "pref.creditNotes.termsConditions.field": "Terms & Conditions", + "pref.creditNotes.customerNotes.field": "Customer Notes", + + "pref.estimates.termsConditions.field": "Terms & Conditions", + "pref.estimates.customerNotes.field": "Customer Notes", + + "pref.receipts.termsConditions.field": "Terms & Conditions", + "pref.receipts.customerNotes.field": "Customer Notes", + + "preferences.invoices": "Invoices", + "preferences.estimates": "Estimates", + "preferences.creditNotes": "Credit Notes", + "preferences.receipts": "Receipts" +} \ No newline at end of file diff --git a/packages/webapp/src/routes/preferences.tsx b/packages/webapp/src/routes/preferences.tsx index 5fdcb13e6..8031230ed 100644 --- a/packages/webapp/src/routes/preferences.tsx +++ b/packages/webapp/src/routes/preferences.tsx @@ -10,7 +10,9 @@ import DefaultRoute from '../containers/Preferences/DefaultRoute'; import Warehouses from '../containers/Preferences/Warehouses'; import Branches from '../containers/Preferences/Branches'; import Invoices from '../containers/Preferences/Invoices/PreferencesInvoices'; - +import { PreferencesCreditNotes } from '../containers/Preferences/CreditNotes/PreferencesCreditNotes'; +import { PreferencesEstimates } from '@/containers/Preferences/Estimates/PreferencesEstimates'; +import{ PreferencesReceipts } from '@/containers/Preferences/Receipts/PreferencesReceipts' const BASE_URL = '/preferences'; @@ -32,7 +34,17 @@ export default [ }, { path: `${BASE_URL}/credit-notes`, - component: CreditNotes, + component: PreferencesCreditNotes, + exact: true, + }, + { + path: `${BASE_URL}/estimates`, + component: PreferencesEstimates, + exact: true, + }, + { + path: `${BASE_URL}/receipts`, + component: PreferencesReceipts, exact: true, }, { From cfd4540a65e7927a1dca9e7e225464a9705015ff Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 14 Dec 2023 23:49:23 +0200 Subject: [PATCH 004/148] feat: wip send an invoice mail the customer email --- .../api/controllers/Sales/SalesInvoices.ts | 110 +++++++++++++++++- packages/server/src/interfaces/SaleInvoice.ts | 8 ++ .../Estimates/SaleEstimatesApplication.ts | 14 +++ .../Sales/Estimates/SendSaleEstimateMail.ts | 6 + .../Invoices/GetSaleInvoiceMailReminder.ts | 3 + .../Sales/Invoices/SaleInvoicesApplication.ts | 58 +++++++++ .../Sales/Invoices/SendSaleInvoiceMail.ts | 12 ++ .../Invoices/SendSaleInvoiceMailReminder.ts | 11 ++ 8 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts create mode 100644 packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts create mode 100644 packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts create mode 100644 packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index d90b94d8d..91c370273 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { body, check, param, query } from 'express-validator'; import { Service, Inject } from 'typedi'; import BaseController from '../BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -145,6 +145,39 @@ export default class SaleInvoicesController extends BaseController { this.handleServiceErrors, this.dynamicListService.handlerErrorsToResponse ); + router.get( + '/:id/mail-reminder', + this.specificSaleInvoiceValidation, + this.validationResult, + asyncMiddleware(this.getSaleInvoiceMailReminder.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/mail-reminder', + [ + ...this.specificSaleInvoiceValidation, + body('from').isString().exists(), + body('to').isString().exists(), + body('body').isString().exists(), + body('attach_invoice').exists().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleInvoiceMailReminder.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/mail', + [ + ...this.specificSaleInvoiceValidation, + body('from').isString().exists(), + body('to').isString().exists(), + body('body').isString().exists(), + body('attach_invoice').exists().isBoolean().toBoolean(), + ], + this.validationResult, + asyncMiddleware(this.sendSaleInvoiceMail.bind(this)), + this.handleServiceErrors + ); return router; } @@ -630,6 +663,81 @@ export default class SaleInvoicesController extends BaseController { } }; + /** + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async sendSaleInvoiceMail( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + await this.saleInvoiceApplication.sendSaleInvoiceMail( + tenantId, + invoiceId + ); + return res.status(200).send({}); + } catch (error) { + next(error); + } + } + + /** + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async getSaleInvoiceMailReminder( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + await this.saleInvoiceApplication.getSaleInvoiceMailReminder( + tenantId, + invoiceId + ); + return res.status(200).send({}); + } catch (error) { + next(error); + } + } + + /** + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async sendSaleInvoiceMailReminder( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { id: invoiceId } = req.params; + + try { + await this.saleInvoiceApplication.sendSaleInvoiceMailReminder( + tenantId, + invoiceId + ); + return res.status(200).send({}); + } catch (error) { + next(error); + } + } + /** * Handles service errors. * @param {Error} error diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 7ef8fdea2..59090cd03 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -186,3 +186,11 @@ export enum SaleInvoiceAction { Writeoff = 'Writeoff', NotifyBySms = 'NotifyBySms', } + +export interface SendInvoiceMailDTO { + to: string; + from: string; + subject: string; + body: string; + attachInvoice?: boolean; +} diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts index 3f63b27de..907693ea0 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts @@ -17,6 +17,7 @@ import { ApproveSaleEstimate } from './ApproveSaleEstimate'; import { RejectSaleEstimate } from './RejectSaleEstimate'; import { SaleEstimateNotifyBySms } from './SaleEstimateSmsNotify'; import { SaleEstimatesPdf } from './SaleEstimatesPdf'; +import { SendSaleEstimateMail } from './SendSaleEstimateMail'; @Service() export class SaleEstimatesApplication { @@ -50,6 +51,9 @@ export class SaleEstimatesApplication { @Inject() private saleEstimatesPdfService: SaleEstimatesPdf; + @Inject() + private sendEstimateMailService: SendSaleEstimateMail; + /** * Create a sale estimate. * @param {number} tenantId - The tenant id. @@ -209,4 +213,14 @@ export class SaleEstimatesApplication { saleEstimate ); } + + /** + * + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns + */ + public sendSaleEstimateMail(tenantId: number, saleEstimateId: number) { + return this.sendEstimateMailService.sendMail(tenantId, saleEstimateId); + } } diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts new file mode 100644 index 000000000..8dca133bb --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts @@ -0,0 +1,6 @@ +import { Service } from "typedi"; + +@Service() +export class SendSaleEstimateMail { + sendMail(tenantId: number, saleEstimateId: number) {} +} diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts new file mode 100644 index 000000000..2a65d316e --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoiceMailReminder.ts @@ -0,0 +1,3 @@ +export class GetSaleInvoiceMailReminder { + public getInvoiceMailReminder(tenantId: number, saleInvoiceId: number) {} +} diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts index 8a37386f9..eb1a475aa 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts @@ -11,6 +11,7 @@ import { ISystemUser, ITenantUser, InvoiceNotificationType, + SendInvoiceMailDTO, } from '@/interfaces'; import { Inject, Service } from 'typedi'; import { CreateSaleInvoice } from './CreateSaleInvoice'; @@ -24,6 +25,9 @@ import { WriteoffSaleInvoice } from './WriteoffSaleInvoice'; import { SaleInvoicePdf } from './SaleInvoicePdf'; import { GetInvoicePaymentsService } from './GetInvoicePaymentsService'; import { SaleInvoiceNotifyBySms } from './SaleInvoiceNotifyBySms'; +import { SendInvoiceMailReminder } from './SendSaleInvoiceMailReminder'; +import { SendSaleInvoiceMail } from './SendSaleInvoiceMail'; +import { GetSaleInvoiceMailReminder } from './GetSaleInvoiceMailReminder'; @Service() export class SaleInvoiceApplication { @@ -60,6 +64,15 @@ export class SaleInvoiceApplication { @Inject() private invoiceSms: SaleInvoiceNotifyBySms; + @Inject() + private sendInvoiceReminderService: SendInvoiceMailReminder; + + @Inject() + private sendSaleInvoiceMailService: SendSaleInvoiceMail; + + @Inject() + private getSaleInvoiceReminderService: GetSaleInvoiceMailReminder; + /** * Creates a new sale invoice with associated GL entries. * @param {number} tenantId @@ -279,4 +292,49 @@ export class SaleInvoiceApplication { invoiceSmsDetailsDTO ); }; + + /** + * Retrieves the metadata of invoice mail reminder. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {} + */ + public getSaleInvoiceMailReminder(tenantId: number, saleInvoiceId: number) { + return this.getSaleInvoiceReminderService.getInvoiceMailReminder( + tenantId, + saleInvoiceId + ); + } + + /** + * Sends reminder of the given invoice to the invoice's customer. + * @param {number} tenantId + * @param {number} saleInvoiceId + * @returns {} + */ + public sendSaleInvoiceMailReminder(tenantId: number, saleInvoiceId: number) { + return this.sendInvoiceReminderService.sendInvoiceMailReminder( + tenantId, + saleInvoiceId + ); + } + + /** + * + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {SendInvoiceMailDTO} messageDTO + * @returns + */ + public sendSaleInvoiceMail( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) { + return this.sendSaleInvoiceMailService.sendSaleInvoiceMail( + tenantId, + saleInvoiceId, + messageDTO + ); + } } diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts new file mode 100644 index 000000000..7d0b89b86 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts @@ -0,0 +1,12 @@ +import { Service } from 'typedi'; +import { SendInvoiceMailDTO } from '@/interfaces'; + + +@Service() +export class SendSaleInvoiceMail { + public sendSaleInvoiceMail( + tenantId: number, + saleInvoiceId: number, + messageDTO: SendInvoiceMailDTO + ) {} +} diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts new file mode 100644 index 000000000..6dcf49693 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts @@ -0,0 +1,11 @@ +import { Service } from 'typedi'; + +@Service() +export class SendInvoiceMailReminder { + /** + * + * @param {number} tenantId + * @param {number} saleInvoiceId + */ + public sendInvoiceMailReminder(tenantId: number, saleInvoiceId: number) {} +} From ad53ddb9ddb49d9eaa5ccd48b53f26d54832d1aa Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 15 Dec 2023 20:15:42 +0200 Subject: [PATCH 005/148] feat: inject default message value to sales forms --- packages/server/src/data/options.ts | 24 ++++++++++++ .../CreditNoteForm/CreditNoteForm.tsx | 35 +++++++++-------- .../Estimates/EstimateForm/EstimateForm.tsx | 38 ++++++++++--------- .../Invoices/InvoiceForm/InvoiceForm.tsx | 8 +++- .../Receipts/ReceiptForm/ReceiptForm.tsx | 35 ++++++++--------- .../Sales/Receipts/ReceiptForm/utils.tsx | 3 +- 6 files changed, 89 insertions(+), 54 deletions(-) diff --git a/packages/server/src/data/options.ts b/packages/server/src/data/options.ts index 023628ef8..ae35fa5da 100644 --- a/packages/server/src/data/options.ts +++ b/packages/server/src/data/options.ts @@ -59,6 +59,12 @@ export default { auto_increment: { type: 'boolean', }, + customer_notes: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, sales_receipts: { next_number: { @@ -73,6 +79,12 @@ export default { preferred_deposit_account: { type: 'number', }, + receipt_message: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, sales_invoices: { next_number: { @@ -84,6 +96,12 @@ export default { auto_increment: { type: 'boolean', }, + customer_notes: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, payment_receives: { next_number: { @@ -147,6 +165,12 @@ export default { auto_increment: { type: 'boolean', }, + customer_notes: { + type: 'string', + }, + terms_conditions: { + type: 'string', + }, }, vendor_credit: { next_number: { diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx index ec8467061..1d51ecbc6 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteForm.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { useHistory } from 'react-router-dom'; import { Formik, Form } from 'formik'; import { Intent } from '@blueprintjs/core'; -import { isEmpty } from 'lodash'; +import { defaultTo, isEmpty } from 'lodash'; import { CLASSES } from '@/constants/classes'; import { CreateCreditNoteFormSchema, @@ -48,6 +48,8 @@ function CreditNoteForm({ creditAutoIncrement, creditNumberPrefix, creditNextNumber, + creditCustomerNotes, + creditTermsConditions, // #withCurrentOrganization organization: { base_currency }, @@ -68,22 +70,21 @@ function CreditNoteForm({ const creditNumber = transactionNumber(creditNumberPrefix, creditNextNumber); // Initial values. - const initialValues = React.useMemo( - () => ({ - ...(!isEmpty(creditNote) - ? { ...transformToEditForm(creditNote) } - : { - ...defaultCreditNote, - ...(creditAutoIncrement && { - credit_note_number: creditNumber, - }), - entries: orderingLinesIndexes(defaultCreditNote.entries), - currency_code: base_currency, - ...newCreditNote, + const initialValues = { + ...(!isEmpty(creditNote) + ? { ...transformToEditForm(creditNote) } + : { + ...defaultCreditNote, + ...(creditAutoIncrement && { + credit_note_number: creditNumber, }), - }), - [], - ); + entries: orderingLinesIndexes(defaultCreditNote.entries), + currency_code: base_currency, + terms_conditions: defaultTo(creditTermsConditions, ''), + note: defaultTo(creditCustomerNotes, ''), + ...newCreditNote, + }), + }; // Handles form submit. const handleFormSubmit = ( @@ -178,6 +179,8 @@ export default compose( creditAutoIncrement: creditNoteSettings?.autoIncrement, creditNextNumber: creditNoteSettings?.nextNumber, creditNumberPrefix: creditNoteSettings?.numberPrefix, + creditCustomerNotes: creditNoteSettings?.customerNotes, + creditTermsConditions: creditNoteSettings?.termsConditions, })), withCurrentOrganization(), )(CreditNoteForm); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx index a4db1cfff..f9cd24673 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx @@ -4,7 +4,7 @@ import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; import { Intent } from '@blueprintjs/core'; -import { sumBy, isEmpty } from 'lodash'; +import { sumBy, isEmpty, defaultTo } from 'lodash'; import { useHistory } from 'react-router-dom'; import { CLASSES } from '@/constants/classes'; @@ -43,6 +43,8 @@ function EstimateForm({ estimateNextNumber, estimateNumberPrefix, estimateAutoIncrementMode, + estimateCustomerNotes, + estimateTermsConditions, // #withCurrentOrganization organization: { base_currency }, @@ -60,25 +62,23 @@ function EstimateForm({ estimateNumberPrefix, estimateNextNumber, ); - // Initial values in create and edit mode. - const initialValues = useMemo( - () => ({ - ...(!isEmpty(estimate) - ? { ...transformToEditForm(estimate) } - : { - ...defaultEstimate, - // If the auto-increment mode is enabled, take the next estimate - // number from the settings. - ...(estimateAutoIncrementMode && { - estimate_number: estimateNumber, - }), - entries: orderingLinesIndexes(defaultEstimate.entries), - currency_code: base_currency, + const initialValues = { + ...(!isEmpty(estimate) + ? { ...transformToEditForm(estimate) } + : { + ...defaultEstimate, + // If the auto-increment mode is enabled, take the next estimate + // number from the settings. + ...(estimateAutoIncrementMode && { + estimate_number: estimateNumber, }), - }), - [estimate, estimateNumber, estimateAutoIncrementMode, base_currency], - ); + entries: orderingLinesIndexes(defaultEstimate.entries), + currency_code: base_currency, + terms_conditions: defaultTo(estimateTermsConditions, ''), + note: defaultTo(estimateCustomerNotes, ''), + }), + }; // Handles form submit. const handleFormSubmit = ( @@ -181,6 +181,8 @@ export default compose( estimateNextNumber: estimatesSettings?.nextNumber, estimateNumberPrefix: estimatesSettings?.numberPrefix, estimateAutoIncrementMode: estimatesSettings?.autoIncrement, + estimateCustomerNotes: estimatesSettings?.customerNotes, + estimateTermsConditions: estimatesSettings?.termsConditions, })), withCurrentOrganization(), )(EstimateForm); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx index a8463619b..c3a447d11 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx @@ -4,7 +4,7 @@ import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; import { Intent } from '@blueprintjs/core'; -import { sumBy, isEmpty } from 'lodash'; +import { sumBy, isEmpty, defaultTo } from 'lodash'; import { useHistory } from 'react-router-dom'; import { CLASSES } from '@/constants/classes'; import { @@ -44,6 +44,8 @@ function InvoiceForm({ invoiceNextNumber, invoiceNumberPrefix, invoiceAutoIncrementMode, + invoiceCustomerNotes, + invoiceTermsConditions, // #withCurrentOrganization organization: { base_currency }, @@ -79,6 +81,8 @@ function InvoiceForm({ }), entries: orderingLinesIndexes(defaultInvoice.entries), currency_code: base_currency, + invoice_message: defaultTo(invoiceCustomerNotes, ''), + terms_conditions: defaultTo(invoiceTermsConditions, ''), ...newInvoice, }), }; @@ -192,6 +196,8 @@ export default compose( invoiceNextNumber: invoiceSettings?.nextNumber, invoiceNumberPrefix: invoiceSettings?.numberPrefix, invoiceAutoIncrementMode: invoiceSettings?.autoIncrement, + invoiceCustomerNotes: invoiceSettings?.customerNotes, + invoiceTermsConditions: invoiceSettings?.termsConditions, })), withCurrentOrganization(), )(InvoiceForm); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx index 75a8b9665..ca7dd26f6 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React, { useMemo } from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; @@ -45,6 +44,8 @@ function ReceiptForm({ receiptNextNumber, receiptNumberPrefix, receiptAutoIncrement, + receiptTermsConditions, + receiptMessage, preferredDepositAccount, // #withCurrentOrganization @@ -67,23 +68,21 @@ function ReceiptForm({ receiptNextNumber, ); // Initial values in create and edit mode. - const initialValues = useMemo( - () => ({ - ...(!isEmpty(receipt) - ? { ...transformToEditForm(receipt) } - : { - ...defaultReceipt, - ...(receiptAutoIncrement && { - receipt_number: nextReceiptNumber, - }), - deposit_account_id: parseInt(preferredDepositAccount), - entries: orderingLinesIndexes(defaultReceipt.entries), - currency_code: base_currency, + const initialValues = { + ...(!isEmpty(receipt) + ? { ...transformToEditForm(receipt) } + : { + ...defaultReceipt, + ...(receiptAutoIncrement && { + receipt_number: nextReceiptNumber, }), - }), - [receipt, preferredDepositAccount, nextReceiptNumber, receiptAutoIncrement], - ); - + deposit_account_id: parseInt(preferredDepositAccount), + entries: orderingLinesIndexes(defaultReceipt.entries), + currency_code: base_currency, + receipt_message: receiptMessage, + terms_conditions: receiptTermsConditions, + }), + }; // Handle the form submit. const handleFormSubmit = ( values, @@ -184,6 +183,8 @@ export default compose( receiptNextNumber: receiptSettings?.nextNumber, receiptNumberPrefix: receiptSettings?.numberPrefix, receiptAutoIncrement: receiptSettings?.autoIncrement, + receiptMessage: receiptSettings?.receiptMessage, + receiptTermsConditions: receiptSettings?.termsConditions, preferredDepositAccount: receiptSettings?.preferredDepositAccount, })), withCurrentOrganization(), diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx index e8c019e7a..d58cb6179 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/utils.tsx @@ -7,7 +7,6 @@ import { omit, first } from 'lodash'; import { useFormikContext } from 'formik'; import { defaultFastFieldShouldUpdate, - transactionNumber, repeatValue, transformToForm, formattedAmount, @@ -50,7 +49,7 @@ export const defaultReceipt = { receipt_date: moment(new Date()).format('YYYY-MM-DD'), reference_no: '', receipt_message: '', - statement: '', + terms_conditions: '', closed: '', branch_id: '', warehouse_id: '', From 6953f7c4a357d12cb24a69ebcf5244767d318ee2 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 16 Dec 2023 19:26:41 +0200 Subject: [PATCH 006/148] feat: assign default default messages on sales transactions --- .../PreferencesCreditNotesForm.tsx | 38 ++++++++---------- .../PreferencesCreditNotesFormPage.tsx | 39 ++++++++++++------- .../Estimates/PreferencesEstimatesForm.tsx | 38 ++++++++---------- .../PreferencesEstimatesFormBoot.tsx | 28 +++++++++---- .../PreferencesEstimatesFormPage.tsx | 32 ++++++++++----- .../Invoices/PreferencesInvoiceFormBoot.tsx | 2 +- .../Invoices/PreferencesInvoiceFormPage.tsx | 33 +++++++++++----- .../Invoices/PreferencesInvoicesForm.tsx | 34 +++++++--------- .../Receipts/PreferencesReceiptsForm.tsx | 34 +++++++--------- .../Receipts/PreferencesReceiptsFormBoot.tsx | 2 +- .../Receipts/PreferencesReceiptsFormPage.tsx | 37 ++++++++++++------ packages/webapp/src/lang/en/index.json | 9 ++++- 12 files changed, 189 insertions(+), 137 deletions(-) diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx index 2796a04a0..c4627a90c 100644 --- a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesForm.tsx @@ -4,12 +4,7 @@ import { Form } from 'formik'; import { Button, Intent } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; -import { - FieldRequiredHint, - FormattedMessage as T, - FFormGroup, - FTextArea, -} from '@/components'; +import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components'; /** * Preferences estimates form. @@ -24,21 +19,6 @@ export function PreferencesCreditNotesForm({ isSubmitting }) { return (
- {/* ---------- Terms & Conditions ---------- */} - } - labelInfo={} - fastField={true} - > - - - {/* ---------- Customer Notes ---------- */} + + + {/* ---------- Terms & Conditions ---------- */} + } + fastField={true} + > + diff --git a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx index 4121c7d98..4fc3956c8 100644 --- a/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx +++ b/packages/webapp/src/containers/Preferences/CreditNotes/PreferencesCreditNotesFormPage.tsx @@ -1,16 +1,19 @@ // @ts-nocheck -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import intl from 'react-intl-universal'; import { Formik } from 'formik'; +import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; import { AppToaster } from '@/components'; import { PreferencesCreditNotesFormSchema } from './PreferencesCreditNotesForm.schema'; -import { usePreferencesCreditNotesFormContext } from './PreferencesCreditNotesFormBoot'; import { PreferencesCreditNotesForm } from './PreferencesCreditNotesForm'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; -import { compose, transformToForm } from '@/utils'; +import { compose, transformToForm, transfromToSnakeCase } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { transferObjectOptionsToArray } from '../Accountant/utils'; +import { useSaveSettings } from '@/hooks/query'; const defaultValues = { termsConditions: '', @@ -23,8 +26,12 @@ const defaultValues = { function PreferencesCreditNotesFormPageRoot({ // #withDashboardActions changePreferencesPageTitle, + + // #withSettings + creditNoteSettings, }) { - const { organization } = usePreferencesCreditNotesFormContext(); + // Save settings. + const { mutateAsync: saveSettingMutate } = useSaveSettings(); useEffect(() => { changePreferencesPageTitle(intl.get('preferences.creditNotes')); @@ -33,14 +40,19 @@ function PreferencesCreditNotesFormPageRoot({ // Initial values. const initialValues = { ...defaultValues, - ...transformToForm(organization.metadata, defaultValues), + ...transformToForm(creditNoteSettings, defaultValues), }; // Handle the form submit. const handleFormSubmit = (values, { setSubmitting }) => { + const options = R.compose( + transferObjectOptionsToArray, + transfromToSnakeCase, + )({ creditNote: { ...values } }); + // Handle request success. - const onSuccess = (response) => { + const onSuccess = () => { AppToaster.show({ - message: intl.get('preferences.estimates.success_message'), + message: intl.get('preferences.credit_notes.success_message'), intent: Intent.SUCCESS, }); setSubmitting(false); @@ -49,9 +61,7 @@ function PreferencesCreditNotesFormPageRoot({ const onError = () => { setSubmitting(false); }; - // updateOrganization({ ...values }) - // .then(onSuccess) - // .catch(onError); + saveSettingMutate({ options }).then(onSuccess).catch(onError); }; return ( @@ -64,6 +74,9 @@ function PreferencesCreditNotesFormPageRoot({ ); } -export const PreferencesCreditNotesFormPage = compose(withDashboardActions)( - PreferencesCreditNotesFormPageRoot, -); +export const PreferencesCreditNotesFormPage = compose( + withDashboardActions, + withSettings(({ creditNoteSettings }) => ({ + creditNoteSettings: creditNoteSettings, + })), +)(PreferencesCreditNotesFormPageRoot); diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx index 426797f4d..7e17acc10 100644 --- a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesForm.tsx @@ -4,12 +4,7 @@ import { Form } from 'formik'; import { Button, Intent } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; -import { - FieldRequiredHint, - FormattedMessage as T, - FFormGroup, - FTextArea, -} from '@/components'; +import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components'; /** * Preferences estimates form. @@ -24,21 +19,6 @@ export function PreferencesEstimatesForm({ isSubmitting }) { return ( - {/* ---------- Terms & Conditions ---------- */} - } - labelInfo={} - fastField={true} - > - - - {/* ---------- Customer Notes ---------- */} + + + {/* ---------- Terms & Conditions ---------- */} + } + fastField={true} + > + diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx index e444c5998..d39d3c817 100644 --- a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormBoot.tsx @@ -4,6 +4,8 @@ import classNames from 'classnames'; import { CLASSES } from '@/constants/classes'; import { useSettings } from '@/hooks/query'; import PreferencesPageLoader from '../PreferencesPageLoader'; +import styled from 'styled-components'; +import { Card } from '@/components'; const PreferencesEstimatesFormContext = React.createContext(); @@ -13,9 +15,8 @@ function PreferencesEstimatesBoot({ ...props }) { // Provider state. const provider = { - organization: {}, + isSettingsLoading, }; - // Detarmines whether if any query is loading. const isLoading = isSettingsLoading; @@ -26,11 +27,16 @@ function PreferencesEstimatesBoot({ ...props }) { CLASSES.PREFERENCES_PAGE_INSIDE_CONTENT_ACCOUNTANT, )} > - {isLoading ? ( - - ) : ( - - )} + + {isLoading ? ( + + ) : ( + + )} + ); } @@ -38,4 +44,12 @@ function PreferencesEstimatesBoot({ ...props }) { const usePreferencesEstimatesFormContext = () => React.useContext(PreferencesEstimatesFormContext); +const PreferencesEstimatesCard = styled(Card)` + padding: 25px; + + .bp4-form-group { + max-width: 600px; + } +`; + export { PreferencesEstimatesBoot, usePreferencesEstimatesFormContext }; diff --git a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx index 6dedd912b..f5241f20a 100644 --- a/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx +++ b/packages/webapp/src/containers/Preferences/Estimates/PreferencesEstimatesFormPage.tsx @@ -3,14 +3,16 @@ import React, { useEffect } from 'react'; import intl from 'react-intl-universal'; import { Formik } from 'formik'; import { Intent } from '@blueprintjs/core'; +import * as R from 'ramda'; import { AppToaster } from '@/components'; import { PreferencesEstimatesFormSchema } from './PreferencesEstimatesForm.schema'; -import { usePreferencesEstimatesFormContext } from './PreferencesEstimatesFormBoot'; import { PreferencesEstimatesForm } from './PreferencesEstimatesForm'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; -import { compose, transformToForm } from '@/utils'; +import { transferObjectOptionsToArray } from '../Accountant/utils'; +import { compose, transformToForm, transfromToSnakeCase } from '@/utils'; +import { useSaveSettings } from '@/hooks/query'; const defaultValues = { termsConditions: '', @@ -23,8 +25,12 @@ const defaultValues = { function PreferencesEstimatesFormPageRoot({ // #withDashboardActions changePreferencesPageTitle, + + // #withSettings + estimatesSettings, }) { - const { organization } = usePreferencesEstimatesFormContext(); + // Save Organization Settings. + const { mutateAsync: saveSettingMutate } = useSaveSettings(); useEffect(() => { changePreferencesPageTitle(intl.get('preferences.estimates')); @@ -33,10 +39,15 @@ function PreferencesEstimatesFormPageRoot({ // Initial values. const initialValues = { ...defaultValues, - ...transformToForm(organization.metadata, defaultValues), + ...transformToForm(estimatesSettings, defaultValues), }; // Handle the form submit. const handleFormSubmit = (values, { setSubmitting }) => { + const options = R.compose( + transferObjectOptionsToArray, + transfromToSnakeCase, + )({ salesEstimates: { ...values } }); + // Handle request success. const onSuccess = (response) => { AppToaster.show({ @@ -49,9 +60,7 @@ function PreferencesEstimatesFormPageRoot({ const onError = () => { setSubmitting(false); }; - // updateOrganization({ ...values }) - // .then(onSuccess) - // .catch(onError); + saveSettingMutate({ options }).then(onSuccess).catch(onError); }; return ( @@ -64,6 +73,9 @@ function PreferencesEstimatesFormPageRoot({ ); } -export const PreferencesEstimatesFormPage = compose(withDashboardActions)( - PreferencesEstimatesFormPageRoot, -); +export const PreferencesEstimatesFormPage = compose( + withDashboardActions, + withSettings(({ estimatesSettings }) => ({ + estimatesSettings: estimatesSettings, + })), +)(PreferencesEstimatesFormPageRoot); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx index 03d9a31a4..2cd42db21 100644 --- a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormBoot.tsx @@ -15,7 +15,7 @@ function PreferencesInvoicesBoot({ ...props }) { // Provider state. const provider = { - organization: {}, + isSettingsLoading }; // Detarmines whether if any query is loading. diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx index 6c364cb7e..097b8f996 100644 --- a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoiceFormPage.tsx @@ -3,14 +3,17 @@ import React, { useEffect } from 'react'; import intl from 'react-intl-universal'; import { Formik } from 'formik'; import { Intent } from '@blueprintjs/core'; +import * as R from 'ramda'; import { AppToaster } from '@/components'; import { PreferencesInvoiceFormSchema } from './PreferencesInvoiceForm.schema'; -import { usePreferencesInvoiceFormContext } from './PreferencesInvoiceFormBoot'; import { PreferencesInvoicesForm } from './PreferencesInvoicesForm'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; -import { compose, transformToForm } from '@/utils'; +import { compose, transformToForm, transfromToSnakeCase } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { transferObjectOptionsToArray } from '../Accountant/utils'; +import { useSaveSettings } from '@/hooks/query'; const defaultValues = { termsConditions: '', @@ -23,8 +26,12 @@ const defaultValues = { function PreferencesInvoiceFormPage({ // #withDashboardActions changePreferencesPageTitle, + + // #withSettings + invoiceSettings, }) { - const { organization } = usePreferencesInvoiceFormContext(); + // Save settings. + const { mutateAsync: saveSettingMutate } = useSaveSettings(); useEffect(() => { changePreferencesPageTitle(intl.get('preferences.invoices')); @@ -33,12 +40,17 @@ function PreferencesInvoiceFormPage({ // Initial values. const initialValues = { ...defaultValues, - ...transformToForm(organization.metadata, defaultValues), + ...transformToForm(invoiceSettings, defaultValues), }; // Handle the form submit. const handleFormSubmit = (values, { setSubmitting }) => { + const options = R.compose( + transferObjectOptionsToArray, + transfromToSnakeCase, + )({ salesInvoices: { ...values } }); + // Handle request success. - const onSuccess = (response) => { + const onSuccess = () => { AppToaster.show({ message: intl.get('preferences.invoices.success_message'), intent: Intent.SUCCESS, @@ -49,9 +61,7 @@ function PreferencesInvoiceFormPage({ const onError = () => { setSubmitting(false); }; - // updateOrganization({ ...values }) - // .then(onSuccess) - // .catch(onError); + saveSettingMutate({ options }).then(onSuccess).catch(onError); }; return ( @@ -64,4 +74,9 @@ function PreferencesInvoiceFormPage({ ); } -export default compose(withDashboardActions)(PreferencesInvoiceFormPage); +export default compose( + withDashboardActions, + withSettings(({ invoiceSettings }) => ({ + invoiceSettings: invoiceSettings, + })), +)(PreferencesInvoiceFormPage); diff --git a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx index 5beec5e0c..95fced8e9 100644 --- a/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx +++ b/packages/webapp/src/containers/Preferences/Invoices/PreferencesInvoicesForm.tsx @@ -4,11 +4,7 @@ import { Form } from 'formik'; import { Button, Intent } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; -import { - FormattedMessage as T, - FFormGroup, - FTextArea, -} from '@/components'; +import { FormattedMessage as T, FFormGroup, FTextArea } from '@/components'; /** * Preferences general form. @@ -23,6 +19,20 @@ export function PreferencesInvoicesForm({ isSubmitting }) { return ( + {/* ---------- Customer Notes ---------- */} + } + fastField={true} + > + + + {/* ---------- Terms & Conditions ---------- */} - {/* ---------- Customer Notes ---------- */} - } - fastField={true} - > - - - + + + + ); } + +const HeaderBox = styled('div')` + border-top-right-radius: 5px; + border-top-left-radius: 5px; + border: 1px solid #dddfe9; + padding: 15px; + + .bp4-form-group { + margin: 0; + padding-top: 12px; + padding-bottom: 12px; + + &:not(:last-of-type) { + border-bottom: 1px solid #dddfe9; + } + &:first-of-type { + padding-top: 0; + } + &:last-of-type { + padding-bottom: 0; + } + } + + .bp4-form-content { + flex: 1 0; + } + + .bp4-label { + min-width: 65px; + color: #738091; + } + + .bp4-input { + } +`; diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index b13e9ffa5..e60213bb8 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -561,8 +561,14 @@ export default { }, 'content-copy': { path: [ - 'M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z' + 'M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z', ], - viewBox: '0 0 16 16' - } + viewBox: '0 0 16 16', + }, + envelope: { + path: [ + 'M0 4.01v11.91l6.27-6.27L0 4.01zm18.91-1.03H1.09L10 10.97l8.91-7.99zm-5.18 6.66L20 15.92V4.01l-6.27 5.63zm-3.23 2.9c-.13.12-.31.19-.5.19s-.37-.07-.5-.19l-2.11-1.89-6.33 6.33h17.88l-6.33-6.33-2.11 1.89z', + ], + viewBox: '0 0 20 20', + }, }; From a676e095376f311be6f457e41d112b06fa223359 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 27 Dec 2023 23:56:34 +0200 Subject: [PATCH 025/148] feat: rich editor component --- .../src/components/Forms/FRichEditor.tsx | 52 ++++++++++++ .../webapp/src/components/Forms/index.tsx | 3 +- .../RichEditor/RichEditor.style.scss | 66 +++++++++++++++ .../src/components/RichEditor/RichEditor.tsx | 58 ++++++++++++++ .../webapp/src/components/RichEditor/index.ts | 1 + .../SendMailNotification/RichEditor.tsx | 63 +++++++++++++++ .../SendMailNotificationForm.tsx | 80 +++++++++++++++++-- 7 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 packages/webapp/src/components/Forms/FRichEditor.tsx create mode 100644 packages/webapp/src/components/RichEditor/RichEditor.style.scss create mode 100644 packages/webapp/src/components/RichEditor/RichEditor.tsx create mode 100644 packages/webapp/src/components/RichEditor/index.ts create mode 100644 packages/webapp/src/containers/SendMailNotification/RichEditor.tsx diff --git a/packages/webapp/src/components/Forms/FRichEditor.tsx b/packages/webapp/src/components/Forms/FRichEditor.tsx new file mode 100644 index 000000000..d490f87f5 --- /dev/null +++ b/packages/webapp/src/components/Forms/FRichEditor.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { FieldConfig, FieldProps } from 'formik'; +import { Field } from '@blueprintjs-formik/core'; +import { RichEditor, RichEditorProps } from '../../components/RichEditor'; + +export interface FRichEditorProps + extends Omit, + RichEditorProps { + name: string; + value?: string; +} + +interface FieldToRichEditorProps + extends FieldProps, + Omit {} + +/** + * Transformes the field props to `RichEditor` props. + * @param {FieldToRichEditorProps} + * @returns {HTMLSelectProps} + */ +function fieldToRichEditor({ + field: { onBlur: onFieldBlur, ...field }, + form: { touched, errors, ...form }, + ...props +}: FieldToRichEditorProps): RichEditorProps { + return { + ...field, + ...props, + onChange: (value: string) => { + form.setFieldValue(field.name, value); + }, + }; +} + +/** + * Transformes field props to `RichEditor` props. + * @param {FieldToRichEditorProps} + * @returns {JSX.Element} + */ +function FieldToRichEditor({ ...props }: FieldToRichEditorProps): JSX.Element { + return ; +} + +/** + * Rich editor wrapper to bind with Formik. + * @param {FRichEditorProps} props - + * @returns {JSX.Element} + */ +export function FRichEditor({ ...props }: FRichEditorProps): JSX.Element { + return ; +} diff --git a/packages/webapp/src/components/Forms/index.tsx b/packages/webapp/src/components/Forms/index.tsx index d4fb2aec0..c638ac029 100644 --- a/packages/webapp/src/components/Forms/index.tsx +++ b/packages/webapp/src/components/Forms/index.tsx @@ -4,4 +4,5 @@ export * from './FMoneyInputGroup'; export * from './BlueprintFormik'; export * from './InputPrependText'; export * from './InputPrependButton'; -export * from './MoneyInputGroup'; \ No newline at end of file +export * from './MoneyInputGroup'; +export * from './FRichEditor'; \ No newline at end of file diff --git a/packages/webapp/src/components/RichEditor/RichEditor.style.scss b/packages/webapp/src/components/RichEditor/RichEditor.style.scss new file mode 100644 index 000000000..942fdf81e --- /dev/null +++ b/packages/webapp/src/components/RichEditor/RichEditor.style.scss @@ -0,0 +1,66 @@ +/* Basic editor styles */ +.tiptap { + color: #222; + + &:focus-visible { + outline: none; + } + + >*+* { + margin-top: 0.75em; + } + + ul, + ol { + padding: 0 1rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + code { + background: rgba(#ffffff, 0.1); + color: rgba(#ffffff, 0.6); + border: 1px solid rgba(#ffffff, 0.1); + border-radius: 0.5rem; + padding: 0.2rem; + } + + pre { + background: rgba(#ffffff, 0.1); + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + + code { + color: inherit; + padding: 0; + background: none; + font-size: 0.8rem; + border: none; + } + } + + img { + max-width: 100%; + height: auto; + } + + blockquote { + margin-left: 0; + padding-left: 1rem; + border-left: 2px solid rgba(#ffffff, 0.4); + + hr { + border: none; + border-top: 2px solid rgba(#ffffff, 0.1); + margin: 2rem 0; + } + } +} \ No newline at end of file diff --git a/packages/webapp/src/components/RichEditor/RichEditor.tsx b/packages/webapp/src/components/RichEditor/RichEditor.tsx new file mode 100644 index 000000000..da82fb09f --- /dev/null +++ b/packages/webapp/src/components/RichEditor/RichEditor.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import { Color } from '@tiptap/extension-color'; +import ListItem from '@tiptap/extension-list-item'; +import TextStyle from '@tiptap/extension-text-style'; +import { EditorProvider } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; +import { Box } from '../Layout/Box'; +import './RichEditor.style.scss'; + +const extensions = [ + Color.configure({ types: [TextStyle.name, ListItem.name] }), + TextStyle.configure({ types: [ListItem.name] }), + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: false, + }, + orderedList: { + keepMarks: true, + keepAttributes: false, + }, + }), +]; + +export interface RichEditorProps { + value?: string; + initialValue?: string; + onChange?: (value: string) => void; + className?: string; +} +export const RichEditor = ({ + value, + initialValue, + onChange, + className, +}: RichEditorProps) => { + const [content, handleChange] = useUncontrolled({ + value, + initialValue, + onChange, + finalValue: '', + }); + + const handleBlur = ({ editor }) => { + handleChange(editor.getHTML()); + }; + + return ( + + + + ); +}; diff --git a/packages/webapp/src/components/RichEditor/index.ts b/packages/webapp/src/components/RichEditor/index.ts new file mode 100644 index 000000000..226b701f3 --- /dev/null +++ b/packages/webapp/src/components/RichEditor/index.ts @@ -0,0 +1 @@ +export * from './RichEditor'; \ No newline at end of file diff --git a/packages/webapp/src/containers/SendMailNotification/RichEditor.tsx b/packages/webapp/src/containers/SendMailNotification/RichEditor.tsx new file mode 100644 index 000000000..e540fd6da --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/RichEditor.tsx @@ -0,0 +1,63 @@ +// @ts-nocheck +import './styles.scss'; +import { Color } from '@tiptap/extension-color'; +import ListItem from '@tiptap/extension-list-item'; +import TextStyle from '@tiptap/extension-text-style'; +import { EditorProvider } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { Box } from '@/components'; +import styled from 'styled-components'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +const extensions = [ + Color.configure({ types: [TextStyle.name, ListItem.name] }), + TextStyle.configure({ types: [ListItem.name] }), + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: false, + }, + orderedList: { + keepMarks: true, + keepAttributes: false, + }, + }), +]; + +export interface RichEditorProps { + value?: string; + initialValue?: string; + onChange?: (value: string) => void; + className?: string; +} +export const RichEditor = ({ + value, + initialValue, + onChange, + className, +}: RichEditorProps) => { + const [content, handleChange] = useUncontrolled({ + value, + initialValue, + finalValue: '', + onChange, + }); + + return ( + + + + ); +}; + +const Root = styled(Box)` + padding: 15px; + border: 1px solid #dedfe9; + border-top: 0; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +`; diff --git a/packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx b/packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx index a23b108af..ec5263c2f 100644 --- a/packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx +++ b/packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx @@ -1,8 +1,15 @@ // @ts-nocheck import { Form, useFormikContext } from 'formik'; -import { FFormGroup, FInputGroup, FMultiSelect } from '@/components'; +import { + FFormGroup, + FInputGroup, + FMultiSelect, + FRichEditor, + FSwitch, + Hint, +} from '@/components'; import styled from 'styled-components'; -import { Button, Classes, Intent } from '@blueprintjs/core'; +import { Button, Classes, Intent, Position } from '@blueprintjs/core'; import { saveInvoke } from '@/utils'; interface SendMailNotificationFormProps { @@ -24,25 +31,47 @@ export function SendMailNotificationForm({ + } name={'from'} inline={true} fastField={true} > @@ -56,6 +85,12 @@ export function SendMailNotificationForm({ + + + + + +
@@ -82,16 +117,33 @@ export function SendMailNotificationForm({ ); } +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; + +const MailMessageEditor = styled(FRichEditor)` + padding: 15px; + border: 1px solid #dedfe9; + border-top: 0; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +`; + const HeaderBox = styled('div')` border-top-right-radius: 5px; border-top-left-radius: 5px; border: 1px solid #dddfe9; - padding: 15px; + border-bottom: 2px solid #eaeaef; + padding: 6px 15px; .bp4-form-group { margin: 0; - padding-top: 12px; - padding-bottom: 12px; + padding-top: 8px; + padding-bottom: 8px; &:not(:last-of-type) { border-bottom: 1px solid #dddfe9; @@ -114,5 +166,19 @@ const HeaderBox = styled('div')` } .bp4-input { + border-color: transparent; + padding: 0; + + &:focus, + &.bp4-active { + box-shadow: 0 0 0 0; + } + } + + .bp4-input-ghost { + margin-top: 5px; + } + .bp4-tag-input-values { + margin: 0; } `; From dc762567b5bbd60a733f75672a6cc8fc1a40c4cf Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 28 Dec 2023 17:53:51 +0200 Subject: [PATCH 026/148] feat(webapp): send mail notififcation of sale transactions --- packages/webapp/package.json | 7 + .../EstimateMailDialog/EstimateMailDialog.tsx | 2 +- .../EstimateMailDialogContent.tsx | 1 - .../EstimateMailDialogForm.tsx | 15 +- .../EstimateMailDialogFormContent.tsx | 62 ++ .../InvoiceMailDialogForm.schema.ts | 9 + .../InvoiceMailDialogForm.tsx | 18 +- .../InvoiceMailDialogFormContent.tsx | 62 ++ .../PaymentMailDialogForm.tsx | 12 +- .../PaymentMailDialogFormContent.tsx | 62 ++ .../ReceiptMailDialogForm.tsx | 10 +- .../ReceiptMailDialogFormContent.tsx | 62 ++ .../MailNotificationForm.tsx | 134 +++++ .../SendMailNotificationForm.tsx | 184 ------ pnpm-lock.yaml | 555 ++++++++++++++++++ 15 files changed, 987 insertions(+), 208 deletions(-) create mode 100644 packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx create mode 100644 packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx create mode 100644 packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx create mode 100644 packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx delete mode 100644 packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx diff --git a/packages/webapp/package.json b/packages/webapp/package.json index d4e1c65d1..c1c2415aa 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -20,6 +20,13 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", + "@tiptap/extension-color": "latest", + "@tiptap/extension-text-style": "2.1.13", + "@tiptap/core": "2.1.13", + "@tiptap/pm": "2.1.13", + "@tiptap/extension-list-item": "2.1.13", + "@tiptap/react": "2.1.13", + "@tiptap/starter-kit": "2.1.13", "@types/jest": "^26.0.15", "@types/js-money": "^0.6.1", "@types/lodash": "^4.14.172", diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx index de6082222..9965db833 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx @@ -19,7 +19,7 @@ function EstimateMailDialog({ return ( - + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx new file mode 100644 index 000000000..d299d5b16 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { saveInvoke } from '@/utils'; + +interface EstimateMailDialogFormContentProps { + onClose?: () => void; +} + +export function EstimateMailDialogFormContent({ + onClose, +}: EstimateMailDialogFormContentProps) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts new file mode 100644 index 000000000..1c365ac4a --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.schema.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +export const InvoiceMailFormSchema = Yup.object().shape({ + from: Yup.array().required().min(1).max(5).label('From address'), + to: Yup.array().required().min(1).max(5).label('To address'), + subject: Yup.string().required().label('Mail subject'), + body: Yup.string().required().label('Mail body'), +}); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx index fed8bee61..6aa62cfba 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx @@ -2,25 +2,27 @@ import { Formik } from 'formik'; import { castArray } from 'lodash'; import * as R from 'ramda'; -import { SendMailNotificationForm } from '@/containers/SendMailNotification'; import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; import { transformToForm } from '@/utils'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; import { useSendSaleInvoiceMail } from '@/hooks/query'; +import { InvoiceMailDialogFormContent } from './InvoiceMailDialogFormContent'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { InvoiceMailFormSchema } from './InvoiceMailDialogForm.schema'; const initialFormValues = { from: [], to: [], subject: '', - message: '', + body: '', + attachInvoice: true, }; interface InvoiceMailFormValues { from: string[]; to: string[]; subject: string; - message: string; + body: string; attachInvoice: boolean; } @@ -54,8 +56,12 @@ function InvoiceMailDialogFormRoot({ }; return ( - - + + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx new file mode 100644 index 000000000..2038d5379 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { saveInvoke } from '@/utils'; + +interface SendMailNotificationFormProps { + onClose?: () => void; +} + +export function InvoiceMailDialogFormContent({ + onClose, +}: SendMailNotificationFormProps) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx index 395aff4f2..04906185d 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx @@ -2,25 +2,27 @@ import { Formik, FormikBag } from 'formik'; import { castArray } from 'lodash'; import * as R from 'ramda'; -import { SendMailNotificationForm } from '@/containers/SendMailNotification'; import { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; -import { transformToForm } from '@/utils'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; import { useSendPaymentReceiveMail } from '@/hooks/query'; +import { PaymentMailDialogFormContent } from './PaymentMailDialogFormContent'; +import { transformToForm } from '@/utils'; const initialFormValues = { from: [], to: [], subject: '', - message: '', + body: '', + attachPayment: true, }; interface PaymentMailFormValue { from: string[]; to: string[]; subject: string; - message: string; + body: string; + attachPayment: boolean; } export function PaymentMailDialogFormRoot({ @@ -57,7 +59,7 @@ export function PaymentMailDialogFormRoot({ return ( - + ); } diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx new file mode 100644 index 000000000..172494c40 --- /dev/null +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { saveInvoke } from '@/utils'; + +interface PaymentMailDialogFormContentProps { + onClose?: () => void; +} + +export function PaymentMailDialogFormContent({ + onClose, +}: PaymentMailDialogFormContentProps) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx index 76b8cef7b..2d5a3bcf1 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx @@ -2,24 +2,26 @@ import { Formik, FormikBag } from 'formik'; import { castArray } from 'lodash'; import * as R from 'ramda'; -import { SendMailNotificationForm } from '@/containers/SendMailNotification'; import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; import { transformToForm } from '@/utils'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; import { useSendSaleReceiptMail } from '@/hooks/query'; +import { ReceiptMailDialogFormContent } from './ReceiptMailDialogFormContent'; const initialFormValues = { from: [], to: [], subject: '', - message: '', + body: '', + attachReceipt: true, }; interface ReceiptMailFormValues { from: string[]; to: string[]; subject: string; - message: string; + body: string; + attachReceipt: boolean; } function ReceiptMailDialogFormRoot({ closeDialog }) { @@ -52,7 +54,7 @@ function ReceiptMailDialogFormRoot({ closeDialog }) { return ( - + ); } diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx new file mode 100644 index 000000000..381160f09 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx @@ -0,0 +1,62 @@ +// @ts-nocheck +import { Form, useFormikContext } from 'formik'; +import { Button, Classes, Intent } from '@blueprintjs/core'; +import styled from 'styled-components'; +import { FFormGroup, FSwitch } from '@/components'; +import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { saveInvoke } from '@/utils'; + +interface SendMailNotificationFormProps { + onClose?: () => void; +} + +export function ReceiptMailDialogFormContent({ + onClose, +}: SendMailNotificationFormProps) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + saveInvoke(onClose); + }; + + return ( +
+
+ + + + + +
+ +
+
+ + + +
+
+
+ ); +} + +const AttachFormGroup = styled(FFormGroup)` + background: #f8f9fb; + margin-top: 0.6rem; + padding: 4px 14px; + border-radius: 5px; + border: 1px solid #dcdcdd; +`; diff --git a/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx new file mode 100644 index 000000000..6b5053dd5 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx @@ -0,0 +1,134 @@ +// @ts-nocheck +import { + Box, + FFormGroup, + FInputGroup, + FMultiSelect, + FRichEditor, + Hint, +} from '@/components'; +import styled from 'styled-components'; +import { Position } from '@blueprintjs/core'; +import { SelectOptionProps } from '@blueprintjs-formik/select'; + +interface MailNotificationFormProps { + fromAddresses: SelectOptionProps[]; + toAddresses: SelectOptionProps[]; +} + +export function MailNotificationForm({ + fromAddresses, + toAddresses, +}: MailNotificationFormProps) { + return ( + + + + } + name={'from'} + inline={true} + fastField={true} + > + + + + + + + + + + + + + + + ); +} + +const MailMessageEditor = styled(FRichEditor)` + padding: 15px; + border: 1px solid #dedfe9; + border-top: 0; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +`; + +const HeaderBox = styled('div')` + border-top-right-radius: 5px; + border-top-left-radius: 5px; + border: 1px solid #dddfe9; + border-bottom: 2px solid #eaeaef; + padding: 6px 15px; + + .bp4-form-group { + margin: 0; + padding-top: 8px; + padding-bottom: 8px; + + &:not(:last-of-type) { + border-bottom: 1px solid #dddfe9; + } + &:first-of-type { + padding-top: 0; + } + &:last-of-type { + padding-bottom: 0; + } + } + + .bp4-form-content { + flex: 1 0; + } + + .bp4-label { + min-width: 65px; + color: #738091; + } + + .bp4-input { + border-color: transparent; + padding: 0; + + &:focus, + &.bp4-active { + box-shadow: 0 0 0 0; + } + } + + .bp4-input-ghost { + margin-top: 5px; + } + .bp4-tag-input-values { + margin: 0; + } +`; diff --git a/packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx b/packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx deleted file mode 100644 index ec5263c2f..000000000 --- a/packages/webapp/src/containers/SendMailNotification/SendMailNotificationForm.tsx +++ /dev/null @@ -1,184 +0,0 @@ -// @ts-nocheck -import { Form, useFormikContext } from 'formik'; -import { - FFormGroup, - FInputGroup, - FMultiSelect, - FRichEditor, - FSwitch, - Hint, -} from '@/components'; -import styled from 'styled-components'; -import { Button, Classes, Intent, Position } from '@blueprintjs/core'; -import { saveInvoke } from '@/utils'; - -interface SendMailNotificationFormProps { - onClose?: () => void; -} - -export function SendMailNotificationForm({ - onClose, -}: SendMailNotificationFormProps) { - const { isSubmitting } = useFormikContext(); - - const handleClose = () => { - saveInvoke(onClose); - }; - - return ( -
-
- - - } - name={'from'} - inline={true} - fastField={true} - > - - - - - - - - - - - - - - - - - -
- -
-
- - - -
-
-
- ); -} - -const AttachFormGroup = styled(FFormGroup)` - background: #f8f9fb; - margin-top: 0.6rem; - padding: 4px 14px; - border-radius: 5px; - border: 1px solid #dcdcdd; -`; - -const MailMessageEditor = styled(FRichEditor)` - padding: 15px; - border: 1px solid #dedfe9; - border-top: 0; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; -`; - -const HeaderBox = styled('div')` - border-top-right-radius: 5px; - border-top-left-radius: 5px; - border: 1px solid #dddfe9; - border-bottom: 2px solid #eaeaef; - padding: 6px 15px; - - .bp4-form-group { - margin: 0; - padding-top: 8px; - padding-bottom: 8px; - - &:not(:last-of-type) { - border-bottom: 1px solid #dddfe9; - } - &:first-of-type { - padding-top: 0; - } - &:last-of-type { - padding-bottom: 0; - } - } - - .bp4-form-content { - flex: 1 0; - } - - .bp4-label { - min-width: 65px; - color: #738091; - } - - .bp4-input { - border-color: transparent; - padding: 0; - - &:focus, - &.bp4-active { - box-shadow: 0 0 0 0; - } - } - - .bp4-input-ghost { - margin-top: 5px; - } - .bp4-tag-input-values { - margin: 0; - } -`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9482a23bf..a3cdf6aa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,27 @@ importers: '@testing-library/user-event': specifier: ^7.2.1 version: 7.2.1(@testing-library/dom@8.20.0) + '@tiptap/core': + specifier: 2.1.13 + version: 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-color': + specifier: latest + version: 2.1.13(@tiptap/core@2.1.13)(@tiptap/extension-text-style@2.1.13) + '@tiptap/extension-list-item': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-text-style': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13) + '@tiptap/pm': + specifier: 2.1.13 + version: 2.1.13 + '@tiptap/react': + specifier: 2.1.13 + version: 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13)(react-dom@18.2.0)(react@18.2.0) + '@tiptap/starter-kit': + specifier: 2.1.13 + version: 2.1.13(@tiptap/pm@2.1.13) '@types/jest': specifier: ^26.0.15 version: 26.0.24 @@ -5690,6 +5711,34 @@ packages: reselect: 4.1.7 dev: false + /@remirror/core-constants@2.0.2: + resolution: {integrity: sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ==} + dev: false + + /@remirror/core-helpers@3.0.0: + resolution: {integrity: sha512-tusEgQJIqg4qKj6HSBUFcyRnWnziw3neh4T9wOmsPGHFC3w9kl5KSrDb9UAgE8uX6y32FnS7vJ955mWOl3n50A==} + dependencies: + '@remirror/core-constants': 2.0.2 + '@remirror/types': 1.0.1 + '@types/object.omit': 3.0.3 + '@types/object.pick': 1.3.4 + '@types/throttle-debounce': 2.1.0 + case-anything: 2.1.13 + dash-get: 1.0.2 + deepmerge: 4.3.1 + fast-deep-equal: 3.1.3 + make-error: 1.3.6 + object.omit: 3.0.0 + object.pick: 1.3.0 + throttle-debounce: 3.0.1 + dev: false + + /@remirror/types@1.0.1: + resolution: {integrity: sha512-VlZQxwGnt1jtQ18D6JqdIF+uFZo525WEqrfp9BOc3COPpK4+AWCgdnAWL+ho6imWcoINlGjR/+3b6y5C1vBVEA==} + dependencies: + type-fest: 2.19.0 + dev: false + /@rollup/plugin-babel@5.3.1(@babel/core@7.20.12)(rollup@2.79.1): resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -5968,6 +6017,273 @@ packages: '@testing-library/dom': 8.20.0 dev: false + /@tiptap/core@2.1.13(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-cMC8bgTN63dj1Mv82iDeeLl6sa9kY0Pug8LSalxVEptRmyFVsVxGgu2/6Y3T+9aCYScxfS06EkA8SdzFMAwYTQ==} + peerDependencies: + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-blockquote@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-oe6wSQACmODugoP9XH3Ouffjy4BsOBWfTC+dETHNCG6ZED6ShHN3CB9Vr7EwwRgmm2WLaKAjMO1sVumwH+Z1rg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-bold@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-6cHsQTh/rUiG4jkbJer3vk7g60I5tBwEBSGpdxmEHh83RsvevD8+n92PjA24hYYte5RNlATB011E1wu8PVhSvw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-bubble-menu@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-Hm7e1GX3AI6lfaUmr6WqsS9MMyXIzCkhh+VQi6K8jj4Q4s8kY4KPoAyD/c3v9pZ/dieUtm2TfqrOCkbHzsJQBg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + tippy.js: 6.3.7 + dev: false + + /@tiptap/extension-bullet-list@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-NkWlQ5bLPUlcROj6G/d4oqAxMf3j3wfndGOPp0z8OoXJtVbVoXl/aMSlLbVgE6n8r6CS8MYxKhXNxrb7Ll2foA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-code-block@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-E3tweNExPOV+t1ODKX0MDVsS0aeHGWc1ECt+uyp6XwzsN0bdF2A5+pttQqM7sTcMnQkVACGFbn9wDeLRRcfyQg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-code@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-f5fLYlSgliVVa44vd7lQGvo49+peC+Z2H0Fn84TKNCH7tkNZzouoJsHYn0/enLaQ9Sq+24YPfqulfiwlxyiT8w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-color@2.1.13(@tiptap/core@2.1.13)(@tiptap/extension-text-style@2.1.13): + resolution: {integrity: sha512-T3tJXCIfFxzIlGOhvbPVIZa3y36YZRPYIo2TKsgkTz8LiMob6hRXXNFjsrFDp2Fnu3DrBzyvrorsW7767s4eYg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/extension-text-style': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-text-style': 2.1.13(@tiptap/core@2.1.13) + dev: false + + /@tiptap/extension-document@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-wLwiTWsVmZTGIE5duTcHRmW4ulVxNW4nmgfpk95+mPn1iKyNGtrVhGWleLhBlTj+DWXDtcfNWZgqZkZNzhkqYQ==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-dropcursor@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-NAyJi4BJxH7vl/2LNS1X0ndwFKjEtX+cRgshXCnMyh7qNpIRW6Plczapc/W1OiMncOEhZJfpZfkRSfwG01FWFg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-floating-menu@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-9Oz7pk1Nts2+EyY+rYfnREGbLzQ5UFazAvRhF6zAJdvyuDmAYm0Jp6s0GoTrpV0/dJEISoFaNpPdMJOb9EBNRw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + tippy.js: 6.3.7 + dev: false + + /@tiptap/extension-gapcursor@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-Cl5apsoTcyPPCgE3ThufxQxZ1wyqqh+9uxUN9VF9AbeTkid6oPZvKXwaILf6AFnkSy+SuKrb9kZD2iaezxpzXw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-hard-break@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-TGkMzMQayuKg+vN4du0x1ahEItBLcCT1jdWeRsjdM8gHfzbPLdo4PQhVsvm1I0xaZmbJZelhnVsUwRZcIu1WNA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-heading@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-PEmc19QLmlVUTiHWoF0hpgNTNPNU0nlaFmMKskzO+cx5Df4xvHmv/UqoIwp7/UFbPMkfVJT1ozQU7oD1IWn9Hg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-history@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-1ouitThGTBUObqw250aDwGLMNESBH5PRXIGybsCFO1bktdmWtEw7m72WY41EuX2BH8iKJpcYPerl3HfY1vmCNw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-horizontal-rule@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-7OgjgNqZXvBejgULNdMSma2M1nzv4bbZG+FT5XMFZmEOxR9IB1x/RzChjPdeicff2ZK2sfhMBc4Y9femF5XkUg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + dev: false + + /@tiptap/extension-italic@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-HyDJfuDn5hzwGKZiANcvgz6wcum6bEgb4wmJnfej8XanTMJatNVv63TVxCJ10dSc9KGpPVcIkg6W8/joNXIEbw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-list-item@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-6e8iiCWXOiJTl1XOwVW2tc0YG18h70HUtEHFCx2m5HspOGFKsFEaSS3qYxOheM9HxlmQeDt8mTtqftRjEFRxPQ==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-ordered-list@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-UO4ZAL5Vrr1WwER5VjgmeNIWHpqy9cnIRo1En07gZ0OWTjs1eITPcu+4TCn1ZG6DhoFvAQzE5DTxxdhIotg+qw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-paragraph@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-cEoZBJrsQn69FPpUMePXG/ltGXtqKISgypj70PEHXt5meKDjpmMVSY4/8cXvFYEYsI9GvIwyAK0OrfAHiSoROA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-strike@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-VN6zlaCNCbyJUCDyBFxavw19XmQ4LkCh8n20M8huNqW77lDGXA2A7UcWLHaNBpqAijBRu9mWI8l4Bftyf2fcAw==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-text-style@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-K9/pNHxpZKQoc++crxrsppVUSeHv8YevfY2FkJ4YMaekGcX+q4BRrHR0tOfii4izAUPJF2L0/PexLQaWXtAY1w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/extension-text@2.1.13(@tiptap/core@2.1.13): + resolution: {integrity: sha512-zzsTTvu5U67a8WjImi6DrmpX2Q/onLSaj+LRWPh36A1Pz2WaxW5asZgaS+xWCnR+UrozlCALWa01r7uv69jq0w==} + peerDependencies: + '@tiptap/core': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + dev: false + + /@tiptap/pm@2.1.13: + resolution: {integrity: sha512-zNbA7muWsHuVg12GrTgN/j119rLePPq5M8dZgkKxUwdw8VmU3eUyBp1SihPEXJ2U0MGdZhNhFX7Y74g11u66sg==} + dependencies: + prosemirror-changeset: 2.2.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.5.2 + prosemirror-dropcursor: 1.8.1 + prosemirror-gapcursor: 1.3.2 + prosemirror-history: 1.3.2 + prosemirror-inputrules: 1.3.0 + prosemirror-keymap: 1.2.2 + prosemirror-markdown: 1.12.0 + prosemirror-menu: 1.2.4 + prosemirror-model: 1.19.4 + prosemirror-schema-basic: 1.2.2 + prosemirror-schema-list: 1.3.0 + prosemirror-state: 1.4.3 + prosemirror-tables: 1.3.5 + prosemirror-trailing-node: 2.0.7(prosemirror-model@1.19.4)(prosemirror-state@1.4.3)(prosemirror-view@1.32.7) + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /@tiptap/react@2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Dq3f8EtJnpImP3iDtJo+7bulnN9SJZRZcVVzxHXccLcC2MxtmDdlPGZjP+wxO800nd8toSIOd5734fPNf/YcfA==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-bubble-menu': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-floating-menu': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/pm': 2.1.13 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@tiptap/starter-kit@2.1.13(@tiptap/pm@2.1.13): + resolution: {integrity: sha512-ph/mUR/OwPtPkZ5rNHINxubpABn8fHnvJSdhXFrY/q6SKoaO11NZXgegRaiG4aL7O6Sz4LsZVw6Sm0Ae+GJmrg==} + dependencies: + '@tiptap/core': 2.1.13(@tiptap/pm@2.1.13) + '@tiptap/extension-blockquote': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-bold': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-bullet-list': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-code': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-code-block': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-document': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-dropcursor': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-gapcursor': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-hard-break': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-heading': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-history': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-horizontal-rule': 2.1.13(@tiptap/core@2.1.13)(@tiptap/pm@2.1.13) + '@tiptap/extension-italic': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-list-item': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-ordered-list': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-paragraph': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-strike': 2.1.13(@tiptap/core@2.1.13) + '@tiptap/extension-text': 2.1.13(@tiptap/core@2.1.13) + transitivePeerDependencies: + - '@tiptap/pm' + dev: false + /@tootallnate/once@1.1.2: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -6276,6 +6592,14 @@ packages: /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} + /@types/object.omit@3.0.3: + resolution: {integrity: sha512-xrq4bQTBGYY2cw+gV4PzoG2Lv3L0pjZ1uXStRRDQoATOYW1lCsFQHhQ+OkPhIcQoqLjAq7gYif7D14Qaa6Zbew==} + dev: false + + /@types/object.pick@1.3.4: + resolution: {integrity: sha512-5PjwB0uP2XDp3nt5u5NJAG2DORHIRClPzWT/TTZhJ2Ekwe8M5bA9tvPdi9NO/n2uvu2/ictat8kgqvLfcIE1SA==} + dev: false + /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} @@ -6473,6 +6797,10 @@ packages: pretty-format: 25.5.0 dev: false + /@types/throttle-debounce@2.1.0: + resolution: {integrity: sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==} + dev: false + /@types/triple-beam@1.3.2: resolution: {integrity: sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==} dev: false @@ -8839,6 +9167,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + dev: false + /case-sensitive-paths-webpack-plugin@2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} @@ -9780,6 +10113,10 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + /crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + dev: false + /cron-parser@3.5.0: resolution: {integrity: sha512-wyVZtbRs6qDfFd8ap457w3XVntdvqcwBGxBoTvJQH9KGVKL/fB+h2k3C8AqiVxvUQKN1Ps/Ns46CNViOpVDhfQ==} engines: {node: '>=0.8'} @@ -10174,6 +10511,10 @@ packages: engines: {node: '>=8'} dev: true + /dash-get@1.0.2: + resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==} + dev: false + /dashdash@1.14.1: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} @@ -10945,6 +11286,11 @@ packages: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: false + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -16314,6 +16660,12 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dev: true + /linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + dependencies: + uc.micro: 2.0.0 + dev: false + /load-json-file@1.1.0: resolution: {integrity: sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==} engines: {node: '>=0.10.0'} @@ -16722,6 +17074,18 @@ packages: object-visit: 1.0.1 dev: false + /markdown-it@14.0.0: + resolution: {integrity: sha512-seFjF0FIcPt4P9U39Bq1JYblX0KZCjDLFFQPHpL5AzHpqPEKtosxmdq/LTVZnjfH7tjt9BxStm+wXcDBNuYmzw==} + hasBin: true + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.0.0 + dev: false + /match-sorter@4.2.1: resolution: {integrity: sha512-s+3h9TiZU9U1pWhIERHf8/f4LmBN6IXaRgo2CI17+XGByGS1GvG5VvXK9pcGyCjGe3WM3mSYRC3ipGrd5UEVgw==} dependencies: @@ -16785,6 +17149,10 @@ packages: resolution: {integrity: sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==} dev: false + /mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + dev: false + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -18069,6 +18437,13 @@ packages: make-iterator: 1.0.1 dev: false + /object.omit@3.0.0: + resolution: {integrity: sha512-EO+BCv6LJfu+gBIF3ggLicFebFLN5zqzz/WWJlMFfkMyGth+oBkhxzDl0wx2W4GkLzuQs/FsSkXZb2IMWQqmBQ==} + engines: {node: '>=0.10.0'} + dependencies: + is-extendable: 1.0.1 + dev: false + /object.pick@1.3.0: resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==} engines: {node: '>=0.10.0'} @@ -18245,6 +18620,10 @@ packages: readable-stream: 2.3.7 dev: false + /orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + dev: false + /os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} dev: true @@ -19825,6 +20204,149 @@ packages: resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==} dev: false + /prosemirror-changeset@2.2.1: + resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==} + dependencies: + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + dependencies: + prosemirror-state: 1.4.3 + dev: false + + /prosemirror-commands@1.5.2: + resolution: {integrity: sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-dropcursor@1.8.1: + resolution: {integrity: sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-gapcursor@1.3.2: + resolution: {integrity: sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==} + dependencies: + prosemirror-keymap: 1.2.2 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-history@1.3.2: + resolution: {integrity: sha512-/zm0XoU/N/+u7i5zepjmZAEnpvjDtzoPWW6VmKptcAnPadN/SStsBjMImdCEbb3seiNTpveziPTIrXQbHLtU1g==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + rope-sequence: 1.3.4 + dev: false + + /prosemirror-inputrules@1.3.0: + resolution: {integrity: sha512-z1GRP2vhh5CihYMQYsJSa1cOwXb3SYxALXOIfAkX8nZserARtl9LiL+CEl+T+OFIsXc3mJIHKhbsmRzC0HDAXA==} + dependencies: + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-keymap@1.2.2: + resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==} + dependencies: + prosemirror-state: 1.4.3 + w3c-keyname: 2.2.8 + dev: false + + /prosemirror-markdown@1.12.0: + resolution: {integrity: sha512-6F5HS8Z0HDYiS2VQDZzfZP6A0s/I0gbkJy8NCzzDMtcsz3qrfqyroMMeoSjAmOhDITyon11NbXSzztfKi+frSQ==} + dependencies: + markdown-it: 14.0.0 + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-menu@1.2.4: + resolution: {integrity: sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==} + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.5.2 + prosemirror-history: 1.3.2 + prosemirror-state: 1.4.3 + dev: false + + /prosemirror-model@1.19.4: + resolution: {integrity: sha512-RPmVXxUfOhyFdayHawjuZCxiROsm9L4FCUA6pWI+l7n2yCBsWy9VpdE1hpDHUS8Vad661YLY9AzqfjLhAKQ4iQ==} + dependencies: + orderedmap: 2.1.1 + dev: false + + /prosemirror-schema-basic@1.2.2: + resolution: {integrity: sha512-/dT4JFEGyO7QnNTe9UaKUhjDXbTNkiWTq/N4VpKaF79bBjSExVV2NXmJpcM7z/gD7mbqNjxbmWW5nf1iNSSGnw==} + dependencies: + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-schema-list@1.3.0: + resolution: {integrity: sha512-Hz/7gM4skaaYfRPNgr421CU4GSwotmEwBVvJh5ltGiffUJwm7C8GfN/Bc6DR1EKEp5pDKhODmdXXyi9uIsZl5A==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + + /prosemirror-state@1.4.3: + resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-tables@1.3.5: + resolution: {integrity: sha512-JSZ2cCNlApu/ObAhdPyotrjBe2cimniniTpz60YXzbL0kZ+47nEYk2LWbfKU2lKpBkUNquta2PjteoNi4YCluQ==} + dependencies: + prosemirror-keymap: 1.2.2 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-trailing-node@2.0.7(prosemirror-model@1.19.4)(prosemirror-state@1.4.3)(prosemirror-view@1.32.7): + resolution: {integrity: sha512-8zcZORYj/8WEwsGo6yVCRXFMOfBo0Ub3hCUvmoWIZYfMP26WqENU0mpEP27w7mt8buZWuGrydBewr0tOArPb1Q==} + peerDependencies: + prosemirror-model: ^1.19.0 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.31.2 + dependencies: + '@remirror/core-constants': 2.0.2 + '@remirror/core-helpers': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-view: 1.32.7 + dev: false + + /prosemirror-transform@1.8.0: + resolution: {integrity: sha512-BaSBsIMv52F1BVVMvOmp1yzD3u65uC3HTzCBQV1WDPqJRQ2LuHKcyfn0jwqodo8sR9vVzMzZyI+Dal5W9E6a9A==} + dependencies: + prosemirror-model: 1.19.4 + dev: false + + /prosemirror-view@1.32.7: + resolution: {integrity: sha512-pvxiOoD4shW41X5bYDjRQk3DSG4fMqxh36yPMt7VYgU3dWRmqFzWJM/R6zeo1KtC8nyk717ZbQND3CC9VNeptw==} + dependencies: + prosemirror-model: 1.19.4 + prosemirror-state: 1.4.3 + prosemirror-transform: 1.8.0 + dev: false + /proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} dev: true @@ -19979,6 +20501,11 @@ packages: pump: 2.0.1 dev: false + /punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + dev: false + /punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} dev: true @@ -21500,6 +22027,10 @@ packages: fsevents: 2.3.2 dev: false + /rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + dev: false + /rsvp@4.8.5: resolution: {integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==} engines: {node: 6.* || >= 7.*} @@ -23119,6 +23650,11 @@ packages: engines: {node: '>=8'} dev: false + /throttle-debounce@3.0.1: + resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} + engines: {node: '>=10'} + dev: false + /through2-filter@3.0.0: resolution: {integrity: sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==} dependencies: @@ -23185,6 +23721,12 @@ packages: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} dev: false + /tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + dependencies: + '@popperjs/core': 2.11.8 + dev: false + /tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} dependencies: @@ -23569,6 +24111,11 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + /type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + dev: false + /type-is@1.6.15: resolution: {integrity: sha512-0uqZYZDiBICTVXEsNcDLueZLPgZ8FgGe8lmVDQ0FcVFUeaxsPbFWiz60ZChVw8VELIt7iGuCehOrZSYjYteWKQ==} engines: {node: '>= 0.6'} @@ -23661,6 +24208,10 @@ packages: engines: {node: '>=4.2.0'} hasBin: true + /uc.micro@2.0.0: + resolution: {integrity: sha512-DffL94LsNOccVn4hyfRe5rdKa273swqeA5DJpMOeFmEn1wCDc7nAbbB0gXlgBCL7TNzeTv6G7XVWzan7iJtfig==} + dev: false + /uglify-js@3.17.4: resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} engines: {node: '>=0.8.0'} @@ -24125,6 +24676,10 @@ packages: browser-process-hrtime: 1.0.0 dev: false + /w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + dev: false + /w3c-xmlserializer@1.1.2: resolution: {integrity: sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==} dependencies: From 2a85fe2f3ca203f18719065360cf1dbadac7579e Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 29 Dec 2023 17:31:51 +0200 Subject: [PATCH 027/148] feat(webapp): the mail notifications dialogs --- .../EstimateMailDialogForm.tsx | 45 +++++++------ .../EstimateMailDialogFormContent.tsx | 8 ++- .../InvoiceMailDialogForm.tsx | 46 ++++++++------ .../InvoiceMailDialogFormContent.tsx | 8 ++- .../PaymentMailDialogForm.tsx | 47 ++++++++------ .../PaymentMailDialogFormContent.tsx | 8 ++- .../ReceiptMailDialogForm.tsx | 50 +++++++++------ .../ReceiptMailDialogFormContent.tsx | 8 ++- .../MailNotificationForm.tsx | 11 +++- .../SendMailNotification/RichEditor.tsx | 63 ------------------- .../containers/SendMailNotification/index.ts | 2 +- .../containers/SendMailNotification/utils.ts | 44 +++++++++++++ packages/webapp/src/hooks/query/receipts.tsx | 2 +- 13 files changed, 192 insertions(+), 150 deletions(-) delete mode 100644 packages/webapp/src/containers/SendMailNotification/RichEditor.tsx create mode 100644 packages/webapp/src/containers/SendMailNotification/utils.ts diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx index 5f00c996e..f8811cdbb 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx @@ -1,27 +1,26 @@ // @ts-nocheck import { Formik } from 'formik'; import * as R from 'ramda'; -import { castArray } from 'lodash'; import { useEstimateMailDialogBoot } from './EstimateMailDialogBoot'; -import { transformToForm } from '@/utils'; import { DialogsName } from '@/constants/dialogs'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { useSendSaleEstimateMail } from '@/hooks/query'; import { EstimateMailDialogFormContent } from './EstimateMailDialogFormContent'; +import { + initialMailNotificationValues, + MailNotificationFormValues, + transformMailFormToInitialValues, + transformMailFormToRequest, +} from '@/containers/SendMailNotification/utils'; +import { Intent } from '@blueprintjs/core'; +import { AppToaster } from '@/components'; const initialFormValues = { - from: [], - to: [], - subject: '', - body: '', + ...initialMailNotificationValues, attachEstimate: true, }; -interface EstimateMailFormValues { - from: string[]; - to: string[]; - subject: string; - body: string; +interface EstimateMailFormValues extends MailNotificationFormValues { attachEstimate: boolean; } @@ -32,21 +31,31 @@ function EstimateMailDialogFormRoot({ const { mutateAsync: sendEstimateMail } = useSendSaleEstimateMail(); const { mailOptions, saleEstimateId } = useEstimateMailDialogBoot(); - const initialValues = { - ...initialFormValues, - ...transformToForm(mailOptions, initialFormValues), - from: mailOptions.from ? castArray(mailOptions.from) : [], - to: mailOptions.to ? castArray(mailOptions.to) : [], - }; + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); // Handle the form submitting. const handleSubmit = (values: EstimateMailFormValues, { setSubmitting }) => { + const reqValues = transformMailFormToRequest(values); + setSubmitting(true); - sendEstimateMail([saleEstimateId, values]) + sendEstimateMail([saleEstimateId, reqValues]) .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + closeDialog(DialogsName.EstimateMail); setSubmitting(false); }) .catch((error) => { setSubmitting(false); + closeDialog(DialogsName.EstimateMail); + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); }); }; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx index d299d5b16..668c7a4c9 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogFormContent.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import { FFormGroup, FSwitch } from '@/components'; import { MailNotificationForm } from '@/containers/SendMailNotification'; import { saveInvoke } from '@/utils'; +import { useEstimateMailDialogBoot } from './EstimateMailDialogBoot'; interface EstimateMailDialogFormContentProps { onClose?: () => void; @@ -14,6 +15,7 @@ export function EstimateMailDialogFormContent({ onClose, }: EstimateMailDialogFormContentProps) { const { isSubmitting } = useFormikContext(); + const { mailOptions } = useEstimateMailDialogBoot(); const handleClose = () => { saveInvoke(onClose); @@ -22,8 +24,10 @@ export function EstimateMailDialogFormContent({ return (
- - + diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx index 6aa62cfba..794ed890d 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx @@ -1,28 +1,27 @@ // @ts-nocheck import { Formik } from 'formik'; -import { castArray } from 'lodash'; import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; -import { transformToForm } from '@/utils'; import { DialogsName } from '@/constants/dialogs'; +import { AppToaster } from '@/components'; import { useSendSaleInvoiceMail } from '@/hooks/query'; -import { InvoiceMailDialogFormContent } from './InvoiceMailDialogFormContent'; import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { InvoiceMailDialogFormContent } from './InvoiceMailDialogFormContent'; import { InvoiceMailFormSchema } from './InvoiceMailDialogForm.schema'; +import { + MailNotificationFormValues, + initialMailNotificationValues, + transformMailFormToRequest, + transformMailFormToInitialValues, +} from '@/containers/SendMailNotification/utils'; const initialFormValues = { - from: [], - to: [], - subject: '', - body: '', + ...initialMailNotificationValues, attachInvoice: true, }; -interface InvoiceMailFormValues { - from: string[]; - to: string[]; - subject: string; - body: string; +interface InvoiceMailFormValues extends MailNotificationFormValues { attachInvoice: boolean; } @@ -33,20 +32,29 @@ function InvoiceMailDialogFormRoot({ const { mailOptions, saleInvoiceId } = useInvoiceMailDialogBoot(); const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail(); - const initialValues = { - ...initialFormValues, - ...transformToForm(mailOptions, initialFormValues), - from: mailOptions.from ? castArray(mailOptions.from) : [], - to: mailOptions.to ? castArray(mailOptions.to) : [], - }; + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); // Handle the form submitting. const handleSubmit = (values: InvoiceMailFormValues, { setSubmitting }) => { + const reqValues = transformMailFormToRequest(values); + setSubmitting(true); - sendInvoiceMail([saleInvoiceId, values]) + sendInvoiceMail([saleInvoiceId, reqValues]) .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + closeDialog(DialogsName.InvoiceMail); setSubmitting(false); }) .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); setSubmitting(false); }); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx index 2038d5379..07e104027 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogFormContent.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import { FFormGroup, FSwitch } from '@/components'; import { MailNotificationForm } from '@/containers/SendMailNotification'; import { saveInvoke } from '@/utils'; +import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; interface SendMailNotificationFormProps { onClose?: () => void; @@ -14,6 +15,7 @@ export function InvoiceMailDialogFormContent({ onClose, }: SendMailNotificationFormProps) { const { isSubmitting } = useFormikContext(); + const { mailOptions } = useInvoiceMailDialogBoot(); const handleClose = () => { saveInvoke(onClose); @@ -22,8 +24,10 @@ export function InvoiceMailDialogFormContent({ return (
- - + diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx index 04906185d..075c2ee8b 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx @@ -1,27 +1,26 @@ // @ts-nocheck import { Formik, FormikBag } from 'formik'; -import { castArray } from 'lodash'; import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; import { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; import { useSendPaymentReceiveMail } from '@/hooks/query'; import { PaymentMailDialogFormContent } from './PaymentMailDialogFormContent'; -import { transformToForm } from '@/utils'; +import { + MailNotificationFormValues, + initialMailNotificationValues, + transformMailFormToRequest, + transformMailFormToInitialValues, +} from '@/containers/SendMailNotification/utils'; +import { AppToaster } from '@/components'; const initialFormValues = { - from: [], - to: [], - subject: '', - body: '', + ...initialMailNotificationValues, attachPayment: true, }; -interface PaymentMailFormValue { - from: string[]; - to: string[]; - subject: string; - body: string; +interface PaymentMailFormValue extends MailNotificationFormValues { attachPayment: boolean; } @@ -32,24 +31,34 @@ export function PaymentMailDialogFormRoot({ const { mailOptions, paymentId } = usePaymentMailDialogBoot(); const { mutateAsync: sendPaymentMail } = useSendPaymentReceiveMail(); - const initialValues = { - ...initialFormValues, - ...transformToForm(mailOptions, initialFormValues), - from: mailOptions.from ? castArray(mailOptions.from) : [], - to: mailOptions.to ? castArray(mailOptions.to) : [], - }; + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); // Handles the form submitting. const handleSubmit = ( values: PaymentMailFormValue, { setSubmitting }: FormikBag, ) => { + const reqValues = transformMailFormToRequest(values); + setSubmitting(true); - sendPaymentMail([paymentId, values]) + sendPaymentMail([paymentId, reqValues]) .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); setSubmitting(false); + closeDialog(DialogsName.PaymentMail); }) - .catch((error) => { + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); setSubmitting(false); + closeDialog(DialogsName.PaymentMail); }); }; diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx index 172494c40..5a04f0f28 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogFormContent.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import { FFormGroup, FSwitch } from '@/components'; import { MailNotificationForm } from '@/containers/SendMailNotification'; import { saveInvoke } from '@/utils'; +import { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; interface PaymentMailDialogFormContentProps { onClose?: () => void; @@ -13,6 +14,7 @@ interface PaymentMailDialogFormContentProps { export function PaymentMailDialogFormContent({ onClose, }: PaymentMailDialogFormContentProps) { + const { mailOptions } = usePaymentMailDialogBoot(); const { isSubmitting } = useFormikContext(); const handleClose = () => { @@ -22,8 +24,10 @@ export function PaymentMailDialogFormContent({ return (
- - + diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx index 2d5a3bcf1..fb9b845af 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx @@ -1,26 +1,25 @@ // @ts-nocheck import { Formik, FormikBag } from 'formik'; -import { castArray } from 'lodash'; import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; -import { transformToForm } from '@/utils'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; import { useSendSaleReceiptMail } from '@/hooks/query'; import { ReceiptMailDialogFormContent } from './ReceiptMailDialogFormContent'; +import { + initialMailNotificationValues, + MailNotificationFormValues, + transformMailFormToInitialValues, + transformMailFormToRequest, +} from '@/containers/SendMailNotification/utils'; +import { AppToaster } from '@/components'; const initialFormValues = { - from: [], - to: [], - subject: '', - body: '', + ...initialMailNotificationValues, attachReceipt: true, }; -interface ReceiptMailFormValues { - from: string[]; - to: string[]; - subject: string; - body: string; +interface ReceiptMailFormValues extends MailNotificationFormValues { attachReceipt: boolean; } @@ -28,26 +27,37 @@ function ReceiptMailDialogFormRoot({ closeDialog }) { const { mailOptions, saleReceiptId } = useReceiptMailDialogBoot(); const { mutateAsync: sendReceiptMail } = useSendSaleReceiptMail(); - const initialValues = { - ...initialFormValues, - ...transformToForm(mailOptions, initialFormValues), - from: mailOptions.from ? castArray(mailOptions.from) : [], - to: mailOptions.to ? castArray(mailOptions.to) : [], - }; + // Transformes mail options to initial form values. + const initialValues = transformMailFormToInitialValues( + mailOptions, + initialFormValues, + ); + // Handle the form submitting. const handleSubmit = ( values: ReceiptMailFormValues, { setSubmitting }: FormikBag, ) => { + const reqValues = transformMailFormToRequest(values); + setSubmitting(true); - sendReceiptMail([saleReceiptId, values]) + sendReceiptMail([saleReceiptId, reqValues]) .then(() => { + AppToaster.show({ + message: 'The mail notification has been sent successfully.', + intent: Intent.SUCCESS, + }); + closeDialog(DialogsName.ReceiptMail); setSubmitting(false); }) - .catch((error) => { + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); setSubmitting(false); }); }; - + // Handle the close button click. const handleClose = () => { closeDialog(DialogsName.ReceiptMail); }; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx index 381160f09..d824d35af 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogFormContent.tsx @@ -4,6 +4,7 @@ import { Button, Classes, Intent } from '@blueprintjs/core'; import styled from 'styled-components'; import { FFormGroup, FSwitch } from '@/components'; import { MailNotificationForm } from '@/containers/SendMailNotification'; +import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; import { saveInvoke } from '@/utils'; interface SendMailNotificationFormProps { @@ -13,6 +14,7 @@ interface SendMailNotificationFormProps { export function ReceiptMailDialogFormContent({ onClose, }: SendMailNotificationFormProps) { + const { mailOptions } = useReceiptMailDialogBoot(); const { isSubmitting } = useFormikContext(); const handleClose = () => { @@ -22,8 +24,10 @@ export function ReceiptMailDialogFormContent({ return (
- - + diff --git a/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx index 6b5053dd5..b7e578b91 100644 --- a/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx +++ b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx @@ -16,6 +16,14 @@ interface MailNotificationFormProps { toAddresses: SelectOptionProps[]; } +const commonAddressSelect = { + placeholder: '', + labelAccessor: '', + valueAccessor: 'mail', + tagAccessor: (item) => `<${item.label}> (${item.mail})`, + textAccessor: (item) => `<${item.label}> (${item.mail})`, +}; + export function MailNotificationForm({ fromAddresses, toAddresses, @@ -38,12 +46,12 @@ export function MailNotificationForm({ @@ -57,6 +65,7 @@ export function MailNotificationForm({ tagProps: { round: true, minimal: true, large: true }, }} fill={true} + {...commonAddressSelect} /> diff --git a/packages/webapp/src/containers/SendMailNotification/RichEditor.tsx b/packages/webapp/src/containers/SendMailNotification/RichEditor.tsx deleted file mode 100644 index e540fd6da..000000000 --- a/packages/webapp/src/containers/SendMailNotification/RichEditor.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// @ts-nocheck -import './styles.scss'; -import { Color } from '@tiptap/extension-color'; -import ListItem from '@tiptap/extension-list-item'; -import TextStyle from '@tiptap/extension-text-style'; -import { EditorProvider } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; -import { Box } from '@/components'; -import styled from 'styled-components'; -import { useUncontrolled } from '@/hooks/useUncontrolled'; - -const extensions = [ - Color.configure({ types: [TextStyle.name, ListItem.name] }), - TextStyle.configure({ types: [ListItem.name] }), - StarterKit.configure({ - bulletList: { - keepMarks: true, - keepAttributes: false, - }, - orderedList: { - keepMarks: true, - keepAttributes: false, - }, - }), -]; - -export interface RichEditorProps { - value?: string; - initialValue?: string; - onChange?: (value: string) => void; - className?: string; -} -export const RichEditor = ({ - value, - initialValue, - onChange, - className, -}: RichEditorProps) => { - const [content, handleChange] = useUncontrolled({ - value, - initialValue, - finalValue: '', - onChange, - }); - - return ( - - - - ); -}; - -const Root = styled(Box)` - padding: 15px; - border: 1px solid #dedfe9; - border-top: 0; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; -`; diff --git a/packages/webapp/src/containers/SendMailNotification/index.ts b/packages/webapp/src/containers/SendMailNotification/index.ts index 6e5788dcc..5662fe7c9 100644 --- a/packages/webapp/src/containers/SendMailNotification/index.ts +++ b/packages/webapp/src/containers/SendMailNotification/index.ts @@ -1 +1 @@ -export * from './SendMailNotificationForm'; \ No newline at end of file +export * from './MailNotificationForm'; \ No newline at end of file diff --git a/packages/webapp/src/containers/SendMailNotification/utils.ts b/packages/webapp/src/containers/SendMailNotification/utils.ts new file mode 100644 index 000000000..59d0f6420 --- /dev/null +++ b/packages/webapp/src/containers/SendMailNotification/utils.ts @@ -0,0 +1,44 @@ +import { castArray, first } from 'lodash'; +import { transformToForm } from '@/utils'; + +export const initialMailNotificationValues = { + from: [], + to: [], + subject: '', + body: '', +}; + +export interface MailNotificationFormValues { + from: string[]; + to: string[]; + subject: string; + body: string; +} + +export const transformMailFormToRequest = ( + values: MailNotificationFormValues, +) => { + return { + ...values, + from: first(values.from), + to: values.to?.join(', '), + }; +}; + +/** + * Transformes the mail options response values to form initial values. + * @param {any} mailOptions + * @param {MailNotificationFormValues} initialValues + * @returns {MailNotificationFormValues} + */ +export const transformMailFormToInitialValues = ( + mailOptions: any, + initialValues: MailNotificationFormValues, +): MailNotificationFormValues => { + return { + ...initialValues, + ...transformToForm(mailOptions, initialValues), + from: mailOptions.from ? castArray(mailOptions.from) : [], + to: mailOptions.to ? castArray(mailOptions.to) : [], + }; +}; diff --git a/packages/webapp/src/hooks/query/receipts.tsx b/packages/webapp/src/hooks/query/receipts.tsx index 60b309589..7a6ae2ce9 100644 --- a/packages/webapp/src/hooks/query/receipts.tsx +++ b/packages/webapp/src/hooks/query/receipts.tsx @@ -216,7 +216,7 @@ export function useSendSaleReceiptMail(props) { const apiRequest = useApiRequest(); return useMutation( - (id, values) => apiRequest.post(`sales/receipts/${id}/mail`, values), + ([id, values]) => apiRequest.post(`sales/receipts/${id}/mail`, values), { onSuccess: () => { // Invalidate queries. From 0d15c16d40b731cba142d2f2cbc915df32294b9d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 29 Dec 2023 17:35:34 +0200 Subject: [PATCH 028/148] feat(server): contact mail notification service --- .../api/controllers/Sales/SalesInvoices.ts | 16 ++- packages/server/src/config/index.ts | 1 + packages/server/src/interfaces/SaleInvoice.ts | 14 ++- packages/server/src/models/Customer.ts | 16 +++ .../ContactMailNotification.ts | 78 ++++++++++++ .../src/services/MailTenancy/MailTenancy.ts | 25 ++++ .../Estimates/SaleEstimatesApplication.ts | 2 +- .../Sales/Estimates/SendSaleEstimateMail.ts | 114 +++++++++-------- .../Sales/Invoices/SaleInvoicesApplication.ts | 14 +-- .../Invoices/SendInvoiceInvoiceMailCommon.ts | 115 ++++++++++++++++++ .../Sales/Invoices/SendSaleInvoiceMail.ts | 107 ++++------------ .../Invoices/SendSaleInvoiceMailReminder.ts | 105 ++++------------ .../src/services/Sales/Invoices/constants.ts | 10 ++ .../PaymentReceiveMailNotification.ts | 50 ++++---- .../PaymentReceivesApplication.ts | 5 +- .../Sales/Receipts/SaleReceiptApplication.ts | 8 +- .../Receipts/SaleReceiptMailNotification.ts | 71 ++++++----- 17 files changed, 441 insertions(+), 310 deletions(-) create mode 100644 packages/server/src/services/MailNotification/ContactMailNotification.ts create mode 100644 packages/server/src/services/MailTenancy/MailTenancy.ts create mode 100644 packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index cd7f5bfec..9e5ac8d25 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -696,7 +696,10 @@ export default class SaleInvoicesController extends BaseController { invoiceId, invoiceMailDTO ); - return res.status(200).send({}); + return res.status(200).send({ + code: 200, + message: 'The sale invoice mail has been sent successfully.', + }); } catch (error) { next(error); } @@ -717,18 +720,18 @@ export default class SaleInvoicesController extends BaseController { const { id: invoiceId } = req.params; try { - await this.saleInvoiceApplication.getSaleInvoiceMailReminder( + const data = await this.saleInvoiceApplication.getSaleInvoiceMailReminder( tenantId, invoiceId ); - return res.status(200).send({}); + return res.status(200).send(data); } catch (error) { next(error); } } /** - * + * Sends mail invoice of the given sale invoice. * @param {Request} req * @param {Response} res * @param {NextFunction} next @@ -749,7 +752,10 @@ export default class SaleInvoicesController extends BaseController { invoiceId, invoiceMailDTO ); - return res.status(200).send({}); + return res.status(200).send({ + code: 200, + message: 'The sale invoice mail reminder has been sent successfully.', + }); } catch (error) { next(error); } diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index bc6833130..0dc9d9676 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -58,6 +58,7 @@ module.exports = { secure: !!parseInt(process.env.MAIL_SECURE, 10), username: process.env.MAIL_USERNAME, password: process.env.MAIL_PASSWORD, + from: process.env.MAIL_FROM_ADDRESS, }, /** diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 7d7633b87..d660b17ca 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -1,5 +1,5 @@ import { Knex } from 'knex'; -import { ISystemUser, IAccount, ITaxTransaction } from '@/interfaces'; +import { ISystemUser, IAccount, ITaxTransaction, AddressItem } from '@/interfaces'; import { IDynamicListFilter } from '@/interfaces/DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; @@ -187,8 +187,18 @@ export enum SaleInvoiceAction { NotifyBySms = 'NotifyBySms', } +export interface SaleInvoiceMailOptions { + toAddresses: AddressItem[]; + fromAddresses: AddressItem[]; + from: string; + to: string | string[]; + subject: string; + body: string; + attachInvoice: boolean; +} + export interface SendInvoiceMailDTO { - to: string; + to: string | string[]; from: string; subject: string; body: string; diff --git a/packages/server/src/models/Customer.ts b/packages/server/src/models/Customer.ts index 690b77d55..631763b71 100644 --- a/packages/server/src/models/Customer.ts +++ b/packages/server/src/models/Customer.ts @@ -24,6 +24,9 @@ export default class Customer extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { + email: string; + displayName: string; + /** * Query builder. */ @@ -76,6 +79,19 @@ export default class Customer extends mixin(TenantModel, [ return 'debit'; } + /** + * + */ + get contactAddresses() { + return [ + { + mail: this.email, + label: this.displayName, + primary: true + }, + ].filter((c) => c.mail); + } + /** * Model modifiers. */ diff --git a/packages/server/src/services/MailNotification/ContactMailNotification.ts b/packages/server/src/services/MailNotification/ContactMailNotification.ts new file mode 100644 index 000000000..21c745a96 --- /dev/null +++ b/packages/server/src/services/MailNotification/ContactMailNotification.ts @@ -0,0 +1,78 @@ +import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import { SaleInvoiceMailOptions } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { MailTenancy } from '@/services/MailTenancy/MailTenancy'; +import { formatSmsMessage } from '@/utils'; + +@Service() +export class ContactMailNotification { + @Inject() + private mailTenancy: MailTenancy; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Parses the default message options. + * @param {number} tenantId + * @param {number} invoiceId + * @returns {Promise} + */ + public async getDefaultMailOptions( + tenantId: number, + contactId: number, + subject: string = '', + body: string = '' + ): Promise { + const { Contact, Customer } = this.tenancy.models(tenantId); + const contact = await Customer.query().findById(contactId).throwIfNotFound(); + + const toAddresses = contact.contactAddresses; + const fromAddresses = await this.mailTenancy.senders(tenantId); + + const toAddress = toAddresses.find((a) => a.primary); + const fromAddress = fromAddresses.find((a) => a.primary); + + const to = toAddress?.mail || ''; + const from = fromAddress?.mail || ''; + + return { + subject, + body, + to, + from, + fromAddresses, + toAddresses, + }; + } + + /** + * Retrieves the mail options. + * @param {number} + * @param {number} invoiceId + * @returns {} + */ + public async getMailOptions( + tenantId: number, + contactId: number, + defaultSubject?: string, + defaultBody?: string, + formatterData?: Record + ): Promise { + const mailOpts = await this.getDefaultMailOptions( + tenantId, + contactId, + defaultSubject, + defaultBody + ); + const subject = formatSmsMessage(mailOpts.subject, formatterData); + const body = formatSmsMessage(mailOpts.body, formatterData); + + return { + ...mailOpts, + subject, + body, + }; + } +} diff --git a/packages/server/src/services/MailTenancy/MailTenancy.ts b/packages/server/src/services/MailTenancy/MailTenancy.ts new file mode 100644 index 000000000..6f8e82e11 --- /dev/null +++ b/packages/server/src/services/MailTenancy/MailTenancy.ts @@ -0,0 +1,25 @@ +import config from '@/config'; +import { Tenant } from "@/system/models"; +import { Service } from 'typedi'; + + +@Service() +export class MailTenancy { + /** + * Retrieves the senders mails of the given tenant. + * @param {number} tenantId + */ + public async senders(tenantId: number) { + const tenant = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return [ + { + mail: config.mail.from, + label: tenant.metadata.name, + primary: true, + } + ].filter((item) => item.mail) + } +} \ No newline at end of file diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts index 68cd8e601..1ceb2bbcc 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts @@ -240,7 +240,7 @@ export class SaleEstimatesApplication { * @returns {} */ public getSaleEstimateMail(tenantId: number, saleEstimateId: number) { - return this.sendEstimateMailService.getDefaultMailOpts( + return this.sendEstimateMailService.getMailOptions( tenantId, saleEstimateId ); diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts index 9ae6fa2f8..5777ab55a 100644 --- a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts @@ -1,5 +1,4 @@ import { Inject, Service } from 'typedi'; -import * as R from 'ramda'; import Mail from '@/lib/Mail'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { @@ -8,28 +7,31 @@ import { } from './constants'; import { SaleEstimatesPdf } from './SaleEstimatesPdf'; import { GetSaleEstimate } from './GetSaleEstimate'; -import { formatSmsMessage } from '@/utils'; import { SaleEstimateMailOptions } from '@/interfaces'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; @Service() export class SendSaleEstimateMail { @Inject() private tenancy: HasTenancyService; - @Inject('agenda') - private agenda: any; - @Inject() private estimatePdf: SaleEstimatesPdf; @Inject() private getSaleEstimateService: GetSaleEstimate; + @Inject() + private contactMailNotification: ContactMailNotification; + + @Inject('agenda') + private agenda: any; + /** * Triggers the reminder mail of the given sale estimate. - * @param {number} tenantId - * @param {number} saleEstimateId - * @param {SaleEstimateMailOptions} messageOptions + * @param {number} tenantId - + * @param {number} saleEstimateId - + * @param {SaleEstimateMailOptions} messageOptions - */ public async triggerMail( tenantId: number, @@ -50,51 +52,50 @@ export class SendSaleEstimateMail { * @param {number} estimateId * @param {string} text */ - public formatText = async ( - tenantId: number, - estimateId: number, - text: string - ) => { + public formatterData = async (tenantId: number, estimateId: number) => { const estimate = await this.getSaleEstimateService.getEstimate( tenantId, estimateId ); - return formatSmsMessage(text, { + return { CustomerName: estimate.customer.displayName, EstimateNumber: estimate.estimateNumber, EstimateDate: estimate.formattedEstimateDate, EstimateAmount: estimate.formattedAmount, EstimateExpirationDate: estimate.formattedExpirationDate, - }); - }; - - /** - * Retrieves the default mail options. - * @param {number} tenantId - * @param {number} saleEstimateId - * @returns {Promise} - */ - public getDefaultMailOpts = async ( - tenantId: number, - saleEstimateId: number - ) => { - const { SaleEstimate } = this.tenancy.models(tenantId); - - const saleEstimate = await SaleEstimate.query() - .findById(saleEstimateId) - .withGraphFetched('customer') - .throwIfNotFound(); - - return { - attachPdf: true, - subject: DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, - body: DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT, - to: saleEstimate.customer.email, }; }; /** - * Sends the mail. + * Retrieves the mail options. + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns + */ + public getMailOptions = async (tenantId: number, saleEstimateId: number) => { + const { SaleEstimate } = this.tenancy.models(tenantId); + + const saleEstimate = await SaleEstimate.query() + .findById(saleEstimateId) + .throwIfNotFound(); + + const formatterData = await this.formatterData(tenantId, saleEstimateId); + + const mailOptions = await this.contactMailNotification.getMailOptions( + tenantId, + saleEstimate.customerId, + DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, + DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT, + formatterData + ); + return { + ...mailOptions, + data: formatterData, + }; + }; + + /** + * Sends the mail notification of the given sale estimate. * @param {number} tenantId * @param {number} saleEstimateId * @param {SaleEstimateMailOptions} messageOptions @@ -104,34 +105,31 @@ export class SendSaleEstimateMail { saleEstimateId: number, messageOptions: SaleEstimateMailOptions ) { - const defaultMessageOpts = await this.getDefaultMailOpts( + const localMessageOpts = await this.getMailOptions( tenantId, saleEstimateId ); - const parsedMessageOpts = { - ...defaultMessageOpts, + const messageOpts = { + ...localMessageOpts, ...messageOptions, }; - const formatter = R.curry(this.formatText)(tenantId, saleEstimateId); - const subject = await formatter(parsedMessageOpts.subject); - const body = await formatter(parsedMessageOpts.body); - const attachments = []; + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); - if (parsedMessageOpts.to) { + if (messageOpts.to) { const estimatePdfBuffer = await this.estimatePdf.getSaleEstimatePdf( tenantId, saleEstimateId ); - attachments.push({ - filename: 'estimate.pdf', - content: estimatePdfBuffer, - }); + mail.setAttachments([ + { + filename: messageOpts.data?.EstimateNumber || 'estimate.pdf', + content: estimatePdfBuffer, + }, + ]); } - await new Mail() - .setSubject(subject) - .setTo(parsedMessageOpts.to) - .setContent(body) - .setAttachments(attachments) - .send(); + await mail.send(); } } diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts index 9b3c19d33..b175d9546 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts @@ -300,10 +300,7 @@ export class SaleInvoiceApplication { * @returns {} */ public getSaleInvoiceMailReminder(tenantId: number, saleInvoiceId: number) { - return this.getSaleInvoiceReminderService.getInvoiceMailReminder( - tenantId, - saleInvoiceId - ); + return this.sendInvoiceReminderService.getMailOpts(tenantId, saleInvoiceId); } /** @@ -345,14 +342,11 @@ export class SaleInvoiceApplication { /** * Retrieves the default mail options of the given sale invoice. - * @param {number} tenantId - * @param {number} saleInvoiceid + * @param {number} tenantId + * @param {number} saleInvoiceid * @returns {Promise} */ public getSaleInvoiceMail(tenantId: number, saleInvoiceid: number) { - return this.sendInvoiceReminderService.getDefaultMailOpts( - tenantId, - saleInvoiceid - ); + return this.sendSaleInvoiceMailService.getMailOpts(tenantId, saleInvoiceid); } } diff --git a/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts new file mode 100644 index 000000000..97be8de87 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts @@ -0,0 +1,115 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { SaleInvoiceMailOptions } from '@/interfaces'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + DEFAULT_INVOICE_MAIL_CONTENT, + DEFAULT_INVOICE_MAIL_SUBJECT, +} from './constants'; +import { GetSaleInvoice } from './GetSaleInvoice'; +import { Tenant } from '@/system/models'; +import { ServiceError } from '@/exceptions'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; + +@Service() +export class SendSaleInvoiceMailCommon { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getSaleInvoiceService: GetSaleInvoice; + + @Inject() + private contactMailNotification: ContactMailNotification; + + /** + * Retrieves the mail options. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Invoice id. + * @param {string} defaultSubject - Subject text. + * @param {string} defaultBody - Subject body. + * @returns {} + */ + public async getMailOpts( + tenantId: number, + invoiceId: number, + defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT, + defaultBody: string = DEFAULT_INVOICE_MAIL_CONTENT + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const saleInvoice = await SaleInvoice.query() + .findById(invoiceId) + .throwIfNotFound(); + + const formatterData = await this.formatText(tenantId, invoiceId); + + return this.contactMailNotification.getMailOptions( + tenantId, + saleInvoice.customerId, + defaultSubject, + defaultBody, + formatterData + ); + } + + /** + * Retrieves the formatted text of the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Sale invoice id. + * @param {string} text - The given text. + * @returns {Promise} + */ + public formatText = async ( + tenantId: number, + invoiceId: number + ): Promise> => { + const invoice = await this.getSaleInvoiceService.getSaleInvoice( + tenantId, + invoiceId + ); + const organization = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return { + CompanyName: organization.metadata.name, + CustomerName: invoice.customer.displayName, + InvoiceNumber: invoice.invoiceNo, + InvoiceDueAmount: invoice.dueAmountFormatted, + InvoiceDueDate: invoice.dueDateFormatted, + InvoiceDate: invoice.invoiceDateFormatted, + InvoiceAmount: invoice.totalFormatted, + OverdueDays: invoice.overdueDays, + }; + }; + + /** + * Validates the mail notification options before sending it. + * @param {Partial} mailNotificationOpts + * @throws {ServiceError} + */ + public validateMailNotification( + mailNotificationOpts: Partial + ) { + if (isEmpty(mailNotificationOpts.from)) { + throw new ServiceError(ERRORS.MAIL_FROM_NOT_FOUND); + } + if (isEmpty(mailNotificationOpts.to)) { + throw new ServiceError(ERRORS.MAIL_TO_NOT_FOUND); + } + if (isEmpty(mailNotificationOpts.subject)) { + throw new ServiceError(ERRORS.MAIL_SUBJECT_NOT_FOUND); + } + if (isEmpty(mailNotificationOpts.body)) { + throw new ServiceError(ERRORS.MAIL_BODY_NOT_FOUND); + } + } +} + +const ERRORS = { + MAIL_FROM_NOT_FOUND: 'Mail from address not found', + MAIL_TO_NOT_FOUND: 'Mail to address not found', + MAIL_SUBJECT_NOT_FOUND: 'Mail subject not found', + MAIL_BODY_NOT_FOUND: 'Mail body not found', +}; diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts index cc8269c56..a5bc744e1 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts @@ -1,29 +1,20 @@ import { Inject, Service } from 'typedi'; -import * as R from 'ramda'; -import { SendInvoiceMailDTO } from '@/interfaces'; import Mail from '@/lib/Mail'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SendInvoiceMailDTO } from '@/interfaces'; import { SaleInvoicePdf } from './SaleInvoicePdf'; +import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon'; import { DEFAULT_INVOICE_MAIL_CONTENT, DEFAULT_INVOICE_MAIL_SUBJECT, - ERRORS, } from './constants'; -import { ServiceError } from '@/exceptions'; -import { formatSmsMessage } from '@/utils'; -import { GetSaleInvoice } from './GetSaleInvoice'; -import { Tenant } from '@/system/models'; @Service() export class SendSaleInvoiceMail { @Inject() - private tenancy: HasTenancyService; + private invoicePdf: SaleInvoicePdf; @Inject() - private getSaleInvoiceService: GetSaleInvoice; - - @Inject() - private invoicePdf: SaleInvoicePdf; + private invoiceMail: SendSaleInvoiceMailCommon; @Inject('agenda') private agenda: any; @@ -48,56 +39,19 @@ export class SendSaleInvoiceMail { } /** - * Retrieves the default invoice mail options. + * Retrieves the mail options of the given sale invoice. * @param {number} tenantId - * @param {number} invoiceId - * @returns {Promise} + * @param {number} saleInvoiceId + * @returns {Promise} */ - public getDefaultMailOpts = async (tenantId: number, invoiceId: number) => { - const { SaleInvoice } = this.tenancy.models(tenantId); - const saleInvoice = await SaleInvoice.query() - .findById(invoiceId) - .withGraphFetched('customer') - .throwIfNotFound(); - - return { - attachInvoice: true, - subject: DEFAULT_INVOICE_MAIL_SUBJECT, - body: DEFAULT_INVOICE_MAIL_CONTENT, - to: saleInvoice.customer.email, - }; - }; - - /** - * Retrieves the formatted text of the given sale invoice. - * @param {number} tenantId - Tenant id. - * @param {number} invoiceId - Sale invoice id. - * @param {string} text - The given text. - * @returns {Promise} - */ - public textFormatter = async ( - tenantId: number, - invoiceId: number, - text: string - ): Promise => { - const invoice = await this.getSaleInvoiceService.getSaleInvoice( + public async getMailOpts(tenantId: number, saleInvoiceId: number) { + return this.invoiceMail.getMailOpts( tenantId, - invoiceId + saleInvoiceId, + DEFAULT_INVOICE_MAIL_SUBJECT, + DEFAULT_INVOICE_MAIL_CONTENT ); - const organization = await Tenant.query() - .findById(tenantId) - .withGraphFetched('metadata'); - - return formatSmsMessage(text, { - CompanyName: organization.metadata.name, - CustomerName: invoice.customer.displayName, - InvoiceNumber: invoice.invoiceNo, - InvoiceDueAmount: invoice.dueAmountFormatted, - InvoiceDueDate: invoice.dueDateFormatted, - InvoiceDate: invoice.invoiceDateFormatted, - InvoiceAmount: invoice.totalFormatted, - }); - }; + } /** * Triggers the mail invoice. @@ -111,37 +65,30 @@ export class SendSaleInvoiceMail { saleInvoiceId: number, messageDTO: SendInvoiceMailDTO ) { - const defaultMessageOpts = await this.getDefaultMailOpts( - tenantId, - saleInvoiceId - ); + const defaultMessageOpts = await this.getMailOpts(tenantId, saleInvoiceId); + // Parsed message opts with default options. - const parsedMessageOpts = { + const messageOpts = { ...defaultMessageOpts, ...messageDTO, }; - // In case there is no email address from the customer or from options, throw an error. - if (!parsedMessageOpts.to) { - throw new ServiceError(ERRORS.NO_INVOICE_CUSTOMER_EMAIL_ADDR); - } - const formatter = R.curry(this.textFormatter)(tenantId, saleInvoiceId); - const subject = await formatter(parsedMessageOpts.subject); - const body = await formatter(parsedMessageOpts.body); - const attachments = []; + this.invoiceMail.validateMailNotification(messageOpts); - if (parsedMessageOpts.attachInvoice) { + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); + + if (messageOpts.attachInvoice) { // Retrieves document buffer of the invoice pdf document. const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( tenantId, saleInvoiceId ); - attachments.push({ filename: 'invoice.pdf', content: invoicePdfBuffer }); + mail.setAttachments([ + { filename: 'invoice.pdf', content: invoicePdfBuffer }, + ]); } - await new Mail() - .setSubject(subject) - .setTo(parsedMessageOpts.to) - .setContent(body) - .setAttachments(attachments) - .send(); + await mail.send(); } } diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts index 886916260..09463bddc 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts @@ -1,24 +1,15 @@ import { Inject, Service } from 'typedi'; -import * as R from 'ramda'; import { SendInvoiceMailDTO } from '@/interfaces'; import Mail from '@/lib/Mail'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SaleInvoicePdf } from './SaleInvoicePdf'; +import { SendSaleInvoiceMailCommon } from './SendInvoiceInvoiceMailCommon'; import { DEFAULT_INVOICE_REMINDER_MAIL_CONTENT, DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, - ERRORS, } from './constants'; -import { SaleInvoicePdf } from './SaleInvoicePdf'; -import { ServiceError } from '@/exceptions'; -import { GetSaleInvoice } from './GetSaleInvoice'; -import { Tenant } from '@/system/models'; -import { formatSmsMessage } from '@/utils'; @Service() export class SendInvoiceMailReminder { - @Inject() - private tenancy: HasTenancyService; - @Inject('agenda') private agenda: any; @@ -26,7 +17,7 @@ export class SendInvoiceMailReminder { private invoicePdf: SaleInvoicePdf; @Inject() - private getSaleInvoiceService: GetSaleInvoice; + private invoiceCommonMail: SendSaleInvoiceMailCommon; /** * Triggers the reminder mail of the given sale invoice. @@ -47,57 +38,19 @@ export class SendInvoiceMailReminder { } /** - * Parses the default message options. + * Retrieves the mail options of the given sale invoice. * @param {number} tenantId - * @param {number} invoiceId - * @returns {Promise} + * @param {number} saleInvoiceId + * @returns {Promise} */ - public async getDefaultMailOpts(tenantId: number, invoiceId: number) { - const { SaleInvoice } = this.tenancy.models(tenantId); - - const saleInvoice = await SaleInvoice.query() - .findById(invoiceId) - .withGraphFetched('customer') - .throwIfNotFound(); - - return { - attachInvoice: true, - subject: DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, - body: DEFAULT_INVOICE_REMINDER_MAIL_CONTENT, - to: saleInvoice.customer.email, - }; - } - - /** - * Retrieves the formatted text of the given sale invoice. - * @param {number} tenantId - Tenant id. - * @param {number} invoiceId - Sale invoice id. - * @param {string} text - The given text. - * @returns {Promise} - */ - public formatText = async ( - tenantId: number, - invoiceId: number, - text: string - ): Promise => { - const invoice = await this.getSaleInvoiceService.getSaleInvoice( + public async getMailOpts(tenantId: number, saleInvoiceId: number) { + return this.invoiceCommonMail.getMailOpts( tenantId, - invoiceId + saleInvoiceId, + DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, + DEFAULT_INVOICE_REMINDER_MAIL_CONTENT ); - const organization = await Tenant.query() - .findById(tenantId) - .withGraphFetched('metadata'); - - return formatSmsMessage(text, { - CompanyName: organization.metadata.name, - CustomerName: invoice.customer.displayName, - InvoiceNumber: invoice.invoiceNo, - InvoiceDueAmount: invoice.dueAmountFormatted, - InvoiceDueDate: invoice.dueDateFormatted, - InvoiceDate: invoice.invoiceDateFormatted, - InvoiceAmount: invoice.totalFormatted, - }); - }; + } /** * Triggers the mail invoice. @@ -111,37 +64,27 @@ export class SendInvoiceMailReminder { saleInvoiceId: number, messageOptions: SendInvoiceMailDTO ) { - const defaultMessageOpts = await this.getDefaultMailOpts( - tenantId, - saleInvoiceId - ); - const parsedMessageOptions = { - ...defaultMessageOpts, + const localMessageOpts = await this.getMailOpts(tenantId, saleInvoiceId); + + const messageOpts = { + ...localMessageOpts, ...messageOptions, }; - // In case there is no email address from the customer or from options, throw an error. - if (!parsedMessageOptions.to) { - throw new ServiceError(ERRORS.NO_INVOICE_CUSTOMER_EMAIL_ADDR); - } - const formatter = R.curry(this.formatText)(tenantId, saleInvoiceId); - const subject = await formatter(parsedMessageOptions.subject); - const body = await formatter(parsedMessageOptions.body); - const attachments = []; + const mail = new Mail() + .setSubject(messageOpts.subject) + .setTo(messageOpts.to) + .setContent(messageOpts.body); - if (parsedMessageOptions.attachInvoice) { + if (messageOpts.attachInvoice) { // Retrieves document buffer of the invoice pdf document. const invoicePdfBuffer = await this.invoicePdf.saleInvoicePdf( tenantId, saleInvoiceId ); - attachments.push({ filename: 'invoice.pdf', content: invoicePdfBuffer }); + mail.setAttachments([ + { filename: 'invoice.pdf', content: invoicePdfBuffer }, + ]); } - const mail = new Mail() - .setSubject(subject) - .setTo(parsedMessageOptions.to) - .setContent(body) - .setAttachments(attachments); - await mail.send(); } } diff --git a/packages/server/src/services/Sales/Invoices/constants.ts b/packages/server/src/services/Sales/Invoices/constants.ts index 79bf67c0a..404b7e613 100644 --- a/packages/server/src/services/Sales/Invoices/constants.ts +++ b/packages/server/src/services/Sales/Invoices/constants.ts @@ -8,6 +8,11 @@ Invoice #{InvoiceNumber}
Due Date : {InvoiceDueDate}
Amount : {InvoiceAmount}

+ +

+Regards
+{CompanyName} +

`; export const DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT = @@ -18,6 +23,11 @@ export const DEFAULT_INVOICE_REMINDER_MAIL_CONTENT = `

Invoice #{InvoiceNumber}
Due Date : {InvoiceDueDate}
Amount : {InvoiceAmount}

+ +

+Regards
+{CompanyName} +

`; export const ERRORS = { diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts index 42069c5f3..79dfe2392 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts @@ -1,17 +1,14 @@ import { Inject, Service } from 'typedi'; -import * as R from 'ramda'; import { IPaymentReceiveMailOpts, SendInvoiceMailDTO } from '@/interfaces'; import Mail from '@/lib/Mail'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { DEFAULT_PAYMENT_MAIL_CONTENT, DEFAULT_PAYMENT_MAIL_SUBJECT, - ERRORS, } from './constants'; -import { ServiceError } from '@/exceptions'; -import { formatSmsMessage } from '@/utils'; import { Tenant } from '@/system/models'; import { GetPaymentReceive } from './GetPaymentReceive'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; @Service() export class SendPaymentReceiveMailNotification { @@ -21,6 +18,9 @@ export class SendPaymentReceiveMailNotification { @Inject() private getPaymentService: GetPaymentReceive; + @Inject() + private contactMailNotification: ContactMailNotification; + @Inject('agenda') private agenda: any; @@ -49,19 +49,22 @@ export class SendPaymentReceiveMailNotification { * @param {number} invoiceId * @returns {Promise} */ - public getDefaultMailOpts = async (tenantId: number, invoiceId: number) => { + public getMailOptions = async (tenantId: number, invoiceId: number) => { const { PaymentReceive } = this.tenancy.models(tenantId); + const paymentReceive = await PaymentReceive.query() .findById(invoiceId) - .withGraphFetched('customer') .throwIfNotFound(); - return { - attachInvoice: true, - subject: DEFAULT_PAYMENT_MAIL_SUBJECT, - body: DEFAULT_PAYMENT_MAIL_CONTENT, - to: paymentReceive.customer.email, - }; + const formatterData = await this.textFormatter(tenantId, invoiceId); + + return this.contactMailNotification.getMailOptions( + tenantId, + paymentReceive.customerId, + DEFAULT_PAYMENT_MAIL_SUBJECT, + DEFAULT_PAYMENT_MAIL_CONTENT, + formatterData + ); }; /** @@ -73,9 +76,8 @@ export class SendPaymentReceiveMailNotification { */ public textFormatter = async ( tenantId: number, - invoiceId: number, - text: string - ): Promise => { + invoiceId: number + ): Promise> => { const payment = await this.getPaymentService.getPaymentReceive( tenantId, invoiceId @@ -84,13 +86,13 @@ export class SendPaymentReceiveMailNotification { .findById(tenantId) .withGraphFetched('metadata'); - return formatSmsMessage(text, { + return { CompanyName: organization.metadata.name, CustomerName: payment.customer.displayName, PaymentNumber: payment.payment_receive_no, PaymentDate: payment.formattedPaymentDate, PaymentAmount: payment.formattedAmount, - }); + }; }; /** @@ -105,7 +107,7 @@ export class SendPaymentReceiveMailNotification { paymentReceiveId: number, messageDTO: SendInvoiceMailDTO ): Promise { - const defaultMessageOpts = await this.getDefaultMailOpts( + const defaultMessageOpts = await this.getMailOptions( tenantId, paymentReceiveId ); @@ -114,18 +116,10 @@ export class SendPaymentReceiveMailNotification { ...defaultMessageOpts, ...messageDTO, }; - // In case there is no email address from the customer or from options, throw an error. - if (!parsedMessageOpts.to) { - throw new ServiceError(ERRORS.NO_INVOICE_CUSTOMER_EMAIL_ADDR); - } - const formatter = R.curry(this.textFormatter)(tenantId, paymentReceiveId); - const subject = await formatter(parsedMessageOpts.subject); - const body = await formatter(parsedMessageOpts.body); - await new Mail() - .setSubject(subject) + .setSubject(parsedMessageOpts.subject) .setTo(parsedMessageOpts.to) - .setContent(body) + .setContent(parsedMessageOpts.body) .send(); } } diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts index 6092664e6..bf1e2da3f 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts @@ -205,10 +205,7 @@ export class PaymentReceivesApplication { * @returns {Promise} */ public getPaymentDefaultMail(tenantId: number, paymentReceiveId: number) { - return this.paymentMailNotify.getDefaultMailOpts( - tenantId, - paymentReceiveId - ); + return this.paymentMailNotify.getMailOptions(tenantId, paymentReceiveId); } /** diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts index 6fe03b2a1..0f790ec6f 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts @@ -191,12 +191,12 @@ export class SaleReceiptApplication { /** * Retrieves the default mail options of the given sale receipt. - * @param {number} tenantId - * @param {number} saleReceiptId - * @returns + * @param {number} tenantId + * @param {number} saleReceiptId + * @returns */ public getSaleReceiptMail(tenantId: number, saleReceiptId: number) { - return this.saleReceiptNotifyByMailService.getDefaultMailOpts( + return this.saleReceiptNotifyByMailService.getMailOptions( tenantId, saleReceiptId ); diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts index 0d93c2b65..20bfc4073 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts @@ -2,8 +2,6 @@ import * as R from 'ramda'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; import { Tenant } from '@/system/models'; -import { formatSmsMessage } from '@/utils'; -import { ServiceError } from '@/exceptions'; import Mail from '@/lib/Mail'; import { GetSaleReceipt } from './GetSaleReceipt'; import { SaleReceiptsPdf } from './SaleReceiptsPdfService'; @@ -11,8 +9,8 @@ import { DEFAULT_RECEIPT_MAIL_CONTENT, DEFAULT_RECEIPT_MAIL_SUBJECT, } from './constants'; -import { ERRORS } from './constants'; import { SaleReceiptMailOpts } from '@/interfaces'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; @Service() export class SaleReceiptMailNotification { @@ -25,6 +23,9 @@ export class SaleReceiptMailNotification { @Inject() private receiptPdfService: SaleReceiptsPdf; + @Inject() + private contactMailNotification: ContactMailNotification; + @Inject('agenda') private agenda: any; @@ -48,25 +49,28 @@ export class SaleReceiptMailNotification { } /** - * Retrieves the default receipt mail options. + * Retrieves the mail options of the given sale receipt. * @param {number} tenantId - * @param {number} invoiceId - * @returns {Promise} + * @param {number} saleReceiptId + * @returns */ - public getDefaultMailOpts = async (tenantId: number, invoiceId: number) => { + public async getMailOptions(tenantId: number, saleReceiptId: number) { const { SaleReceipt } = this.tenancy.models(tenantId); + const saleReceipt = await SaleReceipt.query() - .findById(invoiceId) - .withGraphFetched('customer') + .findById(saleReceiptId) .throwIfNotFound(); - return { - attachInvoice: true, - subject: DEFAULT_RECEIPT_MAIL_SUBJECT, - body: DEFAULT_RECEIPT_MAIL_CONTENT, - to: saleReceipt.customer.email, - }; - }; + const formattedData = await this.textFormatter(tenantId, saleReceiptId); + + return this.contactMailNotification.getMailOptions( + tenantId, + saleReceipt.customerId, + DEFAULT_RECEIPT_MAIL_SUBJECT, + DEFAULT_RECEIPT_MAIL_CONTENT, + formattedData + ); + } /** * Retrieves the formatted text of the given sale invoice. @@ -77,9 +81,8 @@ export class SaleReceiptMailNotification { */ public textFormatter = async ( tenantId: number, - receiptId: number, - text: string - ): Promise => { + receiptId: number + ): Promise> => { const invoice = await this.getSaleReceiptService.getSaleReceipt( tenantId, receiptId @@ -88,13 +91,13 @@ export class SaleReceiptMailNotification { .findById(tenantId) .withGraphFetched('metadata'); - return formatSmsMessage(text, { + return { CompanyName: organization.metadata.name, CustomerName: invoice.customer.displayName, ReceiptNumber: invoice.receiptNumber, ReceiptDate: invoice.formattedReceiptDate, ReceiptAmount: invoice.formattedAmount, - }); + }; }; /** @@ -109,7 +112,7 @@ export class SaleReceiptMailNotification { saleReceiptId: number, messageOpts: SaleReceiptMailOpts ) { - const defaultMessageOpts = await this.getDefaultMailOpts( + const defaultMessageOpts = await this.getMailOptions( tenantId, saleReceiptId ); @@ -118,14 +121,11 @@ export class SaleReceiptMailNotification { ...defaultMessageOpts, ...messageOpts, }; - // In case there is no email address from the customer or from options, throw an error. - if (!parsedMessageOpts.to) { - throw new ServiceError(ERRORS.NO_INVOICE_CUSTOMER_EMAIL_ADDR); - } - const formatter = R.curry(this.textFormatter)(tenantId, saleReceiptId); - const body = await formatter(parsedMessageOpts.body); - const subject = await formatter(parsedMessageOpts.subject); - const attachments = []; + + const mail = new Mail() + .setSubject(parsedMessageOpts.subject) + .setTo(parsedMessageOpts.to) + .setContent(parsedMessageOpts.body); if (parsedMessageOpts.attachInvoice) { // Retrieves document buffer of the invoice pdf document. @@ -133,13 +133,10 @@ export class SaleReceiptMailNotification { tenantId, saleReceiptId ); - attachments.push({ filename: 'invoice.pdf', content: receiptPdfBuffer }); + mail.setAttachments([ + { filename: 'invoice.pdf', content: receiptPdfBuffer }, + ]); } - await new Mail() - .setSubject(subject) - .setTo(parsedMessageOpts.to) - .setContent(body) - .setAttachments(attachments) - .send(); + await mail.send(); } } From ab7abfea352ec38f2883e2d3b20c245875340a91 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 30 Dec 2023 17:49:02 +0200 Subject: [PATCH 029/148] feat: mail notifications of sales transactions --- .../api/controllers/Sales/PaymentReceives.ts | 14 ++-- .../api/controllers/Sales/SalesEstimates.ts | 12 ++-- .../api/controllers/Sales/SalesReceipts.ts | 6 +- packages/server/src/interfaces/Mailable.ts | 41 +++++++++-- .../server/src/interfaces/PaymentReceive.ts | 14 ++-- .../server/src/interfaces/SaleEstimate.ts | 13 ++-- packages/server/src/interfaces/SaleInvoice.ts | 17 ++--- packages/server/src/interfaces/SaleReceipt.ts | 11 ++- packages/server/src/lib/Mail/index.ts | 47 ++++++++----- .../ContactMailNotification.ts | 56 +++++++++++---- .../services/MailNotification/constants.ts | 6 ++ .../src/services/MailNotification/utils.ts | 33 +++++++++ .../Estimates/SaleEstimatesApplication.ts | 16 +++-- .../Sales/Estimates/SendSaleEstimateMail.ts | 43 +++++++----- .../Sales/Invoices/SaleInvoicesApplication.ts | 10 ++- .../Invoices/SendInvoiceInvoiceMailCommon.ts | 50 +++----------- .../Sales/Invoices/SendSaleInvoiceMail.ts | 23 ++++--- .../Invoices/SendSaleInvoiceMailReminder.ts | 6 +- .../PaymentReceiveMailNotification.ts | 43 ++++++------ .../PaymentReceivesApplication.ts | 8 +-- .../Sales/Receipts/SaleReceiptApplication.ts | 13 ++-- .../Receipts/SaleReceiptMailNotification.ts | 68 +++++++++---------- .../src/services/Sales/Receipts/constants.ts | 2 +- .../PaymentMailDialogBoot.tsx | 1 + .../PaymentMailDialogForm.tsx | 4 +- 25 files changed, 336 insertions(+), 221 deletions(-) create mode 100644 packages/server/src/services/MailNotification/constants.ts create mode 100644 packages/server/src/services/MailNotification/utils.ts diff --git a/packages/server/src/api/controllers/Sales/PaymentReceives.ts b/packages/server/src/api/controllers/Sales/PaymentReceives.ts index 74657a703..0bef1e60d 100644 --- a/packages/server/src/api/controllers/Sales/PaymentReceives.ts +++ b/packages/server/src/api/controllers/Sales/PaymentReceives.ts @@ -4,9 +4,8 @@ import { body, check, param, query, ValidationChain } from 'express-validator'; import { AbilitySubject, IPaymentReceiveDTO, - IPaymentReceiveMailOpts, - // IPaymentReceiveMailOpts, PaymentReceiveAction, + PaymentReceiveMailOptsDTO, } from '@/interfaces'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -541,9 +540,12 @@ export default class PaymentReceivesController extends BaseController { ) => { const { tenantId } = req; const { id: paymentReceiveId } = req.params; - const paymentMailDTO: IPaymentReceiveMailOpts = this.matchedBodyData(req, { - includeOptionals: false, - }); + const paymentMailDTO: PaymentReceiveMailOptsDTO = this.matchedBodyData( + req, + { + includeOptionals: false, + } + ); try { await this.paymentReceiveApplication.notifyPaymentByMail( tenantId, @@ -574,7 +576,7 @@ export default class PaymentReceivesController extends BaseController { const { id: paymentReceiveId } = req.params; try { - const data = await this.paymentReceiveApplication.getPaymentDefaultMail( + const data = await this.paymentReceiveApplication.getPaymentMailOptions( tenantId, paymentReceiveId ); diff --git a/packages/server/src/api/controllers/Sales/SalesEstimates.ts b/packages/server/src/api/controllers/Sales/SalesEstimates.ts index f78719de9..b34c1ccf2 100644 --- a/packages/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/packages/server/src/api/controllers/Sales/SalesEstimates.ts @@ -5,7 +5,7 @@ import { AbilitySubject, ISaleEstimateDTO, SaleEstimateAction, - SaleEstimateMailOptions, + SaleEstimateMailOptionsDTO, } from '@/interfaces'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; @@ -513,10 +513,12 @@ export default class SalesEstimatesController extends BaseController { ) => { const { tenantId } = req; const { id: invoiceId } = req.params; - const saleEstimateDTO: SaleEstimateMailOptions = this.matchedBodyData(req, { - includeOptionals: false, - }); - + const saleEstimateDTO: SaleEstimateMailOptionsDTO = this.matchedBodyData( + req, + { + includeOptionals: false, + } + ); try { await this.saleEstimatesApplication.sendSaleEstimateMail( tenantId, diff --git a/packages/server/src/api/controllers/Sales/SalesReceipts.ts b/packages/server/src/api/controllers/Sales/SalesReceipts.ts index c8635fe3c..6151561f8 100644 --- a/packages/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/packages/server/src/api/controllers/Sales/SalesReceipts.ts @@ -3,7 +3,7 @@ import { body, check, param, query } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from '../BaseController'; -import { ISaleReceiptDTO, SaleReceiptMailOpts } from '@/interfaces/SaleReceipt'; +import { ISaleReceiptDTO, SaleReceiptMailOpts, SaleReceiptMailOptsDTO } from '@/interfaces/SaleReceipt'; import { ServiceError } from '@/exceptions'; import DynamicListingService from '@/services/DynamicListing/DynamicListService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; @@ -54,7 +54,7 @@ export default class SalesReceiptsController extends BaseController { body('from').isString().optional(), body('to').isString().optional(), body('body').isString().optional(), - body('attach_invoice').optional().isBoolean().toBoolean(), + body('attach_receipt').optional().isBoolean().toBoolean(), ], this.validationResult, asyncMiddleware(this.sendSaleReceiptMail.bind(this)), @@ -439,7 +439,7 @@ export default class SalesReceiptsController extends BaseController { ) => { const { tenantId } = req; const { id: receiptId } = req.params; - const receiptMailDTO: SaleReceiptMailOpts = this.matchedBodyData(req, { + const receiptMailDTO: SaleReceiptMailOptsDTO = this.matchedBodyData(req, { includeOptionals: false, }); diff --git a/packages/server/src/interfaces/Mailable.ts b/packages/server/src/interfaces/Mailable.ts index 36cc3c81f..5682f2529 100644 --- a/packages/server/src/interfaces/Mailable.ts +++ b/packages/server/src/interfaces/Mailable.ts @@ -1,9 +1,17 @@ +export type IMailAttachment = MailAttachmentPath | MailAttachmentContent; + +export interface MailAttachmentPath { + filename: string; + path: string; + cid: string; +} +export interface MailAttachmentContent { + filename: string; + content: Buffer; +} export interface IMailable { - constructor( - view: string, - data?: { [key: string]: string | number }, - ); + constructor(view: string, data?: { [key: string]: string | number }); send(): Promise; build(): void; setData(data: { [key: string]: string | number }): IMailable; @@ -13,4 +21,27 @@ export interface IMailable { setView(view: string): IMailable; render(data?: { [key: string]: string | number }): string; getViewContent(): string; -} \ No newline at end of file +} + +export interface AddressItem { + label: string; + mail: string; + primary?: boolean; +} + +export interface CommonMailOptions { + toAddresses: AddressItem[]; + fromAddresses: AddressItem[]; + from: string; + to: string | string[]; + subject: string; + body: string; + data?: Record; +} + +export interface CommonMailOptionsDTO { + to?: string | string[]; + from?: string; + subject?: string; + body?: string; +} diff --git a/packages/server/src/interfaces/PaymentReceive.ts b/packages/server/src/interfaces/PaymentReceive.ts index c919182ae..2926d923c 100644 --- a/packages/server/src/interfaces/PaymentReceive.ts +++ b/packages/server/src/interfaces/PaymentReceive.ts @@ -1,5 +1,9 @@ import { Knex } from 'knex'; -import { ISystemUser } from '@/interfaces'; +import { + CommonMailOptions, + CommonMailOptionsDTO, + ISystemUser, +} from '@/interfaces'; import { ILedgerEntry } from './Ledger'; import { ISaleInvoice } from './SaleInvoice'; @@ -19,7 +23,7 @@ export interface IPaymentReceive { createdAt: Date; updatedAt: Date; localAmount?: number; - branchId?: number + branchId?: number; } export interface IPaymentReceiveCreateDTO { customerId: number; @@ -166,6 +170,6 @@ export type IPaymentReceiveGLCommonEntry = Pick< | 'branchId' >; -export interface IPaymentReceiveMailOpts { - -} \ No newline at end of file +export interface PaymentReceiveMailOpts extends CommonMailOptions {} + +export interface PaymentReceiveMailOptsDTO extends CommonMailOptionsDTO {} diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index 3a503e1fd..171c8a0d1 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; export interface ISaleEstimate { id?: number; @@ -125,10 +126,10 @@ export interface ISaleEstimateApprovedEvent { trx: Knex.Transaction; } -export interface SaleEstimateMailOptions { - to: string; - from: string; - subject: string; - body: string; - attachInvoice?: boolean; +export interface SaleEstimateMailOptions extends CommonMailOptions { + attachEstimate?: boolean; +} + +export interface SaleEstimateMailOptionsDTO extends CommonMailOptionsDTO { + attachEstimate?: boolean; } \ No newline at end of file diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index d660b17ca..394319e86 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; -import { ISystemUser, IAccount, ITaxTransaction, AddressItem } from '@/interfaces'; +import { ISystemUser, IAccount, ITaxTransaction } from '@/interfaces'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; import { IDynamicListFilter } from '@/interfaces/DynamicFilter'; import { IItemEntry, IItemEntryDTO } from './ItemEntry'; @@ -187,21 +188,11 @@ export enum SaleInvoiceAction { NotifyBySms = 'NotifyBySms', } -export interface SaleInvoiceMailOptions { - toAddresses: AddressItem[]; - fromAddresses: AddressItem[]; - from: string; - to: string | string[]; - subject: string; - body: string; +export interface SaleInvoiceMailOptions extends CommonMailOptions { attachInvoice: boolean; } -export interface SendInvoiceMailDTO { - to: string | string[]; - from: string; - subject: string; - body: string; +export interface SendInvoiceMailDTO extends CommonMailOptionsDTO { attachInvoice?: boolean; } diff --git a/packages/server/src/interfaces/SaleReceipt.ts b/packages/server/src/interfaces/SaleReceipt.ts index 102513f7e..1e8ffa98e 100644 --- a/packages/server/src/interfaces/SaleReceipt.ts +++ b/packages/server/src/interfaces/SaleReceipt.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { IItemEntry } from './ItemEntry'; +import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; export interface ISaleReceipt { id?: number; @@ -135,6 +136,10 @@ export interface ISaleReceiptDeletingPayload { trx: Knex.Transaction; } -export interface SaleReceiptMailOpts { - -} \ No newline at end of file +export interface SaleReceiptMailOpts extends CommonMailOptions { + attachReceipt: boolean; +} + +export interface SaleReceiptMailOptsDTO extends CommonMailOptionsDTO { + attachReceipt?: boolean; +} diff --git a/packages/server/src/lib/Mail/index.ts b/packages/server/src/lib/Mail/index.ts index dd79c934b..015ca02a8 100644 --- a/packages/server/src/lib/Mail/index.ts +++ b/packages/server/src/lib/Mail/index.ts @@ -2,18 +2,13 @@ import fs from 'fs'; import Mustache from 'mustache'; import { Container } from 'typedi'; import path from 'path'; -import { IMailable } from '@/interfaces'; - -interface IMailAttachment { - filename: string; - path: string; - cid: string; -} +import { IMailAttachment } from '@/interfaces'; export default class Mail { view: string; - subject: string; - to: string; + subject: string = ''; + content: string = ''; + to: string | string[]; from: string = `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`; data: { [key: string]: string | number }; attachments: IMailAttachment[]; @@ -21,16 +16,24 @@ export default class Mail { /** * Mail options. */ - private get mailOptions() { + public get mailOptions() { return { to: this.to, from: this.from, subject: this.subject, - html: this.render(this.data), + html: this.html, attachments: this.attachments, }; } + /** + * Retrieves the html content of the mail. + * @returns {string} + */ + public get html() { + return this.view ? Mail.render(this.view, this.data) : this.content; + } + /** * Sends the given mail to the target address. */ @@ -52,7 +55,7 @@ export default class Mail { * Set send mail to address. * @param {string} to - */ - setTo(to: string) { + setTo(to: string | string[]) { this.to = to; return this; } @@ -62,11 +65,16 @@ export default class Mail { * @param {string} from * @return {} */ - private setFrom(from: string) { + setFrom(from: string) { this.from = from; return this; } + /** + * Set attachments to the mail. + * @param {IMailAttachment[]} attachments + * @returns {Mail} + */ setAttachments(attachments: IMailAttachment[]) { this.attachments = attachments; return this; @@ -95,21 +103,26 @@ export default class Mail { return this; } + setContent(content: string) { + this.content = content; + return this; + } + /** * Renders the view template with the given data. * @param {object} data * @return {string} */ - render(data): string { - const viewContent = this.getViewContent(); + static render(view: string, data: Record): string { + const viewContent = Mail.getViewContent(view); return Mustache.render(viewContent, data); } /** * Retrieve view content from the view directory. */ - private getViewContent(): string { - const filePath = path.join(global.__views_dir, `/${this.view}`); + static getViewContent(view: string): string { + const filePath = path.join(global.__views_dir, `/${view}`); return fs.readFileSync(filePath, 'utf8'); } } diff --git a/packages/server/src/services/MailNotification/ContactMailNotification.ts b/packages/server/src/services/MailNotification/ContactMailNotification.ts index 21c745a96..e1e733a79 100644 --- a/packages/server/src/services/MailNotification/ContactMailNotification.ts +++ b/packages/server/src/services/MailNotification/ContactMailNotification.ts @@ -1,9 +1,9 @@ import { Inject, Service } from 'typedi'; -import * as R from 'ramda'; -import { SaleInvoiceMailOptions } from '@/interfaces'; +import { CommonMailOptions } from '@/interfaces'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { MailTenancy } from '@/services/MailTenancy/MailTenancy'; import { formatSmsMessage } from '@/utils'; +import { Tenant } from '@/system/models'; @Service() export class ContactMailNotification { @@ -15,8 +15,10 @@ export class ContactMailNotification { /** * Parses the default message options. - * @param {number} tenantId - * @param {number} invoiceId + * @param {number} tenantId - + * @param {number} invoiceId - + * @param {string} subject - + * @param {string} body - * @returns {Promise} */ public async getDefaultMailOptions( @@ -24,9 +26,11 @@ export class ContactMailNotification { contactId: number, subject: string = '', body: string = '' - ): Promise { - const { Contact, Customer } = this.tenancy.models(tenantId); - const contact = await Customer.query().findById(contactId).throwIfNotFound(); + ): Promise { + const { Customer } = this.tenancy.models(tenantId); + const contact = await Customer.query() + .findById(contactId) + .throwIfNotFound(); const toAddresses = contact.contactAddresses; const fromAddresses = await this.mailTenancy.senders(tenantId); @@ -48,10 +52,12 @@ export class ContactMailNotification { } /** - * Retrieves the mail options. - * @param {number} - * @param {number} invoiceId - * @returns {} + * Retrieves the mail options of the given contact. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Invoice id. + * @param {string} defaultSubject - Default subject text. + * @param {string} defaultBody - Default body text. + * @returns {Promise} */ public async getMailOptions( tenantId: number, @@ -59,15 +65,20 @@ export class ContactMailNotification { defaultSubject?: string, defaultBody?: string, formatterData?: Record - ): Promise { + ): Promise { const mailOpts = await this.getDefaultMailOptions( tenantId, contactId, defaultSubject, defaultBody ); - const subject = formatSmsMessage(mailOpts.subject, formatterData); - const body = formatSmsMessage(mailOpts.body, formatterData); + const commonFormatArgs = await this.getCommonFormatArgs(tenantId); + const formatArgs = { + ...commonFormatArgs, + ...formatterData, + }; + const subject = formatSmsMessage(mailOpts.subject, formatArgs); + const body = formatSmsMessage(mailOpts.body, formatArgs); return { ...mailOpts, @@ -75,4 +86,21 @@ export class ContactMailNotification { body, }; } + + /** + * Retrieves the common format args. + * @param {number} tenantId + * @returns {Promise>} + */ + public async getCommonFormatArgs( + tenantId: number + ): Promise> { + const organization = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + return { + CompanyName: organization.metadata.name, + }; + } } diff --git a/packages/server/src/services/MailNotification/constants.ts b/packages/server/src/services/MailNotification/constants.ts new file mode 100644 index 000000000..95b720d70 --- /dev/null +++ b/packages/server/src/services/MailNotification/constants.ts @@ -0,0 +1,6 @@ +export const ERRORS = { + MAIL_FROM_NOT_FOUND: 'Mail from address not found', + MAIL_TO_NOT_FOUND: 'Mail to address not found', + MAIL_SUBJECT_NOT_FOUND: 'Mail subject not found', + MAIL_BODY_NOT_FOUND: 'Mail body not found', +}; diff --git a/packages/server/src/services/MailNotification/utils.ts b/packages/server/src/services/MailNotification/utils.ts new file mode 100644 index 000000000..b9e37b297 --- /dev/null +++ b/packages/server/src/services/MailNotification/utils.ts @@ -0,0 +1,33 @@ +import { isEmpty } from 'lodash'; +import { ServiceError } from '@/exceptions'; +import { CommonMailOptions, CommonMailOptionsDTO } from '@/interfaces'; +import { ERRORS } from './constants'; + +/** + * Merges the mail options with incoming options. + * @param {Partial} mailOptions + * @param {Partial} overridedOptions + * @throws {ServiceError} + */ +export function parseAndValidateMailOptions( + mailOptions: Partial, + overridedOptions: Partial +) { + const mergedMessageOptions = { + ...mailOptions, + ...overridedOptions, + }; + if (isEmpty(mergedMessageOptions.from)) { + throw new ServiceError(ERRORS.MAIL_FROM_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.to)) { + throw new ServiceError(ERRORS.MAIL_TO_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.subject)) { + throw new ServiceError(ERRORS.MAIL_SUBJECT_NOT_FOUND); + } + if (isEmpty(mergedMessageOptions.body)) { + throw new ServiceError(ERRORS.MAIL_BODY_NOT_FOUND); + } + return mergedMessageOptions; +} diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts index 1ceb2bbcc..f1c7b3cdf 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimatesApplication.ts @@ -8,6 +8,7 @@ import { ISaleEstimateDTO, ISalesEstimatesFilter, SaleEstimateMailOptions, + SaleEstimateMailOptionsDTO, } from '@/interfaces'; import { EditSaleEstimate } from './EditSaleEstimate'; import { DeleteSaleEstimate } from './DeleteSaleEstimate'; @@ -224,8 +225,8 @@ export class SaleEstimatesApplication { public sendSaleEstimateMail( tenantId: number, saleEstimateId: number, - saleEstimateMailOpts: SaleEstimateMailOptions - ) { + saleEstimateMailOpts: SaleEstimateMailOptionsDTO + ): Promise { return this.sendEstimateMailService.triggerMail( tenantId, saleEstimateId, @@ -235,11 +236,14 @@ export class SaleEstimatesApplication { /** * Retrieves the default mail options of the given sale estimate. - * @param {number} tenantId - * @param {number} saleEstimateId - * @returns {} + * @param {number} tenantId + * @param {number} saleEstimateId + * @returns {Promise} */ - public getSaleEstimateMail(tenantId: number, saleEstimateId: number) { + public getSaleEstimateMail( + tenantId: number, + saleEstimateId: number + ): Promise { return this.sendEstimateMailService.getMailOptions( tenantId, saleEstimateId diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts index 5777ab55a..258496306 100644 --- a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts @@ -7,8 +7,12 @@ import { } from './constants'; import { SaleEstimatesPdf } from './SaleEstimatesPdf'; import { GetSaleEstimate } from './GetSaleEstimate'; -import { SaleEstimateMailOptions } from '@/interfaces'; +import { + SaleEstimateMailOptions, + SaleEstimateMailOptionsDTO, +} from '@/interfaces'; import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; @Service() export class SendSaleEstimateMail { @@ -31,13 +35,14 @@ export class SendSaleEstimateMail { * Triggers the reminder mail of the given sale estimate. * @param {number} tenantId - * @param {number} saleEstimateId - - * @param {SaleEstimateMailOptions} messageOptions - + * @param {SaleEstimateMailOptionsDTO} messageOptions - + * @returns {Promise} */ public async triggerMail( tenantId: number, saleEstimateId: number, - messageOptions: SaleEstimateMailOptions - ) { + messageOptions: SaleEstimateMailOptionsDTO + ): Promise { const payload = { tenantId, saleEstimateId, @@ -48,9 +53,9 @@ export class SendSaleEstimateMail { /** * Formates the text of the mail. - * @param {number} tenantId - * @param {number} estimateId - * @param {string} text + * @param {number} tenantId - Tenant id. + * @param {number} estimateId - Estimate id. + * @returns {Promise>} */ public formatterData = async (tenantId: number, estimateId: number) => { const estimate = await this.getSaleEstimateService.getEstimate( @@ -70,9 +75,12 @@ export class SendSaleEstimateMail { * Retrieves the mail options. * @param {number} tenantId * @param {number} saleEstimateId - * @returns + * @returns {Promise} */ - public getMailOptions = async (tenantId: number, saleEstimateId: number) => { + public getMailOptions = async ( + tenantId: number, + saleEstimateId: number + ): Promise => { const { SaleEstimate } = this.tenancy.models(tenantId); const saleEstimate = await SaleEstimate.query() @@ -91,6 +99,7 @@ export class SendSaleEstimateMail { return { ...mailOptions, data: formatterData, + attachEstimate: true }; }; @@ -99,26 +108,28 @@ export class SendSaleEstimateMail { * @param {number} tenantId * @param {number} saleEstimateId * @param {SaleEstimateMailOptions} messageOptions + * @returns {Promise} */ public async sendMail( tenantId: number, saleEstimateId: number, - messageOptions: SaleEstimateMailOptions - ) { + messageOptions: SaleEstimateMailOptionsDTO + ): Promise { const localMessageOpts = await this.getMailOptions( tenantId, saleEstimateId ); - const messageOpts = { - ...localMessageOpts, - ...messageOptions, - }; + // Overrides and validates the given mail options. + const messageOpts = parseAndValidateMailOptions( + localMessageOpts, + messageOptions + ); const mail = new Mail() .setSubject(messageOpts.subject) .setTo(messageOpts.to) .setContent(messageOpts.body); - if (messageOpts.to) { + if (messageOpts.attachEstimate) { const estimatePdfBuffer = await this.estimatePdf.getSaleEstimatePdf( tenantId, saleEstimateId diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts index b175d9546..bc3f8c24b 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoicesApplication.ts @@ -300,7 +300,10 @@ export class SaleInvoiceApplication { * @returns {} */ public getSaleInvoiceMailReminder(tenantId: number, saleInvoiceId: number) { - return this.sendInvoiceReminderService.getMailOpts(tenantId, saleInvoiceId); + return this.sendInvoiceReminderService.getMailOption( + tenantId, + saleInvoiceId + ); } /** @@ -347,6 +350,9 @@ export class SaleInvoiceApplication { * @returns {Promise} */ public getSaleInvoiceMail(tenantId: number, saleInvoiceid: number) { - return this.sendSaleInvoiceMailService.getMailOpts(tenantId, saleInvoiceid); + return this.sendSaleInvoiceMailService.getMailOption( + tenantId, + saleInvoiceid + ); } } diff --git a/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts index 97be8de87..52ef46a59 100644 --- a/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts +++ b/packages/server/src/services/Sales/Invoices/SendInvoiceInvoiceMailCommon.ts @@ -1,15 +1,12 @@ import { Inject, Service } from 'typedi'; -import { isEmpty } from 'lodash'; import { SaleInvoiceMailOptions } from '@/interfaces'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetSaleInvoice } from './GetSaleInvoice'; +import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; import { DEFAULT_INVOICE_MAIL_CONTENT, DEFAULT_INVOICE_MAIL_SUBJECT, } from './constants'; -import { GetSaleInvoice } from './GetSaleInvoice'; -import { Tenant } from '@/system/models'; -import { ServiceError } from '@/exceptions'; -import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; @Service() export class SendSaleInvoiceMailCommon { @@ -28,9 +25,9 @@ export class SendSaleInvoiceMailCommon { * @param {number} invoiceId - Invoice id. * @param {string} defaultSubject - Subject text. * @param {string} defaultBody - Subject body. - * @returns {} + * @returns {Promise} */ - public async getMailOpts( + public async getMailOption( tenantId: number, invoiceId: number, defaultSubject: string = DEFAULT_INVOICE_MAIL_SUBJECT, @@ -44,13 +41,17 @@ export class SendSaleInvoiceMailCommon { const formatterData = await this.formatText(tenantId, invoiceId); - return this.contactMailNotification.getMailOptions( + const mailOptions = await this.contactMailNotification.getMailOptions( tenantId, saleInvoice.customerId, defaultSubject, defaultBody, formatterData ); + return { + ...mailOptions, + attachInvoice: true, + }; } /** @@ -68,12 +69,8 @@ export class SendSaleInvoiceMailCommon { tenantId, invoiceId ); - const organization = await Tenant.query() - .findById(tenantId) - .withGraphFetched('metadata'); return { - CompanyName: organization.metadata.name, CustomerName: invoice.customer.displayName, InvoiceNumber: invoice.invoiceNo, InvoiceDueAmount: invoice.dueAmountFormatted, @@ -83,33 +80,4 @@ export class SendSaleInvoiceMailCommon { OverdueDays: invoice.overdueDays, }; }; - - /** - * Validates the mail notification options before sending it. - * @param {Partial} mailNotificationOpts - * @throws {ServiceError} - */ - public validateMailNotification( - mailNotificationOpts: Partial - ) { - if (isEmpty(mailNotificationOpts.from)) { - throw new ServiceError(ERRORS.MAIL_FROM_NOT_FOUND); - } - if (isEmpty(mailNotificationOpts.to)) { - throw new ServiceError(ERRORS.MAIL_TO_NOT_FOUND); - } - if (isEmpty(mailNotificationOpts.subject)) { - throw new ServiceError(ERRORS.MAIL_SUBJECT_NOT_FOUND); - } - if (isEmpty(mailNotificationOpts.body)) { - throw new ServiceError(ERRORS.MAIL_BODY_NOT_FOUND); - } - } } - -const ERRORS = { - MAIL_FROM_NOT_FOUND: 'Mail from address not found', - MAIL_TO_NOT_FOUND: 'Mail to address not found', - MAIL_SUBJECT_NOT_FOUND: 'Mail subject not found', - MAIL_BODY_NOT_FOUND: 'Mail body not found', -}; diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts index a5bc744e1..05db4f73e 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMail.ts @@ -7,6 +7,7 @@ import { DEFAULT_INVOICE_MAIL_CONTENT, DEFAULT_INVOICE_MAIL_SUBJECT, } from './constants'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; @Service() export class SendSaleInvoiceMail { @@ -44,8 +45,8 @@ export class SendSaleInvoiceMail { * @param {number} saleInvoiceId * @returns {Promise} */ - public async getMailOpts(tenantId: number, saleInvoiceId: number) { - return this.invoiceMail.getMailOpts( + public async getMailOption(tenantId: number, saleInvoiceId: number) { + return this.invoiceMail.getMailOption( tenantId, saleInvoiceId, DEFAULT_INVOICE_MAIL_SUBJECT, @@ -65,15 +66,15 @@ export class SendSaleInvoiceMail { saleInvoiceId: number, messageDTO: SendInvoiceMailDTO ) { - const defaultMessageOpts = await this.getMailOpts(tenantId, saleInvoiceId); - - // Parsed message opts with default options. - const messageOpts = { - ...defaultMessageOpts, - ...messageDTO, - }; - this.invoiceMail.validateMailNotification(messageOpts); - + const defaultMessageOpts = await this.getMailOption( + tenantId, + saleInvoiceId + ); + // Merge message opts with default options and validate the incoming options. + const messageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageDTO + ); const mail = new Mail() .setSubject(messageOpts.subject) .setTo(messageOpts.to) diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts index 09463bddc..b5389a8a0 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts @@ -43,8 +43,8 @@ export class SendInvoiceMailReminder { * @param {number} saleInvoiceId * @returns {Promise} */ - public async getMailOpts(tenantId: number, saleInvoiceId: number) { - return this.invoiceCommonMail.getMailOpts( + public async getMailOption(tenantId: number, saleInvoiceId: number) { + return this.invoiceCommonMail.getMailOption( tenantId, saleInvoiceId, DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, @@ -64,7 +64,7 @@ export class SendInvoiceMailReminder { saleInvoiceId: number, messageOptions: SendInvoiceMailDTO ) { - const localMessageOpts = await this.getMailOpts(tenantId, saleInvoiceId); + const localMessageOpts = await this.getMailOption(tenantId, saleInvoiceId); const messageOpts = { ...localMessageOpts, diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts index 79dfe2392..acb1ea7a1 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts @@ -1,14 +1,18 @@ import { Inject, Service } from 'typedi'; -import { IPaymentReceiveMailOpts, SendInvoiceMailDTO } from '@/interfaces'; +import { + PaymentReceiveMailOpts, + PaymentReceiveMailOptsDTO, + SendInvoiceMailDTO, +} from '@/interfaces'; import Mail from '@/lib/Mail'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { DEFAULT_PAYMENT_MAIL_CONTENT, DEFAULT_PAYMENT_MAIL_SUBJECT, } from './constants'; -import { Tenant } from '@/system/models'; import { GetPaymentReceive } from './GetPaymentReceive'; import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; @Service() export class SendPaymentReceiveMailNotification { @@ -28,13 +32,14 @@ export class SendPaymentReceiveMailNotification { * Sends the mail of the given payment receive. * @param {number} tenantId * @param {number} paymentReceiveId - * @param {SendInvoiceMailDTO} messageDTO + * @param {PaymentReceiveMailOptsDTO} messageDTO + * @returns {Promise} */ public async triggerMail( tenantId: number, paymentReceiveId: number, - messageDTO: IPaymentReceiveMailOpts - ) { + messageDTO: PaymentReceiveMailOptsDTO + ): Promise { const payload = { tenantId, paymentReceiveId, @@ -45,18 +50,21 @@ export class SendPaymentReceiveMailNotification { /** * Retrieves the default payment mail options. - * @param {number} tenantId - * @param {number} invoiceId - * @returns {Promise} + * @param {number} tenantId - Tenant id. + * @param {number} paymentReceiveId - Payment receive id. + * @returns {Promise} */ - public getMailOptions = async (tenantId: number, invoiceId: number) => { + public getMailOptions = async ( + tenantId: number, + paymentId: number + ): Promise => { const { PaymentReceive } = this.tenancy.models(tenantId); const paymentReceive = await PaymentReceive.query() - .findById(invoiceId) + .findById(paymentId) .throwIfNotFound(); - const formatterData = await this.textFormatter(tenantId, invoiceId); + const formatterData = await this.textFormatter(tenantId, paymentId); return this.contactMailNotification.getMailOptions( tenantId, @@ -82,12 +90,7 @@ export class SendPaymentReceiveMailNotification { tenantId, invoiceId ); - const organization = await Tenant.query() - .findById(tenantId) - .withGraphFetched('metadata'); - return { - CompanyName: organization.metadata.name, CustomerName: payment.customer.displayName, PaymentNumber: payment.payment_receive_no, PaymentDate: payment.formattedPaymentDate, @@ -112,10 +115,10 @@ export class SendPaymentReceiveMailNotification { paymentReceiveId ); // Parsed message opts with default options. - const parsedMessageOpts = { - ...defaultMessageOpts, - ...messageDTO, - }; + const parsedMessageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageDTO + ); await new Mail() .setSubject(parsedMessageOpts.subject) .setTo(parsedMessageOpts.to) diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts index bf1e2da3f..0d5669bf8 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceivesApplication.ts @@ -4,10 +4,10 @@ import { IPaymentReceive, IPaymentReceiveCreateDTO, IPaymentReceiveEditDTO, - IPaymentReceiveMailOpts, IPaymentReceiveSmsDetails, IPaymentReceivesFilter, ISystemUser, + PaymentReceiveMailOptsDTO, } from '@/interfaces'; import { Inject, Service } from 'typedi'; import { CreatePaymentReceive } from './CreatePaymentReceive'; @@ -189,8 +189,8 @@ export class PaymentReceivesApplication { public notifyPaymentByMail( tenantId: number, paymentReceiveId: number, - messageOpts: IPaymentReceiveMailOpts - ) { + messageOpts: PaymentReceiveMailOptsDTO + ): Promise { return this.paymentMailNotify.triggerMail( tenantId, paymentReceiveId, @@ -204,7 +204,7 @@ export class PaymentReceivesApplication { * @param {number} paymentReceiveId * @returns {Promise} */ - public getPaymentDefaultMail(tenantId: number, paymentReceiveId: number) { + public getPaymentMailOptions(tenantId: number, paymentReceiveId: number) { return this.paymentMailNotify.getMailOptions(tenantId, paymentReceiveId); } diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts index 0f790ec6f..459d9c62e 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts @@ -6,6 +6,7 @@ import { ISaleReceipt, ISalesReceiptsFilter, SaleReceiptMailOpts, + SaleReceiptMailOptsDTO, } from '@/interfaces'; import { EditSaleReceipt } from './EditSaleReceipt'; import { GetSaleReceipt } from './GetSaleReceipt'; @@ -176,12 +177,13 @@ export class SaleReceiptApplication { * Sends the receipt mail of the given sale receipt. * @param {number} tenantId * @param {number} saleReceiptId + * @returns {Promise} */ public sendSaleReceiptMail( tenantId: number, saleReceiptId: number, - messageOpts: SaleReceiptMailOpts - ) { + messageOpts: SaleReceiptMailOptsDTO + ): Promise { return this.saleReceiptNotifyByMailService.triggerMail( tenantId, saleReceiptId, @@ -193,9 +195,12 @@ export class SaleReceiptApplication { * Retrieves the default mail options of the given sale receipt. * @param {number} tenantId * @param {number} saleReceiptId - * @returns + * @returns {Promise} */ - public getSaleReceiptMail(tenantId: number, saleReceiptId: number) { + public getSaleReceiptMail( + tenantId: number, + saleReceiptId: number + ): Promise { return this.saleReceiptNotifyByMailService.getMailOptions( tenantId, saleReceiptId diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts index 20bfc4073..572bed2f8 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts @@ -1,7 +1,5 @@ -import * as R from 'ramda'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; -import { Tenant } from '@/system/models'; import Mail from '@/lib/Mail'; import { GetSaleReceipt } from './GetSaleReceipt'; import { SaleReceiptsPdf } from './SaleReceiptsPdfService'; @@ -9,8 +7,9 @@ import { DEFAULT_RECEIPT_MAIL_CONTENT, DEFAULT_RECEIPT_MAIL_SUBJECT, } from './constants'; -import { SaleReceiptMailOpts } from '@/interfaces'; +import { SaleReceiptMailOpts, SaleReceiptMailOptsDTO } from '@/interfaces'; import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; @Service() export class SaleReceiptMailNotification { @@ -32,13 +31,13 @@ export class SaleReceiptMailNotification { /** * Sends the receipt mail of the given sale receipt. * @param {number} tenantId - * @param {number} saleInvoiceId - * @param {SendInvoiceMailDTO} messageDTO + * @param {number} saleReceiptId + * @param {SaleReceiptMailOptsDTO} messageDTO */ public async triggerMail( tenantId: number, saleReceiptId: number, - messageOpts: SaleReceiptMailOpts + messageOpts: SaleReceiptMailOptsDTO ) { const payload = { tenantId, @@ -52,9 +51,12 @@ export class SaleReceiptMailNotification { * Retrieves the mail options of the given sale receipt. * @param {number} tenantId * @param {number} saleReceiptId - * @returns + * @returns {Promise} */ - public async getMailOptions(tenantId: number, saleReceiptId: number) { + public async getMailOptions( + tenantId: number, + saleReceiptId: number + ): Promise { const { SaleReceipt } = this.tenancy.models(tenantId); const saleReceipt = await SaleReceipt.query() @@ -63,17 +65,21 @@ export class SaleReceiptMailNotification { const formattedData = await this.textFormatter(tenantId, saleReceiptId); - return this.contactMailNotification.getMailOptions( + const mailOpts = await this.contactMailNotification.getMailOptions( tenantId, saleReceipt.customerId, DEFAULT_RECEIPT_MAIL_SUBJECT, DEFAULT_RECEIPT_MAIL_CONTENT, formattedData ); + return { + ...mailOpts, + attachReceipt: true, + }; } /** - * Retrieves the formatted text of the given sale invoice. + * Retrieves the formatted text of the given sale receipt. * @param {number} tenantId - Tenant id. * @param {number} receiptId - Sale receipt id. * @param {string} text - The given text. @@ -83,58 +89,52 @@ export class SaleReceiptMailNotification { tenantId: number, receiptId: number ): Promise> => { - const invoice = await this.getSaleReceiptService.getSaleReceipt( + const receipt = await this.getSaleReceiptService.getSaleReceipt( tenantId, receiptId ); - const organization = await Tenant.query() - .findById(tenantId) - .withGraphFetched('metadata'); - return { - CompanyName: organization.metadata.name, - CustomerName: invoice.customer.displayName, - ReceiptNumber: invoice.receiptNumber, - ReceiptDate: invoice.formattedReceiptDate, - ReceiptAmount: invoice.formattedAmount, + CustomerName: receipt.customer.displayName, + ReceiptNumber: receipt.receiptNumber, + ReceiptDate: receipt.formattedReceiptDate, + ReceiptAmount: receipt.formattedAmount, }; }; /** - * Triggers the mail invoice. - * @param {number} tenantId - * @param {number} saleInvoiceId - * @param {SendInvoiceMailDTO} messageDTO + * Triggers the mail notification of the given sale receipt. + * @param {number} tenantId - Tenant id. + * @param {number} saleReceiptId - Sale receipt id. + * @param {SaleReceiptMailOpts} messageDTO - Overrided message options. * @returns {Promise} */ public async sendMail( tenantId: number, saleReceiptId: number, - messageOpts: SaleReceiptMailOpts + messageOpts: SaleReceiptMailOptsDTO ) { const defaultMessageOpts = await this.getMailOptions( tenantId, saleReceiptId ); - // Parsed message opts with default options. - const parsedMessageOpts = { - ...defaultMessageOpts, - ...messageOpts, - }; - + // Merges message opts with default options. + const parsedMessageOpts = parseAndValidateMailOptions( + defaultMessageOpts, + messageOpts + ); const mail = new Mail() .setSubject(parsedMessageOpts.subject) .setTo(parsedMessageOpts.to) .setContent(parsedMessageOpts.body); - if (parsedMessageOpts.attachInvoice) { - // Retrieves document buffer of the invoice pdf document. + if (parsedMessageOpts.attachReceipt) { + // Retrieves document buffer of the receipt pdf document. const receiptPdfBuffer = await this.receiptPdfService.saleReceiptPdf( tenantId, saleReceiptId ); mail.setAttachments([ - { filename: 'invoice.pdf', content: receiptPdfBuffer }, + { filename: 'receipt.pdf', content: receiptPdfBuffer }, ]); } await mail.send(); diff --git a/packages/server/src/services/Sales/Receipts/constants.ts b/packages/server/src/services/Sales/Receipts/constants.ts index ae1a2e388..084af9214 100644 --- a/packages/server/src/services/Sales/Receipts/constants.ts +++ b/packages/server/src/services/Sales/Receipts/constants.ts @@ -1,5 +1,5 @@ export const DEFAULT_RECEIPT_MAIL_SUBJECT = - 'Invoice {InvoiceNumber} from {CompanyName}'; + 'Receipt {ReceiptNumber} from {CompanyName}'; export const DEFAULT_RECEIPT_MAIL_CONTENT = `

Dear {CustomerName}

Thank you for your business, You can view or print your receipt from attachements.

diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx index c70a28896..aa08bd2e1 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx @@ -29,6 +29,7 @@ function PaymentMailDialogBoot({ const provider = { mailOptions, isMailOptionsLoading, + paymentReceiveId }; return ( diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx index 075c2ee8b..bf0aa578b 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx @@ -28,7 +28,7 @@ export function PaymentMailDialogFormRoot({ // #withDialogActions closeDialog, }) { - const { mailOptions, paymentId } = usePaymentMailDialogBoot(); + const { mailOptions, paymentReceiveId } = usePaymentMailDialogBoot(); const { mutateAsync: sendPaymentMail } = useSendPaymentReceiveMail(); const initialValues = transformMailFormToInitialValues( @@ -43,7 +43,7 @@ export function PaymentMailDialogFormRoot({ const reqValues = transformMailFormToRequest(values); setSubmitting(true); - sendPaymentMail([paymentId, reqValues]) + sendPaymentMail([paymentReceiveId, reqValues]) .then(() => { AppToaster.show({ message: 'The mail notification has been sent successfully.', From 5062d891e18181e3c4202338d98c74729911f23c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 1 Jan 2024 20:43:16 +0200 Subject: [PATCH 030/148] feat: journal sheet export --- .../server/src/api/controllers/Sales/PaymentReceives.ts | 3 ++- packages/server/src/models/Contact.ts | 3 +++ .../JournalSheet/JournalSheetApplication.ts | 0 .../JournalSheet/JournalSheetRepository.ts | 0 .../JournalSheet/JournalSheetTable.ts | 0 .../JournalSheet/JournalSheetTableInjectable.ts | 0 .../SalesByItems/SalesByItemsApplication.ts | 0 .../SalesByItems/SalesByItemsTable.ts | 0 .../Sales/Invoices/SendSaleInvoiceMailReminder.ts | 9 +++++---- .../PaymentReceives/PaymentReceiveMailNotificationJob.ts | 3 --- .../services/Sales/Receipts/SaleReceiptApplication.ts | 1 + packages/webapp/src/components/Dashboard/Dashboard.tsx | 2 +- .../webapp/src/containers/Accounts/AccountsDataTable.tsx | 2 +- .../src/containers/Authentication/Authentication.tsx | 2 +- 14 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts create mode 100644 packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetRepository.ts create mode 100644 packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts create mode 100644 packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts diff --git a/packages/server/src/api/controllers/Sales/PaymentReceives.ts b/packages/server/src/api/controllers/Sales/PaymentReceives.ts index 0bef1e60d..823a13f95 100644 --- a/packages/server/src/api/controllers/Sales/PaymentReceives.ts +++ b/packages/server/src/api/controllers/Sales/PaymentReceives.ts @@ -531,7 +531,6 @@ export default class PaymentReceivesController extends BaseController { * @param {Request} req * @param {Response} res * @param {NextFunction} next - * @returns */ public sendPaymentReceiveByMail = async ( req: Request, @@ -546,6 +545,8 @@ export default class PaymentReceivesController extends BaseController { includeOptionals: false, } ); + console.log(req.params); + try { await this.paymentReceiveApplication.notifyPaymentByMail( tenantId, diff --git a/packages/server/src/models/Contact.ts b/packages/server/src/models/Contact.ts index d63a2ea60..69661f639 100644 --- a/packages/server/src/models/Contact.ts +++ b/packages/server/src/models/Contact.ts @@ -2,6 +2,9 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; export default class Contact extends TenantModel { + email: string; + displayName: string; + /** * Table name */ diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetRepository.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetRepository.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts index b5389a8a0..f16db0172 100644 --- a/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts +++ b/packages/server/src/services/Sales/Invoices/SendSaleInvoiceMailReminder.ts @@ -7,6 +7,7 @@ import { DEFAULT_INVOICE_REMINDER_MAIL_CONTENT, DEFAULT_INVOICE_REMINDER_MAIL_SUBJECT, } from './constants'; +import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; @Service() export class SendInvoiceMailReminder { @@ -66,10 +67,10 @@ export class SendInvoiceMailReminder { ) { const localMessageOpts = await this.getMailOption(tenantId, saleInvoiceId); - const messageOpts = { - ...localMessageOpts, - ...messageOptions, - }; + const messageOpts = parseAndValidateMailOptions( + localMessageOpts, + messageOptions + ); const mail = new Mail() .setSubject(messageOpts.subject) .setTo(messageOpts.to) diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts index 236a33758..b29570d42 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotificationJob.ts @@ -1,5 +1,4 @@ import Container, { Service } from 'typedi'; -import events from '@/subscribers/events'; import { SendPaymentReceiveMailNotification } from './PaymentReceiveMailNotification'; @Service() @@ -22,8 +21,6 @@ export class PaymentReceiveMailNotificationJob { const { tenantId, paymentReceiveId, messageDTO } = job.attrs.data; const paymentMail = Container.get(SendPaymentReceiveMailNotification); - console.log(tenantId, paymentReceiveId, messageDTO); - try { await paymentMail.sendMail(tenantId, paymentReceiveId, messageDTO); done(); diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts index 459d9c62e..d4c87df29 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptApplication.ts @@ -177,6 +177,7 @@ export class SaleReceiptApplication { * Sends the receipt mail of the given sale receipt. * @param {number} tenantId * @param {number} saleReceiptId + * @param {SaleReceiptMailOptsDTO} messageOpts * @returns {Promise} */ public sendSaleReceiptMail( diff --git a/packages/webapp/src/components/Dashboard/Dashboard.tsx b/packages/webapp/src/components/Dashboard/Dashboard.tsx index 2637128cf..467963849 100644 --- a/packages/webapp/src/components/Dashboard/Dashboard.tsx +++ b/packages/webapp/src/components/Dashboard/Dashboard.tsx @@ -51,8 +51,8 @@ export default function Dashboard() { - + diff --git a/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx b/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx index dfa4b90be..edcfe2e18 100644 --- a/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx +++ b/packages/webapp/src/containers/Accounts/AccountsDataTable.tsx @@ -32,7 +32,7 @@ function AccountsDataTable({ // #withAlertsDialog openAlert, - // #withDial + // #withDialog openDialog, // #withDrawerActions diff --git a/packages/webapp/src/containers/Authentication/Authentication.tsx b/packages/webapp/src/containers/Authentication/Authentication.tsx index 8d2887849..8b05570c4 100644 --- a/packages/webapp/src/containers/Authentication/Authentication.tsx +++ b/packages/webapp/src/containers/Authentication/Authentication.tsx @@ -8,9 +8,9 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'; import authenticationRoutes from '@/routes/authentication'; import { Icon, FormattedMessage as T } from '@/components'; import { useIsAuthenticated } from '@/hooks/state'; +import { AuthMetaBootProvider } from './AuthMetaBoot'; import '@/style/pages/Authentication/Auth.scss'; -import { AuthMetaBootProvider } from './AuthMetaBoot'; export function Authentication() { const to = { pathname: '/' }; From 99ca683d13bc8c5d5022e7ecccebbcd4e3cf46c0 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 1 Jan 2024 20:57:08 +0200 Subject: [PATCH 031/148] chore: update CHANGELOG file --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aff6671f7..6bc0f93a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Bigcapital server-side will be in this file. +## [0.13.0] - 31-12-2023 + +* feat: Send an invoice mail the customer email by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/292 +* fix: Allow non-numeric postal codes by @cschuijt in https://github.com/bigcapitalhq/bigcapital/pull/294 +* docs: add cschuijt as a contributor for bug by @allcontributors in https://github.com/bigcapitalhq/bigcapital/pull/295 + ## [0.12.1] - 17-11-2023 * feat: Add default customer message and terms conditions to the transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/291 From 83e48cce42d02b69d38c9b9fac76b8b25ea2d3bd Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 1 Jan 2024 20:59:31 +0200 Subject: [PATCH 032/148] feat: remove hint popup of mail notification form --- .../SendMailNotification/MailNotificationForm.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx index b7e578b91..74712a606 100644 --- a/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx +++ b/packages/webapp/src/containers/SendMailNotification/MailNotificationForm.tsx @@ -5,10 +5,8 @@ import { FInputGroup, FMultiSelect, FRichEditor, - Hint, } from '@/components'; import styled from 'styled-components'; -import { Position } from '@blueprintjs/core'; import { SelectOptionProps } from '@blueprintjs-formik/select'; interface MailNotificationFormProps { @@ -31,18 +29,7 @@ export function MailNotificationForm({ return ( - - } - name={'from'} - inline={true} - fastField={true} - > + Date: Tue, 2 Jan 2024 21:53:37 +0200 Subject: [PATCH 033/148] feat(server): journal sheet csv/xlsx export --- .../JournalSheet/JournalSheetApplication.ts | 59 ++++++++++++ .../JournalSheet/JournalSheetExport.ts | 43 +++++++++ .../JournalSheet/JournalSheetService.ts | 10 +- .../JournalSheet/JournalSheetTable.ts | 95 +++++++++++++++++++ .../JournalSheetTableInjectable.ts | 39 ++++++++ 5 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetExport.ts diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts index e69de29bb..4c403ff58 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetApplication.ts @@ -0,0 +1,59 @@ +import { Inject } from 'typedi'; +import { JournalSheetService } from './JournalSheetService'; +import { JournalSheetTableInjectable } from './JournalSheetTableInjectable'; +import { IJournalReportQuery, IJournalTable } from '@/interfaces'; +import { JournalSheetExportInjectable } from './JournalSheetExport'; + +export class JournalSheetApplication { + @Inject() + private journalSheetTable: JournalSheetTableInjectable; + + @Inject() + private journalSheet: JournalSheetService; + + @Inject() + private journalExport: JournalSheetExportInjectable; + + /** + * Retrieves the journal sheet. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {} + */ + public sheet(tenantId: number, query: IJournalReportQuery) { + return this.journalSheet.journalSheet(tenantId, query); + } + + /** + * Retrieves the journal sheet in table format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public table( + tenantId: number, + query: IJournalReportQuery + ): Promise { + return this.journalSheetTable.table(tenantId, query); + } + + /** + * Retrieves the journal sheet in xlsx format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns + */ + public xlsx(tenantId: number, query: IJournalReportQuery) { + return this.journalExport.xlsx(tenantId, query); + } + + /** + * Retrieves the journal sheet in csv format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns + */ + public csv(tenantId: number, query: IJournalReportQuery) { + return this.journalExport.csv(tenantId, query); + } +} diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetExport.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetExport.ts new file mode 100644 index 000000000..815c0a308 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetExport.ts @@ -0,0 +1,43 @@ +import { Inject, Service } from 'typedi'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { IJournalReportQuery } from '@/interfaces'; +import { JournalSheetTableInjectable } from './JournalSheetTableInjectable'; + +@Service() +export class JournalSheetExportInjectable { + @Inject() + private journalSheetTable: JournalSheetTableInjectable; + + /** + * Retrieves the trial balance sheet in XLSX format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public async xlsx(tenantId: number, query: IJournalReportQuery) { + const table = await this.journalSheetTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToXLSX(); + + return tableSheet.convertToBuffer(tableCsv, 'xlsx'); + } + + /** + * Retrieves the trial balance sheet in CSV format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: IJournalReportQuery + ): Promise { + const table = await this.journalSheetTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts index fe0a071ff..afb0b10f3 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts @@ -1,16 +1,15 @@ import { Service, Inject } from 'typedi'; import moment from 'moment'; import { IJournalReportQuery, IJournalSheetMeta } from '@/interfaces'; - import JournalSheet from './JournalSheet'; import TenancyService from '@/services/Tenancy/TenancyService'; import Journal from '@/services/Accounting/JournalPoster'; import InventoryService from '@/services/Inventory/Inventory'; -import { parseBoolean, transformToMap } from 'utils'; import { Tenant } from '@/system/models'; +import { parseBoolean, transformToMap } from 'utils'; @Service() -export default class JournalSheetService { +export class JournalSheetService { @Inject() tenancy: TenancyService; @@ -80,11 +79,6 @@ export default class JournalSheetService { ...this.defaultQuery, ...query, }; - this.logger.info('[journal] trying to calculate the report.', { - tenantId, - filter, - }); - const tenant = await Tenant.query() .findById(tenantId) .withGraphFetched('metadata'); diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts index e69de29bb..2a376a854 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts @@ -0,0 +1,95 @@ +import * as R from 'ramda'; +import { + IJournalReport, + IJournalReportEntriesGroup, + IJournalReportQuery, + IJournalTableData, + ITableColumn, + ITableColumnAccessor, + ITableRow, +} from '@/interfaces'; +import { tableRowMapper } from '@/utils'; +import { FinancialTable } from '../FinancialTable'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import FinancialSheet from '../FinancialSheet'; + +export class JournalSheetTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private data: IJournalTableData; + private query: IJournalReportQuery; + private i18n: any; + + constructor(data: IJournalTableData, query: IJournalReportQuery, i18n: any) { + super(); + this.data = data; + this.query = query; + this.i18n = i18n; + } + + /** + * Retrieves the common table accessors. + * @returns {ITableColumnAccessor[]} + */ + private commonColumnsAccessors = (): ITableColumnAccessor[] => { + return [ + { key: 'date', accessor: 'date' }, + { key: 'reference_type', accessor: 'referenceTypeFormatted' }, + { key: 'reference_number', accessor: 'reference_number' }, + { key: 'currency_code', accessor: 'currencyCode' }, + { key: 'credit', accessor: 'formattedCredit' }, + { key: 'debit', accessor: 'formattedDebit' }, + ]; + }; + + private commonColumns(): ITableColumn[] { + return [ + { key: 'date', label: 'Date' }, + { key: 'reference_type', label: 'Reference Type' }, + { key: 'reference_type', label: 'Reference Number' }, + { key: 'currency_code', label: 'Currency Code' }, + { key: 'credit', label: 'Credit' }, + { key: 'debit', label: 'Debit' }, + ]; + } + + /** + * + */ + private entryGroupMapper = (group: IJournalReportEntriesGroup) => { + const columns = this.commonColumnsAccessors(); + + return tableRowMapper(group, columns, {}); + }; + + /** + * + */ + private entryMapper = () => {}; + + /** + * + */ + private entriesGroupsMapper = (entries: IJournalReportEntriesGroup[]) => { + return R.compose(R.map(this.entryGroupMapper))(entries); + }; + + /** + * Retrieves the table data rows. + * @returns {ITableRow[]} + */ + public tableData(): ITableRow[] { + return R.compose(this.entriesGroupsMapper)(this.data); + } + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + const columns = this.commonColumns(); + + return R.compose(this.tableColumnsCellIndexing)(columns); + } +} diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts index e69de29bb..0754d78f8 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTableInjectable.ts @@ -0,0 +1,39 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject } from 'typedi'; +import { JournalSheetService } from './JournalSheetService'; +import { IJournalReportQuery, IJournalTable } from '@/interfaces'; +import { JournalSheetTable } from './JournalSheetTable'; + +export class JournalSheetTableInjectable { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private journalSheetService: JournalSheetService; + + /** + * Retrieves the journal sheet in table format. + * @param {number} tenantId + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public async table( + tenantId: number, + query: IJournalReportQuery + ): Promise { + const journal = await this.journalSheetService.journalSheet( + tenantId, + query + ); + const table = new JournalSheetTable(journal.data, journal.query, {}); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableData(), + }, + query: journal.query, + meta: journal.meta, + }; + } +} From e6a3daa2c37b47514eabf7afd0ecc75f5c9c7b0c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 2 Jan 2024 21:54:10 +0200 Subject: [PATCH 034/148] feat(server): general ledger exporting to csv/xlsx --- .../FinancialStatements/GeneralLedger.ts | 50 ++++++--- .../FinancialStatements/JournalSheet.ts | 52 ++++++--- .../src/interfaces/GeneralLedgerSheet.ts | 10 +- .../server/src/interfaces/JournalReport.ts | 53 +++++---- .../GeneralLedger/GeneralLedgerApplication.ts | 66 +++++++++++ .../GeneralLedger/GeneralLedgerExport.ts | 43 +++++++ .../GeneralLedger/GeneralLedgerService.ts | 2 +- .../GeneralLedger/GeneralLedgerTable.ts | 105 ++++++++++++++++++ .../GeneralLedgerTableInjectable.ts | 45 ++++++++ 9 files changed, 374 insertions(+), 52 deletions(-) create mode 100644 packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication.ts create mode 100644 packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerExport.ts create mode 100644 packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts create mode 100644 packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTableInjectable.ts diff --git a/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts b/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts index ac3f002a7..2401edf9d 100644 --- a/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts +++ b/packages/server/src/api/controllers/FinancialStatements/GeneralLedger.ts @@ -2,15 +2,16 @@ import { Router, Request, Response, NextFunction } from 'express'; import { query, ValidationChain } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; -import GeneralLedgerService from '@/services/FinancialStatements/GeneralLedger/GeneralLedgerService'; import BaseFinancialReportController from './BaseFinancialReportController'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { GeneralLedgerApplication } from '@/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication'; @Service() export default class GeneralLedgerReportController extends BaseFinancialReportController { @Inject() - generalLedgetService: GeneralLedgerService; + private generalLedgerApplication: GeneralLedgerApplication; /** * Router constructor. @@ -61,20 +62,43 @@ export default class GeneralLedgerReportController extends BaseFinancialReportCo * @param {Response} res - */ async generalLedger(req: Request, res: Response, next: NextFunction) { - const { tenantId, settings } = req; + const { tenantId } = req; const filter = this.matchedQueryData(req); + const accept = this.accepts(req); - try { - const { data, query, meta } = - await this.generalLedgetService.generalLedger(tenantId, filter); + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_CSV, + ]); + // Retrieves the table format. + if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.generalLedgerApplication.table(tenantId, filter); - return res.status(200).send({ - meta: this.transfromToResponse(meta), - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), - }); - } catch (error) { - next(error); + return res.status(200).send(table); + // Retrieves the csv format. + } else if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = await this.generalLedgerApplication.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the xlsx format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = this.generalLedgerApplication.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + return res.send(buffer); + // Retrieves the json format. + } else { + const sheet = await this.generalLedgerApplication.sheet(tenantId, filter); + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts b/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts index ebd6074f4..674d7eb3c 100644 --- a/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts +++ b/packages/server/src/api/controllers/FinancialStatements/JournalSheet.ts @@ -3,14 +3,15 @@ import { Request, Response, Router, NextFunction } from 'express'; import { castArray } from 'lodash'; import { query, oneOf } from 'express-validator'; import BaseFinancialReportController from './BaseFinancialReportController'; -import JournalSheetService from '@/services/FinancialStatements/JournalSheet/JournalSheetService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { JournalSheetApplication } from '@/services/FinancialStatements/JournalSheet/JournalSheetApplication'; @Service() export default class JournalSheetController extends BaseFinancialReportController { @Inject() - journalService: JournalSheetService; + private journalSheetApp: JournalSheetApplication; /** * Router constructor. @@ -57,28 +58,49 @@ export default class JournalSheetController extends BaseFinancialReportControlle * @param {Request} req - * @param {Response} res - */ - async journal(req: Request, res: Response, next: NextFunction) { - const { tenantId, settings } = req; + private async journal(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; let filter = this.matchedQueryData(req); filter = { ...filter, accountsIds: castArray(filter.accountsIds), }; + const accept = this.accepts(req); + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_XLSX, + ACCEPT_TYPE.APPLICATION_CSV, + ]); - try { - const { data, query, meta } = await this.journalService.journalSheet( - tenantId, - filter + // Retrieves the json table format. + if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.journalSheetApp.table(tenantId, filter); + return res.status(200).send(table); + // Retrieves the csv format. + } else if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = this.journalSheetApp.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the xlsx format. + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = await this.journalSheetApp.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ); + return res.send(buffer); + // Retrieves the json format. + } else { + const sheet = await this.journalSheetApp.sheet(tenantId, filter); - return res.status(200).send({ - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), - meta: this.transfromToResponse(meta), - }); - } catch (error) { - next(error); + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/interfaces/GeneralLedgerSheet.ts b/packages/server/src/interfaces/GeneralLedgerSheet.ts index bf1662086..5141ddb47 100644 --- a/packages/server/src/interfaces/GeneralLedgerSheet.ts +++ b/packages/server/src/interfaces/GeneralLedgerSheet.ts @@ -1,3 +1,4 @@ +import { IFinancialTable } from "./Table"; export interface IGeneralLedgerSheetQuery { @@ -56,6 +57,8 @@ export interface IGeneralLedgerSheetAccount { closingBalance: IGeneralLedgerSheetAccountBalance, } +export type IGeneralLedgerSheetData = IGeneralLedgerSheetAccount[]; + export interface IAccountTransaction { id: number, index: number, @@ -78,4 +81,9 @@ export interface IGeneralLedgerMeta { isCostComputeRunning: boolean, organizationName: string, baseCurrency: string, -}; \ No newline at end of file +}; + +export interface IGeneralLedgerTableData extends IFinancialTable { + meta: IGeneralLedgerMeta; + query: IGeneralLedgerSheetQuery; +} \ No newline at end of file diff --git a/packages/server/src/interfaces/JournalReport.ts b/packages/server/src/interfaces/JournalReport.ts index 9786e1634..2a8625f9d 100644 --- a/packages/server/src/interfaces/JournalReport.ts +++ b/packages/server/src/interfaces/JournalReport.ts @@ -1,36 +1,45 @@ import { IJournalEntry } from './Journal'; +import { IFinancialTable } from './Table'; export interface IJournalReportQuery { - fromDate: Date | string, - toDate: Date | string, + fromDate: Date | string; + toDate: Date | string; numberFormat: { - noCents: boolean, - divideOn1000: boolean, - }, - transactionType: string, - transactionId: string, + noCents: boolean; + divideOn1000: boolean; + }; + transactionType: string; + transactionId: string; - accountsIds: number | number[], - fromRange: number, - toRange: number, + accountsIds: number | number[]; + fromRange: number; + toRange: number; } export interface IJournalReportEntriesGroup { - id: string, - entries: IJournalEntry[], - currencyCode: string, - credit: number, - debit: number, - formattedCredit: string, - formattedDebit: string, + id: string; + entries: IJournalEntry[]; + currencyCode: string; + credit: number; + debit: number; + formattedCredit: string; + formattedDebit: string; } export interface IJournalReport { - entries: IJournalReportEntriesGroup[], + entries: IJournalReportEntriesGroup[]; } export interface IJournalSheetMeta { - isCostComputeRunning: boolean, - organizationName: string, - baseCurrency: string, -} \ No newline at end of file + isCostComputeRunning: boolean; + organizationName: string; + baseCurrency: string; +} + +export interface IJournalTable extends IFinancialTable { + query: IJournalReportQuery; + meta: IJournalSheetMeta; +} + + +export type IJournalTableData = IJournalReportEntriesGroup[]; \ No newline at end of file diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication.ts new file mode 100644 index 000000000..924b0da8c --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerApplication.ts @@ -0,0 +1,66 @@ +import { Inject } from 'typedi'; +import { + IGeneralLedgerSheetQuery, + IGeneralLedgerTableData, +} from '@/interfaces'; +import { GeneralLedgerTableInjectable } from './GeneralLedgerTableInjectable'; +import { GeneralLedgerExportInjectable } from './GeneralLedgerExport'; +import { GeneralLedgerService } from './GeneralLedgerService'; + +export class GeneralLedgerApplication { + @Inject() + private GLTable: GeneralLedgerTableInjectable; + + @Inject() + private GLExport: GeneralLedgerExportInjectable; + + @Inject() + private GLSheet: GeneralLedgerService; + + /** + * Retrieves the G/L sheet in json format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + */ + public sheet(tenantId: number, query: IGeneralLedgerSheetQuery) { + return this.GLSheet.generalLedger(tenantId, query); + } + + /** + * Retrieves the G/L sheet in table format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public table( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + return this.GLTable.table(tenantId, query); + } + + /** + * Retrieves the G/L sheet in xlsx format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {} + */ + public xlsx( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + return this.GLExport.xlsx(tenantId, query); + } + + /** + * Retrieves the G/L sheet in csv format. + * @param {number} tenantId - + * @param {IGeneralLedgerSheetQuery} query - + */ + public csv( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + return this.GLExport.csv(tenantId, query); + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerExport.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerExport.ts new file mode 100644 index 000000000..f05c817c2 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerExport.ts @@ -0,0 +1,43 @@ +import { IGeneralLedgerSheetQuery } from '@/interfaces'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { Inject, Service } from 'typedi'; +import { GeneralLedgerTableInjectable } from './GeneralLedgerTableInjectable'; + +@Service() +export class GeneralLedgerExportInjectable { + @Inject() + private generalLedgerTable: GeneralLedgerTableInjectable; + + /** + * Retrieves the general ledger sheet in XLSX format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public async xlsx(tenantId: number, query: IGeneralLedgerSheetQuery) { + const table = await this.generalLedgerTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToXLSX(); + + return tableSheet.convertToBuffer(tableCsv, 'xlsx'); + } + + /** + * Retrieves the general ledger sheet in CSV format. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + const table = await this.generalLedgerTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts index 012a05b94..3ce5461be 100644 --- a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts @@ -15,7 +15,7 @@ const ERRORS = { }; @Service() -export default class GeneralLedgerService { +export class GeneralLedgerService { @Inject() tenancy: TenancyService; diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts new file mode 100644 index 000000000..938052ceb --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts @@ -0,0 +1,105 @@ +import * as R from 'ramda'; +import { + IGeneralLedgerSheetAccount, + IGeneralLedgerSheetData, + IGeneralLedgerSheetQuery, + ITableColumn, + ITableColumnAccessor, + ITableRow, +} from '@/interfaces'; +import FinancialSheet from '../FinancialSheet'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import { tableRowMapper } from '@/utils'; + +export class GeneralLedgerTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { + private data: IGeneralLedgerSheetData; + private query: IGeneralLedgerSheetQuery; + + /** + * Creates an instance of `GeneralLedgerTable`. + * @param {IGeneralLedgerSheetData} data + * @param {IGeneralLedgerSheetQuery} query + */ + constructor(data: IGeneralLedgerSheetData, query: IGeneralLedgerSheetQuery) { + super(); + + this.data = data; + this.query = query; + } + + /** + * Retrieves the common table accessors. + * @returns {ITableColumnAccessor[]} + */ + private commonColumnsAccessors(): ITableColumnAccessor[] { + return [ + { key: 'date', accessor: 'date' }, + { key: 'reference_type', accessor: 'referenceTypeFormatted' }, + { key: 'reference_number', accessor: 'reference_number' }, + { key: 'currency_code', accessor: 'currencyCode' }, + { key: 'credit', accessor: 'formattedCredit' }, + { key: 'debit', accessor: 'formattedDebit' }, + { key: 'running_balance', accessor: 'formattedRunningBalance' }, + ]; + } + + /** + * Retrieves the common table columns. + * @returns {ITableColumn[]} + */ + private commonColumns(): ITableColumn[] { + return [ + { key: 'date', label: 'Date' }, + { key: 'reference_type', label: 'Reference Type' }, + { key: 'reference_number', label: 'Reference Number' }, + { key: 'currency_code', label: 'Currency Code' }, + { key: 'credit', label: 'Credit' }, + { key: 'debit', label: 'Debit' }, + { key: 'running_balance', label: 'Running Balance' }, + ]; + } + + /** + * Maps the given account node to the table rows. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow} + */ + private accountMapper = (account: IGeneralLedgerSheetAccount): ITableRow => { + const columns = this.commonColumnsAccessors(); + + return tableRowMapper(account, columns, {}); + }; + + /** + * Maps the given account node to table rows. + * @param {IGeneralLedgerSheetAccount[]} accounts + * @returns {ITableRow[]} + */ + private accountsMapper = ( + accounts: IGeneralLedgerSheetAccount[] + ): ITableRow[] => { + return R.compose(R.map(this.accountMapper))(accounts); + }; + + /** + * Retrieves the table rows. + * @returns {ITableRow[]} + */ + public tableRows(): ITableRow[] { + return R.compose(this.accountsMapper)(this.data); + } + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + const columns = this.commonColumns(); + + return R.compose(this.tableColumnsCellIndexing)(columns); + } +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTableInjectable.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTableInjectable.ts new file mode 100644 index 000000000..467e8bf4c --- /dev/null +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTableInjectable.ts @@ -0,0 +1,45 @@ +import { + IGeneralLedgerSheetQuery, + IGeneralLedgerTableData, +} from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import { GeneralLedgerService } from './GeneralLedgerService'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GeneralLedgerTable } from './GeneralLedgerTable'; + +@Service() +export class GeneralLedgerTableInjectable { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private GLSheet: GeneralLedgerService; + + /** + * Retrieves the G/L table. + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @returns {Promise} + */ + public async table( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise { + const { + data: sheetData, + query: sheetQuery, + meta: sheetMeta, + } = await this.GLSheet.generalLedger(tenantId, query); + + const table = new GeneralLedgerTable(sheetData, sheetQuery); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableRows(), + }, + query: sheetQuery, + meta: sheetMeta, + }; + } +} From 60b1bc9ed71ecbb32a28f56a9bf767b1c5d843a7 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 4 Jan 2024 17:22:13 +0200 Subject: [PATCH 035/148] feat(server): wip journal and general ledger table layer --- .../src/interfaces/GeneralLedgerSheet.ts | 1 + .../server/src/interfaces/JournalReport.ts | 8 +- .../GeneralLedger/GeneralLedger.ts | 2 + .../GeneralLedger/GeneralLedgerTable.ts | 66 +++++++-- .../JournalSheet/JournalSheet.ts | 6 +- .../JournalSheet/JournalSheetService.ts | 22 ++- .../JournalSheet/JournalSheetTable.ts | 138 +++++++++++++++--- .../FinancialStatements/JournalSheet/types.ts | 6 + 8 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 packages/server/src/services/FinancialStatements/JournalSheet/types.ts diff --git a/packages/server/src/interfaces/GeneralLedgerSheet.ts b/packages/server/src/interfaces/GeneralLedgerSheet.ts index 5141ddb47..9951bf3b2 100644 --- a/packages/server/src/interfaces/GeneralLedgerSheet.ts +++ b/packages/server/src/interfaces/GeneralLedgerSheet.ts @@ -37,6 +37,7 @@ export interface IGeneralLedgerSheetAccountTransaction { referenceType?: string, date: Date|string, + dateFormatted: string; }; export interface IGeneralLedgerSheetAccountBalance { diff --git a/packages/server/src/interfaces/JournalReport.ts b/packages/server/src/interfaces/JournalReport.ts index 2a8625f9d..823e8b887 100644 --- a/packages/server/src/interfaces/JournalReport.ts +++ b/packages/server/src/interfaces/JournalReport.ts @@ -18,6 +18,7 @@ export interface IJournalReportQuery { export interface IJournalReportEntriesGroup { id: string; + dateFormatted: string; entries: IJournalEntry[]; currencyCode: string; credit: number; @@ -41,5 +42,10 @@ export interface IJournalTable extends IFinancialTable { meta: IJournalSheetMeta; } +export type IJournalTableData = IJournalReportEntriesGroup[]; -export type IJournalTableData = IJournalReportEntriesGroup[]; \ No newline at end of file +export interface IJournalSheet { + data: IJournalTableData; + query: IJournalReportQuery; + meta: IJournalSheetMeta; +} diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts index 512ed37d7..507b9deb4 100644 --- a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts @@ -10,6 +10,7 @@ import { IContact, } from '@/interfaces'; import FinancialSheet from '../FinancialSheet'; +import moment from 'moment'; /** * General ledger sheet. @@ -88,6 +89,7 @@ export default class GeneralLedgerSheet extends FinancialSheet { const newEntry = { date: entry.date, + dateFromatted: moment(entry.date).format('YYYY/MM/DD'), entryId: entry.id, referenceType: entry.referenceType, diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts index 938052ceb..2529105f7 100644 --- a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts @@ -1,6 +1,7 @@ import * as R from 'ramda'; import { IGeneralLedgerSheetAccount, + IGeneralLedgerSheetAccountTransaction, IGeneralLedgerSheetData, IGeneralLedgerSheetQuery, ITableColumn, @@ -35,15 +36,34 @@ export class GeneralLedgerTable extends R.compose( * Retrieves the common table accessors. * @returns {ITableColumnAccessor[]} */ - private commonColumnsAccessors(): ITableColumnAccessor[] { + private accountColumnsAccessors(): ITableColumnAccessor[] { + return [ + { key: 'date', accessor: 'name' }, + { key: 'account_name', accessor: '_empty_' }, + { key: 'reference_type', accessor: '_empty_' }, + { key: 'reference_number', accessor: '_empty_' }, + { key: 'description', accessor: 'description' }, + { key: 'credit', accessor: '_empty_' }, + { key: 'debit', accessor: '_empty_' }, + { key: 'amount', accessor: 'amount.formattedAmount' }, + { key: 'running_balance', accessor: 'openingBalance.formattedAmount' }, + ]; + } + + /** + * Retrieves the transaction column accessors. + * @returns {ITableColumnAccessor[]} + */ + private transactionColumnAccessors(): ITableColumnAccessor[] { return [ { key: 'date', accessor: 'date' }, + { key: 'account_name', accessor: 'name' }, { key: 'reference_type', accessor: 'referenceTypeFormatted' }, - { key: 'reference_number', accessor: 'reference_number' }, + { key: 'reference_number', accessor: 'referenceNumber' }, { key: 'currency_code', accessor: 'currencyCode' }, { key: 'credit', accessor: 'formattedCredit' }, { key: 'debit', accessor: 'formattedDebit' }, - { key: 'running_balance', accessor: 'formattedRunningBalance' }, + { key: 'running_balance', accessor: 'runningBalance.formattedAmount' }, ]; } @@ -54,24 +74,52 @@ export class GeneralLedgerTable extends R.compose( private commonColumns(): ITableColumn[] { return [ { key: 'date', label: 'Date' }, - { key: 'reference_type', label: 'Reference Type' }, - { key: 'reference_number', label: 'Reference Number' }, - { key: 'currency_code', label: 'Currency Code' }, + { key: 'account_name', label: 'Account Name' }, + { key: 'reference_type', label: 'Transaction Type' }, + { key: 'reference_number', label: 'Transaction Number' }, + { key: 'description', label: 'Description' }, { key: 'credit', label: 'Credit' }, { key: 'debit', label: 'Debit' }, + { key: 'amount', label: 'Amount' }, { key: 'running_balance', label: 'Running Balance' }, ]; } + /** + * Maps the given transaction node to table row. + * @param {IGeneralLedgerSheetAccountTransaction} transaction + * @returns {ITableRow} + */ + private transactionMapper = ( + transaction: IGeneralLedgerSheetAccountTransaction + ): ITableRow => { + const columns = this.transactionColumnAccessors(); + + return tableRowMapper(transaction, columns, {}); + }; + + /** + * Maps the given transactions nodes to table rows. + * @param {IGeneralLedgerSheetAccountTransaction[]} transactions + * @returns {ITableRow[]} + */ + private transactionsMapper = ( + transactions: IGeneralLedgerSheetAccountTransaction[] + ): ITableRow[] => { + return R.map(this.transactionMapper)(transactions); + }; + /** * Maps the given account node to the table rows. * @param {IGeneralLedgerSheetAccount} account * @returns {ITableRow} */ private accountMapper = (account: IGeneralLedgerSheetAccount): ITableRow => { - const columns = this.commonColumnsAccessors(); + const columns = this.accountColumnsAccessors(); + const row = tableRowMapper(account, columns, {}); + const transactions = this.transactionsMapper(account.transactions); - return tableRowMapper(account, columns, {}); + return R.assoc('children', transactions)(row); }; /** @@ -82,7 +130,7 @@ export class GeneralLedgerTable extends R.compose( private accountsMapper = ( accounts: IGeneralLedgerSheetAccount[] ): ITableRow[] => { - return R.compose(R.map(this.accountMapper))(accounts); + return this.mapNodesDeep(accounts, this.accountMapper)l }; /** diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts index ee184a5a1..ec3d5f8ef 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts @@ -6,8 +6,10 @@ import { IJournalReportQuery, IJournalReport, IContact, + IJournalTableData, } from '@/interfaces'; import FinancialSheet from '../FinancialSheet'; +import moment from 'moment'; export default class JournalSheet extends FinancialSheet { readonly tenantId: number; @@ -96,6 +98,8 @@ export default class JournalSheet extends FinancialSheet { return { date: groupEntry.date, + dateFormatted: moment(groupEntry.date).format('YYYY/MM/DD'), + referenceType: groupEntry.referenceType, referenceId: groupEntry.referenceId, referenceTypeFormatted: this.i18n.__(groupEntry.referenceTypeFormatted), @@ -131,7 +135,7 @@ export default class JournalSheet extends FinancialSheet { * Retrieve journal report. * @return {IJournalReport} */ - reportData(): IJournalReport { + reportData(): IJournalTableData { return this.entriesWalker(this.journal.entries); } } diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts index afb0b10f3..69a319ff3 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts @@ -1,6 +1,11 @@ import { Service, Inject } from 'typedi'; import moment from 'moment'; -import { IJournalReportQuery, IJournalSheetMeta } from '@/interfaces'; +import { + IJournalReportQuery, + IJournalSheet, + IJournalSheetMeta, + IJournalTableData, +} from '@/interfaces'; import JournalSheet from './JournalSheet'; import TenancyService from '@/services/Tenancy/TenancyService'; import Journal from '@/services/Accounting/JournalPoster'; @@ -11,13 +16,10 @@ import { parseBoolean, transformToMap } from 'utils'; @Service() export class JournalSheetService { @Inject() - tenancy: TenancyService; + private tenancy: TenancyService; @Inject() - inventoryService: InventoryService; - - @Inject('logger') - logger: any; + private inventoryService: InventoryService; /** * Default journal sheet filter queyr. @@ -66,9 +68,13 @@ export class JournalSheetService { /** * Journal sheet. * @param {number} tenantId - * @param {IJournalSheetFilterQuery} query + * @param {IJournalReportQuery} query + * @returns {Promise} */ - async journalSheet(tenantId: number, query: IJournalReportQuery) { + async journalSheet( + tenantId: number, + query: IJournalReportQuery + ): Promise { const i18n = this.tenancy.i18n(tenantId); const { accountRepository, transactionsRepository, contactRepository } = this.tenancy.repositories(tenantId); diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts index 2a376a854..2854154e2 100644 --- a/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts +++ b/packages/server/src/services/FinancialStatements/JournalSheet/JournalSheetTable.ts @@ -1,5 +1,6 @@ import * as R from 'ramda'; import { + IJournalEntry, IJournalReport, IJournalReportEntriesGroup, IJournalReportQuery, @@ -12,6 +13,7 @@ import { tableRowMapper } from '@/utils'; import { FinancialTable } from '../FinancialTable'; import { FinancialSheetStructure } from '../FinancialSheetStructure'; import FinancialSheet from '../FinancialSheet'; +import { first } from 'lodash'; export class JournalSheetTable extends R.compose( FinancialTable, @@ -21,6 +23,12 @@ export class JournalSheetTable extends R.compose( private query: IJournalReportQuery; private i18n: any; + /** + * Constructor method. + * @param {IJournalTableData} data + * @param {IJournalReportQuery} query + * @param i18n + */ constructor(data: IJournalTableData, query: IJournalReportQuery, i18n: any) { super(); this.data = data; @@ -32,47 +40,141 @@ export class JournalSheetTable extends R.compose( * Retrieves the common table accessors. * @returns {ITableColumnAccessor[]} */ - private commonColumnsAccessors = (): ITableColumnAccessor[] => { + private groupColumnsAccessors = (): ITableColumnAccessor[] => { return [ { key: 'date', accessor: 'date' }, - { key: 'reference_type', accessor: 'referenceTypeFormatted' }, - { key: 'reference_number', accessor: 'reference_number' }, - { key: 'currency_code', accessor: 'currencyCode' }, + { key: 'transaction_type', accessor: 'referenceTypeFormatted' }, + { key: 'transaction_number', accessor: 'referenceNumber' }, + { key: 'description', accessor: 'entry.description' }, + { key: 'account_code', accessor: 'entry.accountCode' }, + { key: 'account_name', accessor: 'entry.accountName' }, + { key: 'credit', accessor: 'entry.formattedCredit' }, + { key: 'debit', accessor: 'entry.formattedDebit' }, + ]; + }; + + /** + * Retrieves the group entry accessors. + * @returns {ITableColumnAccessor[]} + */ + private entryColumnsAccessors = (): ITableColumnAccessor[] => { + return [ + { key: 'date', accessor: '_empty_' }, + { key: 'transaction_type', accessor: '_empty_' }, + { key: 'transaction_number', accessor: '_empty_' }, + { key: 'description', accessor: 'description' }, + { key: 'account_code', accessor: 'accountCode' }, + { key: 'account_name', accessor: 'accountName' }, { key: 'credit', accessor: 'formattedCredit' }, { key: 'debit', accessor: 'formattedDebit' }, ]; }; + private totalEntryColumnAccessors = (): ITableColumnAccessor[] => { + return [ + { key: 'date', accessor: '_empty_' }, + { key: 'transaction_type', accessor: '_empty_' }, + { key: 'transaction_number', accessor: '_empty_' }, + { key: 'description', accessor: '_empty_' }, + { key: 'account_code', accessor: '_empty_' }, + { key: 'account_name', accessor: '_empty_' }, + { key: 'credit', accessor: 'formattedCredit' }, + { key: 'debit', accessor: 'formattedDebit' }, + ]; + }; + + /** + * Retrieves the common columns. + * @returns {ITableColumn[]} + */ private commonColumns(): ITableColumn[] { return [ { key: 'date', label: 'Date' }, - { key: 'reference_type', label: 'Reference Type' }, - { key: 'reference_type', label: 'Reference Number' }, - { key: 'currency_code', label: 'Currency Code' }, + { key: 'transaction_type', label: 'Transaction Type' }, + { key: 'transaction_number', label: 'Num.' }, + { key: 'description', label: 'Description' }, + { key: 'account_code', label: 'Acc. Code' }, + { key: 'account_name', label: 'Account' }, { key: 'credit', label: 'Credit' }, { key: 'debit', label: 'Debit' }, ]; } /** - * + * Maps the group and first entry to table row. + * @param {IJournalReportEntriesGroup} group + * @returns {ITableRow} */ - private entryGroupMapper = (group: IJournalReportEntriesGroup) => { - const columns = this.commonColumnsAccessors(); - - return tableRowMapper(group, columns, {}); + private firstEntryGroupMapper = ( + group: IJournalReportEntriesGroup + ): ITableRow => { + const meta = { + rowTypes: [ROW_TYPE.ENTRY], + }; + const computedGroup = { ...group, entry: first(group.entries) }; + const columns = this.groupColumnsAccessors(); + return tableRowMapper(computedGroup, columns, meta); }; /** - * + * Maps the given group entry to table rows. + * @param {IJournalEntry} entry + * @returns {ITableRow} */ - private entryMapper = () => {}; + private entryMapper = (entry: IJournalEntry): ITableRow => { + const columns = this.entryColumnsAccessors(); + const meta = { + rowTypes: [ROW_TYPE.ENTRY], + }; + return tableRowMapper(entry, columns, meta); + }; /** - * + * Maps the given group entries to table rows. + * @param {IJournalReportEntriesGroup} group + * @returns {ITableRow[]} */ - private entriesGroupsMapper = (entries: IJournalReportEntriesGroup[]) => { - return R.compose(R.map(this.entryGroupMapper))(entries); + private entriesMapper = (group: IJournalReportEntriesGroup): ITableRow[] => { + const entries = R.remove(0, 1, group.entries); + + return R.map(this.entryMapper, entries); + }; + + /** + * Maps the given group entry to total table row. + * @param {IJournalReportEntriesGroup} group + * @returns {ITableRow} + */ + public totalEntryMapper = (group: IJournalReportEntriesGroup): ITableRow => { + const total = this.totalEntryColumnAccessors(); + const meta = { + rowTypes: [ROW_TYPE.TOTAL], + }; + return tableRowMapper(group, total, meta); + }; + + /** + * Maps the entry group to table rows. + * @param {IJournalReportEntriesGroup} group - + * @returns {ITableRow} + */ + private groupMapper = (group: IJournalReportEntriesGroup): ITableRow[] => { + const firstRow = this.firstEntryGroupMapper(group); + const lastRows = this.entriesMapper(group); + const totalRow = this.totalEntryMapper(group); + + return [firstRow, ...lastRows, totalRow]; + }; + + /** + * Maps the given group entries to table rows. + * @param {IJournalReportEntriesGroup[]} entries - + * @returns {ITableRow[]} + */ + private groupsMapper = ( + entries: IJournalReportEntriesGroup[] + ): ITableRow[] => { + return R.compose(R.flatten, R.map(this.groupMapper))(entries); }; /** @@ -80,7 +182,7 @@ export class JournalSheetTable extends R.compose( * @returns {ITableRow[]} */ public tableData(): ITableRow[] { - return R.compose(this.entriesGroupsMapper)(this.data); + return R.compose(this.groupsMapper)(this.data); } /** diff --git a/packages/server/src/services/FinancialStatements/JournalSheet/types.ts b/packages/server/src/services/FinancialStatements/JournalSheet/types.ts new file mode 100644 index 000000000..f71970251 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/JournalSheet/types.ts @@ -0,0 +1,6 @@ + + +enum ROW_TYPE { + ENTRY = 'ENTRY', + TOTAL = 'TOTAL' +}; \ No newline at end of file From c71836ec27921ecbf60b5087cb914389f138b2e2 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 4 Jan 2024 21:43:57 +0200 Subject: [PATCH 036/148] feat: wip general ledger table --- .../GeneralLedger/GeneralLedger.ts | 2 +- .../GeneralLedger/GeneralLedgerTable.ts | 113 +++++++++++++++--- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts index 507b9deb4..6a96ce24c 100644 --- a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts @@ -89,7 +89,7 @@ export default class GeneralLedgerSheet extends FinancialSheet { const newEntry = { date: entry.date, - dateFromatted: moment(entry.date).format('YYYY/MM/DD'), + dateFormatted: moment(entry.date).format('YYYY/MM/DD'), entryId: entry.id, referenceType: entry.referenceType, diff --git a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts index 2529105f7..e9f3c68b2 100644 --- a/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts +++ b/packages/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerTable.ts @@ -1,5 +1,6 @@ import * as R from 'ramda'; import { + IColumnMapperMeta, IGeneralLedgerSheetAccount, IGeneralLedgerSheetAccountTransaction, IGeneralLedgerSheetData, @@ -46,7 +47,7 @@ export class GeneralLedgerTable extends R.compose( { key: 'credit', accessor: '_empty_' }, { key: 'debit', accessor: '_empty_' }, { key: 'amount', accessor: 'amount.formattedAmount' }, - { key: 'running_balance', accessor: 'openingBalance.formattedAmount' }, + { key: 'running_balance', accessor: 'closingBalance.formattedAmount' }, ]; } @@ -56,14 +57,51 @@ export class GeneralLedgerTable extends R.compose( */ private transactionColumnAccessors(): ITableColumnAccessor[] { return [ - { key: 'date', accessor: 'date' }, - { key: 'account_name', accessor: 'name' }, + { key: 'date', accessor: 'dateFormatted' }, + { key: 'account_name', accessor: 'account.name' }, { key: 'reference_type', accessor: 'referenceTypeFormatted' }, { key: 'reference_number', accessor: 'referenceNumber' }, - { key: 'currency_code', accessor: 'currencyCode' }, + { key: 'description', accessor: 'description' }, { key: 'credit', accessor: 'formattedCredit' }, { key: 'debit', accessor: 'formattedDebit' }, - { key: 'running_balance', accessor: 'runningBalance.formattedAmount' }, + { key: 'amount', accessor: 'formattedAmount' }, + { key: 'running_balance', accessor: 'formattedRunningBalance' }, + ]; + } + + /** + * Retrieves the opening row column accessors. + * @returns {ITableRowIColumnMapperMeta[]} + */ + private openingBalanceColumnsAccessors(): IColumnMapperMeta[] { + return [ + { key: 'date', accessor: 'dateFormatted' }, + { key: 'account_name', value: 'Opening Balance' }, + { key: 'reference_type', accessor: '_empty_' }, + { key: 'reference_number', accessor: '_empty_' }, + { key: 'description', accessor: 'description' }, + { key: 'credit', accessor: '_empty_' }, + { key: 'debit', accessor: '_empty_' }, + { key: 'amount', accessor: 'openingBalance.formattedAmount' }, + { key: 'running_balance', accessor: '_empty' }, + ]; + } + + /** + * Closing balance row column accessors. + * @returns {ITableColumnAccessor[]} + */ + private closingBalanceColumnAccessors(): IColumnMapperMeta[] { + return [ + { key: 'date', accessor: 'dateFormatted' }, + { key: 'account_name', value: 'Closing Balance' }, + { key: 'reference_type', accessor: '_empty_' }, + { key: 'reference_number', accessor: '_empty_' }, + { key: 'description', accessor: '_empty_' }, + { key: 'credit', accessor: '_empty_' }, + { key: 'debit', accessor: '_empty_' }, + { key: 'amount', accessor: 'closingBalance.formattedAmount' }, + { key: 'running_balance', accessor: '_empty_' }, ]; } @@ -90,13 +128,17 @@ export class GeneralLedgerTable extends R.compose( * @param {IGeneralLedgerSheetAccountTransaction} transaction * @returns {ITableRow} */ - private transactionMapper = ( - transaction: IGeneralLedgerSheetAccountTransaction - ): ITableRow => { - const columns = this.transactionColumnAccessors(); + private transactionMapper = R.curry( + ( + account: IGeneralLedgerSheetAccount, + transaction: IGeneralLedgerSheetAccountTransaction + ): ITableRow => { + const columns = this.transactionColumnAccessors(); + const data = { ...transaction, account }; - return tableRowMapper(transaction, columns, {}); - }; + return tableRowMapper(data, columns, {}); + } + ); /** * Maps the given transactions nodes to table rows. @@ -104,9 +146,50 @@ export class GeneralLedgerTable extends R.compose( * @returns {ITableRow[]} */ private transactionsMapper = ( - transactions: IGeneralLedgerSheetAccountTransaction[] + account: IGeneralLedgerSheetAccount ): ITableRow[] => { - return R.map(this.transactionMapper)(transactions); + const transactionMapper = this.transactionMapper(account); + + return R.map(transactionMapper)(account.transactions); + }; + + /** + * Maps the given account node to opening balance table row. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow} + */ + private openingBalanceMapper = ( + account: IGeneralLedgerSheetAccount + ): ITableRow => { + const columns = this.openingBalanceColumnsAccessors(); + + return tableRowMapper(account, columns, {}); + }; + + /** + * Maps the given account node to closing balance table row. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow} + */ + private closingBalanceMapper = (account: IGeneralLedgerSheetAccount) => { + const columns = this.closingBalanceColumnAccessors(); + + return tableRowMapper(account, columns, {}); + }; + + /** + * Maps the given account node to transactions table rows. + * @param {IGeneralLedgerSheetAccount} account + * @returns {ITableRow[]} + */ + private transactionsNode = ( + account: IGeneralLedgerSheetAccount + ): ITableRow[] => { + const openingBalance = this.openingBalanceMapper(account); + const transactions = this.transactionsMapper(account); + const closingBalance = this.closingBalanceMapper(account); + + return [openingBalance, ...transactions, closingBalance]; }; /** @@ -117,7 +200,7 @@ export class GeneralLedgerTable extends R.compose( private accountMapper = (account: IGeneralLedgerSheetAccount): ITableRow => { const columns = this.accountColumnsAccessors(); const row = tableRowMapper(account, columns, {}); - const transactions = this.transactionsMapper(account.transactions); + const transactions = this.transactionsNode(account); return R.assoc('children', transactions)(row); }; @@ -130,7 +213,7 @@ export class GeneralLedgerTable extends R.compose( private accountsMapper = ( accounts: IGeneralLedgerSheetAccount[] ): ITableRow[] => { - return this.mapNodesDeep(accounts, this.accountMapper)l + return this.mapNodesDeep(accounts, this.accountMapper); }; /** From 26e104b9f19adada0177730efa656ceb38a26bc8 Mon Sep 17 00:00:00 2001 From: "a.nasouf" Date: Sat, 6 Jan 2024 15:28:02 +0200 Subject: [PATCH 037/148] feat(webapp): add approve/reject to action bar of estimate details drawer --- .../EstimateDetailActionsBar.tsx | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx index 3ba5b0d02..ee7d57346 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import { useHistory } from 'react-router-dom'; import { @@ -22,6 +21,7 @@ import { Icon, FormattedMessage as T, Can, + Choose, } from '@/components'; import { compose } from '@/utils'; @@ -42,7 +42,7 @@ function EstimateDetailActionsBar({ closeDrawer, }) { // Estimate details drawer context. - const { estimateId } = useEstimateDetailDrawerContext(); + const { estimateId, estimate } = useEstimateDetailDrawerContext(); // History. const history = useHistory(); @@ -53,6 +53,16 @@ function EstimateDetailActionsBar({ closeDrawer(DRAWERS.ESTIMATE_DETAILS); }; + // Handle cancel/confirm estimate approve. + const handleApproveEstimate = () => { + openAlert('estimate-Approve', { estimateId }); + }; + + // Handle cancel/confirm estimate reject. + const handleRejectEstimate = () => { + openAlert('estimate-reject', { estimateId }); + }; + // Handle delete sale estimate. const handleDeleteEstimate = () => { openAlert('estimate-delete', { estimateId }); @@ -83,6 +93,50 @@ function EstimateDetailActionsBar({ /> + + + +
diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx index cd2fc1d1a..3c89f2f04 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormHeaderFields.tsx @@ -26,6 +26,7 @@ import { inputIntent, handleDateChange, } from '@/utils'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Credit note form header fields. @@ -37,10 +38,8 @@ export default function CreditNoteFormHeaderFields({}) { {/* ----------- Exchange rate ----------- */} - + + {/* ----------- Credit note date ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( @@ -93,8 +92,18 @@ export default function CreditNoteFormHeaderFields({}) { */ function CreditNoteCustomersSelect() { // Credit note form context. - const { customers } = useCreditNoteFormContext(); const { setFieldValue, values } = useFormikContext(); + const { customers } = useCreditNoteFormContext(); + + const updateEntries = useCustomerUpdateExRate(); + + // Handles item change. + const handleItemChange = (customer) => { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; return ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx index bb4c7a2cd..872c2f053 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/CreditNoteFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/CreditNote/PageForm.scss'; import CreditNoteForm from './CreditNoteForm'; import { CreditNoteFormProvider } from './CreditNoteFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Credit note form page. @@ -16,7 +17,9 @@ export default function CreditNoteFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx index 2902299fa..afe139f48 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteForm/components.tsx @@ -1,21 +1,27 @@ // @ts-nocheck -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useFormikContext } from 'formik'; import * as R from 'ramda'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useCreditNoteIsForeignCustomer } from './utils'; +import { useCreditNoteIsForeignCustomer, useCreditNoteTotals } from './utils'; import withSettings from '@/containers/Settings/withSettings'; import { transactionNumber } from '@/utils'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** - * credit exchange rate input field. + * Credit note exchange rate input field. * @returns {JSX.Element} */ -export function CreditNoteExchangeRateInputField({ ...props }) { +function CreditNoteExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const isForeignCustomer = useCreditNoteIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -24,13 +30,21 @@ export function CreditNoteExchangeRateInputField({ ...props }) { } return ( ); } +export const CreditNoteExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(CreditNoteExchangeRateInputFieldRoot); + /** * Syncs credit note auto-increment settings to form. * @return {React.ReactNode} @@ -56,3 +70,28 @@ export const CreditNoteSyncIncrementSettingsToForm = R.compose( return null; }); + +/** + * Syncs the realtime exchange rate to the credit note form and shows up popup to the user + * as an indication the entries rates have been re-calculated. + * @returns {React.ReactNode} + */ +export const CreditNoteExchangeRateSync = R.compose(withDialogActions)( + ({ openDialog }) => { + const { total } = useCreditNoteTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // If the total bigger then zero show alert to the user after adjusting entries. + if (total > 0) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx index f9cd24673..aff0888d6 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React, { useMemo } from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; @@ -19,7 +18,10 @@ import EstimateFloatingActions from './EstimateFloatingActions'; import EstimateFormFooter from './EstimateFormFooter'; import EstimateFormDialogs from './EstimateFormDialogs'; import EstimtaeFormTopBar from './EstimtaeFormTopBar'; -import { EstimateIncrementSyncSettingsToForm } from './components'; +import { + EstimateIncrementSyncSettingsToForm, + EstimateSyncAutoExRateToForm, +} from './components'; import withSettings from '@/containers/Settings/withSettings'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; @@ -170,6 +172,7 @@ function EstimateForm({ {/*------- Effects -------*/} +
diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx index e3bfabb9a..eba6eb5da 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeaderFields.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import styled from 'styled-components'; import classNames from 'classnames'; import { FormGroup, InputGroup, Position, Classes } from '@blueprintjs/core'; @@ -24,7 +23,6 @@ import { import { customersFieldShouldUpdate } from './utils'; import { CLASSES } from '@/constants/classes'; import { Features } from '@/constants'; - import { ProjectsSelect } from '@/containers/Projects/components'; import { EstimateExchangeRateInputField, @@ -32,12 +30,13 @@ import { } from './components'; import { EstimateFormEstimateNumberField } from './EstimateFormEstimateNumberField'; import { useEstimateFormContext } from './EstimateFormProvider'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Estimate form header. */ export default function EstimateFormHeader() { - const { customers, projects } = useEstimateFormContext(); + const { projects } = useEstimateFormContext(); return (
@@ -45,10 +44,8 @@ export default function EstimateFormHeader() { {/* ----------- Exchange Rate ----------- */} - + + {/* ----------- Estimate Date ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( @@ -151,6 +148,16 @@ function EstimateFormCustomerSelect() { const { setFieldValue, values } = useFormikContext(); const { customers } = useEstimateFormContext(); + const updateEntries = useCustomerUpdateExRate(); + + // Handles the customer item change. + const handleItemChange = (customer) => { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; + return ( } @@ -165,10 +172,7 @@ function EstimateFormCustomerSelect() { name={'customer_id'} items={customers} placeholder={} - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx index 6f02669db..0ca5d4e37 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleEstimate/PageForm.scss'; import EstimateForm from './EstimateForm'; import { EstimateFormProvider } from './EstimateFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Estimate form page. @@ -16,7 +17,9 @@ export default function EstimateFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx index 65a1c9cd2..3cace61f2 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/components.tsx @@ -1,24 +1,30 @@ // @ts-nocheck -import React, { useEffect } from 'react'; +import React, { useRef } from 'react'; import intl from 'react-intl-universal'; import { Button } from '@blueprintjs/core'; import * as R from 'ramda'; import { useFormikContext } from 'formik'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useEstimateIsForeignCustomer } from './utils'; -import withSettings from '@/containers/Settings/withSettings'; +import { useEstimateIsForeignCustomer, useEstimateTotals } from './utils'; import { transactionNumber } from '@/utils'; import { useUpdateEffect } from '@/hooks'; +import withSettings from '@/containers/Settings/withSettings'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimate exchange rate input field. * @returns {JSX.Element} */ -export function EstimateExchangeRateInputField({ ...props }) { +function EstimateExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const isForeignCustomer = useEstimateIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -27,13 +33,26 @@ export function EstimateExchangeRateInputField({ ...props }) { } return ( ); } +/** + * Renders the estimate exchange rate input field with exchange rate + * with item entries price re-calc once exchange rate change. + * @returns {JSX.Element} + */ +export const EstimateExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(EstimateExchangeRateInputFieldRoot); + /** * Estimate project select. * @returns {JSX.Element} @@ -72,3 +91,32 @@ export const EstimateIncrementSyncSettingsToForm = R.compose( return null; }); + +/** + * Syncs the auto exchange rate to the estimate form and shows up popup to user + * as an indication the entries rates have been changed. + * @returns {React.ReactNode} + */ +export const EstimateSyncAutoExRateToForm = R.compose(withDialogActions)( + ({ + // #withDialogActions + openDialog, + }) => { + const { total } = useEstimateTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // If the total bigger then zero show alert to the user after adjusting entries. + if (total > 0) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + + return null; + }, +); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx index 00a86b326..50771d913 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx @@ -22,7 +22,6 @@ function InvoiceExchangeRateChangeDialog({ return (

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

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

-

+

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

-
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx index 3de699979..e212c8b7d 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx @@ -70,6 +70,7 @@ export default function InvoiceFloatingActions() { history.goBack(); }; + // Handle clear button click. const handleClearBtnClick = (event) => { resetForm(); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx index 51ba306fa..17bd197e5 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.tsx @@ -23,10 +23,7 @@ import { handleDateChange, } from '@/utils'; import { CLASSES } from '@/constants/classes'; -import { - customerNameFieldShouldUpdate, - useInvoiceEntriesOnExchangeRateChange, -} from './utils'; +import { customerNameFieldShouldUpdate } from './utils'; import { useInvoiceFormContext } from './InvoiceFormProvider'; import { @@ -39,7 +36,7 @@ import { ProjectBillableEntriesLink, } from '@/containers/Projects/components'; import { Features } from '@/constants'; -import { useCurrentOrganization } from '@/hooks/state'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Invoice form header fields. @@ -55,10 +52,8 @@ export default function InvoiceFormHeaderFields() { {/* ----------- Exchange rate ----------- */} - + + {/* ----------- Invoice date ----------- */} @@ -166,27 +161,18 @@ export default function InvoiceFormHeaderFields() { */ function InvoiceFormCustomerSelect() { const { values, setFieldValue } = useFormikContext(); - const { customers, setAutoExRateCurrency } = useInvoiceFormContext(); - const currentComapny = useCurrentOrganization(); - const composeEntriesOnExChange = useInvoiceEntriesOnExchangeRateChange(); + const { customers } = useInvoiceFormContext(); + + const updateEntries = useCustomerUpdateExRate(); // Handles the customer item change. const handleItemChange = (customer) => { - setAutoExRateCurrency(null); - // If the customer id has changed change the customer id and currency code. if (values.customer_id !== customer.id) { setFieldValue('customer_id', customer.id); setFieldValue('currency_code', customer?.currency_code); } - // If the customer's currency code is the same the base currency. - if (customer?.currency_code === currentComapny.base_currency) { - setFieldValue('exchange_rate', '1'); - setFieldValue('entries', composeEntriesOnExChange(values.exchange_rate, 1)); - } else { - // Sets the currency code to fetch auto-exchange rate. - setAutoExRateCurrency(customer?.currency_code); - } + updateEntries(customer); }; return ( diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx index eba22308e..42190b42a 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleInvoice/PageForm.scss'; import InvoiceForm from './InvoiceForm'; import { InvoiceFormProvider } from './InvoiceFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Invoice form page. @@ -16,7 +17,9 @@ export default function InvoiceFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx index 1969abd61..fd1b3b0b2 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormProvider.tsx @@ -3,7 +3,7 @@ import React, { createContext, useState } from 'react'; import { isEmpty, pick } from 'lodash'; import { useLocation } from 'react-router-dom'; import { Features } from '@/constants'; -import { useCurrentOrganization, useFeatureCan } from '@/hooks/state'; +import { useFeatureCan } from '@/hooks/state'; import { DashboardInsider } from '@/components/Dashboard'; import { transformToEditForm, ITEMS_FILTER_ROLES_QUERY } from './utils'; import { @@ -16,7 +16,6 @@ import { useEditInvoice, useSettingsInvoices, useEstimate, - useExchangeRate, } from '@/hooks/query'; import { useProjects } from '@/containers/Projects/hooks'; import { useTaxRates } from '@/hooks/query/taxRates'; @@ -94,18 +93,6 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) { // Handle fetching settings. const { isLoading: isSettingsLoading } = useSettingsInvoices(); - const [autoExRateCurrency, setAutoExRateCurrency] = useState(''); - const currentOrganization = useCurrentOrganization(); - - // Retrieves the exchange rate. - const { data: autoExchangeRate, isLoading: isAutoExchangeRateLoading } = - useExchangeRate(autoExRateCurrency, currentOrganization.base_currency, { - enabled: Boolean(currentOrganization.base_currency && autoExRateCurrency), - refetchOnWindowFocus: false, - staleTime: Infinity, - cacheTime: Infinity, - }); - // Create and edit invoice mutations. const { mutateAsync: createInvoiceMutate } = useCreateInvoice(); const { mutateAsync: editInvoiceMutate } = useEditInvoice(); @@ -132,7 +119,6 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) { warehouses, projects, taxRates, - autoExchangeRate, isInvoiceLoading, isItemsLoading, @@ -149,10 +135,6 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) { editInvoiceMutate, setSubmitPayload, isNewMode, - - autoExRateCurrency, - setAutoExRateCurrency, - isAutoExchangeRateLoading, }; return ( diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx index e25b389d7..788cd2c5c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/components.tsx @@ -1,44 +1,22 @@ // @ts-nocheck -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import intl from 'react-intl-universal'; import * as R from 'ramda'; import { Button } from '@blueprintjs/core'; import { useFormikContext } from 'formik'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { - useInvoiceEntriesOnExchangeRateChange, - useInvoiceIsForeignCustomer, - useInvoiceTotal, -} from './utils'; +import { useInvoiceIsForeignCustomer, useInvoiceTotal } from './utils'; import withSettings from '@/containers/Settings/withSettings'; import { useUpdateEffect } from '@/hooks'; import { transactionNumber } from '@/utils'; -import { useInvoiceFormContext } from './InvoiceFormProvider'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; - -/** - * Re-calculate the item entries prices based on the old exchange rate. - * @param {InvoiceExchangeRateInputFieldRoot} Component - * @returns {JSX.Element} - */ -const withExchangeRateItemEntriesPriceRecalc = (Component) => (props) => { - const { setFieldValue } = useFormikContext(); - const composeChangeExRate = useInvoiceEntriesOnExchangeRateChange(); - - return ( - { - setFieldValue( - 'entries', - composeChangeExRate(oldExchangeRate, exchangeRate), - ); - }} - {...props} - /> - ); -}; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Invoice exchange rate input field. @@ -47,8 +25,6 @@ const withExchangeRateItemEntriesPriceRecalc = (Component) => (props) => { const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => { const currentOrganization = useCurrentOrganization(); const { values } = useFormikContext(); - const { isAutoExchangeRateLoading } = useInvoiceFormContext(); - const isForeignCustomer = useInvoiceIsForeignCustomer(); // Can't continue if the customer is not foreign. @@ -60,7 +36,8 @@ const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => { name={'exchange_rate'} fromCurrency={values.currency_code} toCurrency={currentOrganization.base_currency} - isLoading={isAutoExchangeRateLoading} + formGroupProps={{ label: ' ', inline: true }} + withPopoverRecalcConfirm {...props} /> ); @@ -71,6 +48,7 @@ const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => { * @returns {JSX.Element} */ export const InvoiceExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, withExchangeRateItemEntriesPriceRecalc, )(InvoiceExchangeRateInputFieldRoot); @@ -108,40 +86,26 @@ export const InvoiceNoSyncSettingsToForm = R.compose( }); /** - * Syncs the fetched real-time exchange rate to the form. - * @returns {JSX.Element} + * Syncs the realtime exchange rate to the invoice form and shows up popup to the user + * as an indication the entries rates have been re-calculated. + * @returns {React.ReactNode} */ export const InvoiceExchangeRateSync = R.compose(withDialogActions)( ({ openDialog }) => { - const { setFieldValue, values } = useFormikContext(); - const { autoExRateCurrency, autoExchangeRate } = useInvoiceFormContext(); - const composeEntriesOnExChange = useInvoiceEntriesOnExchangeRateChange(); - const total = useInvoiceTotal(); const timeout = useRef(); - // Sync the fetched real-time exchanage rate to the form. - useEffect(() => { - if (autoExchangeRate?.exchange_rate && autoExRateCurrency) { - setFieldValue('exchange_rate', autoExchangeRate?.exchange_rate + ''); - setFieldValue( - 'entries', - composeEntriesOnExChange( - values.exchange_rate, - autoExchangeRate?.exchange_rate, - ), - ); + useSyncExRateToForm({ + onSynced: () => { // If the total bigger then zero show alert to the user after adjusting entries. if (total > 0) { clearTimeout(timeout.current); timeout.current = setTimeout(() => { - openDialog(DialogsName.InvoiceExchangeRateChange); + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); }, 500); } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoExchangeRate?.exchange_rate, autoExRateCurrency]); - + }, + }); return null; }, ); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index f77bfce9d..8c32f017c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -398,85 +398,3 @@ export const useIsInvoiceTaxExclusive = () => { return values.inclusive_exclusive_tax === TaxType.Exclusive; }; - -/** - * Convert the given rate to the local currency. - * @param {number} rate - * @param {number} exchangeRate - * @returns {number} - */ -export const convertToForeignCurrency = ( - rate: number, - exchangeRate: number, -) => { - return rate * exchangeRate; -}; - -/** - * Converts the given rate to the base currency. - * @param {number} rate - * @param {number} exchangeRate - * @returns {number} - */ -export const covertToBaseCurrency = (rate: number, exchangeRate: number) => { - return rate / exchangeRate; -}; - -/** - * Reverts the given rate from the old exchange rate and covert it to the new - * currency based on the given new exchange rate. - * @param {number} rate - - * @param {number} oldExchangeRate - Old exchange rate. - * @param {number} newExchangeRate - New exchange rate. - * @returns {number} - */ -const revertAndConvertExchangeRate = ( - rate: number, - oldExchangeRate: number, - newExchangeRate: number, -) => { - const oldValue = convertToForeignCurrency(rate, oldExchangeRate); - const newValue = covertToBaseCurrency(oldValue, newExchangeRate); - - return round(newValue, 3); -}; - -/** - * Assign the new item entry rate after converting to the new exchange rate. - * @params {number} oldExchangeRate - - * @params {number} newExchangeRate - - * @params {IItemEntry} entries - - */ -const assignRateRevertAndCovertExchangeRate = R.curry( - (oldExchangeRate: number, newExchangeRate: number, entries: IItemEntry[]) => { - return entries.map((entry) => ({ - ...entry, - rate: revertAndConvertExchangeRate( - entry.rate, - oldExchangeRate, - newExchangeRate, - ), - })); - }, -); - -/** - * Compose invoice entries on exchange rate change. - * @returns {(oldExchangeRate: number, newExchangeRate: number) => IItemEntry[]} - */ -export const useInvoiceEntriesOnExchangeRateChange = () => { - const { - values: { entries }, - } = useFormikContext(); - - return React.useMemo(() => { - return R.curry((oldExchangeRate: number, newExchangeRate: number) => { - return R.compose( - // Updates entries total. - updateItemsEntriesTotal, - // Assign a new rate of the given new exchange rate from the old exchange rate. - assignRateRevertAndCovertExchangeRate(oldExchangeRate, newExchangeRate), - )(entries); - }); - }, [entries]); -}; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx index ca7dd26f6..62206bdbf 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx @@ -34,7 +34,7 @@ import { transformFormValuesToRequest, resetFormState, } from './utils'; -import { ReceiptSyncIncrementSettingsToForm } from './components'; +import { ReceiptSyncAutoExRateToForm, ReceiptSyncIncrementSettingsToForm } from './components'; /** * Receipt form. @@ -171,6 +171,7 @@ function ReceiptForm({ {/*---------- Effects ---------*/} +
diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx index 92ef297cb..1536c9785 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.tsx @@ -33,6 +33,7 @@ import { ReceiptProjectSelectButton, } from './components'; import { ReceiptFormReceiptNumberField } from './ReceiptFormReceiptNumberField'; +import { useCustomerUpdateExRate } from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; /** * Receipt form header fields. @@ -46,10 +47,7 @@ export default function ReceiptFormHeader() { {/* ----------- Exchange rate ----------- */} - + {/* ----------- Deposit account ----------- */} { + setFieldValue('customer_id', customer.id); + setFieldValue('currency_code', customer?.currency_code); + + updateEntries(customer); + }; + return ( } - onItemChange={(customer) => { - setFieldValue('customer_id', customer.id); - setFieldValue('currency_code', customer?.currency_code); - }} + onItemChange={handleItemChange} popoverFill={true} allowCreate={true} fastField={true} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx index dddd093ab..da66da72b 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormPage.tsx @@ -6,6 +6,7 @@ import '@/style/pages/SaleReceipt/PageForm.scss'; import ReceiptFrom from './ReceiptForm'; import { ReceiptFormProvider } from './ReceiptFormProvider'; +import { AutoExchangeRateProvider } from '@/containers/Entries/AutoExchangeProvider'; /** * Receipt form page. @@ -16,7 +17,9 @@ export default function ReceiptFormPage() { return ( - + + + ); } diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx index 937a4f937..7d47998e3 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/components.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useRef } from 'react'; import intl from 'react-intl-universal'; import { Button } from '@blueprintjs/core'; import { useFormikContext } from 'formik'; @@ -7,20 +7,26 @@ import * as R from 'ramda'; import { ExchangeRateInputGroup } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { useReceiptIsForeignCustomer } from './utils'; +import { useReceiptIsForeignCustomer, useReceiptTotals } from './utils'; import { useUpdateEffect } from '@/hooks'; -import withSettings from '@/containers/Settings/withSettings'; import { transactionNumber } from '@/utils'; +import withSettings from '@/containers/Settings/withSettings'; +import { + useSyncExRateToForm, + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +} from '@/containers/Entries/withExRateItemEntriesPriceRecalc'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** * Receipt exchange rate input field. * @returns {JSX.Element} */ -export function ReceiptExchangeRateInputField({ ...props }) { +function ReceiptExchangeRateInputFieldRoot({ ...props }) { const currentOrganization = useCurrentOrganization(); - const { values } = useFormikContext(); - const isForeignCustomer = useReceiptIsForeignCustomer(); + const { values } = useFormikContext(); // Can't continue if the customer is not foreign. if (!isForeignCustomer) { @@ -28,13 +34,21 @@ export function ReceiptExchangeRateInputField({ ...props }) { } return ( ); } +export const ReceiptExchangeRateInputField = R.compose( + withExchangeRateFetchingLoading, + withExchangeRateItemEntriesPriceRecalc, +)(ReceiptExchangeRateInputFieldRoot); + /** * Receipt project select. * @returns {JSX.Element} @@ -73,3 +87,31 @@ export const ReceiptSyncIncrementSettingsToForm = R.compose( return null; }); + +/** + * Syncs the auto exchange rate to the receipt form and shows up popup to user + * as an indication the entries rates have been changed. + * @returns {React.ReactNode} + */ +export const ReceiptSyncAutoExRateToForm = R.compose(withDialogActions)( + ({ + // #withDialogActions + openDialog, + }) => { + const { total } = useReceiptTotals(); + const timeout = useRef(); + + useSyncExRateToForm({ + onSynced: () => { + // If the total bigger then zero show alert to the user after adjusting entries. + if (total > 0) { + clearTimeout(timeout.current); + timeout.current = setTimeout(() => { + openDialog(DialogsName.InvoiceExchangeRateChangeNotice); + }, 500); + } + }, + }); + return null; + }, +); diff --git a/packages/webapp/src/hooks/query/exchangeRates.tsx b/packages/webapp/src/hooks/query/exchangeRates.tsx index b861d7c7d..f56958040 100644 --- a/packages/webapp/src/hooks/query/exchangeRates.tsx +++ b/packages/webapp/src/hooks/query/exchangeRates.tsx @@ -6,7 +6,9 @@ function getRandomItemFromArray(arr) { const randomIndex = Math.floor(Math.random() * arr.length); return arr[randomIndex]; } - +function delay(t, val) { + return new Promise((resolve) => setTimeout(resolve, t, val)); +} /** * Retrieves tax rates. * @param {number} customerId - Customer id. @@ -18,12 +20,15 @@ export function useExchangeRate( ) { return useQuery( [QUERY_TYPES.EXCHANGE_RATE, fromCurrency, toCurrency], - () => - Promise.resolve({ + async () => { + await delay(100); + + return { from_currency: fromCurrency, to_currency: toCurrency, - exchange_rate: getRandomItemFromArray([4.231, 2.231]), - }), + exchange_rate: 1.00, + }; + }, props, ); } From 1e4b29f83c8caba2ce6aec7f7fe8dd6e5d21796c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 14 Jan 2024 15:59:25 +0200 Subject: [PATCH 048/148] fix: content tweaks in rates re-calc popover --- .../ExchangeRate/ExchangeRateInput.tsx | 26 +++++++++++-------- .../InvoiceExchangeRateChangeDialog.tsx | 16 ++++-------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx index caf2f3d58..d96d91231 100644 --- a/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx +++ b/packages/webapp/src/components/ExchangeRate/ExchangeRateInput.tsx @@ -8,6 +8,7 @@ import { ControlGroup, Intent, Popover, + Position, Spinner, } from '@blueprintjs/core'; import { FlagIcon } from '../Tags'; @@ -106,15 +107,22 @@ export function ExchangeRateInputGroup({ const popoverConfirmContent = (

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

+ -
); @@ -144,7 +144,11 @@ export function ExchangeRateInputGroup({ {withPopoverRecalcConfirm ? ( - + {exchangeRateField} ) : ( diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx index 50771d913..2880ac383 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceExchangeRateChangeDialog.tsx @@ -10,9 +10,7 @@ import { Button, Classes, Intent } from '@blueprintjs/core'; */ function InvoiceExchangeRateChangeDialog({ dialogName, - payload: { initialFormValues }, isOpen, - onConfirm, // #withDialogActions closeDialog, }) { @@ -23,6 +21,7 @@ function InvoiceExchangeRateChangeDialog({ return (

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

- -

- The item rates have been adjusted to the new currency using exchange - rate feeds. + The item rates have been adjusted to the new + currency using realtime exchange rate.

- Before saving the transaction, ensure that the item rates align with - the current exchange rate of the newly selected currency. + Make sure to check that the item rates match the current exchange + rate of the newly selected currency before saving the transaction.

From c9f57d9a7527deb5d5f26a0bd0520eb724b411e7 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 15 Jan 2024 00:30:08 +0200 Subject: [PATCH 049/148] chore: update CHANGELOG.md file --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc0f93a3..ca8948cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Bigcapital server-side will be in this file. +## [0.13.1] - 15-01-2024 + +* feat(webapp): add approve/reject to action bar of estimate details dr… by @ANasouf in https://github.com/bigcapitalhq/bigcapital/pull/304 +* docs: add ANasouf as a contributor for code by @allcontributors in https://github.com/bigcapitalhq/bigcapital/pull/305 +* feat: Export general ledger & Journal to CSV and XLSX by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/303 +* feat: Auto re-calculate the items rate once changing the invoice exchange rate. by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/270 + ## [0.13.0] - 31-12-2023 * feat: Send an invoice mail the customer email by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/292 From 74fd76ce770297040bcd21a7eec4905a9d70a794 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 17 Jan 2024 00:24:13 +0200 Subject: [PATCH 050/148] feat(server): sales by items export csv & xlsx --- .../FinancialStatements/SalesByItems.ts | 28 ++++---- .../src/interfaces/SalesByItemsSheet.ts | 2 +- .../SalesByItems/SalesByItemsApplication.ts | 54 +++++++++++++++ .../SalesByItems/SalesByItemsExport.ts | 43 ++++++++++++ .../SalesByItems/SalesByItemsService.ts | 2 +- .../SalesByItems/SalesByItemsTable.ts | 65 +++++++++++++++++++ .../SalesByItemsTableInjectable.tsx | 33 ++++++++++ 7 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.tsx diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts index d31954398..0952610f2 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts @@ -7,6 +7,7 @@ import BaseFinancialReportController from './BaseFinancialReportController'; import SalesByItemsReportService from '@/services/FinancialStatements/SalesByItems/SalesByItemsService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { ACCEPT_TYPE } from '@/interfaces/Http'; @Service() export default class SalesByItemsReportController extends BaseFinancialReportController { @@ -68,18 +69,21 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon const { tenantId } = req; const filter = this.matchedQueryData(req); - try { - const { data, query, meta } = await this.salesByItemsService.salesByItems( - tenantId, - filter - ); - return res.status(200).send({ - meta: this.transfromToResponse(meta), - data: this.transfromToResponse(data), - query: this.transfromToResponse(query), - }); - } catch (error) { - next(error); + const accept = this.accepts(req); + + const acceptType = accept.types([ + ACCEPT_TYPE.APPLICATION_JSON, + ACCEPT_TYPE.APPLICATION_JSON_TABLE, + ACCEPT_TYPE.APPLICATION_CSV, + ACCEPT_TYPE.APPLICATION_XLSX, + ]); + + if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + } else { + await this.salesByItemsService.salesByItems(tenantId, filter); + return res.status(200).send({ meta, data, query }); } } } diff --git a/packages/server/src/interfaces/SalesByItemsSheet.ts b/packages/server/src/interfaces/SalesByItemsSheet.ts index 0e0a41f9e..2e253c49d 100644 --- a/packages/server/src/interfaces/SalesByItemsSheet.ts +++ b/packages/server/src/interfaces/SalesByItemsSheet.ts @@ -41,5 +41,5 @@ export interface ISalesByItemsTotal { export type ISalesByItemsSheetStatement = { items: ISalesByItemsItem[], total: ISalesByItemsTotal -} | {}; +}; diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts new file mode 100644 index 000000000..6fbd97459 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts @@ -0,0 +1,54 @@ +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesByItemsReportService } from './SalesByItemsService'; +import { SalesByItemsTableInjectable } from './SalesByItemsTableInjectable'; +import { SalesByItemsExport } from './SalesByItemsExport'; +import { Inject, Service } from 'typedi'; + +@Service() +export class SalesByItemsApplication { + @Inject() + private salesByItemsSheet: SalesByItemsReportService; + + @Inject() + private salesByItemsTable: SalesByItemsTableInjectable; + + @Inject() + private salesByItemsExport: SalesByItemsExport; + + /** + * Retrieves the sales by items report in json format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public sheet(tenantId: number, filter: ISalesByItemsReportQuery) { + return this.salesByItemsSheet.salesByItems(tenantId, filter); + } + /** + * Retrieves the sales by items report in table format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public table(tenantId: number, filter: ISalesByItemsReportQuery) { + return this.salesByItemsTable.table(tenantId, filter); + } + /** + * Retrieves the sales by items report in csv format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public csv(tenantId: number, filter: ISalesByItemsReportQuery) { + return this.salesByItemsExport.csv(tenantId, filter); + } + /** + * Retrieves the sales by items report in xlsx format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public xlsx(tenantId: number, filter: ISalesByItemsReportQuery) { + return this.salesByItemsExport.xlsx(tenantId, filter); + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts new file mode 100644 index 000000000..067aab546 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsExport.ts @@ -0,0 +1,43 @@ +import { Inject, Service } from 'typedi'; +import { TableSheet } from '@/lib/Xlsx/TableSheet'; +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { SalesByItemsTableInjectable } from './SalesByItemsTableInjectable'; + +@Service() +export class SalesByItemsExport { + @Inject() + private salesByItemsTable: SalesByItemsTableInjectable; + + /** + * Retrieves the trial balance sheet in XLSX format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} query + * @returns {Promise} + */ + public async xlsx(tenantId: number, query: ISalesByItemsReportQuery) { + const table = await this.salesByItemsTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToXLSX(); + + return tableSheet.convertToBuffer(tableCsv, 'xlsx'); + } + + /** + * Retrieves the trial balance sheet in CSV format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} query + * @returns {Promise} + */ + public async csv( + tenantId: number, + query: ISalesByItemsReportQuery + ): Promise { + const table = await this.salesByItemsTable.table(tenantId, query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts index 6f81e1489..086284d8e 100644 --- a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts @@ -10,7 +10,7 @@ import SalesByItems from './SalesByItems'; import { Tenant } from '@/system/models'; @Service() -export default class SalesByItemsReportService { +export class SalesByItemsReportService { @Inject() tenancy: TenancyService; diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts new file mode 100644 index 000000000..dad7126dc --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts @@ -0,0 +1,65 @@ +import * as R from 'ramda'; +import { ISalesByItemsSheetStatement, ITableColumn, ITableData, ITableRow } from '@/interfaces'; +import { tableRowMapper } from '@/utils'; + +export class SalesByItemsTable { + private readonly data: ISalesByItemsSheetStatement; + + constructor(data: ISalesByItemsSheetStatement) { + this.data = data; + } + + private commonTableAccessors() { + return [ + { key: 'item_name', accessor: 'name' }, + { key: 'quantity', accessor: 'quantitySoldFormatted' }, + { key: 'sold', accessor: 'soldCostFormatted' }, + ]; + } + + private itemMap(item: any) { + const columns = this.commonTableAccessors(); + const meta = {}; + + return tableRowMapper(item, columns, meta); + } + + private itemsMap(items: any[]) { + return R.map(this.itemMap, items); + } + + /** + * + * @param total + * @returns + */ + private totalMap(total: any) { + const columns = this.commonTableAccessors(); + const meta = {}; + + return tableRowMapper(total, columns, meta); + } + + /** + * + * @returns {ITableRow[]} + */ + public tableData(): ITableRow[] { + const itemsRows = this.itemsMap(this.data.items); + const totalRow = this.totalMap(this.data.total); + + return [...itemsRows, totalRow]; + } + + /** + * Retrieves the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + return [ + { key: 'item_name', label: 'Item Name' }, + { key: 'quantity', label: 'Quantity' }, + { key: 'sold_cost', label: 'Sold Cost' }, + ]; + } +} diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.tsx b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.tsx new file mode 100644 index 000000000..037ca7073 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTableInjectable.tsx @@ -0,0 +1,33 @@ +import { ISalesByItemsReportQuery } from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import { SalesByItemsReportService } from './SalesByItemsService'; +import { SalesByItemsTable } from './SalesByItemsTable'; + +@Service() +export class SalesByItemsTableInjectable { + @Inject() + salesByItemSheet: SalesByItemsReportService; + + /** + * Retrieves the sales by items report in table format. + * @param {number} tenantId + * @param {ISalesByItemsReportQuery} filter + * @returns {Promise} + */ + public async table(tenantId: number, filter: ISalesByItemsReportQuery) { + const { data, query, meta } = await this.salesByItemSheet.salesByItems( + tenantId, + filter + ); + const table = new SalesByItemsTable(data); + + return { + table: { + columns: table.tableColumns(), + rows: table.tableData(), + }, + meta, + query, + }; + } +} From 4a920176f42624e59443fde32142b6451ca65d7a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 17 Jan 2024 18:49:38 +0200 Subject: [PATCH 051/148] feat(server): sales by items table --- .../FinancialStatements/SalesByItems.ts | 37 ++++++++--- .../SalesByItems/SalesByItemsApplication.ts | 3 + .../SalesByItems/SalesByItemsTable.ts | 62 +++++++++++++------ 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts index 0952610f2..ffc0cb959 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesByItems.ts @@ -1,18 +1,17 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { query, ValidationChain } from 'express-validator'; -import moment from 'moment'; +import { query, ValidationChain, ValidationSchema } from 'express-validator'; import { Inject, Service } from 'typedi'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseFinancialReportController from './BaseFinancialReportController'; -import SalesByItemsReportService from '@/services/FinancialStatements/SalesByItems/SalesByItemsService'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { ACCEPT_TYPE } from '@/interfaces/Http'; +import { SalesByItemsApplication } from '@/services/FinancialStatements/SalesByItems/SalesByItemsApplication'; @Service() export default class SalesByItemsReportController extends BaseFinancialReportController { @Inject() - salesByItemsService: SalesByItemsReportService; + salesByItemsApp: SalesByItemsApplication; /** * Router constructor. @@ -32,6 +31,7 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon /** * Validation schema. + * @returns {ValidationChain[]} */ private get validationSchema(): ValidationChain[] { return [ @@ -68,7 +68,6 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon ) { const { tenantId } = req; const filter = this.matchedQueryData(req); - const accept = this.accepts(req); const acceptType = accept.types([ @@ -77,13 +76,33 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon ACCEPT_TYPE.APPLICATION_CSV, ACCEPT_TYPE.APPLICATION_XLSX, ]); - + // Retrieves the csv format. if (ACCEPT_TYPE.APPLICATION_CSV === acceptType) { + const buffer = await this.salesByItemsApp.csv(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(buffer); + // Retrieves the json table format. + } else if (ACCEPT_TYPE.APPLICATION_JSON_TABLE === acceptType) { + const table = await this.salesByItemsApp.table(tenantId, filter); + + return res.status(200).send(table); + // Retrieves the xlsx format. } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { - } else if (ACCEPT_TYPE.APPLICATION_XLSX === acceptType) { + const buffer = this.salesByItemsApp.xlsx(tenantId, filter); + + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ); + return res.send(buffer); + // Retrieves the json format. } else { - await this.salesByItemsService.salesByItems(tenantId, filter); - return res.status(200).send({ meta, data, query }); + const sheet = await this.salesByItemsApp.sheet(tenantId, filter); + return res.status(200).send(sheet); } } } diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts index 6fbd97459..d70cd703a 100644 --- a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsApplication.ts @@ -24,6 +24,7 @@ export class SalesByItemsApplication { public sheet(tenantId: number, filter: ISalesByItemsReportQuery) { return this.salesByItemsSheet.salesByItems(tenantId, filter); } + /** * Retrieves the sales by items report in table format. * @param {number} tenantId @@ -33,6 +34,7 @@ export class SalesByItemsApplication { public table(tenantId: number, filter: ISalesByItemsReportQuery) { return this.salesByItemsTable.table(tenantId, filter); } + /** * Retrieves the sales by items report in csv format. * @param {number} tenantId @@ -42,6 +44,7 @@ export class SalesByItemsApplication { public csv(tenantId: number, filter: ISalesByItemsReportQuery) { return this.salesByItemsExport.csv(tenantId, filter); } + /** * Retrieves the sales by items report in xlsx format. * @param {number} tenantId diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts index dad7126dc..33c39ccd6 100644 --- a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts @@ -1,47 +1,72 @@ import * as R from 'ramda'; -import { ISalesByItemsSheetStatement, ITableColumn, ITableData, ITableRow } from '@/interfaces'; +import { + ISalesByItemsItem, + ISalesByItemsSheetStatement, + ISalesByItemsTotal, + ITableColumn, + ITableRow, +} from '@/interfaces'; import { tableRowMapper } from '@/utils'; export class SalesByItemsTable { private readonly data: ISalesByItemsSheetStatement; + /** + * Constructor method. + * @param {ISalesByItemsSheetStatement} data + */ constructor(data: ISalesByItemsSheetStatement) { this.data = data; } + /** + * Retrieves the common table accessors. + * @returns {ITableColumn[]} + */ private commonTableAccessors() { return [ { key: 'item_name', accessor: 'name' }, - { key: 'quantity', accessor: 'quantitySoldFormatted' }, - { key: 'sold', accessor: 'soldCostFormatted' }, + { key: 'sold_quantity', accessor: 'quantitySoldFormatted' }, + { key: 'sold_amount', accessor: 'soldCostFormatted' }, + { key: 'average_price', accessor: 'averageSellPriceFormatted' }, ]; } - private itemMap(item: any) { + /** + * Maps the given item node to table row. + * @param {ISalesByItemsItem} item + * @returns {ITableRow} + */ + private itemMap = (item: ISalesByItemsItem): ITableRow => { const columns = this.commonTableAccessors(); const meta = {}; return tableRowMapper(item, columns, meta); - } - - private itemsMap(items: any[]) { - return R.map(this.itemMap, items); - } + }; /** - * - * @param total - * @returns + * Maps the given items nodes to table rows. + * @param {ISalesByItemsItem[]} items + * @returns {ITableRow[]} */ - private totalMap(total: any) { + private itemsMap = (items: ISalesByItemsItem[]): ITableRow[] => { + return R.map(this.itemMap, items); + }; + + /** + * Maps the given total node to table row. + * @param {ISalesByItemsTotal} total + * @returns {ITableRow[]} + */ + private totalMap = (total: ISalesByItemsTotal) => { const columns = this.commonTableAccessors(); const meta = {}; return tableRowMapper(total, columns, meta); - } + }; /** - * + * Retrieves the table rows. * @returns {ITableRow[]} */ public tableData(): ITableRow[] { @@ -57,9 +82,10 @@ export class SalesByItemsTable { */ public tableColumns(): ITableColumn[] { return [ - { key: 'item_name', label: 'Item Name' }, - { key: 'quantity', label: 'Quantity' }, - { key: 'sold_cost', label: 'Sold Cost' }, + { key: 'item_name', label: 'Item name' }, + { key: 'sold_quantity', label: 'Sold quantity' }, + { key: 'sold_amount', label: 'Sold amount' }, + { key: 'average_price', label: 'Average price' }, ]; } } From 471ce1b7af15ddd9d3e7169006e050ad229f3e9d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 18 Jan 2024 14:39:56 +0200 Subject: [PATCH 052/148] feat(webapp): dynamic columns of sales by items sheet --- .../SalesByItems/SalesByItemsTable.ts | 23 +++-- .../SalesByItems/constants.ts | 6 ++ .../SalesByItems/SalesByItemProvider.tsx | 6 +- .../SalesByItems/SalesByItemsActionsBar.tsx | 18 +++- .../SalesByItems/SalesByItemsTable.tsx | 8 +- .../SalesByItems/components.tsx | 90 ++++++++++++++++- .../SalesByItems/dynamicColumns.ts | 77 +++++++++++++++ .../src/hooks/query/financialReports.tsx | 99 +++++++++++++------ 8 files changed, 277 insertions(+), 50 deletions(-) create mode 100644 packages/server/src/services/FinancialStatements/SalesByItems/constants.ts create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesByItems/dynamicColumns.ts diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts index 33c39ccd6..f876e9a02 100644 --- a/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts +++ b/packages/server/src/services/FinancialStatements/SalesByItems/SalesByItemsTable.ts @@ -7,8 +7,15 @@ import { ITableRow, } from '@/interfaces'; import { tableRowMapper } from '@/utils'; +import FinancialSheet from '../FinancialSheet'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import { ROW_TYPE } from './constants'; -export class SalesByItemsTable { +export class SalesByItemsTable extends R.compose( + FinancialTable, + FinancialSheetStructure +)(FinancialSheet) { private readonly data: ISalesByItemsSheetStatement; /** @@ -16,6 +23,7 @@ export class SalesByItemsTable { * @param {ISalesByItemsSheetStatement} data */ constructor(data: ISalesByItemsSheetStatement) { + super(); this.data = data; } @@ -39,8 +47,9 @@ export class SalesByItemsTable { */ private itemMap = (item: ISalesByItemsItem): ITableRow => { const columns = this.commonTableAccessors(); - const meta = {}; - + const meta = { + rowTypes: [ROW_TYPE.ITEM], + }; return tableRowMapper(item, columns, meta); }; @@ -60,8 +69,9 @@ export class SalesByItemsTable { */ private totalMap = (total: ISalesByItemsTotal) => { const columns = this.commonTableAccessors(); - const meta = {}; - + const meta = { + rowTypes: [ROW_TYPE.TOTAL], + }; return tableRowMapper(total, columns, meta); }; @@ -81,11 +91,12 @@ export class SalesByItemsTable { * @returns {ITableColumn[]} */ public tableColumns(): ITableColumn[] { - return [ + const columns = [ { key: 'item_name', label: 'Item name' }, { key: 'sold_quantity', label: 'Sold quantity' }, { key: 'sold_amount', label: 'Sold amount' }, { key: 'average_price', label: 'Average price' }, ]; + return R.compose(this.tableColumnsCellIndexing)(columns); } } diff --git a/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts b/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts new file mode 100644 index 000000000..0eb1e2311 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesByItems/constants.ts @@ -0,0 +1,6 @@ + + +export enum ROW_TYPE { + ITEM = 'ITEM', + TOTAL = 'TOTAL', +} \ No newline at end of file diff --git a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx index 8b06996dc..16c2ad44f 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.tsx @@ -1,7 +1,7 @@ // @ts-nocheck -import React, { createContext, useContext } from 'react'; +import { createContext, useContext } from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useSalesByItems } from '@/hooks/query'; +import { useSalesByItemsTable } from '@/hooks/query'; import { transformFilterFormToQuery } from '../common'; const SalesByItemsContext = createContext(); @@ -12,7 +12,7 @@ function SalesByItemProvider({ query, ...props }) { isFetching, isLoading, refetch, - } = useSalesByItems( + } = useSalesByItemsTable( { ...transformFilterFormToQuery(query), }, diff --git a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx index 56b025f35..9ac5a46a0 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesByItems/SalesByItemsActionsBar.tsx @@ -19,6 +19,7 @@ import withSalesByItemsActions from './withSalesByItemsActions'; import { compose, saveInvoke } from '@/utils'; import { useSalesByItemsContext } from './SalesByItemProvider'; +import { SalesByItemsSheetExportMenu } from './components'; function SalesByItemsActionsBar({ // #withSalesByItems @@ -108,11 +109,18 @@ function SalesByItemsActionsBar({ icon={} text={} /> -
diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx index d68afb4c5..65b05a9c1 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBoot.tsx @@ -6,12 +6,14 @@ import { DialogContent } from '@/components'; interface EstimateMailDialogBootValues { estimateId: number; mailOptions: any; + redirectToEstimatesList: boolean; } const EstimateMailDialagBoot = createContext(); interface EstimateMailDialogBootProps { estimateId: number; + redirectToEstimatesList?: boolean; children: React.ReactNode; } @@ -20,6 +22,7 @@ interface EstimateMailDialogBootProps { */ function EstimateMailDialogBoot({ estimateId, + redirectToEstimatesList, ...props }: EstimateMailDialogBootProps) { const { data: mailOptions, isLoading: isMailOptionsLoading } = @@ -29,6 +32,7 @@ function EstimateMailDialogBoot({ saleEstimateId: estimateId, mailOptions, isMailOptionsLoading, + redirectToEstimatesList, }; return ( diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx index ad67bb048..3eb0af76d 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx @@ -4,14 +4,19 @@ import { EstimateMailDialogForm } from './EstimateMailDialogForm'; interface EstimateMailDialogContentProps { dialogName: string; estimateId: number; + redirectToEstimatesList?: boolean; } export default function EstimateMailDialogContent({ dialogName, estimateId, + redirectToEstimatesList, }: EstimateMailDialogContentProps) { return ( - - + + - ) + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx index f8811cdbb..4c6e7e943 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx @@ -1,6 +1,8 @@ // @ts-nocheck import { Formik } from 'formik'; import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; import { useEstimateMailDialogBoot } from './EstimateMailDialogBoot'; import { DialogsName } from '@/constants/dialogs'; import withDialogActions from '@/containers/Dialog/withDialogActions'; @@ -12,7 +14,6 @@ import { transformMailFormToInitialValues, transformMailFormToRequest, } from '@/containers/SendMailNotification/utils'; -import { Intent } from '@blueprintjs/core'; import { AppToaster } from '@/components'; const initialFormValues = { @@ -29,7 +30,10 @@ function EstimateMailDialogFormRoot({ closeDialog, }) { const { mutateAsync: sendEstimateMail } = useSendSaleEstimateMail(); - const { mailOptions, saleEstimateId } = useEstimateMailDialogBoot(); + const { mailOptions, saleEstimateId, redirectToEstimatesList } = + useEstimateMailDialogBoot(); + + const history = useHistory(); const initialValues = transformMailFormToInitialValues( mailOptions, @@ -48,8 +52,12 @@ function EstimateMailDialogFormRoot({ }); closeDialog(DialogsName.EstimateMail); setSubmitting(false); + + if (redirectToEstimatesList) { + history.push('/estimates'); + } }) - .catch((error) => { + .catch(() => { setSubmitting(false); closeDialog(DialogsName.EstimateMail); AppToaster.show({ diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx index 63430ce10..1b385b83f 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx @@ -13,7 +13,12 @@ const InvoiceMailDialogContent = React.lazy( */ function InvoiceMailDialog({ dialogName, - payload: { invoiceId = null }, + payload: { + invoiceId = null, + + // Redirects to the invoices list. + redirectToInvoicesList = false, + }, isOpen, }) { return ( @@ -29,6 +34,7 @@ function InvoiceMailDialog({
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx index ae16a0cf2..8c7d5f7e2 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBoot.tsx @@ -6,12 +6,14 @@ import { DialogContent } from '@/components'; interface InvoiceMailDialogBootValues { invoiceId: number; mailOptions: any; + redirectToInvoicesList: boolean; } const InvoiceMailDialagBoot = createContext(); interface InvoiceMailDialogBootProps { invoiceId: number; + redirectToInvoicesList?: boolean; children: React.ReactNode; } @@ -20,6 +22,7 @@ interface InvoiceMailDialogBootProps { */ function InvoiceMailDialogBoot({ invoiceId, + redirectToInvoicesList, ...props }: InvoiceMailDialogBootProps) { const { data: mailOptions, isLoading: isMailOptionsLoading } = @@ -29,6 +32,7 @@ function InvoiceMailDialogBoot({ saleInvoiceId: invoiceId, mailOptions, isMailOptionsLoading, + redirectToInvoicesList, }; return ( diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx index 37f3f091f..769a722b6 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx @@ -4,13 +4,20 @@ import { InvoiceMailDialogForm } from './InvoiceMailDialogForm'; interface InvoiceMailDialogContentProps { dialogName: string; invoiceId: number; + + // Redirect to invoices list after submitting the message. + redirectToInvoicesList?: boolean; } export default function InvoiceMailDialogContent({ dialogName, invoiceId, + redirectToInvoicesList, }: InvoiceMailDialogContentProps) { return ( - + ); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx index 794ed890d..697e8e9a1 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx @@ -15,6 +15,7 @@ import { transformMailFormToRequest, transformMailFormToInitialValues, } from '@/containers/SendMailNotification/utils'; +import { useHistory } from 'react-router-dom'; const initialFormValues = { ...initialMailNotificationValues, @@ -29,7 +30,9 @@ function InvoiceMailDialogFormRoot({ // #withDialogActions closeDialog, }) { - const { mailOptions, saleInvoiceId } = useInvoiceMailDialogBoot(); + const history = useHistory(); + const { mailOptions, saleInvoiceId, redirectToInvoicesList } = + useInvoiceMailDialogBoot(); const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail(); const initialValues = transformMailFormToInitialValues( @@ -49,6 +52,11 @@ function InvoiceMailDialogFormRoot({ }); closeDialog(DialogsName.InvoiceMail); setSubmitting(false); + + // Redirect to the dashboard if the option was enabled. + if (redirectToInvoicesList) { + history.push('/invoices'); + } }) .catch(() => { AppToaster.show({ diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx index 6da51d03e..32c175ed9 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog.tsx @@ -13,7 +13,12 @@ const PaymentMailDialogContent = React.lazy( */ function PaymentMailDialog({ dialogName, - payload: { paymentReceiveId = null }, + payload: { + paymentReceiveId = null, + + // Redirects to the payments list on mail submitting. + redirectToPaymentsList = false, + }, isOpen, }) { return ( @@ -29,6 +34,7 @@ function PaymentMailDialog({ diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx index aa08bd2e1..5fcbd4afa 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogBoot.tsx @@ -13,6 +13,7 @@ const PaymentMailDialogBootContext = interface PaymentMailDialogBootProps { paymentReceiveId: number; + redirectToPaymentsList: boolean; children: React.ReactNode; } @@ -29,7 +30,8 @@ function PaymentMailDialogBoot({ const provider = { mailOptions, isMailOptionsLoading, - paymentReceiveId + paymentReceiveId, + redirectToPaymentsList }; return ( diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx index 12fa57e05..33597cfa9 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogContent.tsx @@ -4,13 +4,18 @@ import { PaymentMailDialogForm } from './PaymentMailDialogForm'; interface PaymentMailDialogContentProps { dialogName: string; paymentReceiveId: number; + redirectToPaymentsList: boolean; } export default function PaymentMailDialogContent({ dialogName, paymentReceiveId, + redirectToPaymentsList, }: PaymentMailDialogContentProps) { return ( - + ); diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx index bf0aa578b..f397a8740 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialogForm.tsx @@ -3,7 +3,6 @@ import { Formik, FormikBag } from 'formik'; import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; import { usePaymentMailDialogBoot } from './PaymentMailDialogBoot'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; import { useSendPaymentReceiveMail } from '@/hooks/query'; import { PaymentMailDialogFormContent } from './PaymentMailDialogFormContent'; @@ -14,6 +13,8 @@ import { transformMailFormToInitialValues, } from '@/containers/SendMailNotification/utils'; import { AppToaster } from '@/components'; +import { useHistory } from 'react-router-dom'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; const initialFormValues = { ...initialMailNotificationValues, @@ -28,9 +29,12 @@ export function PaymentMailDialogFormRoot({ // #withDialogActions closeDialog, }) { - const { mailOptions, paymentReceiveId } = usePaymentMailDialogBoot(); + const { mailOptions, paymentReceiveId, redirectToPaymentsList } = + usePaymentMailDialogBoot(); const { mutateAsync: sendPaymentMail } = useSendPaymentReceiveMail(); + const history = useHistory(); + const initialValues = transformMailFormToInitialValues( mailOptions, initialFormValues, @@ -51,6 +55,11 @@ export function PaymentMailDialogFormRoot({ }); setSubmitting(false); closeDialog(DialogsName.PaymentMail); + + // Redirects to payments list if the option is enabled. + if (redirectToPaymentsList) { + history.push('/payment-receives'); + } }) .catch(() => { AppToaster.show({ diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx index a64ad5531..eb68d7d37 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx @@ -13,7 +13,12 @@ const ReceiptMailDialogContent = React.lazy( */ function ReceiptMailDialog({ dialogName, - payload: { receiptId = null }, + payload: { + receiptId = null, + + // Redirects to receipts list after mail submitting. + redirectToReceiptsList = false, + }, isOpen, }) { return ( @@ -29,6 +34,7 @@ function ReceiptMailDialog({ diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx index 09eeb55f1..54f7200db 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBoot.tsx @@ -6,6 +6,7 @@ import { DialogContent } from '@/components'; interface ReceiptMailDialogBootValues { receiptId: number; mailOptions: any; + redirectToReceiptsList: boolean; } const ReceiptMailDialogBootContext = @@ -14,6 +15,7 @@ const ReceiptMailDialogBootContext = interface ReceiptMailDialogBootProps { receiptId: number; children: React.ReactNode; + redirectToReceiptsList?: boolean; } /** @@ -21,6 +23,7 @@ interface ReceiptMailDialogBootProps { */ function ReceiptMailDialogBoot({ receiptId, + redirectToReceiptsList = false, ...props }: ReceiptMailDialogBootProps) { const { data: mailOptions, isLoading: isMailOptionsLoading } = @@ -30,6 +33,7 @@ function ReceiptMailDialogBoot({ saleReceiptId: receiptId, mailOptions, isMailOptionsLoading, + redirectToReceiptsList, }; return ( diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx index 955620f86..586de745c 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx @@ -3,15 +3,20 @@ import { ReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; import { ReceiptMailDialogForm } from './ReceiptMailDialogForm'; interface ReceiptMailDialogContentProps { - dialogName: string + dialogName: string; receiptId: number; + redirectToReceiptsList?: boolean; } export default function ReceiptMailDialogContent({ dialogName, receiptId, + redirectToReceiptsList = false, }: ReceiptMailDialogContentProps) { return ( - + ); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx index fb9b845af..d46ea7eb0 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx @@ -2,6 +2,7 @@ import { Formik, FormikBag } from 'formik'; import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; +import { useHistory } from 'react-router-dom'; import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; @@ -24,9 +25,12 @@ interface ReceiptMailFormValues extends MailNotificationFormValues { } function ReceiptMailDialogFormRoot({ closeDialog }) { - const { mailOptions, saleReceiptId } = useReceiptMailDialogBoot(); + const { mailOptions, saleReceiptId, redirectToReceiptsList } = + useReceiptMailDialogBoot(); const { mutateAsync: sendReceiptMail } = useSendSaleReceiptMail(); + const history = useHistory(); + // Transformes mail options to initial form values. const initialValues = transformMailFormToInitialValues( mailOptions, @@ -48,6 +52,10 @@ function ReceiptMailDialogFormRoot({ closeDialog }) { }); closeDialog(DialogsName.ReceiptMail); setSubmitting(false); + + if (redirectToReceiptsList) { + history.push('/receipts'); + } }) .catch(() => { AppToaster.show({ From 760dbc6cfc7d890f99c08a73cf76cb5e72801db1 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 25 Jan 2024 21:52:07 +0200 Subject: [PATCH 071/148] feat(server): change estimate and receipts status once delivering mail --- .../server/src/interfaces/PaymentReceive.ts | 6 +++ .../server/src/interfaces/SaleEstimate.ts | 8 +++- packages/server/src/interfaces/SaleReceipt.ts | 6 +++ packages/server/src/loaders/eventEmitter.ts | 9 +++- .../Accounts/AccountTransactionTransformer.ts | 1 - .../src/services/Accounts/AccountTransform.ts | 2 +- .../src/services/Accounts/ActivateAccount.ts | 4 -- .../Sales/Estimates/SendSaleEstimateMail.ts | 15 ++++++- .../SaleEstimateMarkApprovedOnMailSent.ts | 43 +++++++++++++++++++ .../PaymentReceiveMailNotification.ts | 13 ++++++ .../Receipts/SaleReceiptMailNotification.ts | 22 ++++++++-- .../SaleReceiptCostGLEntriesSubscriber.ts | 2 +- ...aleReceiptMarkClosedOnMailSentSubcriber.ts | 41 ++++++++++++++++++ packages/server/src/subscribers/events.ts | 12 ++++++ 14 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts create mode 100644 packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber.ts diff --git a/packages/server/src/interfaces/PaymentReceive.ts b/packages/server/src/interfaces/PaymentReceive.ts index 2926d923c..329d0d944 100644 --- a/packages/server/src/interfaces/PaymentReceive.ts +++ b/packages/server/src/interfaces/PaymentReceive.ts @@ -173,3 +173,9 @@ export type IPaymentReceiveGLCommonEntry = Pick< export interface PaymentReceiveMailOpts extends CommonMailOptions {} export interface PaymentReceiveMailOptsDTO extends CommonMailOptionsDTO {} + +export interface PaymentReceiveMailPresendEvent { + tenantId: number; + paymentReceiveId: number; + messageOptions: PaymentReceiveMailOptsDTO; +} diff --git a/packages/server/src/interfaces/SaleEstimate.ts b/packages/server/src/interfaces/SaleEstimate.ts index 171c8a0d1..9ac17295c 100644 --- a/packages/server/src/interfaces/SaleEstimate.ts +++ b/packages/server/src/interfaces/SaleEstimate.ts @@ -132,4 +132,10 @@ export interface SaleEstimateMailOptions extends CommonMailOptions { export interface SaleEstimateMailOptionsDTO extends CommonMailOptionsDTO { attachEstimate?: boolean; -} \ No newline at end of file +} + +export interface ISaleEstimateMailPresendEvent { + tenantId: number; + saleEstimateId: number; + messageOptions: SaleEstimateMailOptionsDTO; +} diff --git a/packages/server/src/interfaces/SaleReceipt.ts b/packages/server/src/interfaces/SaleReceipt.ts index 1e8ffa98e..8904767c6 100644 --- a/packages/server/src/interfaces/SaleReceipt.ts +++ b/packages/server/src/interfaces/SaleReceipt.ts @@ -143,3 +143,9 @@ export interface SaleReceiptMailOpts extends CommonMailOptions { export interface SaleReceiptMailOptsDTO extends CommonMailOptionsDTO { attachReceipt?: boolean; } + +export interface ISaleReceiptMailPresend { + tenantId: number; + saleReceiptId: number; + messageOptions: SaleReceiptMailOptsDTO; +} diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 5e052f376..5d3269646 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -85,6 +85,8 @@ import { BillTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/B import { WriteBillTaxTransactionsSubscriber } from '@/services/TaxRates/subscribers/WriteBillTaxTransactionsSubscriber'; import { SyncItemTaxRateOnEditTaxSubscriber } from '@/services/TaxRates/SyncItemTaxRateOnEditTaxSubscriber'; import { InvoiceChangeStatusOnMailSentSubscriber } from '@/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber'; +import { SaleReceiptMarkClosedOnMailSentSubcriber } from '@/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber'; +import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent'; export default () => { return new EventPublisher(); @@ -105,8 +107,12 @@ export const susbcribers = () => { InventorySubscriber, CustomerWriteGLOpeningBalanceSubscriber, VendorsWriteGLOpeningSubscriber, + + // # Estimate SaleEstimateAutoSerialSubscriber, SaleEstimateSmsNotificationSubscriber, + SaleEstimateMarkApprovedOnMailSent, + ExpensesWriteGLSubscriber, SaleReceiptAutoSerialSubscriber, SaleInvoiceAutoIncrementSubscriber, @@ -159,6 +165,7 @@ export const susbcribers = () => { // # Receipts SaleReceiptCostGLEntriesSubscriber, + SaleReceiptMarkClosedOnMailSentSubcriber, // Transaction locking. SalesTransactionLockingGuardSubscriber, @@ -201,6 +208,6 @@ export const susbcribers = () => { BillTaxRateValidateSubscriber, WriteBillTaxTransactionsSubscriber, - SyncItemTaxRateOnEditTaxSubscriber + SyncItemTaxRateOnEditTaxSubscriber, ]; }; diff --git a/packages/server/src/services/Accounts/AccountTransactionTransformer.ts b/packages/server/src/services/Accounts/AccountTransactionTransformer.ts index 857fe5ccc..d0e3e487f 100644 --- a/packages/server/src/services/Accounts/AccountTransactionTransformer.ts +++ b/packages/server/src/services/Accounts/AccountTransactionTransformer.ts @@ -1,6 +1,5 @@ import { IAccountTransaction } from '@/interfaces'; import { Transformer } from '@/lib/Transformer/Transformer'; -import { transaction } from 'objection'; export default class AccountTransactionTransformer extends Transformer { /** diff --git a/packages/server/src/services/Accounts/AccountTransform.ts b/packages/server/src/services/Accounts/AccountTransform.ts index 9297994be..98e19553e 100644 --- a/packages/server/src/services/Accounts/AccountTransform.ts +++ b/packages/server/src/services/Accounts/AccountTransform.ts @@ -34,7 +34,7 @@ export class AccountTransformer extends Transformer { /** * Retrieve formatted account amount. - * @param {IAccount} invoice + * @param {IAccount} invoice * @returns {string} */ protected formattedAmount = (account: IAccount): string => { diff --git a/packages/server/src/services/Accounts/ActivateAccount.ts b/packages/server/src/services/Accounts/ActivateAccount.ts index 1fcd104f6..26afd836d 100644 --- a/packages/server/src/services/Accounts/ActivateAccount.ts +++ b/packages/server/src/services/Accounts/ActivateAccount.ts @@ -5,7 +5,6 @@ import { IAccountEventActivatedPayload } from '@/interfaces'; import events from '@/subscribers/events'; import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; -import { CommandAccountValidators } from './CommandAccountValidators'; @Service() export class ActivateAccount { @@ -18,9 +17,6 @@ export class ActivateAccount { @Inject() private uow: UnitOfWork; - @Inject() - private validator: CommandAccountValidators; - /** * Activates/Inactivates the given account. * @param {number} tenantId diff --git a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts index 258496306..0ac580b90 100644 --- a/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts +++ b/packages/server/src/services/Sales/Estimates/SendSaleEstimateMail.ts @@ -8,11 +8,14 @@ import { import { SaleEstimatesPdf } from './SaleEstimatesPdf'; import { GetSaleEstimate } from './GetSaleEstimate'; import { + ISaleEstimateMailPresendEvent, SaleEstimateMailOptions, SaleEstimateMailOptionsDTO, } from '@/interfaces'; import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; @Service() export class SendSaleEstimateMail { @@ -31,6 +34,9 @@ export class SendSaleEstimateMail { @Inject('agenda') private agenda: any; + @Inject() + private eventPublisher: EventPublisher; + /** * Triggers the reminder mail of the given sale estimate. * @param {number} tenantId - @@ -49,6 +55,13 @@ export class SendSaleEstimateMail { messageOptions, }; await this.agenda.now('sale-estimate-mail-send', payload); + + // Triggers `onSaleEstimatePreMailSend` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onPreMailSend, { + tenantId, + saleEstimateId, + messageOptions, + } as ISaleEstimateMailPresendEvent); } /** @@ -99,7 +112,7 @@ export class SendSaleEstimateMail { return { ...mailOptions, data: formatterData, - attachEstimate: true + attachEstimate: true, }; }; diff --git a/packages/server/src/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts b/packages/server/src/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts new file mode 100644 index 000000000..99caa3952 --- /dev/null +++ b/packages/server/src/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts @@ -0,0 +1,43 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ISaleEstimateMailPresendEvent } from '@/interfaces'; +import { DeliverSaleEstimate } from '../DeliverSaleEstimate'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '../constants'; + +@Service() +export class SaleEstimateMarkApprovedOnMailSent { + @Inject() + private deliverEstimateService: DeliverSaleEstimate; + + /** + * Attaches events. + */ + public attach(bus) { + bus.subscribe(events.saleEstimate.onPreMailSend, this.markEstimateApproved); + } + + /** + * Marks the given estimate approved on submitting mail. + * @param {ISaleEstimateMailPresendEvent} + */ + private markEstimateApproved = async ({ + tenantId, + saleEstimateId, + }: ISaleEstimateMailPresendEvent) => { + try { + await this.deliverEstimateService.deliverSaleEstimate( + tenantId, + saleEstimateId + ); + } catch (error) { + if ( + error instanceof ServiceError && + error.errorType === ERRORS.SALE_ESTIMATE_ALREADY_DELIVERED + ) { + } else { + throw error; + } + } + }; +} diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts index acb1ea7a1..bd8d4fa64 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveMailNotification.ts @@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi'; import { PaymentReceiveMailOpts, PaymentReceiveMailOptsDTO, + PaymentReceiveMailPresendEvent, SendInvoiceMailDTO, } from '@/interfaces'; import Mail from '@/lib/Mail'; @@ -13,6 +14,8 @@ import { import { GetPaymentReceive } from './GetPaymentReceive'; import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; @Service() export class SendPaymentReceiveMailNotification { @@ -28,6 +31,9 @@ export class SendPaymentReceiveMailNotification { @Inject('agenda') private agenda: any; + @Inject() + private eventPublisher: EventPublisher; + /** * Sends the mail of the given payment receive. * @param {number} tenantId @@ -46,6 +52,13 @@ export class SendPaymentReceiveMailNotification { messageDTO, }; await this.agenda.now('payment-receive-mail-send', payload); + + // Triggers `onPaymentReceivePreMailSend` event. + await this.eventPublisher.emitAsync(events.paymentReceive.onPreMailSend, { + tenantId, + paymentReceiveId, + messageOptions: messageDTO, + } as PaymentReceiveMailPresendEvent); } /** diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts index 572bed2f8..24add40cc 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptMailNotification.ts @@ -7,9 +7,15 @@ import { DEFAULT_RECEIPT_MAIL_CONTENT, DEFAULT_RECEIPT_MAIL_SUBJECT, } from './constants'; -import { SaleReceiptMailOpts, SaleReceiptMailOptsDTO } from '@/interfaces'; +import { + ISaleReceiptMailPresend, + SaleReceiptMailOpts, + SaleReceiptMailOptsDTO, +} from '@/interfaces'; import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; import { parseAndValidateMailOptions } from '@/services/MailNotification/utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; @Service() export class SaleReceiptMailNotification { @@ -25,6 +31,9 @@ export class SaleReceiptMailNotification { @Inject() private contactMailNotification: ContactMailNotification; + @Inject() + private eventPublisher: EventPublisher; + @Inject('agenda') private agenda: any; @@ -37,14 +46,21 @@ export class SaleReceiptMailNotification { public async triggerMail( tenantId: number, saleReceiptId: number, - messageOpts: SaleReceiptMailOptsDTO + messageOptions: SaleReceiptMailOptsDTO ) { const payload = { tenantId, saleReceiptId, - messageOpts, + messageOpts: messageOptions, }; await this.agenda.now('sale-receipt-mail-send', payload); + + // Triggers the event `onSaleReceiptPreMailSend`. + await this.eventPublisher.emitAsync(events.saleReceipt.onPreMailSend, { + tenantId, + saleReceiptId, + messageOptions, + } as ISaleReceiptMailPresend); } /** diff --git a/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts index 5e6311005..39590198b 100644 --- a/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts +++ b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptCostGLEntriesSubscriber.ts @@ -6,7 +6,7 @@ import { SaleReceiptCostGLEntries } from '../SaleReceiptCostGLEntries'; @Service() export class SaleReceiptCostGLEntriesSubscriber { @Inject() - saleReceiptCostEntries: SaleReceiptCostGLEntries; + private saleReceiptCostEntries: SaleReceiptCostGLEntries; /** * Attaches events. diff --git a/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber.ts b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber.ts new file mode 100644 index 000000000..3a8d26394 --- /dev/null +++ b/packages/server/src/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber.ts @@ -0,0 +1,41 @@ +import { ISaleReceiptMailPresend } from '@/interfaces'; +import events from '@/subscribers/events'; +import { CloseSaleReceipt } from '../CloseSaleReceipt'; +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '../constants'; + +@Service() +export class SaleReceiptMarkClosedOnMailSentSubcriber { + @Inject() + private closeReceiptService: CloseSaleReceipt; + + /** + * Attaches events. + */ + public attach(bus) { + bus.subscribe(events.saleReceipt.onPreMailSend, this.markReceiptClosed); + } + + /** + * Marks the sale receipt closed on submitting mail. + * @param {ISaleReceiptMailPresend} + */ + private markReceiptClosed = async ({ + tenantId, + saleReceiptId, + messageOptions, + }: ISaleReceiptMailPresend) => { + try { + await this.closeReceiptService.closeSaleReceipt(tenantId, saleReceiptId); + } catch (error) { + if ( + error instanceof ServiceError && + error.errorType === ERRORS.SALE_RECEIPT_IS_ALREADY_CLOSED + ) { + } else { + throw error; + } + } + }; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index a46c32696..882027f98 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -172,6 +172,10 @@ export default { onRejected: 'onSaleEstimateRejected', onNotifyMail: 'onSaleEstimateNotifyMail', + + onPreMailSend: 'onSaleEstimatePreMailSend', + onMailSend: 'onSaleEstimateMailSend', + onMailSent: 'onSaleEstimateMailSend', }, /** @@ -195,6 +199,10 @@ export default { onNotifySms: 'onSaleReceiptNotifySms', onNotifiedSms: 'onSaleReceiptNotifiedSms', + + onPreMailSend: 'onSaleReceiptPreMailSend', + onMailSend: 'onSaleReceiptMailSend', + onMailSent: 'onSaleReceiptMailSent', }, /** @@ -215,6 +223,10 @@ export default { onNotifySms: 'onPaymentReceiveNotifySms', onNotifiedSms: 'onPaymentReceiveNotifiedSms', + + onPreMailSend: 'onPaymentReceivePreMailSend', + onMailSend: 'onPaymentReceiveMailSend', + onMailSent: 'onPaymentReceiveMailSent', }, /** From 63708ae839b91b2c802c171d91c1bae9df9c710b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 25 Jan 2024 21:56:18 +0200 Subject: [PATCH 072/148] feat(webapp): showing up mail popup once saving invoice, receipt and estimate --- packages/webapp/src/constants/dialogs.ts | 4 ++ .../Dialogs/EstimateFormMailDeliverDialog.tsx | 39 ++++++++++++++++++ .../EstimateFormMailDeliverDialogContent.tsx | 40 +++++++++++++++++++ .../EstimateForm/EstimateFloatingActions.tsx | 2 +- .../Estimates/EstimateForm/EstimateForm.tsx | 13 +++++- .../EstimateForm/EstimateFormDialogs.tsx | 5 +++ .../EstimateMailDialog/EstimateMailDialog.tsx | 19 +++------ .../EstimateMailDialogBody.tsx | 33 +++++++++++++++ .../EstimateMailDialogContent.tsx | 18 ++++----- .../EstimateMailDialogForm.tsx | 12 +++--- .../InvoiceFormMailDeliverDialog.tsx | 39 ++++++++++++++++++ .../InvoiceFormMailDeliverDialogContent.tsx | 40 +++++++++++++++++++ .../InvoiceForm/InvoiceFloatingActions.tsx | 8 ++-- .../Invoices/InvoiceForm/InvoiceForm.tsx | 18 ++++++++- .../InvoiceForm/InvoiceFormDialogs.tsx | 15 ++++--- .../InvoiceMailDialog/InvoiceMailDialog.tsx | 20 +++------- .../InvoiceMailDialogBody.tsx | 36 +++++++++++++++++ .../InvoiceMailDialogContent.tsx | 22 +++++----- .../InvoiceMailDialogForm.tsx | 26 ++---------- .../Sales/Invoices/InvoiceMailDialog/index.ts | 3 +- .../Dialogs/ReceiptFormMailDeliverDialog.tsx | 39 ++++++++++++++++++ .../ReceiptFormMailDeliverDialogContent.tsx | 40 +++++++++++++++++++ .../Receipts/ReceiptForm/ReceiptForm.tsx | 21 +++++++--- .../ReceiptForm/ReceiptFormDialogs.tsx | 5 +++ .../ReceiptFormFloatingActions.tsx | 2 +- .../ReceiptMailDialog/ReceiptMailDialog.tsx | 19 +++------ .../ReceiptMailDialogBody.tsx | 33 +++++++++++++++ .../ReceiptMailDialogContent.tsx | 20 +++++----- .../ReceiptMailDialogForm.tsx | 29 ++++++-------- 29 files changed, 482 insertions(+), 138 deletions(-) create mode 100644 packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx create mode 100644 packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx create mode 100644 packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialogContent.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBody.tsx create mode 100644 packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialog.tsx create mode 100644 packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialogContent.tsx create mode 100644 packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBody.tsx diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts index c9bb52a0e..e7aa4d324 100644 --- a/packages/webapp/src/constants/dialogs.ts +++ b/packages/webapp/src/constants/dialogs.ts @@ -53,4 +53,8 @@ export enum DialogsName { EstimateMail = 'estimate-mail', ReceiptMail = 'receipt-mail', PaymentMail = 'payment-mail', + + InvoiceFormMailDeliver = 'InvoiceFormMailDeliver', + EstimateFormMailDeliver = 'EstimateFormMailDeliver', + ReceiptFormMailDeliver = 'ReceiptFormMailDeliver', } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx new file mode 100644 index 000000000..6a0b832c3 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialog.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const EstimateFormMailDeliverDialogContent = React.lazy( + () => import('./EstimateFormMailDeliverDialogContent'), +); + +/** + * Estimate mail dialog. + */ +function EstimateFormMailDeliverDialog({ + dialogName, + payload: { estimateId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(EstimateFormMailDeliverDialog); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx new file mode 100644 index 000000000..e77e3ee99 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/Dialogs/EstimateFormMailDeliverDialogContent.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { useHistory } from 'react-router-dom'; +import EstimateMailDialogContent from '../../EstimateMailDialog/EstimateMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface EstimateFormDeliverDialogContent { + estimateId: number; +} + +function EstimateFormDeliverDialogContentRoot({ + estimateId, + + // #withDialogActions + closeDialog, +}: EstimateFormDeliverDialogContent) { + const history = useHistory(); + + const handleSubmit = () => { + closeDialog(DialogsName.EstimateFormMailDeliver); + history.push('/estimates'); + }; + const handleCancel = () => { + closeDialog(DialogsName.EstimateFormMailDeliver); + history.push('/estimates'); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)( + EstimateFormDeliverDialogContentRoot, +); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFloatingActions.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFloatingActions.tsx index df212c38c..474730d93 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFloatingActions.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFloatingActions.tsx @@ -29,7 +29,7 @@ export default function EstimateFloatingActions() { // Handle submit & deliver button click. const handleSubmitDeliverBtnClick = (event) => { - setSubmitPayload({ redirect: true, deliver: true }); + setSubmitPayload({ redirect: false, deliverViaMail: true }); submitForm(); }; diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx index aff0888d6..b1e665d3d 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateForm.tsx @@ -36,11 +36,16 @@ import { handleErrors, resetFormState, } from './utils'; +import { DialogsName } from '@/constants/dialogs'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; /** * Estimate form. */ function EstimateForm({ + // #withDialogActions + openDialog, + // #withSettings estimateNextNumber, estimateNumberPrefix, @@ -108,7 +113,7 @@ function EstimateForm({ delivered: submitPayload.deliver, }; // Handle the request success. - const onSuccess = (response) => { + const onSuccess = (res) => { AppToaster.show({ message: intl.get( isNewMode @@ -126,6 +131,11 @@ function EstimateForm({ if (submitPayload.resetForm) { resetFormState({ resetForm, initialValues, values }); } + if (submitPayload.deliverViaMail) { + openDialog(DialogsName.EstimateFormMailDeliver, { + estimateId: res.data.id, + }); + } }; // Handle the request error. const onError = ({ @@ -180,6 +190,7 @@ function EstimateForm({ } export default compose( + withDialogActions, withSettings(({ estimatesSettings }) => ({ estimateNextNumber: estimatesSettings?.nextNumber, estimateNumberPrefix: estimatesSettings?.numberPrefix, diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx index aa1c165a1..a50326486 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateForm/EstimateFormDialogs.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { useFormikContext } from 'formik'; import EstimateNumberDialog from '@/containers/Dialogs/EstimateNumberDialog'; +import EstimateFormMailDeliverDialog from './Dialogs/EstimateFormMailDeliverDialog'; +import { DialogsName } from '@/constants/dialogs'; /** * Estimate form dialogs. @@ -25,6 +27,9 @@ export default function EstimateFormDialogs() { dialogName={'estimate-number-form'} onConfirm={handleEstimateNumberFormConfirm} /> + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx index a90f0e94a..0d13e07fb 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialog.tsx @@ -4,21 +4,16 @@ import { Dialog, DialogSuspense } from '@/components'; import withDialogRedux from '@/components/DialogReduxConnect'; import { compose } from '@/utils'; -const EstimateMailDialogContent = React.lazy( - () => import('./EstimateMailDialogContent'), +const EstimateMailDialogBody = React.lazy( + () => import('./EstimateMailDialogBody'), ); /** - * Invoice mail dialog. + * Estimate mail dialog. */ function EstimateMailDialog({ dialogName, - payload: { - estimateId = null, - - // Redirect to the estimates list after mail submitting. - redirectToEstimatesList = false, - }, + payload: { estimateId = null }, isOpen, }) { return ( @@ -31,11 +26,7 @@ function EstimateMailDialog({ style={{ width: 600 }} > - + ); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx new file mode 100644 index 000000000..2fa1c0472 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogBody.tsx @@ -0,0 +1,33 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import EstimateMailDialogContent from './EstimateMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface EstimateMailDialogBodyProps { + estimateId: number; +} + +function EstimateMailDialogBodyRoot({ + estimateId, + + // #withDialogActions + closeDialog, +}: EstimateMailDialogBodyProps) { + const handleSubmit = () => { + closeDialog(DialogsName.EstimateMail); + }; + const handleCancelClick = () => { + closeDialog(DialogsName.EstimateMail); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)(EstimateMailDialogBodyRoot); diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx index 3eb0af76d..c673f71c6 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogContent.tsx @@ -2,21 +2,21 @@ import { EstimateMailDialogBoot } from './EstimateMailDialogBoot'; import { EstimateMailDialogForm } from './EstimateMailDialogForm'; interface EstimateMailDialogContentProps { - dialogName: string; estimateId: number; - redirectToEstimatesList?: boolean; + onFormSubmit?: () => void; + onCancelClick?: () => void; } export default function EstimateMailDialogContent({ - dialogName, estimateId, - redirectToEstimatesList, + onFormSubmit, + onCancelClick, }: EstimateMailDialogContentProps) { return ( - - + + ); } diff --git a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx index 4c6e7e943..8f51add43 100644 --- a/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Estimates/EstimateMailDialog/EstimateMailDialogForm.tsx @@ -2,7 +2,6 @@ import { Formik } from 'formik'; import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; -import { useHistory } from 'react-router-dom'; import { useEstimateMailDialogBoot } from './EstimateMailDialogBoot'; import { DialogsName } from '@/constants/dialogs'; import withDialogActions from '@/containers/Dialog/withDialogActions'; @@ -26,6 +25,9 @@ interface EstimateMailFormValues extends MailNotificationFormValues { } function EstimateMailDialogFormRoot({ + onFormSubmit, + onCancelClick, + // #withDialogClose closeDialog, }) { @@ -33,8 +35,6 @@ function EstimateMailDialogFormRoot({ const { mailOptions, saleEstimateId, redirectToEstimatesList } = useEstimateMailDialogBoot(); - const history = useHistory(); - const initialValues = transformMailFormToInitialValues( mailOptions, initialFormValues, @@ -52,10 +52,7 @@ function EstimateMailDialogFormRoot({ }); closeDialog(DialogsName.EstimateMail); setSubmitting(false); - - if (redirectToEstimatesList) { - history.push('/estimates'); - } + onFormSubmit && onFormSubmit(); }) .catch(() => { setSubmitting(false); @@ -64,6 +61,7 @@ function EstimateMailDialogFormRoot({ message: 'Something went wrong.', intent: Intent.DANGER, }); + onCancelClick && onCancelClick(); }); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog.tsx new file mode 100644 index 000000000..f6ceb38f8 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const InvoiceFormMailDeliverDialogContent = React.lazy( + () => import('./InvoiceFormMailDeliverDialogContent'), +); + +/** + * Invoice mail dialog. + */ +function InvoiceFormMailDeliverDialog({ + dialogName, + payload: { invoiceId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(InvoiceFormMailDeliverDialog); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialogContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialogContent.tsx new file mode 100644 index 000000000..8ce5e7c12 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialogContent.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { useHistory } from 'react-router-dom'; +import InvoiceMailDialogContent from '../../../InvoiceMailDialog/InvoiceMailDialogContent'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; + +interface InvoiceFormDeliverDialogContent { + invoiceId: number; +} + +function InvoiceFormDeliverDialogContentRoot({ + invoiceId, + + // #withDialogActions + closeDialog, +}: InvoiceFormDeliverDialogContent) { + const history = useHistory(); + + const handleSubmit = () => { + history.push('/invoices'); + closeDialog(DialogsName.InvoiceFormMailDeliver); + }; + const handleCancel = () => { + history.push('/invoices'); + closeDialog(DialogsName.InvoiceFormMailDeliver); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)( + InvoiceFormDeliverDialogContentRoot, +); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx index e212c8b7d..0a9a72913 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFloatingActions.tsx @@ -30,19 +30,19 @@ export default function InvoiceFloatingActions() { const { setSubmitPayload, invoice } = useInvoiceFormContext(); // Handle submit & deliver button click. - const handleSubmitDeliverBtnClick = (event) => { - setSubmitPayload({ redirect: true, deliver: true }); + const handleSubmitDeliverBtnClick = () => { + setSubmitPayload({ redirectToEdit: true, deliverViaMail: true }); submitForm(); }; // Handle submit, deliver & new button click. - const handleSubmitDeliverAndNewBtnClick = (event) => { + const handleSubmitDeliverAndNewBtnClick = () => { setSubmitPayload({ redirect: false, deliver: true, resetForm: true }); submitForm(); }; // Handle submit, deliver & continue editing button click. - const handleSubmitDeliverContinueEditingBtnClick = (event) => { + const handleSubmitDeliverContinueEditingBtnClick = () => { setSubmitPayload({ redirect: false, deliver: true }); submitForm(); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx index 6b9f234ac..c47f7bc53 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx @@ -34,12 +34,20 @@ import { transformValueToRequest, resetFormState, } from './utils'; -import { InvoiceExchangeRateSync, InvoiceNoSyncSettingsToForm } from './components'; +import { + InvoiceExchangeRateSync, + InvoiceNoSyncSettingsToForm, +} from './components'; +import { DialogsName } from '@/constants/dialogs'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; /** * Invoice form. */ function InvoiceForm({ + // #withDialogActions + openDialog, + // #withSettings invoiceNextNumber, invoiceNumberPrefix, @@ -111,7 +119,7 @@ function InvoiceForm({ from_estimate_id: estimateId, }; // Handle the request success. - const onSuccess = () => { + const onSuccess = (res) => { AppToaster.show({ message: intl.get( isNewMode @@ -123,6 +131,11 @@ function InvoiceForm({ }); setSubmitting(false); + if (submitPayload.deliverViaMail) { + openDialog(DialogsName.InvoiceFormMailDeliver, { + invoiceId: res.data.id, + }); + } if (submitPayload.redirect) { history.push('/invoices'); } @@ -201,4 +214,5 @@ export default compose( invoiceTermsConditions: invoiceSettings?.termsConditions, })), withCurrentOrganization(), + withDialogActions, )(InvoiceForm); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx index eb0e3e0ba..fca6a8bcb 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormDialogs.tsx @@ -1,8 +1,8 @@ // @ts-nocheck -import React from 'react'; import { useFormikContext } from 'formik'; import InvoiceNumberDialog from '@/containers/Dialogs/InvoiceNumberDialog'; import { DialogsName } from '@/constants/dialogs'; +import InvoiceFormMailDeliverDialog from './Dialogs/InvoiceFormMailDeliverDialog/InvoiceFormMailDeliverDialog'; /** * Invoice form dialogs. @@ -23,9 +23,14 @@ export default function InvoiceFormDialogs() { }; return ( - + <> + + + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx index 1b385b83f..02c629e7c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialog.tsx @@ -4,8 +4,8 @@ import { Dialog, DialogSuspense } from '@/components'; import withDialogRedux from '@/components/DialogReduxConnect'; import { compose } from '@/utils'; -const InvoiceMailDialogContent = React.lazy( - () => import('./InvoiceMailDialogContent'), +const InvoiceMailDialogBody = React.lazy( + () => import('./InvoiceMailDialogBody'), ); /** @@ -13,12 +13,7 @@ const InvoiceMailDialogContent = React.lazy( */ function InvoiceMailDialog({ dialogName, - payload: { - invoiceId = null, - - // Redirects to the invoices list. - redirectToInvoicesList = false, - }, + payload: { invoiceId = null }, isOpen, }) { return ( @@ -26,16 +21,13 @@ function InvoiceMailDialog({ name={dialogName} title={'Invoice Mail'} isOpen={isOpen} - canEscapeJeyClose={true} + canEscapeJeyClose={false} + isCloseButtonShown={false} autoFocus={true} style={{ width: 600 }} > - + ); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBody.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBody.tsx new file mode 100644 index 000000000..3728c60ce --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogBody.tsx @@ -0,0 +1,36 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import InvoiceMailDialogContent, { + InvoiceMailDialogContentProps, +} from './InvoiceMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +export interface InvoiceMailDialogBodyProps + extends InvoiceMailDialogContentProps {} + +function InvoiceMailDialogBodyRoot({ + invoiceId, + onCancelClick, + onFormSubmit, + + // #withDialogActions + closeDialog, +}: InvoiceMailDialogBodyProps) { + const handleCancelClick = () => { + closeDialog(DialogsName.InvoiceMail); + }; + const handleSubmitClick = () => { + closeDialog(DialogsName.InvoiceMail); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)(InvoiceMailDialogBodyRoot); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx index 769a722b6..dbecb34fc 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogContent.tsx @@ -1,24 +1,22 @@ import { InvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; import { InvoiceMailDialogForm } from './InvoiceMailDialogForm'; -interface InvoiceMailDialogContentProps { - dialogName: string; +export interface InvoiceMailDialogContentProps { invoiceId: number; - - // Redirect to invoices list after submitting the message. - redirectToInvoicesList?: boolean; + onFormSubmit?: () => void; + onCancelClick?: () => void; } export default function InvoiceMailDialogContent({ - dialogName, invoiceId, - redirectToInvoicesList, + onFormSubmit, + onCancelClick, }: InvoiceMailDialogContentProps) { return ( - - + + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx index 697e8e9a1..a91c03466 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/InvoiceMailDialogForm.tsx @@ -1,12 +1,9 @@ // @ts-nocheck import { Formik } from 'formik'; -import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; import { useInvoiceMailDialogBoot } from './InvoiceMailDialogBoot'; -import { DialogsName } from '@/constants/dialogs'; import { AppToaster } from '@/components'; import { useSendSaleInvoiceMail } from '@/hooks/query'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; import { InvoiceMailDialogFormContent } from './InvoiceMailDialogFormContent'; import { InvoiceMailFormSchema } from './InvoiceMailDialogForm.schema'; import { @@ -15,7 +12,6 @@ import { transformMailFormToRequest, transformMailFormToInitialValues, } from '@/containers/SendMailNotification/utils'; -import { useHistory } from 'react-router-dom'; const initialFormValues = { ...initialMailNotificationValues, @@ -26,13 +22,8 @@ interface InvoiceMailFormValues extends MailNotificationFormValues { attachInvoice: boolean; } -function InvoiceMailDialogFormRoot({ - // #withDialogActions - closeDialog, -}) { - const history = useHistory(); - const { mailOptions, saleInvoiceId, redirectToInvoicesList } = - useInvoiceMailDialogBoot(); +export function InvoiceMailDialogForm({ onFormSubmit, onCancelClick }) { + const { mailOptions, saleInvoiceId } = useInvoiceMailDialogBoot(); const { mutateAsync: sendInvoiceMail } = useSendSaleInvoiceMail(); const initialValues = transformMailFormToInitialValues( @@ -50,13 +41,8 @@ function InvoiceMailDialogFormRoot({ message: 'The mail notification has been sent successfully.', intent: Intent.SUCCESS, }); - closeDialog(DialogsName.InvoiceMail); setSubmitting(false); - - // Redirect to the dashboard if the option was enabled. - if (redirectToInvoicesList) { - history.push('/invoices'); - } + onFormSubmit && onFormSubmit(values); }) .catch(() => { AppToaster.show({ @@ -68,7 +54,7 @@ function InvoiceMailDialogFormRoot({ }; // Handle the close button click. const handleClose = () => { - closeDialog(DialogsName.InvoiceMail); + onCancelClick && onCancelClick(); }; return ( @@ -81,7 +67,3 @@ function InvoiceMailDialogFormRoot({ ); } - -export const InvoiceMailDialogForm = R.compose(withDialogActions)( - InvoiceMailDialogFormRoot, -); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts index b64dcaaf3..b40bce27b 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceMailDialog/index.ts @@ -1 +1,2 @@ -export * from './InvoiceMailDialog'; \ No newline at end of file +export * from './InvoiceMailDialog'; +export * from './InvoiceMailDialogContent'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialog.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialog.tsx new file mode 100644 index 000000000..60f2758ff --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialog.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const ReceiptFormMailDeliverDialogContent = React.lazy( + () => import('./ReceiptFormMailDeliverDialogContent'), +); + +/** + * Receipt mail dialog. + */ +function ReceiptFormMailDeliverDialog({ + dialogName, + payload: { receiptId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(ReceiptFormMailDeliverDialog); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialogContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialogContent.tsx new file mode 100644 index 000000000..4b5d31e40 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/Dialogs/ReceiptFormMailDeliverDialogContent.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { useHistory } from 'react-router-dom'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import ReceiptMailDialogContent from '../../ReceiptMailDialog/ReceiptMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface ReceiptFormDeliverDialogContent { + receiptId: number; +} + +function ReceiptFormDeliverDialogContentRoot({ + receiptId, + + // #withDialogActions + closeDialog, +}: ReceiptFormDeliverDialogContent) { + const history = useHistory(); + + const handleSubmit = () => { + history.push('/receipts'); + closeDialog(DialogsName.ReceiptFormMailDeliver); + }; + const handleCancel = () => { + history.push('/receipts'); + closeDialog(DialogsName.ReceiptFormMailDeliver); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)( + ReceiptFormDeliverDialogContentRoot, +); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx index 62206bdbf..d7010d2ae 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptForm.tsx @@ -34,12 +34,20 @@ import { transformFormValuesToRequest, resetFormState, } from './utils'; -import { ReceiptSyncAutoExRateToForm, ReceiptSyncIncrementSettingsToForm } from './components'; +import { + ReceiptSyncAutoExRateToForm, + ReceiptSyncIncrementSettingsToForm, +} from './components'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; /** * Receipt form. */ function ReceiptForm({ + // #withDialogActions + openDialog, + // #withSettings receiptNextNumber, receiptNumberPrefix, @@ -84,10 +92,7 @@ function ReceiptForm({ }), }; // Handle the form submit. - const handleFormSubmit = ( - values, - { setErrors, setSubmitting, resetForm }, - ) => { + const handleFormSubmit = (values, { setErrors, setSubmitting }) => { const entries = values.entries.filter( (item) => item.item_id && item.quantity, ); @@ -124,6 +129,11 @@ function ReceiptForm({ if (submitPayload.resetForm) { resetFormState(); } + if (submitPayload.deliverMail) { + openDialog(DialogsName.ReceiptFormMailDeliver, { + receiptId: response.data.id, + }); + } }; // Handle the request error. @@ -179,6 +189,7 @@ function ReceiptForm({ } export default compose( + withDialogActions, withDashboardActions, withSettings(({ receiptSettings }) => ({ receiptNextNumber: receiptSettings?.nextNumber, diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx index 4fe2cb947..30477aee6 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormDialogs.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { useFormikContext } from 'formik'; import ReceiptNumberDialog from '@/containers/Dialogs/ReceiptNumberDialog'; +import ReceiptFormMailDeliverDialog from './Dialogs/ReceiptFormMailDeliverDialog'; +import { DialogsName } from '@/constants/dialogs'; /** * Receipt form dialogs. @@ -27,6 +29,9 @@ export default function ReceiptFormDialogs() { dialogName={'receipt-number-form'} onConfirm={handleReceiptNumberFormConfirm} /> + ); } diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormFloatingActions.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormFloatingActions.tsx index d8d83dfb5..4db00c3ae 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormFloatingActions.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormFloatingActions.tsx @@ -33,7 +33,7 @@ export default function ReceiptFormFloatingActions() { // Handle submit & close button click. const handleSubmitCloseBtnClick = (event) => { - setSubmitPayload({ redirect: true, status: true }); + setSubmitPayload({ redirect: false, deliverMail: true, status: true }); submitForm(); }; diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx index eb68d7d37..69a0e64a9 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog.tsx @@ -4,21 +4,16 @@ import { Dialog, DialogSuspense } from '@/components'; import withDialogRedux from '@/components/DialogReduxConnect'; import { compose } from '@/utils'; -const ReceiptMailDialogContent = React.lazy( - () => import('./ReceiptMailDialogContent'), +const ReceiptMailDialogBody = React.lazy( + () => import('./ReceiptMailDialogBody'), ); /** - * Invoice mail dialog. + * Receipt mail dialog. */ function ReceiptMailDialog({ dialogName, - payload: { - receiptId = null, - - // Redirects to receipts list after mail submitting. - redirectToReceiptsList = false, - }, + payload: { receiptId = null }, isOpen, }) { return ( @@ -31,11 +26,7 @@ function ReceiptMailDialog({ style={{ width: 600 }} > - + ); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBody.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBody.tsx new file mode 100644 index 000000000..fbd379b84 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogBody.tsx @@ -0,0 +1,33 @@ +// @ts-nocheck +import * as R from 'ramda'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import ReceiptMailDialogContent, { + ReceiptMailDialogContentProps, +} from './ReceiptMailDialogContent'; +import { DialogsName } from '@/constants/dialogs'; + +interface ReceiptMailDialogBodyProps extends ReceiptMailDialogContentProps {} + +function ReceiptMailDialogBodyRoot({ + receiptId, + + // #withDialogActions + closeDialog, +}: ReceiptMailDialogBodyProps) { + const handleCancelClick = () => { + closeDialog(DialogsName.ReceiptMail); + }; + const handleSubmitClick = () => { + closeDialog(DialogsName.ReceiptMail); + }; + + return ( + + ); +} + +export default R.compose(withDialogActions)(ReceiptMailDialogBodyRoot); diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx index 586de745c..a02966a1c 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogContent.tsx @@ -2,22 +2,22 @@ import React from 'react'; import { ReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; import { ReceiptMailDialogForm } from './ReceiptMailDialogForm'; -interface ReceiptMailDialogContentProps { - dialogName: string; +export interface ReceiptMailDialogContentProps { receiptId: number; - redirectToReceiptsList?: boolean; + onFormSubmit?: () => void; + onCancelClick?: () => void; } export default function ReceiptMailDialogContent({ - dialogName, receiptId, - redirectToReceiptsList = false, + onFormSubmit, + onCancelClick }: ReceiptMailDialogContentProps) { return ( - - + + ); } diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx index d46ea7eb0..db2808f4c 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialogForm.tsx @@ -2,7 +2,6 @@ import { Formik, FormikBag } from 'formik'; import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; -import { useHistory } from 'react-router-dom'; import { useReceiptMailDialogBoot } from './ReceiptMailDialogBoot'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; @@ -24,12 +23,18 @@ interface ReceiptMailFormValues extends MailNotificationFormValues { attachReceipt: boolean; } -function ReceiptMailDialogFormRoot({ closeDialog }) { - const { mailOptions, saleReceiptId, redirectToReceiptsList } = - useReceiptMailDialogBoot(); - const { mutateAsync: sendReceiptMail } = useSendSaleReceiptMail(); +interface ReceiptMailDialogFormProps { + onFormSubmit?: () => void; + onCancelClick?: () => void; +} - const history = useHistory(); +export function ReceiptMailDialogForm({ + // #props + onFormSubmit, + onCancelClick, +}: ReceiptMailDialogFormProps) { + const { mailOptions, saleReceiptId } = useReceiptMailDialogBoot(); + const { mutateAsync: sendReceiptMail } = useSendSaleReceiptMail(); // Transformes mail options to initial form values. const initialValues = transformMailFormToInitialValues( @@ -50,12 +55,8 @@ function ReceiptMailDialogFormRoot({ closeDialog }) { message: 'The mail notification has been sent successfully.', intent: Intent.SUCCESS, }); - closeDialog(DialogsName.ReceiptMail); setSubmitting(false); - - if (redirectToReceiptsList) { - history.push('/receipts'); - } + onFormSubmit && onFormSubmit(values); }) .catch(() => { AppToaster.show({ @@ -67,7 +68,7 @@ function ReceiptMailDialogFormRoot({ closeDialog }) { }; // Handle the close button click. const handleClose = () => { - closeDialog(DialogsName.ReceiptMail); + onCancelClick && onCancelClick(); }; return ( @@ -76,7 +77,3 @@ function ReceiptMailDialogFormRoot({ closeDialog }) { ); } - -export const ReceiptMailDialogForm = R.compose(withDialogActions)( - ReceiptMailDialogFormRoot, -); From 475c4e996717060717f49681b6179f0493e213c4 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 26 Jan 2024 00:05:24 +0200 Subject: [PATCH 073/148] fix(webapp): inconsistency in currency of universal search items --- .../src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx | 4 ++-- .../src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx index 84a8858e4..1656b9bc7 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx @@ -83,13 +83,13 @@ export function InvoiceUniversalSearchItem( {highlightText(item.reference.invoice_no, query)}{' '} - {item.reference.formatted_invoice_date} + {item.reference.invoice_date_formatted}
} label={ <> -
${item.reference.balance}
+
${item.reference.total_formatted}
} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx index ec050da8f..2eb9405ac 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx @@ -75,7 +75,7 @@ export function ReceiptUniversalSearchItem( } label={ <> -
${item.reference.amount}
+
${item.reference.formatted_amount}
} From de5920f9109ef90c7cdf890712041b889d210cd2 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 26 Jan 2024 23:46:45 +0200 Subject: [PATCH 074/148] fix: Expense amounts should not be rounded --- packages/server/src/models/ExpenseCategory.ts | 2 ++ .../CRUD/ExpenseCategoryTransformer.ts | 25 +++++++++++++++++++ .../Expenses/CRUD/ExpenseTransformer.ts | 17 +++++++++++-- .../ExpenseDrawer/ExpenseDrawerFooter.tsx | 6 ++--- .../Drawers/ExpenseDrawer/utils.tsx | 3 +-- 5 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/services/Expenses/CRUD/ExpenseCategoryTransformer.ts diff --git a/packages/server/src/models/ExpenseCategory.ts b/packages/server/src/models/ExpenseCategory.ts index 50416805e..21d61f7e8 100644 --- a/packages/server/src/models/ExpenseCategory.ts +++ b/packages/server/src/models/ExpenseCategory.ts @@ -2,6 +2,8 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; export default class ExpenseCategory extends TenantModel { + amount: number; + /** * Table name */ diff --git a/packages/server/src/services/Expenses/CRUD/ExpenseCategoryTransformer.ts b/packages/server/src/services/Expenses/CRUD/ExpenseCategoryTransformer.ts new file mode 100644 index 000000000..3f8383c03 --- /dev/null +++ b/packages/server/src/services/Expenses/CRUD/ExpenseCategoryTransformer.ts @@ -0,0 +1,25 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { ExpenseCategory } from '@/models'; +import { formatNumber } from '@/utils'; + +export class ExpenseCategoryTransformer extends Transformer { + /** + * Include these attributes to expense object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['amountFormatted']; + }; + + /** + * Retrieves the formatted amount. + * @param {ExpenseCategory} category + * @returns {string} + */ + protected amountFormatted(category: ExpenseCategory) { + return formatNumber(category.amount, { + currencyCode: this.context.currencyCode, + money: false, + }); + } +} diff --git a/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts b/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts index 2812a9261..89f461934 100644 --- a/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts +++ b/packages/server/src/services/Expenses/CRUD/ExpenseTransformer.ts @@ -1,6 +1,7 @@ import { Transformer } from '@/lib/Transformer/Transformer'; import { formatNumber } from 'utils'; import { IExpense } from '@/interfaces'; +import { ExpenseCategoryTransformer } from './ExpenseCategoryTransformer'; export class ExpenseTransfromer extends Transformer { /** @@ -12,7 +13,8 @@ export class ExpenseTransfromer extends Transformer { 'formattedAmount', 'formattedLandedCostAmount', 'formattedAllocatedCostAmount', - 'formattedDate' + 'formattedDate', + 'categories', ]; }; @@ -56,5 +58,16 @@ export class ExpenseTransfromer extends Transformer { */ protected formattedDate = (expense: IExpense): string => { return this.formatDate(expense.paymentDate); - } + }; + + /** + * Retrieves the transformed expense categories. + * @param {IExpense} expense + * @returns {} + */ + protected categories = (expense: IExpense) => { + return this.item(expense.categories, new ExpenseCategoryTransformer(), { + currencyCode: expense.currencyCode, + }); + }; } diff --git a/packages/webapp/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerFooter.tsx b/packages/webapp/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerFooter.tsx index c1acea3f2..48b3b2514 100644 --- a/packages/webapp/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerFooter.tsx +++ b/packages/webapp/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerFooter.tsx @@ -9,7 +9,7 @@ import { TotalLineTextStyle, } from '@/components'; import { useExpenseDrawerContext } from './ExpenseDrawerProvider'; -import { FormatNumber, TotalLine } from '@/components'; +import { TotalLine } from '@/components'; /** * Footer details of expense readonly details. @@ -22,12 +22,12 @@ export default function ExpenseDrawerFooter() { } - value={} + value={expense.formatted_amount} borderStyle={TotalLineBorderStyle.SingleDark} /> } - value={} + value={expense.formatted_amount} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Drawers/ExpenseDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/ExpenseDrawer/utils.tsx index 276923cce..377f6f9a4 100644 --- a/packages/webapp/src/containers/Drawers/ExpenseDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/ExpenseDrawer/utils.tsx @@ -36,8 +36,7 @@ export const useExpenseReadEntriesColumns = () => { }, { Header: intl.get('amount'), - accessor: 'amount', - Cell: FormatNumberCell, + accessor: 'amount_formatted', width: getColumnWidth(categories, 'amount', { minWidth: 60, magicSpacing: 5, From ac7175d83b7d0109c39d89826a4b7f5dff32262b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Jan 2024 15:52:54 +0200 Subject: [PATCH 075/148] feat: get latest exchange rate from third party services --- .env.example | 8 +- .../src/api/controllers/ExchangeRates.ts | 214 +++++------------- packages/server/src/config/index.ts | 10 + .../src/lib/ExchangeRate/ExchangeRate.ts | 45 ++++ .../src/lib/ExchangeRate/OpenExchangeRate.ts | 62 +++++ packages/server/src/lib/ExchangeRate/types.ts | 17 ++ .../ExchangeRates/ExchangeRatesService.ts | 208 +++-------------- 7 files changed, 224 insertions(+), 340 deletions(-) create mode 100644 packages/server/src/lib/ExchangeRate/ExchangeRate.ts create mode 100644 packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts create mode 100644 packages/server/src/lib/ExchangeRate/types.ts diff --git a/.env.example b/.env.example index 78945ab91..7af1c6061 100644 --- a/.env.example +++ b/.env.example @@ -57,4 +57,10 @@ GOTENBERG_DOCS_URL=http://server:3000/public/ # Gotenberg API - (development) # GOTENBERG_URL=http://localhost:9000 -# GOTENBERG_DOCS_URL=http://host.docker.internal:3000/public/ \ No newline at end of file +# GOTENBERG_DOCS_URL=http://host.docker.internal:3000/public/ + +# Exchange Rate Service +EXCHANGE_RATE_SERVICE=open-exchange-rate + +# Open Exchange Rate +OPEN_EXCHANGE_RATE_APP_I= \ No newline at end of file diff --git a/packages/server/src/api/controllers/ExchangeRates.ts b/packages/server/src/api/controllers/ExchangeRates.ts index 4b808e921..4ea59ad1b 100644 --- a/packages/server/src/api/controllers/ExchangeRates.ts +++ b/packages/server/src/api/controllers/ExchangeRates.ts @@ -4,16 +4,13 @@ import { check, param, query } from 'express-validator'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from './BaseController'; import { ServiceError } from '@/exceptions'; -import ExchangeRatesService from '@/services/ExchangeRates/ExchangeRatesService'; -import DynamicListingService from '@/services/DynamicListing/DynamicListService'; +import { ExchangeRatesService } from '@/services/ExchangeRates/ExchangeRatesService'; +import { EchangeRateErrors } from '@/lib/ExchangeRate/types'; @Service() export default class ExchangeRatesController extends BaseController { @Inject() - exchangeRatesService: ExchangeRatesService; - - @Inject() - dynamicListService: DynamicListingService; + private exchangeRatesService: ExchangeRatesService; /** * Constructor method. @@ -22,164 +19,35 @@ export default class ExchangeRatesController extends BaseController { const router = Router(); router.get( - '/', - [...this.exchangeRatesListSchema], + '/latest', + [query('to_currency').exists().isString()], this.validationResult, - asyncMiddleware(this.exchangeRates.bind(this)), - this.dynamicListService.handlerErrorsToResponse, - this.handleServiceError, - ); - router.post( - '/', - [...this.exchangeRateDTOSchema], - this.validationResult, - asyncMiddleware(this.addExchangeRate.bind(this)), - this.handleServiceError - ); - router.post( - '/:id', - [...this.exchangeRateEditDTOSchema, ...this.exchangeRateIdSchema], - this.validationResult, - asyncMiddleware(this.editExchangeRate.bind(this)), - this.handleServiceError - ); - router.delete( - '/:id', - [...this.exchangeRateIdSchema], - this.validationResult, - asyncMiddleware(this.deleteExchangeRate.bind(this)), + asyncMiddleware(this.latestExchangeRate.bind(this)), this.handleServiceError ); return router; } - get exchangeRatesListSchema() { - return [ - query('page').optional().isNumeric().toInt(), - query('page_size').optional().isNumeric().toInt(), - - query('column_sort_by').optional(), - query('sort_order').optional().isIn(['desc', 'asc']), - ]; - } - - get exchangeRateDTOSchema() { - return [ - check('exchange_rate').exists().isNumeric().toFloat(), - check('currency_code').exists().trim().escape(), - check('date').exists().isISO8601(), - ]; - } - - get exchangeRateEditDTOSchema() { - return [check('exchange_rate').exists().isNumeric().toFloat()]; - } - - get exchangeRateIdSchema() { - return [param('id').isNumeric().toInt()]; - } - - get exchangeRatesIdsSchema() { - return [ - query('ids').isArray({ min: 2 }), - query('ids.*').isNumeric().toInt(), - ]; - } - /** * Retrieve exchange rates. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ - async exchangeRates(req: Request, res: Response, next: NextFunction) { + private async latestExchangeRate( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId } = req; - const filter = { - page: 1, - pageSize: 12, - filterRoles: [], - columnSortBy: 'created_at', - sortOrder: 'asc', - ...this.matchedQueryData(req), - }; - if (filter.stringifiedFilterRoles) { - filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles); - } - try { - const exchangeRates = await this.exchangeRatesService.listExchangeRates( - tenantId, - filter - ); - return res.status(200).send({ exchange_rates: exchangeRates }); - } catch (error) { - next(error); - } - } - - /** - * Adds a new exchange rate on the given date. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async addExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const exchangeRateDTO = this.matchedBodyData(req); + const exchangeRateQuery = this.matchedQueryData(req); try { - const exchangeRate = await this.exchangeRatesService.newExchangeRate( + const exchangeRate = await this.exchangeRatesService.latest( tenantId, - exchangeRateDTO + exchangeRateQuery ); - return res.status(200).send({ id: exchangeRate.id }); - } catch (error) { - next(error); - } - } - - /** - * Edit the given exchange rate. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async editExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const { id: exchangeRateId } = req.params; - const exchangeRateDTO = this.matchedBodyData(req); - - try { - const exchangeRate = await this.exchangeRatesService.editExchangeRate( - tenantId, - exchangeRateId, - exchangeRateDTO - ); - - return res.status(200).send({ - id: exchangeRateId, - message: 'The exchange rate has been edited successfully.', - }); - } catch (error) { - next(error); - } - } - - /** - * Delete the given exchange rate from the storage. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async deleteExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const { id: exchangeRateId } = req.params; - - try { - await this.exchangeRatesService.deleteExchangeRate( - tenantId, - exchangeRateId - ); - return res.status(200).send({ id: exchangeRateId }); + return res.status(200).send(exchangeRate); } catch (error) { next(error); } @@ -192,26 +60,56 @@ export default class ExchangeRatesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - handleServiceError( + private handleServiceError( error: Error, req: Request, res: Response, next: NextFunction ) { if (error instanceof ServiceError) { - if (error.errorType === 'EXCHANGE_RATE_NOT_FOUND') { - return res.status(404).send({ - errors: [{ type: 'EXCHANGE.RATE.NOT.FOUND', code: 200 }], - }); - } - if (error.errorType === 'NOT_FOUND_EXCHANGE_RATES') { + if (EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY === error.errorType) { return res.status(400).send({ - errors: [{ type: 'EXCHANGE.RATES.IS.NOT.FOUND', code: 100 }], + errors: [ + { + type: EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY, + code: 100, + message: 'The given base currency is invalid.', + }, + ], }); - } - if (error.errorType === 'EXCHANGE_RATE_PERIOD_EXISTS') { + } else if ( + EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED === error.errorType + ) { return res.status(400).send({ - errors: [{ type: 'EXCHANGE.RATE.PERIOD.EXISTS', code: 300 }], + errors: [ + { + type: EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED, + code: 200, + message: 'The service is not allowed', + }, + ], + }); + } else if ( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED === error.errorType + ) { + return res.status(400).send({ + errors: [ + { + type: EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + code: 300, + message: 'The API key is required', + }, + ], + }); + } else if (EchangeRateErrors.EX_RATE_LIMIT_EXCEEDED === error.errorType) { + return res.status(400).send({ + errors: [ + { + type: EchangeRateErrors.EX_RATE_LIMIT_EXCEEDED, + code: 400, + message: 'The API rate limit has been exceeded', + }, + ], }); } } diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 0dc9d9676..4d096875a 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -169,4 +169,14 @@ module.exports = { * to application detarmines to upgrade. */ databaseBatch: 4, + + /** + * Exchange rate. + */ + exchangeRate: { + service: 'open-exchange-rate', + openExchangeRate: { + appId: process.env.OPEN_EXCHANGE_RATE_APP_ID, + } + } }; diff --git a/packages/server/src/lib/ExchangeRate/ExchangeRate.ts b/packages/server/src/lib/ExchangeRate/ExchangeRate.ts new file mode 100644 index 000000000..5bb84cff3 --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/ExchangeRate.ts @@ -0,0 +1,45 @@ +import { OpenExchangeRate } from './OpenExchangeRate'; +import { ExchangeRateServiceType, IExchangeRateService } from './types'; + +export class ExchangeRate { + private exchangeRateService: IExchangeRateService; + private exchangeRateServiceType: ExchangeRateServiceType; + + /** + * Constructor method. + * @param {ExchangeRateServiceType} service + */ + constructor(service: ExchangeRateServiceType) { + this.exchangeRateServiceType = service; + this.initService(); + } + + /** + * Initialize the exchange rate service based on the service type. + */ + private initService() { + if ( + this.exchangeRateServiceType === ExchangeRateServiceType.OpenExchangeRate + ) { + this.setExchangeRateService(new OpenExchangeRate()); + } + } + + /** + * Sets the exchange rate service. + * @param {IExchangeRateService} service + */ + private setExchangeRateService(service: IExchangeRateService) { + this.exchangeRateService = service; + } + + /** + * Gets the latest exchange rate. + * @param {string} baseCurrency + * @param {string} toCurrency + * @returns {number} + */ + public latest(baseCurrency: string, toCurrency: string): Promise { + return this.exchangeRateService.latest(baseCurrency, toCurrency); + } +} diff --git a/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts new file mode 100644 index 000000000..5f0a9b15b --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts @@ -0,0 +1,62 @@ +import Axios, { AxiosError } from 'axios'; +import { + EchangeRateErrors, + IExchangeRateService, + OPEN_EXCHANGE_RATE_LATEST_URL, +} from './types'; +import config from '@/config'; +import { ServiceError } from '@/exceptions'; + +export class OpenExchangeRate implements IExchangeRateService { + /** + * Gets the latest exchange rate. + * @param {string} baseCurrency + * @param {string} toCurrency + * @returns {Promise { + try { + const result = await Axios.get(OPEN_EXCHANGE_RATE_LATEST_URL, { + params: { + app_id: config.exchangeRate.openExchangeRate.appId, + base: baseCurrency, + symbols: toCurrency, + }, + }); + return result.data.rates[toCurrency] || (1 as number); + } catch (error) { + this.handleLatestErrors(error); + } + } + + /** + * Handles the latest errors. + * @param {any} error + */ + private handleLatestErrors(error: any) { + if (error.response.data?.message === 'missing_app_id') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } else if (error.response.data?.message === 'invalid_app_id') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } else if (error.response.data?.message === 'not_allowed') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED, + 'Getting the exchange rate from the given base currency to the given currency is not allowed.' + ); + } else if (error.response.data?.message === 'invalid_base') { + throw new ServiceError( + EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY, + 'The given base currency is invalid.' + ); + } + } +} diff --git a/packages/server/src/lib/ExchangeRate/types.ts b/packages/server/src/lib/ExchangeRate/types.ts new file mode 100644 index 000000000..8b40125cd --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/types.ts @@ -0,0 +1,17 @@ +export interface IExchangeRateService { + latest(baseCurrency: string, toCurrency: string): Promise; +} + +export enum ExchangeRateServiceType { + OpenExchangeRate = 'OpenExchangeRate', +} + +export enum EchangeRateErrors { + EX_RATE_SERVICE_NOT_ALLOWED = 'EX_RATE_SERVICE_NOT_ALLOWED', + EX_RATE_LIMIT_EXCEEDED = 'EX_RATE_LIMIT_EXCEEDED', + EX_RATE_SERVICE_API_KEY_REQUIRED = 'EX_RATE_SERVICE_API_KEY_REQUIRED', + EX_RATE_INVALID_BASE_CURRENCY = 'EX_RATE_INVALID_BASE_CURRENCY', +} + +export const OPEN_EXCHANGE_RATE_LATEST_URL = + 'https://openexchangerates.org/api/latest.json'; diff --git a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts index 9bc63fbfd..bb2437abc 100644 --- a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts +++ b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts @@ -1,193 +1,39 @@ -import moment from 'moment'; -import { difference } from 'lodash'; import { Service, Inject } from 'typedi'; -import { ServiceError } from '@/exceptions'; -import DynamicListingService from '@/services/DynamicListing/DynamicListService'; -import { - EventDispatcher, - EventDispatcherInterface, -} from 'decorators/eventDispatcher'; -import { - IExchangeRateDTO, - IExchangeRate, - IExchangeRatesService, - IExchangeRateEditDTO, - IExchangeRateFilter, -} from '@/interfaces'; -import TenancyService from '@/services/Tenancy/TenancyService'; +import { IExchangeRatesService } from '@/interfaces'; +import { ExchangeRate } from '@/lib/ExchangeRate/ExchangeRate'; +import { ExchangeRateServiceType } from '@/lib/ExchangeRate/types'; +interface ExchangeRateLatestDTO { + toCurrency: string; +} -const ERRORS = { - NOT_FOUND_EXCHANGE_RATES: 'NOT_FOUND_EXCHANGE_RATES', - EXCHANGE_RATE_PERIOD_EXISTS: 'EXCHANGE_RATE_PERIOD_EXISTS', - EXCHANGE_RATE_NOT_FOUND: 'EXCHANGE_RATE_NOT_FOUND', -}; +interface EchangeRateLatestPOJO { + baseCurrency: string; + toCurrency: string; + exchangeRate: number; +} @Service() -export default class ExchangeRatesService implements IExchangeRatesService { - @Inject('logger') - logger: any; - - @EventDispatcher() - eventDispatcher: EventDispatcherInterface; - - @Inject() - tenancy: TenancyService; - - @Inject() - dynamicListService: DynamicListingService; - +export class ExchangeRatesService { /** - * Creates a new exchange rate. + * Gets the latest exchange rate. * @param {number} tenantId - * @param {IExchangeRateDTO} exchangeRateDTO - * @returns {Promise} + * @param {number} exchangeRateLatestDTO + * @returns {EchangeRateLatestPOJO} */ - public async newExchangeRate( + public async latest( tenantId: number, - exchangeRateDTO: IExchangeRateDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to insert new exchange rate.', { - tenantId, - exchangeRateDTO, - }); - await this.validateExchangeRatePeriodExistance(tenantId, exchangeRateDTO); - - const exchangeRate = await ExchangeRate.query().insertAndFetch({ - ...exchangeRateDTO, - date: moment(exchangeRateDTO.date).format('YYYY-MM-DD'), - }); - this.logger.info('[exchange_rates] inserted successfully.', { - tenantId, - exchangeRateDTO, - }); - return exchangeRate; - } - - /** - * Edits the exchange rate details. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - * @param {IExchangeRateEditDTO} editExRateDTO - Edit exchange rate DTO. - */ - public async editExchangeRate( - tenantId: number, - exchangeRateId: number, - editExRateDTO: IExchangeRateEditDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to edit exchange rate.', { - tenantId, - exchangeRateId, - editExRateDTO, - }); - await this.validateExchangeRateExistance(tenantId, exchangeRateId); - - await ExchangeRate.query() - .where('id', exchangeRateId) - .update({ ...editExRateDTO }); - this.logger.info('[exchange_rates] exchange rate edited successfully.', { - tenantId, - exchangeRateId, - editExRateDTO, - }); - } - - /** - * Deletes the given exchange rate. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - */ - public async deleteExchangeRate( - tenantId: number, - exchangeRateId: number - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - await this.validateExchangeRateExistance(tenantId, exchangeRateId); - - await ExchangeRate.query().findById(exchangeRateId).delete(); - } - - /** - * Listing exchange rates details. - * @param {number} tenantId - Tenant id. - * @param {IExchangeRateFilter} exchangeRateFilter - Exchange rates list filter. - */ - public async listExchangeRates( - tenantId: number, - exchangeRateFilter: IExchangeRateFilter - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - const dynamicFilter = await this.dynamicListService.dynamicList( - tenantId, - ExchangeRate, - exchangeRateFilter - ); - // Retrieve exchange rates by the given query. - const exchangeRates = await ExchangeRate.query() - .onBuild((query) => { - dynamicFilter.buildQuery()(query); - }) - .pagination(exchangeRateFilter.page - 1, exchangeRateFilter.pageSize); - - return exchangeRates; - } - - /** - * Validates period of the exchange rate existance. - * @param {number} tenantId - Tenant id. - * @param {IExchangeRateDTO} exchangeRateDTO - Exchange rate DTO. - * @return {Promise} - */ - private async validateExchangeRatePeriodExistance( - tenantId: number, - exchangeRateDTO: IExchangeRateDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to validate period existance.', { - tenantId, - }); - const foundExchangeRate = await ExchangeRate.query() - .where('currency_code', exchangeRateDTO.currencyCode) - .where('date', exchangeRateDTO.date); - - if (foundExchangeRate.length > 0) { - this.logger.info('[exchange_rates] given exchange rate period exists.', { - tenantId, - }); - throw new ServiceError(ERRORS.EXCHANGE_RATE_PERIOD_EXISTS); - } - } - - /** - * Validate the given echange rate id existance. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - * @returns {Promise} - */ - private async validateExchangeRateExistance( - tenantId: number, - exchangeRateId: number - ) { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info( - '[exchange_rates] trying to validate exchange rate id existance.', - { tenantId, exchangeRateId } - ); - const foundExchangeRate = await ExchangeRate.query().findById( - exchangeRateId + exchangeRateLatestDTO: ExchangeRateLatestDTO + ): Promise { + const exchange = new ExchangeRate(ExchangeRateServiceType.OpenExchangeRate); + const exchangeRate = await exchange.latest( + 'USD', + exchangeRateLatestDTO.toCurrency ); - if (!foundExchangeRate) { - this.logger.info('[exchange_rates] exchange rate not found.', { - tenantId, - exchangeRateId, - }); - throw new ServiceError(ERRORS.EXCHANGE_RATE_NOT_FOUND); - } + return { + baseCurrency: 'USD', + toCurrency: exchangeRateLatestDTO.toCurrency, + exchangeRate, + }; } } From 1b20d1b073319e2814c56a96a15e92786771aaaf Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Jan 2024 18:47:49 +0200 Subject: [PATCH 076/148] feat(server): add application layer to exchange rate service --- .../src/api/controllers/ExchangeRates.ts | 8 ++-- .../server/src/interfaces/ExchangeRate.ts | 43 ++++--------------- .../src/lib/ExchangeRate/OpenExchangeRate.ts | 19 ++++++++ .../ExchangeRates/ExchangeRateApplication.ts | 21 +++++++++ .../ExchangeRates/ExchangeRatesService.ts | 13 +----- 5 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 packages/server/src/services/ExchangeRates/ExchangeRateApplication.ts diff --git a/packages/server/src/api/controllers/ExchangeRates.ts b/packages/server/src/api/controllers/ExchangeRates.ts index 4ea59ad1b..5a935a173 100644 --- a/packages/server/src/api/controllers/ExchangeRates.ts +++ b/packages/server/src/api/controllers/ExchangeRates.ts @@ -1,16 +1,16 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { check, param, query } from 'express-validator'; +import { query } from 'express-validator'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from './BaseController'; import { ServiceError } from '@/exceptions'; -import { ExchangeRatesService } from '@/services/ExchangeRates/ExchangeRatesService'; import { EchangeRateErrors } from '@/lib/ExchangeRate/types'; +import { ExchangeRateApplication } from '@/services/ExchangeRates/ExchangeRateApplication'; @Service() export default class ExchangeRatesController extends BaseController { @Inject() - private exchangeRatesService: ExchangeRatesService; + private exchangeRatesApp: ExchangeRateApplication; /** * Constructor method. @@ -43,7 +43,7 @@ export default class ExchangeRatesController extends BaseController { const exchangeRateQuery = this.matchedQueryData(req); try { - const exchangeRate = await this.exchangeRatesService.latest( + const exchangeRate = await this.exchangeRatesApp.latest( tenantId, exchangeRateQuery ); diff --git a/packages/server/src/interfaces/ExchangeRate.ts b/packages/server/src/interfaces/ExchangeRate.ts index fc3bd33e4..757ca5f3d 100644 --- a/packages/server/src/interfaces/ExchangeRate.ts +++ b/packages/server/src/interfaces/ExchangeRate.ts @@ -1,36 +1,9 @@ -import { IFilterRole } from './DynamicFilter'; +export interface ExchangeRateLatestDTO { + toCurrency: string; +} -export interface IExchangeRate { - id: number, - currencyCode: string, - exchangeRate: number, - date: Date, - createdAt: Date, - updatedAt: Date, -}; - -export interface IExchangeRateDTO { - currencyCode: string, - exchangeRate: number, - date: Date, -}; - -export interface IExchangeRateEditDTO { - exchangeRate: number, -}; - -export interface IExchangeRateFilter { - page: number, - pageSize: number, - filterRoles?: IFilterRole[]; - columnSortBy: string; - sortOrder: string; -}; - -export interface IExchangeRatesService { - newExchangeRate(tenantId: number, exchangeRateDTO: IExchangeRateDTO): Promise; - editExchangeRate(tenantId: number, exchangeRateId: number, editExRateDTO: IExchangeRateEditDTO): Promise; - - deleteExchangeRate(tenantId: number, exchangeRateId: number): Promise; - listExchangeRates(tenantId: number, exchangeRateFilter: IExchangeRateFilter): Promise; -}; \ No newline at end of file +export interface EchangeRateLatestPOJO { + baseCurrency: string; + toCurrency: string; + exchangeRate: number; +} diff --git a/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts index 5f0a9b15b..221c5c5c5 100644 --- a/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts +++ b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts @@ -18,6 +18,9 @@ export class OpenExchangeRate implements IExchangeRateService { baseCurrency: string, toCurrency: string ): Promise { + // Vaclidates the Open Exchange Rate api id early. + this.validateApiIdExistance(); + try { const result = await Axios.get(OPEN_EXCHANGE_RATE_LATEST_URL, { params: { @@ -32,9 +35,25 @@ export class OpenExchangeRate implements IExchangeRateService { } } + /** + * Validates the Open Exchange Rate api id. + * @throws {ServiceError} + */ + private validateApiIdExistance() { + const apiId = config.exchangeRate.openExchangeRate.appId; + + if (!apiId) { + throw new ServiceError( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + 'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.' + ); + } + } + /** * Handles the latest errors. * @param {any} error + * @throws {ServiceError} */ private handleLatestErrors(error: any) { if (error.response.data?.message === 'missing_app_id') { diff --git a/packages/server/src/services/ExchangeRates/ExchangeRateApplication.ts b/packages/server/src/services/ExchangeRates/ExchangeRateApplication.ts new file mode 100644 index 000000000..9e51c5d72 --- /dev/null +++ b/packages/server/src/services/ExchangeRates/ExchangeRateApplication.ts @@ -0,0 +1,21 @@ +import { Inject } from 'typedi'; +import { ExchangeRatesService } from './ExchangeRatesService'; +import { EchangeRateLatestPOJO, ExchangeRateLatestDTO } from '@/interfaces'; + +export class ExchangeRateApplication { + @Inject() + private exchangeRateService: ExchangeRatesService; + + /** + * Gets the latest exchange rate. + * @param {number} tenantId + * @param {ExchangeRateLatestDTO} exchangeRateLatestDTO + * @returns {Promise} + */ + public latest( + tenantId: number, + exchangeRateLatestDTO: ExchangeRateLatestDTO + ): Promise { + return this.exchangeRateService.latest(tenantId, exchangeRateLatestDTO); + } +} diff --git a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts index bb2437abc..ae322cdff 100644 --- a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts +++ b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts @@ -1,16 +1,7 @@ -import { Service, Inject } from 'typedi'; -import { IExchangeRatesService } from '@/interfaces'; +import { Service } from 'typedi'; import { ExchangeRate } from '@/lib/ExchangeRate/ExchangeRate'; import { ExchangeRateServiceType } from '@/lib/ExchangeRate/types'; -interface ExchangeRateLatestDTO { - toCurrency: string; -} - -interface EchangeRateLatestPOJO { - baseCurrency: string; - toCurrency: string; - exchangeRate: number; -} +import { EchangeRateLatestPOJO, ExchangeRateLatestDTO } from '@/interfaces'; @Service() export class ExchangeRatesService { From 1740226294f2289666565ecaf6796094bef7a3fc Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Jan 2024 18:48:35 +0200 Subject: [PATCH 077/148] feat(webapp): hook up latest exchange rate api --- .../Entries/AutoExchangeProvider.tsx | 9 ++--- .../webapp/src/hooks/query/exchangeRates.tsx | 39 ++++++++----------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx b/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx index 6554b85a1..dcaf86283 100644 --- a/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx +++ b/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx @@ -1,6 +1,5 @@ -import { useExchangeRate } from '@/hooks/query'; -import { useCurrentOrganization } from '@/hooks/state'; import React from 'react'; +import { useLatestExchangeRate } from '@/hooks/query'; interface AutoExchangeRateProviderProps { children: React.ReactNode; @@ -18,15 +17,15 @@ const AutoExchangeRateContext = React.createContext( function AutoExchangeRateProvider({ children }: AutoExchangeRateProviderProps) { const [autoExRateCurrency, setAutoExRateCurrency] = React.useState(''); - const currentOrganization = useCurrentOrganization(); // Retrieves the exchange rate. const { data: autoExchangeRate, isLoading: isAutoExchangeRateLoading } = - useExchangeRate(autoExRateCurrency, currentOrganization.base_currency, { - enabled: Boolean(currentOrganization.base_currency && autoExRateCurrency), + useLatestExchangeRate(autoExRateCurrency, { + enabled: Boolean(autoExRateCurrency), refetchOnWindowFocus: false, staleTime: 0, cacheTime: 0, + retry: 0, }); const value = { diff --git a/packages/webapp/src/hooks/query/exchangeRates.tsx b/packages/webapp/src/hooks/query/exchangeRates.tsx index f56958040..a0f58f7ac 100644 --- a/packages/webapp/src/hooks/query/exchangeRates.tsx +++ b/packages/webapp/src/hooks/query/exchangeRates.tsx @@ -1,34 +1,27 @@ // @ts-nocheck import { useQuery } from 'react-query'; import QUERY_TYPES from './types'; +import useApiRequest from '../useRequest'; -function getRandomItemFromArray(arr) { - const randomIndex = Math.floor(Math.random() * arr.length); - return arr[randomIndex]; -} -function delay(t, val) { - return new Promise((resolve) => setTimeout(resolve, t, val)); -} /** - * Retrieves tax rates. + * Retrieves latest exchange rate. * @param {number} customerId - Customer id. */ -export function useExchangeRate( - fromCurrency: string, - toCurrency: string, - props, -) { - return useQuery( - [QUERY_TYPES.EXCHANGE_RATE, fromCurrency, toCurrency], - async () => { - await delay(100); +export function useLatestExchangeRate(toCurrency: string, props) { + const apiRequest = useApiRequest(); - return { - from_currency: fromCurrency, - to_currency: toCurrency, - exchange_rate: 1.00, - }; - }, + return useQuery( + [QUERY_TYPES.EXCHANGE_RATE, toCurrency], + () => + apiRequest + .http({ + url: `/api/exchange_rates/latest`, + method: 'get', + params: { + to_currency: toCurrency, + }, + }) + .then((res) => res.data), props, ); } From 74a07847a4ce8fb1983a18beaadb320918f77dff Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Jan 2024 20:10:23 +0200 Subject: [PATCH 078/148] feat(server): ability to assign the base currency as api query when getting latest ex. rate --- .../server/src/api/controllers/ExchangeRates.ts | 9 +++++++-- packages/server/src/interfaces/ExchangeRate.ts | 1 + .../ExchangeRates/ExchangeRatesService.ts | 17 ++++++++++++----- .../server/src/system/models/TenantMetadata.ts | 2 ++ .../containers/Entries/AutoExchangeProvider.tsx | 17 ++++++++++------- .../webapp/src/hooks/query/exchangeRates.tsx | 13 +++++++++++-- 6 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/server/src/api/controllers/ExchangeRates.ts b/packages/server/src/api/controllers/ExchangeRates.ts index 5a935a173..63c476bf9 100644 --- a/packages/server/src/api/controllers/ExchangeRates.ts +++ b/packages/server/src/api/controllers/ExchangeRates.ts @@ -1,6 +1,6 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { query } from 'express-validator'; +import { query, oneOf } from 'express-validator'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from './BaseController'; import { ServiceError } from '@/exceptions'; @@ -20,7 +20,12 @@ export default class ExchangeRatesController extends BaseController { router.get( '/latest', - [query('to_currency').exists().isString()], + [ + oneOf([ + query('to_currency').exists().isString().isISO4217(), + query('from_currency').exists().isString().isISO4217(), + ]), + ], this.validationResult, asyncMiddleware(this.latestExchangeRate.bind(this)), this.handleServiceError diff --git a/packages/server/src/interfaces/ExchangeRate.ts b/packages/server/src/interfaces/ExchangeRate.ts index 757ca5f3d..45080fc0f 100644 --- a/packages/server/src/interfaces/ExchangeRate.ts +++ b/packages/server/src/interfaces/ExchangeRate.ts @@ -1,5 +1,6 @@ export interface ExchangeRateLatestDTO { toCurrency: string; + fromCurrency: string; } export interface EchangeRateLatestPOJO { diff --git a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts index ae322cdff..4cde544ad 100644 --- a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts +++ b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts @@ -2,6 +2,7 @@ import { Service } from 'typedi'; import { ExchangeRate } from '@/lib/ExchangeRate/ExchangeRate'; import { ExchangeRateServiceType } from '@/lib/ExchangeRate/types'; import { EchangeRateLatestPOJO, ExchangeRateLatestDTO } from '@/interfaces'; +import { TenantMetadata } from '@/system/models'; @Service() export class ExchangeRatesService { @@ -15,14 +16,20 @@ export class ExchangeRatesService { tenantId: number, exchangeRateLatestDTO: ExchangeRateLatestDTO ): Promise { + const organization = await TenantMetadata.query().findOne({ tenantId }); + + // Assign the organization base currency as a default currency + // if no currency is provided + const fromCurrency = + exchangeRateLatestDTO.fromCurrency || organization.baseCurrency; + const toCurrency = + exchangeRateLatestDTO.toCurrency || organization.baseCurrency; + const exchange = new ExchangeRate(ExchangeRateServiceType.OpenExchangeRate); - const exchangeRate = await exchange.latest( - 'USD', - exchangeRateLatestDTO.toCurrency - ); + const exchangeRate = await exchange.latest(fromCurrency, toCurrency); return { - baseCurrency: 'USD', + baseCurrency: fromCurrency, toCurrency: exchangeRateLatestDTO.toCurrency, exchangeRate, }; diff --git a/packages/server/src/system/models/TenantMetadata.ts b/packages/server/src/system/models/TenantMetadata.ts index 4664cfd6d..7040a6a68 100644 --- a/packages/server/src/system/models/TenantMetadata.ts +++ b/packages/server/src/system/models/TenantMetadata.ts @@ -1,6 +1,8 @@ import BaseModel from 'models/Model'; export default class TenantMetadata extends BaseModel { + baseCurrency: string; + /** * Table name. */ diff --git a/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx b/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx index dcaf86283..c4b5ea1f9 100644 --- a/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx +++ b/packages/webapp/src/containers/Entries/AutoExchangeProvider.tsx @@ -20,13 +20,16 @@ function AutoExchangeRateProvider({ children }: AutoExchangeRateProviderProps) { // Retrieves the exchange rate. const { data: autoExchangeRate, isLoading: isAutoExchangeRateLoading } = - useLatestExchangeRate(autoExRateCurrency, { - enabled: Boolean(autoExRateCurrency), - refetchOnWindowFocus: false, - staleTime: 0, - cacheTime: 0, - retry: 0, - }); + useLatestExchangeRate( + { fromCurrency: autoExRateCurrency }, + { + enabled: Boolean(autoExRateCurrency), + refetchOnWindowFocus: false, + staleTime: 0, + cacheTime: 0, + retry: 0, + }, + ); const value = { autoExRateCurrency, diff --git a/packages/webapp/src/hooks/query/exchangeRates.tsx b/packages/webapp/src/hooks/query/exchangeRates.tsx index a0f58f7ac..36700276b 100644 --- a/packages/webapp/src/hooks/query/exchangeRates.tsx +++ b/packages/webapp/src/hooks/query/exchangeRates.tsx @@ -3,15 +3,23 @@ import { useQuery } from 'react-query'; import QUERY_TYPES from './types'; import useApiRequest from '../useRequest'; +interface LatestExchangeRateQuery { + fromCurrency?: string; + toCurrency?: string; +} + /** * Retrieves latest exchange rate. * @param {number} customerId - Customer id. */ -export function useLatestExchangeRate(toCurrency: string, props) { +export function useLatestExchangeRate( + { toCurrency, fromCurrency }: LatestExchangeRateQuery, + props, +) { const apiRequest = useApiRequest(); return useQuery( - [QUERY_TYPES.EXCHANGE_RATE, toCurrency], + [QUERY_TYPES.EXCHANGE_RATE, toCurrency, fromCurrency], () => apiRequest .http({ @@ -19,6 +27,7 @@ export function useLatestExchangeRate(toCurrency: string, props) { method: 'get', params: { to_currency: toCurrency, + from_currency: fromCurrency, }, }) .then((res) => res.data), From 59c5c8979de223fe41526eee95d28a3ce3067c5a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 29 Jan 2024 18:39:28 +0200 Subject: [PATCH 079/148] feat(webapp): add default exchange rate value to 1 in cause the ex. rate service returned error or failed. --- .../withExRateItemEntriesPriceRecalc.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/webapp/src/containers/Entries/withExRateItemEntriesPriceRecalc.tsx b/packages/webapp/src/containers/Entries/withExRateItemEntriesPriceRecalc.tsx index 490ef20b5..a1fd7af39 100644 --- a/packages/webapp/src/containers/Entries/withExRateItemEntriesPriceRecalc.tsx +++ b/packages/webapp/src/containers/Entries/withExRateItemEntriesPriceRecalc.tsx @@ -98,24 +98,30 @@ interface UseSyncExRateToFormProps { */ export const useSyncExRateToForm = ({ onSynced }: UseSyncExRateToFormProps) => { const { setFieldValue, values } = useFormikContext(); - const { autoExRateCurrency, autoExchangeRate } = useAutoExRateContext(); + const { autoExRateCurrency, autoExchangeRate, isAutoExchangeRateLoading } = + 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 + ''); + if (!isAutoExchangeRateLoading && autoExRateCurrency) { + // Sets a default ex. rate to 1 in case the exchange rate service wasn't configured. + // or returned an error from the server-side. + const exchangeRate = autoExchangeRate?.exchange_rate || 1; + + setFieldValue('exchange_rate', exchangeRate + ''); setFieldValue( 'entries', - updateEntriesOnExChange( - values.exchange_rate, - autoExchangeRate?.exchange_rate, - ), + updateEntriesOnExChange(values.exchange_rate, exchangeRate), ); onSynced?.(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoExchangeRate?.exchange_rate, autoExRateCurrency]); + }, [ + autoExchangeRate?.exchange_rate, + autoExRateCurrency, + isAutoExchangeRateLoading, + ]); return null; }; From f93c8b46dcf222268057424869160943bc8cfb1a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 29 Jan 2024 18:42:54 +0200 Subject: [PATCH 080/148] chore: change env name in .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 7af1c6061..f32107eb4 100644 --- a/.env.example +++ b/.env.example @@ -63,4 +63,4 @@ GOTENBERG_DOCS_URL=http://server:3000/public/ EXCHANGE_RATE_SERVICE=open-exchange-rate # Open Exchange Rate -OPEN_EXCHANGE_RATE_APP_I= \ No newline at end of file +OPEN_EXCHANGE_RATE_APP_ID= \ No newline at end of file From d01eacf8d962f25899139bfd3b3ab0381f7e9841 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 29 Jan 2024 19:18:59 +0200 Subject: [PATCH 081/148] feat(server): add `formattedAmount` to purchase invoice to POJO --- .../src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts index 4ec163d8b..4cb92adf9 100644 --- a/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts +++ b/packages/server/src/services/Purchases/Bills/PurchaseInvoiceTransformer.ts @@ -13,6 +13,7 @@ export class PurchaseInvoiceTransformer extends Transformer { return [ 'formattedBillDate', 'formattedDueDate', + 'formattedAmount', 'formattedPaymentAmount', 'formattedBalance', 'formattedDueAmount', From a52f3a933fe6073e5fee16d41971e03a0feeebf9 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 29 Jan 2024 19:19:46 +0200 Subject: [PATCH 082/148] fix(webapp): inconsistency in currency of universal search items --- .../CreditNotes/VendorCreditIUniversalSearchBind.tsx | 4 ++-- .../Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx | 1 + .../Sales/CreditNotes/CreditNoteUniversalSearch.tsx | 2 +- .../src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx | 2 +- .../src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx | 2 +- .../webapp/src/containers/Vendors/VendorsUniversalSearch.tsx | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx b/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx index fa82987e3..a360ab617 100644 --- a/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx +++ b/packages/webapp/src/containers/Purchases/CreditNotes/VendorCreditIUniversalSearchBind.tsx @@ -22,7 +22,7 @@ function VendorCreditUniversalSearchSelectComponent({ openDrawer, }) { if (resourceType === RESOURCES_TYPES.VENDOR_CREDIT) { - openDrawer(DRAWERS.VENDOR_CREDIT_DETAIL_DRAWER, { + openDrawer(DRAWERS.VENDOR_CREDIT_DETAILS, { vendorCreditId: resourceId, }); onAction && onAction(); @@ -83,7 +83,7 @@ export function VendorCreditUniversalSearchItem( } label={ <> -
${item.reference.amount}
+
{item.reference.formatted_amount}
} diff --git a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx index f1cdc60ce..890401038 100644 --- a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx +++ b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentMadeUniversalSearch.tsx @@ -7,6 +7,7 @@ import { Icon } from '@/components'; import { RESOURCES_TYPES } from '@/constants/resourcesTypes'; import { highlightText } from '@/utils'; import { AbilitySubject, PaymentMadeAction } from '@/constants/abilityOption'; +import { DRAWERS } from '@/constants/drawers'; import withDrawerActions from '@/containers/Drawer/withDrawerActions'; /** diff --git a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx index 2c495f802..8407a4f39 100644 --- a/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/CreditNotes/CreditNoteUniversalSearch.tsx @@ -82,7 +82,7 @@ export function CreditNoteUniversalSearchItem( } label={ <> -
${item.reference.amount}
+
{item.reference.formatted_amount}
} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx index 1656b9bc7..a86141612 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceUniversalSearch.tsx @@ -89,7 +89,7 @@ export function InvoiceUniversalSearchItem( } label={ <> -
${item.reference.total_formatted}
+
{item.reference.total_formatted}
} diff --git a/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx b/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx index 2eb9405ac..abaea4004 100644 --- a/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx +++ b/packages/webapp/src/containers/Sales/Receipts/ReceiptUniversalSearch.tsx @@ -75,7 +75,7 @@ export function ReceiptUniversalSearchItem( } label={ <> -
${item.reference.formatted_amount}
+
{item.reference.formatted_amount}
} diff --git a/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx b/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx index 84ccdfcb7..8134cc25c 100644 --- a/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx +++ b/packages/webapp/src/containers/Vendors/VendorsUniversalSearch.tsx @@ -34,7 +34,7 @@ const VendorUniversalSearchSelectAction = withDrawerActions( const vendorToSearch = (contact) => ({ id: contact.id, text: contact.display_name, - label: contact.balance > 0 ? contact.formatted_balance + '' : '', + label: contact.formatted_balance, reference: contact, }); From ba387e81f761f87b7e1e38ff572e4d78cf7dcea3 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 29 Jan 2024 23:25:37 +0200 Subject: [PATCH 083/148] hotfix: editing sales and expense transactions don't reflect GL entries --- .../server/src/api/controllers/Purchases/VendorCredit.ts | 9 ++++----- .../server/src/services/CreditNotes/EditCreditNote.ts | 2 +- .../server/src/services/Expenses/CRUD/EditExpense.ts | 2 +- .../src/services/Expenses/ExpenseGLEntriesStorage.ts | 2 +- .../src/services/Expenses/ExpenseGLEntriesSubscriber.ts | 6 +++--- .../services/Purchases/VendorCredits/EditVendorCredit.ts | 6 +++++- .../src/services/Sales/Receipts/SaleReceiptGLEntries.ts | 2 +- packages/webapp/src/hooks/query/bills.tsx | 3 +++ packages/webapp/src/hooks/query/creditNote.tsx | 3 +++ packages/webapp/src/hooks/query/invoices.tsx | 3 +++ packages/webapp/src/hooks/query/paymentReceives.tsx | 3 +++ packages/webapp/src/hooks/query/receipts.tsx | 3 +++ packages/webapp/src/hooks/query/vendorCredit.tsx | 3 +++ 13 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/server/src/api/controllers/Purchases/VendorCredit.ts b/packages/server/src/api/controllers/Purchases/VendorCredit.ts index 3d2832308..79ae6741b 100644 --- a/packages/server/src/api/controllers/Purchases/VendorCredit.ts +++ b/packages/server/src/api/controllers/Purchases/VendorCredit.ts @@ -320,20 +320,19 @@ export default class VendorCreditController extends BaseController { res: Response, next: NextFunction ) => { - const { id: billId } = req.params; + const { id: vendorCreditId } = req.params; const { tenantId, user } = req; const vendorCreditEditDTO: IVendorCreditEditDTO = this.matchedBodyData(req); try { await this.editVendorCreditService.editVendorCredit( tenantId, - billId, - vendorCreditEditDTO, - user + vendorCreditId, + vendorCreditEditDTO ); return res.status(200).send({ - id: billId, + id: vendorCreditId, message: 'The vendor credit has been edited successfully.', }); } catch (error) { diff --git a/packages/server/src/services/CreditNotes/EditCreditNote.ts b/packages/server/src/services/CreditNotes/EditCreditNote.ts index 074115c04..0e045227d 100644 --- a/packages/server/src/services/CreditNotes/EditCreditNote.ts +++ b/packages/server/src/services/CreditNotes/EditCreditNote.ts @@ -80,7 +80,7 @@ export default class EditCreditNote extends BaseCreditNotes { } as ICreditNoteEditingPayload); // Saves the credit note graph to the storage. - const creditNote = await CreditNote.query(trx).upsertGraph({ + const creditNote = await CreditNote.query(trx).upsertGraphAndFetch({ id: creditNoteId, ...creditNoteModel, }); diff --git a/packages/server/src/services/Expenses/CRUD/EditExpense.ts b/packages/server/src/services/Expenses/CRUD/EditExpense.ts index 93b0acc62..e3aeb06ce 100644 --- a/packages/server/src/services/Expenses/CRUD/EditExpense.ts +++ b/packages/server/src/services/Expenses/CRUD/EditExpense.ts @@ -136,7 +136,7 @@ export class EditExpense { } as IExpenseEventEditingPayload); // Upsert the expense object with expense entries. - const expense: IExpense = await Expense.query(trx).upsertGraph({ + const expense: IExpense = await Expense.query(trx).upsertGraphAndFetch({ id: expenseId, ...expenseObj, }); diff --git a/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts b/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts index 0821c3bd4..76b450c39 100644 --- a/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts +++ b/packages/server/src/services/Expenses/ExpenseGLEntriesStorage.ts @@ -1,7 +1,7 @@ import { Knex } from 'knex'; +import { Service, Inject } from 'typedi'; import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { Service, Inject } from 'typedi'; import { ExpenseGLEntries } from './ExpenseGLEntries'; @Service() diff --git a/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts b/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts index 1a177876e..4c69b7f04 100644 --- a/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts +++ b/packages/server/src/services/Expenses/ExpenseGLEntriesSubscriber.ts @@ -70,10 +70,10 @@ export class ExpensesWriteGLSubscriber { authorizedUser, trx, }: IExpenseEventEditPayload) => { - // In case expense published, write journal entries. - if (expense.publishedAt) return; + // Cannot continue if the expense is not published. + if (!expense.publishedAt) return; - await this.expenseGLEntries.writeExpenseGLEntries( + await this.expenseGLEntries.rewriteExpenseGLEntries( tenantId, expense.id, trx diff --git a/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts b/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts index d76dd3690..a0ff0f421 100644 --- a/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts +++ b/packages/server/src/services/Purchases/VendorCredits/EditVendorCredit.ts @@ -9,6 +9,7 @@ import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import ItemsEntriesService from '@/services/Items/ItemsEntriesService'; import events from '@/subscribers/events'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; @Service() export default class EditVendorCredit extends BaseVendorCredit { @@ -21,6 +22,9 @@ export default class EditVendorCredit extends BaseVendorCredit { @Inject() private itemsEntriesService: ItemsEntriesService; + @Inject() + private tenancy: HasTenancyService; + /** * Deletes the given vendor credit. * @param {number} tenantId - Tenant id. @@ -31,7 +35,7 @@ export default class EditVendorCredit extends BaseVendorCredit { vendorCreditId: number, vendorCreditDTO: IVendorCreditEditDTO ) => { - const { VendorCredit } = this.tenancy.models(tenantId); + const { VendorCredit, Contact } = this.tenancy.models(tenantId); // Retrieve the vendor credit or throw not found service error. const oldVendorCredit = await this.getVendorCreditOrThrowError( diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts index df440958f..d354141e9 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptGLEntries.ts @@ -32,7 +32,7 @@ export class SaleReceiptGLEntries { ): Promise => { const { SaleReceipt } = this.tenancy.models(tenantId); - const saleReceipt = await SaleReceipt.query() + const saleReceipt = await SaleReceipt.query(trx) .findById(saleReceiptId) .withGraphFetched('entries.item'); diff --git a/packages/webapp/src/hooks/query/bills.tsx b/packages/webapp/src/hooks/query/bills.tsx index 828621d89..1ec3ef0e1 100644 --- a/packages/webapp/src/hooks/query/bills.tsx +++ b/packages/webapp/src/hooks/query/bills.tsx @@ -32,6 +32,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate the transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate items associated bills transactions. queryClient.invalidateQueries(t.ITEMS_ASSOCIATED_WITH_BILLS); diff --git a/packages/webapp/src/hooks/query/creditNote.tsx b/packages/webapp/src/hooks/query/creditNote.tsx index 99cfd535d..0fb31f873 100644 --- a/packages/webapp/src/hooks/query/creditNote.tsx +++ b/packages/webapp/src/hooks/query/creditNote.tsx @@ -44,6 +44,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate mutate base currency abilities. queryClient.invalidateQueries(t.ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES); }; diff --git a/packages/webapp/src/hooks/query/invoices.tsx b/packages/webapp/src/hooks/query/invoices.tsx index 2adbf87e0..886b04e7d 100644 --- a/packages/webapp/src/hooks/query/invoices.tsx +++ b/packages/webapp/src/hooks/query/invoices.tsx @@ -24,6 +24,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + + // Invalidate transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); // Invalidate accounts. queryClient.invalidateQueries(t.ACCOUNTS); diff --git a/packages/webapp/src/hooks/query/paymentReceives.tsx b/packages/webapp/src/hooks/query/paymentReceives.tsx index 376993e19..5ed0118ee 100644 --- a/packages/webapp/src/hooks/query/paymentReceives.tsx +++ b/packages/webapp/src/hooks/query/paymentReceives.tsx @@ -24,6 +24,9 @@ const commonInvalidateQueries = (client) => { // Invalidate financial reports. client.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate transactions by reference. + client.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate customers. client.invalidateQueries(t.CUSTOMERS); client.invalidateQueries(t.CUSTOMER); diff --git a/packages/webapp/src/hooks/query/receipts.tsx b/packages/webapp/src/hooks/query/receipts.tsx index 7a6ae2ce9..fed4ad5af 100644 --- a/packages/webapp/src/hooks/query/receipts.tsx +++ b/packages/webapp/src/hooks/query/receipts.tsx @@ -21,6 +21,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate the transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate the cashflow transactions. queryClient.invalidateQueries(t.CASH_FLOW_TRANSACTIONS); queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY); diff --git a/packages/webapp/src/hooks/query/vendorCredit.tsx b/packages/webapp/src/hooks/query/vendorCredit.tsx index 48e424099..0d2131543 100644 --- a/packages/webapp/src/hooks/query/vendorCredit.tsx +++ b/packages/webapp/src/hooks/query/vendorCredit.tsx @@ -43,6 +43,9 @@ const commonInvalidateQueries = (queryClient) => { // Invalidate financial reports. queryClient.invalidateQueries(t.FINANCIAL_REPORT); + // Invalidate the transactions by reference. + queryClient.invalidateQueries(t.TRANSACTIONS_BY_REFERENCE); + // Invalidate mutate base currency abilities. queryClient.invalidateQueries(t.ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES); }; From ba3ea93a2d13f2b977a5b3899fff74271f227a1b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 30 Jan 2024 00:28:23 +0200 Subject: [PATCH 084/148] chore: dump CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c2938ad4..ae75e5f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Bigcapital server-side will be in this file. +## [0.14.0] - 30-01-2024 + +* feat: purchases by items exporting by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/327 +* fix: expense amounts should not be rounded by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/339 +* feat: get latest exchange rate from third party services by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/340 +* fix(webapp): inconsistency in currency of universal search items by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/335 +* hotfix: editing sales and expense transactions don't reflect GL entries by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/342 + ## [0.13.3] - 22-01-2024 * hotfix(server): Unhandled thrown errors of services by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/329 From b9886cfac3d2d1a3175cc0255231e1b570913e06 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 30 Jan 2024 22:51:55 +0200 Subject: [PATCH 085/148] feat(server): api endpoint to get Plaid link token --- packages/server/package.json | 1 + .../controllers/Banking/BankingController.ts | 18 +++ .../Banking/PlaidBankingController.ts | 29 +++++ packages/server/src/api/index.ts | 2 + packages/server/src/config/index.ts | 16 ++- packages/server/src/lib/Plaid/Plaid.ts | 103 ++++++++++++++++++ .../src/lib/Plaid/PlaidApiEventsDBSync.ts | 48 ++++++++ packages/server/src/lib/Plaid/index.ts | 1 + .../Banking/Plaid/PlaidApplication.ts | 18 +++ .../services/Banking/Plaid/PlaidLinkToken.ts | 37 +++++++ 10 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/api/controllers/Banking/BankingController.ts create mode 100644 packages/server/src/api/controllers/Banking/PlaidBankingController.ts create mode 100644 packages/server/src/lib/Plaid/Plaid.ts create mode 100644 packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts create mode 100644 packages/server/src/lib/Plaid/index.ts create mode 100644 packages/server/src/services/Banking/Plaid/PlaidApplication.ts create mode 100644 packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts diff --git a/packages/server/package.json b/packages/server/package.json index d359376de..5265e986d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -91,6 +91,7 @@ "pluralize": "^8.0.0", "pug": "^3.0.2", "puppeteer": "^10.2.0", + "plaid": "^10.3.0", "qim": "0.0.52", "ramda": "^0.27.1", "rate-limiter-flexible": "^2.1.14", diff --git a/packages/server/src/api/controllers/Banking/BankingController.ts b/packages/server/src/api/controllers/Banking/BankingController.ts new file mode 100644 index 000000000..27838a285 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -0,0 +1,18 @@ +import Container, { Inject, Service } from 'typedi'; +import { Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { PlaidBankingController } from './PlaidBankingController'; + +@Service() +export class BankingController extends BaseController { + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use('/plaid', Container.get(PlaidBankingController).router()); + + return router; + } +} diff --git a/packages/server/src/api/controllers/Banking/PlaidBankingController.ts b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts new file mode 100644 index 000000000..ceef9383b --- /dev/null +++ b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts @@ -0,0 +1,29 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication'; + +@Service() +export class PlaidBankingController extends BaseController { + @Inject() + private plaidApp: PlaidApplication; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post('/link-token', this.linkToken.bind(this)); + + return router; + } + + private async linkToken(req: Request, res: Response) { + const { tenantId } = req; + + const linkToken = await this.plaidApp.getLinkToken(tenantId); + + return res.status(200).send(linkToken); + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 6a41c8304..fdc7d5e3a 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -56,6 +56,7 @@ import { ProjectsController } from './controllers/Projects/Projects'; import { ProjectTasksController } from './controllers/Projects/Tasks'; import { ProjectTimesController } from './controllers/Projects/Times'; import { TaxRatesController } from './controllers/TaxRates/TaxRates'; +import { BankingController } from './controllers/Banking/BankingController'; export default () => { const app = Router(); @@ -118,6 +119,7 @@ export default () => { Container.get(InventoryItemsCostController).router() ); dashboard.use('/cashflow', Container.get(CashflowController).router()); + dashboard.use('/banking', Container.get(BankingController).router()); dashboard.use('/roles', Container.get(RolesController).router()); dashboard.use( '/transactions-locking', diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 4d096875a..c38472b0c 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -177,6 +177,18 @@ module.exports = { service: 'open-exchange-rate', openExchangeRate: { appId: process.env.OPEN_EXCHANGE_RATE_APP_ID, - } - } + }, + }, + + /** + * Plaid. + */ + plaid: { + env: process.env.PLAID_ENV || 'sandbox', + clientId: process.env.PLAID_CLIENT_ID, + secretDevelopment: process.env.PLAID_SECRET_DEVELOPMENT, + secretSandbox: process.env.PLAID_SECRET_SANDBOX, + redirectSandBox: process.env.PLAID_SANDBOX_REDIRECT_URI, + redirectDevelopment: process.env.PLAID_DEVELOPMENT_REDIRECT_URI, + }, }; diff --git a/packages/server/src/lib/Plaid/Plaid.ts b/packages/server/src/lib/Plaid/Plaid.ts new file mode 100644 index 000000000..ccc2a7f10 --- /dev/null +++ b/packages/server/src/lib/Plaid/Plaid.ts @@ -0,0 +1,103 @@ +import { forEach } from 'lodash'; +import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid'; +import { createPlaidApiEvent } from './PlaidApiEventsDBSync'; +import config from '@/config'; + +const OPTIONS = { clientApp: 'Plaid-Pattern' }; + +// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data +// can be useful for troubleshooting. + +/** + * Logging function for Plaid client methods that use an access_token as an argument. Associates + * the Plaid API event log entry with the item and user the request is for. + * + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. + */ +const defaultLogger = async (clientMethod, clientMethodArgs, response) => { + const accessToken = clientMethodArgs[0].access_token; + // const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken( + // accessToken + // ); + // await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response); + + console.log(response); +}; + +/** + * Logging function for Plaid client methods that do not use access_token as an argument. These + * Plaid API event log entries will not be associated with an item or user. + * + * @param {string} clientMethod the name of the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the response from the Plaid client. + */ +const noAccessTokenLogger = async ( + clientMethod, + clientMethodArgs, + response +) => { + console.log(response); + + // await createPlaidApiEvent( + // undefined, + // undefined, + // clientMethod, + // clientMethodArgs, + // response + // ); +}; + +// Plaid client methods used in this app, mapped to their appropriate logging functions. +const clientMethodLoggingFns = { + accountsGet: defaultLogger, + institutionsGet: noAccessTokenLogger, + institutionsGetById: noAccessTokenLogger, + itemPublicTokenExchange: noAccessTokenLogger, + itemRemove: defaultLogger, + linkTokenCreate: noAccessTokenLogger, + transactionsSync: defaultLogger, + sandboxItemResetLogin: defaultLogger, +}; +// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests. +export class PlaidClientWrapper { + constructor() { + // Initialize the Plaid client. + const configuration = new Configuration({ + basePath: PlaidEnvironments[config.plaid.env], + baseOptions: { + headers: { + 'PLAID-CLIENT-ID': config.plaid.clientId, + 'PLAID-SECRET': + config.plaid.env === 'development' + ? config.plaid.secretDevelopment + : config.plaid.secretSandbox, + 'Plaid-Version': '2020-09-14', + }, + }, + }); + + this.client = new PlaidApi(configuration); + + // Wrap the Plaid client methods to add a logging function. + forEach(clientMethodLoggingFns, (logFn, method) => { + this[method] = this.createWrappedClientMethod(method, logFn); + }); + } + + // Allows us to log API request data for troubleshooting purposes. + createWrappedClientMethod(clientMethod, log) { + return async (...args) => { + try { + const res = await this.client[clientMethod](...args); + await log(clientMethod, args, res); + return res; + } catch (err) { + await log(clientMethod, args, err?.response?.data); + throw err; + } + }; + } +} diff --git a/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts b/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts new file mode 100644 index 000000000..9d257b727 --- /dev/null +++ b/packages/server/src/lib/Plaid/PlaidApiEventsDBSync.ts @@ -0,0 +1,48 @@ +/** + * Creates a single Plaid api event log entry. + * + * @param {string} itemId the item id in the request. + * @param {string} userId the user id in the request. + * @param {string} plaidMethod the Plaid client method called. + * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. + * @param {Object} response the Plaid api response object. + */ +export const createPlaidApiEvent = async ( + itemId, + userId, + plaidMethod, + clientMethodArgs, + response +) => { + const { + error_code: errorCode, + error_type: errorType, + request_id: requestId, + } = response; + const query = { + text: ` + INSERT INTO plaid_api_events_table + ( + item_id, + user_id, + plaid_method, + arguments, + request_id, + error_type, + error_code + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7); + `, + values: [ + itemId, + userId, + plaidMethod, + JSON.stringify(clientMethodArgs), + requestId, + errorType, + errorCode, + ], + }; + // await db.query(query); +}; diff --git a/packages/server/src/lib/Plaid/index.ts b/packages/server/src/lib/Plaid/index.ts new file mode 100644 index 000000000..4a580954e --- /dev/null +++ b/packages/server/src/lib/Plaid/index.ts @@ -0,0 +1 @@ +export * from './Plaid'; \ No newline at end of file diff --git a/packages/server/src/services/Banking/Plaid/PlaidApplication.ts b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts new file mode 100644 index 000000000..05d814881 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts @@ -0,0 +1,18 @@ +import { Inject, Service } from 'typedi'; +import { PlaidLinkTokenService } from './PlaidLinkToken'; + +@Service() +export class PlaidApplication { + @Inject() + private getLinkTokenService: PlaidLinkTokenService; + + /** + * + * @param tenantId + * @param itemId + * @returns + */ + public getLinkToken(tenantId: number) { + return this.getLinkTokenService.getLinkToken(tenantId); + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts new file mode 100644 index 000000000..7d67ec8af --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts @@ -0,0 +1,37 @@ +import { PlaidClientWrapper } from '@/lib/Plaid'; +import { Service } from 'typedi'; + +@Service() +export class PlaidLinkTokenService { + /** + * Retrieves the plaid link token. + * @param {number} tenantId + * @returns + */ + async getLinkToken(tenantId: number) { + const accessToken = null; + + // must include transactions in order to receive transactions webhooks + const products = ['transactions']; + const linkTokenParams = { + user: { + // This should correspond to a unique id for the current user. + client_user_id: 'uniqueId' + tenantId, + }, + client_name: 'Pattern', + products, + country_codes: ['US'], + language: 'en', + // webhook: httpTunnel.public_url + '/services/webhook', + access_token: accessToken, + }; + // If user has entered a redirect uri in the .env file + // if (redirect_uri.indexOf('http') === 0) { + // linkTokenParams.redirect_uri = redirect_uri; + // } + const plaidInstance = new PlaidClientWrapper(); + const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams); + + return createResponse.data; + } +} From b43cd26ecc85ff0de95857e7af7ba0d875af4a6a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 30 Jan 2024 23:09:29 +0200 Subject: [PATCH 086/148] feat(webapp): popup Plaid Link component --- .env.example | 31 +- packages/webapp/package.json | 3 + .../Banking/Plaid/PlaidLanchLink.tsx | 121 ++++++ .../CashFlowAccountsActionsBar.tsx | 26 +- packages/webapp/src/hooks/query/index.tsx | 1 + packages/webapp/src/hooks/query/plaid.ts | 17 + pnpm-lock.yaml | 345 +++++++++++++++++- 7 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx create mode 100644 packages/webapp/src/hooks/query/plaid.ts diff --git a/.env.example b/.env.example index f32107eb4..4ae0ef539 100644 --- a/.env.example +++ b/.env.example @@ -63,4 +63,33 @@ GOTENBERG_DOCS_URL=http://server:3000/public/ EXCHANGE_RATE_SERVICE=open-exchange-rate # Open Exchange Rate -OPEN_EXCHANGE_RATE_APP_ID= \ No newline at end of file +OPEN_EXCHANGE_RATE_APP_ID= + +# The Plaid environment to use ('sandbox' or 'development'). +# https://plaid.com/docs/#api-host +PLAID_ENV=sandbox + +# Your Plaid keys, which can be found in the Plaid Dashboard. +# https://dashboard.plaid.com/account/keys +PLAID_CLIENT_ID= +PLAID_SECRET_DEVELOPMENT= +PLAID_SECRET_SANDBOX= + +# (Optional) Redirect URI settings section +# Only required for OAuth redirect URI testing (not common on desktop): +# Sandbox Mode: +# Set the PLAID_SANDBOX_REDIRECT_URI below to 'http://localhost:3001/oauth-link'. +# The OAuth redirect flow requires an endpoint on the developer's website +# that the bank website should redirect to. You will also need to configure +# this redirect URI for your client ID through the Plaid developer dashboard +# at https://dashboard.plaid.com/team/api. +# Development mode: +# When running in development mode, you must use an https:// url. +# You will need to configure this https:// redirect URI in the Plaid developer dashboard. +# Instructions to create a self-signed certificate for localhost can be found at +# https://github.com/plaid/pattern/blob/master/README.md#testing-oauth. +# If your system is not set up to run localhost with https://, you will be unable to test +# the OAuth in development and should leave the PLAID_DEVELOPMENT_REDIRECT_URI blank. + +PLAID_SANDBOX_REDIRECT_URI= +PLAID_DEVELOPMENT_REDIRECT_URI= diff --git a/packages/webapp/package.json b/packages/webapp/package.json index c1c2415aa..a11d565bd 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -69,6 +69,9 @@ "moment-timezone": "^0.5.33", "path-browserify": "^1.0.1", "prop-types": "15.8.1", + "plaid": "^9.3.0", + "plaid-threads": "^11.4.3", + "react-plaid-link": "^3.2.1", "query-string": "^7.1.1", "ramda": "^0.27.1", "react": "^18.2.0", diff --git a/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx new file mode 100644 index 000000000..dcc5f1893 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; +import { + usePlaidLink, + PlaidLinkOnSuccessMetadata, + PlaidLinkOnExitMetadata, + PlaidLinkError, + PlaidLinkOptionsWithLinkToken, + PlaidLinkOnEventMetadata, + PlaidLinkStableEvent, +} from 'react-plaid-link'; +import { useHistory } from 'react-router-dom'; +// import { exchangeToken, setItemState } from '../services/api'; +// import { useItems, useLink, useErrors } from '../services'; + +interface LaunchLinkProps { + isOauth?: boolean; + token: string; + userId: number; + itemId?: number | null; + children?: React.ReactNode; +} + +// Uses the usePlaidLink hook to manage the Plaid Link creation. See https://github.com/plaid/react-plaid-link for full usage instructions. +// The link token passed to usePlaidLink cannot be null. It must be generated outside of this component. In this sample app, the link token +// is generated in the link context in client/src/services/link.js. + +export function LaunchLink(props: LaunchLinkProps) { + const history = useHistory(); + // const { getItemsByUser, getItemById } = useItems(); + // const { generateLinkToken, deleteLinkToken } = useLink(); + // const { setError, resetError } = useErrors(); + + // define onSuccess, onExit and onEvent functions as configs for Plaid Link creation + const onSuccess = async ( + publicToken: string, + metadata: PlaidLinkOnSuccessMetadata, + ) => { + // log and save metatdata + // logSuccess(metadata, props.userId); + if (props.itemId != null) { + // update mode: no need to exchange public token + // await setItemState(props.itemId, 'good'); + // deleteLinkToken(null, props.itemId); + // getItemById(props.itemId, true); + // regular link mode: exchange public token for access token + } else { + // call to Plaid api endpoint: /item/public_token/exchange in order to obtain access_token which is then stored with the created item + // await exchangeToken( + // publicToken, + // metadata.institution, + // metadata.accounts, + // props.userId, + // ); + // getItemsByUser(props.userId, true); + } + // resetError(); + // deleteLinkToken(props.userId, null); + history.push(`/user/${props.userId}`); + }; + + const onExit = async ( + error: PlaidLinkError | null, + metadata: PlaidLinkOnExitMetadata, + ) => { + // log and save error and metatdata + // logExit(error, metadata, props.userId); + if (error != null && error.error_code === 'INVALID_LINK_TOKEN') { + // await generateLinkToken(props.userId, props.itemId); + } + if (error != null) { + // setError(error.error_code, error.display_message || error.error_message); + } + // to handle other error codes, see https://plaid.com/docs/errors/ + }; + + const onEvent = async ( + eventName: PlaidLinkStableEvent | string, + metadata: PlaidLinkOnEventMetadata, + ) => { + // handle errors in the event end-user does not exit with onExit function error enabled. + if (eventName === 'ERROR' && metadata.error_code != null) { + // setError(metadata.error_code, ' '); + } + // logEvent(eventName, metadata); + }; + + const config: PlaidLinkOptionsWithLinkToken = { + onSuccess, + onExit, + onEvent, + token: props.token, + }; + + if (props.isOauth) { + // add additional receivedRedirectUri config when handling an OAuth reidrect + config.receivedRedirectUri = window.location.href; + } + const { open, ready } = usePlaidLink(config); + + useEffect(() => { + // initiallizes Link automatically + if (props.isOauth && ready) { + open(); + } else if (ready) { + // regular, non-OAuth case: + // set link token, userId and itemId in local storage for use if needed later by OAuth + + localStorage.setItem( + 'oauthConfig', + JSON.stringify({ + userId: props.userId, + itemId: props.itemId, + token: props.token, + }), + ); + open(); + } + }, [ready, open, props.isOauth, props.userId, props.itemId, props.token]); + + return <>; +} diff --git a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx index 4c5c2d4c8..23f7a6df7 100644 --- a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useState } from 'react'; import { Button, NavbarGroup, @@ -14,7 +14,10 @@ import { Icon, FormattedMessage as T, } from '@/components'; -import { useRefreshCashflowAccounts } from '@/hooks/query'; +import { + useGetPlaidLinkToken, + useRefreshCashflowAccounts, +} from '@/hooks/query'; import { CashflowAction, AbilitySubject } from '@/constants/abilityOption'; import withDialogActions from '@/containers/Dialog/withDialogActions'; @@ -26,6 +29,7 @@ import { ACCOUNT_TYPE } from '@/constants'; import { DialogsName } from '@/constants/dialogs'; import { compose } from '@/utils'; +import { LaunchLink } from '@/containers/Banking/Plaid/PlaidLanchLink'; /** * Cash Flow accounts actions bar. @@ -63,8 +67,20 @@ function CashFlowAccountsActionsBar({ setCashflowAccountsTableState({ inactiveMode: checked }); }; + const { mutateAsync: getPlaidLinkToken } = useGetPlaidLinkToken(); + const [linkToken, setLinkToken] = useState(''); + + const handleConnectToBank = () => { + getPlaidLinkToken() + .then((res) => { + setLinkToken(res.data.link_token); + }) + .catch(() => {}); + }; + return ( + + + ); +} + +export const BankFeedsServiceProviders = [{ label: 'Plaid', key: 'plaid' }]; diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/index.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/index.tsx new file mode 100644 index 000000000..2267439d5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/index.tsx @@ -0,0 +1 @@ +export * from './ConnectBankDialog'; diff --git a/packages/webapp/src/hooks/query/plaid.ts b/packages/webapp/src/hooks/query/plaid.ts index e90db7ca9..b4fd88e19 100644 --- a/packages/webapp/src/hooks/query/plaid.ts +++ b/packages/webapp/src/hooks/query/plaid.ts @@ -5,7 +5,7 @@ import useApiRequest from '../useRequest'; /** * Retrieves the plaid link token. */ -export function useGetPlaidLinkToken(props) { +export function useGetPlaidLinkToken(props = {}) { const apiRequest = useApiRequest(); return useMutation( @@ -15,3 +15,17 @@ export function useGetPlaidLinkToken(props) { }, ); } + +/** + * Retrieves the plaid link token. + */ +export function usePlaidExchangeToken(props = {}) { + const apiRequest = useApiRequest(); + + return useMutation( + (data) => apiRequest.post('banking/plaid/exchange-token', data, {}), + { + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/state/banking.ts b/packages/webapp/src/hooks/state/banking.ts new file mode 100644 index 000000000..d7499dfd7 --- /dev/null +++ b/packages/webapp/src/hooks/state/banking.ts @@ -0,0 +1,20 @@ +import { getPlaidToken, setPlaidId } from '@/store/banking/banking.reducer'; +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +export const useSetBankingPlaidToken = () => { + const dispatch = useDispatch(); + + return useCallback( + (plaidId: string) => { + dispatch(setPlaidId(plaidId)); + }, + [dispatch], + ); +}; + +export const useGetBankingPlaidToken = () => { + const plaidToken = useSelector(getPlaidToken); + + return plaidToken; +}; diff --git a/packages/webapp/src/store/banking/banking.reducer.ts b/packages/webapp/src/store/banking/banking.reducer.ts new file mode 100644 index 000000000..93023cdc0 --- /dev/null +++ b/packages/webapp/src/store/banking/banking.reducer.ts @@ -0,0 +1,20 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; + +interface StorePlaidState { + plaidToken: string; +} + +export const PlaidSlice = createSlice({ + name: 'plaid', + initialState: { + plaidToken: '', + } as StorePlaidState, + reducers: { + setPlaidId: (state: StorePlaidState, action: PayloadAction) => { + state.plaidToken = action.payload; + }, + }, +}); + +export const { setPlaidId } = PlaidSlice.actions; +export const getPlaidToken = (state: any) => state.plaid.plaidToken; diff --git a/packages/webapp/src/store/reducers.tsx b/packages/webapp/src/store/reducers.tsx index 4a778e1de..ddcc6ff27 100644 --- a/packages/webapp/src/store/reducers.tsx +++ b/packages/webapp/src/store/reducers.tsx @@ -37,6 +37,7 @@ import creditNotes from './CreditNote/creditNote.reducer'; import vendorCredit from './VendorCredit/VendorCredit.reducer'; import warehouseTransfers from './WarehouseTransfer/warehouseTransfer.reducer'; import projects from './Project/projects.reducer'; +import { PlaidSlice } from './banking/banking.reducer'; const appReducer = combineReducers({ authentication, @@ -73,6 +74,7 @@ const appReducer = combineReducers({ vendorCredit, warehouseTransfers, projects, + plaid: PlaidSlice.reducer, }); // Reset the state of a redux store From 6d888060d3601a031a8439503add2d03e9e87cf7 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 4 Feb 2024 19:59:04 +0200 Subject: [PATCH 090/148] feat: reset plaid lanch link token --- .../Banking/Plaid/PlaidLanchLink.tsx | 79 ++++++------------- .../src/containers/Banking/Plaid/_utils.ts | 30 +++++++ .../CashflowAccountsPlaidLink.tsx | 2 +- packages/webapp/src/hooks/state/banking.ts | 14 +++- .../src/store/banking/banking.reducer.ts | 7 +- 5 files changed, 73 insertions(+), 59 deletions(-) create mode 100644 packages/webapp/src/containers/Banking/Plaid/_utils.ts diff --git a/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx index a925181da..fb0cef281 100644 --- a/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx +++ b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import { usePlaidExchangeToken } from '@/hooks/query'; import React, { useEffect } from 'react'; import { usePlaidLink, @@ -10,28 +9,28 @@ import { PlaidLinkOnEventMetadata, PlaidLinkStableEvent, } from 'react-plaid-link'; -import { useHistory } from 'react-router-dom'; -// import { exchangeToken, setItemState } from '../services/api'; -// import { useItems, useLink, useErrors } from '../services'; +import { logEvent } from './_utils'; +import { usePlaidExchangeToken } from '@/hooks/query'; +import { useResetBankingPlaidToken } from '@/hooks/state/banking'; -interface LaunchLinkProps { - isOauth?: boolean; +interface PlaidLaunchLinkProps { token: string; - userId: number; itemId?: number | null; children?: React.ReactNode; } -// Uses the usePlaidLink hook to manage the Plaid Link creation. See https://github.com/plaid/react-plaid-link for full usage instructions. -// The link token passed to usePlaidLink cannot be null. It must be generated outside of this component. In this sample app, the link token -// is generated in the link context in client/src/services/link.js. - -export function LaunchLink(props: LaunchLinkProps) { - const history = useHistory(); - // const { getItemsByUser, getItemById } = useItems(); - // const { generateLinkToken, deleteLinkToken } = useLink(); - // const { setError, resetError } = useErrors(); - +/** + * Uses the usePlaidLink hook to manage the Plaid Link creation. + * See https://github.com/plaid/react-plaid-link for full usage instructions. + * The link token passed to usePlaidLink cannot be null. + * It must be generated outside of this component. In this sample app, the link token + * is generated in the link context in client/src/services/link.js. + * + * @param {PlaidLaunchLinkProps} props + * @returns {React.ReactNode} + */ +export function LaunchLink(props: PlaidLaunchLinkProps) { + const resetPlaidToken = useResetBankingPlaidToken(); const { mutateAsync: exchangeAccessToken } = usePlaidExchangeToken(); // define onSuccess, onExit and onEvent functions as configs for Plaid Link creation @@ -40,7 +39,7 @@ export function LaunchLink(props: LaunchLinkProps) { metadata: PlaidLinkOnSuccessMetadata, ) => { // log and save metatdata - // logSuccess(metadata, props.userId); + logSuccess(metadata); if (props.itemId != null) { // update mode: no need to exchange public token // await setItemState(props.itemId, 'good'); @@ -48,39 +47,26 @@ export function LaunchLink(props: LaunchLinkProps) { // getItemById(props.itemId, true); // regular link mode: exchange public token for access token } else { - // call to Plaid api endpoint: /item/public_token/exchange in order to obtain access_token which is then stored with the created item - debugger; - await exchangeAccessToken({ public_token: publicToken, institution_id: metadata.institution.institution_id, }); - // await exchangeToken( - // publicToken, - // metadata.institution, - // metadata.accounts, - // props.userId, - // ); - // getItemsByUser(props.userId, true); } // resetError(); - // deleteLinkToken(props.userId, null); - history.push(`/user/${props.userId}`); + resetPlaidToken(); }; + // Handle other error codes, see https://plaid.com/docs/errors/ const onExit = async ( error: PlaidLinkError | null, metadata: PlaidLinkOnExitMetadata, ) => { // log and save error and metatdata - // logExit(error, metadata, props.userId); - if (error != null && error.error_code === 'INVALID_LINK_TOKEN') { - // await generateLinkToken(props.userId, props.itemId); - } + logExit(error, metadata, props.userId); if (error != null) { // setError(error.error_code, error.display_message || error.error_message); } - // to handle other error codes, see https://plaid.com/docs/errors/ + resetPlaidToken(); }; const onEvent = async ( @@ -91,7 +77,7 @@ export function LaunchLink(props: LaunchLinkProps) { if (eventName === 'ERROR' && metadata.error_code != null) { // setError(metadata.error_code, ' '); } - // logEvent(eventName, metadata); + logEvent(eventName, metadata); }; const config: PlaidLinkOptionsWithLinkToken = { @@ -101,31 +87,14 @@ export function LaunchLink(props: LaunchLinkProps) { token: props.token, }; - if (props.isOauth) { - // add additional receivedRedirectUri config when handling an OAuth reidrect - config.receivedRedirectUri = window.location.href; - } const { open, ready } = usePlaidLink(config); useEffect(() => { // initiallizes Link automatically - if (props.isOauth && ready) { - open(); - } else if (ready) { - // regular, non-OAuth case: - // set link token, userId and itemId in local storage for use if needed later by OAuth - - localStorage.setItem( - 'oauthConfig', - JSON.stringify({ - userId: props.userId, - itemId: props.itemId, - token: props.token, - }), - ); + if (ready) { open(); } - }, [ready, open, props.isOauth, props.userId, props.itemId, props.token]); + }, [ready, open, props.itemId, props.token]); return <>; } diff --git a/packages/webapp/src/containers/Banking/Plaid/_utils.ts b/packages/webapp/src/containers/Banking/Plaid/_utils.ts new file mode 100644 index 000000000..cef95bba2 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Plaid/_utils.ts @@ -0,0 +1,30 @@ +import { + PlaidLinkError, + PlaidLinkOnEventMetadata, + PlaidLinkOnExitMetadata, + PlaidLinkOnSuccessMetadata, + PlaidLinkStableEvent, +} from 'react-plaid-link'; + +export const logEvent = ( + eventName: PlaidLinkStableEvent | string, + metadata: + | PlaidLinkOnEventMetadata + | PlaidLinkOnSuccessMetadata + | PlaidLinkOnExitMetadata, + error?: PlaidLinkError | null, +) => { + console.log(`Link Event: ${eventName}`, metadata, error); +}; + +export const logSuccess = async ({ + institution, + accounts, + link_session_id, +}: PlaidLinkOnSuccessMetadata) => { + logEvent('onSuccess', { + institution, + accounts, + link_session_id, + }); +}; diff --git a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx index b3a927f9b..5eeb72ddd 100644 --- a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx +++ b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx @@ -4,5 +4,5 @@ import { useGetBankingPlaidToken } from '@/hooks/state/banking'; export function CashflowAccountsPlaidLink() { const plaidToken = useGetBankingPlaidToken(); - return ; + return ; } diff --git a/packages/webapp/src/hooks/state/banking.ts b/packages/webapp/src/hooks/state/banking.ts index d7499dfd7..9b6b356ca 100644 --- a/packages/webapp/src/hooks/state/banking.ts +++ b/packages/webapp/src/hooks/state/banking.ts @@ -1,4 +1,8 @@ -import { getPlaidToken, setPlaidId } from '@/store/banking/banking.reducer'; +import { + getPlaidToken, + setPlaidId, + resetPlaidId, +} from '@/store/banking/banking.reducer'; import { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -18,3 +22,11 @@ export const useGetBankingPlaidToken = () => { return plaidToken; }; + +export const useResetBankingPlaidToken = () => { + const dispatch = useDispatch(); + + return useCallback(() => { + dispatch(resetPlaidId()); + }, [dispatch]); +}; diff --git a/packages/webapp/src/store/banking/banking.reducer.ts b/packages/webapp/src/store/banking/banking.reducer.ts index 93023cdc0..d6a842d32 100644 --- a/packages/webapp/src/store/banking/banking.reducer.ts +++ b/packages/webapp/src/store/banking/banking.reducer.ts @@ -13,8 +13,11 @@ export const PlaidSlice = createSlice({ setPlaidId: (state: StorePlaidState, action: PayloadAction) => { state.plaidToken = action.payload; }, + resetPlaidId: (state: StorePlaidState) => { + state.plaidToken = ''; + } }, }); -export const { setPlaidId } = PlaidSlice.actions; -export const getPlaidToken = (state: any) => state.plaid.plaidToken; +export const { setPlaidId, resetPlaidId } = PlaidSlice.actions; +export const getPlaidToken = (state: any) => state.plaid.plaidToken; \ No newline at end of file From 2e0b3d0d5e7225e5ebc47aa9c58f5e701ec8d96f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 4 Feb 2024 22:16:03 +0200 Subject: [PATCH 091/148] feat: add bank balance column to account --- ...235818_add_plaid_account_id_to_accounts_table.js | 2 ++ ..._plaid_transaction_id_to_cashflow_transaction.js | 7 +++++++ packages/server/src/interfaces/Account.ts | 6 ++++-- packages/server/src/interfaces/CashflowService.ts | 1 + .../src/services/Accounts/AccountTransform.ts | 13 ++++++++++++- .../src/services/Banking/Plaid/PlaidApplication.ts | 2 +- packages/server/src/services/Banking/Plaid/utils.ts | 11 +++++++++-- .../Cashflow/NewCashflowTransactionService.ts | 4 ++-- 8 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/database/migrations/20240204180554_add_plaid_transaction_id_to_cashflow_transaction.js diff --git a/packages/server/src/database/migrations/20240201235818_add_plaid_account_id_to_accounts_table.js b/packages/server/src/database/migrations/20240201235818_add_plaid_account_id_to_accounts_table.js index 2c58038b3..901f08987 100644 --- a/packages/server/src/database/migrations/20240201235818_add_plaid_account_id_to_accounts_table.js +++ b/packages/server/src/database/migrations/20240201235818_add_plaid_account_id_to_accounts_table.js @@ -1,6 +1,8 @@ exports.up = function (knex) { return knex.schema.table('accounts', (table) => { table.string('plaid_account_id'); + table.string('account_mask').nullable(); + table.decimal('bank_balance', 15, 5); }); }; diff --git a/packages/server/src/database/migrations/20240204180554_add_plaid_transaction_id_to_cashflow_transaction.js b/packages/server/src/database/migrations/20240204180554_add_plaid_transaction_id_to_cashflow_transaction.js new file mode 100644 index 000000000..3c88cd589 --- /dev/null +++ b/packages/server/src/database/migrations/20240204180554_add_plaid_transaction_id_to_cashflow_transaction.js @@ -0,0 +1,7 @@ +exports.up = function (knex) { + return knex.schema.table('cashflow_transactions', (table) => { + table.string('plaid_transaction_id'); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index 5e80a0d92..7c045def3 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -8,6 +8,8 @@ export interface IAccountDTO { accountType: string; parentAccountId?: number; active: boolean; + bankBalance?: number; + accountMask?: string; } export interface IAccountCreateDTO extends IAccountDTO { @@ -34,6 +36,7 @@ export interface IAccount { type?: any[]; accountNormal: string; accountParentType: string; + bankBalance: string; } export enum AccountNormal { @@ -155,10 +158,9 @@ export enum AccountAction { TransactionsLocking = 'TransactionsLocking', } - export enum TaxRateAction { CREATE = 'Create', EDIT = 'Edit', DELETE = 'Delete', VIEW = 'View', -} \ No newline at end of file +} diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index 9df08a9ad..e279aec05 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -45,6 +45,7 @@ export interface ICashflowCommandDTO { publish: boolean; branchId?: number; + plaidTransactionId?: string; } export interface ICashflowNewCommandDTO extends ICashflowCommandDTO { diff --git a/packages/server/src/services/Accounts/AccountTransform.ts b/packages/server/src/services/Accounts/AccountTransform.ts index 9297994be..ee22a2cc5 100644 --- a/packages/server/src/services/Accounts/AccountTransform.ts +++ b/packages/server/src/services/Accounts/AccountTransform.ts @@ -13,7 +13,7 @@ export class AccountTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['formattedAmount', 'flattenName']; + return ['formattedAmount', 'flattenName', 'bankBalanceFormatted']; }; /** @@ -41,6 +41,17 @@ export class AccountTransformer extends Transformer { return formatNumber(account.amount, { currencyCode: account.currencyCode }); }; + /** + * Retrieves the formatted bank balance. + * @param {IAccount} account + * @returns {string} + */ + protected bankBalanceFormatted = (account: IAccount): string => { + return formatNumber(account.bankBalance, { + currencyCode: account.currencyCode, + }); + }; + /** * Transformes the accounts collection to flat or nested array. * @param {IAccount[]} diff --git a/packages/server/src/services/Banking/Plaid/PlaidApplication.ts b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts index 1789eafdd..16096207e 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidApplication.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts @@ -27,7 +27,7 @@ export class PlaidApplication { * @param {PlaidItemDTO} itemDTO * @returns */ - public exchangeToken(tenantId: number, itemDTO: PlaidItemDTO) { + public exchangeToken(tenantId: number, itemDTO: PlaidItemDTO): Promise { return this.plaidItemService.item(tenantId, itemDTO); } } diff --git a/packages/server/src/services/Banking/Plaid/utils.ts b/packages/server/src/services/Banking/Plaid/utils.ts index c8b97d7b7..1fe18bee7 100644 --- a/packages/server/src/services/Banking/Plaid/utils.ts +++ b/packages/server/src/services/Banking/Plaid/utils.ts @@ -1,6 +1,10 @@ import * as R from 'ramda'; -import { IAccountCreateDTO, ICashflowNewCommandDTO } from '@/interfaces'; -import { PlaidAccount, PlaidTransaction } from './_types'; +import { + IAccountCreateDTO, + ICashflowNewCommandDTO, + PlaidAccount, + PlaidTransaction, +} from '@/interfaces'; /** * Transformes the Plaid account to create cashflow account DTO. @@ -18,6 +22,8 @@ export const transformPlaidAccountToCreateAccount = ( accountType: 'cash', active: true, plaidAccountId: plaidAccount.account_id, + bankBalance: plaidAccount.balances.current, + accountMask: plaidAccount.mask, }; }; @@ -48,6 +54,7 @@ export const transformPlaidTrxsToCashflowCreate = R.curry( // transactionNumber: string; // referenceNo: string; + plaidTransactionId: plaidTranasction.transaction_id, publish: true, }; } diff --git a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts index 45ef60308..e8c53f5fc 100644 --- a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts @@ -86,7 +86,7 @@ export default class NewCashflowTransactionService { 'cashflowAccountId', 'creditAccountId', 'branchId', - 'plaidAccountId' + 'plaidTransactionId', ]); // Retreive the next invoice number. const autoNextNumber = @@ -125,7 +125,7 @@ export default class NewCashflowTransactionService { public newCashflowTransaction = async ( tenantId: number, newTransactionDTO: ICashflowNewCommandDTO, - userId?: number + userId?: number ): Promise<{ cashflowTransaction: ICashflowTransaction }> => { const { CashflowTransaction, Account } = this.tenancy.models(tenantId); From c688190acc2e7778467c86ddd94f35fe56308b4a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 4 Feb 2024 22:16:59 +0200 Subject: [PATCH 092/148] feat: add bank balance to accounts chart table --- .../src/containers/Accounts/components.tsx | 13 +++++++++++++ .../webapp/src/containers/Accounts/utils.tsx | 15 ++++++++++++--- .../containers/Banking/Plaid/PlaidLanchLink.tsx | 2 +- .../src/containers/Banking/Plaid/_utils.ts | 16 ++++++++++++++++ .../AccountTransactionsDetailsBar.tsx | 14 ++++++++++++++ .../CashflowAccountsPlaidLink.tsx | 3 +++ 6 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/webapp/src/containers/Accounts/components.tsx b/packages/webapp/src/containers/Accounts/components.tsx index d3c793f24..5b9d2b31b 100644 --- a/packages/webapp/src/containers/Accounts/components.tsx +++ b/packages/webapp/src/containers/Accounts/components.tsx @@ -116,3 +116,16 @@ export function BalanceCell({ cell }) { ); } + +/** + * Balance cell. + */ +export function BankBalanceCell({ cell }) { + const account = cell.row.original; + + return account.amount !== null ? ( + {account.bank_balance_formatted} + ) : ( + + ); +} diff --git a/packages/webapp/src/containers/Accounts/utils.tsx b/packages/webapp/src/containers/Accounts/utils.tsx index 3277df6a6..6e535fd28 100644 --- a/packages/webapp/src/containers/Accounts/utils.tsx +++ b/packages/webapp/src/containers/Accounts/utils.tsx @@ -4,7 +4,7 @@ import intl from 'react-intl-universal'; import { Intent, Tag } from '@blueprintjs/core'; import { If, AppToaster } from '@/components'; -import { NormalCell, BalanceCell } from './components'; +import { NormalCell, BalanceCell, BankBalanceCell } from './components'; import { transformTableStateToQuery, isBlank } from '@/utils'; /** @@ -94,6 +94,15 @@ export const useAccountsTableColumns = () => { width: 75, clickable: true, }, + { + id: 'bank_balance', + Header: 'Bank Balance', + accessor: 'bank_balance_formatted', + Cell: BankBalanceCell, + width: 150, + clickable: true, + align: 'right', + }, { id: 'balance', Header: intl.get('balance'), @@ -119,5 +128,5 @@ export const transformAccountsStateToQuery = (tableState) => { return { ...transformTableStateToQuery(tableState), inactive_mode: tableState.inactiveMode, - } -} \ No newline at end of file + }; +}; diff --git a/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx index fb0cef281..a33c400a2 100644 --- a/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx +++ b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx @@ -9,7 +9,7 @@ import { PlaidLinkOnEventMetadata, PlaidLinkStableEvent, } from 'react-plaid-link'; -import { logEvent } from './_utils'; +import { logEvent, logExit, logSuccess } from './_utils'; import { usePlaidExchangeToken } from '@/hooks/query'; import { useResetBankingPlaidToken } from '@/hooks/state/banking'; diff --git a/packages/webapp/src/containers/Banking/Plaid/_utils.ts b/packages/webapp/src/containers/Banking/Plaid/_utils.ts index cef95bba2..4e662947c 100644 --- a/packages/webapp/src/containers/Banking/Plaid/_utils.ts +++ b/packages/webapp/src/containers/Banking/Plaid/_utils.ts @@ -28,3 +28,19 @@ export const logSuccess = async ({ link_session_id, }); }; + +export const logExit = async ( + error: PlaidLinkError | null, + { institution, status, link_session_id, request_id }: PlaidLinkOnExitMetadata, +) => { + logEvent( + 'onExit', + { + institution, + status, + link_session_id, + request_id, + }, + error, + ); +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx index 02a03c3a8..7f4d64921 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx @@ -71,6 +71,19 @@ function AccountBalanceItem() { ); } +function AccountBankBalanceItem() { + const { currentAccount } = useAccountTransactionsContext(); + + return ( + + Balance in Bank Account + + {currentAccount.bank_balance_formatted} + + + ); +} + function AccountTransactionsDetailsBarSkeleton() { return ( @@ -89,6 +102,7 @@ function AccountTransactionsDetailsContent() { + ); } diff --git a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx index 5eeb72ddd..7f39d1b6d 100644 --- a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx +++ b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashflowAccountsPlaidLink.tsx @@ -4,5 +4,8 @@ import { useGetBankingPlaidToken } from '@/hooks/state/banking'; export function CashflowAccountsPlaidLink() { const plaidToken = useGetBankingPlaidToken(); + if (!plaidToken) { + return null; + } return ; } From 00d9bc537cd76c1004454484e46c09bd6a57fd3c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 4 Feb 2024 22:17:37 +0200 Subject: [PATCH 093/148] feat: ability to remove the removed Plaid transactions in updating --- .../src/services/Banking/Plaid/PlaidItem.ts | 2 +- .../src/services/Banking/Plaid/PlaidSyncDB.ts | 42 +++++++++++++++++-- .../Banking/Plaid/PlaidUpdateTransactions.ts | 1 + 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/server/src/services/Banking/Plaid/PlaidItem.ts b/packages/server/src/services/Banking/Plaid/PlaidItem.ts index ddf434aae..bde464096 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidItem.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidItem.ts @@ -23,7 +23,7 @@ export class PlaidItemService { * @param {PlaidItemDTO} itemDTO * @returns {Promise} */ - public async item(tenantId: number, itemDTO: PlaidItemDTO) { + public async item(tenantId: number, itemDTO: PlaidItemDTO): Promise { const { PlaidItem } = this.tenancy.models(tenantId); const { publicToken, institutionId } = itemDTO; diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index c104a4bf0..6f0260a2b 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -9,8 +9,11 @@ import { transformPlaidTrxsToCashflowCreate, } from './utils'; import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; +import DeleteCashflowTransactionService from '@/services/Cashflow/DeleteCashflowTransactionService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +const CONCURRENCY_ASYNC = 10; + @Service() export class PlaidSyncDb { @Inject() @@ -22,6 +25,9 @@ export class PlaidSyncDb { @Inject() private createCashflowTransactionService: NewCashflowTransactionService; + @Inject() + private deleteCashflowTransactionService: DeleteCashflowTransactionService; + /** * Syncs the plaid accounts to the system accounts. * @param {number} tenantId Tenant ID. @@ -39,7 +45,7 @@ export class PlaidSyncDb { accountCreateDTOs, (createAccountDTO: any) => this.createAccountService.createAccount(tenantId, createAccountDTO), - { concurrency: 10 } + { concurrency: CONCURRENCY_ASYNC } ); } @@ -79,7 +85,7 @@ export class PlaidSyncDb { tenantId, cashflowDTO ), - { concurrency: 10 } + { concurrency: CONCURRENCY_ASYNC } ); } @@ -104,7 +110,35 @@ export class PlaidSyncDb { plaidTransactions ); }, - { concurrency: 10 } + { concurrency: CONCURRENCY_ASYNC } + ); + } + + /** + * Syncs the removed Plaid transactions ids from the cashflow system transactions. + * @param {string[]} plaidTransactionsIds - Plaid Transactions IDs. + */ + public async syncRemoveTransactions( + tenantId: number, + plaidTransactionsIds: string[] + ) { + const { CashflowTransaction } = this.tenancy.models(tenantId); + + const cashflowTransactions = await CashflowTransaction.query().whereIn( + 'plaidTransactionId', + plaidTransactionsIds + ); + const cashflowTransactionsIds = cashflowTransactions.map( + (trans) => trans.id + ); + await bluebird.map( + cashflowTransactionsIds, + (transactionId: number) => + this.deleteCashflowTransactionService.deleteCashflowTransaction( + tenantId, + transactionId + ), + { concurrency: CONCURRENCY_ASYNC } ); } @@ -121,6 +155,6 @@ export class PlaidSyncDb { ) { const { PlaidItem } = this.tenancy.models(tenantId); - await PlaidItem.query().findById(plaidItemId).patch({ lastCursor }); + await PlaidItem.query().findOne({ plaidItemId }).patch({ lastCursor }); } } diff --git a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts index d82bda770..8ac30b6f6 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts @@ -34,6 +34,7 @@ export class PlaidUpdateTransactions { tenantId, added.concat(modified) ); + await this.plaidSync.syncRemoveTransactions(tenantId, removed); await this.plaidSync.syncTransactionsCursor(tenantId, plaidItemId, cursor); return { From 7b5287ee8011e607fac5b9efcf153661a8ca6736 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 5 Feb 2024 18:50:34 +0200 Subject: [PATCH 094/148] fix(server): Revert the paid amount to bill transaction after editing bill payment amount --- .../BillPayments/BillPaymentValidators.ts | 14 -------------- .../Purchases/BillPayments/DeleteBillPayment.ts | 10 ++-------- .../Purchases/BillPayments/EditBillPayment.ts | 12 ++++++------ .../Purchases/BillPayments/GetBillPayment.ts | 13 ++----------- .../Purchases/BillPayments/GetPaymentBills.ts | 7 +++---- .../Sales/PaymentReceives/EditPaymentReceive.ts | 3 ++- 6 files changed, 15 insertions(+), 44 deletions(-) diff --git a/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts b/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts index 3aa2902ca..8bbfadbff 100644 --- a/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts +++ b/packages/server/src/services/Purchases/BillPayments/BillPaymentValidators.ts @@ -10,7 +10,6 @@ import { import TenancyService from '@/services/Tenancy/TenancyService'; import { ServiceError } from '@/exceptions'; import { ACCOUNT_TYPE } from '@/data/AccountTypes'; -import { BillPayment } from '@/models'; import { ERRORS } from './constants'; @Service() @@ -18,19 +17,6 @@ export class BillPaymentValidators { @Inject() private tenancy: TenancyService; - /** - * Validates the payment existance. - * @param {BillPayment | undefined | null} payment - * @throws {ServiceError(ERRORS.PAYMENT_MADE_NOT_FOUND)} - */ - public async validateBillPaymentExistance( - payment: BillPayment | undefined | null - ) { - if (!payment) { - throw new ServiceError(ERRORS.PAYMENT_MADE_NOT_FOUND); - } - } - /** * Validates the bill payment existance. * @param {Request} req diff --git a/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts index 4ab1c9a25..e4a1ab1fa 100644 --- a/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/DeleteBillPayment.ts @@ -1,6 +1,5 @@ import { Knex } from 'knex'; import UnitOfWork from '@/services/UnitOfWork'; -import { BillPaymentValidators } from './BillPaymentValidators'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; @@ -21,9 +20,6 @@ export class DeleteBillPayment { @Inject() private uow: UnitOfWork; - @Inject() - private validators: BillPaymentValidators; - /** * Deletes the bill payment and associated transactions. * @param {number} tenantId - Tenant id. @@ -36,10 +32,8 @@ export class DeleteBillPayment { // Retrieve the bill payment or throw not found service error. const oldBillPayment = await BillPayment.query() .withGraphFetched('entries') - .findById(billPaymentId); - - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(oldBillPayment); + .findById(billPaymentId) + .throwIfNotFound(); // Deletes the bill transactions with associated transactions under // unit-of-work envirement. diff --git a/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts index 20c72d38b..de18853bb 100644 --- a/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/EditBillPayment.ts @@ -57,12 +57,12 @@ export class EditBillPayment { const tenantMeta = await TenantMetadata.query().findOne({ tenantId }); - const oldBillPayment = await BillPayment.query().findById(billPaymentId); + const oldBillPayment = await BillPayment.query() + .findById(billPaymentId) + .withGraphFetched('entries') + .throwIfNotFound(); - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(oldBillPayment); - - // + // Retrieves the bill payment vendor or throw not found error. const vendor = await Contact.query() .modify('vendor') .findById(billPaymentDTO.vendorId) @@ -126,7 +126,7 @@ export class EditBillPayment { trx, } as IBillPaymentEditingPayload); - // Deletes the bill payment transaction graph from the storage. + // Edits the bill payment transaction graph on the storage. const billPayment = await BillPayment.query(trx).upsertGraphAndFetch({ id: billPaymentId, ...billPaymentObj, diff --git a/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts b/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts index 5984e8932..ccb1cab77 100644 --- a/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts +++ b/packages/server/src/services/Purchases/BillPayments/GetBillPayment.ts @@ -1,12 +1,8 @@ import { IBillPayment } from '@/interfaces'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; -import { ERRORS } from './constants'; -import { ServiceError } from '@/exceptions'; import { BillPaymentTransformer } from './BillPaymentTransformer'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; -import { BillsValidators } from '../Bills/BillsValidators'; -import { BillPaymentValidators } from './BillPaymentValidators'; @Service() export class GetBillPayment { @@ -16,9 +12,6 @@ export class GetBillPayment { @Inject() private transformer: TransformerInjectable; - @Inject() - private validators: BillPaymentValidators; - /** * Retrieve bill payment. * @param {number} tenantId @@ -37,10 +30,8 @@ export class GetBillPayment { .withGraphFetched('paymentAccount') .withGraphFetched('transactions') .withGraphFetched('branch') - .findById(billPyamentId); - - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(billPayment); + .findById(billPyamentId) + .throwIfNotFound(); return this.transformer.transform( tenantId, diff --git a/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts b/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts index 7b86c8f04..ec839411b 100644 --- a/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts +++ b/packages/server/src/services/Purchases/BillPayments/GetPaymentBills.ts @@ -18,10 +18,9 @@ export class GetPaymentBills { public async getPaymentBills(tenantId: number, billPaymentId: number) { const { Bill, BillPayment } = this.tenancy.models(tenantId); - const billPayment = await BillPayment.query().findById(billPaymentId); - - // Validates the bill payment existance. - this.validators.validateBillPaymentExistance(billPayment); + const billPayment = await BillPayment.query() + .findById(billPaymentId) + .throwIfNotFound(); const paymentBillsIds = billPayment.entries.map((entry) => entry.id); diff --git a/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts b/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts index b99413748..635f48946 100644 --- a/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts +++ b/packages/server/src/services/Sales/PaymentReceives/EditPaymentReceive.ts @@ -61,7 +61,8 @@ export class EditPaymentReceive { // Validate the payment receive existance. const oldPaymentReceive = await PaymentReceive.query() .withGraphFetched('entries') - .findById(paymentReceiveId); + .findById(paymentReceiveId) + .throwIfNotFound(); // Validates the payment existance. this.validators.validatePaymentExistance(oldPaymentReceive); From b38020d3971f562ed1aafb3f93b2b1cc95bf5a8d Mon Sep 17 00:00:00 2001 From: "a.nasouf" Date: Mon, 5 Feb 2024 18:58:02 +0200 Subject: [PATCH 095/148] fix: gmail email addresses dots gets removed --- .../src/api/controllers/Contacts/Contacts.ts | 126 +++++++++--------- .../webapp/src/components/Details/index.tsx | 2 +- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/packages/server/src/api/controllers/Contacts/Contacts.ts b/packages/server/src/api/controllers/Contacts/Contacts.ts index a05ee93fd..2449e7d6f 100644 --- a/packages/server/src/api/controllers/Contacts/Contacts.ts +++ b/packages/server/src/api/controllers/Contacts/Contacts.ts @@ -1,11 +1,11 @@ -import { check, param, query, body, ValidationChain } from 'express-validator'; -import { Router, Request, Response, NextFunction } from 'express'; -import { Inject, Service } from 'typedi'; -import { ServiceError } from '@/exceptions'; -import BaseController from '@/api/controllers/BaseController'; -import ContactsService from '@/services/Contacts/ContactsService'; -import DynamicListingService from '@/services/DynamicListing/DynamicListService'; -import { DATATYPES_LENGTH } from '@/data/DataTypes'; +import { check, param, query, body, ValidationChain } from "express-validator"; +import { Router, Request, Response, NextFunction } from "express"; +import { Inject, Service } from "typedi"; +import { ServiceError } from "@/exceptions"; +import BaseController from "@/api/controllers/BaseController"; +import ContactsService from "@/services/Contacts/ContactsService"; +import DynamicListingService from "@/services/DynamicListing/DynamicListService"; +import { DATATYPES_LENGTH } from "@/data/DataTypes"; @Service() export default class ContactsController extends BaseController { @@ -22,28 +22,28 @@ export default class ContactsController extends BaseController { const router = Router(); router.get( - '/auto-complete', + "/auto-complete", [...this.autocompleteQuerySchema], this.validationResult, this.asyncMiddleware(this.autocompleteContacts.bind(this)), this.dynamicListService.handlerErrorsToResponse ); router.get( - '/:id', - [param('id').exists().isNumeric().toInt()], + "/:id", + [param("id").exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.getContact.bind(this)) ); router.post( - '/:id/inactivate', - [param('id').exists().isNumeric().toInt()], + "/:id/inactivate", + [param("id").exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.inactivateContact.bind(this)), this.handlerServiceErrors ); router.post( - '/:id/activate', - [param('id').exists().isNumeric().toInt()], + "/:id/activate", + [param("id").exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.activateContact.bind(this)), this.handlerServiceErrors @@ -56,11 +56,11 @@ export default class ContactsController extends BaseController { */ get autocompleteQuerySchema() { return [ - query('column_sort_by').optional().trim().escape(), - query('sort_order').optional().isIn(['desc', 'asc']), + query("column_sort_by").optional().trim().escape(), + query("sort_order").optional().isIn(["desc", "asc"]), - query('stringified_filter_roles').optional().isJSON(), - query('limit').optional().isNumeric().toInt(), + query("stringified_filter_roles").optional().isJSON(), + query("limit").optional().isNumeric().toInt(), ]; } @@ -97,8 +97,8 @@ export default class ContactsController extends BaseController { const { tenantId } = req; const filter = { filterRoles: [], - sortOrder: 'asc', - columnSortBy: 'display_name', + sortOrder: "asc", + columnSortBy: "display_name", limit: 10, ...this.matchedQueryData(req), }; @@ -118,170 +118,170 @@ export default class ContactsController extends BaseController { */ get contactDTOSchema(): ValidationChain[] { return [ - check('salutation') + check("salutation") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('first_name') + check("first_name") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('last_name') + check("last_name") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('company_name') + check("company_name") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('display_name') + check("display_name") .exists() .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('email') + check("email") .optional({ nullable: true }) .isString() - .normalizeEmail() + .normalizeEmail({ gmail_remove_dots: false }) .isEmail() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('website') + check("website") .optional({ nullable: true }) .isString() .trim() .isURL() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('work_phone') + check("work_phone") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('personal_phone') + check("personal_phone") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_1') + check("billing_address_1") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_2') + check("billing_address_2") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_city') + check("billing_address_city") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_country') + check("billing_address_country") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_email') + check("billing_address_email") .optional({ nullable: true }) .isString() .isEmail() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_postcode') + check("billing_address_postcode") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_phone') + check("billing_address_phone") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('billing_address_state') + check("billing_address_state") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_1') + check("shipping_address_1") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_2') + check("shipping_address_2") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_city') + check("shipping_address_city") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_country') + check("shipping_address_country") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_email') + check("shipping_address_email") .optional({ nullable: true }) .isString() .isEmail() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_postcode') + check("shipping_address_postcode") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_phone') + check("shipping_address_phone") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('shipping_address_state') + check("shipping_address_state") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check('note') + check("note") .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.TEXT }), - check('active').optional().isBoolean().toBoolean(), + check("active").optional().isBoolean().toBoolean(), ]; } @@ -291,19 +291,19 @@ export default class ContactsController extends BaseController { */ get contactNewDTOSchema(): ValidationChain[] { return [ - check('opening_balance') + check("opening_balance") .optional({ nullable: true }) .isInt({ min: 0, max: DATATYPES_LENGTH.DECIMAL_13_3 }) .toInt(), - check('opening_balance_exchange_rate') + check("opening_balance_exchange_rate") .default(1) .isFloat({ gt: 0 }) .toFloat(), - body('opening_balance_at') - .if(body('opening_balance').exists()) + body("opening_balance_at") + .if(body("opening_balance").exists()) .exists() .isISO8601(), - check('opening_balance_branch_id') + check("opening_balance_branch_id") .optional({ nullable: true }) .isNumeric() .toInt(), @@ -322,7 +322,7 @@ export default class ContactsController extends BaseController { * @returns {ValidationChain[]} */ get specificContactSchema(): ValidationChain[] { - return [param('id').exists().isNumeric().toInt()]; + return [param("id").exists().isNumeric().toInt()]; } /** @@ -340,7 +340,7 @@ export default class ContactsController extends BaseController { return res.status(200).send({ id: contactId, - message: 'The given contact activated successfully.', + message: "The given contact activated successfully.", }); } catch (error) { next(error); @@ -362,7 +362,7 @@ export default class ContactsController extends BaseController { return res.status(200).send({ id: contactId, - message: 'The given contact inactivated successfully.', + message: "The given contact inactivated successfully.", }); } catch (error) { next(error); @@ -383,19 +383,19 @@ export default class ContactsController extends BaseController { next: NextFunction ) { if (error instanceof ServiceError) { - if (error.errorType === 'contact_not_found') { + if (error.errorType === "contact_not_found") { return res.boom.badRequest(null, { - errors: [{ type: 'CONTACT.NOT.FOUND', code: 100 }], + errors: [{ type: "CONTACT.NOT.FOUND", code: 100 }], }); } - if (error.errorType === 'CONTACT_ALREADY_ACTIVE') { + if (error.errorType === "CONTACT_ALREADY_ACTIVE") { return res.boom.badRequest(null, { - errors: [{ type: 'CONTACT_ALREADY_ACTIVE', code: 700 }], + errors: [{ type: "CONTACT_ALREADY_ACTIVE", code: 700 }], }); } - if (error.errorType === 'CONTACT_ALREADY_INACTIVE') { + if (error.errorType === "CONTACT_ALREADY_INACTIVE") { return res.boom.badRequest(null, { - errors: [{ type: 'CONTACT_ALREADY_INACTIVE', code: 800 }], + errors: [{ type: "CONTACT_ALREADY_INACTIVE", code: 800 }], }); } } diff --git a/packages/webapp/src/components/Details/index.tsx b/packages/webapp/src/components/Details/index.tsx index e6803b069..9879fc7c0 100644 --- a/packages/webapp/src/components/Details/index.tsx +++ b/packages/webapp/src/components/Details/index.tsx @@ -66,7 +66,7 @@ export function DetailItem({ label, children, name, align, className }) { > {label} -
{children}
+
{children}
); } From a6db4fb6dffad1c75edf053c7f7526049e555f50 Mon Sep 17 00:00:00 2001 From: "a.nasouf" Date: Mon, 5 Feb 2024 19:52:48 +0200 Subject: [PATCH 096/148] fix: some keywords are not localized --- packages/webapp/src/containers/Authentication/Login.tsx | 2 +- packages/webapp/src/containers/Authentication/Register.tsx | 2 +- .../webapp/src/containers/Authentication/ResetPassword.tsx | 6 +++--- .../src/containers/Authentication/SendResetPassword.tsx | 6 +++--- .../src/containers/Authentication/SendResetPasswordForm.tsx | 5 ++--- packages/webapp/src/lang/ar/index.json | 5 +++++ packages/webapp/src/lang/en/index.json | 5 +++++ 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/webapp/src/containers/Authentication/Login.tsx b/packages/webapp/src/containers/Authentication/Login.tsx index 9efbac5e9..0ac30e1bc 100644 --- a/packages/webapp/src/containers/Authentication/Login.tsx +++ b/packages/webapp/src/containers/Authentication/Login.tsx @@ -70,7 +70,7 @@ function LoginFooterLinks() { {!signupDisabled && ( - Don't have an account? Sign up + )} diff --git a/packages/webapp/src/containers/Authentication/Register.tsx b/packages/webapp/src/containers/Authentication/Register.tsx index 5a42bbf67..32225c850 100644 --- a/packages/webapp/src/containers/Authentication/Register.tsx +++ b/packages/webapp/src/containers/Authentication/Register.tsx @@ -87,7 +87,7 @@ function RegisterFooterLinks() { return ( - Return to Sign In + diff --git a/packages/webapp/src/containers/Authentication/ResetPassword.tsx b/packages/webapp/src/containers/Authentication/ResetPassword.tsx index 136d28174..afb43308b 100644 --- a/packages/webapp/src/containers/Authentication/ResetPassword.tsx +++ b/packages/webapp/src/containers/Authentication/ResetPassword.tsx @@ -5,7 +5,7 @@ import { Formik } from 'formik'; import { Intent, Position } from '@blueprintjs/core'; import { Link, useParams, useHistory } from 'react-router-dom'; -import { AppToaster } from '@/components'; +import { AppToaster, FormattedMessage as T } from '@/components'; import { useAuthResetPassword } from '@/hooks/query'; import AuthInsider from '@/containers/Authentication/AuthInsider'; @@ -86,11 +86,11 @@ function ResetPasswordFooterLinks() { {!signupDisabled && ( - Don't have an account? Sign up + )} - Return to Sign In + ); diff --git a/packages/webapp/src/containers/Authentication/SendResetPassword.tsx b/packages/webapp/src/containers/Authentication/SendResetPassword.tsx index c90f872c1..b8f24831c 100644 --- a/packages/webapp/src/containers/Authentication/SendResetPassword.tsx +++ b/packages/webapp/src/containers/Authentication/SendResetPassword.tsx @@ -5,7 +5,7 @@ import { Formik } from 'formik'; import { Link, useHistory } from 'react-router-dom'; import { Intent } from '@blueprintjs/core'; -import { AppToaster } from '@/components'; +import { AppToaster, FormattedMessage as T } from '@/components'; import { useAuthSendResetPassword } from '@/hooks/query'; import SendResetPasswordForm from './SendResetPasswordForm'; @@ -82,11 +82,11 @@ function SendResetPasswordFooterLinks() { {!signupDisabled && ( - Don't have an account? Sign up + )} - Return to Sign In + ); diff --git a/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx b/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx index 3f2718d59..a2f6e114b 100644 --- a/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx +++ b/packages/webapp/src/containers/Authentication/SendResetPasswordForm.tsx @@ -14,8 +14,7 @@ export default function SendResetPasswordForm({ isSubmitting }) { return (
- Enter the email address associated with your account and we'll send you - a link to reset your password. + }> @@ -29,7 +28,7 @@ export default function SendResetPasswordForm({ isSubmitting }) { large={true} loading={isSubmitting} > - Reset Password + ); diff --git a/packages/webapp/src/lang/ar/index.json b/packages/webapp/src/lang/ar/index.json index 976b049d6..6e3996d91 100644 --- a/packages/webapp/src/lang/ar/index.json +++ b/packages/webapp/src/lang/ar/index.json @@ -20,6 +20,11 @@ "log_in": "تسجيل الدخول", "forget_my_password": "نسيت كلمة المرور الخاصة بي", "keep_me_logged_in": "تذكرني", + "dont_have_an_account": "ليس لديك حساب؟", + "sign_up": "تسجيل", + "return_to": "عودة إلى", + "sign_in": "صفحة الدخول", + "enter_the_email_address_associated_with_your_account": "قم بادخال بريدك الإلكتروني المرتبط بالحساب وسوف نرسل لك رابط لاعادة تعيين كلمة المرور.", "create_an_account": "إنشاء حساب", "need_bigcapital_account": "تحتاج إلى حساب Bigcapital؟", "show": "عرض", diff --git a/packages/webapp/src/lang/en/index.json b/packages/webapp/src/lang/en/index.json index 5522f57d6..12a36e5bd 100644 --- a/packages/webapp/src/lang/en/index.json +++ b/packages/webapp/src/lang/en/index.json @@ -19,6 +19,11 @@ "log_in": "Log in", "forget_my_password": "Forget my password", "keep_me_logged_in": "Keep me logged in", + "dont_have_an_account": "Don't have an account?", + "sign_up": "Sign up", + "return_to": "Return to", + "sign_in": "Sign In", + "enter_the_email_address_associated_with_your_account": "Enter the email address associated with your account and we'll send you a link to reset your password.", "create_an_account": "Create an account", "need_bigcapital_account": "Need a Bigcapital account ?", "show": "Show", From f7f77b12c9d70b9b89f56d18ec9a368b6f1b568d Mon Sep 17 00:00:00 2001 From: "a.nasouf" Date: Mon, 5 Feb 2024 20:05:10 +0200 Subject: [PATCH 097/148] fix: file formatting --- .../src/api/controllers/Contacts/Contacts.ts | 138 +++++++++--------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/packages/server/src/api/controllers/Contacts/Contacts.ts b/packages/server/src/api/controllers/Contacts/Contacts.ts index 2449e7d6f..751654a8e 100644 --- a/packages/server/src/api/controllers/Contacts/Contacts.ts +++ b/packages/server/src/api/controllers/Contacts/Contacts.ts @@ -1,11 +1,11 @@ -import { check, param, query, body, ValidationChain } from "express-validator"; -import { Router, Request, Response, NextFunction } from "express"; -import { Inject, Service } from "typedi"; -import { ServiceError } from "@/exceptions"; -import BaseController from "@/api/controllers/BaseController"; -import ContactsService from "@/services/Contacts/ContactsService"; -import DynamicListingService from "@/services/DynamicListing/DynamicListService"; -import { DATATYPES_LENGTH } from "@/data/DataTypes"; +import { check, param, query, body, ValidationChain } from 'express-validator'; +import { Router, Request, Response, NextFunction } from 'express'; +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import BaseController from '@/api/controllers/BaseController'; +import ContactsService from '@/services/Contacts/ContactsService'; +import DynamicListingService from '@/services/DynamicListing/DynamicListService'; +import { DATATYPES_LENGTH } from '@/data/DataTypes'; @Service() export default class ContactsController extends BaseController { @@ -22,31 +22,31 @@ export default class ContactsController extends BaseController { const router = Router(); router.get( - "/auto-complete", + '/auto-complete', [...this.autocompleteQuerySchema], this.validationResult, this.asyncMiddleware(this.autocompleteContacts.bind(this)), - this.dynamicListService.handlerErrorsToResponse + this.dynamicListService.handlerErrorsToResponse, ); router.get( - "/:id", - [param("id").exists().isNumeric().toInt()], + '/:id', + [param('id').exists().isNumeric().toInt()], this.validationResult, - this.asyncMiddleware(this.getContact.bind(this)) + this.asyncMiddleware(this.getContact.bind(this)), ); router.post( - "/:id/inactivate", - [param("id").exists().isNumeric().toInt()], + '/:id/inactivate', + [param('id').exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.inactivateContact.bind(this)), - this.handlerServiceErrors + this.handlerServiceErrors, ); router.post( - "/:id/activate", - [param("id").exists().isNumeric().toInt()], + '/:id/activate', + [param('id').exists().isNumeric().toInt()], this.validationResult, this.asyncMiddleware(this.activateContact.bind(this)), - this.handlerServiceErrors + this.handlerServiceErrors, ); return router; } @@ -56,11 +56,11 @@ export default class ContactsController extends BaseController { */ get autocompleteQuerySchema() { return [ - query("column_sort_by").optional().trim().escape(), - query("sort_order").optional().isIn(["desc", "asc"]), + query('column_sort_by').optional().trim().escape(), + query('sort_order').optional().isIn(['desc', 'asc']), - query("stringified_filter_roles").optional().isJSON(), - query("limit").optional().isNumeric().toInt(), + query('stringified_filter_roles').optional().isJSON(), + query('limit').optional().isNumeric().toInt(), ]; } @@ -77,7 +77,7 @@ export default class ContactsController extends BaseController { try { const contact = await this.contactsService.getContact( tenantId, - contactId + contactId, ); return res.status(200).send({ customer: this.transfromToResponse(contact), @@ -97,15 +97,15 @@ export default class ContactsController extends BaseController { const { tenantId } = req; const filter = { filterRoles: [], - sortOrder: "asc", - columnSortBy: "display_name", + sortOrder: 'asc', + columnSortBy: 'display_name', limit: 10, ...this.matchedQueryData(req), }; try { const contacts = await this.contactsService.autocompleteContacts( tenantId, - filter + filter, ); return res.status(200).send({ contacts }); } catch (error) { @@ -118,170 +118,170 @@ export default class ContactsController extends BaseController { */ get contactDTOSchema(): ValidationChain[] { return [ - check("salutation") + check('salutation') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("first_name") + check('first_name') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("last_name") + check('last_name') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("company_name") + check('company_name') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("display_name") + check('display_name') .exists() .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("email") + check('email') .optional({ nullable: true }) .isString() .normalizeEmail({ gmail_remove_dots: false }) .isEmail() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("website") + check('website') .optional({ nullable: true }) .isString() .trim() .isURL() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("work_phone") + check('work_phone') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("personal_phone") + check('personal_phone') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_1") + check('billing_address_1') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_2") + check('billing_address_2') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_city") + check('billing_address_city') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_country") + check('billing_address_country') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_email") + check('billing_address_email') .optional({ nullable: true }) .isString() .isEmail() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_postcode") + check('billing_address_postcode') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_phone") + check('billing_address_phone') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("billing_address_state") + check('billing_address_state') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_1") + check('shipping_address_1') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_2") + check('shipping_address_2') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_city") + check('shipping_address_city') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_country") + check('shipping_address_country') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_email") + check('shipping_address_email') .optional({ nullable: true }) .isString() .isEmail() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_postcode") + check('shipping_address_postcode') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_phone") + check('shipping_address_phone') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("shipping_address_state") + check('shipping_address_state') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), - check("note") + check('note') .optional({ nullable: true }) .isString() .trim() .escape() .isLength({ max: DATATYPES_LENGTH.TEXT }), - check("active").optional().isBoolean().toBoolean(), + check('active').optional().isBoolean().toBoolean(), ]; } @@ -291,19 +291,19 @@ export default class ContactsController extends BaseController { */ get contactNewDTOSchema(): ValidationChain[] { return [ - check("opening_balance") + check('opening_balance') .optional({ nullable: true }) .isInt({ min: 0, max: DATATYPES_LENGTH.DECIMAL_13_3 }) .toInt(), - check("opening_balance_exchange_rate") + check('opening_balance_exchange_rate') .default(1) .isFloat({ gt: 0 }) .toFloat(), - body("opening_balance_at") - .if(body("opening_balance").exists()) + body('opening_balance_at') + .if(body('opening_balance').exists()) .exists() .isISO8601(), - check("opening_balance_branch_id") + check('opening_balance_branch_id') .optional({ nullable: true }) .isNumeric() .toInt(), @@ -322,7 +322,7 @@ export default class ContactsController extends BaseController { * @returns {ValidationChain[]} */ get specificContactSchema(): ValidationChain[] { - return [param("id").exists().isNumeric().toInt()]; + return [param('id').exists().isNumeric().toInt()]; } /** @@ -340,7 +340,7 @@ export default class ContactsController extends BaseController { return res.status(200).send({ id: contactId, - message: "The given contact activated successfully.", + message: 'The given contact activated successfully.', }); } catch (error) { next(error); @@ -362,7 +362,7 @@ export default class ContactsController extends BaseController { return res.status(200).send({ id: contactId, - message: "The given contact inactivated successfully.", + message: 'The given contact inactivated successfully.', }); } catch (error) { next(error); @@ -380,22 +380,22 @@ export default class ContactsController extends BaseController { error: Error, req: Request, res: Response, - next: NextFunction + next: NextFunction, ) { if (error instanceof ServiceError) { - if (error.errorType === "contact_not_found") { + if (error.errorType === 'contact_not_found') { return res.boom.badRequest(null, { - errors: [{ type: "CONTACT.NOT.FOUND", code: 100 }], + errors: [{ type: 'CONTACT.NOT.FOUND', code: 100 }], }); } - if (error.errorType === "CONTACT_ALREADY_ACTIVE") { + if (error.errorType === 'CONTACT_ALREADY_ACTIVE') { return res.boom.badRequest(null, { - errors: [{ type: "CONTACT_ALREADY_ACTIVE", code: 700 }], + errors: [{ type: 'CONTACT_ALREADY_ACTIVE', code: 700 }], }); } - if (error.errorType === "CONTACT_ALREADY_INACTIVE") { + if (error.errorType === 'CONTACT_ALREADY_INACTIVE') { return res.boom.badRequest(null, { - errors: [{ type: "CONTACT_ALREADY_INACTIVE", code: 800 }], + errors: [{ type: 'CONTACT_ALREADY_INACTIVE', code: 800 }], }); } } From 12740223a8664cf8464628e62ab3863366988885 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 5 Feb 2024 22:38:56 +0200 Subject: [PATCH 098/148] fix: Invalid bill payment amount on editing bill payment --- .../server/src/api/controllers/Purchases/Bills.ts | 10 ++++++++++ .../services/Purchases/Bills/BillsValidators.ts | 14 ++++++++++++++ .../src/services/Purchases/Bills/EditBill.ts | 6 ++++++ .../src/services/Purchases/Bills/constants.ts | 1 + packages/webapp/src/constants/errors.tsx | 6 ++++-- .../containers/Purchases/Bills/BillForm/utils.tsx | 9 +++++++++ .../PaymentForm/PaymentMadeEntriesTable.tsx | 3 ++- .../Sales/Invoices/InvoiceForm/utils.tsx | 10 ++++++++++ .../PaymentReceiveItemsTable.tsx | 3 ++- packages/webapp/src/lang/en/index.json | 2 ++ 10 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/server/src/api/controllers/Purchases/Bills.ts b/packages/server/src/api/controllers/Purchases/Bills.ts index 9de79ecf7..4f65eab26 100644 --- a/packages/server/src/api/controllers/Purchases/Bills.ts +++ b/packages/server/src/api/controllers/Purchases/Bills.ts @@ -560,6 +560,16 @@ export default class BillsController extends BaseController { errors: [{ type: 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND', code: 1900 }], }); } + if (error.errorType === 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT') { + return res.boom.badRequest(null, { + errors: [ + { + type: 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT', + code: 2000, + }, + ], + }); + } } next(error); } diff --git a/packages/server/src/services/Purchases/Bills/BillsValidators.ts b/packages/server/src/services/Purchases/Bills/BillsValidators.ts index cba38dfbb..9f209e40d 100644 --- a/packages/server/src/services/Purchases/Bills/BillsValidators.ts +++ b/packages/server/src/services/Purchases/Bills/BillsValidators.ts @@ -21,6 +21,20 @@ export class BillsValidators { } } + /** + * Validates the bill amount is bigger than paid amount. + * @param {number} billAmount + * @param {number} paidAmount + */ + public validateBillAmountBiggerPaidAmount( + billAmount: number, + paidAmount: number, + ) { + if (billAmount < paidAmount) { + throw new ServiceError(ERRORS.BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT); + } + } + /** * Validates the bill number existance. */ diff --git a/packages/server/src/services/Purchases/Bills/EditBill.ts b/packages/server/src/services/Purchases/Bills/EditBill.ts index 26121c120..7b10bb7f1 100644 --- a/packages/server/src/services/Purchases/Bills/EditBill.ts +++ b/packages/server/src/services/Purchases/Bills/EditBill.ts @@ -103,6 +103,7 @@ export class EditBill { tenantId, billDTO.entries ); + // Transforms the bill DTO to model object. const billObj = await this.transformerDTO.billDTOToModel( tenantId, @@ -111,6 +112,11 @@ export class EditBill { authorizedUser, oldBill ); + // Validate bill total amount should be bigger than paid amount. + this.validators.validateBillAmountBiggerPaidAmount( + billObj.amount, + oldBill.paymentAmount + ); // Validate landed cost entries that have allocated cost could not be deleted. await this.entriesService.validateLandedCostEntriesNotDeleted( oldBill.entries, diff --git a/packages/server/src/services/Purchases/Bills/constants.ts b/packages/server/src/services/Purchases/Bills/constants.ts index 12afad4c7..9cc8566c8 100644 --- a/packages/server/src/services/Purchases/Bills/constants.ts +++ b/packages/server/src/services/Purchases/Bills/constants.ts @@ -18,6 +18,7 @@ export const ERRORS = { LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS: 'LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS', BILL_HAS_APPLIED_TO_VENDOR_CREDIT: 'BILL_HAS_APPLIED_TO_VENDOR_CREDIT', + BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT: 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT', }; export const DEFAULT_VIEW_COLUMNS = []; diff --git a/packages/webapp/src/constants/errors.tsx b/packages/webapp/src/constants/errors.tsx index 2411c4d74..22f6345cb 100644 --- a/packages/webapp/src/constants/errors.tsx +++ b/packages/webapp/src/constants/errors.tsx @@ -9,6 +9,8 @@ export const ERROR = { SALE_INVOICE_NO_NOT_UNIQUE: 'SALE_INVOICE_NO_NOT_UNIQUE', SALE_ESTIMATE_IS_ALREADY_CONVERTED_TO_INVOICE: 'SALE_ESTIMATE_IS_ALREADY_CONVERTED_TO_INVOICE', + INVOICE_AMOUNT_SMALLER_THAN_PAYMENT_AMOUNT: + 'INVOICE_AMOUNT_SMALLER_THAN_PAYMENT_AMOUNT', // Sales Receipts SALE_RECEIPT_NUMBER_NOT_UNIQUE: 'SALE_RECEIPT_NUMBER_NOT_UNIQUE', @@ -17,6 +19,6 @@ export const ERROR = { // Bills BILL_NUMBER_EXISTS: 'BILL.NUMBER.EXISTS', SALE_INVOICE_NO_IS_REQUIRED: 'SALE_INVOICE_NO_IS_REQUIRED', - ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED:"ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED", - + ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED: + 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED', }; diff --git a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx index 763573b30..f98f02757 100644 --- a/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx +++ b/packages/webapp/src/containers/Purchases/Bills/BillForm/utils.tsx @@ -67,6 +67,7 @@ export const ERRORS = { BILL_NUMBER_EXISTS: 'BILL.NUMBER.EXISTS', ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED: 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED', + BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT: 'BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT', }; /** * Transformes the bill to initial values of edit form. @@ -200,6 +201,14 @@ export const handleErrors = (errors, { setErrors }) => { }), ); } + if ( + errors.some((e) => e.type === ERRORS.BILL_AMOUNT_SMALLER_THAN_PAID_AMOUNT) + ) { + AppToaster.show({ + intent: Intent.DANGER, + message: intl.get('bill.total_smaller_than_paid_amount'), + }); + } }; export const useSetPrimaryBranchToForm = () => { diff --git a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx index cd0228799..841a3f359 100644 --- a/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx +++ b/packages/webapp/src/containers/Purchases/PaymentMades/PaymentForm/PaymentMadeEntriesTable.tsx @@ -30,6 +30,7 @@ export default function PaymentMadeEntriesTable({ // Formik context. const { values: { vendor_id }, + errors, } = useFormikContext(); // Handle update data. @@ -63,7 +64,7 @@ export default function PaymentMadeEntriesTable({ data={entries} spinnerProps={false} payload={{ - errors: [], + errors: errors?.entries || [], updateData: handleUpdateData, currencyCode, }} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index 8c32f017c..a2ff9988a 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -112,6 +112,16 @@ export const transformErrors = (errors, { setErrors }) => { intent: Intent.DANGER, }); } + if ( + errors.some( + ({ type }) => type === ERROR.INVOICE_AMOUNT_SMALLER_THAN_PAYMENT_AMOUNT, + ) + ) { + AppToaster.show({ + message: intl.get('sale_invoice.total_smaller_than_paid_amount'), + intent: Intent.DANGER, + }); + } if ( errors.some((error) => error.type === ERROR.SALE_INVOICE_NO_IS_REQUIRED) ) { diff --git a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx index 5b8a1be34..910403c7e 100644 --- a/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx +++ b/packages/webapp/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveItemsTable.tsx @@ -28,6 +28,7 @@ export default function PaymentReceiveItemsTable({ // Formik context. const { values: { customer_id }, + errors, } = useFormikContext(); // No results message. @@ -58,7 +59,7 @@ export default function PaymentReceiveItemsTable({ data={entries} spinnerProps={false} payload={{ - errors: [], + errors: errors?.entries || [], updateData: handleUpdateData, currencyCode, }} diff --git a/packages/webapp/src/lang/en/index.json b/packages/webapp/src/lang/en/index.json index 5522f57d6..bf780d4cf 100644 --- a/packages/webapp/src/lang/en/index.json +++ b/packages/webapp/src/lang/en/index.json @@ -653,7 +653,9 @@ "invoice_number_is_not_unqiue": "Invoice number is not unqiue", "sale_receipt_number_not_unique": "Receipt number is not unique", "sale_invoice_number_is_exists": "Sale invoice number is exists", + "sale_invoice.total_smaller_than_paid_amount": "The invoice total is smaller than the invoice paid amount.", "bill_number_exists": "Bill number exists", + "bill.total_smaller_than_paid_amount": "The bill total is smaller than the bill paid amount.", "ok": "Ok!", "quantity_cannot_be_zero_or_empty": "Quantity cannot be zero or empty.", "customer_email": "Customer email", From 528d447443a4b2ada6f1e365e0878ab6c43e30e5 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 5 Feb 2024 23:04:02 +0200 Subject: [PATCH 099/148] fix(server): Trial balance total row shouldn't show if accounts have no balances --- .../TrialBalanceSheet/TrialBalanceSheet.ts | 4 ---- .../TrialBalanceSheet/TrialBalanceSheetTable.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts index 38ed3a944..8554268e3 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts @@ -252,10 +252,6 @@ export default class TrialBalanceSheet extends FinancialSheet { * @return {ITrialBalanceSheetData} */ public reportData(): ITrialBalanceSheetData { - // Don't return noting if the journal has no transactions. - if (this.repository.totalAccountsLedger.isEmpty()) { - return null; - } // Retrieve accounts nodes. const accounts = this.accountsSection(this.repository.accounts); diff --git a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts index 1cbb2e7e6..5de03eb71 100644 --- a/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts +++ b/packages/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetTable.ts @@ -46,7 +46,7 @@ export class TrialBalanceSheetTable extends R.compose( this.query = query; this.i18n = i18n; } - + /** * Retrieve the common columns for all report nodes. * @param {ITableColumnAccessor[]} @@ -123,7 +123,7 @@ export class TrialBalanceSheetTable extends R.compose( */ public tableRows = (): ITableRow[] => { return R.compose( - R.append(this.totalTableRow()), + R.unless(R.isEmpty, R.append(this.totalTableRow())), R.concat(this.accountsTableRows()) )([]); }; From 374f1acf8a48c6035d3777f63c1faffda15f5b41 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 6 Feb 2024 10:54:41 +0200 Subject: [PATCH 100/148] fix: payment receive subtotal shouldn't be rounded --- .../PaymentReceives/PaymentReceiveTransformer.ts | 13 +++++++++++++ .../PaymentReceiveDetailTableFooter.tsx | 5 ++--- .../Drawers/PaymentReceiveDetailDrawer/utils.tsx | 12 +++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts index 531023ce3..5ca84db07 100644 --- a/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts +++ b/packages/server/src/services/Sales/PaymentReceives/PaymentReceiveTransformer.ts @@ -10,6 +10,7 @@ export class PaymentReceiveTransfromer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'subtotalFormatted', 'formattedPaymentDate', 'formattedAmount', 'formattedExchangeRate', @@ -26,6 +27,18 @@ export class PaymentReceiveTransfromer extends Transformer { return this.formatDate(payment.paymentDate); }; + /** + * Retrieve the formatted payment subtotal. + * @param {IPaymentReceive} payment + * @returns {string} + */ + protected subtotalFormatted = (payment: IPaymentReceive): string => { + return formatNumber(payment.amount, { + currencyCode: payment.currencyCode, + money: false, + }); + }; + /** * Retrieve formatted payment amount. * @param {ISaleInvoice} invoice diff --git a/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveDetailTableFooter.tsx index eeee13ffe..868aecc0d 100644 --- a/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/PaymentReceiveDetailTableFooter.tsx @@ -3,10 +3,9 @@ import React from 'react'; import styled from 'styled-components'; import { - FormatNumber, + T, TotalLineTextStyle, TotalLineBorderStyle, - T, TotalLine, TotalLines, } from '@/components'; @@ -27,7 +26,7 @@ export default function PaymentReceiveDetailTableFooter() { > } - value={} + value={paymentReceive.subtotal_formatted} /> } diff --git a/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/utils.tsx index 8971f3ec6..984eb5658 100644 --- a/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/PaymentReceiveDetailDrawer/utils.tsx @@ -10,7 +10,7 @@ import { MenuItem, Menu, } from '@blueprintjs/core'; -import { Icon, FormatNumberCell } from '@/components'; +import { Icon } from '@/components'; import { getColumnWidth } from '@/utils'; import { usePaymentReceiveDetailContext } from './PaymentReceiveDetailProvider'; @@ -40,9 +40,8 @@ export const usePaymentReceiveEntriesColumns = () => { }, { Header: intl.get('invoice_amount'), - accessor: 'invoice.balance', - Cell: FormatNumberCell, - width: getColumnWidth(entries, 'invoice.balance', { + accessor: 'invoice.total_formatted', + width: getColumnWidth(entries, 'invoice.total_formatted', { minWidth: 60, magicSpacing: 5, }), @@ -51,10 +50,9 @@ export const usePaymentReceiveEntriesColumns = () => { }, { Header: intl.get('amount_due'), - accessor: 'invoice.due_amount', - Cell: FormatNumberCell, + accessor: 'invoice.due_amount_formatted', align: 'right', - width: getColumnWidth(entries, 'invoice.due_amount', { + width: getColumnWidth(entries, 'invoice.due_amount_formatted', { minWidth: 60, magicSpacing: 5, }), From 0f678e61c5322ae5c299e3a18bb3bffbfd83862e Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 6 Feb 2024 20:31:48 +0200 Subject: [PATCH 101/148] fix: Decimal amounts are rounded when create a new transaction on some transactions types --- .../src/services/CreditNotes/CreditNoteTransformer.ts | 10 ++++++++++ .../VendorCredits/VendorCreditTransformer.ts | 10 ++++++++++ .../Sales/Estimates/SaleEstimateTransformer.ts | 10 ++++++++++ .../services/Sales/Invoices/ItemEntryTransformer.ts | 11 ++++++++++- .../services/Sales/Receipts/SaleReceiptTransformer.ts | 10 ++++++++++ .../CreditNoteDetailTableFooter.tsx | 5 +---- .../EstimateDetailTableFooter.tsx | 3 +-- .../containers/Drawers/EstimateDetailDrawer/utils.tsx | 5 ++--- .../ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx | 2 +- .../JournalEntriesTransactions/components.tsx | 7 ++++--- .../VendorCreditDetailDrawerFooter.tsx | 3 +-- .../Drawers/VendorCreditDetailDrawer/utils.tsx | 5 ++--- 12 files changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts index f532c2eab..6ed80a6f0 100644 --- a/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts +++ b/packages/server/src/services/CreditNotes/CreditNoteTransformer.ts @@ -14,6 +14,7 @@ export class CreditNoteTransformer extends Transformer { 'formattedCreditNoteDate', 'formattedAmount', 'formattedCreditsUsed', + 'formattedSubtotal', 'entries', ]; }; @@ -60,6 +61,15 @@ export class CreditNoteTransformer extends Transformer { }); }; + /** + * Retrieves the formatted subtotal. + * @param {ICreditNote} credit + * @returns {string} + */ + protected formattedSubtotal = (credit): string => { + return formatNumber(credit.amount, { money: false }); + }; + /** * Retrieves the entries of the credit note. * @param {ICreditNote} credit diff --git a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts index 3d74ee770..be1431ac2 100644 --- a/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts +++ b/packages/server/src/services/Purchases/VendorCredits/VendorCreditTransformer.ts @@ -11,6 +11,7 @@ export class VendorCreditTransformer extends Transformer { public includeAttributes = (): string[] => { return [ 'formattedAmount', + 'formattedSubtotal', 'formattedVendorCreditDate', 'formattedCreditsRemaining', 'entries', @@ -37,6 +38,15 @@ export class VendorCreditTransformer extends Transformer { }); }; + /** + * Retrieves the vendor credit formatted subtotal. + * @param {IVendorCredit} vendorCredit + * @returns {string} + */ + protected formattedSubtotal = (vendorCredit): string => { + return formatNumber(vendorCredit.amount, { money: false }); + }; + /** * Retrieve formatted credits remaining. * @param {IVendorCredit} credit diff --git a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts index 1102f7bd0..8cd99a9db 100644 --- a/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts +++ b/packages/server/src/services/Sales/Estimates/SaleEstimateTransformer.ts @@ -10,6 +10,7 @@ export class SaleEstimateTransfromer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'formattedSubtotal', 'formattedAmount', 'formattedEstimateDate', 'formattedExpirationDate', @@ -76,6 +77,15 @@ export class SaleEstimateTransfromer extends Transformer { }); }; + /** + * Retrieves the formatted invoice subtotal. + * @param {ISaleEstimate} estimate + * @returns {string} + */ + protected formattedSubtotal = (estimate: ISaleEstimate): string => { + return formatNumber(estimate.amount, { money: false }); + }; + /** * Retrieves the entries of the sale estimate. * @param {ISaleEstimate} estimate diff --git a/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts b/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts index ad0d88525..dbaea4862 100644 --- a/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/ItemEntryTransformer.ts @@ -8,7 +8,16 @@ export class ItemEntryTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['rateFormatted', 'totalFormatted']; + return ['quantityFormatted', 'rateFormatted', 'totalFormatted']; + }; + + /** + * Retrieves the formatted quantitty of item entry. + * @param {IItemEntry} entry + * @returns {string} + */ + protected quantityFormatted = (entry: IItemEntry): string => { + return formatNumber(entry.quantity, { money: false }); }; /** diff --git a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts index c8b950711..9e5d3a127 100644 --- a/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts +++ b/packages/server/src/services/Sales/Receipts/SaleReceiptTransformer.ts @@ -12,6 +12,7 @@ export class SaleReceiptTransformer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'formattedSubtotal', 'formattedAmount', 'formattedReceiptDate', 'formattedClosedAtDate', @@ -37,6 +38,15 @@ export class SaleReceiptTransformer extends Transformer { return this.formatDate(receipt.closedAt); }; + /** + * Retrieves the estimate formatted subtotal. + * @param {ISaleReceipt} receipt + * @returns {string} + */ + protected formattedSubtotal = (receipt: ISaleReceipt): string => { + return formatNumber(receipt.amount, { money: false }); + }; + /** * Retrieve formatted invoice amount. * @param {ISaleReceipt} estimate diff --git a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx index 66ecf1b66..6171686cf 100644 --- a/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/CreditNoteDetailDrawer/CreditNoteDetailTableFooter.tsx @@ -1,12 +1,9 @@ // @ts-nocheck -import React from 'react'; import styled from 'styled-components'; - import { T, TotalLines, TotalLine, - FormatNumber, TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; @@ -23,7 +20,7 @@ export default function CreditNoteDetailTableFooter() { } - value={} + value={creditNote.formatted_subtotal} /> } diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx index c5a79c935..8bc3ee96a 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailTableFooter.tsx @@ -8,7 +8,6 @@ import { TotalLine, TotalLineBorderStyle, TotalLineTextStyle, - FormatNumber, } from '@/components'; import { useEstimateDetailDrawerContext } from './EstimateDetailDrawerProvider'; @@ -23,7 +22,7 @@ export default function EstimateDetailTableFooter() { } - value={} + value={estimate.formatted_subtotal} borderStyle={TotalLineBorderStyle.SingleDark} /> { }, { Header: intl.get('quantity'), - accessor: 'quantity', + accessor: 'quantity_formatted', Cell: FormatNumberCell, - width: getColumnWidth(entries, 'quantity', { + width: getColumnWidth(entries, 'quantity_formatted', { minWidth: 60, magicSpacing: 5, }), @@ -59,7 +59,6 @@ export const useEstimateReadonlyEntriesColumns = () => { { Header: intl.get('amount'), accessor: 'total_formatted', - Cell: FormatNumberCell, width: getColumnWidth(entries, 'total_formatted', { minWidth: 60, magicSpacing: 5, diff --git a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx index 657606f17..b5687a091 100644 --- a/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/ReceiptDetailDrawer/ReceiptDetailTableFooter.tsx @@ -23,7 +23,7 @@ export default function ReceiptDetailTableFooter() { } - value={receipt.formatted_amount} + value={receipt.formatted_subtotal} /> } diff --git a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/JournalEntriesTransactions/components.tsx b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/JournalEntriesTransactions/components.tsx index 247d21a46..99c8101de 100644 --- a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/JournalEntriesTransactions/components.tsx +++ b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/JournalEntriesTransactions/components.tsx @@ -13,7 +13,6 @@ export const useJournalEntriesTransactionsColumns = () => { () => [ { Header: intl.get('date'), - accessor: 'date', accessor: 'formatted_date', Cell: FormatDateCell, width: 140, @@ -34,15 +33,17 @@ export const useJournalEntriesTransactionsColumns = () => { }, { Header: intl.get('credit'), - accessor: ({ credit }) => credit.formatted_amount, + accessor: 'credit.formatted_amount', width: 100, className: 'credit', + align: 'right', }, { Header: intl.get('debit'), - accessor: ({ debit }) => debit.formatted_amount, + accessor: 'debit.formatted_amount', width: 100, className: 'debit', + align: 'right', }, ], [], diff --git a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx index e11cbce67..0bfd435e9 100644 --- a/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx +++ b/packages/webapp/src/containers/Drawers/VendorCreditDetailDrawer/VendorCreditDetailDrawerFooter.tsx @@ -8,7 +8,6 @@ import { TotalLine, TotalLineBorderStyle, TotalLineTextStyle, - FormatNumber, } from '@/components'; import { useVendorCreditDetailDrawerContext } from './VendorCreditDetailDrawerProvider'; @@ -23,7 +22,7 @@ export default function VendorCreditDetailDrawerFooter() { } - value={vendorCredit.formatted_amount} + value={vendorCredit.formatted_subtotal} borderStyle={TotalLineBorderStyle.SingleDark} /> { }, { Header: intl.get('quantity'), - accessor: 'quantity', - Cell: FormatNumberCell, - width: getColumnWidth(entries, 'quantity', { + accessor: 'quantity_formatted', + width: getColumnWidth(entries, 'quantity_formatted', { minWidth: 60, magicSpacing: 5, }), From 0c61f85707c83c4ebd6e6cd4866bc147034685d6 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 6 Feb 2024 20:38:25 +0200 Subject: [PATCH 102/148] chore: remove format number from estimate quantity --- .../src/containers/Drawers/EstimateDetailDrawer/utils.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx index 84acab544..eb9def4e6 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/utils.tsx @@ -2,7 +2,7 @@ import React from 'react'; import intl from 'react-intl-universal'; import { getColumnWidth } from '@/utils'; -import { FormatNumberCell, TextOverviewTooltipCell } from '@/components'; +import { TextOverviewTooltipCell } from '@/components'; import { useEstimateDetailDrawerContext } from './EstimateDetailDrawerProvider'; /** @@ -36,7 +36,6 @@ export const useEstimateReadonlyEntriesColumns = () => { { Header: intl.get('quantity'), accessor: 'quantity_formatted', - Cell: FormatNumberCell, width: getColumnWidth(entries, 'quantity_formatted', { minWidth: 60, magicSpacing: 5, From 706a324121b6f7256427eeeb7ec0f2de9a629901 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 8 Feb 2024 20:18:52 +0200 Subject: [PATCH 103/148] feat(server): Plaid webhooks --- .../Banking/PlaidBankingController.ts | 18 +++ .../Banking/Plaid/PlaidApplication.ts | 26 +++ .../services/Banking/Plaid/PlaidWebhooks.ts | 151 ++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 packages/server/src/services/Banking/Plaid/PlaidWebhooks.ts diff --git a/packages/server/src/api/controllers/Banking/PlaidBankingController.ts b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts index 1d1cfe8a0..1f6c3f4dc 100644 --- a/packages/server/src/api/controllers/Banking/PlaidBankingController.ts +++ b/packages/server/src/api/controllers/Banking/PlaidBankingController.ts @@ -16,6 +16,7 @@ export class PlaidBankingController extends BaseController { router.post('/link-token', this.linkToken.bind(this)); router.post('/exchange-token', this.exchangeToken.bind(this)); + router.post('/webhooks', this.webhooks.bind(this)); return router; } @@ -50,4 +51,21 @@ export class PlaidBankingController extends BaseController { }); return res.status(200).send({}); } + + public async webhooks(req: Request, res: Response) { + const { tenantId } = req; + const { + webhook_type: webhookType, + webhook_code: webhookCode, + item_id: plaidItemId, + } = req.body; + + await this.plaidApp.webhooks( + tenantId, + webhookType, + plaidItemId, + webhookCode + ); + return res.status(200).send({ code: 200, message: 'ok' }); + } } diff --git a/packages/server/src/services/Banking/Plaid/PlaidApplication.ts b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts index 16096207e..891b06c45 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidApplication.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidApplication.ts @@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi'; import { PlaidLinkTokenService } from './PlaidLinkToken'; import { PlaidItemService } from './PlaidItem'; import { PlaidItemDTO } from '@/interfaces'; +import { PlaidWebooks } from './PlaidWebhooks'; @Service() export class PlaidApplication { @@ -11,6 +12,9 @@ export class PlaidApplication { @Inject() private plaidItemService: PlaidItemService; + @Inject() + private plaidWebhooks: PlaidWebooks; + /** * Retrieves the Plaid link token. * @param {number} tenantId @@ -30,4 +34,26 @@ export class PlaidApplication { public exchangeToken(tenantId: number, itemDTO: PlaidItemDTO): Promise { return this.plaidItemService.item(tenantId, itemDTO); } + + /** + * Listens to Plaid webhooks + * @param {number} tenantId + * @param {string} webhookType + * @param {string} plaidItemId + * @param {string} webhookCode + * @returns + */ + public webhooks( + tenantId: number, + webhookType: string, + plaidItemId: string, + webhookCode: string + ) { + return this.plaidWebhooks.webhooks( + tenantId, + webhookType, + plaidItemId, + webhookCode + ); + } } diff --git a/packages/server/src/services/Banking/Plaid/PlaidWebhooks.ts b/packages/server/src/services/Banking/Plaid/PlaidWebhooks.ts new file mode 100644 index 000000000..a91ac7d82 --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/PlaidWebhooks.ts @@ -0,0 +1,151 @@ +import { Inject, Service } from 'typedi'; +import { PlaidUpdateTransactions } from './PlaidUpdateTransactions'; + +@Service() +export class PlaidWebooks { + @Inject() + private updateTransactionsService: PlaidUpdateTransactions; + + /** + * Listens to Plaid webhooks + * @param {number} tenantId + * @param {string} webhookType + * @param {string} plaidItemId + * @param {string} webhookCode + */ + async webhooks( + tenantId: number, + webhookType: string, + plaidItemId: string, + webhookCode: string + ) { + // There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS. + // @TODO implement handling for remaining webhook types. + const webhookHandlerMap = { + transactions: this.handleTransactionsWebooks, + item: this.itemsHandler, + }; + const webhookHandler = + webhookHandlerMap[webhookType] || this.unhandledWebhook; + + await webhookHandler(tenantId, webhookCode, plaidItemId); + } + + /** + * Handles all unhandled/not yet implemented webhook events. + * @param {string} webhookType + * @param {string} webhookCode + * @param {string} plaidItemId + */ + async unhandledWebhook( + webhookType: string, + webhookCode: string, + plaidItemId: string + ) { + console.log( + `UNHANDLED ${webhookType} WEBHOOK: ${webhookCode}: Plaid item id ${plaidItemId}: unhandled webhook type received.` + ); + } + + /** + * Logs to console and emits to socket + * @param {string} additionalInfo + * @param {string} webhookCode + * @param {string} plaidItemId + */ + private serverLogAndEmitSocket( + additionalInfo: string, + webhookCode: string, + plaidItemId: string + ) { + console.log( + `WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}` + ); + } + + /** + * Handles all transaction webhook events. The transaction webhook notifies + * you that a single item has new transactions available. + * @param {number} tenantId + * @param {string} plaidItemId + * @param {string} webhookCode + * @returns {Promise} + */ + public async handleTransactionsWebooks( + tenantId: number, + plaidItemId: string, + webhookCode: string + ): Promise { + switch (webhookCode) { + case 'SYNC_UPDATES_AVAILABLE': { + // Fired when new transactions data becomes available. + const { addedCount, modifiedCount, removedCount } = + await this.updateTransactionsService.updateTransactions( + tenantId, + plaidItemId + ); + this.serverLogAndEmitSocket( + `Transactions: ${addedCount} added, ${modifiedCount} modified, ${removedCount} removed`, + webhookCode, + plaidItemId + ); + break; + } + case 'DEFAULT_UPDATE': + case 'INITIAL_UPDATE': + case 'HISTORICAL_UPDATE': + /* ignore - not needed if using sync endpoint + webhook */ + break; + default: + this.serverLogAndEmitSocket( + `unhandled webhook type received.`, + webhookCode, + plaidItemId + ); + } + } + + /** + * Handles all Item webhook events. + * @param {number} tenantId - Tenant ID + * @param {string} webhookCode - The webhook code + * @param {string} plaidItemId - The Plaid ID for the item + * @returns {Promise} + */ + public async itemsHandler( + tenantId: number, + webhookCode: string, + plaidItemId: string + ): Promise { + switch (webhookCode) { + case 'WEBHOOK_UPDATE_ACKNOWLEDGED': + this.serverLogAndEmitSocket('is updated', plaidItemId, error); + break; + case 'ERROR': { + this.serverLogAndEmitSocket( + `ERROR: ${error.error_code}: ${error.error_message}`, + itemId, + error.error_code + ); + break; + } + case 'PENDING_EXPIRATION': { + const { id: itemId } = await retrieveItemByPlaidItemId(plaidItemId); + await updateItemStatus(itemId, 'bad'); + + this.serverLogAndEmitSocket( + `user needs to re-enter login credentials`, + itemId, + error + ); + break; + } + default: + this.serverLogAndEmitSocket( + 'unhandled webhook type received.', + plaidItemId, + error + ); + } + } +} From 17dbe9713b97267a350a9cd52076ed5ba91af857 Mon Sep 17 00:00:00 2001 From: "a.nasouf" Date: Sat, 10 Feb 2024 19:59:12 +0200 Subject: [PATCH 104/148] fix: remove normalizeEmail function --- packages/server/src/api/controllers/Contacts/Contacts.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/api/controllers/Contacts/Contacts.ts b/packages/server/src/api/controllers/Contacts/Contacts.ts index 751654a8e..24b99e09f 100644 --- a/packages/server/src/api/controllers/Contacts/Contacts.ts +++ b/packages/server/src/api/controllers/Contacts/Contacts.ts @@ -153,7 +153,6 @@ export default class ContactsController extends BaseController { check('email') .optional({ nullable: true }) .isString() - .normalizeEmail({ gmail_remove_dots: false }) .isEmail() .isLength({ max: DATATYPES_LENGTH.STRING }), check('website') From cd8f64dfdc72cd7df124692334ac2837581d1d7e Mon Sep 17 00:00:00 2001 From: "a.nasouf" Date: Sat, 10 Feb 2024 21:04:54 +0200 Subject: [PATCH 105/148] feat(webapp): add mark as delivered to action bar of invoice details drawer --- .../InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx | 6 ++++++ .../src/containers/Drawers/InvoiceDetailDrawer/utils.tsx | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx index 4a2482165..678517ef4 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailActionsBar.tsx @@ -57,6 +57,11 @@ function InvoiceDetailActionsBar({ closeDrawer(DRAWERS.INVOICE_DETAILS); }; + // Hanlde deliver sale invoice. + const handleDeliverInvoice = ({ id }) => { + openAlert('invoice-deliver', { invoiceId }); + }; + // Handle convert to invoice. const handleConvertToCreitNote = () => { history.push(`/credit-notes/new?from_invoice_id=${invoiceId}`, { @@ -153,6 +158,7 @@ function InvoiceDetailActionsBar({ onCancelBadDebt: handleCancelBadDebtInvoice, onNotifyViaSMS: handleNotifyViaSMS, onConvert: handleConvertToCreitNote, + onDeliver: handleDeliverInvoice, }} />
diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/utils.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/utils.tsx index fc2d3d1b4..bb7f8ebc8 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/utils.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/utils.tsx @@ -19,6 +19,7 @@ import { FormattedMessage as T, Choose, Can, + If, TextOverviewTooltipCell, } from '@/components'; import { SaleInvoiceAction, AbilitySubject } from '@/constants/abilityOption'; @@ -94,7 +95,7 @@ export const useInvoiceReadonlyEntriesColumns = () => { * @returns {React.JSX} */ export const BadDebtMenuItem = ({ - payload: { onCancelBadDebt, onBadDebt, onNotifyViaSMS, onConvert }, + payload: { onCancelBadDebt, onBadDebt, onNotifyViaSMS, onConvert, onDeliver }, }) => { const { invoice } = useInvoiceDetailDrawerContext(); @@ -108,6 +109,12 @@ export const BadDebtMenuItem = ({ }} content={ + + } + /> + Date: Sat, 10 Feb 2024 22:18:13 +0200 Subject: [PATCH 106/148] feat(webapp): add convert to invoice on estimate drawer toolbar --- .../EstimateDetailActionsBar.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx index 3ac2b1b75..02ebed97c 100644 --- a/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx +++ b/packages/webapp/src/containers/Drawers/EstimateDetailDrawer/EstimateDetailActionsBar.tsx @@ -52,6 +52,15 @@ function EstimateDetailActionsBar({ history.push(`/estimates/${estimateId}/edit`); closeDrawer(DRAWERS.ESTIMATE_DETAILS); }; + + // Handle convert to invoice. + const handleConvertEstimate = () => { + history.push(`/invoices/new?from_estimate_id=${estimateId}`, { + action: estimateId, + }); + closeDrawer(DRAWERS.ESTIMATE_DETAILS); + }; + // Handle delete sale estimate. const handleDeleteEstimate = () => { openAlert('estimate-delete', { estimateId }); @@ -84,6 +93,12 @@ function EstimateDetailActionsBar({ + + + } + > + Contrary to popular belief, Lorem Ipsum is not simply random text. It + has roots in a piece of classical Latin literature. + + + } + disabled + > + Contrary to popular belief, Lorem Ipsum is not simply random text. It + has roots in a piece of classical Latin literature. + + + } + disabled + > + Contrary to popular belief, Lorem Ipsum is not simply random text. It + has roots in a piece of classical Latin literature. + + ); } - -export const BankFeedsServiceProviders = [{ label: 'Plaid', key: 'plaid' }]; diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankServiceCard.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankServiceCard.tsx new file mode 100644 index 000000000..72b0852a6 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankServiceCard.tsx @@ -0,0 +1,72 @@ +import styled from 'styled-components'; +import { Group } from '@/components'; + +const BankServiceIcon = styled('div')` + height: 40px; + width: 40px; + border: 1px solid #c8cad0; + border-radius: 3px; + display: flex; + + svg { + margin: auto; + } +`; +const BankServiceContent = styled(`div`)` + flex: 1 0; +`; +const BankServiceCardRoot = styled('button')` + border-radius: 3px; + border: 1px solid #c8cad0; + transition: all 0.1s ease-in-out; + background: transparent; + text-align: inherit; + padding: 14px; + + &:not(:disabled) { + cursor: pointer; + } + &:hover:not(:disabled) { + border-color: #0153cc; + } + &:disabled { + background: #f9fdff; + } +`; +const BankServiceTitle = styled(`h3`)` + font-weight: 600; + font-size: 14px; + color: #2d333d; +`; +const BankServiceDesc = styled('p')` + margin-top: 4px; + margin-bottom: 6px; + font-size: 13px; + color: #738091; +`; + +interface BankServiceCardProps { + title: string; + children: React.ReactNode; + disabled?: boolean; + icon: React.ReactNode; +} + +export function BankServiceCard({ + title, + children, + icon, + disabled, +}: BankServiceCardProps) { + return ( + + + {icon} + + {title} + {children} + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/Icons/PlaidIcon.tsx b/packages/webapp/src/containers/CashFlow/Icons/PlaidIcon.tsx new file mode 100644 index 000000000..7a9f5ef62 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/Icons/PlaidIcon.tsx @@ -0,0 +1,17 @@ + +export const PlaidIcon = (props: any) => ( + + + +); diff --git a/packages/webapp/src/containers/CashFlow/Icons/TellerIcon.tsx b/packages/webapp/src/containers/CashFlow/Icons/TellerIcon.tsx new file mode 100644 index 000000000..74ec99dd0 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/Icons/TellerIcon.tsx @@ -0,0 +1,42 @@ +export const TellerIcon = () => ( + + + + + + + + + + + + + + +); diff --git a/packages/webapp/src/containers/CashFlow/Icons/YodleeIcon.tsx b/packages/webapp/src/containers/CashFlow/Icons/YodleeIcon.tsx new file mode 100644 index 000000000..3016cd729 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/Icons/YodleeIcon.tsx @@ -0,0 +1,45 @@ +export const YodleeIcon = (props: any) => ( + + + + + + + + + + + +); From 685a6150e61871371063886b39bdcefa3eac8724 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 1 Mar 2024 17:55:21 +0200 Subject: [PATCH 136/148] feat(webapp): add the text of connect bank dialog --- .../ConnectBankDialogContent.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogContent.tsx b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogContent.tsx index bfa897e2b..1bbc9c970 100644 --- a/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogContent.tsx +++ b/packages/webapp/src/containers/CashFlow/ConnectBankDialog/ConnectBankDialogContent.tsx @@ -15,35 +15,32 @@ export function ConnectBankDialogContent() { return (
- Contrary to popular belief, Lorem Ipsum is not simply random text. It - has roots in a piece of classical Latin literature + Connect your bank accounts and fetch the bank transactions using + one of our supported third-party service providers. } > - Contrary to popular belief, Lorem Ipsum is not simply random text. It - has roots in a piece of classical Latin literature. + Plaid gives the connection to 12,000 financial institutions across US, UK and Canada. } disabled > - Contrary to popular belief, Lorem Ipsum is not simply random text. It - has roots in a piece of classical Latin literature. + Connect instantly with more than 5,000 financial institutions across US. } disabled > - Contrary to popular belief, Lorem Ipsum is not simply random text. It - has roots in a piece of classical Latin literature. + Connect instantly with a global network of financial institutions.
From 0273714a07feb01952526ef8ad7ad39112962cf6 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 2 Mar 2024 17:01:58 +0200 Subject: [PATCH 137/148] feat(webapp): Filter account transactions by categorized/uncategorized transactions --- .../components/ContentTabs/ContentTabs.tsx | 111 ++++++++++++++++++ .../src/components/ContentTabs/index.ts | 1 + .../AccountTransactionsFilterTabs.tsx | 33 ++++++ .../AccountTransactionsList.tsx | 19 ++- .../AccountTransactionsUncategorizeFilter.tsx | 27 +++++ 5 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 packages/webapp/src/components/ContentTabs/ContentTabs.tsx create mode 100644 packages/webapp/src/components/ContentTabs/index.ts create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx new file mode 100644 index 000000000..f872430b3 --- /dev/null +++ b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx @@ -0,0 +1,111 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +const ContentTabsRoot = styled('div')` + display: flex; + gap: 10px; + +`; +interface ContentTabItemRootProps { + active?: boolean; +} +const ContentTabItemRoot = styled.button` + flex: 1 0; + background: #fff; + border: 1px solid #e1e2e8; + border-radius: 5px; + padding: 11px; + text-align: left; + cursor: pointer; + + ${(props) => + props.active && + ` + border-color: #1552c8; + box-shadow: 0 0 0 0.25px #1552c8; + + ${ContentTabTitle} { + color: #1552c8; + font-weight: 500; + } + ${ContentTabDesc} { + color: #1552c8; + } + `} + &:hover, + &:active { + border-color: #1552c8; + } +`; +const ContentTabTitle = styled('h3')` + font-size: 14px; + font-weight: 400; + color: #2f343c; +`; +const ContentTabDesc = styled('p')` + margin: 0; + color: #5f6b7c; + margin-top: 4px; + font-size: 12px; +`; + +interface ContentTabsItemProps { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + active?: boolean; +} + +const ContentTabsItem = ({ + title, + description, + active, +}: ContentTabsItemProps) => { + return ( + + {title} + {description} + + ); +}; + +interface ContentTabsProps { + initialValue?: string; + value?: string; + onChange?: (value: string) => void; + children?: React.ReactNode; + className?: string; +} + +export function ContentTabs({ + initialValue, + value, + onChange, + children, + className +}: ContentTabsProps) { + const [localValue, handleItemChange] = useUncontrolled({ + initialValue, + value, + onChange, + finalValue: '', + }); + const tabs = React.Children.toArray(children); + + return ( + + {tabs.map((tab) => ( + handleItemChange(tab.props?.id)} + /> + ))} + + ); +} + +ContentTabs.Tab = ContentTabsItem; diff --git a/packages/webapp/src/components/ContentTabs/index.ts b/packages/webapp/src/components/ContentTabs/index.ts new file mode 100644 index 000000000..332e23bfb --- /dev/null +++ b/packages/webapp/src/components/ContentTabs/index.ts @@ -0,0 +1 @@ +export * from './ContentTabs'; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx new file mode 100644 index 000000000..d8a4b8056 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx @@ -0,0 +1,33 @@ +import styled from 'styled-components'; +import { ContentTabs } from '@/components/ContentTabs/ContentTabs'; + +const AccountContentTabs = styled(ContentTabs)` + margin: 15px 15px 0 15px; +`; + +export function AccountTransactionsFilterTabs() { + return ( + + + + 20 Uncategorized + Transactions + + } + description={'For Bank Statement'} + /> + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx index 27fd17af2..e85d48a1c 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -11,6 +11,8 @@ import AccountTransactionsDataTable from './AccountTransactionsDataTable'; import { AccountTransactionsProvider } from './AccountTransactionsProvider'; import { AccountTransactionsDetailsBar } from './AccountTransactionsDetailsBar'; import { AccountTransactionsProgressBar } from './components'; +import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs'; +import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter'; /** * Account transactions list. @@ -23,9 +25,15 @@ function AccountTransactionsList() { - - - + + + + + + + + + ); @@ -37,7 +45,10 @@ const CashflowTransactionsTableCard = styled.div` border: 2px solid #f0f0f0; border-radius: 10px; padding: 30px 18px; - margin: 30px 15px; background: #fff; flex: 0 1; `; + +const Box = styled.div` + margin: 30px 15px; +`; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx new file mode 100644 index 000000000..862a55855 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx @@ -0,0 +1,27 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Tag } from '@blueprintjs/core'; + +const Root = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + margin-bottom: 18px; +`; + +const FilterTag = styled(Tag)` + min-height: 26px; +`; + +export function AccountTransactionsUncategorizeFilter() { + return ( + + + All (2) + + + Recognized (0) + + + ); +} From 9db03350e08fcd99968edfc49bf431f597f6a44f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 4 Mar 2024 13:41:15 +0200 Subject: [PATCH 138/148] feat(webapp): categorize the cashflow uncategorized transactions --- .../components/ContentTabs/ContentTabs.tsx | 6 +- .../AccountTransactionsFilterTabs.tsx | 10 +- .../AccountTransactionsList.tsx | 46 +++--- .../AccountTransactionsProvider.tsx | 58 +++++++- .../AccountTransactionsUncategorizedTable.tsx | 139 ++++++++++++++++++ .../AccountsTransactionsAll.tsx | 31 ++++ .../AllTransactionsUncategorized.tsx | 29 ++++ .../AccountTransactions/components.tsx | 72 ++++++++- .../CashFlowAccountsActionsBar.tsx | 4 +- .../drawers/CategorizeTransactionDrawer.tsx | 33 +++++ .../src/hooks/query/cashflowAccounts.tsx | 41 +++++- packages/webapp/src/hooks/query/types.tsx | 6 +- 12 files changed, 439 insertions(+), 36 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx index f872430b3..58f844782 100644 --- a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx +++ b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx @@ -6,7 +6,6 @@ import { useUncontrolled } from '@/hooks/useUncontrolled'; const ContentTabsRoot = styled('div')` display: flex; gap: 10px; - `; interface ContentTabItemRootProps { active?: boolean; @@ -62,9 +61,10 @@ const ContentTabsItem = ({ title, description, active, + onClick, }: ContentTabsItemProps) => { return ( - + {title} {description} @@ -84,7 +84,7 @@ export function ContentTabs({ value, onChange, children, - className + className, }: ContentTabsProps) { const [localValue, handleItemChange] = useUncontrolled({ initialValue, diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx index d8a4b8056..aede21571 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx @@ -1,13 +1,21 @@ +// @ts-nocheck import styled from 'styled-components'; import { ContentTabs } from '@/components/ContentTabs/ContentTabs'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; const AccountContentTabs = styled(ContentTabs)` margin: 15px 15px 0 15px; `; export function AccountTransactionsFilterTabs() { + const { filterTab, setFilterTab } = useAccountTransactionsContext(); + + const handleChange = (value) => { + setFilterTab(value); + }; + return ( - + - - - - - - - + }> + + ); @@ -41,14 +39,20 @@ function AccountTransactionsList() { export default AccountTransactionsList; -const CashflowTransactionsTableCard = styled.div` - border: 2px solid #f0f0f0; - border-radius: 10px; - padding: 30px 18px; - background: #fff; - flex: 0 1; -`; +const AccountsTransactionsAll = React.lazy( + () => import('./AccountsTransactionsAll'), +); -const Box = styled.div` - margin: 30px 15px; -`; +const AccountsTransactionsUncategorized = React.lazy( + () => import('./AllTransactionsUncategorized'), +); + +function AccountTransactionsContent() { + const { filterTab } = useAccountTransactionsContext(); + + return filterTab === 'uncategorized' ? ( + + ) : ( + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx index 744863b87..f0aa661e3 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx @@ -7,7 +7,9 @@ import { useAccountTransactionsInfinity, useCashflowAccounts, useAccount, + useAccountUncategorizedTransactionsInfinity, } from '@/hooks/query'; +import { useAppQueryString } from '@/hooks'; const AccountTransactionsContext = React.createContext(); @@ -15,6 +17,10 @@ function flattenInfinityPages(data) { return flatten(map(data.pages, (page) => page.transactions)); } +function flattenInfinityPagesData(data) { + return flatten(map(data.pages, (page) => page.data)); +} + /** * Account transctions provider. */ @@ -22,6 +28,13 @@ function AccountTransactionsProvider({ query, ...props }) { const { id } = useParams(); const accountId = parseInt(id, 10); + const [locationQuery, setLocationQuery] = useAppQueryString(); + + const filterTab = locationQuery?.filter || 'all'; + const setFilterTab = (value: stirng) => { + setLocationQuery({ filter: value }); + }; + // Fetch cashflow account transactions list const { data: cashflowTransactionsPages, @@ -31,10 +44,32 @@ function AccountTransactionsProvider({ query, ...props }) { fetchNextPage: fetchNextTransactionsPage, isFetchingNextPage, hasNextPage, - } = useAccountTransactionsInfinity(accountId, { - page_size: 50, - account_id: accountId, - }); + } = useAccountTransactionsInfinity( + accountId, + { + page_size: 50, + account_id: accountId, + }, + { + enabled: filterTab === 'all' || filterTab === 'dashboard', + }, + ); + + const { + data: uncategorizedTransactionsPage, + isFetching: isUncategorizedTransactionFetching, + isLoading: isUncategorizedTransactionsLoading, + isSuccess: isUncategorizedTransactionsSuccess, + fetchNextPage: fetchNextUncategorizedTransactionsPage, + } = useAccountUncategorizedTransactionsInfinity( + accountId, + { + page_size: 50, + }, + { + enabled: filterTab === 'uncategorized', + }, + ); // Memorized the cashflow account transactions. const cashflowTransactions = React.useMemo( @@ -45,6 +80,15 @@ function AccountTransactionsProvider({ query, ...props }) { [cashflowTransactionsPages, isCashflowTransactionsSuccess], ); + // Memorized the cashflow account transactions. + const uncategorizedTransactions = React.useMemo( + () => + isUncategorizedTransactionsSuccess + ? flattenInfinityPagesData(uncategorizedTransactionsPage) + : [], + [uncategorizedTransactionsPage, isUncategorizedTransactionsSuccess], + ); + // Fetch cashflow accounts. const { data: cashflowAccounts, @@ -78,6 +122,12 @@ function AccountTransactionsProvider({ query, ...props }) { isCashFlowAccountsLoading, isCurrentAccountFetching, isCurrentAccountLoading, + + filterTab, + setFilterTab, + + uncategorizedTransactions, + isUncategorizedTransactionFetching }; return ( diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx new file mode 100644 index 000000000..b6a671bb6 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -0,0 +1,139 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; + +import { + DataTable, + TableFastCell, + TableSkeletonRows, + TableSkeletonHeader, + TableVirtualizedListRows, + FormattedMessage as T, +} from '@/components'; +import { TABLES } from '@/constants/tables'; + +import withSettings from '@/containers/Settings/withSettings'; +import withAlertsActions from '@/containers/Alert/withAlertActions'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; + +import { useMemorizedColumnsWidths } from '@/hooks'; +import { + ActionsMenu, + useAccountUncategorizedTransactionsColumns, +} from './components'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; +import { handleCashFlowTransactionType } from './utils'; + +import { compose } from '@/utils'; + +/** + * Account transactions data table. + */ +function AccountTransactionsDataTable({ + // #withSettings + cashflowTansactionsTableSize, + + // #withAlertsActions + openAlert, + + // #withDrawerActions + openDrawer, +}) { + // Retrieve table columns. + const columns = useAccountUncategorizedTransactionsColumns(); + + // Retrieve list context. + const { uncategorizedTransactions, isCashFlowTransactionsLoading } = + useAccountTransactionsContext(); + + // Local storage memorizing columns widths. + const [initialColumnsWidths, , handleColumnResizing] = + useMemorizedColumnsWidths(TABLES.CASHFLOW_Transactions); + + // handle delete transaction + const handleDeleteTransaction = ({ reference_id }) => {}; + + const handleViewDetailCashflowTransaction = (referenceType) => {}; + + // Handle cell click. + const handleCellClick = (cell, event) => {}; + + return ( + } + className="table-constrant" + payload={{ + onViewDetails: handleViewDetailCashflowTransaction, + onDelete: handleDeleteTransaction, + }} + /> + ); +} + +export default compose( + withSettings(({ cashflowTransactionsSettings }) => ({ + cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, + })), + withAlertsActions, + withDrawerActions, +)(AccountTransactionsDataTable); + +const DashboardConstrantTable = styled(DataTable)` + .table { + .thead { + .th { + background: #fff; + } + } + + .tbody { + .tr:last-child .td { + border-bottom: 0; + } + } + } +`; + +const CashflowTransactionsTable = styled(DashboardConstrantTable)` + .table .tbody { + .tbody-inner .tr.no-results { + .td { + padding: 2rem 0; + font-size: 14px; + color: #888; + font-weight: 400; + border-bottom: 0; + } + } + + .tbody-inner { + .tr .td:not(:first-child) { + border-left: 1px solid #e6e6e6; + } + + .td-description { + color: #5F6B7C; + } + } + } +`; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx new file mode 100644 index 000000000..f181983af --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck +import styled from 'styled-components'; + +import '@/style/pages/CashFlow/AccountTransactions/List.scss'; + +import AccountTransactionsDataTable from './AccountTransactionsDataTable'; +import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter'; + +const Box = styled.div` + margin: 30px 15px; +`; + +const CashflowTransactionsTableCard = styled.div` + border: 2px solid #f0f0f0; + border-radius: 10px; + padding: 30px 18px; + background: #fff; + flex: 0 1; +`; + +export default function AccountTransactionsAll() { + return ( + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx new file mode 100644 index 000000000..1595b746d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -0,0 +1,29 @@ +// @ts-nocheck +import styled from 'styled-components'; + +import '@/style/pages/CashFlow/AccountTransactions/List.scss'; + +import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable'; + +const Box = styled.div` + margin: 30px 15px; +`; + +const CashflowTransactionsTableCard = styled.div` + border: 2px solid #f0f0f0; + border-radius: 10px; + padding: 30px 18px; + background: #fff; + flex: 0 1; +` + + +export default function AllTransactionsUncategorized() { + return ( + + + + + + ) +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 18e75161e..7d5b17f3a 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -131,7 +131,75 @@ export function useAccountTransactionsColumns() { * Account transactions progress bar. */ export function AccountTransactionsProgressBar() { - const { isCashFlowTransactionsFetching } = useAccountTransactionsContext(); + const { isCashFlowTransactionsFetching, isUncategorizedTransactionFetching } = + useAccountTransactionsContext(); - return isCashFlowTransactionsFetching ? : null; + return isCashFlowTransactionsFetching || + isUncategorizedTransactionFetching ? ( + + ) : null; +} + +/** + * Retrieve account uncategorized transctions table columns. + */ +export function useAccountUncategorizedTransactionsColumns() { + return React.useMemo( + () => [ + { + id: 'date', + Header: intl.get('date'), + accessor: 'formatted_date', + width: 40, + clickable: true, + textOverview: true, + }, + { + id: 'description', + Header: 'Description', + accessor: 'description', + width: 160, + textOverview: true, + clickable: true, + }, + { + id: 'payee', + Header: 'Payee', + accessor: 'payee', + width: 60, + clickable: true, + textOverview: true, + }, + { + id: 'reference_number', + Header: intl.get('reference_no'), + accessor: 'reference_number', + width: 50, + className: 'reference_number', + clickable: true, + textOverview: true, + }, + { + id: 'deposit', + Header: intl.get('cash_flow.label.deposit'), + accessor: 'formattet_deposit_amount', + width: 40, + className: 'deposit', + textOverview: true, + align: 'right', + clickable: true, + }, + { + id: 'withdrawal', + Header: intl.get('cash_flow.label.withdrawal'), + accessor: 'formatted_withdrawal_amount', + className: 'withdrawal', + width: 40, + textOverview: true, + align: 'right', + clickable: true, + }, + ], + [], + ); } diff --git a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx index a0028f231..66298dc8b 100644 --- a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx @@ -110,12 +110,12 @@ function CashFlowAccountsActionsBar({ - {/* + + + + + ); +} diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index a2be16b13..179317f2f 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -211,3 +211,23 @@ export function useRefreshCashflowTransactions() { }, }; } + +/** + * + */ +export function useUncategorizedTransaction( + uncategorizedTranasctionId: nunber, + props, +) { + return useRequestQuery( + [t.CASHFLOW_UNCAATEGORIZED_TRANSACTION, uncategorizedTranasctionId], + { + method: 'get', + url: `cashflow/transactions/uncategorized/${uncategorizedTranasctionId}`, + }, + { + select: (res) => res.data?.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index 81e7cd647..1d8d2d7a8 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -202,6 +202,8 @@ const CASH_FLOW_ACCOUNTS = { 'CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY', CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY: 'CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY', + + CASHFLOW_UNCAATEGORIZED_TRANSACTION: 'CASHFLOW_UNCAATEGORIZED_TRANSACTION', }; const TARNSACTIONS_LOCKING = { From d87d674abaec8db2d3752ea0838195db1bf2ff82 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 6 Mar 2024 22:15:31 +0200 Subject: [PATCH 143/148] feat: wip categorize the cashflow transactions --- .../Cashflow/NewCashflowTransaction.ts | 5 +- packages/server/src/interfaces/CashFlow.ts | 3 +- packages/server/src/models/Account.ts | 2 +- .../UncategorizedCashflowTransaction.ts | 17 ++- .../Cashflow/CategorizeCashflowTransaction.ts | 9 +- .../GetCashflowTransactionsService.ts | 1 + .../Cashflow/GetUncategorizedTransactions.ts | 3 +- .../UncategorizedTransactionTransformer.ts | 12 ++ .../server/src/services/Cashflow/utils.ts | 2 +- .../src/components/Drawer/DrawerBody.tsx | 5 +- .../webapp/src/components/Forms/Select.tsx | 4 +- .../CategorizeTransactionBoot.tsx | 33 +++- .../CategorizeTransactionContent.tsx | 10 +- .../CategorizeTransactionForm.schema.tsx | 11 +- .../CategorizeTransactionForm.tsx | 95 ++++++++---- .../CategorizeTransactionFormContent.tsx | 144 +++++++++--------- .../CategorizeTransactionFormFooter.tsx | 66 +++++--- .../CategorizeTransactionOtherIncome.tsx | 73 +++++++++ ...CategorizeTransactionOwnerContribution.tsx | 68 +++++++++ .../CategorizeTransactionTransferFrom.tsx | 73 +++++++++ .../CategorizeTransactionOtherExpense.tsx | 73 +++++++++ .../CategorizeTransactionOwnerDrawings.tsx | 73 +++++++++ .../CategorizeTransactionToAccount.tsx | 73 +++++++++ .../CategorizeTransactionDrawer/_utils.ts | 31 ++++ .../CategorizeTransactionDrawer/index.ts | 1 + .../src/hooks/query/cashflowAccounts.tsx | 24 +++ packages/webapp/src/hooks/query/types.tsx | 1 - 27 files changed, 768 insertions(+), 144 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index 30b2a2326..d0953d8d8 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -80,10 +80,7 @@ export default class NewCashflowTransactionController extends BaseController { public get categorizeCashflowTransactionValidationSchema() { return [ check('date').exists().isISO8601().toDate(), - oneOf([ - check('to_account_id').exists().isInt().toInt(), - check('from_account_id').exists().isInt().toInt(), - ]), + check('credit_account_id').exists().isInt().toInt(), check('transaction_number').optional(), check('transaction_type').exists(), check('reference_no').optional(), diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index aab3bb766..499c526b0 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -235,8 +235,7 @@ export interface ICashflowTransactionSchema { export interface ICashflowTransactionInput extends ICashflowTransactionSchema {} export interface ICategorizeCashflowTransactioDTO { - fromAccountId: number; - toAccountId: number; + creditAccountId: number; referenceNo: string; transactionNumber: string; transactionType: string; diff --git a/packages/server/src/models/Account.ts b/packages/server/src/models/Account.ts index c46f9e77b..7e0d8d6e4 100644 --- a/packages/server/src/models/Account.ts +++ b/packages/server/src/models/Account.ts @@ -318,7 +318,7 @@ export default class Account extends mixin(TenantModel, [ to: 'uncategorized_cashflow_transactions.accountId', }, filter: (query) => { - query.filter('categorized', false); + query.where('categorized', false); }, }, }; diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index d8f3db543..cb5ebfeef 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -1,6 +1,6 @@ /* eslint-disable global-require */ import TenantModel from 'models/TenantModel'; -import { Model } from 'objection'; +import { Model, ModelOptions, QueryContext } from 'objection'; import Account from './Account'; export default class UncategorizedCashflowTransaction extends TenantModel { @@ -95,6 +95,19 @@ export default class UncategorizedCashflowTransaction extends TenantModel { .increment('uncategorized_transactions', 1); } + public async $afterUpdate( + opt: ModelOptions, + queryContext: QueryContext + ): void | Promise { + await super.$afterUpdate(opt, queryContext); + + if (this.id && this.categorized) { + await Account.query(queryContext.transaction) + .findById(this.accountId) + .decrement('uncategorized_transactions', 1); + } + } + /** * * @param queryContext @@ -102,7 +115,7 @@ export default class UncategorizedCashflowTransaction extends TenantModel { public async $afterDelete(queryContext) { await super.$afterDelete(queryContext); - await Account.query() + await Account.query(queryContext.transaction) .findById(this.accountId) .decrement('uncategorized_transactions', 1); } diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts index da5b81419..3d19e1547 100644 --- a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -79,13 +79,14 @@ export class CategorizeCashflowTransaction { cashflowTransactionDTO ); // Updates the uncategorized transaction as categorized. - await UncategorizedCashflowTransaction.query(trx) - .findById(uncategorizedTransactionId) - .patch({ + await UncategorizedCashflowTransaction.query(trx).patchAndFetchById( + uncategorizedTransactionId, + { categorized: true, categorizeRefType: 'CashflowTransaction', categorizeRefId: cashflowTransaction.id, - }); + } + ); // Triggers `onCashflowTransactionCategorized` event. await this.eventPublisher.emitAsync( events.cashflow.onTransactionCategorized, diff --git a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts index 13852dd05..42bf7ca9e 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -31,6 +31,7 @@ export default class GetCashflowTransactionsService { .withGraphFetched('entries.cashflowAccount') .withGraphFetched('entries.creditAccount') .withGraphFetched('transactions.account') + .orderBy('date', 'DESC') .throwIfNotFound(); this.throwErrorCashflowTranscationNotFound(cashflowTransaction); diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts index 9b273920f..41cfa2e85 100644 --- a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -24,7 +24,8 @@ export class GetUncategorizedTransactions { .where('accountId', accountId) .where('categorized', false) .withGraphFetched('account') - .pagination(0, 10); + .orderBy('date', 'DESC') + .pagination(0, 1000); const data = await this.transformer.transform( tenantId, diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts index bf0a6a1cf..85d1a1fbb 100644 --- a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -8,6 +8,7 @@ export class UncategorizedTransactionTransformer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'formattedAmount', 'formattedDate', 'formattetDepositAmount', 'formattedWithdrawalAmount', @@ -23,6 +24,17 @@ export class UncategorizedTransactionTransformer extends Transformer { return this.formatDate(transaction.date); } + /** + * Formatted amount. + * @param transaction + * @returns {string} + */ + public formattedAmount(transaction) { + return formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + }); + } + /** * Formatted deposit amount. * @param transaction diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 9d76f1862..7957b73a9 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -55,7 +55,7 @@ export const transformCategorizeTransToCashflow = ( referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo, description: categorizeDTO.description || uncategorizeModel.description, cashflowAccountId: uncategorizeModel.accountId, - creditAccountId: categorizeDTO.fromAccountId || categorizeDTO.toAccountId, + creditAccountId: categorizeDTO.creditAccountId, exchangeRate: categorizeDTO.exchangeRate || 1, currencyCode: uncategorizeModel.currencyCode, amount: uncategorizeModel.amount, diff --git a/packages/webapp/src/components/Drawer/DrawerBody.tsx b/packages/webapp/src/components/Drawer/DrawerBody.tsx index be7f34ac4..e6bab2b05 100644 --- a/packages/webapp/src/components/Drawer/DrawerBody.tsx +++ b/packages/webapp/src/components/Drawer/DrawerBody.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import React from 'react'; +import clsx from 'classnames'; import { Classes } from '@blueprintjs/core'; import { LoadingIndicator } from '../Indicator'; @@ -11,8 +12,8 @@ export function DrawerLoading({ loading, mount = false, children }) { ); } -export function DrawerBody({ children }) { - return
{children}
; +export function DrawerBody({ children, className }) { + return
{children}
; } export * from './DrawerActionsBar'; diff --git a/packages/webapp/src/components/Forms/Select.tsx b/packages/webapp/src/components/Forms/Select.tsx index cae52b1b8..c77c51ce2 100644 --- a/packages/webapp/src/components/Forms/Select.tsx +++ b/packages/webapp/src/components/Forms/Select.tsx @@ -26,12 +26,12 @@ const SelectButton = styled(Button)` position: relative; padding-right: 30px; - &.bp4-small{ + &.bp4-small { padding-right: 24px; } &:not(.is-selected):not([class*='bp4-intent-']):not(.bp4-minimal) { - color: #5c7080; + color: #8f99a8; } &:after { content: ''; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx index 24af8cd26..6a7b8ea2b 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx @@ -2,30 +2,55 @@ import React from 'react'; import { DrawerHeaderContent, DrawerLoading } from '@/components'; import { DRAWERS } from '@/constants/drawers'; -import { useUncategorizedTransaction } from '@/hooks/query'; +import { + useAccounts, + useBranches, + useUncategorizedTransaction, +} from '@/hooks/query'; +import { useFeatureCan } from '@/hooks/state'; +import { Features } from '@/constants'; const CategorizeTransactionBootContext = React.createContext(); /** - * Estimate detail provider. + * Categorize transcation boot. */ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) { + // Detarmines whether the feature is enabled. + const { featureCan } = useFeatureCan(); + const isBranchFeatureCan = featureCan(Features.Branches); + + // Fetches accounts list. + const { isLoading: isAccountsLoading, data: accounts } = useAccounts(); + + // Fetches the branches list. + const { data: branches, isLoading: isBranchesLoading } = useBranches( + {}, + { enabled: isBranchFeatureCan }, + ); + // Retrieves the uncategorized transaction. const { data: uncategorizedTransaction, isLoading: isUncategorizedTransactionLoading, } = useUncategorizedTransaction(uncategorizedTransactionId); const provider = { + uncategorizedTransactionId, uncategorizedTransaction, isUncategorizedTransactionLoading, + branches, + accounts, + isBranchesLoading, + isAccountsLoading, }; + const isLoading = + isBranchesLoading || isUncategorizedTransactionLoading || isAccountsLoading; return ( - + diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx index 9bda3cdbc..7716e8beb 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx @@ -1,4 +1,5 @@ // @ts-nocheck +import styled from 'styled-components'; import { DrawerBody } from '@/components'; import { CategorizeTransactionBoot } from './CategorizeTransactionBoot'; import { CategorizeTransactionForm } from './CategorizeTransactionForm'; @@ -10,9 +11,14 @@ export default function CategorizeTransactionContent({ - + - + ); } + +export const CategorizeTransactionDrawerBody = styled(DrawerBody)` + padding: 20px; + background-color: #fff; +`; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx index f25c18a64..8c6d0eb72 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx @@ -1,7 +1,14 @@ // @ts-nocheck import * as Yup from 'yup'; -const Schema = Yup.object().shape({}); +const Schema = Yup.object().shape({ + amount: Yup.string().required().label('Amount'), + exchangeRate: Yup.string().required().label('Exchange rate'), + transactionType: Yup.string().required().label('Transaction type'), + date: Yup.string().required().label('Date'), + creditAccountId: Yup.string().required().label('Credit account'), + referenceNo: Yup.string().optional().label('Reference No.'), + description: Yup.string().optional().label('Description'), +}); export const CreateCategorizeTransactionSchema = Schema; -export const EditCategorizeTransactionSchema = Schema; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx index 479be60bf..364c4111c 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx @@ -1,34 +1,57 @@ // @ts-nocheck import { Formik, Form } from 'formik'; +import styled from 'styled-components'; import withDialogActions from '@/containers/Dialog/withDialogActions'; -import { - EditCategorizeTransactionSchema, - CreateCategorizeTransactionSchema, -} from './CategorizeTransactionForm.schema'; -import { compose, transformToForm } from '@/utils'; +import { CreateCategorizeTransactionSchema } from './CategorizeTransactionForm.schema'; import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent'; import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter'; - -// Default initial form values. -const defaultInitialValues = {}; +import { useCategorizeTransaction } from '@/hooks/query'; +import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; +import { DRAWERS } from '@/constants/drawers'; +import { + transformToCategorizeForm, + defaultInitialValues, + tranformToRequest, +} from './_utils'; +import { compose } from '@/utils'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { AppToaster } from '@/components'; +import { Intent } from '@blueprintjs/core'; /** * Categorize cashflow transaction form dialog content. */ function CategorizeTransactionFormRoot({ - // #withDialogActions - closeDialog, + // #withDrawerActions + closeDrawer, }) { - const isNewMode = true; - - // Form validation schema in create and edit mode. - const validationSchema = isNewMode - ? CreateCategorizeTransactionSchema - : EditCategorizeTransactionSchema; + const { uncategorizedTransactionId, uncategorizedTransaction } = + useCategorizeTransactionBoot(); + const { mutateAsync: categorizeTransaction } = useCategorizeTransaction(); // Callbacks handles form submit. - const handleFormSubmit = (values, { setSubmitting, setErrors }) => {}; + const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + const transformedValues = tranformToRequest(values); + setSubmitting(true); + categorizeTransaction([uncategorizedTransactionId, transformedValues]) + .then(() => { + setSubmitting(false); + closeDrawer(DRAWERS.CATEGORIZE_TRANSACTION); + + AppToaster.show({ + message: 'The uncategorized transaction has been categorized.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + setSubmitting(false); + AppToaster.show({ + message: 'Something went wrong!', + intent: Intent.DANGER, + }); + }); + }; // Form initial values in create and edit mode. const initialValues = { ...defaultInitialValues, @@ -37,23 +60,37 @@ function CategorizeTransactionFormRoot({ * values such as `notes` come back from the API as null, so remove those * as well. */ - ...transformToForm({}, defaultInitialValues), + ...transformToCategorizeForm(uncategorizedTransaction), }; return ( - -
- - - -
+ + +
+ + + +
+
); } -export const CategorizeTransactionForm = compose(withDialogActions)( +export const CategorizeTransactionForm = compose(withDrawerActions)( CategorizeTransactionFormRoot, ); + +const DivRoot = styled.div` + .bp4-form-group .bp4-form-content { + flex: 1 0; + } + .bp4-form-group .bp4-label { + width: 140px; + } + .bp4-form-group { + margin-bottom: 18px; + } +`; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx index 9e5362f02..dfe1db6b0 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx @@ -1,31 +1,39 @@ // @ts-nocheck -import { Position } from '@blueprintjs/core'; +import React from 'react'; import styled from 'styled-components'; -import { - AccountsSelect, - FDateInput, - FFormGroup, - FInputGroup, - FSelect, - FSuggest, - FTextArea, -} from '@/components'; -import { getAddMoneyInOptions } from '@/constants'; +import { FormGroup } from '@blueprintjs/core'; +import { FFormGroup, FSelect, FSuggest } from '@/components'; +import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants'; +import { useFormikContext } from 'formik'; +import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; // Retrieves the add money in button options. -const AddMoneyInOptions = getAddMoneyInOptions(); +const MoneyInOptions = getAddMoneyInOptions(); +const MoneyOutOptions = getAddMoneyOutOptions(); -const Title = styled('h3')``; +const Title = styled('h3')` + font-size: 20px; + font-weight: 400; + color: #cd4246; +`; export function CategorizeTransactionFormContent() { + const { uncategorizedTransaction } = useCategorizeTransactionBoot(); + + const transactionTypes = uncategorizedTransaction?.is_deposit_transaction + ? MoneyInOptions + : MoneyOutOptions; + return ( <> - $22,583.00 + + {uncategorizedTransaction.formatted_amount} + - - - date.toLocaleDateString()} - parseDate={(str) => new Date(str)} - inputProps={{ fill: true }} - /> - - - - - - - - - - - - - - - - - + ); } + +const CategorizeTransactionOtherIncome = React.lazy( + () => import('./MoneyIn/CategorizeTransactionOtherIncome'), +); + +const CategorizeTransactionOwnerContribution = React.lazy( + () => import('./MoneyIn/CategorizeTransactionOwnerContribution'), +); + +const CategorizeTransactionTransferFrom = React.lazy( + () => import('./MoneyIn/CategorizeTransactionTransferFrom'), +); + +const CategorizeTransactionOtherExpense = React.lazy( + () => import('./MoneyOut/CategorizeTransactionOtherExpense'), +); + +const CategorizeTransactionToAccount = React.lazy( + () => import('./MoneyOut/CategorizeTransactionToAccount'), +); + +const CategorizeTransactionOwnerDrawings = React.lazy( + () => import('./MoneyOut/CategorizeTransactionOwnerDrawings'), +); + +function CategorizeTransactionFormSubContent() { + const { values } = useFormikContext(); + + // Other expense. + if (values.transactionType === 'other_expense') { + return ; + // Owner contribution. + } else if (values.transactionType === 'owner_contribution') { + return ; + // Other Income. + } else if (values.transactionType === 'other_income') { + return ; + // Transfer from account. + } else if (values.transactionType === 'transfer_from_account') { + return ; + // Transfer to account. + } else if (values.transactionType === 'transfer_to_account') { + return ; + // Owner drawings. + } else if (values.transactionType === 'OwnerDrawing') { + return ; + } + return null; +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx index acfaeec2c..3f8d7009b 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx @@ -1,26 +1,56 @@ +// @ts-nocheck +import * as R from 'ramda'; import { Button, Classes, Intent } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; +import styled from 'styled-components'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { DRAWERS } from '@/constants/drawers'; +import { Group } from '@/components'; + +function CategorizeTransactionFormFooterRoot({ + // #withDrawerActions + closeDrawer, +}) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + closeDrawer(DRAWERS.CATEGORIZE_TRANSACTION); + }; -export function CategorizeTransactionFormFooter() { return ( -
+
- + + - + +
-
+ ); } + +export const CategorizeTransactionFormFooter = R.compose(withDrawerActions)( + CategorizeTransactionFormFooterRoot, +); + +const Root = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: #fff; +`; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx new file mode 100644 index 000000000..2afc65f87 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOtherIncome() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx new file mode 100644 index 000000000..83b485c51 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx @@ -0,0 +1,68 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOwnerContribution() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx new file mode 100644 index 000000000..57f2a1911 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionTransferFrom() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx new file mode 100644 index 000000000..b85436e17 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOtherExpense() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx new file mode 100644 index 000000000..e39235fd9 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOwnerDrawings() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx new file mode 100644 index 000000000..4e4545e52 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionToAccount() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts new file mode 100644 index 000000000..9fedc3678 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts @@ -0,0 +1,31 @@ +// @ts-nocheck +import { transformToForm, transfromToSnakeCase } from '@/utils'; + +// Default initial form values. +export const defaultInitialValues = { + amount: '', + date: '', + creditAccountId: '', + debitAccountId: '', + exchangeRate: '1', + transactionType: '', + referenceNo: '', + description: '', +}; + +export const transformToCategorizeForm = (uncategorizedTransaction) => { + const defaultValues = { + debitAccountId: uncategorizedTransaction.account_id, + transactionType: uncategorizedTransaction.is_deposit_transaction + ? 'other_income' + : 'other_expense', + amount: uncategorizedTransaction.amount, + date: uncategorizedTransaction.date, + }; + return transformToForm(defaultValues, defaultInitialValues); +}; + + +export const tranformToRequest = (formValues) => { + return transfromToSnakeCase(formValues); +}; \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts new file mode 100644 index 000000000..bff919dc5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts @@ -0,0 +1 @@ +export * from './CategorizeTransactionDrawer'; \ No newline at end of file diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index 179317f2f..3a225db58 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -231,3 +231,27 @@ export function useUncategorizedTransaction( }, ); } + +/** + * Categorize the cashflow transaction. + */ +export function useCategorizeTransaction(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => + apiRequest.post(`cashflow/transactions/${id}/categorize`, values), + { + onSuccess: (res, id) => { + // Invalidate queries. + commonInvalidateQueries(queryClient); + queryClient.invalidateQueries(t.CASHFLOW_UNCAATEGORIZED_TRANSACTION); + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + }, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index 1d8d2d7a8..5446282e2 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -202,7 +202,6 @@ const CASH_FLOW_ACCOUNTS = { 'CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY', CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY: 'CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY', - CASHFLOW_UNCAATEGORIZED_TRANSACTION: 'CASHFLOW_UNCAATEGORIZED_TRANSACTION', }; From 62d3e386dd80cd35a68468e07d192ca94f0564e4 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 7 Mar 2024 14:19:11 +0200 Subject: [PATCH 144/148] feat(server): move all cashflow under application service --- .../Cashflow/DeleteCashflowTransaction.ts | 7 +- .../Cashflow/GetCashflowAccounts.ts | 11 +-- .../Cashflow/GetCashflowTransaction.ts | 13 +-- .../Cashflow/NewCashflowTransaction.ts | 52 +++++++----- .../server/src/interfaces/CashflowService.ts | 5 ++ .../services/Cashflow/CashflowApplication.ts | 82 +++++++++++++++++-- .../GetCashflowTransactionsService.ts | 2 +- .../Cashflow/GetUncategorizedTransactions.ts | 15 +++- 8 files changed, 138 insertions(+), 49 deletions(-) diff --git a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts index 4d94022da..1d0edece0 100644 --- a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts @@ -3,14 +3,15 @@ import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import { DeleteCashflowTransaction } from '../../../services/Cashflow/DeleteCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; + import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class DeleteCashflowTransactionController extends BaseController { @Inject() - private deleteCashflowService: DeleteCashflowTransaction; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -44,7 +45,7 @@ export default class DeleteCashflowTransactionController extends BaseController try { const { oldCashflowTransaction } = - await this.deleteCashflowService.deleteCashflowTransaction( + await this.cashflowApplication.deleteTransaction( tenantId, transactionId ); diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index b84dad4eb..d1bc97e0a 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -7,14 +7,12 @@ import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTrans import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - private getCashflowAccountsService: GetCashflowAccountsService; - - @Inject() - private getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -62,10 +60,7 @@ export default class GetCashflowAccounts extends BaseController { try { const cashflowAccounts = - await this.getCashflowAccountsService.getCashflowAccounts( - tenantId, - filter - ); + await this.cashflowApplication.getCashflowAccounts(tenantId, filter); return res.status(200).send({ cashflow_accounts: this.transfromToResponse(cashflowAccounts), diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 80d610f48..9e7169859 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -6,11 +6,12 @@ import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTrans import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - private getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -43,11 +44,11 @@ export default class GetCashflowAccounts extends BaseController { const { transactionId } = req.params; try { - const cashflowTransaction = - await this.getCashflowTransactionsService.getCashflowTransaction( - tenantId, - transactionId - ); + const cashflowTransaction = await this.cashflowApplication.getTransaction( + tenantId, + transactionId + + ); return res.status(200).send({ cashflow_transaction: this.transfromToResponse(cashflowTransaction), diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index d0953d8d8..a1af70c15 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -1,18 +1,14 @@ import { Service, Inject } from 'typedi'; -import { check, oneOf } from 'express-validator'; +import { ValidationChain, check, param, query } from 'express-validator'; import { Router, Request, Response, NextFunction } from 'express'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class NewCashflowTransactionController extends BaseController { - @Inject() - private newCashflowTranscationService: NewCashflowTransactionService; - @Inject() private cashflowApplication: CashflowApplication; @@ -29,6 +25,8 @@ export default class NewCashflowTransactionController extends BaseController { ); router.get( '/transactions/:id/uncategorized', + this.getUncategorizedTransactionsValidationSchema, + this.validationResult, this.asyncMiddleware(this.getUncategorizedCashflowTransactions), this.catchServiceErrors ); @@ -62,6 +60,18 @@ export default class NewCashflowTransactionController extends BaseController { return router; } + /** + * Getting uncategorized transactions validation schema. + * @returns {ValidationChain} + */ + public get getUncategorizedTransactionsValidationSchema() { + return [ + param('id').exists().isNumeric().toInt(), + query('page').optional().isNumeric().toInt(), + query('page_size').optional().isNumeric().toInt(), + ]; + } + /** * Categorize as expense validation schema. */ @@ -112,7 +122,7 @@ export default class NewCashflowTransactionController extends BaseController { check('branch_id').optional({ nullable: true }).isNumeric().toInt(), check('publish').default(false).isBoolean().toBoolean(), ]; - }√ + } /** * Creates a new cashflow transaction. @@ -130,7 +140,7 @@ export default class NewCashflowTransactionController extends BaseController { try { const cashflowTransaction = - await this.newCashflowTranscationService.newCashflowTransaction( + await this.cashflowApplication.createTransaction( tenantId, ownerContributionDTO, userId @@ -159,7 +169,7 @@ export default class NewCashflowTransactionController extends BaseController { const { id: cashflowTransactionId } = req.params; try { - const data= await this.cashflowApplication.uncategorizeTransaction( + const data = await this.cashflowApplication.uncategorizeTransaction( tenantId, cashflowTransactionId ); @@ -229,9 +239,9 @@ export default class NewCashflowTransactionController extends BaseController { /** * Retrieves the uncategorized cashflow transactions. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ public getUncategorizedCashflowTransaction = async ( req: Request, @@ -240,7 +250,7 @@ export default class NewCashflowTransactionController extends BaseController { ) => { const { tenantId } = req; const { id: transactionId } = req.params; - + try { const data = await this.cashflowApplication.getUncategorizedTransaction( tenantId, @@ -254,9 +264,9 @@ export default class NewCashflowTransactionController extends BaseController { /** * Retrieves the uncategorized cashflow transactions. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ public getUncategorizedCashflowTransactions = async ( req: Request, @@ -265,11 +275,13 @@ export default class NewCashflowTransactionController extends BaseController { ) => { const { tenantId } = req; const { id: accountId } = req.params; - + const query = this.matchedQueryData(req); + try { const data = await this.cashflowApplication.getUncategorizedTransactions( tenantId, - accountId + accountId, + query ); return res.status(200).send(data); @@ -337,9 +349,9 @@ export default class NewCashflowTransactionController extends BaseController { errors: [ { type: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', - code: 4100, - } - ] + code: 4100, + }, + ], }); } } diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index 5b446571d..acce307db 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -156,3 +156,8 @@ export interface CategorizeTransactionAsExpenseDTO { description: string; branchId?: number; } + +export interface IGetUncategorizedTransactionsQuery { + page?: number; + pageSize?: number; +} diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts index 2fb1ff7bb..6688c9016 100644 --- a/packages/server/src/services/Cashflow/CashflowApplication.ts +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -5,19 +5,33 @@ import { CategorizeCashflowTransaction } from './CategorizeCashflowTransaction'; import { CategorizeTransactionAsExpenseDTO, CreateUncategorizedTransactionDTO, + ICashflowAccountsFilter, + ICashflowNewCommandDTO, ICategorizeCashflowTransactioDTO, - IUncategorizedCashflowTransaction, + IGetUncategorizedTransactionsQuery, } from '@/interfaces'; import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense'; import { GetUncategorizedTransactions } from './GetUncategorizedTransactions'; import { CreateUncategorizedTransaction } from './CreateUncategorizedTransaction'; import { GetUncategorizedTransaction } from './GetUncategorizedTransaction'; +import NewCashflowTransactionService from './NewCashflowTransactionService'; +import GetCashflowAccountsService from './GetCashflowAccountsService'; +import { GetCashflowTransactionService } from './GetCashflowTransactionsService'; @Service() export class CashflowApplication { + @Inject() + private createTransactionService: NewCashflowTransactionService; + @Inject() private deleteTransactionService: DeleteCashflowTransaction; + @Inject() + private getCashflowAccountsService: GetCashflowAccountsService; + + @Inject() + private getCashflowTransactionService: GetCashflowTransactionService; + @Inject() private uncategorizeTransactionService: UncategorizeCashflowTransaction; @@ -36,6 +50,25 @@ export class CashflowApplication { @Inject() private createUncategorizedTransactionService: CreateUncategorizedTransaction; + /** + * Creates a new cashflow transaction. + * @param {number} tenantId + * @param {ICashflowNewCommandDTO} transactionDTO + * @param {number} userId + * @returns + */ + public createTransaction( + tenantId: number, + transactionDTO: ICashflowNewCommandDTO, + userId?: number + ) { + return this.createTransactionService.newCashflowTransaction( + tenantId, + transactionDTO, + userId + ); + } + /** * Deletes the given cashflow transaction. * @param {number} tenantId @@ -49,6 +82,35 @@ export class CashflowApplication { ); } + /** + * Retrieves specific cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public getTransaction(tenantId: number, cashflowTransactionId: number) { + return this.getCashflowTransactionService.getCashflowTransaction( + tenantId, + cashflowTransactionId + ); + } + + /** + * Retrieves the cashflow accounts. + * @param {number} tenantId + * @param {ICashflowAccountsFilter} filterDTO + * @returns + */ + public getCashflowAccounts( + tenantId: number, + filterDTO: ICashflowAccountsFilter + ) { + return this.getCashflowAccountsService.getCashflowAccounts( + tenantId, + filterDTO + ); + } + /** * Creates a new uncategorized cash transaction. * @param {number} tenantId @@ -105,7 +167,6 @@ export class CashflowApplication { * @param {number} tenantId * @param {number} cashflowTransactionId * @param {CategorizeTransactionAsExpenseDTO} transactionDTO - * @returns */ public categorizeAsExpense( tenantId: number, @@ -122,20 +183,23 @@ export class CashflowApplication { /** * Retrieves the uncategorized cashflow transactions. * @param {number} tenantId - * @returns {} */ - public getUncategorizedTransactions(tenantId: number, accountId: number) { + public getUncategorizedTransactions( + tenantId: number, + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { return this.getUncategorizedTransactionsService.getTransactions( tenantId, - accountId + accountId, + query ); } /** - * - * @param {number} tenantId - * @param {number} uncategorizedTransactionId - * @returns + * Retrieves specific uncategorized transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId */ public getUncategorizedTransaction( tenantId: number, diff --git a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts index 42bf7ca9e..64afd2194 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -7,7 +7,7 @@ import { ServiceError } from '@/exceptions'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; @Service() -export default class GetCashflowTransactionsService { +export class GetCashflowTransactionService { @Inject() private tenancy: HasTenancyService; diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts index 41cfa2e85..36606f582 100644 --- a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi'; import HasTenancyService from '../Tenancy/TenancyService'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; +import { IGetUncategorizedTransactionsQuery } from '@/interfaces'; @Service() export class GetUncategorizedTransactions { @@ -16,16 +17,26 @@ export class GetUncategorizedTransactions { * @param {number} tenantId - Tenant id. * @param {number} accountId - Account Id. */ - public async getTransactions(tenantId: number, accountId: number) { + public async getTransactions( + tenantId: number, + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + // Parsed query with default values. + const _query = { + page: 1, + pageSize: 20, + ...query, + }; const { results, pagination } = await UncategorizedCashflowTransaction.query() .where('accountId', accountId) .where('categorized', false) .withGraphFetched('account') .orderBy('date', 'DESC') - .pagination(0, 1000); + .pagination(_query.page - 1, _query.pageSize); const data = await this.transformer.transform( tenantId, From b9a00418fa084f8e83069db73712e0ce9a78648c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 7 Mar 2024 14:31:59 +0200 Subject: [PATCH 145/148] feat: abstract the uncategorized and all transactions boot wrappers --- packages/webapp/src/constants/tables.tsx | 1 + .../AccountTransactionsAllBoot.tsx | 78 +++++++++++++++ .../AccountTransactionsDataTable.tsx | 7 +- .../AccountTransactionsProvider.tsx | 94 +------------------ .../AccountTransactionsUncategorizedTable.tsx | 29 ++---- .../AccountsTransactionsAll.tsx | 15 +-- .../AllTransactionsUncategorized.tsx | 20 ++-- .../AllTransactionsUncategorizedBoot.tsx | 78 +++++++++++++++ .../AccountTransactions/components.tsx | 1 + 9 files changed, 193 insertions(+), 130 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx diff --git a/packages/webapp/src/constants/tables.tsx b/packages/webapp/src/constants/tables.tsx index 2770d9cf8..33ad65b3e 100644 --- a/packages/webapp/src/constants/tables.tsx +++ b/packages/webapp/src/constants/tables.tsx @@ -15,6 +15,7 @@ export const TABLES = { EXPENSES: 'expenses', CASHFLOW_ACCOUNTS: 'cashflow_accounts', CASHFLOW_Transactions: 'cashflow_transactions', + UNCATEGORIZED_CASHFLOW_TRANSACTION: 'UNCATEGORIZED_CASHFLOW_TRANSACTION', CREDIT_NOTES: 'credit_notes', VENDOR_CREDITS: 'vendor_credits', WAREHOUSE_TRANSFERS: 'warehouse_transfers', diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx new file mode 100644 index 000000000..618a542fb --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck +import React from 'react'; +import { flatten, map } from 'lodash'; +import { IntersectionObserver } from '@/components'; +import { useAccountTransactionsInfinity } from '@/hooks/query'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; + +const AccountTransactionsAllBootContext = React.createContext(); + +function flattenInfinityPages(data) { + return flatten(map(data.pages, (page) => page.transactions)); +} + +interface AccountTransactionsAllPoviderProps { + children: React.ReactNode; +} + +/** + * Account transctions all provider. + */ +function AccountTransactionsAllProvider({ + children, +}: AccountTransactionsAllPoviderProps) { + const { accountId } = useAccountTransactionsContext(); + + // Fetch cashflow account transactions list + const { + data: cashflowTransactionsPages, + isFetching: isCashFlowTransactionsFetching, + isLoading: isCashFlowTransactionsLoading, + isSuccess: isCashflowTransactionsSuccess, + fetchNextPage: fetchNextTransactionsPage, + isFetchingNextPage: isCashflowTransactionsFetchingNextPage, + hasNextPage: hasCashflowTransactionsNextPgae, + } = useAccountTransactionsInfinity(accountId, { + page_size: 50, + account_id: accountId, + }); + // Memorized the cashflow account transactions. + const cashflowTransactions = React.useMemo( + () => + isCashflowTransactionsSuccess + ? flattenInfinityPages(cashflowTransactionsPages) + : [], + [cashflowTransactionsPages, isCashflowTransactionsSuccess], + ); + // Handle the observer ineraction. + const handleObserverInteract = React.useCallback(() => { + if (!isCashFlowTransactionsFetching && hasCashflowTransactionsNextPgae) { + fetchNextTransactionsPage(); + } + }, [ + isCashFlowTransactionsFetching, + hasCashflowTransactionsNextPgae, + fetchNextTransactionsPage, + ]); + // Provider payload. + const provider = { + cashflowTransactions, + isCashFlowTransactionsFetching, + isCashFlowTransactionsLoading, + }; + + return ( + + {children} + + + ); +} + +const useAccountTransactionsAllContext = () => + React.useContext(AccountTransactionsAllBootContext); + +export { AccountTransactionsAllProvider, useAccountTransactionsAllContext }; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx index cd189a933..2b7c9e79a 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx @@ -18,10 +18,10 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import { useMemorizedColumnsWidths } from '@/hooks'; import { useAccountTransactionsColumns, ActionsMenu } from './components'; -import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { handleCashFlowTransactionType } from './utils'; import { compose } from '@/utils'; +import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot'; /** * Account transactions data table. @@ -41,7 +41,7 @@ function AccountTransactionsDataTable({ // Retrieve list context. const { cashflowTransactions, isCashFlowTransactionsLoading } = - useAccountTransactionsContext(); + useAccountTransactionsAllContext(); // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = @@ -51,11 +51,10 @@ function AccountTransactionsDataTable({ const handleDeleteTransaction = ({ reference_id }) => { openAlert('account-transaction-delete', { referenceId: reference_id }); }; - + // Handle view details action. const handleViewDetailCashflowTransaction = (referenceType) => { handleCashFlowTransactionType(referenceType, openDrawer); }; - // Handle cell click. const handleCellClick = (cell, event) => { const referenceType = cell.row.original; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx index f0aa661e3..1b3c98a29 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx @@ -1,26 +1,12 @@ // @ts-nocheck import React from 'react'; import { useParams } from 'react-router-dom'; -import { flatten, map } from 'lodash'; -import { IntersectionObserver, DashboardInsider } from '@/components'; -import { - useAccountTransactionsInfinity, - useCashflowAccounts, - useAccount, - useAccountUncategorizedTransactionsInfinity, -} from '@/hooks/query'; +import { DashboardInsider } from '@/components'; +import { useCashflowAccounts, useAccount } from '@/hooks/query'; import { useAppQueryString } from '@/hooks'; const AccountTransactionsContext = React.createContext(); -function flattenInfinityPages(data) { - return flatten(map(data.pages, (page) => page.transactions)); -} - -function flattenInfinityPagesData(data) { - return flatten(map(data.pages, (page) => page.data)); -} - /** * Account transctions provider. */ @@ -31,64 +17,9 @@ function AccountTransactionsProvider({ query, ...props }) { const [locationQuery, setLocationQuery] = useAppQueryString(); const filterTab = locationQuery?.filter || 'all'; - const setFilterTab = (value: stirng) => { + const setFilterTab = (value: string) => { setLocationQuery({ filter: value }); }; - - // Fetch cashflow account transactions list - const { - data: cashflowTransactionsPages, - isFetching: isCashFlowTransactionsFetching, - isLoading: isCashFlowTransactionsLoading, - isSuccess: isCashflowTransactionsSuccess, - fetchNextPage: fetchNextTransactionsPage, - isFetchingNextPage, - hasNextPage, - } = useAccountTransactionsInfinity( - accountId, - { - page_size: 50, - account_id: accountId, - }, - { - enabled: filterTab === 'all' || filterTab === 'dashboard', - }, - ); - - const { - data: uncategorizedTransactionsPage, - isFetching: isUncategorizedTransactionFetching, - isLoading: isUncategorizedTransactionsLoading, - isSuccess: isUncategorizedTransactionsSuccess, - fetchNextPage: fetchNextUncategorizedTransactionsPage, - } = useAccountUncategorizedTransactionsInfinity( - accountId, - { - page_size: 50, - }, - { - enabled: filterTab === 'uncategorized', - }, - ); - - // Memorized the cashflow account transactions. - const cashflowTransactions = React.useMemo( - () => - isCashflowTransactionsSuccess - ? flattenInfinityPages(cashflowTransactionsPages) - : [], - [cashflowTransactionsPages, isCashflowTransactionsSuccess], - ); - - // Memorized the cashflow account transactions. - const uncategorizedTransactions = React.useMemo( - () => - isUncategorizedTransactionsSuccess - ? flattenInfinityPagesData(uncategorizedTransactionsPage) - : [], - [uncategorizedTransactionsPage, isUncategorizedTransactionsSuccess], - ); - // Fetch cashflow accounts. const { data: cashflowAccounts, @@ -97,27 +28,19 @@ function AccountTransactionsProvider({ query, ...props }) { } = useCashflowAccounts(query, { keepPreviousData: true }); // Retrieve specific account details. + const { data: currentAccount, isFetching: isCurrentAccountFetching, isLoading: isCurrentAccountLoading, } = useAccount(accountId, { keepPreviousData: true }); - // Handle the observer ineraction. - const handleObserverInteract = React.useCallback(() => { - if (!isFetchingNextPage && hasNextPage) { - fetchNextTransactionsPage(); - } - }, [isFetchingNextPage, hasNextPage, fetchNextTransactionsPage]); - // Provider payload. const provider = { accountId, - cashflowTransactions, cashflowAccounts, currentAccount, - isCashFlowTransactionsFetching, - isCashFlowTransactionsLoading, + isCashFlowAccountsFetching, isCashFlowAccountsLoading, isCurrentAccountFetching, @@ -125,18 +48,11 @@ function AccountTransactionsProvider({ query, ...props }) { filterTab, setFilterTab, - - uncategorizedTransactions, - isUncategorizedTransactionFetching }; return ( - ); } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx index 488b416e5..781f9b9b1 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -13,7 +13,6 @@ import { import { TABLES } from '@/constants/tables'; import withSettings from '@/containers/Settings/withSettings'; -import withAlertsActions from '@/containers/Alert/withAlertActions'; import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import { useMemorizedColumnsWidths } from '@/hooks'; @@ -21,8 +20,7 @@ import { ActionsMenu, useAccountUncategorizedTransactionsColumns, } from './components'; -import { useAccountTransactionsContext } from './AccountTransactionsProvider'; -import { handleCashFlowTransactionType } from './utils'; +import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot'; import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; @@ -34,9 +32,6 @@ function AccountTransactionsDataTable({ // #withSettings cashflowTansactionsTableSize, - // #withAlertsActions - openAlert, - // #withDrawerActions openDrawer, }) { @@ -44,17 +39,12 @@ function AccountTransactionsDataTable({ const columns = useAccountUncategorizedTransactionsColumns(); // Retrieve list context. - const { uncategorizedTransactions, isCashFlowTransactionsLoading } = - useAccountTransactionsContext(); + const { uncategorizedTransactions, isUncategorizedTransactionsLoading } = + useAccountUncategorizedTransactionsContext(); // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = - useMemorizedColumnsWidths(TABLES.CASHFLOW_Transactions); - - // handle delete transaction - const handleDeleteTransaction = ({ reference_id }) => {}; - - const handleViewDetailCashflowTransaction = (referenceType) => {}; + useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_CASHFLOW_TRANSACTION); // Handle cell click. const handleCellClick = (cell, event) => { @@ -69,8 +59,8 @@ function AccountTransactionsDataTable({ columns={columns} data={uncategorizedTransactions || []} sticky={true} - loading={isCashFlowTransactionsLoading} - headerLoading={isCashFlowTransactionsLoading} + loading={isUncategorizedTransactionsLoading} + headerLoading={isUncategorizedTransactionsLoading} expandColumnSpace={1} expandToggleColumn={2} selectionColumnWidth={45} @@ -81,16 +71,12 @@ function AccountTransactionsDataTable({ ContextMenu={ActionsMenu} onCellClick={handleCellClick} // #TableVirtualizedListRows props. - vListrowHeight={cashflowTansactionsTableSize == 'small' ? 32 : 40} + vListrowHeight={cashflowTansactionsTableSize === 'small' ? 32 : 40} vListOverscanRowCount={0} initialColumnsWidths={initialColumnsWidths} onColumnResizing={handleColumnResizing} noResults={} className="table-constrant" - payload={{ - onViewDetails: handleViewDetailCashflowTransaction, - onDelete: handleDeleteTransaction, - }} /> ); } @@ -99,7 +85,6 @@ export default compose( withSettings(({ cashflowTransactionsSettings }) => ({ cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, })), - withAlertsActions, withDrawerActions, )(AccountTransactionsDataTable); diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx index f181983af..c598e4bdc 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx @@ -5,6 +5,7 @@ import '@/style/pages/CashFlow/AccountTransactions/List.scss'; import AccountTransactionsDataTable from './AccountTransactionsDataTable'; import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter'; +import { AccountTransactionsAllProvider } from './AccountTransactionsAllBoot'; const Box = styled.div` margin: 30px 15px; @@ -20,12 +21,14 @@ const CashflowTransactionsTableCard = styled.div` export default function AccountTransactionsAll() { return ( - - + + + - - - - + + + + + ); } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx index 1595b746d..716712a0d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable'; +import { AccountUncategorizedTransactionsBoot } from './AllTransactionsUncategorizedBoot'; const Box = styled.div` margin: 30px 15px; @@ -15,15 +16,16 @@ const CashflowTransactionsTableCard = styled.div` padding: 30px 18px; background: #fff; flex: 0 1; -` - +`; export default function AllTransactionsUncategorized() { return ( - - - - - - ) -} \ No newline at end of file + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx new file mode 100644 index 000000000..ce57832b3 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck + +import React from 'react'; +import { flatten, map } from 'lodash'; +import { IntersectionObserver } from '@/components'; +import { useAccountUncategorizedTransactionsInfinity } from '@/hooks/query'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; + +const AccountUncategorizedTransactionsContext = React.createContext(); + +function flattenInfinityPagesData(data) { + return flatten(map(data.pages, (page) => page.data)); +} + +/** + * Account uncategorized transctions provider. + */ +function AccountUncategorizedTransactionsBoot({ children }) { + const { accountId } = useAccountTransactionsContext(); + + // Fetches the uncategorized transactions. + const { + data: uncategorizedTransactionsPage, + isFetching: isUncategorizedTransactionFetching, + isLoading: isUncategorizedTransactionsLoading, + isSuccess: isUncategorizedTransactionsSuccess, + isFetchingNextPage: isUncategorizedTransactionFetchNextPage, + fetchNextPage: fetchNextUncategorizedTransactionsPage, + hasNextPage: hasUncategorizedTransactionsNextPage, + } = useAccountUncategorizedTransactionsInfinity(accountId, { + page_size: 50, + }); + // Memorized the cashflow account transactions. + const uncategorizedTransactions = React.useMemo( + () => + isUncategorizedTransactionsSuccess + ? flattenInfinityPagesData(uncategorizedTransactionsPage) + : [], + [uncategorizedTransactionsPage, isUncategorizedTransactionsSuccess], + ); + // Handle the observer ineraction. + const handleObserverInteract = React.useCallback(() => { + if ( + !isUncategorizedTransactionFetching && + hasUncategorizedTransactionsNextPage + ) { + fetchNextUncategorizedTransactionsPage(); + } + }, [ + isUncategorizedTransactionFetching, + hasUncategorizedTransactionsNextPage, + fetchNextUncategorizedTransactionsPage, + ]); + // Provider payload. + const provider = { + uncategorizedTransactions, + isUncategorizedTransactionFetching, + isUncategorizedTransactionsLoading, + }; + + return ( + + {children} + + + ); +} + +const useAccountUncategorizedTransactionsContext = () => + React.useContext(AccountUncategorizedTransactionsContext); + +export { + AccountUncategorizedTransactionsBoot, + useAccountUncategorizedTransactionsContext, +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 7d5b17f3a..6dc334aa6 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -39,6 +39,7 @@ export function ActionsMenu({
); } + /** * Retrieve account transctions table columns. */ From 83fbb7225d2d95e2526541b10463530e6ce1af01 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 7 Mar 2024 20:58:44 +0200 Subject: [PATCH 146/148] feat: remove uncategorized transaction from expenses --- .../Cashflow/GetCashflowAccounts.ts | 4 +- .../Cashflow/GetCashflowTransaction.ts | 2 - ...6_add_categorized_transaction_id_column.js | 11 --- packages/server/src/models/Expense.ts | 13 --- .../UncategorizedCashflowTransaction.ts | 80 ++++++++++++------- .../src/services/Banking/Plaid/PlaidSyncDB.ts | 4 - .../Cashflow/UncategorizeTransactionByRef.ts | 9 --- .../AccountTransactionsList.tsx | 1 - .../drawers/CategorizeTransactionDrawer.tsx | 33 -------- .../CategorizeTransactionForm.tsx | 1 - .../CategorizeTransactionFormContent.tsx | 2 +- .../src/hooks/query/cashflowAccounts.tsx | 3 +- 12 files changed, 53 insertions(+), 110 deletions(-) delete mode 100644 packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js delete mode 100644 packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts delete mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index d1bc97e0a..559a5f4f2 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -1,9 +1,7 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { param, query } from 'express-validator'; -import GetCashflowAccountsService from '@/services/Cashflow/GetCashflowAccountsService'; +import { query } from 'express-validator'; import BaseController from '../BaseController'; -import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTransactionsService'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 9e7169859..2625a1cb9 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -2,7 +2,6 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; -import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTransactionsService'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; @@ -47,7 +46,6 @@ export default class GetCashflowAccounts extends BaseController { const cashflowTransaction = await this.cashflowApplication.getTransaction( tenantId, transactionId - ); return res.status(200).send({ diff --git a/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js b/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js deleted file mode 100644 index 749cc53b6..000000000 --- a/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js +++ /dev/null @@ -1,11 +0,0 @@ -exports.up = function (knex) { - return knex.schema.table('expenses_transactions', (table) => { - table - .integer('categorized_transaction_id') - .unsigned() - .references('id') - .inTable('uncategorized_cashflow_transactions'); - }); -}; - -exports.down = function (knex) {}; diff --git a/packages/server/src/models/Expense.ts b/packages/server/src/models/Expense.ts index 967c9c734..b2fce9a65 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -182,7 +182,6 @@ export default class Expense extends mixin(TenantModel, [ const ExpenseCategory = require('models/ExpenseCategory'); const Media = require('models/Media'); const Branch = require('models/Branch'); - const UncategorizedCashflowTransaction = require('models/UncategorizedCashflowTransaction'); return { paymentAccount: { @@ -235,18 +234,6 @@ export default class Expense extends mixin(TenantModel, [ query.where('model_name', 'Expense'); }, }, - - /** - * Retrieves the related uncategorized cashflow transaction. - */ - categorized: { - relation: Model.BelongsToOneRelation, - modelClass: UncategorizedCashflowTransaction.default, - join: { - from: 'expenses_transactions.categorizedTransactionId', - to: 'uncategorized_cashflow_transactions.id', - }, - } }; } diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index cb5ebfeef..928db9a4d 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -4,7 +4,11 @@ import { Model, ModelOptions, QueryContext } from 'objection'; import Account from './Account'; export default class UncategorizedCashflowTransaction extends TenantModel { - amount: number; + id!: number; + amount!: number; + categorized!: boolean; + accountId!: number; + /** * Table name. */ @@ -19,6 +23,18 @@ export default class UncategorizedCashflowTransaction extends TenantModel { return ['createdAt', 'updatedAt']; } + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + ]; + } + /** * Retrieves the withdrawal amount. * @returns {number} @@ -49,18 +65,6 @@ export default class UncategorizedCashflowTransaction extends TenantModel { return 0 < this.withdrawal; } - /** - * Virtual attributes. - */ - static get virtualAttributes() { - return [ - 'withdrawal', - 'deposit', - 'isDepositTransaction', - 'isWithdrawalTransaction', - ]; - } - /** * Relationship mapping. */ @@ -83,40 +87,54 @@ export default class UncategorizedCashflowTransaction extends TenantModel { } /** - * - * @param queryContext + * Updates the count of uncategorized transactions for the associated account + * based on the specified operation. + * @param {QueryContext} queryContext - The query context for the transaction. + * @param {boolean} increment - Indicates whether to increment or decrement the count. + */ + private async updateUncategorizedTransactionCount( + queryContext: QueryContext, + increment: boolean + ) { + const operation = increment ? 'increment' : 'decrement'; + const amount = increment ? 1 : -1; + + await Account.query(queryContext.transaction) + .findById(this.accountId) + [operation]('uncategorized_transactions', amount); + } + + /** + * Runs after insert. + * @param {QueryContext} queryContext */ public async $afterInsert(queryContext) { await super.$afterInsert(queryContext); - - // Increments the uncategorized transactions count of the associated account. - await Account.query(queryContext.transaction) - .findById(this.accountId) - .increment('uncategorized_transactions', 1); + await this.updateUncategorizedTransactionCount(queryContext, true); } + /** + * Runs after update. + * @param {ModelOptions} opt + * @param {QueryContext} queryContext + */ public async $afterUpdate( opt: ModelOptions, queryContext: QueryContext - ): void | Promise { + ): Promise { await super.$afterUpdate(opt, queryContext); if (this.id && this.categorized) { - await Account.query(queryContext.transaction) - .findById(this.accountId) - .decrement('uncategorized_transactions', 1); + await this.updateUncategorizedTransactionCount(queryContext, false); } } /** - * - * @param queryContext + * Runs after delete. + * @param {QueryContext} queryContext */ - public async $afterDelete(queryContext) { + public async $afterDelete(queryContext: QueryContext) { await super.$afterDelete(queryContext); - - await Account.query(queryContext.transaction) - .findById(this.accountId) - .decrement('uncategorized_transactions', 1); + await this.updateUncategorizedTransactionCount(queryContext, false); } } diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index b313d7f3e..b3bf85ddc 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -8,7 +8,6 @@ import { transformPlaidAccountToCreateAccount, transformPlaidTrxsToCashflowCreate, } from './utils'; -import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @@ -26,9 +25,6 @@ export class PlaidSyncDb { @Inject() private cashflowApp: CashflowApplication; - @Inject() - private createCashflowTransactionService: NewCashflowTransactionService; - @Inject() private deleteCashflowTransactionService: DeleteCashflowTransaction; diff --git a/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts b/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts deleted file mode 100644 index f5590fb83..000000000 --- a/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Service } from 'typedi'; -import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; - -@Service() -export class UncategorizeTransactionByRef { - private uncategorizeTransactionService: UncategorizeCashflowTransaction; - - public uncategorize(tenantId: number, refId: number, refType: string) {} -} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx index 5a5b33d3f..43b4b706d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -1,6 +1,5 @@ // @ts-nocheck import React, { Suspense } from 'react'; -import styled from 'styled-components'; import { Spinner } from '@blueprintjs/core'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx deleted file mode 100644 index 1a54468c0..000000000 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-nocheck -import React, { lazy } from 'react'; -import { Drawer, DrawerSuspense } from '@/components'; -import withDrawers from '@/containers/Drawer/withDrawers'; - -import { compose } from '@/utils'; - -const AccountDrawerContent = lazy(() => import('./AccountDrawerContent')); - -/** - * Categorize the uncategorized transaction drawer. - */ -function CategorizeTransactionDrawer({ - name, - // #withDrawer - isOpen, - payload: { uncategorizedTranasctionId }, -}) { - return ( - - - - - - ); -} - -export default compose(withDrawers())(AccountDrawer); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx index 364c4111c..a10387af6 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx @@ -1,7 +1,6 @@ // @ts-nocheck import { Formik, Form } from 'formik'; import styled from 'styled-components'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; import { CreateCategorizeTransactionSchema } from './CategorizeTransactionForm.schema'; import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent'; import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter'; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx index dfe1db6b0..95d2bc974 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx @@ -2,7 +2,7 @@ import React from 'react'; import styled from 'styled-components'; import { FormGroup } from '@blueprintjs/core'; -import { FFormGroup, FSelect, FSuggest } from '@/components'; +import { FFormGroup, FSelect, } from '@/components'; import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants'; import { useFormikContext } from 'formik'; import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index 3a225db58..0bafef8bb 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -213,7 +213,8 @@ export function useRefreshCashflowTransactions() { } /** - * + * Retrieves specific uncategorized transaction. + * @param {number} uncategorizedTranasctionId - */ export function useUncategorizedTransaction( uncategorizedTranasctionId: nunber, From b71c79fef54e4d74f5eece4a2a7a6c6150eb10ae Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 10 Mar 2024 02:53:57 +0200 Subject: [PATCH 147/148] feat: uncategorize the cashflow transaction --- .../Cashflow/DeleteCashflowTransaction.ts | 13 +++ ...transaction_id_to_cashflow_transactions.js | 15 ++++ .../server/src/interfaces/CashflowService.ts | 3 + packages/server/src/loaders/eventEmitter.ts | 2 + .../server/src/models/CashflowTransaction.ts | 9 ++ .../Cashflow/NewCashflowTransactionService.ts | 1 + .../server/src/services/Cashflow/constants.ts | 3 +- ...DeleteCashflowTransactionOnUncategorize.ts | 12 +++ .../PreventDeleteTransactionsOnDelete.ts | 37 +++++++++ .../server/src/services/Cashflow/utils.ts | 1 + .../containers/AlertsContainer/registered.tsx | 2 + .../src/containers/CashFlow/CashflowAlerts.ts | 16 ++++ .../UncategorizeTransactionAlert.tsx | 83 +++++++++++++++++++ .../UncategorizeTransactionAlert/index.ts | 1 + .../CashflowTransactionDrawerActionBar.tsx | 28 ++++++- .../src/hooks/query/cashflowAccounts.tsx | 23 +++++ 16 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/database/migrations/20240308122047_add_uncategorized_transaction_id_to_cashflow_transactions.js create mode 100644 packages/server/src/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete.ts create mode 100644 packages/webapp/src/containers/CashFlow/CashflowAlerts.ts create mode 100644 packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/UncategorizeTransactionAlert.tsx create mode 100644 packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/index.ts diff --git a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts index 1d0edece0..5e0a763d9 100644 --- a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts @@ -93,6 +93,19 @@ export default class DeleteCashflowTransactionController extends BaseController ], }); } + if ( + error.errorType === + 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED' + ) { + return res.boom.badRequest(null, { + errors: [ + { + type: 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED', + code: 4100, + }, + ], + }); + } } next(error); } diff --git a/packages/server/src/database/migrations/20240308122047_add_uncategorized_transaction_id_to_cashflow_transactions.js b/packages/server/src/database/migrations/20240308122047_add_uncategorized_transaction_id_to_cashflow_transactions.js new file mode 100644 index 000000000..01b93bea5 --- /dev/null +++ b/packages/server/src/database/migrations/20240308122047_add_uncategorized_transaction_id_to_cashflow_transactions.js @@ -0,0 +1,15 @@ +exports.up = function (knex) { + return knex.schema.table('cashflow_transactions', (table) => { + table + .integer('uncategorized_transaction_id') + .unsigned() + .references('id') + .inTable('uncategorized_cashflow_transactions'); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('cashflow_transactions', (table) => { + table.dropColumn('uncategorized_transaction_id'); + }); +}; diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index acce307db..7d427b998 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -51,6 +51,7 @@ export interface ICashflowCommandDTO { export interface ICashflowNewCommandDTO extends ICashflowCommandDTO { plaidAccountId?: string; + uncategorizedTransactionId?: number; } export interface ICashflowTransaction { @@ -83,6 +84,8 @@ export interface ICashflowTransaction { isCashDebit?: boolean; isCashCredit?: boolean; + + uncategorizedTransactionId?: number; } export interface ICashflowTransactionLine { diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index ccc28df3d..584fb22d9 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -89,6 +89,7 @@ import { InvoiceChangeStatusOnMailSentSubscriber } from '@/services/Sales/Invoic import { SaleReceiptMarkClosedOnMailSentSubcriber } from '@/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber'; import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent'; import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize'; +import { PreventDeleteTransactionOnDelete } from '@/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete'; } export default () => { return new EventPublisher(); @@ -217,5 +218,6 @@ export const susbcribers = () => { // Cashflow DeleteCashflowTransactionOnUncategorize, + PreventDeleteTransactionOnDelete ]; }; diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index 4e47d0e2d..3cc2baba7 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -13,6 +13,7 @@ export default class CashflowTransaction extends TenantModel { amount: number; exchangeRate: number; uncategorize: boolean; + uncategorizedTransaction!: boolean; /** * Table name. @@ -86,6 +87,14 @@ export default class CashflowTransaction extends TenantModel { return this.typeMeta?.direction === CASHFLOW_DIRECTION.IN; } + /** + * Detarmines whether the transaction imported from uncategorized transaction. + * @returns {boolean} + */ + get isCategroizedTranasction() { + return !!this.uncategorizedTransaction; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts index 5222a2664..ecc0d3267 100644 --- a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts @@ -86,6 +86,7 @@ export default class NewCashflowTransactionService { 'creditAccountId', 'branchId', 'plaidTransactionId', + 'uncategorizedTransactionId', ]); // Retreive the next invoice number. const autoNextNumber = diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index 293275855..bf448a549 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -11,7 +11,8 @@ export const ERRORS = { ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED', - UNCATEGORIZED_TRANSACTION_TYPE_INVALID: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID' + UNCATEGORIZED_TRANSACTION_TYPE_INVALID: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', + CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED' }; export enum CASHFLOW_DIRECTION { diff --git a/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts index 3715b769c..75f595c65 100644 --- a/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts +++ b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts @@ -2,12 +2,16 @@ import { Inject, Service } from 'typedi'; import events from '@/subscribers/events'; import { ICashflowTransactionUncategorizedPayload } from '@/interfaces'; import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; @Service() export class DeleteCashflowTransactionOnUncategorize { @Inject() private deleteCashflowTransactionService: DeleteCashflowTransaction; + @Inject() + private tenancy: HasTenancyService; + /** * Attaches events with handlers. */ @@ -27,10 +31,18 @@ export class DeleteCashflowTransactionOnUncategorize { oldUncategorizedTransaction, trx, }: ICashflowTransactionUncategorizedPayload) { + const { CashflowTransaction } = this.tenancy.models(tenantId); + // Deletes the cashflow transaction. if ( oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction' ) { + await CashflowTransaction.query() + .findById(oldUncategorizedTransaction.categorizeRefId) + .patch({ + uncategorizedTransactionId: null, + }); + await this.deleteCashflowTransactionService.deleteCashflowTransaction( tenantId, oldUncategorizedTransaction.categorizeRefId diff --git a/packages/server/src/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete.ts b/packages/server/src/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete.ts new file mode 100644 index 000000000..54cd25d72 --- /dev/null +++ b/packages/server/src/services/Cashflow/subscribers/PreventDeleteTransactionsOnDelete.ts @@ -0,0 +1,37 @@ +import { Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ICommandCashflowDeletingPayload } from '@/interfaces'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '../constants'; + +@Service() +export class PreventDeleteTransactionOnDelete { + /** + * Attaches events with handlers. + */ + public attach = (bus) => { + bus.subscribe( + events.cashflow.onTransactionDeleting, + this.preventDeleteCashflowTransactionHasUncategorizedTransaction.bind( + this + ) + ); + }; + + /** + * Prevent delete cashflow transaction has converted from uncategorized transaction. + * @param {ICommandCashflowDeletingPayload} payload + */ + public async preventDeleteCashflowTransactionHasUncategorizedTransaction({ + tenantId, + oldCashflowTransaction, + trx, + }: ICommandCashflowDeletingPayload) { + if (oldCashflowTransaction.uncategorizedTransactionId) { + throw new ServiceError( + ERRORS.CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED, + 'Cannot delete cashflow transaction converted from uncategorized transaction.' + ); + } + } +} diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 7957b73a9..ce95e8416 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -61,6 +61,7 @@ export const transformCategorizeTransToCashflow = ( amount: uncategorizeModel.amount, transactionNumber: categorizeDTO.transactionNumber, transactionType: categorizeDTO.transactionType, + uncategorizedTransactionId: uncategorizeModel.id, publish: true, }; }; diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index d68666253..9bf51e222 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -25,6 +25,7 @@ import WarehousesTransfersAlerts from '@/containers/WarehouseTransfers/Warehouse import BranchesAlerts from '@/containers/Preferences/Branches/BranchesAlerts'; import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts'; import TaxRatesAlerts from '@/containers/TaxRates/alerts'; +import { CashflowAlerts } from '../CashFlow/CashflowAlerts'; export default [ ...AccountsAlerts, @@ -53,4 +54,5 @@ export default [ ...BranchesAlerts, ...ProjectAlerts, ...TaxRatesAlerts, + ...CashflowAlerts, ]; diff --git a/packages/webapp/src/containers/CashFlow/CashflowAlerts.ts b/packages/webapp/src/containers/CashFlow/CashflowAlerts.ts new file mode 100644 index 000000000..640de032d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CashflowAlerts.ts @@ -0,0 +1,16 @@ +// @ts-nocheck +import React from 'react'; + +const UncategorizeTransactionAlert = React.lazy( + () => import('./UncategorizeTransactionAlert/UncategorizeTransactionAlert'), +); + +/** + * Cashflow alerts. + */ +export const CashflowAlerts = [ + { + name: 'cashflow-tranaction-uncategorize', + component: UncategorizeTransactionAlert, + }, +]; diff --git a/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/UncategorizeTransactionAlert.tsx b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/UncategorizeTransactionAlert.tsx new file mode 100644 index 000000000..0ef15fabe --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/UncategorizeTransactionAlert.tsx @@ -0,0 +1,83 @@ +// @ts-nocheck +import React from 'react'; +import { Intent, Alert } from '@blueprintjs/core'; +import { FormattedMessage as T } from '@/components'; +import { AppToaster } from '@/components'; + +import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect'; +import withAlertActions from '@/containers/Alert/withAlertActions'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; + +import { useUncategorizeTransaction } from '@/hooks/query'; +import { compose } from '@/utils'; +import { DRAWERS } from '@/constants/drawers'; + +/** + * Project delete alert. + */ +function UncategorizeTransactionAlert({ + name, + + // #withAlertStoreConnect + isOpen, + payload: { uncategorizedTransactionId }, + + // #withAlertActions + closeAlert, + + // #withDrawerActions + closeDrawer, +}) { + const { mutateAsync: uncategorizeTransaction, isLoading } = + useUncategorizeTransaction(); + + // handle cancel delete project alert. + const handleCancelDeleteAlert = () => { + closeAlert(name); + }; + + // handleConfirm delete project + const handleConfirmBtnClick = () => { + uncategorizeTransaction(uncategorizedTransactionId) + .then(() => { + AppToaster.show({ + message: 'The transaction has uncategorized successfully.', + intent: Intent.SUCCESS, + }); + closeAlert(name); + closeDrawer(DRAWERS.CASHFLOW_TRNASACTION_DETAILS); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }, + ); + }; + + return ( + } + confirmButtonText={'Uncategorize'} + intent={Intent.WARNING} + isOpen={isOpen} + onCancel={handleCancelDeleteAlert} + onConfirm={handleConfirmBtnClick} + loading={isLoading} + > +

Are you sure want to uncategorize the transaction?

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, + withDrawerActions, +)(UncategorizeTransactionAlert); diff --git a/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/index.ts b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/index.ts new file mode 100644 index 000000000..41a2b5dc3 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/UncategorizeTransactionAlert/index.ts @@ -0,0 +1 @@ +export * from './UncategorizeTransactionAlert'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx b/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx index ade249bae..2e0baa665 100644 --- a/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx +++ b/packages/webapp/src/containers/Drawers/CashflowTransactionDetailDrawer/CashflowTransactionDrawerActionBar.tsx @@ -1,11 +1,18 @@ // @ts-nocheck import React from 'react'; -import { Button, Classes, NavbarGroup, Intent } from '@blueprintjs/core'; +import { + Button, + Classes, + NavbarGroup, + Intent, + NavbarDivider, +} from '@blueprintjs/core'; import { Can, FormattedMessage as T, DrawerActionsBar, Icon, + If, } from '@/components'; import withAlertsActions from '@/containers/Alert/withAlertActions'; import { useCashflowTransactionDrawerContext } from './CashflowTransactionDrawerProvider'; @@ -19,13 +26,22 @@ function CashflowTransactionDrawerActionBar({ // #withAlertsDialog openAlert, }) { - const { referenceId } = useCashflowTransactionDrawerContext(); + const { referenceId, cashflowTransaction } = + useCashflowTransactionDrawerContext(); // Handle cashflow transaction delete action. const handleDeleteCashflowTransaction = () => { openAlert('account-transaction-delete', { referenceId }); }; + // Handles the uncategorize button click. + const handleUncategorizeBtnClick = () => { + openAlert('cashflow-tranaction-uncategorize', { + uncategorizedTransactionId: + cashflowTransaction.uncategorized_transaction_id, + }); + }; + return ( @@ -37,6 +53,14 @@ function CashflowTransactionDrawerActionBar({ intent={Intent.DANGER} onClick={handleDeleteCashflowTransaction} /> + + +