diff --git a/client/src/common/allocateLandedCostType.js b/client/src/common/allocateLandedCostType.js new file mode 100644 index 000000000..74bcffacf --- /dev/null +++ b/client/src/common/allocateLandedCostType.js @@ -0,0 +1,6 @@ +import intl from 'react-intl-universal'; + +export default [ + { name: intl.get('bills'), value: 'Bill' }, + { name: intl.get('expenses'), value: 'Expense' }, +]; diff --git a/client/src/common/countries.js b/client/src/common/countries.js index c86568887..c1bfe5c5a 100644 --- a/client/src/common/countries.js +++ b/client/src/common/countries.js @@ -1,3 +1,8 @@ import intl from 'react-intl-universal'; -export default [{ name: intl.get('libya'), value: 'libya' }]; +export const getCountries = () => [ + { + name: intl.get('libya'), + value: 'libya', + }, +]; diff --git a/client/src/common/currencies.js b/client/src/common/currencies.js index ae1dbd6d9..b47f16807 100644 --- a/client/src/common/currencies.js +++ b/client/src/common/currencies.js @@ -1,6 +1,6 @@ import intl from 'react-intl-universal'; -export default [ +export const getCurrencies = () => [ { name: intl.get('us_dollar'), code: 'USD' }, { name: intl.get('euro'), code: 'EUR' }, { name: intl.get('libyan_diner'), code: 'LYD' }, diff --git a/client/src/common/dateFormatsOptions.js b/client/src/common/dateFormatsOptions.js index 8c6ed99a2..3a194a790 100644 --- a/client/src/common/dateFormatsOptions.js +++ b/client/src/common/dateFormatsOptions.js @@ -1,7 +1,7 @@ import moment from 'moment'; import intl from 'react-intl-universal'; -export default [ +export const getDateFormats =()=> [ { id: 1, name: intl.get('mm_dd_yy'), diff --git a/client/src/common/fiscalYearOptions.js b/client/src/common/fiscalYearOptions.js index 24043ec80..9fd5c3845 100644 --- a/client/src/common/fiscalYearOptions.js +++ b/client/src/common/fiscalYearOptions.js @@ -1,6 +1,6 @@ import intl from 'react-intl-universal'; -export const getFiscalYearOptions = () => [ +export const getFiscalYear = () => [ { id: 0, name: `${intl.get('january')} - ${intl.get('december')}`, diff --git a/client/src/common/languagesOptions.js b/client/src/common/languagesOptions.js index 77ddb2f6b..76f630e76 100644 --- a/client/src/common/languagesOptions.js +++ b/client/src/common/languagesOptions.js @@ -1,6 +1,6 @@ import intl from 'react-intl-universal'; -export default [ +export const getLanguages = () => [ { name: intl.get('english'), value: 'en' }, { name: intl.get('arabic'), value: 'ar' }, ]; diff --git a/client/src/components/AppIntlLoader.js b/client/src/components/AppIntlLoader.js index fdd533325..8b1b6690b 100644 --- a/client/src/components/AppIntlLoader.js +++ b/client/src/components/AppIntlLoader.js @@ -4,6 +4,7 @@ import { setLocale } from 'yup'; import intl from 'react-intl-universal'; import { find } from 'lodash'; import rtlDetect from 'rtl-detect'; +import { AppIntlProvider } from './AppIntlProvider'; import DashboardLoadingIndicator from 'components/Dashboard/DashboardLoadingIndicator'; const SUPPORTED_LOCALES = [ @@ -40,16 +41,14 @@ function loadYupLocales(currentLocale) { /** * Modifies the html document direction to RTl if it was rtl-language. */ -function useDocumentDirectionModifier(locale) { +function useDocumentDirectionModifier(locale, isRTL) { React.useEffect(() => { - const isRTL = rtlDetect.isRtlLang(locale); - if (isRTL) { const htmlDocument = document.querySelector('html'); htmlDocument.setAttribute('dir', 'rtl'); htmlDocument.setAttribute('lang', locale); } - }, []); + }, [isRTL, locale]); } /** @@ -59,8 +58,10 @@ export default function AppIntlLoader({ children }) { const [isLoading, setIsLoading] = React.useState(true); const currentLocale = getCurrentLocal(); + const isRTL = rtlDetect.isRtlLang(currentLocale); + // Modifies the html document direction - useDocumentDirectionModifier(currentLocale); + useDocumentDirectionModifier(currentLocale, isRTL); React.useEffect(() => { // Lodas the locales data file. @@ -86,10 +87,12 @@ export default function AppIntlLoader({ children }) { }) .then(() => {}); }, [currentLocale]); - + return ( - - {children} - + + + {children} + + ); } diff --git a/client/src/components/AppIntlProvider.js b/client/src/components/AppIntlProvider.js new file mode 100644 index 000000000..84e2f0638 --- /dev/null +++ b/client/src/components/AppIntlProvider.js @@ -0,0 +1,24 @@ +import React, { createContext } from 'react'; + +const AppIntlContext = createContext(); + +/** + * Application intl provider. + */ +function AppIntlProvider({ currentLocale, isRTL, children }) { + const provider = { + currentLocale, + isRTL, + isLTR: !isRTL, + }; + + return ( + + {children} + + ); +} + +const useAppIntlContext = () => React.useContext(AppIntlContext); + +export { AppIntlProvider, useAppIntlContext }; diff --git a/client/src/components/Card.js b/client/src/components/Card.js new file mode 100644 index 000000000..e28d3816b --- /dev/null +++ b/client/src/components/Card.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function Card({ children }) { + return
{children}
; +} diff --git a/client/src/components/ContactsMultiSelect.js b/client/src/components/ContactsMultiSelect.js index 5c8171294..75755ab9a 100644 --- a/client/src/components/ContactsMultiSelect.js +++ b/client/src/components/ContactsMultiSelect.js @@ -1,58 +1,57 @@ -import React, { useMemo, useCallback, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { MenuItem, Button } from '@blueprintjs/core'; -import { omit } from 'lodash'; +import intl from 'react-intl-universal'; import MultiSelect from 'components/MultiSelect'; import { FormattedMessage as T } from 'components'; -import intl from 'react-intl-universal'; +import { safeInvoke } from 'utils'; +/** + * Contacts multi-select component. + */ export default function ContactsMultiSelect({ contacts, defaultText = , buttonProps, - onCustomerSelected: onContactSelected, - ...selectProps + onContactSelect, + contactsSelected = [], + ...multiSelectProps }) { - const [selectedContacts, setSelectedContacts] = useState({}); + const [localSelected, setLocalSelected] = useState([ ...contactsSelected]); - const isContactSelect = useCallback( - (id) => typeof selectedContacts[id] !== 'undefined', - [selectedContacts], + // Detarmines the given id is selected. + const isItemSelected = useCallback( + (id) => localSelected.some(s => s === id), + [localSelected], ); + // Contact item renderer. const contactRenderer = useCallback( (contact, { handleClick }) => ( ), - [isContactSelect], + [isItemSelected], ); - const countSelected = useMemo( - () => Object.values(selectedContacts).length, - [selectedContacts], - ); + // Count selected items. + const countSelected = localSelected.length; - const onContactSelect = useCallback( + // Handle item selected. + const handleItemSelect = useCallback( ({ id }) => { - const selected = { - ...(isContactSelect(id) - ? { - ...omit(selectedContacts, [id]), - } - : { - ...selectedContacts, - [id]: true, - }), - }; - setSelectedContacts({ ...selected }); - onContactSelected && onContactSelected(selected); + const selected = isItemSelected(id) + ? localSelected.filter(s => s !== id) + : [...localSelected, id]; + + setLocalSelected([ ...selected ]); + safeInvoke(onContactSelect, selected); }, - [setSelectedContacts, selectedContacts, isContactSelect, onContactSelected], + [setLocalSelected, localSelected, isItemSelected, onContactSelect], ); return ( @@ -62,7 +61,8 @@ export default function ContactsMultiSelect({ itemRenderer={contactRenderer} popoverProps={{ minimal: true }} filterable={true} - onItemSelect={onContactSelect} + onItemSelect={handleItemSelect} + {...multiSelectProps} > + + + + ); +} + +export default compose(withDialogActions)(AllocateLandedCostFloatingActions); diff --git a/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostForm.js b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostForm.js new file mode 100644 index 000000000..3b6ded70a --- /dev/null +++ b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostForm.js @@ -0,0 +1,102 @@ +import React from 'react'; +import { Formik } from 'formik'; +import { Intent } from '@blueprintjs/core'; +import intl from 'react-intl-universal'; +import moment from 'moment'; +import { sumBy } from 'lodash'; + +import 'style/pages/AllocateLandedCost/AllocateLandedCostForm.scss'; + +import { AppToaster } from 'components'; +import { AllocateLandedCostFormSchema } from './AllocateLandedCostForm.schema'; +import { useAllocateLandedConstDialogContext } from './AllocateLandedCostDialogProvider'; +import AllocateLandedCostFormContent from './AllocateLandedCostFormContent'; +import withDialogActions from 'containers/Dialog/withDialogActions'; +import { compose, transformToForm } from 'utils'; + +// Default form initial values. +const defaultInitialValues = { + transaction_type: 'Bill', + transaction_date: moment(new Date()).format('YYYY-MM-DD'), + transaction_id: '', + transaction_entry_id: '', + amount: '', + allocation_method: 'quantity', + items: [ + { + entry_id: '', + cost: '', + }, + ], +}; + +/** + * Allocate landed cost form. + */ +function AllocateLandedCostForm({ + // #withDialogActions + closeDialog, +}) { + const { dialogName, bill, billId, createLandedCostMutate } = + useAllocateLandedConstDialogContext(); + + // Initial form values. + const initialValues = { + ...defaultInitialValues, + items: bill.entries.map((entry) => ({ + ...entry, + entry_id: entry.id, + cost: '', + })), + }; + const amount = sumBy(initialValues.items, 'amount'); + + // Handle form submit. + const handleFormSubmit = (values, { setSubmitting }) => { + setSubmitting(false); + + // Filters the entries has no cost. + const entries = values.items + .filter((entry) => entry.entry_id && entry.cost) + .map((entry) => transformToForm(entry, defaultInitialValues.items[0])); + + if (entries.length <= 0) { + AppToaster.show({ message: 'Something wrong!', intent: Intent.DANGER }); + return; + } + const form = { + ...values, + items: entries, + }; + // Handle the request success. + const onSuccess = (response) => { + AppToaster.show({ + message: intl.get('the_landed_cost_has_been_created_successfully'), + intent: Intent.SUCCESS, + }); + setSubmitting(false); + closeDialog(dialogName); + }; + + // Handle the request error. + const onError = () => { + setSubmitting(false); + AppToaster.show({ message: 'Something went wrong!', intent: Intent.DANGER }); + }; + createLandedCostMutate([billId, form]).then(onSuccess).catch(onError); + }; + + // Computed validation schema. + const validationSchema = AllocateLandedCostFormSchema(amount); + + return ( + + ); +} + +export default compose(withDialogActions)(AllocateLandedCostForm); diff --git a/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostForm.schema.js b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostForm.schema.js new file mode 100644 index 000000000..d940aa5f2 --- /dev/null +++ b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostForm.schema.js @@ -0,0 +1,18 @@ +import * as Yup from 'yup'; +import intl from 'react-intl-universal'; + +export const AllocateLandedCostFormSchema = (minAmount) => + Yup.object().shape({ + transaction_type: Yup.string().label(intl.get('transaction_type')), + transaction_date: Yup.date().label(intl.get('transaction_date')), + transaction_id: Yup.string().label(intl.get('transaction_number')), + transaction_entry_id: Yup.string().label(intl.get('transaction_line')), + amount: Yup.number().max(minAmount).label(intl.get('amount')), + allocation_method: Yup.string().trim(), + items: Yup.array().of( + Yup.object().shape({ + entry_id: Yup.number().nullable(), + cost: Yup.number().nullable(), + }), + ), + }); diff --git a/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormBody.js b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormBody.js new file mode 100644 index 000000000..2e57fa57e --- /dev/null +++ b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormBody.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { FastField } from 'formik'; +import classNames from 'classnames'; +import { CLASSES } from 'common/classes'; +import AllocateLandedCostEntriesTable from './AllocateLandedCostEntriesTable'; + +export default function AllocateLandedCostFormBody() { + return ( +
+ + {({ + form: { setFieldValue, values }, + field: { value }, + meta: { error, touched }, + }) => ( + { + setFieldValue('items', newEntries); + }} + /> + )} + +
+ ); +} diff --git a/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormContent.js b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormContent.js new file mode 100644 index 000000000..e1f5f08c0 --- /dev/null +++ b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormContent.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Form } from 'formik'; +import AllocateLandedCostFormFields from './AllocateLandedCostFormFields'; +import AllocateLandedCostFloatingActions from './AllocateLandedCostFloatingActions'; + +/** + * Allocate landed cost form content. + */ +export default function AllocateLandedCostFormContent() { + return ( +
+ + + + ); +} diff --git a/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormFields.js b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormFields.js new file mode 100644 index 000000000..4861a65eb --- /dev/null +++ b/client/src/containers/Dialogs/AllocateLandedCostDialog/AllocateLandedCostFormFields.js @@ -0,0 +1,202 @@ +import React from 'react'; +import { FastField, Field, ErrorMessage, useFormikContext } from 'formik'; +import { + Classes, + FormGroup, + RadioGroup, + Radio, + InputGroup, +} from '@blueprintjs/core'; +import classNames from 'classnames'; +import { FormattedMessage as T, If } from 'components'; +import intl from 'react-intl-universal'; +import { inputIntent, handleStringChange } from 'utils'; +import { FieldRequiredHint, ListSelect } from 'components'; +import { CLASSES } from 'common/classes'; +import allocateLandedCostType from 'common/allocateLandedCostType'; +import { useLandedCostTransaction } from 'hooks/query'; + +import AllocateLandedCostFormBody from './AllocateLandedCostFormBody'; +import { getEntriesByTransactionId, allocateCostToEntries } from './utils'; + +/** + * Allocate landed cost form fields. + */ +export default function AllocateLandedCostFormFields() { + const { values } = useFormikContext(); + + const { + data: { transactions }, + } = useLandedCostTransaction(values.transaction_type); + + // Retrieve entries of the given transaction id. + const transactionEntries = React.useMemo( + () => getEntriesByTransactionId(transactions, values.transaction_id), + [transactions, values.transaction_id], + ); + + return ( +
+ {/*------------Transaction type -----------*/} + + {({ + form: { values, setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + labelInfo={} + helperText={} + intent={inputIntent({ error, touched })} + inline={true} + className={classNames(CLASSES.FILL, 'form-group--transaction_type')} + > + { + setFieldValue('transaction_type', type.value); + setFieldValue('transaction_id', ''); + setFieldValue('transaction_entry_id', ''); + }} + filterable={false} + selectedItem={value} + selectedItemProp={'value'} + textProp={'name'} + popoverProps={{ minimal: true }} + /> + + )} + + + {/*------------ Transaction -----------*/} + + {({ form, field: { value }, meta: { error, touched } }) => ( + } + labelInfo={} + intent={inputIntent({ error, touched })} + helperText={} + className={classNames(CLASSES.FILL, 'form-group--transaction_id')} + inline={true} + > + { + form.setFieldValue('transaction_id', id); + form.setFieldValue('transaction_entry_id', ''); + }} + filterable={false} + selectedItem={value} + selectedItemProp={'id'} + textProp={'name'} + labelProp={'id'} + defaultText={intl.get('Select transaction')} + popoverProps={{ minimal: true }} + /> + + )} + + + {/*------------ Transaction line -----------*/} + 0}> + + {({ form, field: { value }, meta: { error, touched } }) => ( + } + intent={inputIntent({ error, touched })} + helperText={} + className={classNames( + CLASSES.FILL, + 'form-group--transaction_entry_id', + )} + inline={true} + > + { + const { items, allocation_method } = form.values; + + form.setFieldValue('amount', amount); + form.setFieldValue('transaction_entry_id', id); + + form.setFieldValue( + 'items', + allocateCostToEntries(amount, allocation_method, items), + ); + }} + filterable={false} + selectedItem={value} + selectedItemProp={'id'} + textProp={'name'} + defaultText={intl.get('Select transaction entry')} + popoverProps={{ minimal: true }} + /> + + )} + + + + {/*------------ Amount -----------*/} + + {({ form, field, meta: { error, touched } }) => ( + } + intent={inputIntent({ error, touched })} + helperText={} + className={'form-group--amount'} + inline={true} + > + { + const amount = e.target.value; + const { allocation_method, items } = form.values; + + form.setFieldValue( + 'items', + allocateCostToEntries(amount, allocation_method, items), + ); + }} + /> + + )} + + + {/*------------ Allocation method -----------*/} + + {({ form, field: { value }, meta: { touched, error } }) => ( + } + labelInfo={} + className={'form-group--allocation_method'} + intent={inputIntent({ error, touched })} + helperText={} + inline={true} + > + { + const { amount, items, allocation_method } = form.values; + + form.setFieldValue('allocation_method', _value); + form.setFieldValue( + 'items', + allocateCostToEntries(amount, allocation_method, items), + ); + })} + selectedValue={value} + inline={true} + > + } value="quantity" /> + } value="value" /> + + + )} + + + {/*------------ Allocate Landed cost Table -----------*/} + +
+ ); +} diff --git a/client/src/containers/Dialogs/AllocateLandedCostDialog/index.js b/client/src/containers/Dialogs/AllocateLandedCostDialog/index.js new file mode 100644 index 000000000..de65ae01b --- /dev/null +++ b/client/src/containers/Dialogs/AllocateLandedCostDialog/index.js @@ -0,0 +1,36 @@ +import React, { lazy } from 'react'; +import { FormattedMessage as T, Dialog, DialogSuspense } from 'components'; +import withDialogRedux from 'components/DialogReduxConnect'; +import { compose } from 'utils'; + +const AllocateLandedCostDialogContent = lazy(() => + import('./AllocateLandedCostDialogContent'), +); + +/** + * Allocate landed cost dialog. + */ +function AllocateLandedCostDialog({ + dialogName, + payload = { billId: null }, + isOpen, +}) { + return ( + } + canEscapeKeyClose={true} + isOpen={isOpen} + className="dialog--allocate-landed-cost-form" + > + + + + + ); +} + +export default compose(withDialogRedux())(AllocateLandedCostDialog); diff --git a/client/src/containers/Dialogs/AllocateLandedCostDialog/utils.js b/client/src/containers/Dialogs/AllocateLandedCostDialog/utils.js new file mode 100644 index 000000000..21d5fa076 --- /dev/null +++ b/client/src/containers/Dialogs/AllocateLandedCostDialog/utils.js @@ -0,0 +1,62 @@ +import { sumBy, round } from 'lodash'; +import * as R from 'ramda'; +/** + * Retrieve transaction entries of the given transaction id. + */ +export function getEntriesByTransactionId(transactions, id) { + const transaction = transactions.find((trans) => trans.id === id); + return transaction ? transaction.entries : []; +} + +export function allocateCostToEntries(total, allocateType, entries) { + return R.compose( + R.when( + R.always(allocateType === 'value'), + R.curry(allocateCostByValue)(total), + ), + R.when( + R.always(allocateType === 'quantity'), + R.curry(allocateCostByQuantity)(total), + ), + )(entries); +} + +/** + * Allocate total cost on entries on value. + * @param {*} entries + * @param {*} total + * @returns + */ +export function allocateCostByValue(total, entries) { + const totalAmount = sumBy(entries, 'amount'); + + const _entries = entries.map((entry) => ({ + ...entry, + percentageOfValue: entry.amount / totalAmount, + })); + + return _entries.map((entry) => ({ + ...entry, + cost: round(entry.percentageOfValue * total, 2), + })); +} + +/** + * Allocate total cost on entries by quantity. + * @param {*} entries + * @param {*} total + * @returns + */ +export function allocateCostByQuantity(total, entries) { + const totalQuantity = sumBy(entries, 'quantity'); + + const _entries = entries.map((entry) => ({ + ...entry, + percentageOfQuantity: entry.quantity / totalQuantity, + })); + + return _entries.map((entry) => ({ + ...entry, + cost: round(entry.percentageOfQuantity * total, 2), + })); +} diff --git a/client/src/containers/Dialogs/PaymentViaVoucherDialog/PaymentViaVoucherDialogContent.js b/client/src/containers/Dialogs/PaymentViaVoucherDialog/PaymentViaVoucherDialogContent.js index 58cf04831..2a2202422 100644 --- a/client/src/containers/Dialogs/PaymentViaVoucherDialog/PaymentViaVoucherDialogContent.js +++ b/client/src/containers/Dialogs/PaymentViaVoucherDialog/PaymentViaVoucherDialogContent.js @@ -26,13 +26,10 @@ function PaymentViaLicenseDialogContent({ // #withDialog closeDialog, }) { - const history = useHistory(); // Payment via voucher - const { - mutateAsync: paymentViaVoucherMutate, - } = usePaymentByVoucher(); + const { mutateAsync: paymentViaVoucherMutate } = usePaymentByVoucher(); // Handle submit. const handleSubmit = (values, { setSubmitting, setErrors }) => { @@ -41,7 +38,7 @@ function PaymentViaLicenseDialogContent({ paymentViaVoucherMutate({ ...values }) .then(() => { Toaster.show({ - message: 'Payment has been done successfully.', + message: intl.get('payment_has_been_done_successfully'), intent: Intent.SUCCESS, }); return closeDialog('payment-via-voucher'); diff --git a/client/src/containers/Drawers/AccountDrawer/AccountDrawerContent.js b/client/src/containers/Drawers/AccountDrawer/AccountDrawerContent.js index aa4bcc436..3bf0bf706 100644 --- a/client/src/containers/Drawers/AccountDrawer/AccountDrawerContent.js +++ b/client/src/containers/Drawers/AccountDrawer/AccountDrawerContent.js @@ -1,4 +1,7 @@ import React from 'react'; + +import 'style/components/Drawers/AccountDrawer.scss'; + import { AccountDrawerProvider } from './AccountDrawerProvider'; import AccountDrawerDetails from './AccountDrawerDetails'; diff --git a/client/src/containers/Drawers/AccountDrawer/AccountDrawerDetails.js b/client/src/containers/Drawers/AccountDrawer/AccountDrawerDetails.js index 230d89beb..30d5a30fb 100644 --- a/client/src/containers/Drawers/AccountDrawer/AccountDrawerDetails.js +++ b/client/src/containers/Drawers/AccountDrawer/AccountDrawerDetails.js @@ -5,8 +5,6 @@ import AccountDrawerHeader from './AccountDrawerHeader'; import AccountDrawerTable from './AccountDrawerTable'; import { useAccountDrawerContext } from './AccountDrawerProvider'; -import 'style/components/Drawer/AccountDrawer.scss'; - /** * Account view details. */ diff --git a/client/src/containers/Drawers/BillDrawer/BillDrawerAlerts.js b/client/src/containers/Drawers/BillDrawer/BillDrawerAlerts.js new file mode 100644 index 000000000..bc8ca4660 --- /dev/null +++ b/client/src/containers/Drawers/BillDrawer/BillDrawerAlerts.js @@ -0,0 +1,13 @@ +import React from 'react'; +import BillLocatedLandedCostDeleteAlert from 'containers/Alerts/Bills/BillLocatedLandedCostDeleteAlert'; + +/** + * Bill drawer alert. + */ +export default function BillDrawerAlerts() { + return ( +
+ +
+ ); +} diff --git a/client/src/containers/Drawers/BillDrawer/BillDrawerContent.js b/client/src/containers/Drawers/BillDrawer/BillDrawerContent.js new file mode 100644 index 000000000..a9a49c0ce --- /dev/null +++ b/client/src/containers/Drawers/BillDrawer/BillDrawerContent.js @@ -0,0 +1,22 @@ +import React from 'react'; + +import 'style/components/Drawers/BillDrawer.scss'; + +import { BillDrawerProvider } from './BillDrawerProvider'; +import BillDrawerDetails from './BillDrawerDetails'; +import BillDrawerAlerts from './BillDrawerAlerts'; + +/** + * Bill drawer content. + */ +export default function BillDrawerContent({ + // #ownProp + bill, +}) { + return ( + + + + + ); +} diff --git a/client/src/containers/Drawers/BillDrawer/BillDrawerDetails.js b/client/src/containers/Drawers/BillDrawer/BillDrawerDetails.js new file mode 100644 index 000000000..bd9de99e1 --- /dev/null +++ b/client/src/containers/Drawers/BillDrawer/BillDrawerDetails.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { Tabs, Tab } from '@blueprintjs/core'; +import intl from 'react-intl-universal'; + +import LocatedLandedCostTable from './LocatedLandedCostTable'; + +/** + * Bill view details. + */ +export default function BillDrawerDetails() { + return ( +
+ + + } + /> + +
+ ); +} diff --git a/client/src/containers/Drawers/BillDrawer/BillDrawerProvider.js b/client/src/containers/Drawers/BillDrawer/BillDrawerProvider.js new file mode 100644 index 000000000..47bdd53b1 --- /dev/null +++ b/client/src/containers/Drawers/BillDrawer/BillDrawerProvider.js @@ -0,0 +1,37 @@ +import React from 'react'; +import intl from 'react-intl-universal'; +import { DrawerHeaderContent, DashboardInsider } from 'components'; +import { useBillLocatedLandedCost } from 'hooks/query'; + +const BillDrawerContext = React.createContext(); + +/** + * Bill drawer provider. + */ +function BillDrawerProvider({ billId, ...props }) { + // Handle fetch bill located landed cost transaction. + const { isLoading: isLandedCostLoading, data: transactions } = + useBillLocatedLandedCost(billId, { + enabled: !!billId, + }); + + //provider. + const provider = { + transactions, + billId, + }; + + return ( + + + + + ); +} + +const useBillDrawerContext = () => React.useContext(BillDrawerContext); + +export { BillDrawerProvider, useBillDrawerContext }; diff --git a/client/src/containers/Drawers/BillDrawer/LocatedLandedCostTable.js b/client/src/containers/Drawers/BillDrawer/LocatedLandedCostTable.js new file mode 100644 index 000000000..c06848a30 --- /dev/null +++ b/client/src/containers/Drawers/BillDrawer/LocatedLandedCostTable.js @@ -0,0 +1,91 @@ +import React from 'react'; +import { DataTable, Card } from 'components'; +import { Button, Classes, NavbarGroup } from '@blueprintjs/core'; + +import { useLocatedLandedCostColumns, ActionsMenu } from './components'; +import { useBillDrawerContext } from './BillDrawerProvider'; + +import withAlertsActions from 'containers/Alert/withAlertActions'; +import withDialogActions from 'containers/Dialog/withDialogActions'; +import withDrawerActions from 'containers/Drawer/withDrawerActions'; + +import { compose } from 'utils'; +import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar'; +import Icon from 'components/Icon'; + +/** + * Located landed cost table. + */ +function LocatedLandedCostTable({ + // #withAlertsActions + openAlert, + + // #withDialogActions + openDialog, + + // #withDrawerActions + openDrawer, +}) { + const columns = useLocatedLandedCostColumns(); + const { transactions, billId } = useBillDrawerContext(); + + // Handle the transaction delete action. + const handleDeleteTransaction = ({ id }) => { + openAlert('bill-located-cost-delete', { BillId: id }); + }; + + // Handle allocate landed cost button click. + const handleAllocateCostClick = () => { + openDialog('allocate-landed-cost', { billId }); + }; + + // Handle from transaction link click. + const handleFromTransactionClick = (original) => { + const { from_transaction_type, from_transaction_id } = original; + + switch (from_transaction_type) { + case 'Expense': + openDrawer('expense-drawer', { expenseId: from_transaction_id }); + break; + + case 'Bill': + default: + openDrawer('bill-drawer', { billId: from_transaction_id }); + break; + } + }; + + return ( +
+ + +
+ ); +} + +export default compose( + withAlertsActions, + withDialogActions, + withDrawerActions, +)(LocatedLandedCostTable); diff --git a/client/src/containers/Drawers/BillDrawer/components.js b/client/src/containers/Drawers/BillDrawer/components.js new file mode 100644 index 000000000..2a4d29f93 --- /dev/null +++ b/client/src/containers/Drawers/BillDrawer/components.js @@ -0,0 +1,75 @@ +import React from 'react'; +import intl from 'react-intl-universal'; +import { Intent, MenuItem, Menu } from '@blueprintjs/core'; +import { safeCallback } from 'utils'; +import { Icon } from 'components'; +/** + * Actions menu. + */ +export function ActionsMenu({ row: { original }, payload: { onDelete } }) { + return ( + + } + text={intl.get('delete_transaction')} + intent={Intent.DANGER} + onClick={safeCallback(onDelete, original)} + /> + + ); +} + +/** + * From transaction table cell. + */ +export function FromTransactionCell({ + row: { original }, + payload: { onFromTranscationClick } +}) { + // Handle the link click + const handleAnchorClick = () => { + onFromTranscationClick && onFromTranscationClick(original); + }; + + return ( + + {original.from_transaction_type} → {original.from_transaction_id} + + ); +} + +/** + * Retrieve bill located landed cost table columns. + */ +export function useLocatedLandedCostColumns() { + return React.useMemo( + () => [ + { + Header: intl.get('name'), + accessor: 'description', + width: 150, + className: 'name', + }, + { + Header: intl.get('amount'), + accessor: 'formatted_amount', + width: 100, + className: 'amount', + }, + { + id: 'from_transaction', + Header: intl.get('From transaction'), + Cell: FromTransactionCell, + width: 100, + className: 'from-transaction', + }, + { + Header: intl.get('allocation_method'), + accessor: 'allocation_method_formatted', + width: 100, + className: 'allocation-method', + }, + ], + [], + ); +} diff --git a/client/src/containers/Drawers/BillDrawer/index.js b/client/src/containers/Drawers/BillDrawer/index.js new file mode 100644 index 000000000..c712e4a15 --- /dev/null +++ b/client/src/containers/Drawers/BillDrawer/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { Drawer, DrawerSuspense } from 'components'; +import withDrawers from 'containers/Drawer/withDrawers'; + +import { compose } from 'utils'; + +const BillDrawerContent = React.lazy(() => import('./BillDrawerContent')); + +/** + * Bill drawer. + */ +function BillDrawer({ + name, + // #withDrawer + isOpen, + payload: { billId }, +}) { + return ( + + + + + + ); +} + +export default compose(withDrawers())(BillDrawer); diff --git a/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerContent.js b/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerContent.js index 37f3bd8cb..fca141626 100644 --- a/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerContent.js +++ b/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerContent.js @@ -1,4 +1,7 @@ import React from 'react'; + +import 'style/components/Drawers/ViewDetails.scss'; + import { ExpenseDrawerProvider } from './ExpenseDrawerProvider'; import ExpenseDrawerDetails from './ExpenseDrawerDetails'; diff --git a/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerDetails.js b/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerDetails.js index 763d6304d..8ddbf6674 100644 --- a/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerDetails.js +++ b/client/src/containers/Drawers/ExpenseDrawer/ExpenseDrawerDetails.js @@ -4,7 +4,6 @@ import ExpenseDrawerHeader from './ExpenseDrawerHeader'; import ExpenseDrawerTable from './ExpenseDrawerTable'; import ExpenseDrawerFooter from './ExpenseDrawerFooter'; import { useExpenseDrawerContext } from './ExpenseDrawerProvider'; -import 'style/components/Drawer/ViewDetails.scss'; /** * Expense view details. diff --git a/client/src/containers/Drawers/ExpenseDrawer/index.js b/client/src/containers/Drawers/ExpenseDrawer/index.js index 6e97a5d60..07031c210 100644 --- a/client/src/containers/Drawers/ExpenseDrawer/index.js +++ b/client/src/containers/Drawers/ExpenseDrawer/index.js @@ -1,6 +1,7 @@ import React, { lazy } from 'react'; import { Drawer, DrawerSuspense } from 'components'; import withDrawers from 'containers/Drawer/withDrawers'; +import intl from 'react-intl-universal'; import { compose } from 'utils'; @@ -17,7 +18,7 @@ function ExpenseDrawer({ payload: { expenseId, title }, }) { return ( - + diff --git a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerContent.js b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerContent.js index c6ff9ccc0..905dd2174 100644 --- a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerContent.js +++ b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerContent.js @@ -1,4 +1,7 @@ import React from 'react'; + +import 'style/components/Drawers/ViewDetails.scss'; + import { ManualJournalDrawerProvider } from './ManualJournalDrawerProvider'; import ManualJournalDrawerDetails from './ManualJournalDrawerDetails'; diff --git a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerDetails.js b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerDetails.js index 804b47c8e..e5129cb5c 100644 --- a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerDetails.js +++ b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerDetails.js @@ -6,8 +6,6 @@ import ManualJournalDrawerFooter from './ManualJournalDrawerFooter'; import { useManualJournalDrawerContext } from 'containers/Drawers/ManualJournalDrawer/ManualJournalDrawerProvider'; -import 'style/components/Drawer/ViewDetails.scss'; - /** * Manual journal view details. */ diff --git a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerProvider.js b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerProvider.js index ebbf206cd..6b2b531f0 100644 --- a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerProvider.js +++ b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerProvider.js @@ -1,4 +1,5 @@ import React from 'react'; +import intl from 'react-intl-universal'; import { useJournal } from 'hooks/query'; import { DashboardInsider, DrawerHeaderContent } from 'components'; @@ -25,7 +26,9 @@ function ManualJournalDrawerProvider({ manualJournalId, ...props }) { diff --git a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerTable.js b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerTable.js index e0d59a46f..26798517b 100644 --- a/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerTable.js +++ b/client/src/containers/Drawers/ManualJournalDrawer/ManualJournalDrawerTable.js @@ -69,6 +69,7 @@ export default function ManualJournalDrawerTable({ return (
+

Description: {description} diff --git a/client/src/containers/Drawers/PaperTemplate/PaperTemplate.js b/client/src/containers/Drawers/PaperTemplate/PaperTemplate.js index 9c12fc7fd..d55b83c97 100644 --- a/client/src/containers/Drawers/PaperTemplate/PaperTemplate.js +++ b/client/src/containers/Drawers/PaperTemplate/PaperTemplate.js @@ -1,11 +1,13 @@ import React from 'react'; + +import 'style/components/Drawers/DrawerTemplate.scss'; + import PaperTemplateHeader from './PaperTemplateHeader'; import PaperTemplateTable from './PaperTemplateTable'; import PaperTemplateFooter from './PaperTemplateFooter'; import { updateItemsEntriesTotal } from 'containers/Entries/utils'; import intl from 'react-intl-universal'; -import 'style/components/Drawer/DrawerTemplate.scss'; function PaperTemplate({ labels: propLabels, paperData, entries }) { const labels = { diff --git a/client/src/containers/Drawers/PaperTemplate/PaperTemplateFooter.js b/client/src/containers/Drawers/PaperTemplate/PaperTemplateFooter.js index cedf261a2..5481982e3 100644 --- a/client/src/containers/Drawers/PaperTemplate/PaperTemplateFooter.js +++ b/client/src/containers/Drawers/PaperTemplate/PaperTemplateFooter.js @@ -1,5 +1,6 @@ import React from 'react'; import { If } from 'components'; +import intl from 'react-intl-universal'; export default function PaperTemplateFooter({ footerData: { terms_conditions }, @@ -8,7 +9,7 @@ export default function PaperTemplateFooter({

-

Conditions and terms

+

{intl.get('conditions_and_terms')}

    diff --git a/client/src/containers/Drawers/PaymentPaperTemplate/PaymentPaperTemplate.js b/client/src/containers/Drawers/PaymentPaperTemplate/PaymentPaperTemplate.js index b0fbb1ebb..12fb4e403 100644 --- a/client/src/containers/Drawers/PaymentPaperTemplate/PaymentPaperTemplate.js +++ b/client/src/containers/Drawers/PaymentPaperTemplate/PaymentPaperTemplate.js @@ -1,9 +1,11 @@ import React from 'react'; + +import 'style/components/Drawers/DrawerTemplate.scss'; + import PaymentPaperTemplateHeader from './PaymentPaperTemplateHeader'; import PaymentPaperTemplateTable from './PaymentPaperTemplateTable'; import intl from 'react-intl-universal'; -import 'style/components/Drawer/DrawerTemplate.scss'; export default function PaymentPaperTemplate({ labels: propLabels, diff --git a/client/src/containers/Entries/ItemsEntriesTable.js b/client/src/containers/Entries/ItemsEntriesTable.js index c4a482012..ce4e0ce9f 100644 --- a/client/src/containers/Entries/ItemsEntriesTable.js +++ b/client/src/containers/Entries/ItemsEntriesTable.js @@ -30,6 +30,7 @@ function ItemsEntriesTable({ linesNumber, currencyCode, itemType, // sellable or purchasable + landedCost = false }) { const [rows, setRows] = React.useState(initialEntries); const [rowItem, setRowItem] = React.useState(null); @@ -94,7 +95,7 @@ function ItemsEntriesTable({ }, [entries, rows]); // Editiable items entries columns. - const columns = useEditableItemsEntriesColumns(); + const columns = useEditableItemsEntriesColumns({ landedCost }); // Handles the editor data update. const handleUpdateData = useCallback( diff --git a/client/src/containers/Entries/components.js b/client/src/containers/Entries/components.js index 44cb44d11..59db5385d 100644 --- a/client/src/containers/Entries/components.js +++ b/client/src/containers/Entries/components.js @@ -1,7 +1,7 @@ import React from 'react'; import { FormattedMessage as T } from 'components'; import intl from 'react-intl-universal'; -import { Tooltip, Button, Intent, Position } from '@blueprintjs/core'; +import { Tooltip, Button, Checkbox, Intent, Position } from '@blueprintjs/core'; import { Hint, Icon } from 'components'; import { formattedAmount, safeSumBy } from 'utils'; import { @@ -10,6 +10,7 @@ import { ItemsListCell, PercentFieldCell, NumericInputCell, + CheckBoxFieldCell, } from 'components/DataTableCells'; /** @@ -28,7 +29,11 @@ export function ItemHeaderCell() { * Item column footer cell. */ export function ItemFooterCell() { - return ; + return ( + + + + ); } /** @@ -86,12 +91,26 @@ export function IndexTableCell({ row: { index } }) { return {index + 1}; } +/** + * Landed cost header cell. + */ +const LandedCostHeaderCell = () => { + return ( + <> + + + + ); +}; + /** * Retrieve editable items entries columns. */ -export function useEditableItemsEntriesColumns() { - - +export function useEditableItemsEntriesColumns({ landedCost }) { return React.useMemo( () => [ { @@ -155,6 +174,19 @@ export function useEditableItemsEntriesColumns() { width: 100, className: 'total', }, + ...(landedCost + ? [ + { + Header: LandedCostHeaderCell, + accessor: 'landed_cost', + Cell: CheckBoxFieldCell, + width: 100, + disableSortBy: true, + disableResizing: true, + className: 'landed-cost', + }, + ] + : []), { Header: '', accessor: 'action', diff --git a/client/src/containers/Expenses/ExpenseForm/ExpenseForm.js b/client/src/containers/Expenses/ExpenseForm/ExpenseForm.js index a75fd5dd6..2f0472447 100644 --- a/client/src/containers/Expenses/ExpenseForm/ExpenseForm.js +++ b/client/src/containers/Expenses/ExpenseForm/ExpenseForm.js @@ -79,7 +79,10 @@ function ExpenseForm({ } const categories = values.categories.filter( (category) => - category.amount && category.index && category.expense_account_id, + category.amount && + category.index && + category.expense_account_id && + category.landed_cost, ); const form = { diff --git a/client/src/containers/Expenses/ExpenseForm/ExpenseForm.schema.js b/client/src/containers/Expenses/ExpenseForm/ExpenseForm.schema.js index d05661cb6..8fb55b94a 100644 --- a/client/src/containers/Expenses/ExpenseForm/ExpenseForm.schema.js +++ b/client/src/containers/Expenses/ExpenseForm/ExpenseForm.schema.js @@ -8,9 +8,7 @@ const Schema = Yup.object().shape({ payment_account_id: Yup.number() .required() .label(intl.get('payment_account_')), - payment_date: Yup.date() - .required() - .label(intl.get('payment_date_')), + payment_date: Yup.date().required().label(intl.get('payment_date_')), reference_no: Yup.string().min(1).max(DATATYPES_LENGTH.STRING).nullable(), currency_code: Yup.string() .nullable() @@ -33,6 +31,7 @@ const Schema = Yup.object().shape({ is: (amount) => !isBlank(amount), then: Yup.number().required(), }), + landed_cost: Yup.boolean(), description: Yup.string().max(DATATYPES_LENGTH.TEXT).nullable(), }), ), diff --git a/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesField.js b/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesField.js index 437b10bb8..abec92e0f 100644 --- a/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesField.js +++ b/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesField.js @@ -1,14 +1,22 @@ import { FastField } from 'formik'; import React from 'react'; import ExpenseFormEntriesTable from './ExpenseFormEntriesTable'; -import { defaultExpenseEntry } from './utils'; +import { useExpenseFormContext } from './ExpenseFormPageProvider'; +import { defaultExpenseEntry, accountsFieldShouldUpdate } from './utils'; /** * Expense form entries field. */ export default function ExpenseFormEntriesField({ linesNumber = 4 }) { + // Expense form context. + const { accounts } = useExpenseFormContext(); + return ( - + {({ form: { values, setFieldValue }, field: { value }, diff --git a/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesTable.js b/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesTable.js index ab1c2cdbc..79ee363be 100644 --- a/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesTable.js +++ b/client/src/containers/Expenses/ExpenseForm/ExpenseFormEntriesTable.js @@ -22,12 +22,13 @@ export default function ExpenseFormEntriesTable({ error, onChange, currencyCode, + landedCost = true, }) { // Expense form context. const { accounts } = useExpenseFormContext(); // Memorized data table columns. - const columns = useExpenseFormTableColumns(); + const columns = useExpenseFormTableColumns({ landedCost }); // Handles update datatable data. const handleUpdateData = useCallback( @@ -61,6 +62,7 @@ export default function ExpenseFormEntriesTable({ return ( - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -118,7 +123,11 @@ export default function ExpenseFormHeader() { )} - + {({ form, field: { value }, meta: { error, touched } }) => ( } diff --git a/client/src/containers/Expenses/ExpenseForm/components.js b/client/src/containers/Expenses/ExpenseForm/components.js index dfd0c96d2..64f275d00 100644 --- a/client/src/containers/Expenses/ExpenseForm/components.js +++ b/client/src/containers/Expenses/ExpenseForm/components.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Button, Tooltip, Intent, Position } from '@blueprintjs/core'; +import { Button, Tooltip, Intent, Position, Checkbox } from '@blueprintjs/core'; import { FormattedMessage as T } from 'components'; import { Icon, Hint } from 'components'; import intl from 'react-intl-universal'; @@ -7,6 +7,7 @@ import { InputGroupCell, MoneyFieldCell, AccountsListFieldCell, + CheckBoxFieldCell, } from 'components/DataTableCells'; import { formattedAmount, safeSumBy } from 'utils'; @@ -49,6 +50,22 @@ const ActionsCellRenderer = ({ ); }; +/** + * Landed cost header cell. + */ +const LandedCostHeaderCell = () => { + return ( + <> + + + + ); +}; + /** * Amount footer cell. */ @@ -74,7 +91,7 @@ function ExpenseAccountFooterCell() { /** * Retrieve expense form table entries columns. */ -export function useExpenseFormTableColumns() { +export function useExpenseFormTableColumns({ landedCost }) { return React.useMemo( () => [ { @@ -114,6 +131,19 @@ export function useExpenseFormTableColumns() { className: 'description', width: 100, }, + ...(landedCost + ? [ + { + Header: LandedCostHeaderCell, + accessor: 'landed_cost', + Cell: CheckBoxFieldCell, + disableSortBy: true, + disableResizing: true, + width: 100, + className: 'landed-cost', + }, + ] + : []), { Header: '', accessor: 'action', diff --git a/client/src/containers/Expenses/ExpenseForm/utils.js b/client/src/containers/Expenses/ExpenseForm/utils.js index a2a045abd..f9d8392c3 100644 --- a/client/src/containers/Expenses/ExpenseForm/utils.js +++ b/client/src/containers/Expenses/ExpenseForm/utils.js @@ -1,7 +1,11 @@ import { AppToaster } from 'components'; import moment from 'moment'; import intl from 'react-intl-universal'; -import { transformToForm, repeatValue } from 'utils'; +import { + defaultFastFieldShouldUpdate, + transformToForm, + repeatValue, +} from 'utils'; const ERROR = { EXPENSE_ALREADY_PUBLISHED: 'EXPENSE.ALREADY.PUBLISHED', @@ -27,6 +31,7 @@ export const defaultExpenseEntry = { amount: '', expense_account_id: '', description: '', + landed_cost: false, }; export const defaultExpense = { @@ -61,3 +66,23 @@ export const transformToEditForm = ( ], }; }; + +/** + * Detarmine cusotmers fast-field should update. + */ +export const customersFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.customers !== oldProps.customers || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmine accounts fast-field should update. + */ +export const accountsFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.accounts !== oldProps.accounts || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; diff --git a/client/src/containers/FinancialStatements/APAgingSummary/APAgingSummary.js b/client/src/containers/FinancialStatements/APAgingSummary/APAgingSummary.js index 20b2dba0c..0f281a38d 100644 --- a/client/src/containers/FinancialStatements/APAgingSummary/APAgingSummary.js +++ b/client/src/containers/FinancialStatements/APAgingSummary/APAgingSummary.js @@ -30,6 +30,7 @@ function APAgingSummary({ asDate: moment().endOf('day').format('YYYY-MM-DD'), agingBeforeDays: 30, agingPeriods: 3, + vendorsIds: [], }); // Handle filter submit. @@ -63,7 +64,7 @@ function APAgingSummary({ - + { @@ -55,18 +60,17 @@ function APAgingSummaryHeader({ setSubmitting(false); }; - // handle cancel button click. - const handleCancelClick = () => { - toggleFilterDrawerDisplay(false); - }; + // Handle cancel button click. + const handleCancelClick = () => { toggleFilterDrawerDisplay(false); }; // Handle the drawer closing. - const handleDrawerClose = () => { - toggleFilterDrawerDisplay(false); - }; + const handleDrawerClose = () => { toggleFilterDrawerDisplay(false); }; return ( - + + - + {({ field, meta: { error } }) => ( } @@ -66,9 +67,10 @@ export default function APAgingSummaryHeaderGeneral() { + - + {({ field, meta: { error } }) => ( } @@ -81,17 +83,29 @@ export default function APAgingSummaryHeaderGeneral() { + - } - className={classNames('form-group--select-list', Classes.FILL)} - > - } - contacts={vendors} - /> - + + {({ + form: { setFieldValue }, + field: { value }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + } + contacts={vendors} + contactsSelected={value} + onContactSelect={(contactsIds) => { + setFieldValue('vendorsIds', contactsIds); + }} + /> + + )} +
diff --git a/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummary.js b/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummary.js index 3f069c2e8..0a112eb49 100644 --- a/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummary.js +++ b/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummary.js @@ -18,7 +18,7 @@ import withSettings from 'containers/Settings/withSettings'; import { compose } from 'utils'; /** - * AR aging summary report. + * A/R aging summary report. */ function ReceivableAgingSummarySheet({ // #withSettings @@ -31,6 +31,7 @@ function ReceivableAgingSummarySheet({ asDate: moment().endOf('day').format('YYYY-MM-DD'), agingDaysBefore: 30, agingPeriods: 3, + customersIds: [], }); // Handle filter submit. @@ -61,7 +62,7 @@ function ReceivableAgingSummarySheet({ - + { @@ -58,7 +67,7 @@ function ARAgingSummaryHeader({ const handleCancelClick = () => { toggleFilterDrawerDisplay(false); }; - + // Handle the drawer close. const handleDrawerClose = () => { toggleFilterDrawerDisplay(false); diff --git a/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderGeneral.js b/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderGeneral.js index f604b6693..a77bbdb71 100644 --- a/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderGeneral.js +++ b/client/src/containers/FinancialStatements/ARAgingSummary/ARAgingSummaryHeaderGeneral.js @@ -1,5 +1,5 @@ import React from 'react'; -import { FastField } from 'formik'; +import { FastField, Field } from 'formik'; import { DateInput } from '@blueprintjs/datetime'; import { Intent, @@ -93,14 +93,24 @@ export default function ARAgingSummaryHeaderGeneral() { - } - className={classNames('form-group--select-list', Classes.FILL)} - > - - + + {({ form: { setFieldValue }, field: { value }, meta: { error, touched } }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('customersIds', contactsIds); + }} + /> + + )} +
); -} \ No newline at end of file +} diff --git a/client/src/containers/FinancialStatements/BalanceSheet/BalanceSheetHeader.js b/client/src/containers/FinancialStatements/BalanceSheet/BalanceSheetHeader.js index 30474ddc4..066f5c66b 100644 --- a/client/src/containers/FinancialStatements/BalanceSheet/BalanceSheetHeader.js +++ b/client/src/containers/FinancialStatements/BalanceSheet/BalanceSheetHeader.js @@ -11,7 +11,7 @@ import FinancialStatementHeader from 'containers/FinancialStatements/FinancialSt import withBalanceSheet from './withBalanceSheet'; import withBalanceSheetActions from './withBalanceSheetActions'; -import { compose } from 'utils'; +import { compose, transformToForm } from 'utils'; import BalanceSheetHeaderGeneralPanal from './BalanceSheetHeaderGeneralPanal'; /** @@ -28,20 +28,25 @@ function BalanceSheetHeader({ // #withBalanceSheetActions toggleBalanceSheetFilterDrawer: toggleFilterDrawer, }) { - // Filter form initial values. - const initialValues = { - basis: 'cash', - ...pageFilter, - fromDate: moment(pageFilter.fromDate).toDate(), - toDate: moment(pageFilter.toDate).toDate(), + const defaultValues = { + basic: 'cash', + fromDate: moment().toDate(), + toDate: moment().toDate(), }; + // Filter form initial values. + const initialValues = transformToForm( + { + ...pageFilter, + fromDate: moment(pageFilter.fromDate).toDate(), + toDate: moment(pageFilter.toDate).toDate(), + }, + defaultValues, + ); // Validation schema. const validationSchema = Yup.object().shape({ dateRange: Yup.string().optional(), - fromDate: Yup.date() - .required() - .label(intl.get('fromDate')), + fromDate: Yup.date().required().label(intl.get('fromDate')), toDate: Yup.date() .min(Yup.ref('fromDate')) .required() @@ -58,14 +63,10 @@ function BalanceSheetHeader({ }; // Handle cancel button click. - const handleCancelClick = () => { - toggleFilterDrawer(false); - }; + const handleCancelClick = () => { toggleFilterDrawer(false); }; // Handle drawer close action. - const handleDrawerClose = () => { - toggleFilterDrawer(false); - }; + const handleDrawerClose = () => { toggleFilterDrawer(false); }; return ( @@ -48,6 +46,7 @@ export default function CustomersBalanceSummaryGeneralPanel() { + @@ -57,7 +56,7 @@ export default function CustomersBalanceSummaryGeneralPanel() { inline={true} name={'percentage'} small={true} - label={} + label={} {...field} /> @@ -65,6 +64,31 @@ export default function CustomersBalanceSummaryGeneralPanel() { + + + + + {({ + form: { setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('customersIds', contactsIds); + }} + contacts={customers} + contactsSelected={value} + /> + + )} + + + ); } diff --git a/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryHeader.js b/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryHeader.js index 64506fa20..02ed51335 100644 --- a/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryHeader.js +++ b/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryHeader.js @@ -4,14 +4,13 @@ import { Formik, Form } from 'formik'; import moment from 'moment'; import { Tabs, Tab, Button, Intent } from '@blueprintjs/core'; import { FormattedMessage as T } from 'components'; -import intl from 'react-intl-universal'; import FinancialStatementHeader from 'containers/FinancialStatements/FinancialStatementHeader'; import withCustomersBalanceSummary from './withCustomersBalanceSummary'; import withCustomersBalanceSummaryActions from './withCustomersBalanceSummaryActions'; import CustomersBalanceSummaryGeneralPanel from './CustomersBalanceSummaryGeneralPanel'; -import { compose } from 'utils'; +import { compose, transformToForm } from 'utils'; /** * Customers balance summary. @@ -33,11 +32,17 @@ function CustomersBalanceSummaryHeader({ asDate: Yup.date().required().label('asDate'), }); - // filter form initial values. - const initialValues = { + // Default form values. + const defaultValues = { + asDate: moment().toDate(), + customersIds: [], + }; + + // Filter form initial values. + const initialValues = transformToForm({ ...pageFilter, asDate: moment(pageFilter.asDate).toDate(), - }; + }, defaultValues); // handle form submit. const handleSubmit = (values, { setSubmitting }) => { diff --git a/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryProvider.js b/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryProvider.js index af091546f..3db7ce570 100644 --- a/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryProvider.js +++ b/client/src/containers/FinancialStatements/CustomersBalanceSummary/CustomersBalanceSummaryProvider.js @@ -1,6 +1,6 @@ import React, { createContext, useContext } from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useCustomerBalanceSummaryReport } from 'hooks/query'; +import { useCustomerBalanceSummaryReport, useCustomers } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const CustomersBalanceSummaryContext = createContext(); @@ -14,6 +14,7 @@ function CustomersBalanceSummaryProvider({ filter, ...props }) { filter, ]); + // Fetches customers balance summary report based on the given report. const { data: CustomerBalanceSummary, isLoading: isCustomersBalanceLoading, @@ -23,10 +24,22 @@ function CustomersBalanceSummaryProvider({ filter, ...props }) { keepPreviousData: true, }); + // Fetches the customers list. + const { + data: { customers }, + isFetching: isCustomersFetching, + isLoading: isCustomersLoading, + } = useCustomers(); + const provider = { CustomerBalanceSummary, isCustomersBalanceFetching, isCustomersBalanceLoading, + + isCustomersLoading, + isCustomersFetching, + customers, + refetch, }; return ( diff --git a/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsHeader.js b/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsHeader.js index 4f3e8e77b..166de3ee1 100644 --- a/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsHeader.js +++ b/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsHeader.js @@ -12,7 +12,7 @@ import CustomersTransactionsHeaderGeneralPanel from './CustomersTransactionsHead import withCustomersTransactions from './withCustomersTransactions'; import withCustomersTransactionsActions from './withCustomersTransactionsActions'; -import { compose } from 'utils'; +import { compose, transformToForm } from 'utils'; /** * Customers transactions header. @@ -28,14 +28,18 @@ function CustomersTransactionsHeader({ //#withCustomersTransactionsActions toggleCustomersTransactionsFilterDrawer: toggleFilterDrawer, }) { - - - // Filter form initial values. - const initialValues = { + // Default form values. + const defaultValues = { + fromDate: moment().toDate(), + toDate: moment().toDate(), + customersIds: [], + }; + // Initial form values. + const initialValues = transformToForm({ ...pageFilter, fromDate: moment(pageFilter.fromDate).toDate(), toDate: moment(pageFilter.toDate).toDate(), - }; + }, defaultValues); // Validation schema. const validationSchema = Yup.object().shape({ @@ -54,11 +58,8 @@ function CustomersTransactionsHeader({ toggleFilterDrawer(false); setSubmitting(false); }; - // Handle drawer close action. - const handleDrawerClose = () => { - toggleFilterDrawer(false); - }; + const handleDrawerClose = () => { toggleFilterDrawer(false); }; return ( + + + + + {({ + form: { setFieldValue }, + field: { value }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('customersIds', contactsIds); + }} + contacts={customers} + contactsSelected={value} + /> + + )} + + + ); } diff --git a/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsProvider.js b/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsProvider.js index 86c64787b..9e198e156 100644 --- a/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsProvider.js +++ b/client/src/containers/FinancialStatements/CustomersTransactions/CustomersTransactionsProvider.js @@ -1,6 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useCustomersTransactionsReport } from 'hooks/query'; +import { useCustomersTransactionsReport, useCustomers } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const CustomersTransactionsContext = createContext(); @@ -21,11 +21,23 @@ function CustomersTransactionsProvider({ filter, ...props }) { refetch: CustomersTransactionsRefetch, } = useCustomersTransactionsReport(query, { keepPreviousData: true }); + // Fetches the customers list. + const { + data: { customers }, + isFetching: isCustomersFetching, + isLoading: isCustomersLoading, + } = useCustomers(); + const provider = { customersTransactions, isCustomersTransactionsFetching, isCustomersTransactionsLoading, CustomersTransactionsRefetch, + + customers, + isCustomersLoading, + isCustomersFetching, + filter, query }; diff --git a/client/src/containers/FinancialStatements/GeneralLedger/GeneralLedgerHeader.js b/client/src/containers/FinancialStatements/GeneralLedger/GeneralLedgerHeader.js index 54545999f..80b2de834 100644 --- a/client/src/containers/FinancialStatements/GeneralLedger/GeneralLedgerHeader.js +++ b/client/src/containers/FinancialStatements/GeneralLedger/GeneralLedgerHeader.js @@ -11,7 +11,7 @@ import GeneralLedgerHeaderGeneralPane from './GeneralLedgerHeaderGeneralPane'; import withGeneralLedger from './withGeneralLedger'; import withGeneralLedgerActions from './withGeneralLedgerActions'; -import { compose, saveInvoke } from 'utils'; +import { compose, transformToForm, saveInvoke } from 'utils'; /** * Geenral Ledger (GL) - Header. @@ -27,13 +27,22 @@ function GeneralLedgerHeader({ // #withGeneralLedger isFilterDrawerOpen, }) { - // Initial values. - const initialValues = { - ...pageFilter, - fromDate: moment(pageFilter.fromDate).toDate(), - toDate: moment(pageFilter.toDate).toDate(), + // Default values. + const defaultValues = { + fromDate: moment().toDate(), + toDate: moment().toDate(), }; + // Initial values. + const initialValues = transformToForm( + { + ...pageFilter, + fromDate: moment(pageFilter.fromDate).toDate(), + toDate: moment(pageFilter.toDate).toDate(), + }, + defaultValues, + ); + // Validation schema. const validationSchema = Yup.object().shape({ dateRange: Yup.string().optional(), diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js index 21d8159c5..802cc4188 100644 --- a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js @@ -33,7 +33,7 @@ function InventoryItemDetails({ fromDate: moment().startOf('year').format('YYYY-MM-DD'), toDate: moment().endOf('year').format('YYYY-MM-DD'), }); - + // Handle filter submit. const handleFilterSubmit = (filter) => { const _filter = { ...filter, @@ -61,10 +61,14 @@ function InventoryItemDetails({ /> - + -
+
{ - toggleFilterDrawer(false); - }; + const handleDrawerClose = () => { toggleFilterDrawer(false); }; return ( + + + + + {({ + form: { setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('itemsIds', itemsIds); + }} + /> + + )} + + +
); } diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js index 746fce999..f46980dc1 100644 --- a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js @@ -1,6 +1,6 @@ import React from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useInventoryItemDetailsReport } from 'hooks/query'; +import { useItems, useInventoryItemDetailsReport } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const InventoryItemDetailsContext = React.createContext(); @@ -14,7 +14,7 @@ function InventoryItemDetailsProvider({ filter, ...props }) { [filter], ); - // fetch inventory item details. + // Fetching inventory item details report based on the givne query. const { data: inventoryItemDetails, isFetching: isInventoryItemDetailsFetching, @@ -22,11 +22,23 @@ function InventoryItemDetailsProvider({ filter, ...props }) { refetch: inventoryItemDetailsRefetch, } = useInventoryItemDetailsReport(query, { keepPreviousData: true }); + // Handle fetching the items based on the given query. + const { + data: { items }, + isLoading: isItemsLoading, + isFetching: isItemsFetching, + } = useItems({ page_size: 10000 }); + const provider = { inventoryItemDetails, isInventoryItemDetailsFetching, isInventoryItemDetailsLoading, inventoryItemDetailsRefetch, + + isItemsFetching, + isItemsLoading, + items, + query, filter, }; diff --git a/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuation.js b/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuation.js index 08352414c..cea4716b1 100644 --- a/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuation.js +++ b/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuation.js @@ -64,7 +64,7 @@ function InventoryValuation({ -
+
{ onSubmitFilter(values); toggleInventoryValuationFilterDrawer(false); diff --git a/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationHeaderGeneralPanel.js b/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationHeaderGeneralPanel.js index e330c8c24..cf3f0f918 100644 --- a/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationHeaderGeneralPanel.js +++ b/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationHeaderGeneralPanel.js @@ -1,20 +1,24 @@ import React from 'react'; -import { FastField } from 'formik'; +import { FastField, Field } from 'formik'; import { DateInput } from '@blueprintjs/datetime'; -import { FormGroup, Position } from '@blueprintjs/core'; +import { FormGroup, Position, Classes } from '@blueprintjs/core'; +import classNames from 'classnames'; import { FormattedMessage as T } from 'components'; -import { Row, Col, FieldHint } from 'components'; +import { ItemsMultiSelect, Row, Col, FieldHint } from 'components'; import { momentFormatter, tansformDateValue, inputIntent, handleDateChange, } from 'utils'; +import { useInventoryValuationContext } from './InventoryValuationProvider'; /** - * inventory valuation - Drawer Header - General panel. + * Inventory valuation - Drawer Header - General panel. */ export default function InventoryValuationHeaderGeneralPanel() { + const { items } = useInventoryValuationContext(); + return (
@@ -42,6 +46,31 @@ export default function InventoryValuationHeaderGeneralPanel() { + + + + + {({ + form: { setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('itemsIds', itemsIds); + }} + /> + + )} + + +
); } diff --git a/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationProvider.js b/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationProvider.js index d765db9b6..60f8006bb 100644 --- a/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationProvider.js +++ b/client/src/containers/FinancialStatements/InventoryValuation/InventoryValuationProvider.js @@ -1,6 +1,6 @@ import React from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useInventoryValuation } from 'hooks/query'; +import { useInventoryValuation, useItems } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const InventoryValuationContext = React.createContext(); @@ -20,11 +20,23 @@ function InventoryValuationProvider({ query, ...props }) { }, ); + // Handle fetching the items based on the given query. + const { + data: { items }, + isLoading: isItemsLoading, + isFetching: isItemsFetching, + } = useItems({ page_size: 10000 }); + + // Provider data. const provider = { inventoryValuation, isLoading, isFetching, refetchSheet: refetch, + + items, + isItemsFetching, + isItemsLoading }; return ( diff --git a/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItems.js b/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItems.js index b6d9546c4..f0afb8566 100644 --- a/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItems.js +++ b/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItems.js @@ -66,7 +66,7 @@ function PurchasesByItems({ /> -
+
+ + + + + {({ + form: { setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('itemsIds', itemsIds); + }} + /> + + )} + + +
); } diff --git a/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsHeader.js b/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsHeader.js index 9c8a28f93..6b15ac729 100644 --- a/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsHeader.js +++ b/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsHeader.js @@ -12,7 +12,7 @@ import PurchasesByItemsGeneralPanel from './PurchasesByItemsGeneralPanel'; import withPurchasesByItems from './withPurchasesByItems'; import withPurchasesByItemsActions from './withPurchasesByItemsActions'; -import { compose } from 'utils'; +import { compose, transformToForm } from 'utils'; /** * Purchases by items header. @@ -28,25 +28,31 @@ function PurchasesByItemsHeader({ // #withPurchasesByItems togglePurchasesByItemsFilterDrawer, }) { - - // Form validation schema. const validationSchema = Yup.object().shape({ - fromDate: Yup.date() - .required() - .label(intl.get('from_date')), + fromDate: Yup.date().required().label(intl.get('from_date')), toDate: Yup.date() .min(Yup.ref('fromDate')) .required() .label(intl.get('to_date')), }); - // Initial values. - const initialValues = { + // Default form values. + const defaultValues = { ...pageFilter, - fromDate: moment(pageFilter.fromDate).toDate(), - toDate: moment(pageFilter.toDate).toDate(), + fromDate: moment().toDate(), + toDate: moment().toDate(), + itemsIds: [], }; + // Initial form values. + const initialValues = transformToForm( + { + ...pageFilter, + fromDate: moment(pageFilter.fromDate).toDate(), + toDate: moment(pageFilter.toDate).toDate(), + }, + defaultValues, + ); // Handle form submit. const handleSubmit = (values, { setSubmitting }) => { diff --git a/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsProvider.js b/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsProvider.js index 9de8c6c47..b49757a96 100644 --- a/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsProvider.js +++ b/client/src/containers/FinancialStatements/PurchasesByItems/PurchasesByItemsProvider.js @@ -1,12 +1,13 @@ import React, { createContext, useContext } from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { usePurchasesByItems } from 'hooks/query'; +import { usePurchasesByItems, useItems } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const PurchasesByItemsContext = createContext(); function PurchasesByItemsProvider({ query, ...props }) { + // Handle fetching the purchases by items report based on the given query. const { data: purchaseByItems, isFetching, @@ -21,11 +22,23 @@ function PurchasesByItemsProvider({ query, ...props }) { }, ); + // Handle fetching the items based on the given query. + const { + data: { items }, + isLoading: isItemsLoading, + isFetching: isItemsFetching, + } = useItems({ page_size: 10000 }); + const provider = { purchaseByItems, isFetching, isLoading, - refetchSheet: refetch, + + items, + isItemsLoading, + isItemsFetching, + + refetchSheet: refetch, }; return ( diff --git a/client/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.js b/client/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.js index 86ce8b8a1..54bd0b174 100644 --- a/client/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.js +++ b/client/src/containers/FinancialStatements/SalesByItems/SalesByItemProvider.js @@ -1,6 +1,6 @@ import React, { createContext, useContext } from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useSalesByItems } from 'hooks/query'; +import { useSalesByItems, useItems } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const SalesByItemsContext = createContext(); @@ -20,10 +20,22 @@ function SalesByItemProvider({ query, ...props }) { }, ); + // Handle fetching the items based on the given query. + const { + data: { items }, + isLoading: isItemsLoading, + isFetching: isItemsFetching, + } = useItems({ page_size: 10000 }); + const provider = { salesByItems, isFetching, isLoading, + + items, + isItemsLoading, + isItemsFetching, + refetchSheet: refetch, }; return ( diff --git a/client/src/containers/FinancialStatements/SalesByItems/SalesByItems.js b/client/src/containers/FinancialStatements/SalesByItems/SalesByItems.js index 7fb334fc1..73c159396 100644 --- a/client/src/containers/FinancialStatements/SalesByItems/SalesByItems.js +++ b/client/src/containers/FinancialStatements/SalesByItems/SalesByItems.js @@ -68,7 +68,7 @@ function SalesByItems({ /> -
+
+ + + + + {({ + form: { setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('itemsIds', itemsIds); + }} + /> + + )} + + +
); } diff --git a/client/src/containers/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetHeader.js b/client/src/containers/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetHeader.js index de60c8718..77e993c85 100644 --- a/client/src/containers/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetHeader.js +++ b/client/src/containers/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetHeader.js @@ -12,7 +12,7 @@ import TrialBalanceSheetHeaderGeneralPanel from './TrialBalanceSheetHeaderGenera import withTrialBalance from './withTrialBalance'; import withTrialBalanceActions from './withTrialBalanceActions'; -import { compose } from 'utils'; +import { compose, transformToForm } from 'utils'; /** * Trial balance sheet header. @@ -28,42 +28,41 @@ function TrialBalanceSheetHeader({ // #withTrialBalanceActions toggleTrialBalanceFilterDrawer: toggleFilterDrawer, }) { - - // Form validation schema. const validationSchema = Yup.object().shape({ - fromDate: Yup.date() - .required() - .label(intl.get('from_date')), + fromDate: Yup.date().required().label(intl.get('from_date')), toDate: Yup.date() .min(Yup.ref('fromDate')) .required() .label(intl.get('to_date')), }); - // Initial values. - const initialValues = { - ...pageFilter, - fromDate: moment(pageFilter.fromDate).toDate(), - toDate: moment(pageFilter.toDate).toDate(), + // Default values. + const defaultValues = { + fromDate: moment().toDate(), + toDate: moment().toDate(), }; + // Initial values. + const initialValues = transformToForm( + { + ...pageFilter, + fromDate: moment(pageFilter.fromDate).toDate(), + toDate: moment(pageFilter.toDate).toDate(), + }, + defaultValues, + ); // Handle form submit. const handleSubmit = (values, { setSubmitting }) => { onSubmitFilter(values); setSubmitting(false); toggleFilterDrawer(false); }; - // Handle drawer close action. - const handleDrawerClose = () => { - toggleFilterDrawer(false); - }; + const handleDrawerClose = () => { toggleFilterDrawer(false); }; // Handle cancel button click. - const handleCancelClick = () => { - toggleFilterDrawer(false); - }; + const handleCancelClick = () => { toggleFilterDrawer(false); }; return ( { diff --git a/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryHeaderGeneral.js b/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryHeaderGeneral.js index 7ac062a36..e599fc02d 100644 --- a/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryHeaderGeneral.js +++ b/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryHeaderGeneral.js @@ -1,8 +1,9 @@ import React from 'react'; -import { FastField } from 'formik'; +import { Field, FastField } from 'formik'; import { DateInput } from '@blueprintjs/datetime'; +import classNames from 'classnames'; import { FormGroup, Position, Classes, Checkbox } from '@blueprintjs/core'; -import { FormattedMessage as T } from 'components'; +import { ContactsMultiSelect, FormattedMessage as T } from 'components'; import { Row, Col, FieldHint } from 'components'; import { momentFormatter, @@ -10,11 +11,14 @@ import { inputIntent, handleDateChange, } from 'utils'; +import { useVendorsBalanceSummaryContext } from './VendorsBalanceSummaryProvider'; /** * Vendors balance header -general panel. */ export default function VendorsBalanceSummaryHeaderGeneral() { + const { vendors } = useVendorsBalanceSummaryContext(); + return (
@@ -42,6 +46,7 @@ export default function VendorsBalanceSummaryHeaderGeneral() { + @@ -59,6 +64,31 @@ export default function VendorsBalanceSummaryHeaderGeneral() { + + + + + {({ + form: { setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('vendorsIds', contactsIds); + }} + contacts={vendors} + contactsSelected={value} + /> + + )} + + +
); } diff --git a/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryProvider.js b/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryProvider.js index 840b45268..db2847d64 100644 --- a/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryProvider.js +++ b/client/src/containers/FinancialStatements/VendorsBalanceSummary/VendorsBalanceSummaryProvider.js @@ -1,6 +1,6 @@ import React from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useVendorsBalanceSummaryReport } from 'hooks/query'; +import { useVendorsBalanceSummaryReport, useVendors } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const VendorsBalanceSummaryContext = React.createContext(); @@ -13,6 +13,7 @@ function VendorsBalanceSummaryProvider({ filter, ...props }) { filter, ]); + // Fetching vendors balance summary report based on the given query. const { data: VendorBalanceSummary, isLoading: isVendorsBalanceLoading, @@ -22,10 +23,23 @@ function VendorsBalanceSummaryProvider({ filter, ...props }) { keepPreviousData: true, }); + // Fetch vendors list with pagination meta. + const { + data: { vendors }, + isLoading: isVendorsLoading, + isFetching: isVendorsFetching, + } = useVendors({ page_size: 1000000 }); + + // Provider. const provider = { VendorBalanceSummary, isVendorsBalanceLoading, isVendorsBalanceFetching, + + vendors, + isVendorsFetching, + isVendorsLoading, + refetch, }; diff --git a/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsHeader.js b/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsHeader.js index babf5f7c8..d3e29f513 100644 --- a/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsHeader.js +++ b/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsHeader.js @@ -12,7 +12,7 @@ import VendorsTransactionsHeaderGeneralPanel from './VendorsTransactionsHeaderGe import withVendorsTransaction from './withVendorsTransaction'; import withVendorsTransactionsActions from './withVendorsTransactionsActions'; -import { compose } from 'utils'; +import { compose, transformToForm } from 'utils'; /** * Vendors transactions header. @@ -29,14 +29,19 @@ function VendorsTransactionsHeader({ //#withVendorsTransactionsActions toggleVendorsTransactionsFilterDrawer: toggleFilterDrawer, }) { - + // Default form values. + const defaultValues = { + fromDate: moment().toDate(), + toDate: moment().toDate(), + vendorsIds: [], + }; - // Filter form initial values. - const initialValues = { + // Initial form values. + const initialValues = transformToForm({ ...pageFilter, fromDate: moment(pageFilter.fromDate).toDate(), toDate: moment(pageFilter.toDate).toDate(), - }; + }, defaultValues); // Validation schema. const validationSchema = Yup.object().shape({ @@ -57,9 +62,7 @@ function VendorsTransactionsHeader({ }; // Handle drawer close action. - const handleDrawerClose = () => { - toggleFilterDrawer(false); - }; + const handleDrawerClose = () => { toggleFilterDrawer(false); }; return ( + + + + + {({ form: { setFieldValue }, field: { value } }) => ( + } + className={classNames('form-group--select-list', Classes.FILL)} + > + { + setFieldValue('vendorsIds', contactsIds); + }} + contacts={vendors} + contactsSelected={value} + /> + + )} + + +
); } diff --git a/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsProvider.js b/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsProvider.js index afbf44242..a15a40f23 100644 --- a/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsProvider.js +++ b/client/src/containers/FinancialStatements/VendorsTransactions/VendorsTransactionsProvider.js @@ -1,6 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import FinancialReportPage from '../FinancialReportPage'; -import { useVendorsTransactionsReport } from 'hooks/query'; +import { useVendorsTransactionsReport, useVendors } from 'hooks/query'; import { transformFilterFormToQuery } from '../common'; const VendorsTransactionsContext = createContext(); @@ -11,6 +11,7 @@ const VendorsTransactionsContext = createContext(); function VendorsTransactionsProvider({ filter, ...props }) { const query = useMemo(() => transformFilterFormToQuery(filter), [filter]); + // Fetch vendors transactions based on the given query. const { data: vendorsTransactions, isFetching: isVendorsTransactionFetching, @@ -18,10 +19,22 @@ function VendorsTransactionsProvider({ filter, ...props }) { refetch, } = useVendorsTransactionsReport(query, { keepPreviousData: true }); + // Fetch vendors list based on the given query. + const { + data: { vendors }, + isLoading: isVendorsLoading, + isFetching: isVendorsFetching, + } = useVendors({ page_size: 100000 }); + const provider = { vendorsTransactions, isVendorsTransactionsLoading, isVendorsTransactionFetching, + + vendors, + isVendorsLoading, + + isVendorsFetching, refetch, filter, query, diff --git a/client/src/containers/FinancialStatements/common.js b/client/src/containers/FinancialStatements/common.js index d9234a63d..fa5e760e9 100644 --- a/client/src/containers/FinancialStatements/common.js +++ b/client/src/containers/FinancialStatements/common.js @@ -82,5 +82,5 @@ export const transformFilterFormToQuery = (form) => { noneZero: form.accountsFilter === 'without-zero-balance', noneTransactions: form.accountsFilter === 'with-transactions', }); - return flatObject(transformed); + return transformed; }; diff --git a/client/src/containers/FinancialStatements/reducers.js b/client/src/containers/FinancialStatements/reducers.js index 7d6770764..6159722f1 100644 --- a/client/src/containers/FinancialStatements/reducers.js +++ b/client/src/containers/FinancialStatements/reducers.js @@ -113,7 +113,8 @@ export const profitLossSheetReducer = (profitLoss) => { } if (profitLoss.other_income) { results.push({ - name: 'other_income', + + name:, total: profitLoss.other_income.total, total_periods: profitLoss.other_income.total_periods, children: [ diff --git a/client/src/containers/Items/ItemFormBody.js b/client/src/containers/Items/ItemFormBody.js index 9123ebf4d..481e42db9 100644 --- a/client/src/containers/Items/ItemFormBody.js +++ b/client/src/containers/Items/ItemFormBody.js @@ -1,5 +1,5 @@ import React from 'react'; -import { FastField, Field, ErrorMessage } from 'formik'; +import { useFormikContext, FastField, Field, ErrorMessage } from 'formik'; import { FormGroup, Classes, @@ -23,12 +23,21 @@ import withSettings from 'containers/Settings/withSettings'; import { ACCOUNT_PARENT_TYPE } from 'common/accountTypes'; import { compose, inputIntent } from 'utils'; +import { + sellDescriptionFieldShouldUpdate, + sellAccountFieldShouldUpdate, + sellPriceFieldShouldUpdate, + costPriceFieldShouldUpdate, + costAccountFieldShouldUpdate, + purchaseDescFieldShouldUpdate, +} from './utils'; /** * Item form body. */ function ItemFormBody({ baseCurrency }) { const { accounts } = useItemFormContext(); + const { values } = useFormikContext(); return (
@@ -53,7 +62,11 @@ function ItemFormBody({ baseCurrency }) { {/*------------- Selling price ------------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -78,7 +91,12 @@ function ItemFormBody({ baseCurrency }) { {/*------------- Selling account ------------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -107,7 +125,11 @@ function ItemFormBody({ baseCurrency }) { )} - + {({ form: { values }, field, meta: { error, touched } }) => ( } @@ -146,7 +168,11 @@ function ItemFormBody({ baseCurrency }) { {/*------------- Cost price ------------- */} - + {({ field, form, field: { value }, meta: { error, touched } }) => ( } @@ -171,7 +197,12 @@ function ItemFormBody({ baseCurrency }) { {/*------------- Cost account ------------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -200,7 +231,11 @@ function ItemFormBody({ baseCurrency }) { )} - + {({ form: { values }, field, meta: { error, touched } }) => ( } diff --git a/client/src/containers/Items/ItemFormInventorySection.js b/client/src/containers/Items/ItemFormInventorySection.js index c4684fcec..d5a28d7e3 100644 --- a/client/src/containers/Items/ItemFormInventorySection.js +++ b/client/src/containers/Items/ItemFormInventorySection.js @@ -8,6 +8,7 @@ import classNames from 'classnames'; import withSettings from 'containers/Settings/withSettings'; +import { accountsFieldShouldUpdate } from './utils'; import { compose, inputIntent } from 'utils'; import { ACCOUNT_TYPE } from 'common/accountTypes'; import { useItemFormContext } from './ItemFormProvider'; @@ -27,7 +28,11 @@ function ItemFormInventorySection({ baseCurrency }) { {/*------------- Inventory account ------------- */} - + {({ form, field: { value }, meta: { touched, error } }) => ( } diff --git a/client/src/containers/Items/ItemFormPrimarySection.js b/client/src/containers/Items/ItemFormPrimarySection.js index db6ae61d8..118601781 100644 --- a/client/src/containers/Items/ItemFormPrimarySection.js +++ b/client/src/containers/Items/ItemFormPrimarySection.js @@ -21,6 +21,7 @@ import { CLASSES } from 'common/classes'; import { useItemFormContext } from './ItemFormProvider'; import { handleStringChange, inputIntent } from 'utils'; +import { categoriesFieldShouldUpdate } from './utils'; /** * Item form primary section. @@ -130,7 +131,11 @@ export default function ItemFormPrimarySection() { {/*----------- Item category ----------*/} - + {({ form, field: { value }, meta: { error, touched } }) => ( } diff --git a/client/src/containers/Items/utils.js b/client/src/containers/Items/utils.js index 1a5abe31d..406f54fa1 100644 --- a/client/src/containers/Items/utils.js +++ b/client/src/containers/Items/utils.js @@ -1,6 +1,7 @@ import intl from 'react-intl-universal'; import { Intent } from '@blueprintjs/core'; import { AppToaster } from 'components'; +import { defaultFastFieldShouldUpdate } from 'utils'; export const transitionItemTypeKeyToLabel = (itemTypeKey) => { const table = { @@ -28,7 +29,9 @@ export const handleDeleteErrors = (errors) => { ) ) { AppToaster.show({ - message: intl.get('you_could_not_delete_item_that_has_associated_inventory_adjustments_transacions'), + message: intl.get( + 'you_could_not_delete_item_that_has_associated_inventory_adjustments_transacions', + ), intent: Intent.DANGER, }); } @@ -38,8 +41,83 @@ export const handleDeleteErrors = (errors) => { ) ) { AppToaster.show({ - message: intl.get('cannot_change_item_type_to_inventory_with_item_has_associated_transactions'), + message: intl.get( + 'cannot_change_item_type_to_inventory_with_item_has_associated_transactions', + ), intent: Intent.DANGER, }); } }; + +/** + * Detarmines accounts fast field should update. + */ +export const accountsFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.accounts !== oldProps.accounts || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmines categories fast field should update. + */ +export const categoriesFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.categories !== oldProps.categories || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Sell price fast field should update. + */ +export const sellPriceFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.sellable !== oldProps.sellable || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Sell account fast field should update. + */ +export const sellAccountFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.accounts !== oldProps.accounts || + newProps.sellable !== oldProps.sellable || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Sell description fast field should update. + */ +export const sellDescriptionFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.sellable !== oldProps.sellable || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +export const costAccountFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.accounts !== oldProps.accounts || + newProps.purchasable !== oldProps.purchasable || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +export const costPriceFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.purchasable !== oldProps.purchasable || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +export const purchaseDescFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.purchasable !== oldProps.purchasable || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; diff --git a/client/src/containers/Preferences/General/GeneralForm.js b/client/src/containers/Preferences/General/GeneralForm.js index 06c164b2a..6446ecacb 100644 --- a/client/src/containers/Preferences/General/GeneralForm.js +++ b/client/src/containers/Preferences/General/GeneralForm.js @@ -22,15 +22,20 @@ import { handleDateChange, } from 'utils'; import { CLASSES } from 'common/classes'; -import countriesOptions from 'common/countries'; -import currencies from 'common/currencies'; -import { getFiscalYearOptions } from 'common/fiscalYearOptions'; -import languages from 'common/languagesOptions'; -import dateFormatsOptions from 'common/dateFormatsOptions'; +import { getCountries } from 'common/countries'; +import { getCurrencies } from 'common/currencies'; +import { getFiscalYear } from 'common/fiscalYearOptions'; +import { getLanguages } from 'common/languagesOptions'; +import { getDateFormats } from 'common/dateFormatsOptions'; export default function PreferencesGeneralForm({}) { const history = useHistory(); - const fiscalYearOptions = getFiscalYearOptions(); + + const FiscalYear = getFiscalYear(); + const Countries = getCountries(); + const Languages = getLanguages(); + const Currencies = getCurrencies(); + const DataFormats = getDateFormats(); const handleCloseClick = () => { history.go(-1); @@ -38,6 +43,7 @@ export default function PreferencesGeneralForm({}) { return (
+ {/* ---------- Organization name ---------- */} {({ field, meta: { error, touched } }) => ( + {/* ---------- Financial starting date ---------- */} {({ form, field: { value }, meta: { error, touched } }) => ( + {/* ---------- Location ---------- */} {({ form, field: { value }, meta: { error, touched } }) => ( { form.setFieldValue('location', value); }} @@ -116,6 +124,7 @@ export default function PreferencesGeneralForm({}) { )} + {/* ---------- Base currency ---------- */} {({ form, field: { value }, meta: { error, touched } }) => ( { form.setFieldValue('base_currency', currency.code); }} @@ -148,6 +157,7 @@ export default function PreferencesGeneralForm({}) { )} + {/* --------- Fiscal Year ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( form.setFieldValue('fiscal_year', value) } @@ -173,6 +183,7 @@ export default function PreferencesGeneralForm({}) { )} + {/* ---------- Language ---------- */} {({ form, field: { value }, meta: { error, touched } }) => ( } > } @@ -198,6 +209,7 @@ export default function PreferencesGeneralForm({}) { )} + {/* ---------- Time zone ---------- */} {({ form, field: { value }, meta: { error, touched } }) => ( + {/* --------- Data format ----------- */} {({ form, field: { value }, meta: { error, touched } }) => ( } > { form.setFieldValue('date_format', dateFormat.value); }} diff --git a/client/src/containers/Purchases/Bills/BillForm/BillForm.js b/client/src/containers/Purchases/Bills/BillForm/BillForm.js index 02f6103ca..11dc49733 100644 --- a/client/src/containers/Purchases/Bills/BillForm/BillForm.js +++ b/client/src/containers/Purchases/Bills/BillForm/BillForm.js @@ -48,7 +48,7 @@ function BillForm({ currency_code: baseCurrency, }), }), - [bill], + [bill, baseCurrency], ); // Transform response error to fields. diff --git a/client/src/containers/Purchases/Bills/BillForm/BillFormHeaderFields.js b/client/src/containers/Purchases/Bills/BillForm/BillFormHeaderFields.js index 8f5086849..dfa2a5d28 100644 --- a/client/src/containers/Purchases/Bills/BillForm/BillFormHeaderFields.js +++ b/client/src/containers/Purchases/Bills/BillForm/BillFormHeaderFields.js @@ -7,6 +7,7 @@ import classNames from 'classnames'; import { CLASSES } from 'common/classes'; import { ContactSelecetList, FieldRequiredHint, Icon } from 'components'; +import { vendorsFieldShouldUpdate } from './utils'; import { useBillFormContext } from './BillFormProvider'; import withDialogActions from 'containers/Dialog/withDialogActions'; @@ -28,7 +29,11 @@ function BillFormHeader() { return (
{/* ------- Vendor name ------ */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } diff --git a/client/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.js b/client/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.js index 25136485e..9ec38558d 100644 --- a/client/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.js +++ b/client/src/containers/Purchases/Bills/BillForm/BillItemsEntriesEditor.js @@ -4,13 +4,23 @@ import { FastField } from 'formik'; import { CLASSES } from 'common/classes'; import { useBillFormContext } from './BillFormProvider'; import ItemsEntriesTable from 'containers/Entries/ItemsEntriesTable'; +import { + entriesFieldShouldUpdate +} from './utils'; +/** + * Bill form body. + */ export default function BillFormBody({ defaultBill }) { const { items } = useBillFormContext(); return (
- + {({ form: { values, setFieldValue }, field: { value }, @@ -25,6 +35,7 @@ export default function BillFormBody({ defaultBill }) { errors={error} linesNumber={4} currencyCode={values.currency_code} + landedCost={true} /> )} diff --git a/client/src/containers/Purchases/Bills/BillForm/utils.js b/client/src/containers/Purchases/Bills/BillForm/utils.js index 47ebec4a2..9723b4f97 100644 --- a/client/src/containers/Purchases/Bills/BillForm/utils.js +++ b/client/src/containers/Purchases/Bills/BillForm/utils.js @@ -2,7 +2,11 @@ import moment from 'moment'; import intl from 'react-intl-universal'; import { Intent } from '@blueprintjs/core'; import { AppToaster } from 'components'; -import { transformToForm, repeatValue } from 'utils'; +import { + defaultFastFieldShouldUpdate, + transformToForm, + repeatValue, +} from 'utils'; export const MIN_LINES_NUMBER = 4; @@ -13,6 +17,7 @@ export const defaultBillEntry = { discount: '', quantity: '', description: '', + landed_cost: false, }; export const defaultBill = { @@ -51,4 +56,34 @@ export const handleDeleteErrors = (errors) => { intent: Intent.DANGER, }); } + if ( + errors.find((error) => error.type === 'BILL_HAS_ASSOCIATED_LANDED_COSTS') + ) { + AppToaster.show({ + message: intl.get( + 'cannot_delete_bill_that_has_associated_landed_cost_transactions', + ), + intent: Intent.DANGER, + }); + } +}; + +/** + * Detarmines vendors fast field should update + */ +export const vendorsFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.vendors !== oldProps.vendors || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmines entries fast field should update. + */ +export const entriesFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.items !== oldProps.items || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); }; diff --git a/client/src/containers/Purchases/Bills/BillsLanding/BillsTable.js b/client/src/containers/Purchases/Bills/BillsLanding/BillsTable.js index cbf514012..0bb5de39b 100644 --- a/client/src/containers/Purchases/Bills/BillsLanding/BillsTable.js +++ b/client/src/containers/Purchases/Bills/BillsLanding/BillsTable.js @@ -14,6 +14,7 @@ import withBillActions from './withBillsActions'; import withSettings from 'containers/Settings/withSettings'; import withAlertsActions from 'containers/Alert/withAlertActions'; import withDialogActions from 'containers/Dialog/withDialogActions'; +import withDrawerActions from 'containers/Drawer/withDrawerActions'; import { useBillsTableColumns, ActionsMenu } from './components'; import { useBillsListContext } from './BillsListProvider'; @@ -32,15 +33,13 @@ function BillsDataTable({ // #withDialogActions openDialog, + + // #withDrawerActions + openDrawer, }) { // Bills list context. - const { - bills, - pagination, - isBillsLoading, - isBillsFetching, - isEmptyStatus, - } = useBillsListContext(); + const { bills, pagination, isBillsLoading, isBillsFetching, isEmptyStatus } = + useBillsListContext(); const history = useHistory(); @@ -78,6 +77,16 @@ function BillsDataTable({ openDialog('quick-payment-made', { billId: id }); }; + // handle allocate landed cost. + const handleAllocateLandedCost = ({ id }) => { + openDialog('allocate-landed-cost', { billId: id }); + }; + + // Handle view detail bill. + const handleViewDetailBill = ({ id }) => { + openDrawer('bill-drawer', { billId: id }); + }; + if (isEmptyStatus) { return ; } @@ -105,6 +114,8 @@ function BillsDataTable({ onEdit: handleEditBill, onOpen: handleOpenBill, onQuick: handleQuickPaymentMade, + onAllocateLandedCost: handleAllocateLandedCost, + onViewDetails: handleViewDetailBill, }} /> ); @@ -114,6 +125,7 @@ export default compose( withBills(({ billsTableState }) => ({ billsTableState })), withBillActions, withAlertsActions, + withDrawerActions, withDialogActions, withSettings(({ organizationSettings }) => ({ baseCurrency: organizationSettings?.baseCurrency, diff --git a/client/src/containers/Purchases/Bills/BillsLanding/components.js b/client/src/containers/Purchases/Bills/BillsLanding/components.js index 34d299000..de7271f8d 100644 --- a/client/src/containers/Purchases/Bills/BillsLanding/components.js +++ b/client/src/containers/Purchases/Bills/BillsLanding/components.js @@ -20,7 +20,14 @@ import moment from 'moment'; * Actions menu. */ export function ActionsMenu({ - payload: { onEdit, onOpen, onDelete, onQuick }, + payload: { + onEdit, + onOpen, + onDelete, + onQuick, + onViewDetails, + onAllocateLandedCost, + }, row: { original }, }) { return ( @@ -28,6 +35,7 @@ export function ActionsMenu({ } text={intl.get('view_details')} + onClick={safeCallback(onViewDetails, original)} /> - + } + text={intl.get('allocate_landed_coast')} + onClick={safeCallback(onAllocateLandedCost, original)} + /> safeSumBy(entries, 'due_amount'), [ - entries, - ]); + const payableFullAmount = useMemo( + () => safeSumBy(entries, 'due_amount'), + [entries], + ); // Handle receive full-amount click. const handleReceiveFullAmountClick = () => { @@ -78,7 +76,11 @@ function PaymentMadeFormHeaderFields({ baseCurrency }) { return (
{/* ------------ Vendor name ------------ */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -157,7 +159,7 @@ function PaymentMadeFormHeaderFields({ baseCurrency }) { small={true} minimal={true} > - ( + ( ) @@ -184,7 +186,11 @@ function PaymentMadeFormHeaderFields({ baseCurrency }) { {/* ------------ Payment account ------------ */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } diff --git a/client/src/containers/Purchases/PaymentMades/PaymentForm/utils.js b/client/src/containers/Purchases/PaymentMades/PaymentForm/utils.js index 54aa39bca..0467c07c4 100644 --- a/client/src/containers/Purchases/PaymentMades/PaymentForm/utils.js +++ b/client/src/containers/Purchases/PaymentMades/PaymentForm/utils.js @@ -1,5 +1,9 @@ import moment from 'moment'; -import { safeSumBy, transformToForm } from 'utils'; +import { + defaultFastFieldShouldUpdate, + safeSumBy, + transformToForm, +} from 'utils'; export const ERRORS = { PAYMENT_NUMBER_NOT_UNIQUE: 'PAYMENT.NUMBER.NOT.UNIQUE', @@ -9,10 +13,10 @@ export const ERRORS = { export const defaultPaymentMadeEntry = { bill_id: '', payment_amount: '', - currency_code:'', + currency_code: '', id: null, due_amount: null, - amount:'' + amount: '', }; // Default initial values of payment made. @@ -48,7 +52,26 @@ export const transformToNewPageEntries = (entries) => { return entries.map((entry) => ({ ...transformToForm(entry, defaultPaymentMadeEntry), payment_amount: '', - currency_code:entry.currency_code, - + currency_code: entry.currency_code, })); -} \ No newline at end of file +}; + +/** + * Detarmines vendors fast field when update. + */ +export const vendorsFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.vendors !== oldProps.vendors || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmines accounts fast field when update. + */ +export const accountsFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.accounts !== oldProps.accounts || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; diff --git a/client/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.js b/client/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.js index 91da74c0a..9402d5d75 100644 --- a/client/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.js +++ b/client/src/containers/Sales/Estimates/EstimateForm/EstimateFormHeader.js @@ -27,6 +27,7 @@ function EstimateFormHeader({ return (
+ {/* ----------- Customer name ----------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -170,7 +175,9 @@ function EstimateFormHeader({ }} tooltip={true} tooltipProps={{ - content: , + content: ( + + ), position: Position.BOTTOM_LEFT, }} /> diff --git a/client/src/containers/Sales/Estimates/EstimateForm/EstimateItemsEntriesField.js b/client/src/containers/Sales/Estimates/EstimateForm/EstimateItemsEntriesField.js index 82125ccf2..e549f9342 100644 --- a/client/src/containers/Sales/Estimates/EstimateForm/EstimateItemsEntriesField.js +++ b/client/src/containers/Sales/Estimates/EstimateForm/EstimateItemsEntriesField.js @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { CLASSES } from 'common/classes'; import ItemsEntriesTable from 'containers/Entries/ItemsEntriesTable'; import { useEstimateFormContext } from './EstimateFormProvider'; +import { entriesFieldShouldUpdate } from './utils'; /** * Estimate form items entries editor. @@ -13,7 +14,11 @@ export default function EstimateFormItemsEntriesField() { return (
- + {({ form: { values, setFieldValue }, field: { value }, diff --git a/client/src/containers/Sales/Estimates/EstimateForm/utils.js b/client/src/containers/Sales/Estimates/EstimateForm/utils.js index 42f820356..eba82d104 100644 --- a/client/src/containers/Sales/Estimates/EstimateForm/utils.js +++ b/client/src/containers/Sales/Estimates/EstimateForm/utils.js @@ -1,7 +1,12 @@ import React from 'react'; import { useFormikContext } from 'formik'; import moment from 'moment'; -import { transactionNumber, repeatValue, transformToForm } from 'utils'; +import { + defaultFastFieldShouldUpdate, + transactionNumber, + repeatValue, + transformToForm, +} from 'utils'; export const MIN_LINES_NUMBER = 4; @@ -49,4 +54,24 @@ export const useObserveEstimateNoSettings = (prefix, nextNumber) => { const estimateNo = transactionNumber(prefix, nextNumber); setFieldValue('estimate_number', estimateNo); }, [setFieldValue, prefix, nextNumber]); -} \ No newline at end of file +}; + +/** + * Detarmines customers fast field when update. + */ +export const customersFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.customers !== oldProps.customers || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmines entries fast field should update. + */ +export const entriesFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.items !== oldProps.items || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; diff --git a/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.js b/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.js index c28ccb1e6..96594a06c 100644 --- a/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.js +++ b/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeaderFields.js @@ -10,7 +10,10 @@ import { FastField, Field, ErrorMessage } from 'formik'; import { FormattedMessage as T } from 'components'; import { momentFormatter, compose, tansformDateValue } from 'utils'; import classNames from 'classnames'; -import { useObserveInvoiceNoSettings } from './utils'; +import { + useObserveInvoiceNoSettings, + customerNameFieldShouldUpdate, +} from './utils'; import { CLASSES } from 'common/classes'; import { ContactSelecetList, @@ -58,15 +61,16 @@ function InvoiceFormHeaderFields({ }; // Syncs invoice number settings with form. - useObserveInvoiceNoSettings( - invoiceNumberPrefix, - invoiceNextNumber, - ); + useObserveInvoiceNoSettings(invoiceNumberPrefix, invoiceNextNumber); return (
{/* ----------- Customer name ----------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -168,7 +172,9 @@ function InvoiceFormHeaderFields({ }} tooltip={true} tooltipProps={{ - content: , + content: ( + + ), position: Position.BOTTOM_LEFT, }} /> diff --git a/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceItemsEntriesEditorField.js b/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceItemsEntriesEditorField.js index 4adf2e44e..afd87d7dc 100644 --- a/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceItemsEntriesEditorField.js +++ b/client/src/containers/Sales/Invoices/InvoiceForm/InvoiceItemsEntriesEditorField.js @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { CLASSES } from 'common/classes'; import ItemsEntriesTable from 'containers/Entries/ItemsEntriesTable'; import { useInvoiceFormContext } from './InvoiceFormProvider'; +import { entriesFieldShouldUpdate } from './utils'; /** * Invoice items entries editor field. @@ -13,7 +14,11 @@ export default function InvoiceItemsEntriesEditorField() { return (
- + {({ form: { values, setFieldValue }, field: { value }, diff --git a/client/src/containers/Sales/Invoices/InvoiceForm/utils.js b/client/src/containers/Sales/Invoices/InvoiceForm/utils.js index 6dde8e128..70787c7fa 100644 --- a/client/src/containers/Sales/Invoices/InvoiceForm/utils.js +++ b/client/src/containers/Sales/Invoices/InvoiceForm/utils.js @@ -11,7 +11,7 @@ import { updateItemsEntriesTotal } from 'containers/Entries/utils'; import { useFormikContext } from 'formik'; import { Intent } from '@blueprintjs/core'; -import { orderingLinesIndexes } from 'utils'; +import { defaultFastFieldShouldUpdate } from 'utils'; import intl from 'react-intl-universal'; import { ERROR } from 'common/errors'; import { AppToaster } from 'components'; @@ -100,4 +100,18 @@ export const useObserveInvoiceNoSettings = (prefix, nextNumber) => { const invoiceNo = transactionNumber(prefix, nextNumber); setFieldValue('invoice_no', invoiceNo); }, [setFieldValue, prefix, nextNumber]); -}; \ No newline at end of file +}; + +export const customerNameFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.customers !== oldProps.customers || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +export const entriesFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.items !== oldProps.items || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; diff --git a/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveHeaderFields.js b/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveHeaderFields.js index 5fa0f1e11..1bccd3bca 100644 --- a/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveHeaderFields.js +++ b/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/PaymentReceiveHeaderFields.js @@ -34,6 +34,7 @@ import { } from 'components'; import { usePaymentReceiveFormContext } from './PaymentReceiveFormProvider'; import { ACCOUNT_TYPE } from 'common/accountTypes'; + import withDialogActions from 'containers/Dialog/withDialogActions'; import withSettings from 'containers/Settings/withSettings'; @@ -41,6 +42,8 @@ import { useObservePaymentNoSettings, amountPaymentEntries, fullAmountPaymentEntries, + customersFieldShouldUpdate, + accountsFieldShouldUpdate, } from './utils'; import { toSafeInteger } from 'lodash'; @@ -115,7 +118,11 @@ function PaymentReceiveHeaderFields({ return (
{/* ------------- Customer name ------------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -247,7 +254,11 @@ function PaymentReceiveHeaderFields({ {/* ------------ Deposit account ------------ */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } diff --git a/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/utils.js b/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/utils.js index 5513b6939..8309b2fc0 100644 --- a/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/utils.js +++ b/client/src/containers/Sales/PaymentReceives/PaymentReceiveForm/utils.js @@ -1,7 +1,12 @@ import React from 'react'; import { useFormikContext } from 'formik'; import moment from 'moment'; -import { transactionNumber, transformToForm, safeSumBy } from 'utils'; +import { + defaultFastFieldShouldUpdate, + transactionNumber, + transformToForm, + safeSumBy, +} from 'utils'; // Default payment receive entry. export const defaultPaymentReceiveEntry = { @@ -99,3 +104,23 @@ export const useObservePaymentNoSettings = (prefix, nextNumber) => { setFieldValue('payment_receive_no', invoiceNo); }, [setFieldValue, prefix, nextNumber]); }; + +/** + * Detarmines the customers fast-field should update. + */ +export const customersFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.customers !== oldProps.customers || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmines the accounts fast-field should update. + */ +export const accountsFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.accounts !== oldProps.accounts || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; diff --git a/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.js b/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.js index 7db0129d6..d14478e3e 100644 --- a/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.js +++ b/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptFormHeaderFields.js @@ -28,7 +28,11 @@ import { inputIntent, } from 'utils'; import { useReceiptFormContext } from './ReceiptFormProvider'; -import { useObserveReceiptNoSettings } from './utils'; +import { + accountsFieldShouldUpdate, + customersFieldShouldUpdate, + useObserveReceiptNoSettings, +} from './utils'; /** * Receipt form header fields. @@ -70,7 +74,11 @@ function ReceiptFormHeader({ return (
{/* ----------- Customer name ----------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } @@ -94,7 +102,11 @@ function ReceiptFormHeader({ {/* ----------- Deposit account ----------- */} - + {({ form, field: { value }, meta: { error, touched } }) => ( } diff --git a/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptItemsEntriesEditor.js b/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptItemsEntriesEditor.js index 581b7f4b0..b678bc7ba 100644 --- a/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptItemsEntriesEditor.js +++ b/client/src/containers/Sales/Receipts/ReceiptForm/ReceiptItemsEntriesEditor.js @@ -4,13 +4,14 @@ import { FastField } from 'formik'; import ItemsEntriesTable from 'containers/Entries/ItemsEntriesTable'; import { CLASSES } from 'common/classes'; import { useReceiptFormContext } from './ReceiptFormProvider'; +import { entriesFieldShouldUpdate } from './utils'; export default function ReceiptItemsEntriesEditor({ defaultReceipt }) { const { items } = useReceiptFormContext(); return (
- + {({ form: { values, setFieldValue }, field: { value }, diff --git a/client/src/containers/Sales/Receipts/ReceiptForm/utils.js b/client/src/containers/Sales/Receipts/ReceiptForm/utils.js index 431500601..93cbb75cd 100644 --- a/client/src/containers/Sales/Receipts/ReceiptForm/utils.js +++ b/client/src/containers/Sales/Receipts/ReceiptForm/utils.js @@ -1,7 +1,12 @@ import React from 'react'; import { useFormikContext } from 'formik'; import moment from 'moment'; -import { transactionNumber, repeatValue, transformToForm } from 'utils'; +import { + defaultFastFieldShouldUpdate, + transactionNumber, + repeatValue, + transformToForm, +} from 'utils'; export const MIN_LINES_NUMBER = 4; @@ -42,7 +47,6 @@ export const transformToEditForm = (receipt) => ({ ], }); - export const useObserveReceiptNoSettings = (prefix, nextNumber) => { const { setFieldValue } = useFormikContext(); @@ -50,4 +54,34 @@ export const useObserveReceiptNoSettings = (prefix, nextNumber) => { const receiptNo = transactionNumber(prefix, nextNumber); setFieldValue('receipt_number', receiptNo); }, [setFieldValue, prefix, nextNumber]); -} \ No newline at end of file +}; + +/** + * Detarmines entries fast field should update. + */ +export const entriesFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.items !== oldProps.items || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmines accounts fast field should update. + */ +export const accountsFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.accounts !== oldProps.accounts || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; + +/** + * Detarmines customers fast field should update. + */ +export const customersFieldShouldUpdate = (newProps, oldProps) => { + return ( + newProps.customers !== oldProps.customers || + defaultFastFieldShouldUpdate(newProps, oldProps) + ); +}; diff --git a/client/src/containers/Setup/SetupLeftSection.js b/client/src/containers/Setup/SetupLeftSection.js index 3e39b9932..9b82e3744 100644 --- a/client/src/containers/Setup/SetupLeftSection.js +++ b/client/src/containers/Setup/SetupLeftSection.js @@ -8,7 +8,9 @@ import { useAuthActions, useAuthOrganizationId } from 'hooks/state'; function FooterLinkItem({ title, link }) { return ( ); } @@ -32,7 +34,12 @@ export default function SetupLeftSection() {
- +

@@ -46,17 +53,22 @@ export default function SetupLeftSection() {
- : { organizationId }, + :{' '} + {organizationId},
- + + +
-
-

{'+21892-791-8381'}

+
+

+ {'+21892-738-1987'} +

@@ -65,5 +77,5 @@ export default function SetupLeftSection() {

- ) -} \ No newline at end of file + ); +} diff --git a/client/src/containers/Setup/SetupOrganizationForm.js b/client/src/containers/Setup/SetupOrganizationForm.js index 4fa0a6a46..4630cd9d2 100644 --- a/client/src/containers/Setup/SetupOrganizationForm.js +++ b/client/src/containers/Setup/SetupOrganizationForm.js @@ -22,16 +22,19 @@ import { handleDateChange } from 'utils'; -import { getFiscalYearOptions } from 'common/fiscalYearOptions'; -import languages from 'common/languagesOptions'; -import currencies from 'common/currencies'; +import { getFiscalYear } from 'common/fiscalYearOptions'; +import { getLanguages } from 'common/languagesOptions'; +import { getCurrencies } from 'common/currencies'; + /** * Setup organization form. */ export default function SetupOrganizationForm({ isSubmitting, values }) { - const fiscalYearOptions = getFiscalYearOptions(); + const FiscalYear = getFiscalYear(); + const Languages = getLanguages(); + const Currencies = getCurrencies(); return ( @@ -97,7 +100,7 @@ export default function SetupOrganizationForm({ isSubmitting, values }) { helperText={} > } />} popoverProps={{ minimal: true }} onItemSelect={(item) => { @@ -132,7 +135,7 @@ export default function SetupOrganizationForm({ isSubmitting, values }) { helperText={} > } />} onItemSelect={(item) => { setFieldValue('language', item.value); @@ -164,7 +167,7 @@ export default function SetupOrganizationForm({ isSubmitting, values }) { helperText={} > } />} selectedItem={value} selectedItemProp={'value'} diff --git a/client/src/hooks/query/index.js b/client/src/hooks/query/index.js index 1ce3eef49..cb9f8301c 100644 --- a/client/src/hooks/query/index.js +++ b/client/src/hooks/query/index.js @@ -23,3 +23,4 @@ export * from './exchangeRates'; export * from './contacts'; export * from './subscriptions'; export * from './organization'; +export * from './landedCost'; diff --git a/client/src/hooks/query/landedCost.js b/client/src/hooks/query/landedCost.js new file mode 100644 index 000000000..6249a6061 --- /dev/null +++ b/client/src/hooks/query/landedCost.js @@ -0,0 +1,90 @@ +import { useQueryClient, useMutation } from 'react-query'; +import useApiRequest from '../useRequest'; +import { useRequestQuery } from '../useQueryRequest'; + +import t from './types'; + +const commonInvalidateQueries = (queryClient) => { + // Invalidate bills. + queryClient.invalidateQueries(t.BILLS); + queryClient.invalidateQueries(t.BILL); + // Invalidate landed cost. + queryClient.invalidateQueries(t.LANDED_COST); + queryClient.invalidateQueries(t.LANDED_COST_TRANSACTION); +}; + +/** + * Creates a new landed cost. + */ +export function useCreateLandedCost(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => + apiRequest.post(`purchases/landed-cost/bills/${id}/allocate`, values), + { + onSuccess: (res, id) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +/** + * Deletes the given landed cost. + */ +export function useDeleteLandedCost(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + return useMutation( + (landedCostId) => + apiRequest.delete(`purchases/landed-cost/${landedCostId}`), + { + onSuccess: (res, id) => { + // Common invalidate queries. + commonInvalidateQueries(queryClient); + }, + ...props, + }, + ); +} + +/** + * Retrieve the landed cost transactions. + */ +export function useLandedCostTransaction(query, props) { + return useRequestQuery( + [t.LANDED_COST, query], + { + method: 'get', + url: 'purchases/landed-cost/transactions', + params: { transaction_type: query }, + }, + { + select: (res) => res.data, + + defaultData: { + transactions: [], + }, + ...props, + }, + ); +} + +/** + * Retrieve the bill located landed cost transactions. + */ +export function useBillLocatedLandedCost(id, props) { + return useRequestQuery( + [t.LANDED_COST_TRANSACTION, id], + { method: 'get', url: `purchases/landed-cost/bills/${id}/transactions` }, + { + select: (res) => res.data.transactions, + defaultData: {}, + ...props, + }, + ); +} diff --git a/client/src/hooks/query/types.js b/client/src/hooks/query/types.js index c2bfb67d4..886c07dd3 100644 --- a/client/src/hooks/query/types.js +++ b/client/src/hooks/query/types.js @@ -22,7 +22,7 @@ const FINANCIAL_REPORTS = { PURCHASES_BY_ITEMS: 'PURCHASES_BY_ITEMS', INVENTORY_VALUATION: 'INVENTORY_VALUATION', CASH_FLOW_STATEMENT: 'CASH_FLOW_STATEMENT', - INVENTORY_ITEM_DETAILS:'INVENTORY_ITEM_DETAILS' + INVENTORY_ITEM_DETAILS: 'INVENTORY_ITEM_DETAILS', }; const BILLS = { @@ -117,6 +117,13 @@ const MANUAL_JOURNALS = { MANUAL_JOURNALS: 'MANUAL_JOURNALS', MANUAL_JOURNAL: 'MANUAL_JOURNAL', }; + +const LANDED_COSTS = { + LANDED_COST: 'LANDED_COST', + LANDED_COSTS: 'LANDED_COSTS', + LANDED_COST_TRANSACTION: 'LANDED_COST_TRANSACTION', +}; + export default { ...ACCOUNTS, ...BILLS, @@ -137,4 +144,5 @@ export default { ...SUBSCRIPTIONS, ...EXPENSES, ...MANUAL_JOURNALS, + ...LANDED_COSTS, }; diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js index 9b072d26c..3341432e6 100644 --- a/client/src/lang/en/index.js +++ b/client/src/lang/en/index.js @@ -1068,5 +1068,9 @@ export default { statement_of_cash_flow: 'Statement of Cash Flow ', inventory_item_details: 'Inventory Item Details', congratulations: 'Congratulations', +<<<<<<< HEAD +======= + "all_items" +>>>>>>> feature/landed-cost }; diff --git a/client/src/lang/en/index.json b/client/src/lang/en/index.json index 2369d498e..5e68a7f89 100644 --- a/client/src/lang/en/index.json +++ b/client/src/lang/en/index.json @@ -1087,7 +1087,7 @@ "mm_dd_yy_": "MM-DD-YY", "dd_mm_yy_": "DD-MM-YY", "yy_mm_dd_": "YY-MM-DD", - "plan_radio_name":"{name}", + "plan_radio_name": "{name}", "customers_payments": "Customers Payments", "receiving_customer_payments_is_one_pleasant_accounting_tasks": "Receiving payments is one of your more pleasant accounting tasks. The payments transactions will appear once receive payments to invoices.", "estimate_is_used_to_create_bid_proposal_or_quote": "An estimate is used to create a bid, proposal, or quote. The estimate can later be turned into a sales order or an invoice.", @@ -1135,5 +1135,36 @@ "Plans & Payment": "Plans & Payment", "Initializing": "Initializing", "Getting started": "Getting started", - "Congratulations": "Congratulations" + "Congratulations": "Congratulations", + "payment_has_been_done_successfully": "Payment has been done successfully.", + "manual_journal_number": "Manual journal {number}", + "conditions_and_terms": "Conditions and terms", + "allocate_landed_coast": "Allocate landed cost", + "transaction_date": "Transaction date", + "transaction_type": "Transaction type", + "transaction_id": "Transaction #", + "transaction_number": "Transaction number", + "transaction_line": "Transaction line", + "allocation_method": "Allocation method", + "valuation": "Valuation", + "select_transaction": "Select transaction account", + "details": "Details", + "located_landed_cost": "Located Landed Cost", + "delete_transaction": "Delete transaction", + "all_items": "All items", + "Specific items": "Specific items", + "Selected contacts": "Selected contacts", + "All contacts": "All contacts", + "Selected items ({count})": "Selected items ({count})", + "All items": "All items", + "No items": "No items", + "cannot_delete_bill_that_has_associated_landed_cost_transactions": "Cannot delete bill that has associated landed cost transactions.", + "couldn_t_delete_expense_transaction_has_associated_located_landed_cost_transaction": "Couldn't delete expense transaction has associated located landed cost transaction", + "the_landed_cost_has_been_created_successfully": "The landed cost has been created successfully", + "Select transaction": "Select transaction", + "Select transaction entry": "Select transaction entry", + "From transaction": "From transaction", + "Landed": "Landed", + "This options allows you to be able to add additional cost eg. freight then allocate cost to the items in your bills.": "This options allows you to be able to add additional cost eg. freight then allocate cost to the items in your bills.", + "Once your delete this located landed cost, you won't be able to restore it later, Are your sure you want to delete this transaction?": "Once your delete this located landed cost, you won't be able to restore it later, Are your sure you want to delete this transaction?" } \ No newline at end of file diff --git a/client/src/store/plans/plans.reducer.js b/client/src/store/plans/plans.reducer.js index e0d9bbf3b..766399b7f 100644 --- a/client/src/store/plans/plans.reducer.js +++ b/client/src/store/plans/plans.reducer.js @@ -1,3 +1,4 @@ +import React from 'react'; import { createReducer } from '@reduxjs/toolkit'; import intl from 'react-intl-universal'; import t from 'store/types'; diff --git a/client/src/style/components/DataTable/DataTableEditable.scss b/client/src/style/components/DataTable/DataTableEditable.scss index e10c4f78f..703afb089 100644 --- a/client/src/style/components/DataTable/DataTableEditable.scss +++ b/client/src/style/components/DataTable/DataTableEditable.scss @@ -10,7 +10,7 @@ .th, .td { - border-left: 1px dashed #e2e2e2; + border-left: 1px solid #e2e2e2; &.index { text-align: center; @@ -55,6 +55,19 @@ margin-bottom: auto; } } + + &.landed-cost{ + + .bp3-control{ + margin-top: 0; + margin-left: 34px; + } + .bp3-control-indicator{ + height: 18px; + width: 18px; + border-color: #e0e0e0; + } + } } .tr { .bp3-form-group:not(.bp3-intent-danger) .bp3-input, diff --git a/client/src/style/components/DataTable/Pagination.scss b/client/src/style/components/DataTable/Pagination.scss index 52514fbfc..3e98c9534 100644 --- a/client/src/style/components/DataTable/Pagination.scss +++ b/client/src/style/components/DataTable/Pagination.scss @@ -1,7 +1,7 @@ .pagination{ display: flex; - padding: 28px 14px; + padding: 20px 14px; font-size: 13px; .bp3-button{ diff --git a/client/src/style/components/Details.scss b/client/src/style/components/Details.scss new file mode 100644 index 000000000..d9a2134a1 --- /dev/null +++ b/client/src/style/components/Details.scss @@ -0,0 +1,21 @@ +.details-menu { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + + &.is-vertical {} + + .detail-item { + + + &__label { + color: #666666; + font-weight: 500; + } + + &__content { + text-transform: capitalize; + margin: 5px 0; + } + } +} \ No newline at end of file diff --git a/client/src/style/components/Drawer.scss b/client/src/style/components/Drawer.scss new file mode 100644 index 000000000..8d4fc9f4c --- /dev/null +++ b/client/src/style/components/Drawer.scss @@ -0,0 +1,17 @@ +.bp3-drawer { + + + .bp3-drawer-header { + margin-bottom: 2px; + background-color: #FFF; + + .bp3-heading { + font-weight: 500; + } + + .bp3-heading, + .bp3-icon { + color: #354152; + } + } +} \ No newline at end of file diff --git a/client/src/style/components/Drawer/AccountDrawer.scss b/client/src/style/components/Drawers/AccountDrawer.scss similarity index 72% rename from client/src/style/components/Drawer/AccountDrawer.scss rename to client/src/style/components/Drawers/AccountDrawer.scss index 318c66a44..2a90ded17 100644 --- a/client/src/style/components/Drawer/AccountDrawer.scss +++ b/client/src/style/components/Drawers/AccountDrawer.scss @@ -1,14 +1,4 @@ -.bp3-drawer-header { - box-shadow: 0 0 0; - .bp3-heading{ - font-size: 16px; - } - .bp3-button{ - min-height: 28px; - min-width: 28px; - } -} .account-drawer { background-color: #fbfbfb; @@ -94,29 +84,4 @@ } } } -} - -.bp3-drawer.bp3-position-right { - bottom: 0; - right: 0; - top: 0; - overflow: auto; - height: 100%; - - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - - .bp3-drawer-header { - margin-bottom: 2px; - box-shadow: (0, 0, 0); - background-color: #6a7993; - - .bp3-heading, - .bp3-icon { - color: white; - } - } } \ No newline at end of file diff --git a/client/src/style/components/Drawers/BillDrawer.scss b/client/src/style/components/Drawers/BillDrawer.scss new file mode 100644 index 000000000..3179bda99 --- /dev/null +++ b/client/src/style/components/Drawers/BillDrawer.scss @@ -0,0 +1,65 @@ +@import '../../Base.scss'; + +.bill-drawer { + .bp3-tabs { + .bp3-tab-list { + position: relative; + background-color: #FFF; + + &:before { + content: ''; + position: absolute; + bottom: 0; + width: 100%; + height: 2px; + background: #e1e2e8; + } + + > *:not(:last-child) { + margin-right: 25px; + } + + &.bp3-large > .bp3-tab { + font-size: 15px; + color: #555; + margin: 0 0.8rem; + + &[aria-selected='true'], + &:not([aria-disabled='true']):hover { + color: $pt-link-color; + } + } + } + + .bp3-tab-panel{ + margin-top: 0; + + .card{ + margin: 15px; + } + } + } + + .datatable--landed-cost-transactions { + .table { + + .tbody, + .tbody-inner { + height: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + .tbody { + .tr .td { + padding: 0.6rem; + + &.amount{ + font-weight: 600; + } + } + } + } + } +} \ No newline at end of file diff --git a/client/src/style/components/Drawer/DrawerTemplate.scss b/client/src/style/components/Drawers/DrawerTemplate.scss similarity index 99% rename from client/src/style/components/Drawer/DrawerTemplate.scss rename to client/src/style/components/Drawers/DrawerTemplate.scss index 4ad14b775..60e63bc8c 100644 --- a/client/src/style/components/Drawer/DrawerTemplate.scss +++ b/client/src/style/components/Drawers/DrawerTemplate.scss @@ -122,6 +122,7 @@ top: 0; overflow: auto; height: 100%; + .bp3-drawer-header .bp3-heading { overflow: hidden; text-overflow: ellipsis; diff --git a/client/src/style/components/Drawer/ViewDetails.scss b/client/src/style/components/Drawers/ViewDetails.scss similarity index 77% rename from client/src/style/components/Drawer/ViewDetails.scss rename to client/src/style/components/Drawers/ViewDetails.scss index 2a71ec9d8..f0d982727 100644 --- a/client/src/style/components/Drawer/ViewDetails.scss +++ b/client/src/style/components/Drawers/ViewDetails.scss @@ -1,6 +1,5 @@ .journal-drawer, .expense-drawer { - background: #f5f5f5; &__content { display: flex; @@ -18,8 +17,8 @@ justify-content: flex-start; margin: 15px 0 20px; font-size: 14px; - // color: #333333; color: #666666; + > div { flex-grow: 1; span { @@ -44,17 +43,17 @@ &--table { flex-grow: 1; flex-shrink: 0; + .table { color: #666666; font-size: 14px; - .thead .tr .th .resizer { - display: none; - } + .thead .th { + background: transparent; color: #222222; border-bottom: 1px solid #000000; + padding: 0.5rem; } - .thead .th, .tbody .tr .td { background: transparent; padding: 0.8rem 0.5rem; @@ -63,7 +62,6 @@ .desc { margin: 20px 0 60px; - // margin: 20px 0; > b { color: #2f2f2f; } @@ -93,25 +91,3 @@ } } } - -.bp3-drawer.bp3-position-right { - bottom: 0; - right: 0; - top: 0; - overflow: auto; - height: 100%; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } - - .bp3-drawer-header { - margin-bottom: 2px; - box-shadow: (0, 0, 0); - background-color: #6a7993; - .bp3-heading, - .bp3-icon { - color: white; - } - } -} diff --git a/client/src/style/pages/AllocateLandedCost/AllocateLandedCostForm.scss b/client/src/style/pages/AllocateLandedCost/AllocateLandedCostForm.scss new file mode 100644 index 000000000..1040746e2 --- /dev/null +++ b/client/src/style/pages/AllocateLandedCost/AllocateLandedCostForm.scss @@ -0,0 +1,63 @@ +// Allocate Landed Cost Form. +.dialog--allocate-landed-cost-form { + width: 700px; + + .bp3-dialog-body { + .bp3-form-group{ + margin-bottom: 18px; + } + .bp3-form-group.bp3-inline { + .bp3-label { + min-width: 150px; + } + .bp3-form-content { + width: 300px; + } + + &:not(.dialog--loading) .bp3-dialog-body { + margin-bottom: 30px; + } + } + } + + .bp3-dialog-footer{ + padding-top: 10px; + } + + .bigcapital-datatable { + .table { + // max-height: 300px; + border: 1px solid #d1dee2; + min-width: auto; + + .tbody, + .tbody-inner { + height: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + .tbody { + .tr .td { + padding: 0.4rem; + margin-left: -1px; + border-left: 1px solid #ececec; + } + + .bp3-form-group{ + margin-bottom: 0; + + &:not(.bp3-intent-danger) .bp3-input{ + border: 1px solid #d0dfe2; + + &:focus{ + box-shadow: 0 0 0 1px #116cd0; + border-color: #116cd0; + } + } + } + } + } + } +} diff --git a/client/src/style/pages/Bills/List.scss b/client/src/style/pages/Bills/List.scss index 4fa38a7a9..ced4ceec0 100644 --- a/client/src/style/pages/Bills/List.scss +++ b/client/src/style/pages/Bills/List.scss @@ -2,7 +2,7 @@ .bigcapital-datatable { .tbody { .tr { - min-height: 50px; + min-height: 46px; } .td.amount { .cell-inner { diff --git a/client/src/style/pages/Bills/PageForm.scss b/client/src/style/pages/Bills/PageForm.scss index edecac102..df8a23c28 100644 --- a/client/src/style/pages/Bills/PageForm.scss +++ b/client/src/style/pages/Bills/PageForm.scss @@ -11,7 +11,6 @@ body.page-bill-edit{ padding-bottom: 64px; } - .page-form--bill{ $self: '.page-form'; @@ -36,7 +35,7 @@ body.page-bill-edit{ max-width: 440px; } - &.form-group{ + &.form-group{ &--expiration-date{ max-width: 340px; diff --git a/client/src/style/pages/Customers/List.scss b/client/src/style/pages/Customers/List.scss index b271d1a3a..a12f9e3b4 100644 --- a/client/src/style/pages/Customers/List.scss +++ b/client/src/style/pages/Customers/List.scss @@ -5,8 +5,8 @@ .bigcapital-datatable{ .tr .td{ - padding-top: 0.6rem; - padding-bottom: 0.6rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; } .avatar.td{ diff --git a/client/src/style/pages/Expense/List.scss b/client/src/style/pages/Expense/List.scss index eb9900e9d..e6471c90b 100644 --- a/client/src/style/pages/Expense/List.scss +++ b/client/src/style/pages/Expense/List.scss @@ -7,7 +7,7 @@ .tbody { .tr{ - min-height: 50px; + min-height: 46px; } .td.amount { span { diff --git a/client/src/style/pages/Expense/PageForm.scss b/client/src/style/pages/Expense/PageForm.scss index 6a93637e5..102a3819e 100644 --- a/client/src/style/pages/Expense/PageForm.scss +++ b/client/src/style/pages/Expense/PageForm.scss @@ -1,11 +1,10 @@ +.dashboard__insider--expenses { -.dashboard__insider--expenses{ + .bigcapital-datatable { - .bigcapital-datatable{ - - .tbody{ - .tr .td.total_amount{ - span{ + .tbody { + .tr .td.total_amount { + span { font-weight: 600; } } @@ -13,36 +12,64 @@ } } -.page-form--expense{ +.page-form--expense { $self: '.page-form'; - #{$self}__header{ + #{$self}__header { display: flex; - &-fields{ + &-fields { flex: 1 0 0; } - .bp3-label{ + .bp3-label { min-width: 140px; } - .bp3-form-content{ + + .bp3-form-content { width: 100%; } - .bp3-form-group{ + .bp3-form-group { margin-bottom: 18px; - &.bp3-inline{ - max-width: 440px; + &.bp3-inline { + max-width: 440px; } } } - .form-group--description{ + .datatable-editor--expense-form { + + + .table { + + .tbody { + .tr .td { + + + &.landed-cost { + + .bp3-control { + margin-top: 0; + margin-left: 34px; + } + + .bp3-control-indicator { + height: 18px; + width: 18px; + border-color: #e0e0e0; + } + } + } + } + } + } + + .form-group--description { max-width: 500px; - textarea{ + textarea { min-height: 60px; width: 100%; } diff --git a/client/src/style/pages/FinancialStatements/ARAgingSummary.scss b/client/src/style/pages/FinancialStatements/ARAgingSummary.scss index d663e5f8a..2bb2d6f5f 100644 --- a/client/src/style/pages/FinancialStatements/ARAgingSummary.scss +++ b/client/src/style/pages/FinancialStatements/ARAgingSummary.scss @@ -36,4 +36,13 @@ } } } +} + +.financial-statement--AR-aging-summary{ + + .financial-header-drawer{ + .bp3-drawer{ + max-height: 450px; + } + } } \ No newline at end of file diff --git a/client/src/style/pages/FinancialStatements/SalesAndPurchasesSheet.scss b/client/src/style/pages/FinancialStatements/SalesAndPurchasesSheet.scss index 48f9656b6..f3a0976a1 100644 --- a/client/src/style/pages/FinancialStatements/SalesAndPurchasesSheet.scss +++ b/client/src/style/pages/FinancialStatements/SalesAndPurchasesSheet.scss @@ -27,3 +27,23 @@ } } } + +.financial-statement--sales-by-items, +.financial-statement--purchases-by-items{ + + .financial-header-drawer{ + .bp3-drawer{ + max-height: 400px; + } + } +} + + +.financial-statement--inventory-valuation{ + + .financial-header-drawer{ + .bp3-drawer{ + max-height: 350px; + } + } +} \ No newline at end of file diff --git a/client/src/style/pages/InventoryAdjustments/List.scss b/client/src/style/pages/InventoryAdjustments/List.scss index d904555ef..87fb6158d 100644 --- a/client/src/style/pages/InventoryAdjustments/List.scss +++ b/client/src/style/pages/InventoryAdjustments/List.scss @@ -7,7 +7,7 @@ .table { .tbody { .tr{ - min-height: 50px; + min-height: 46px; } } } diff --git a/client/src/style/pages/Items/List.scss b/client/src/style/pages/Items/List.scss index e0fcf371a..f6da8d84e 100644 --- a/client/src/style/pages/Items/List.scss +++ b/client/src/style/pages/Items/List.scss @@ -7,7 +7,7 @@ .table { .tbody { .tr{ - min-height: 50px; + min-height: 46px; } .item_type.td { .bp3-tag { diff --git a/client/src/style/pages/ItemsCategories/List.scss b/client/src/style/pages/ItemsCategories/List.scss index a925a3f21..c6f4859b4 100644 --- a/client/src/style/pages/ItemsCategories/List.scss +++ b/client/src/style/pages/ItemsCategories/List.scss @@ -6,7 +6,7 @@ .table { .tbody { .tr{ - min-height: 50px; + min-height: 46px; } } } diff --git a/client/src/style/pages/PaymentMade/List.scss b/client/src/style/pages/PaymentMade/List.scss index aab384b28..e3e41df3c 100644 --- a/client/src/style/pages/PaymentMade/List.scss +++ b/client/src/style/pages/PaymentMade/List.scss @@ -5,7 +5,7 @@ .tbody{ .tr{ - min-height: 50px; + min-height: 46px; } .td.amount { diff --git a/client/src/style/pages/PaymentReceive/List.scss b/client/src/style/pages/PaymentReceive/List.scss index 491f47e0f..89d1bf4b3 100644 --- a/client/src/style/pages/PaymentReceive/List.scss +++ b/client/src/style/pages/PaymentReceive/List.scss @@ -5,7 +5,7 @@ .tbody{ .tr .td{ - min-height: 50px; + min-height: 46px; } .td.amount { diff --git a/client/src/style/pages/SaleEstimate/List.scss b/client/src/style/pages/SaleEstimate/List.scss index 5706c00b7..02e7d738b 100644 --- a/client/src/style/pages/SaleEstimate/List.scss +++ b/client/src/style/pages/SaleEstimate/List.scss @@ -4,6 +4,9 @@ .bigcapital-datatable{ .tbody{ + .tr{ + min-height: 46px; + } .tr .td{ padding-top: 0.88rem; diff --git a/client/src/style/pages/SaleInvoice/List.scss b/client/src/style/pages/SaleInvoice/List.scss index 2f3e85bff..24e706b2f 100644 --- a/client/src/style/pages/SaleInvoice/List.scss +++ b/client/src/style/pages/SaleInvoice/List.scss @@ -8,7 +8,7 @@ .tbody{ .tr{ - min-height: 50px; + min-height: 46px; } .balance.td{ diff --git a/client/src/style/pages/SaleReceipt/List.scss b/client/src/style/pages/SaleReceipt/List.scss index 06e8073f5..b246a5a9f 100644 --- a/client/src/style/pages/SaleReceipt/List.scss +++ b/client/src/style/pages/SaleReceipt/List.scss @@ -4,9 +4,8 @@ .bigcapital-datatable{ .tbody{ - - .tr .td{ - min-height: 50px; + .tr{ + min-height: 46px; } .td.amount { diff --git a/client/src/style/pages/Setup/SetupPage.scss b/client/src/style/pages/Setup/SetupPage.scss index 1a1b3531d..c0c6471f0 100644 --- a/client/src/style/pages/Setup/SetupPage.scss +++ b/client/src/style/pages/Setup/SetupPage.scss @@ -101,6 +101,9 @@ opacity: 0.75; padding-bottom: 5px; border-bottom: 1px solid rgba(255, 255, 255, 0.15); + p > span { + unicode-bidi: plaintext; + } } &__links { diff --git a/client/src/style/pages/Vendors/List.scss b/client/src/style/pages/Vendors/List.scss index 97764e88a..ef592ef98 100644 --- a/client/src/style/pages/Vendors/List.scss +++ b/client/src/style/pages/Vendors/List.scss @@ -4,8 +4,8 @@ tbody { .tr .td { - padding-top: 0.6rem; - padding-bottom: 0.6rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; } } diff --git a/client/src/style/pages/fonts.scss b/client/src/style/pages/fonts.scss index 76e0d759f..8f5bb71b8 100644 --- a/client/src/style/pages/fonts.scss +++ b/client/src/style/pages/fonts.scss @@ -1,3 +1,7 @@ + + +// Noto Sans +// ------------------------------------- @font-face { font-family: Noto Sans; src: local('Noto Sans'), url('../fonts/NotoSans-SemiBold.woff') format('woff'); @@ -30,46 +34,8 @@ font-display: swap; } -// arabic regular -@font-face { - font-family: Noto Sans Arabic; - src: local('Noto Sans'), - url('../fonts/NotoSansArabicUI-SemiCondensed.woff') format('woff'); - font-style: normal; - font-weight: 400; - font-display: swap; -} - -// arabic black -@font-face { - font-family: Noto Sans Arabic; - src: local('Noto Sans'), - url('../fonts/NotoSansArabicUI-SemiCondensedBlack.woff') format('woff'); - font-style: normal; - font-weight: 900; - font-display: swap; -} - -//arabic Medium -@font-face { - font-family: Noto Sans Arabic; - src: local('Noto Sans'), - url('../fonts/NotoSansArabicUI-SemiCondensedMedium.woff') format('woff'); - font-style: normal; - font-weight: 500; - font-display: swap; -} - -//arabic SemiBold -@font-face { - font-family: Noto Sans Arabic; - src: local('Noto Sans'), - url('../fonts/NotoSansArabicUI-SemiCondensedSemiBold.woff') format('woff'); - font-style: normal; - font-weight: 600; - font-display: swap; -} - +// Segoe UI Arabic +// ------------------------------------- // Segoe UI Arabic - Regular @font-face { font-family: 'Segoe UI'; diff --git a/client/src/style/variables.scss b/client/src/style/variables.scss index 63c0400ee..02561ebe9 100644 --- a/client/src/style/variables.scss +++ b/client/src/style/variables.scss @@ -16,7 +16,7 @@ $menu-item-color-active: $light-gray3; $breadcrumbs-collapsed-icon: url("data:image/svg+xml,"); $sidebar-zindex: 15; -$pt-font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, +$pt-font-family: 'Noto Sans', -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, Icons16, sans-serif; diff --git a/client/src/utils.js b/client/src/utils.js index e08b226e9..32dadaaec 100644 --- a/client/src/utils.js +++ b/client/src/utils.js @@ -90,7 +90,9 @@ export const objectKeysTransform = (obj, transform) => { export const compose = (...funcs) => funcs.reduce( - (a, b) => (...args) => a(b(...args)), + (a, b) => + (...args) => + a(b(...args)), (arg) => arg, ); @@ -297,6 +299,10 @@ export const saveInvoke = (func, ...rest) => { return func && func(...rest); }; +export const safeInvoke = (func, ...rest) => { + return func && func(...rest); +}; + export const transformToForm = (obj, emptyInitialValues) => { return _.pickBy( obj, @@ -432,11 +438,10 @@ export function flatObject(obj) { const path = []; // current path function dig(obj) { - if (obj !== Object(obj)) - /*is primitive, end of path*/ - return (flatObject[path.join('.')] = obj); /*<- value*/ + if (obj !== Object(obj)) { + return (flatObject[path.join('.')] = obj); + } - //no? so this is an object with keys. go deeper on each key down for (let key in obj) { path.push(key); dig(obj[key]); @@ -636,7 +641,32 @@ const getCurrenciesOptions = () => { currency_code: currencyCode, formatted_name: `${currencyCode} - ${currency.name}`, }; - }) + }); +}; + +export const currenciesOptions = getCurrenciesOptions(); + +/** + * Deeply get a value from an object via its path. + */ +function getIn(obj, key, def, p = 0) { + const path = _.toPath(key); + while (obj && p < path.length) { + obj = obj[path[p++]]; + } + return obj === undefined ? def : obj; } -export const currenciesOptions = getCurrenciesOptions(); \ No newline at end of file +export const defaultFastFieldShouldUpdate = (props, prevProps) => { + return ( + props.name !== prevProps.name || + getIn(props.formik.values, prevProps.name) !== + getIn(prevProps.formik.values, prevProps.name) || + getIn(props.formik.errors, prevProps.name) !== + getIn(prevProps.formik.errors, prevProps.name) || + getIn(props.formik.touched, prevProps.name) !== + getIn(prevProps.formik.touched, prevProps.name) || + Object.keys(prevProps).length !== Object.keys(props).length || + props.formik.isSubmitting !== prevProps.formik.isSubmitting + ); +}; diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index c30dca97e..b6010d706 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -39,7 +39,7 @@ export default class ExpensesController extends BaseController { ); router.post( '/:id', - [...this.expenseDTOSchema, ...this.expenseParamSchema], + [...this.editExpenseDTOSchema, ...this.expenseParamSchema], this.validationResult, asyncMiddleware(this.editExpense.bind(this)), this.catchServiceErrors @@ -111,16 +111,67 @@ export default class ExpensesController extends BaseController { .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), + check('categories.*.landed_cost').optional().isBoolean().toBoolean(), ]; } /** - * Expense param schema. + * Edit expense validation schema. + */ + get editExpenseDTOSchema() { + return [ + check('reference_no') + .optional({ nullable: true }) + .trim() + .escape() + .isLength({ max: DATATYPES_LENGTH.STRING }), + check('payment_date').exists().isISO8601(), + check('payment_account_id') + .exists() + .isInt({ max: DATATYPES_LENGTH.INT_10 }) + .toInt(), + check('description') + .optional({ nullable: true }) + .isString() + .isLength({ max: DATATYPES_LENGTH.TEXT }), + check('currency_code').optional().isString().isLength({ max: 3 }), + check('exchange_rate').optional({ nullable: true }).isNumeric().toFloat(), + check('publish').optional().isBoolean().toBoolean(), + check('payee_id').optional({ nullable: true }).isNumeric().toInt(), + + check('categories').exists().isArray({ min: 1 }), + check('categories.*.id').optional().isNumeric().toInt(), + check('categories.*.index') + .exists() + .isInt({ max: DATATYPES_LENGTH.INT_10 }) + .toInt(), + check('categories.*.expense_account_id') + .exists() + .isInt({ max: DATATYPES_LENGTH.INT_10 }) + .toInt(), + check('categories.*.amount') + .optional({ nullable: true }) + .isFloat({ max: DATATYPES_LENGTH.DECIMAL_13_3 }) // 13, 3 + .toFloat(), + check('categories.*.description') + .optional() + .trim() + .escape() + .isLength({ max: DATATYPES_LENGTH.STRING }), + check('categories.*.landed_cost').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Expense param validation schema. */ get expenseParamSchema() { return [param('id').exists().isNumeric().toInt()]; } - + + /** + * Expenses list validation schema. + */ get expensesListSchema() { return [ query('custom_view_id').optional().isNumeric().toInt(), @@ -251,11 +302,8 @@ export default class ExpensesController extends BaseController { } try { - const { - expenses, - pagination, - filterMeta, - } = await this.expensesService.getExpensesList(tenantId, filter); + const { expenses, pagination, filterMeta } = + await this.expensesService.getExpensesList(tenantId, filter); return res.status(200).send({ expenses, @@ -293,7 +341,7 @@ export default class ExpensesController extends BaseController { * @param {Response} res * @param {ServiceError} error */ - catchServiceErrors( + private catchServiceErrors( error: Error, req: Request, res: Response, @@ -345,6 +393,30 @@ export default class ExpensesController extends BaseController { errors: [{ type: 'CONTACT_NOT_FOUND', code: 800 }], }); } + if (error.errorType === 'EXPENSE_HAS_ASSOCIATED_LANDED_COST') { + return res.status(400).send({ + errors: [{ type: 'EXPENSE_HAS_ASSOCIATED_LANDED_COST', code: 900 }], + }); + } + if (error.errorType === 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED') { + return res.status(400).send({ + errors: [ + { type: 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED', code: 1000 }, + ], + }); + } + if ( + error.errorType === 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES' + ) { + return res.status(400).send({ + errors: [ + { + type: 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES', + code: 1100, + }, + ], + }); + } } next(error); } diff --git a/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts b/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts index 87235b609..aa0943833 100644 --- a/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts +++ b/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts @@ -71,7 +71,6 @@ export default class CashFlowController extends BaseFinancialReportController { /** * Transformes the report statement to table rows. * @param {ITransactionsByVendorsStatement} statement - - * */ private transformToTableRows(cashFlowDOO: ICashFlowStatementDOO, tenantId: number) { const i18n = this.tenancy.i18n(tenantId); diff --git a/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts b/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts index a0e2e4c2c..99140c25a 100644 --- a/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts +++ b/server/src/api/controllers/FinancialStatements/CustomerBalanceSummary/index.ts @@ -23,6 +23,7 @@ export default class CustomerBalanceSummaryReportController extends BaseFinancia router.get( '/', this.validationSchema, + this.validationResult, asyncMiddleware(this.customerBalanceSummary.bind(this)) ); return router; @@ -34,7 +35,13 @@ export default class CustomerBalanceSummaryReportController extends BaseFinancia get validationSchema() { return [ ...this.sheetNumberFormatValidationSchema, + + // As date. query('as_date').optional().isISO8601(), + + // Customers ids. + query('customers_ids').optional().isArray({ min: 1 }), + query('customers_ids.*').exists().isInt().toInt(), ]; } diff --git a/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts b/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts index d40d9e585..78138c317 100644 --- a/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts +++ b/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts @@ -53,8 +53,12 @@ export default class InventoryDetailsController extends BaseController { .escape(), query('from_date').optional(), query('to_date').optional(), + query('none_zero').optional().isBoolean().toBoolean(), query('none_transactions').optional().isBoolean().toBoolean(), + + query('items_ids').optional().isArray(), + query('items_ids.*').optional().isInt().toInt(), ]; } diff --git a/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts b/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts index 0353870b3..266cc4e82 100644 --- a/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts +++ b/server/src/api/controllers/FinancialStatements/InventoryValuationSheet.ts @@ -32,6 +32,10 @@ export default class InventoryValuationReportController extends BaseFinancialRep return [ query('from_date').optional().isISO8601(), query('to_date').optional().isISO8601(), + + query('items_ids').optional().isArray(), + query('items_ids.*').optional().isInt().toInt(), + query('number_format.no_cents').optional().isBoolean().toBoolean(), query('number_format.divide_1000').optional().isBoolean().toBoolean(), query('none_transactions').default(true).isBoolean().toBoolean(), diff --git a/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts b/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts index 835e6412f..7a5063dcf 100644 --- a/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts +++ b/server/src/api/controllers/FinancialStatements/PurchasesByItem.ts @@ -28,14 +28,20 @@ export default class PurchasesByItemReportController extends BaseFinancialReport /** * Validation schema. + * @return {ValidationChain[]} */ get validationSchema(): ValidationChain[] { return [ query('from_date').optional().isISO8601(), query('to_date').optional().isISO8601(), + query('number_format.no_cents').optional().isBoolean().toBoolean(), query('number_format.divide_1000').optional().isBoolean().toBoolean(), query('none_transactions').default(true).isBoolean().toBoolean(), + + query('items_ids').optional().isArray(), + query('items_ids.*').optional().isInt().toInt(), + query('orderBy').optional().isIn(['created_at', 'name', 'code']), query('order').optional().isIn(['desc', 'asc']), ]; diff --git a/server/src/api/controllers/FinancialStatements/SalesByItems.ts b/server/src/api/controllers/FinancialStatements/SalesByItems.ts index 7b9fb4602..5987d9418 100644 --- a/server/src/api/controllers/FinancialStatements/SalesByItems.ts +++ b/server/src/api/controllers/FinancialStatements/SalesByItems.ts @@ -33,6 +33,10 @@ export default class SalesByItemsReportController extends BaseFinancialReportCon return [ query('from_date').optional().isISO8601(), query('to_date').optional().isISO8601(), + + query('items_ids').optional().isArray(), + query('items_ids.*').optional().isInt().toInt(), + query('number_format.no_cents').optional().isBoolean().toBoolean(), query('number_format.divide_1000').optional().isBoolean().toBoolean(), query('none_transactions').default(true).isBoolean().toBoolean(), diff --git a/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts b/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts index 30ed4d41e..a484963f3 100644 --- a/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts +++ b/server/src/api/controllers/FinancialStatements/TransactionsByCustomers/index.ts @@ -23,6 +23,7 @@ export default class TransactionsByCustomersReportController extends BaseFinanci router.get( '/', this.validationSchema, + this.validationResult, asyncMiddleware(this.transactionsByCustomers.bind(this)) ); return router; @@ -31,13 +32,18 @@ export default class TransactionsByCustomersReportController extends BaseFinanci /** * Validation schema. */ - get validationSchema() { + private get validationSchema() { return [ ...this.sheetNumberFormatValidationSchema, query('from_date').optional().isISO8601(), query('to_date').optional().isISO8601(), + query('none_zero').optional().isBoolean().toBoolean(), query('none_transactions').optional().isBoolean().toBoolean(), + + // Customers ids. + query('customers_ids').optional().isArray({ min: 1 }), + query('customers_ids.*').exists().isInt().toInt(), ]; } @@ -45,7 +51,9 @@ export default class TransactionsByCustomersReportController extends BaseFinanci * Transformes the statement to table rows response. * @param {ITransactionsByCustomersStatement} statement - */ - transformToTableResponse({ data }: ITransactionsByCustomersStatement) { + private transformToTableResponse({ + data, + }: ITransactionsByCustomersStatement) { return { table: { rows: this.transactionsByCustomersTableRows.tableRows(data), @@ -57,7 +65,7 @@ export default class TransactionsByCustomersReportController extends BaseFinanci * Transformes the statement to json response. * @param {ITransactionsByCustomersStatement} statement - */ - transfromToJsonResponse({ + private transfromToJsonResponse({ data, columns, }: ITransactionsByCustomersStatement) { @@ -83,10 +91,11 @@ export default class TransactionsByCustomersReportController extends BaseFinanci const filter = this.matchedQueryData(req); try { - const transactionsByCustomers = await this.transactionsByCustomersService.transactionsByCustomers( - tenantId, - filter - ); + const transactionsByCustomers = + await this.transactionsByCustomersService.transactionsByCustomers( + tenantId, + filter + ); const accept = this.accepts(req); const acceptType = accept.types(['json', 'application/json+table']); diff --git a/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts b/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts index 2f24c2259..cf2240543 100644 --- a/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts +++ b/server/src/api/controllers/FinancialStatements/TransactionsByVendors/index.ts @@ -23,6 +23,7 @@ export default class TransactionsByVendorsReportController extends BaseFinancial router.get( '/', this.validationSchema, + this.validationResult, asyncMiddleware(this.transactionsByVendors.bind(this)) ); return router; @@ -34,10 +35,16 @@ export default class TransactionsByVendorsReportController extends BaseFinancial get validationSchema(): ValidationChain[] { return [ ...this.sheetNumberFormatValidationSchema, + query('from_date').optional().isISO8601(), query('to_date').optional().isISO8601(), + query('none_zero').optional().isBoolean().toBoolean(), query('none_transactions').optional().isBoolean().toBoolean(), + + // Vendors ids. + query('vendors_ids').optional().isArray({ min: 1 }), + query('vendors_ids.*').exists().isInt().toInt(), ]; } @@ -80,10 +87,11 @@ export default class TransactionsByVendorsReportController extends BaseFinancial const filter = this.matchedQueryData(req); try { - const transactionsByVendors = await this.transactionsByVendorsService.transactionsByVendors( - tenantId, - filter - ); + const transactionsByVendors = + await this.transactionsByVendorsService.transactionsByVendors( + tenantId, + filter + ); const accept = this.accepts(req); const acceptType = accept.types(['json', 'application/json+table']); diff --git a/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts b/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts index c94f77319..41b2e88c1 100644 --- a/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts +++ b/server/src/api/controllers/FinancialStatements/VendorBalanceSummary/index.ts @@ -34,6 +34,10 @@ export default class VendorBalanceSummaryReportController extends BaseFinancialR return [ ...this.sheetNumberFormatValidationSchema, query('as_date').optional().isISO8601(), + + // Vendors ids. + query('vendors_ids').optional().isArray({ min: 1 }), + query('vendors_ids.*').exists().isInt().toInt(), ]; } @@ -41,7 +45,7 @@ export default class VendorBalanceSummaryReportController extends BaseFinancialR * Transformes the report statement to table rows. * @param {IVendorBalanceSummaryStatement} statement - */ - transformToTableRows({ data }: IVendorBalanceSummaryStatement) { + private transformToTableRows({ data }: IVendorBalanceSummaryStatement) { return { table: { data: this.vendorBalanceSummaryTableRows.tableRowsTransformer(data), @@ -53,7 +57,10 @@ export default class VendorBalanceSummaryReportController extends BaseFinancialR * Transformes the report statement to raw json. * @param {IVendorBalanceSummaryStatement} statement - */ - transformToJsonResponse({ data, columns }: IVendorBalanceSummaryStatement) { + private transformToJsonResponse({ + data, + columns, + }: IVendorBalanceSummaryStatement) { return { data: this.transfromToResponse(data), columns: this.transfromToResponse(columns), @@ -72,10 +79,11 @@ export default class VendorBalanceSummaryReportController extends BaseFinancialR const filter = this.matchedQueryData(req); try { - const vendorBalanceSummary = await this.vendorBalanceSummaryService.vendorBalanceSummary( - tenantId, - filter - ); + const vendorBalanceSummary = + await this.vendorBalanceSummaryService.vendorBalanceSummary( + tenantId, + filter + ); const accept = this.accepts(req); const acceptType = accept.types(['json', 'application/json+table']); diff --git a/server/src/api/controllers/Items.ts b/server/src/api/controllers/Items.ts index 44f782ce7..ff30657e3 100644 --- a/server/src/api/controllers/Items.ts +++ b/server/src/api/controllers/Items.ts @@ -406,7 +406,7 @@ export default class ItemsController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - handlerServiceErrors( + private handlerServiceErrors( error: Error, req: Request, res: Response, diff --git a/server/src/api/controllers/Purchases/Bills.ts b/server/src/api/controllers/Purchases/Bills.ts index d8f8affde..c119bcfce 100644 --- a/server/src/api/controllers/Purchases/Bills.ts +++ b/server/src/api/controllers/Purchases/Bills.ts @@ -110,6 +110,10 @@ export default class BillsController extends BaseController { .optional({ nullable: true }) .trim() .escape(), + check('entries.*.landed_cost') + .optional({ nullable: true }) + .isBoolean() + .toBoolean(), ]; } @@ -141,6 +145,10 @@ export default class BillsController extends BaseController { .optional({ nullable: true }) .trim() .escape(), + check('entries.*.landed_cost') + .optional({ nullable: true }) + .isBoolean() + .toBoolean(), ]; } @@ -301,11 +309,8 @@ export default class BillsController extends BaseController { filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles); } try { - const { - bills, - pagination, - filterMeta, - } = await this.billsService.getBills(tenantId, filter); + const { bills, pagination, filterMeta } = + await this.billsService.getBills(tenantId, filter); return res.status(200).send({ bills, @@ -342,7 +347,7 @@ export default class BillsController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - handleServiceError( + private handleServiceError( error: Error, req: Request, res: Response, @@ -397,17 +402,72 @@ export default class BillsController extends BaseController { if (error.errorType === 'contact_not_found') { return res.boom.badRequest(null, { errors: [ - { type: 'VENDOR_NOT_FOUND', message: 'Vendor not found.', code: 1200 }, + { + type: 'VENDOR_NOT_FOUND', + message: 'Vendor not found.', + code: 1200, + }, ], }); } if (error.errorType === 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES') { return res.status(400).send({ - errors: [{ - type: 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES', - message: 'Cannot delete bill that has associated payment transactions.', - code: 1200 - }], + errors: [ + { + type: 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES', + message: + 'Cannot delete bill that has associated payment transactions.', + code: 1200, + }, + ], + }); + } + if (error.errorType === 'BILL_HAS_ASSOCIATED_LANDED_COSTS') { + return res.status(400).send({ + errors: [ + { + type: 'BILL_HAS_ASSOCIATED_LANDED_COSTS', + message: + 'Cannot delete bill that has associated landed cost transactions.', + code: 1300, + }, + ], + }); + } + if (error.errorType === 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED') { + return res.status(400).send({ + errors: [ + { + type: 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED', + code: 1400, + message: + 'Bill entries that have landed cost type can not be deleted.', + }, + ], + }); + } + if ( + error.errorType === 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES' + ) { + return res.status(400).send({ + errors: [ + { + type: 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES', + code: 1500, + }, + ], + }); + } + if (error.errorType === 'LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS') { + return res.status(400).send({ + errors: [ + { + type: 'LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS', + message: + 'Landed cost entries should be only with inventory items.', + code: 1600, + }, + ], }); } } diff --git a/server/src/api/controllers/Purchases/LandedCost.ts b/server/src/api/controllers/Purchases/LandedCost.ts new file mode 100644 index 000000000..bf149c2ff --- /dev/null +++ b/server/src/api/controllers/Purchases/LandedCost.ts @@ -0,0 +1,291 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { check, param, query } from 'express-validator'; +import { Service, Inject } from 'typedi'; +import { ServiceError } from 'exceptions'; +import AllocateLandedCostService from 'services/Purchases/LandedCost'; +import LandedCostListing from 'services/Purchases/LandedCost/LandedCostListing'; +import BaseController from '../BaseController'; + +@Service() +export default class BillAllocateLandedCost extends BaseController { + @Inject() + allocateLandedCost: AllocateLandedCostService; + + @Inject() + landedCostListing: LandedCostListing; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/bills/:billId/allocate', + [ + check('transaction_id').exists().isInt(), + check('transaction_type').exists().isIn(['Expense', 'Bill']), + check('transaction_entry_id').exists().isInt(), + + check('allocation_method').exists().isIn(['value', 'quantity']), + check('description').optional({ nullable: true }), + + check('items').isArray({ min: 1 }), + check('items.*.entry_id').isInt(), + check('items.*.cost').isDecimal(), + ], + this.validationResult, + this.calculateLandedCost.bind(this), + this.handleServiceErrors + ); + router.delete( + '/:allocatedLandedCostId', + [param('allocatedLandedCostId').exists().isInt()], + this.validationResult, + this.deleteAllocatedLandedCost.bind(this), + this.handleServiceErrors + ); + router.get( + '/transactions', + [query('transaction_type').exists().isIn(['Expense', 'Bill'])], + this.validationResult, + this.getLandedCostTransactions.bind(this), + this.handleServiceErrors + ); + router.get( + '/bills/:billId/transactions', + [param('billId').exists()], + this.validationResult, + this.getBillLandedCostTransactions.bind(this), + this.handleServiceErrors + ); + return router; + } + + /** + * Retrieve the landed cost transactions of the given query. + * @param {Request} req - Request + * @param {Response} res - Response. + * @param {NextFunction} next - Next function. + */ + private async getLandedCostTransactions( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const query = this.matchedQueryData(req); + + try { + const transactions = + await this.landedCostListing.getLandedCostTransactions(tenantId, query); + return res.status(200).send({ transactions }); + } catch (error) { + next(error); + } + } + + /** + * Allocate landed cost. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response} + */ + public async calculateLandedCost( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { billId: purchaseInvoiceId } = req.params; + const landedCostDTO = this.matchedBodyData(req); + + try { + const { billLandedCost } = + await this.allocateLandedCost.allocateLandedCost( + tenantId, + landedCostDTO, + purchaseInvoiceId + ); + + return res.status(200).send({ + id: billLandedCost.id, + message: 'The items cost are located successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the allocated landed cost. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response} + */ + public async deleteAllocatedLandedCost( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { allocatedLandedCostId } = req.params; + + try { + await this.allocateLandedCost.deleteAllocatedLandedCost( + tenantId, + allocatedLandedCostId + ); + + return res.status(200).send({ + id: allocatedLandedCostId, + message: 'The allocated landed cost are delete successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve the list unlocated landed costs. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async listLandedCosts( + req: Request, + res: Response, + next: NextFunction + ) { + const query = this.matchedQueryData(req); + const { tenantId } = req; + + try { + const transactions = + await this.landedCostListing.getLandedCostTransactions(tenantId, query); + return res.status(200).send({ transactions }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve the bill landed cost transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async getBillLandedCostTransactions( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { billId } = req.params; + + try { + const transactions = + await this.landedCostListing.getBillLandedCostTransactions( + tenantId, + billId + ); + + return res.status(200).send({ + billId, + transactions: this.transfromToResponse(transactions) + }); + } catch (error) { + next(error); + } + } + + /** + * Handle service errors. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @param {Error} error + */ + public handleServiceErrors( + error: Error, + req: Request, + res: Response, + next: NextFunction + ) { + if (error instanceof ServiceError) { + if (error.errorType === 'BILL_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'BILL_NOT_FOUND', + message: 'The give bill id not found.', + code: 100, + }, + ], + }); + } + if (error.errorType === 'LANDED_COST_TRANSACTION_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'LANDED_COST_TRANSACTION_NOT_FOUND', + message: 'The given landed cost transaction id not found.', + code: 200, + }, + ], + }); + } + if (error.errorType === 'LANDED_COST_ENTRY_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'LANDED_COST_ENTRY_NOT_FOUND', + message: 'The given landed cost tranasction entry id not found.', + code: 300, + }, + ], + }); + } + if (error.errorType === 'COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT') { + return res.status(400).send({ + errors: [ + { + type: 'COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT', + code: 400, + }, + ], + }); + } + if (error.errorType === 'LANDED_COST_ITEMS_IDS_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'LANDED_COST_ITEMS_IDS_NOT_FOUND', + message: 'The given entries ids of purchase invoice not found.', + code: 500, + }, + ], + }); + } + if (error.errorType === 'BILL_LANDED_COST_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'BILL_LANDED_COST_NOT_FOUND', + message: 'The given bill located landed cost not found.', + code: 600, + }, + ], + }); + } + if (error.errorType === 'COST_TRASNACTION_NOT_FOUND') { + return res.status(400).send({ + errors: [{ type: 'COST_TRASNACTION_NOT_FOUND', code: 500 }], + }); + } + } + next(error); + } +} diff --git a/server/src/api/controllers/Purchases/index.ts b/server/src/api/controllers/Purchases/index.ts index a56ac1261..2ee3686f2 100644 --- a/server/src/api/controllers/Purchases/index.ts +++ b/server/src/api/controllers/Purchases/index.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { Container, Service } from 'typedi'; import Bills from 'api/controllers/Purchases/Bills' import BillPayments from 'api/controllers/Purchases/BillsPayments'; +import BillAllocateLandedCost from './LandedCost'; @Service() export default class PurchasesController { @@ -11,6 +12,7 @@ export default class PurchasesController { router.use('/bills', Container.get(Bills).router()); router.use('/bill_payments', Container.get(BillPayments).router()); + router.use('/landed-cost', Container.get(BillAllocateLandedCost).router()); return router; } diff --git a/server/src/api/controllers/Sales/SalesInvoices.ts b/server/src/api/controllers/Sales/SalesInvoices.ts index c2fbc8aa1..7b8641c36 100644 --- a/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/server/src/api/controllers/Sales/SalesInvoices.ts @@ -347,7 +347,7 @@ export default class SaleInvoicesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - handleServiceErrors( + private handleServiceErrors( error: Error, req: Request, res: Response, diff --git a/server/src/api/index.ts b/server/src/api/index.ts index 649e67f0a..1e95d7a05 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -40,7 +40,6 @@ import Ping from 'api/controllers/Ping'; import Subscription from 'api/controllers/Subscription'; import Licenses from 'api/controllers/Subscription/Licenses'; import InventoryAdjustments from 'api/controllers/Inventory/InventoryAdjustments'; - import Setup from 'api/controllers/Setup'; export default () => { diff --git a/server/src/database/migrations/20190822214306_create_items_table.js b/server/src/database/migrations/20190822214306_create_items_table.js index 7d0100506..16ac1ed66 100644 --- a/server/src/database/migrations/20190822214306_create_items_table.js +++ b/server/src/database/migrations/20190822214306_create_items_table.js @@ -17,6 +17,7 @@ exports.up = function (knex) { table.text('sell_description').nullable(); table.text('purchase_description').nullable(); table.integer('quantity_on_hand'); + table.boolean('landed_cost').nullable(); table.text('note').nullable(); table.boolean('active'); diff --git a/server/src/database/migrations/20200105014405_create_expenses_table.js b/server/src/database/migrations/20200105014405_create_expenses_table.js index 5f4382e35..169856f33 100644 --- a/server/src/database/migrations/20200105014405_create_expenses_table.js +++ b/server/src/database/migrations/20200105014405_create_expenses_table.js @@ -1,20 +1,29 @@ +exports.up = function (knex) { + return knex.schema + .createTable('expenses_transactions', (table) => { + table.increments(); + table.string('currency_code', 3); + table.text('description'); + table + .integer('payment_account_id') + .unsigned() + .references('id') + .inTable('accounts'); + table.integer('payee_id').unsigned().references('id').inTable('contacts'); + table.string('reference_no'); -exports.up = function(knex) { - return knex.schema.createTable('expenses_transactions', (table) => { - table.increments(); - table.decimal('total_amount', 13, 3); - table.string('currency_code', 3); - table.text('description'); - table.integer('payment_account_id').unsigned().references('id').inTable('accounts'); - table.integer('payee_id').unsigned().references('id').inTable('contacts');; - table.string('reference_no'); - table.date('published_at').index(); - table.integer('user_id').unsigned().index(); - table.date('payment_date').index(); - table.timestamps(); - }).raw('ALTER TABLE `EXPENSES_TRANSACTIONS` AUTO_INCREMENT = 1000'); + table.decimal('total_amount', 13, 3); + table.decimal('landed_cost_amount', 13, 3).defaultTo(0); + table.decimal('allocated_cost_amount', 13, 3).defaultTo(0); + + table.date('published_at').index(); + table.integer('user_id').unsigned().index(); + table.date('payment_date').index(); + table.timestamps(); + }) + .raw('ALTER TABLE `EXPENSES_TRANSACTIONS` AUTO_INCREMENT = 1000'); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.dropTableIfExists('expenses'); }; diff --git a/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js b/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js index b383bd668..a1bc88052 100644 --- a/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js +++ b/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js @@ -1,16 +1,29 @@ - -exports.up = function(knex) { - return knex.schema.createTable('expense_transaction_categories', table => { - table.increments(); - table.integer('expense_account_id').unsigned().index().references('id').inTable('accounts'); - table.integer('index').unsigned(); - table.text('description'); - table.decimal('amount', 13, 3); - table.integer('expense_id').unsigned().index().references('id').inTable('expenses_transactions'); - table.timestamps(); - }).raw('ALTER TABLE `EXPENSE_TRANSACTION_CATEGORIES` AUTO_INCREMENT = 1000');; +exports.up = function (knex) { + return knex.schema + .createTable('expense_transaction_categories', (table) => { + table.increments(); + table + .integer('expense_account_id') + .unsigned() + .index() + .references('id') + .inTable('accounts'); + table.integer('index').unsigned(); + table.text('description'); + table.decimal('amount', 13, 3); + table.decimal('allocated_cost_amount', 13, 3).defaultTo(0); + table.boolean('landed_cost').defaultTo(false); + table + .integer('expense_id') + .unsigned() + .index() + .references('id') + .inTable('expenses_transactions'); + table.timestamps(); + }) + .raw('ALTER TABLE `EXPENSE_TRANSACTION_CATEGORIES` AUTO_INCREMENT = 1000'); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.dropTableIfExists('expense_transaction_categories'); }; diff --git a/server/src/database/migrations/20200719152005_create_bills_table.js b/server/src/database/migrations/20200719152005_create_bills_table.js index c8c432a32..34cb845ef 100644 --- a/server/src/database/migrations/20200719152005_create_bills_table.js +++ b/server/src/database/migrations/20200719152005_create_bills_table.js @@ -1,8 +1,12 @@ - -exports.up = function(knex) { +exports.up = function (knex) { return knex.schema.createTable('bills', (table) => { table.increments(); - table.integer('vendor_id').unsigned().index().references('id').inTable('contacts'); + table + .integer('vendor_id') + .unsigned() + .index() + .references('id') + .inTable('contacts'); table.string('bill_number'); table.date('bill_date').index(); table.date('due_date').index(); @@ -12,6 +16,8 @@ exports.up = function(knex) { table.decimal('amount', 13, 3).defaultTo(0); table.string('currency_code'); table.decimal('payment_amount', 13, 3).defaultTo(0); + table.decimal('landed_cost_amount', 13, 3).defaultTo(0); + table.decimal('allocated_cost_amount', 13, 3).defaultTo(0); table.string('inv_lot_number').index(); table.date('opened_at').index(); table.integer('user_id').unsigned(); @@ -19,6 +25,6 @@ exports.up = function(knex) { }); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.dropTableIfExists('bills'); }; diff --git a/server/src/database/migrations/20200722164252_create_landed_cost_table.js b/server/src/database/migrations/20200722164252_create_landed_cost_table.js new file mode 100644 index 000000000..f315e1bde --- /dev/null +++ b/server/src/database/migrations/20200722164252_create_landed_cost_table.js @@ -0,0 +1,21 @@ +exports.up = function (knex) { + return knex.schema.createTable('bill_located_costs', (table) => { + table.increments(); + + table.decimal('amount', 13, 3).unsigned(); + + table.integer('fromTransactionId').unsigned(); + table.string('fromTransactionType'); + table.integer('fromTransactionEntryId').unsigned(); + + table.string('allocationMethod'); + table.integer('costAccountId').unsigned(); + table.text('description'); + + table.integer('billId').unsigned(); + + table.timestamps(); + }); +}; + +exports.down = function (knex) {}; diff --git a/server/src/database/migrations/20200722164253_create_landed_cost_entries_table.js b/server/src/database/migrations/20200722164253_create_landed_cost_entries_table.js new file mode 100644 index 000000000..96cdc5d77 --- /dev/null +++ b/server/src/database/migrations/20200722164253_create_landed_cost_entries_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.createTable('bill_located_cost_entries', (table) => { + table.increments(); + + table.decimal('cost', 13, 3).unsigned(); + table.integer('entry_id').unsigned(); + table.integer('bill_located_cost_id').unsigned(); + }); +}; + +exports.down = function (knex) {}; diff --git a/server/src/database/migrations/20200722173423_create_items_entries_table.js b/server/src/database/migrations/20200722173423_create_items_entries_table.js index b6313eaba..b480540de 100644 --- a/server/src/database/migrations/20200722173423_create_items_entries_table.js +++ b/server/src/database/migrations/20200722173423_create_items_entries_table.js @@ -1,24 +1,39 @@ - -exports.up = function(knex) { +exports.up = function (knex) { return knex.schema.createTable('items_entries', (table) => { table.increments(); table.string('reference_type').index(); table.string('reference_id').index(); table.integer('index').unsigned(); - table.integer('item_id').unsigned().index().references('id').inTable('items'); + table + .integer('item_id') + .unsigned() + .index() + .references('id') + .inTable('items'); table.text('description'); table.integer('discount').unsigned(); table.integer('quantity').unsigned(); table.integer('rate').unsigned(); - table.integer('sell_account_id').unsigned().references('id').inTable('accounts'); - table.integer('cost_account_id').unsigned().references('id').inTable('accounts'); + table + .integer('sell_account_id') + .unsigned() + .references('id') + .inTable('accounts'); + table + .integer('cost_account_id') + .unsigned() + .references('id') + .inTable('accounts'); + + table.boolean('landed_cost').defaultTo(false); + table.decimal('allocated_cost_amount', 13, 3).defaultTo(0); table.timestamps(); }); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.dropTableIfExists('items_entries'); }; diff --git a/server/src/interfaces/Bill.ts b/server/src/interfaces/Bill.ts index 0598dde6a..ea68100c3 100644 --- a/server/src/interfaces/Bill.ts +++ b/server/src/interfaces/Bill.ts @@ -1,64 +1,69 @@ -import { IDynamicListFilterDTO } from "./DynamicFilter"; -import { IItemEntry, IItemEntryDTO } from "./ItemEntry"; +import { IDynamicListFilterDTO } from './DynamicFilter'; +import { IItemEntry, IItemEntryDTO } from './ItemEntry'; export interface IBillDTO { - vendorId: number, - billNumber: string, - billDate: Date, - dueDate: Date, - referenceNo: string, - status: string, - note: string, - amount: number, - paymentAmount: number, - open: boolean, - entries: IItemEntryDTO[], -}; + vendorId: number; + billNumber: string; + billDate: Date; + dueDate: Date; + referenceNo: string; + status: string; + note: string; + amount: number; + paymentAmount: number; + open: boolean; + entries: IItemEntryDTO[]; +} export interface IBillEditDTO { - vendorId: number, - billNumber: string, - billDate: Date, - dueDate: Date, - referenceNo: string, - status: string, - note: string, - amount: number, - paymentAmount: number, - open: boolean, - entries: IItemEntryDTO[], -}; + vendorId: number; + billNumber: string; + billDate: Date; + dueDate: Date; + referenceNo: string; + status: string; + note: string; + amount: number; + paymentAmount: number; + open: boolean; + entries: IItemEntryDTO[]; +} export interface IBill { - id?: number, + id?: number; - vendorId: number, - billNumber: string, - billDate: Date, - dueDate: Date, - referenceNo: string, - status: string, - note: string, - amount: number, - paymentAmount: number, - currencyCode: string, + vendorId: number; + billNumber: string; + billDate: Date; + dueDate: Date; + referenceNo: string; + status: string; + note: string; - dueAmount: number, - overdueDays: number, + amount: number; + allocatedCostAmount: number; + landedCostAmount: number; + unallocatedCostAmount: number; - openedAt: Date | string, + paymentAmount: number; + currencyCode: string; - entries: IItemEntry[], - userId: number, + dueAmount: number; + overdueDays: number; - createdAt: Date, - updateAt: Date, -}; + openedAt: Date | string; -export interface IBillsFilter extends IDynamicListFilterDTO { - stringifiedFilterRoles?: string, + entries: IItemEntry[]; + userId: number; + + createdAt: Date; + updateAt: Date; +} + +export interface IBillsFilter extends IDynamicListFilterDTO { + stringifiedFilterRoles?: string; } export interface IBillsService { validateVendorHasNoBills(tenantId: number, vendorId: number): Promise; -} \ No newline at end of file +} diff --git a/server/src/interfaces/Entry.ts b/server/src/interfaces/Entry.ts new file mode 100644 index 000000000..b55bb0aa1 --- /dev/null +++ b/server/src/interfaces/Entry.ts @@ -0,0 +1,18 @@ +export interface ICommonEntry { + id: number; + amount: number; +} + +export interface ICommonLandedCostEntry extends ICommonEntry { + landedCost: boolean; + allocatedCostAmount: number; +} + +export interface ICommonEntryDTO { + id?: number; + amount: number; +} + +export interface ICommonLandedCostEntryDTO extends ICommonEntryDTO { + landedCost?: boolean; +} diff --git a/server/src/interfaces/Expenses.ts b/server/src/interfaces/Expenses.ts index 0d1a11a1f..78dba8946 100644 --- a/server/src/interfaces/Expenses.ts +++ b/server/src/interfaces/Expenses.ts @@ -27,15 +27,23 @@ export interface IExpense { userId: number; paymentDate: Date; payeeId: number; + landedCostAmount: number; + allocatedCostAmount: number; + unallocatedCostAmount: number; categories: IExpenseCategory[]; } export interface IExpenseCategory { + id?: number; expenseAccountId: number; index: number; description: string; expenseId: number; amount: number; + + allocatedCostAmount: number; + unallocatedCostAmount: number; + landedCost: boolean; } export interface IExpenseDTO { @@ -52,10 +60,13 @@ export interface IExpenseDTO { } export interface IExpenseCategoryDTO { + id?: number; expenseAccountId: number; index: number; + amount: number; description?: string; expenseId: number; + landedCost?: boolean; } export interface IExpensesService { diff --git a/server/src/interfaces/IInventoryValuationSheet.ts b/server/src/interfaces/IInventoryValuationSheet.ts index 8a6cf5f40..a295020ed 100644 --- a/server/src/interfaces/IInventoryValuationSheet.ts +++ b/server/src/interfaces/IInventoryValuationSheet.ts @@ -7,6 +7,7 @@ export interface IInventoryValuationReportQuery { asDate: Date | string; numberFormat: INumberFormatQuery; noneTransactions: boolean; + itemsIds: number[], }; export interface IInventoryValuationSheetMeta { diff --git a/server/src/interfaces/InventoryDetails.ts b/server/src/interfaces/InventoryDetails.ts index 69680f04f..270a4c254 100644 --- a/server/src/interfaces/InventoryDetails.ts +++ b/server/src/interfaces/InventoryDetails.ts @@ -7,6 +7,7 @@ export interface IInventoryDetailsQuery { toDate: Date | string; numberFormat: INumberFormatQuery; noneTransactions: boolean; + itemsIds: number[] } export interface IInventoryDetailsNumber { diff --git a/server/src/interfaces/ItemEntry.ts b/server/src/interfaces/ItemEntry.ts index 3e082c716..348e5875d 100644 --- a/server/src/interfaces/ItemEntry.ts +++ b/server/src/interfaces/ItemEntry.ts @@ -1,24 +1,30 @@ - export type IItemEntryTransactionType = 'SaleInvoice' | 'Bill' | 'SaleReceipt'; export interface IItemEntry { - id?: number, + id?: number; - referenceType: string, - referenceId: number, + referenceType: string; + referenceId: number; - index: number, + index: number; - itemId: number, - description: string, - discount: number, - quantity: number, - rate: number, + itemId: number; + description: string; + discount: number; + quantity: number; + rate: number; + amount: number; - sellAccountId: number, - costAccountId: number, + landedCost: number; + allocatedCostAmount: number; + unallocatedCostAmount: number; + + sellAccountId: number; + costAccountId: number; } export interface IItemEntryDTO { - -} \ No newline at end of file + id?: number, + itemId: number; + landedCost?: boolean; +} diff --git a/server/src/interfaces/LandedCost.ts b/server/src/interfaces/LandedCost.ts new file mode 100644 index 000000000..8e9ce2e77 --- /dev/null +++ b/server/src/interfaces/LandedCost.ts @@ -0,0 +1,96 @@ +export interface IBillLandedCost { + fromTransactionId: number; + fromTransactionType: string; + amount: number; + BillId: number; +} + +export interface IBillLandedCostEntry { + id?: number, + cost: number, + entryId: number, + billLocatedCostId: number, +} + +export interface ILandedCostItemDTO { + entryId: number, + cost: number; +} +export type ILandedCostType = 'Expense' | 'Bill'; + +export interface ILandedCostDTO { + transactionType: ILandedCostType; + transactionId: number; + transactionEntryId: number, + allocationMethod: string; + description: string; + items: ILandedCostItemDTO[]; +} + +export interface ILandedCostQueryDTO { + vendorId: number; + fromDate: Date; + toDate: Date; +} + +export interface IUnallocatedListCost { + costNumber: string; + costAmount: number; + unallocatedAmount: number; +} + +export interface ILandedCostTransactionsQueryDTO { + transactionType: string, + date: Date, +} + +export interface ILandedCostEntriesQueryDTO { + transactionType: string, + transactionId: number, +} + +export interface ILandedCostTransaction { + id: number; + name: string; + amount: number; + allocatedCostAmount: number; + unallocatedCostAmount: number; + transactionType: string; + entries?: ILandedCostTransactionEntry[]; +} + +export interface ILandedCostTransactionEntry { + id: number; + name: string; + code: string; + amount: number; + unallocatedCostAmount: number; + allocatedCostAmount: number; + description: string; + costAccountId: number; +} + +interface ILandedCostEntry { + id: number; + landedCost?: boolean; +} + +export interface IBillLandedCostTransaction { + id: number, + fromTranscationId: number, + fromTransactionType: string; + fromTransactionEntryId: number; + + billId: number, + allocationMethod: string; + costAccountId: number, + description: string; + + allocateEntries?: IBillLandedCostTransactionEntry[], +}; + +export interface IBillLandedCostTransactionEntry { + cost: number; + entryId: number; + billLocatedCostId: number, +} \ No newline at end of file diff --git a/server/src/interfaces/SalesByItemsSheet.ts b/server/src/interfaces/SalesByItemsSheet.ts index d90fff55e..362f4ceea 100644 --- a/server/src/interfaces/SalesByItemsSheet.ts +++ b/server/src/interfaces/SalesByItemsSheet.ts @@ -5,6 +5,7 @@ import { export interface ISalesByItemsReportQuery { fromDate: Date | string; toDate: Date | string; + itemsIds: number[], numberFormat: INumberFormatQuery; noneTransactions: boolean; }; diff --git a/server/src/interfaces/TransactionsByCustomers.ts b/server/src/interfaces/TransactionsByCustomers.ts index 9ccd8de9c..fe2fbf5e2 100644 --- a/server/src/interfaces/TransactionsByCustomers.ts +++ b/server/src/interfaces/TransactionsByCustomers.ts @@ -18,7 +18,9 @@ export interface ITransactionsByCustomersCustomer { } export interface ITransactionsByCustomersFilter - extends ITransactionsByContactsFilter {} + extends ITransactionsByContactsFilter { + customersIds: number[]; +} export type ITransactionsByCustomersData = ITransactionsByCustomersCustomer[]; diff --git a/server/src/interfaces/TransactionsByVendors.ts b/server/src/interfaces/TransactionsByVendors.ts index c2a4bc77a..107c7662e 100644 --- a/server/src/interfaces/TransactionsByVendors.ts +++ b/server/src/interfaces/TransactionsByVendors.ts @@ -18,7 +18,9 @@ export interface ITransactionsByVendorsVendor { } export interface ITransactionsByVendorsFilter - extends ITransactionsByContactsFilter {} + extends ITransactionsByContactsFilter { + vendorsIds: number[]; +} export type ITransactionsByVendorsData = ITransactionsByVendorsVendor[]; diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 388668041..4e8dc9e78 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -53,6 +53,8 @@ export * from './Table'; export * from './Ledger'; export * from './CashFlow'; export * from './InventoryDetails'; +export * from './LandedCost'; +export * from './Entry'; export interface I18nService { __: (input: string) => string; diff --git a/server/src/loaders/events.ts b/server/src/loaders/events.ts index e0478145f..92a8427a4 100644 --- a/server/src/loaders/events.ts +++ b/server/src/loaders/events.ts @@ -26,4 +26,6 @@ import 'subscribers/vendors'; import 'subscribers/paymentMades'; import 'subscribers/paymentReceives'; import 'subscribers/saleEstimates'; -import 'subscribers/items'; \ No newline at end of file +import 'subscribers/items'; + +import 'subscribers/LandedCost'; \ No newline at end of file diff --git a/server/src/loaders/tenantModels.ts b/server/src/loaders/tenantModels.ts index 86adacdab..c71242b02 100644 --- a/server/src/loaders/tenantModels.ts +++ b/server/src/loaders/tenantModels.ts @@ -36,6 +36,8 @@ import Media from 'models/Media'; import MediaLink from 'models/MediaLink'; import InventoryAdjustment from 'models/InventoryAdjustment'; import InventoryAdjustmentEntry from 'models/InventoryAdjustmentEntry'; +import BillLandedCost from 'models/BillLandedCost'; +import BillLandedCostEntry from 'models/BillLandedCostEntry'; export default (knex) => { const models = { @@ -75,6 +77,8 @@ export default (knex) => { Contact, InventoryAdjustment, InventoryAdjustmentEntry, + BillLandedCost, + BillLandedCostEntry }; return mapValues(models, (model) => model.bindKnex(knex)); } \ No newline at end of file diff --git a/server/src/models/Bill.js b/server/src/models/Bill.js index 9b69d0c3e..f7e050eb2 100644 --- a/server/src/models/Bill.js +++ b/server/src/models/Bill.js @@ -103,6 +103,7 @@ export default class Bill extends TenantModel { 'remainingDays', 'overdueDays', 'isOverdue', + 'unallocatedCostAmount' ]; } @@ -178,6 +179,14 @@ export default class Bill extends TenantModel { return this.overdueDays > 0; } + /** + * Retrieve the unallocated cost amount. + * @return {number} + */ + get unallocatedCostAmount() { + return Math.max(this.landedCostAmount - this.allocatedCostAmount, 0); + } + getOverdueDays(asDate = moment().format('YYYY-MM-DD')) { // Can't continue in case due date not defined. if (!this.dueDate) { @@ -195,6 +204,7 @@ export default class Bill extends TenantModel { static get relationMappings() { const Contact = require('models/Contact'); const ItemEntry = require('models/ItemEntry'); + const BillLandedCost = require('models/BillLandedCost'); return { vendor: { @@ -220,6 +230,15 @@ export default class Bill extends TenantModel { builder.where('reference_type', 'Bill'); }, }, + + locatedLandedCosts: { + relation: Model.HasManyRelation, + modelClass: BillLandedCost.default, + join: { + from: 'bills.id', + to: 'bill_located_costs.billId', + }, + }, }; } diff --git a/server/src/models/BillLandedCost.js b/server/src/models/BillLandedCost.js new file mode 100644 index 000000000..ffa69eaeb --- /dev/null +++ b/server/src/models/BillLandedCost.js @@ -0,0 +1,65 @@ +import { Model } from 'objection'; +import { lowerCase } from 'lodash'; +import TenantModel from 'models/TenantModel'; + +export default class BillLandedCost extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'bill_located_costs'; + } + + /** + * Model timestamps. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['allocationMethodFormatted']; + } + + /** + * Allocation method formatted. + */ + get allocationMethodFormatted() { + const allocationMethod = lowerCase(this.allocationMethod); + const keyLabelsPairs = { + value: 'Value', + quantity: 'Quantity', + }; + return keyLabelsPairs[allocationMethod] || ''; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const BillLandedCostEntry = require('models/BillLandedCostEntry'); + const Bill = require('models/Bill'); + + return { + bill: { + relation: Model.BelongsToOneRelation, + modelClass: Bill.default, + join: { + from: 'bill_located_costs.billId', + to: 'bills.id', + }, + }, + allocateEntries: { + relation: Model.HasManyRelation, + modelClass: BillLandedCostEntry.default, + join: { + from: 'bill_located_costs.id', + to: 'bill_located_cost_entries.billLocatedCostId', + }, + }, + }; + } +} diff --git a/server/src/models/BillLandedCostEntry.js b/server/src/models/BillLandedCostEntry.js new file mode 100644 index 000000000..d4f3fc833 --- /dev/null +++ b/server/src/models/BillLandedCostEntry.js @@ -0,0 +1,32 @@ +import { Model } from 'objection'; +import TenantModel from 'models/TenantModel'; + +export default class BillLandedCostEntry extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'bill_located_cost_entries'; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const ItemEntry = require('models/ItemEntry'); + + return { + itemEntry: { + relation: Model.BelongsToOneRelation, + modelClass: ItemEntry.default, + join: { + from: 'bill_located_cost_entries.entryId', + to: 'items_entries.referenceId', + }, + filter(builder) { + builder.where('reference_type', 'Bill'); + }, + }, + }; + } +} diff --git a/server/src/models/Expense.js b/server/src/models/Expense.js index c95d9e57f..efadc2bbb 100644 --- a/server/src/models/Expense.js +++ b/server/src/models/Expense.js @@ -1,27 +1,27 @@ -import { Model } from "objection"; -import TenantModel from "models/TenantModel"; -import { viewRolesBuilder } from "lib/ViewRolesBuilder"; +import { Model } from 'objection'; +import TenantModel from 'models/TenantModel'; +import { viewRolesBuilder } from 'lib/ViewRolesBuilder'; export default class Expense extends TenantModel { /** * Table name */ static get tableName() { - return "expenses_transactions"; + return 'expenses_transactions'; } /** * Account transaction reference type. */ static get referenceType() { - return "Expense"; + return 'Expense'; } /** * Model timestamps. */ get timestamps() { - return ["createdAt", "updatedAt"]; + return ['createdAt', 'updatedAt']; } /** @@ -37,14 +37,23 @@ export default class Expense extends TenantModel { static get media() { return true; } - + static get virtualAttributes() { - return ["isPublished"]; + return ['isPublished', 'unallocatedCostAmount']; } + isPublished() { return Boolean(this.publishedAt); } + /** + * Retrieve the unallocated cost amount. + * @return {number} + */ + get unallocatedCostAmount() { + return Math.max(this.amount - this.allocatedCostAmount, 0); + } + /** * Model modifiers. */ @@ -52,28 +61,28 @@ export default class Expense extends TenantModel { return { filterByDateRange(query, startDate, endDate) { if (startDate) { - query.where("date", ">=", startDate); + query.where('date', '>=', startDate); } if (endDate) { - query.where("date", "<=", endDate); + query.where('date', '<=', endDate); } }, filterByAmountRange(query, from, to) { if (from) { - query.where("amount", ">=", from); + query.where('amount', '>=', from); } if (to) { - query.where("amount", "<=", to); + query.where('amount', '<=', to); } }, filterByExpenseAccount(query, accountId) { if (accountId) { - query.where("expense_account_id", accountId); + query.where('expense_account_id', accountId); } }, filterByPaymentAccount(query, accountId) { if (accountId) { - query.where("payment_account_id", accountId); + query.where('payment_account_id', accountId); } }, viewRolesBuilder(query, conditionals, expression) { @@ -94,40 +103,40 @@ export default class Expense extends TenantModel { * Relationship mapping. */ static get relationMappings() { - const Account = require("models/Account"); - const ExpenseCategory = require("models/ExpenseCategory"); - const Media = require("models/Media"); + const Account = require('models/Account'); + const ExpenseCategory = require('models/ExpenseCategory'); + const Media = require('models/Media'); return { paymentAccount: { relation: Model.BelongsToOneRelation, modelClass: Account.default, join: { - from: "expenses_transactions.paymentAccountId", - to: "accounts.id", + from: 'expenses_transactions.paymentAccountId', + to: 'accounts.id', }, }, categories: { relation: Model.HasManyRelation, modelClass: ExpenseCategory.default, join: { - from: "expenses_transactions.id", - to: "expense_transaction_categories.expenseId", + from: 'expenses_transactions.id', + to: 'expense_transaction_categories.expenseId', }, }, media: { relation: Model.ManyToManyRelation, modelClass: Media.default, join: { - from: "expenses_transactions.id", + from: 'expenses_transactions.id', through: { - from: "media_links.model_id", - to: "media_links.media_id", + from: 'media_links.model_id', + to: 'media_links.media_id', }, - to: "media.id", + to: 'media.id', }, filter(query) { - query.where("model_name", "Expense"); + query.where('model_name', 'Expense'); }, }, }; @@ -139,39 +148,39 @@ export default class Expense extends TenantModel { static get fields() { return { payment_date: { - label: "Payment date", - column: "payment_date", - columnType: "date", + label: 'Payment date', + column: 'payment_date', + columnType: 'date', }, payment_account: { - label: "Payment account", - column: "payment_account_id", - relation: "accounts.id", - optionsResource: "account", + label: 'Payment account', + column: 'payment_account_id', + relation: 'accounts.id', + optionsResource: 'account', }, amount: { - label: "Amount", - column: "total_amount", - columnType: "number", + label: 'Amount', + column: 'total_amount', + columnType: 'number', }, currency_code: { - label: "Currency", - column: "currency_code", - optionsResource: "currency", + label: 'Currency', + column: 'currency_code', + optionsResource: 'currency', }, reference_no: { - label: "Reference No.", - column: "reference_no", - columnType: "string", + label: 'Reference No.', + column: 'reference_no', + columnType: 'string', }, description: { - label: "Description", - column: "description", - columnType: "string", + label: 'Description', + column: 'description', + columnType: 'string', }, published: { - label: "Published", - column: "published_at", + label: 'Published', + column: 'published_at', }, status: { label: 'Status', @@ -194,9 +203,9 @@ export default class Expense extends TenantModel { }, }, created_at: { - label: "Created at", - column: "created_at", - columnType: "date", + label: 'Created at', + column: 'created_at', + columnType: 'date', }, }; } diff --git a/server/src/models/ExpenseCategory.js b/server/src/models/ExpenseCategory.js index 80b89e89f..50416805e 100644 --- a/server/src/models/ExpenseCategory.js +++ b/server/src/models/ExpenseCategory.js @@ -9,6 +9,21 @@ export default class ExpenseCategory extends TenantModel { return 'expense_transaction_categories'; } + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['unallocatedCostAmount']; + } + + /** + * Remain unallocated landed cost. + * @return {number} + */ + get unallocatedCostAmount() { + return Math.max(this.amount - this.allocatedCostAmount, 0); + } + /** * Relationship mapping. */ diff --git a/server/src/models/ItemEntry.js b/server/src/models/ItemEntry.js index 3434b01af..a8156fc01 100644 --- a/server/src/models/ItemEntry.js +++ b/server/src/models/ItemEntry.js @@ -21,8 +21,8 @@ export default class ItemEntry extends TenantModel { return ['amount']; } - static amount() { - return this.calcAmount(this); + get amount() { + return ItemEntry.calcAmount(this); } static calcAmount(itemEntry) { @@ -34,6 +34,7 @@ export default class ItemEntry extends TenantModel { static get relationMappings() { const Item = require('models/Item'); + const BillLandedCostEntry = require('models/BillLandedCostEntry'); return { item: { @@ -44,6 +45,14 @@ export default class ItemEntry extends TenantModel { to: 'items.id', }, }, + allocatedCostEntries: { + relation: Model.HasManyRelation, + modelClass: BillLandedCostEntry.default, + join: { + from: 'items_entries.referenceId', + to: 'bill_located_cost_entries.entryId', + }, + }, }; } } diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts index 15f731f3c..4a5df9bf0 100644 --- a/server/src/services/Accounting/JournalCommands.ts +++ b/server/src/services/Accounting/JournalCommands.ts @@ -1,9 +1,11 @@ import moment from 'moment'; +import { sumBy } from 'lodash'; import { IBill, IManualJournalEntry, ISaleReceipt, ISystemUser, + IAccount, } from 'interfaces'; import JournalPoster from './JournalPoster'; import JournalEntry from './JournalEntry'; @@ -17,7 +19,6 @@ import { IItemEntry, } from 'interfaces'; import { increment } from 'utils'; - export default class JournalCommands { journal: JournalPoster; models: any; @@ -37,45 +38,20 @@ export default class JournalCommands { /** * Records the bill journal entries. * @param {IBill} bill - * @param {boolean} override - Override the old bill entries. + * @param {IAccount} payableAccount - */ - async bill(bill: IBill, override: boolean = false): Promise { - const { transactionsRepository, accountRepository } = this.repositories; - const { Item, ItemEntry } = this.models; - - const entriesItemsIds = bill.entries.map((entry) => entry.itemId); - - // Retrieve the bill transaction items. - const storedItems = await Item.query().whereIn('id', entriesItemsIds); - - const storedItemsMap = new Map(storedItems.map((item) => [item.id, item])); - const payableAccount = await accountRepository.findOne({ - slug: 'accounts-payable', - }); - const formattedDate = moment(bill.billDate).format('YYYY-MM-DD'); - + bill(bill: IBill, payableAccount: IAccount): void { const commonJournalMeta = { debit: 0, credit: 0, referenceId: bill.id, referenceType: 'Bill', - date: formattedDate, + date: moment(bill.billDate).format('YYYY-MM-DD'), userId: bill.userId, - referenceNumber: bill.referenceNo, transactionNumber: bill.billNumber, - createdAt: bill.createdAt, }; - // Overrides the old bill entries. - if (override) { - const entries = await transactionsRepository.journal({ - referenceType: ['Bill'], - referenceId: [bill.id], - }); - this.journal.fromTransactions(entries); - this.journal.removeEntries(); - } const payableEntry = new JournalEntry({ ...commonJournalMeta, credit: bill.amount, @@ -86,15 +62,15 @@ export default class JournalCommands { this.journal.credit(payableEntry); bill.entries.forEach((entry, index) => { - const item: IItem = storedItemsMap.get(entry.itemId); - const amount = ItemEntry.calcAmount(entry); + const landedCostAmount = sumBy(entry.allocatedCostEntries, 'cost'); + // Inventory or cost entry. const debitEntry = new JournalEntry({ ...commonJournalMeta, - debit: amount, + debit: entry.amount + landedCostAmount, account: - ['inventory'].indexOf(item.type) !== -1 - ? item.inventoryAccountId + ['inventory'].indexOf(entry.item.type) !== -1 + ? entry.item.inventoryAccountId : entry.costAccountId, index: index + 2, itemId: entry.itemId, @@ -102,6 +78,16 @@ export default class JournalCommands { }); this.journal.debit(debitEntry); }); + + // Allocate cost entries journal entries. + bill.locatedLandedCosts.forEach((landedCost) => { + const creditEntry = new JournalEntry({ + ...commonJournalMeta, + credit: landedCost.amount, + account: landedCost.costAccountId, + }); + this.journal.credit(creditEntry); + }); } /** diff --git a/server/src/services/Entries/index.ts b/server/src/services/Entries/index.ts new file mode 100644 index 000000000..482fffa5a --- /dev/null +++ b/server/src/services/Entries/index.ts @@ -0,0 +1,78 @@ +import { Service } from 'typedi'; +import { ServiceError } from 'exceptions'; +import { transformToMap } from 'utils'; +import { + ICommonLandedCostEntry, + ICommonLandedCostEntryDTO +} from 'interfaces'; + +const ERRORS = { + ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED: + 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED', + LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES: + 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES', +}; + +@Service() +export default class EntriesService { + /** + * Validates bill entries that has allocated landed cost amount not deleted. + * @param {IItemEntry[]} oldCommonEntries - + * @param {IItemEntry[]} newBillEntries - + */ + public getLandedCostEntriesDeleted( + oldCommonEntries: ICommonLandedCostEntry[], + newCommonEntriesDTO: ICommonLandedCostEntryDTO[] + ): ICommonLandedCostEntry[] { + const newBillEntriesById = transformToMap(newCommonEntriesDTO, 'id'); + + return oldCommonEntries.filter((entry) => { + const newEntry = newBillEntriesById.get(entry.id); + + if (entry.allocatedCostAmount > 0 && typeof newEntry === 'undefined') { + return true; + } + return false; + }); + } + + /** + * Validates the bill entries that have located cost amount should not be deleted. + * @param {IItemEntry[]} oldCommonEntries - Old bill entries. + * @param {IItemEntryDTO[]} newBillEntries - New DTO bill entries. + */ + public validateLandedCostEntriesNotDeleted( + oldCommonEntries: ICommonLandedCostEntry[], + newCommonEntriesDTO: ICommonLandedCostEntryDTO[] + ): void { + const entriesDeleted = this.getLandedCostEntriesDeleted( + oldCommonEntries, + newCommonEntriesDTO + ); + if (entriesDeleted.length > 0) { + throw new ServiceError(ERRORS.ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED); + } + } + + /** + * Validate allocated cost amount entries should be smaller than new entries amount. + * @param {IItemEntry[]} oldCommonEntries - Old bill entries. + * @param {IItemEntryDTO[]} newBillEntries - New DTO bill entries. + */ + public validateLocatedCostEntriesSmallerThanNewEntries( + oldCommonEntries: ICommonLandedCostEntry[], + newCommonEntriesDTO: ICommonLandedCostEntryDTO[] + ): void { + const oldBillEntriesById = transformToMap(oldCommonEntries, 'id'); + + newCommonEntriesDTO.forEach((entry) => { + const oldEntry = oldBillEntriesById.get(entry.id); + + if (oldEntry && oldEntry.allocatedCostAmount > entry.amount) { + throw new ServiceError( + ERRORS.LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES + ); + } + }); + } +} diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts index 1e3670e08..39a8e8fdd 100644 --- a/server/src/services/Expenses/ExpensesService.ts +++ b/server/src/services/Expenses/ExpensesService.ts @@ -17,11 +17,13 @@ import { IExpensesService, ISystemUser, IPaginationMeta, + IExpenseCategory, } from 'interfaces'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; import ContactsService from 'services/Contacts/ContactsService'; -import { ACCOUNT_PARENT_TYPE, ACCOUNT_ROOT_TYPE } from 'data/AccountTypes' +import { ACCOUNT_PARENT_TYPE, ACCOUNT_ROOT_TYPE } from 'data/AccountTypes'; +import EntriesService from 'services/Entries'; const ERRORS = { EXPENSE_NOT_FOUND: 'expense_not_found', @@ -32,6 +34,7 @@ const ERRORS = { PAYMENT_ACCOUNT_HAS_INVALID_TYPE: 'payment_account_has_invalid_type', EXPENSES_ACCOUNT_HAS_INVALID_TYPE: 'expenses_account_has_invalid_type', EXPENSE_ALREADY_PUBLISHED: 'expense_already_published', + EXPENSE_HAS_ASSOCIATED_LANDED_COST: 'EXPENSE_HAS_ASSOCIATED_LANDED_COST', }; @Service() @@ -51,6 +54,9 @@ export default class ExpensesService implements IExpensesService { @Inject() contactsService: ContactsService; + @Inject() + entriesService: EntriesService; + /** * Retrieve the payment account details or returns not found server error in case the * given account not found on the storage. @@ -249,14 +255,16 @@ export default class ExpensesService implements IExpensesService { * @returns {IExpense|ServiceError} */ private async getExpenseOrThrowError(tenantId: number, expenseId: number) { - const { expenseRepository } = this.tenancy.repositories(tenantId); + const { Expense } = this.tenancy.models(tenantId); this.logger.info('[expense] trying to get the given expense.', { tenantId, expenseId, }); // Retrieve the given expense by id. - const expense = await expenseRepository.findOneById(expenseId); + const expense = await Expense.query() + .findById(expenseId) + .withGraphFetched('categories'); if (!expense) { this.logger.info('[expense] the given expense not found.', { @@ -308,6 +316,27 @@ export default class ExpensesService implements IExpensesService { } } + /** + * Retrieve the expense landed cost amount. + * @param {IExpenseDTO} expenseDTO + * @return {number} + */ + private getExpenseLandedCostAmount(expenseDTO: IExpenseDTO): number { + const landedCostEntries = expenseDTO.categories.filter((entry) => { + return entry.landedCost === true; + }); + return this.getExpenseCategoriesTotal(landedCostEntries); + } + + /** + * Retrieve the given expense categories total. + * @param {IExpenseCategory} categories + * @returns {number} + */ + private getExpenseCategoriesTotal(categories): number { + return sumBy(categories, 'amount'); + } + /** * Mapping expense DTO to model. * @param {IExpenseDTO} expenseDTO @@ -315,12 +344,14 @@ export default class ExpensesService implements IExpensesService { * @return {IExpense} */ private expenseDTOToModel(expenseDTO: IExpenseDTO, user?: ISystemUser) { - const totalAmount = sumBy(expenseDTO.categories, 'amount'); + const landedCostAmount = this.getExpenseLandedCostAmount(expenseDTO); + const totalAmount = this.getExpenseCategoriesTotal(expenseDTO.categories); return { categories: [], ...omit(expenseDTO, ['publish']), totalAmount, + landedCostAmount, paymentDate: moment(expenseDTO.paymentDate).toMySqlDateTime(), ...(user ? { @@ -340,7 +371,7 @@ export default class ExpensesService implements IExpensesService { * @param {IExpenseDTO} expenseDTO * @return {number[]} */ - mapExpensesAccountsIdsFromDTO(expenseDTO: IExpenseDTO) { + private mapExpensesAccountsIdsFromDTO(expenseDTO: IExpenseDTO) { return expenseDTO.categories.map((category) => category.expenseAccountId); } @@ -434,36 +465,47 @@ export default class ExpensesService implements IExpensesService { const { expenseRepository } = this.tenancy.repositories(tenantId); const oldExpense = await this.getExpenseOrThrowError(tenantId, expenseId); - // - Validate payment account existance on the storage. + // Validate payment account existance on the storage. const paymentAccount = await this.getPaymentAccountOrThrowError( tenantId, expenseDTO.paymentAccountId ); - // - Validate expense accounts exist on the storage. + // Validate expense accounts exist on the storage. const expensesAccounts = await this.getExpensesAccountsOrThrowError( tenantId, this.mapExpensesAccountsIdsFromDTO(expenseDTO) ); - // - Validate payment account type. + // Validate payment account type. await this.validatePaymentAccountType(tenantId, paymentAccount); - // - Validate expenses accounts type. + // Validate expenses accounts type. await this.validateExpensesAccountsType(tenantId, expensesAccounts); - // - Validate the expense payee contact id existance on storage. + // Validate the expense payee contact id existance on storage. if (expenseDTO.payeeId) { await this.contactsService.getContactByIdOrThrowError( tenantId, expenseDTO.payeeId ); } - // - Validate the given expense categories not equal zero. + // Validate the given expense categories not equal zero. this.validateCategoriesNotEqualZero(expenseDTO); - // - Update the expense on the storage. + // Update the expense on the storage. const expenseObj = this.expenseDTOToModel(expenseDTO); - // - Upsert the expense object with expense entries. + // Validate expense entries that have allocated landed cost cannot be deleted. + this.entriesService.validateLandedCostEntriesNotDeleted( + oldExpense.categories, + expenseDTO.categories, + ); + // Validate expense entries that have allocated cost amount should be bigger than amount. + this.entriesService.validateLocatedCostEntriesSmallerThanNewEntries( + oldExpense.categories, + expenseDTO.categories, + ); + + // Upsert the expense object with expense entries. const expense = await expenseRepository.upsertGraph({ id: expenseId, ...expenseObj, @@ -544,15 +586,16 @@ export default class ExpensesService implements IExpensesService { authorizedUser: ISystemUser ): Promise { const oldExpense = await this.getExpenseOrThrowError(tenantId, expenseId); - const { - expenseRepository, - expenseEntryRepository, - } = this.tenancy.repositories(tenantId); + const { expenseRepository, expenseEntryRepository } = + this.tenancy.repositories(tenantId); this.logger.info('[expense] trying to delete the expense.', { tenantId, expenseId, }); + // Validates the expense has no associated landed cost. + await this.validateNoAssociatedLandedCost(tenantId, expenseId); + await expenseEntryRepository.deleteBy({ expenseId }); await expenseRepository.deleteById(expenseId); @@ -572,7 +615,7 @@ export default class ExpensesService implements IExpensesService { /** * Filters the not published expenses. - * @param {IExpense[]} expenses - + * @param {IExpense[]} expenses - */ public getNonePublishedExpenses(expenses: IExpense[]): IExpense[] { return expenses.filter((expense) => !expense.publishedAt); @@ -648,4 +691,25 @@ export default class ExpensesService implements IExpensesService { } return expense; } + + /** + * Validates the expense has not associated landed cost + * references to the given expense. + * @param {number} tenantId + * @param {number} expenseId + */ + public async validateNoAssociatedLandedCost( + tenantId: number, + expenseId: number + ) { + const { BillLandedCost } = this.tenancy.models(tenantId); + + const associatedLandedCosts = await BillLandedCost.query() + .where('fromTransactionType', 'Expense') + .where('fromTransactionId', expenseId); + + if (associatedLandedCosts.length > 0) { + throw new ServiceError(ERRORS.EXPENSE_HAS_ASSOCIATED_LANDED_COST); + } + } } diff --git a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts index de623f854..21b60064b 100644 --- a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts +++ b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts @@ -83,7 +83,7 @@ export default class ARAgingSummaryService { }); // Retrieve all customers from the storage. const customers = - filter.customersIds.length > 0 + (filter.customersIds.length > 0) ? await customerRepository.findWhereIn('id', filter.customersIds) : await customerRepository.all(); diff --git a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts index 8cc98ca0a..538febe9b 100644 --- a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts +++ b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts @@ -1,5 +1,6 @@ import { Inject } from 'typedi'; import { raw } from 'objection'; +import { isEmpty } from 'lodash'; import moment from 'moment'; import { IItem, @@ -17,10 +18,16 @@ export default class InventoryDetailsRepository { * @param {number} tenantId - * @returns {Promise} */ - public getInventoryItems(tenantId: number): Promise { + public getInventoryItems(tenantId: number, itemsIds?: number[]): Promise { const { Item } = this.tenancy.models(tenantId); - return Item.query().where('type', 'inventory'); + return Item.query().onBuild((q) => { + q.where('type', 'inventory'); + + if (!isEmpty(itemsIds)) { + q.whereIn('id', itemsIds); + } + }) } /** diff --git a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts index c1628042e..93e8045a0 100644 --- a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts +++ b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts @@ -31,6 +31,7 @@ export default class InventoryDetailsService extends FinancialSheet { return { fromDate: moment().startOf('year').format('YYYY-MM-DD'), toDate: moment().endOf('year').format('YYYY-MM-DD'), + itemsIds: [], numberFormat: { precision: 2, divideOn1000: false, @@ -91,8 +92,10 @@ export default class InventoryDetailsService extends FinancialSheet { ...query, }; // Retrieves the items. - const items = await this.reportRepo.getInventoryItems(tenantId); - + const items = await this.reportRepo.getInventoryItems( + tenantId, + filter.itemsIds + ); // Opening balance transactions. const openingBalanceTransactions = await this.reportRepo.openingBalanceTransactions(tenantId, filter); diff --git a/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts b/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts index fe818b419..f34d12eb6 100644 --- a/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts +++ b/server/src/services/FinancialStatements/InventoryValuationSheet/InventoryValuationSheetService.ts @@ -26,6 +26,7 @@ export default class InventoryValuationSheetService { get defaultQuery(): IInventoryValuationReportQuery { return { asDate: moment().endOf('year').format('YYYY-MM-DD'), + itemsIds: [], numberFormat: { precision: 2, divideOn1000: false, @@ -75,9 +76,6 @@ export default class InventoryValuationSheetService { ) { const { Item, InventoryCostLotTracker } = this.tenancy.models(tenantId); - const inventoryItems = await Item.query().where('type', 'inventory'); - const inventoryItemsIds = inventoryItems.map((item) => item.id); - // Settings tenant service. const settings = this.tenancy.settings(tenantId); const baseCurrency = settings.get({ @@ -89,6 +87,15 @@ export default class InventoryValuationSheetService { ...this.defaultQuery, ...query, }; + const inventoryItems = await Item.query().onBuild(q => { + q.where('type', 'inventory'); + + if (filter.itemsIds.length > 0) { + q.whereIn('id', filter.itemsIds); + } + }); + const inventoryItemsIds = inventoryItems.map((item) => item.id); + const commonQuery = (builder) => { builder.whereIn('item_id', inventoryItemsIds); builder.sum('rate as rate'); diff --git a/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts b/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts index aa52120c1..80455f585 100644 --- a/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts +++ b/server/src/services/FinancialStatements/PurchasesByItems/PurchasesByItemsService.ts @@ -24,6 +24,7 @@ export default class InventoryValuationReportService { return { fromDate: moment().startOf('year').format('YYYY-MM-DD'), toDate: moment().endOf('year').format('YYYY-MM-DD'), + itemsIds: [], numberFormat: { precision: 2, divideOn1000: false, @@ -91,7 +92,13 @@ export default class InventoryValuationReportService { filter, tenantId, }); - const inventoryItems = await Item.query().where('type', 'inventory'); + const inventoryItems = await Item.query().onBuild(q => { + q.where('type', 'inventory'); + + if (filter.itemsIds.length > 0) { + q.whereIn('id', filter.itemsIds); + } + }); const inventoryItemsIds = inventoryItems.map((item) => item.id); // Calculates the total inventory total quantity and rate `IN` transactions. diff --git a/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts b/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts index bbcc05100..093027fa2 100644 --- a/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts +++ b/server/src/services/FinancialStatements/SalesByItems/SalesByItemsService.ts @@ -24,6 +24,7 @@ export default class SalesByItemsReportService { return { fromDate: moment().startOf('year').format('YYYY-MM-DD'), toDate: moment().endOf('year').format('YYYY-MM-DD'), + itemsIds: [], numberFormat: { precision: 2, divideOn1000: false, @@ -91,7 +92,14 @@ export default class SalesByItemsReportService { filter, tenantId, }); - const inventoryItems = await Item.query().where('type', 'inventory'); + // Inventory items for sales report. + const inventoryItems = await Item.query().onBuild((q) => { + q.where('type', 'inventory'); + + if (filter.itemsIds.length > 0) { + q.whereIn('id', filter.itemsIds); + } + }); const inventoryItemsIds = inventoryItems.map((item) => item.id); // Calculates the total inventory total quantity and rate `IN` transactions. diff --git a/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts index 603c339db..e3201660a 100644 --- a/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts +++ b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts @@ -1,4 +1,4 @@ -import { map } from 'lodash'; +import { isEmpty, map } from 'lodash'; import { IAccount, IAccountTransaction } from 'interfaces'; import { ACCOUNT_TYPE } from 'data/AccountTypes'; import HasTenancyService from 'services/Tenancy/TenancyService'; @@ -13,10 +13,16 @@ export default class TransactionsByCustomersRepository { * @param {number} tenantId * @returns {Promise} */ - public async getCustomers(tenantId: number) { + public async getCustomers(tenantId: number, customersIds?: number[]) { const { Customer } = this.tenancy.models(tenantId); - return Customer.query().orderBy('displayName'); + return Customer.query().onBuild((q) => { + q.orderBy('displayName'); + + if (!isEmpty(customersIds)) { + q.whereIn('id', customersIds); + } + }); } /** diff --git a/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts index 0de5a070c..548ee7645 100644 --- a/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts +++ b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts @@ -44,6 +44,8 @@ export default class TransactionsByCustomersService }, noneZero: false, noneTransactions: false, + + customersIds: [], }; } @@ -125,7 +127,7 @@ export default class TransactionsByCustomersService const accountsGraph = await accountRepository.getDependencyGraph(); // Retrieve the report customers. - const customers = await this.reportRepository.getCustomers(tenantId); + const customers = await this.reportRepository.getCustomers(tenantId, filter.customersIds); const openingBalanceDate = moment(filter.fromDate) .subtract(1, 'days') diff --git a/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts index c67b09741..daa9f3424 100644 --- a/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts +++ b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts @@ -1,5 +1,5 @@ import { Inject, Service } from 'typedi'; -import { map } from 'lodash'; +import { isEmpty, map } from 'lodash'; import { IVendor, IAccount, IAccountTransaction } from 'interfaces'; import HasTenancyService from 'services/Tenancy/TenancyService'; import { ACCOUNT_TYPE } from 'data/AccountTypes'; @@ -14,10 +14,19 @@ export default class TransactionsByVendorRepository { * @param {number} tenantId * @returns {Promise} */ - public getVendors(tenantId: number): Promise { + public getVendors( + tenantId: number, + vendorsIds?: number[] + ): Promise { const { Vendor } = this.tenancy.models(tenantId); - return Vendor.query().orderBy('displayName'); + return Vendor.query().onBuild((q) => { + q.orderBy('displayName'); + + if (!isEmpty(vendorsIds)) { + q.whereIn('id', vendorsIds); + } + }); } /** @@ -67,7 +76,7 @@ export default class TransactionsByVendorRepository { * @param {Date|string} openingDate * @param {number[]} customersIds */ - public async getVendorsPeriodTransactions( + public async getVendorsPeriodTransactions( tenantId: number, fromDate: Date, toDate: Date diff --git a/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts index 354e5fa45..db934fbd3 100644 --- a/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts +++ b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts @@ -45,6 +45,8 @@ export default class TransactionsByVendorsService }, noneZero: false, noneTransactions: false, + + vendorsIds: [], }; } @@ -139,12 +141,13 @@ export default class TransactionsByVendorsService group: 'organization', key: 'base_currency', }); - const filter = { ...this.defaultQuery, ...query }; // Retrieve the report vendors. - const vendors = await this.reportRepository.getVendors(tenantId); - + const vendors = await this.reportRepository.getVendors( + tenantId, + filter.vendorsIds + ); // Retrieve the accounts graph. const accountsGraph = await accountRepository.getDependencyGraph(); diff --git a/server/src/services/Purchases/Bills.ts b/server/src/services/Purchases/Bills.ts index b35284e40..439cdfb74 100644 --- a/server/src/services/Purchases/Bills.ts +++ b/server/src/services/Purchases/Bills.ts @@ -1,4 +1,4 @@ -import { omit, sumBy } from 'lodash'; +import { omit, runInContext, sumBy } from 'lodash'; import moment from 'moment'; import { Inject, Service } from 'typedi'; import composeAsync from 'async/compose'; @@ -13,7 +13,7 @@ import InventoryService from 'services/Inventory/Inventory'; import SalesInvoicesCost from 'services/Sales/SalesInvoicesCost'; import TenancyService from 'services/Tenancy/TenancyService'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; -import { formatDateFields } from 'utils'; +import { formatDateFields, transformToMap } from 'utils'; import { IBillDTO, IBill, @@ -24,6 +24,7 @@ import { IBillsFilter, IBillsService, IItemEntry, + IItemEntryDTO, } from 'interfaces'; import { ServiceError } from 'exceptions'; import ItemsService from 'services/Items/ItemsService'; @@ -32,6 +33,7 @@ import JournalCommands from 'services/Accounting/JournalCommands'; import JournalPosterService from 'services/Sales/JournalPosterService'; import VendorsService from 'services/Contacts/VendorsService'; import { ERRORS } from './constants'; +import EntriesService from 'services/Entries'; /** * Vendor bills services. @@ -40,7 +42,8 @@ import { ERRORS } from './constants'; @Service('Bills') export default class BillsService extends SalesInvoicesCost - implements IBillsService { + implements IBillsService +{ @Inject() inventoryService: InventoryService; @@ -71,6 +74,9 @@ export default class BillsService @Inject() vendorsService: VendorsService; + @Inject() + entriesService: EntriesService; + /** * Validates whether the vendor is exist. * @async @@ -100,7 +106,7 @@ export default class BillsService * @param {number} tenantId - * @param {number} billId - */ - private async getBillOrThrowError(tenantId: number, billId: number) { + public async getBillOrThrowError(tenantId: number, billId: number) { const { Bill } = this.tenancy.models(tenantId); this.logger.info('[bill] trying to get bill.', { tenantId, billId }); @@ -165,16 +171,63 @@ export default class BillsService * Validate the bill number require. * @param {string} billNo - */ - validateBillNoRequire(billNo: string) { + private validateBillNoRequire(billNo: string) { if (!billNo) { throw new ServiceError(ERRORS.BILL_NO_IS_REQUIRED); } } + /** + * Validate bill transaction has no associated allocated landed cost transactions. + * @param {number} tenantId + * @param {number} billId + */ + private async validateBillHasNoLandedCost(tenantId: number, billId: number) { + const { BillLandedCost } = this.tenancy.models(tenantId); + + const billLandedCosts = await BillLandedCost.query().where( + 'billId', + billId + ); + if (billLandedCosts.length > 0) { + throw new ServiceError(ERRORS.BILL_HAS_ASSOCIATED_LANDED_COSTS); + } + } + + /** + * Validate transaction entries that have landed cost type should not be + * inventory items. + * @param {number} tenantId - + * @param {IItemEntryDTO[]} newEntriesDTO - + */ + public async validateCostEntriesShouldBeInventoryItems( + tenantId: number, + newEntriesDTO: IItemEntryDTO[] + ) { + const { Item } = this.tenancy.models(tenantId); + + const entriesItemsIds = newEntriesDTO.map((e) => e.itemId); + const entriesItems = await Item.query().whereIn('id', entriesItemsIds); + + const entriesItemsById = transformToMap(entriesItems, 'id'); + + // Filter the landed cost entries that not associated with inventory item. + const nonInventoryHasCost = newEntriesDTO.filter((entry) => { + const item = entriesItemsById.get(entry.itemId); + + return entry.landedCost && item.type !== 'inventory'; + }); + if (nonInventoryHasCost.length > 0) { + throw new ServiceError( + ERRORS.LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS + ); + } + } + /** * Sets the default cost account to the bill entries. */ - setBillEntriesDefaultAccounts(tenantId: number) { + private setBillEntriesDefaultAccounts(tenantId: number) { return async (entries: IItemEntry[]) => { const { Item } = this.tenancy.models(tenantId); @@ -194,6 +247,28 @@ export default class BillsService }; } + /** + * Retrieve the bill entries total. + * @param {IItemEntry[]} entries + * @returns {number} + */ + private getBillEntriesTotal(tenantId: number, entries: IItemEntry[]): number { + const { ItemEntry } = this.tenancy.models(tenantId); + + return sumBy(entries, (e) => ItemEntry.calcAmount(e)); + } + + /** + * Retrieve the bill landed cost amount. + * @param {IBillDTO} billDTO + * @returns {number} + */ + private getBillLandedCostAmount(tenantId: number, billDTO: IBillDTO): number { + const costEntries = billDTO.entries.filter((entry) => entry.landedCost); + + return this.getBillEntriesTotal(tenantId, costEntries); + } + /** * Converts create bill DTO to model. * @param {number} tenantId @@ -211,6 +286,9 @@ export default class BillsService const amount = sumBy(billDTO.entries, (e) => ItemEntry.calcAmount(e)); + // Retrieve the landed cost amount from landed cost entries. + const landedCostAmount = this.getBillLandedCostAmount(tenantId, billDTO); + // Bill number from DTO or from auto-increment. const billNumber = billDTO.billNumber || oldBill?.billNumber; @@ -220,6 +298,7 @@ export default class BillsService billDTO.vendorId ); const initialEntries = billDTO.entries.map((entry) => ({ + amount: ItemEntry.calcAmount(entry), reference_type: 'Bill', ...omit(entry, ['amount']), })); @@ -234,6 +313,7 @@ export default class BillsService 'dueDate', ]), amount, + landedCostAmount, currencyCode: vendor.currencyCode, billNumber, entries, @@ -284,6 +364,10 @@ export default class BillsService tenantId, billDTO.entries ); + await this.validateCostEntriesShouldBeInventoryItems( + tenantId, + billDTO.entries, + ); this.logger.info('[bill] trying to create a new bill', { tenantId, billDTO, @@ -370,6 +454,16 @@ export default class BillsService authorizedUser, oldBill ); + // Validate landed cost entries that have allocated cost could not be deleted. + await this.entriesService.validateLandedCostEntriesNotDeleted( + oldBill.entries, + billObj.entries + ); + // Validate new landed cost entries should be bigger than new entries. + await this.entriesService.validateLocatedCostEntriesSmallerThanNewEntries( + oldBill.entries, + billObj.entries + ); // Update the bill transaction. const bill = await billRepository.upsertGraph({ id: billId, @@ -402,6 +496,9 @@ export default class BillsService // Retrieve the given bill or throw not found error. const oldBill = await this.getBillOrThrowError(tenantId, billId); + // Validate the givne bill has no associated landed cost transactions. + await this.validateBillHasNoLandedCost(tenantId, billId); + // Validate the purchase bill has no assocaited payments transactions. await this.validateBillHasNoEntries(tenantId, billId); @@ -498,7 +595,7 @@ export default class BillsService const bill = await Bill.query() .findById(billId) .withGraphFetched('vendor') - .withGraphFetched('entries'); + .withGraphFetched('entries.item'); if (!bill) { throw new ServiceError(ERRORS.BILL_NOT_FOUND); @@ -534,18 +631,25 @@ export default class BillsService */ public async recordInventoryTransactions( tenantId: number, - bill: IBill, + billId: number, override?: boolean ): Promise { + const { Bill } = this.tenancy.models(tenantId); + + // Retireve bill with assocaited entries and allocated cost entries. + const bill = await Bill.query() + .findById(billId) + .withGraphFetched('entries.allocatedCostEntries'); + // Loads the inventory items entries of the given sale invoice. - const inventoryEntries = await this.itemsEntriesService.filterInventoryEntries( - tenantId, - bill.entries - ); + const inventoryEntries = + await this.itemsEntriesService.filterInventoryEntries( + tenantId, + bill.entries + ); const transaction = { transactionId: bill.id, transactionType: 'Bill', - date: bill.billDate, direction: 'IN', entries: inventoryEntries, @@ -581,13 +685,30 @@ export default class BillsService */ public async recordJournalTransactions( tenantId: number, - bill: IBill, + billId: number, override: boolean = false ) { + const { Bill, Account } = this.tenancy.models(tenantId); + const journal = new JournalPoster(tenantId); const journalCommands = new JournalCommands(journal); - await journalCommands.bill(bill, override); + const bill = await Bill.query() + .findById(billId) + .withGraphFetched('entries.item') + .withGraphFetched('entries.allocatedCostEntries') + .withGraphFetched('locatedLandedCosts.allocateEntries'); + + const payableAccount = await Account.query().findOne({ + slug: 'accounts-payable', + }); + + // Overrides the bill journal entries. + if (override) { + await journalCommands.revertJournalEntries(billId, 'Bill'); + } + // Writes the bill journal entries. + journalCommands.bill(bill, payableAccount); return Promise.all([ journal.deleteEntries(), diff --git a/server/src/services/Purchases/LandedCost/BillLandedCost.ts b/server/src/services/Purchases/LandedCost/BillLandedCost.ts new file mode 100644 index 000000000..92fcb89ee --- /dev/null +++ b/server/src/services/Purchases/LandedCost/BillLandedCost.ts @@ -0,0 +1,58 @@ +import { Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IBill, + IItem, + ILandedCostTransactionEntry, + ILandedCostTransaction, + IItemEntry, +} from 'interfaces'; + +@Service() +export default class BillLandedCost { + /** + * Retrieve the landed cost transaction from the given bill transaction. + * @param {IBill} bill - Bill transaction. + * @returns {ILandedCostTransaction} - Landed cost transaction. + */ + public transformToLandedCost = (bill: IBill): ILandedCostTransaction => { + const number = bill.billNumber || bill.referenceNo; + const name = [ + number, + bill.currencyCode + ' ' + bill.unallocatedCostAmount, + ].join(' - '); + + return { + id: bill.id, + name, + allocatedCostAmount: bill.allocatedCostAmount, + amount: bill.landedCostAmount, + unallocatedCostAmount: bill.unallocatedCostAmount, + transactionType: 'Bill', + + ...(!isEmpty(bill.entries)) && { + entries: bill.entries.map(this.transformToLandedCostEntry), + }, + }; + }; + + /** + * Transformes bill entry to landed cost entry. + * @param {IItemEntry} billEntry - Bill entry. + * @return {ILandedCostTransactionEntry} + */ + public transformToLandedCostEntry( + billEntry: IItemEntry & { item: IItem } + ): ILandedCostTransactionEntry { + return { + id: billEntry.id, + name: billEntry.item.name, + code: billEntry.item.code, + amount: billEntry.amount, + unallocatedCostAmount: billEntry.unallocatedCostAmount, + allocatedCostAmount: billEntry.allocatedCostAmount, + description: billEntry.description, + costAccountId: billEntry.costAccountId || billEntry.item.costAccountId, + }; + } +} diff --git a/server/src/services/Purchases/LandedCost/ExpenseLandedCost.ts b/server/src/services/Purchases/LandedCost/ExpenseLandedCost.ts new file mode 100644 index 000000000..078ae6627 --- /dev/null +++ b/server/src/services/Purchases/LandedCost/ExpenseLandedCost.ts @@ -0,0 +1,56 @@ +import { Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IExpense, + ILandedCostTransactionEntry, + IExpenseCategory, + IAccount, + ILandedCostTransaction, +} from 'interfaces'; + +@Service() +export default class ExpenseLandedCost { + /** + * Retrieve the landed cost transaction from the given expense transaction. + * @param {IExpense} expense + * @returns {ILandedCostTransaction} + */ + public transformToLandedCost = ( + expense: IExpense + ): ILandedCostTransaction => { + const name = [expense.currencyCode + ' ' + expense.totalAmount].join(' - '); + + return { + id: expense.id, + name, + allocatedCostAmount: expense.allocatedCostAmount, + amount: expense.landedCostAmount, + unallocatedCostAmount: expense.unallocatedCostAmount, + transactionType: 'Expense', + + ...(!isEmpty(expense.categories) && { + entries: expense.categories.map(this.transformToLandedCostEntry), + }), + }; + }; + + /** + * Transformes expense entry to landed cost entry. + * @param {IExpenseCategory & { expenseAccount: IAccount }} expenseEntry - + * @return {ILandedCostTransactionEntry} + */ + public transformToLandedCostEntry = ( + expenseEntry: IExpenseCategory & { expenseAccount: IAccount } + ): ILandedCostTransactionEntry => { + return { + id: expenseEntry.id, + name: expenseEntry.expenseAccount.name, + code: expenseEntry.expenseAccount.code, + amount: expenseEntry.amount, + description: expenseEntry.description, + allocatedCostAmount: expenseEntry.allocatedCostAmount, + unallocatedCostAmount: expenseEntry.unallocatedCostAmount, + costAccountId: expenseEntry.expenseAccount.id, + }; + }; +} diff --git a/server/src/services/Purchases/LandedCost/LandedCostListing.ts b/server/src/services/Purchases/LandedCost/LandedCostListing.ts new file mode 100644 index 000000000..afa02a5aa --- /dev/null +++ b/server/src/services/Purchases/LandedCost/LandedCostListing.ts @@ -0,0 +1,86 @@ +import { Inject, Service } from 'typedi'; +import { ref, transaction } from 'objection'; +import { + ILandedCostTransactionsQueryDTO, + ILandedCostTransaction, + IBillLandedCostTransaction, +} from 'interfaces'; +import TransactionLandedCost from './TransctionLandedCost'; +import BillsService from '../Bills'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { formatNumber } from 'utils'; + +@Service() +export default class LandedCostListing { + @Inject() + transactionLandedCost: TransactionLandedCost; + + @Inject() + billsService: BillsService; + + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the landed costs based on the given query. + * @param {number} tenantId + * @param {ILandedCostTransactionsQueryDTO} query + * @returns {Promise} + */ + public getLandedCostTransactions = async ( + tenantId: number, + query: ILandedCostTransactionsQueryDTO + ): Promise => { + const { transactionType } = query; + const Model = this.transactionLandedCost.getModel( + tenantId, + query.transactionType + ); + + // Retrieve the model entities. + const transactions = await Model.query().onBuild((q) => { + q.where('allocated_cost_amount', '<', ref('landed_cost_amount')); + + if (query.transactionType === 'Bill') { + q.withGraphFetched('entries.item'); + } else if (query.transactionType === 'Expense') { + q.withGraphFetched('categories.expenseAccount'); + } + }); + return transactions.map((transaction) => ({ + ...this.transactionLandedCost.transformToLandedCost( + transactionType, + transaction + ), + })); + }; + + /** + * Retrieve the bill associated landed cost transactions. + * @param {number} tenantId - Tenant id. + * @param {number} billId - Bill id. + * @return {Promise} + */ + public getBillLandedCostTransactions = async ( + tenantId: number, + billId: number + ): Promise => { + const { BillLandedCost } = this.tenancy.models(tenantId); + + // Retrieve the given bill id or throw not found service error. + const bill = await this.billsService.getBillOrThrowError(tenantId, billId); + + const landedCostTransactions = await BillLandedCost.query() + .where('bill_id', billId) + .withGraphFetched('allocateEntries') + .withGraphFetched('bill'); + + return landedCostTransactions.map((transaction) => ({ + ...transaction.toJSON(), + formattedAmount: formatNumber( + transaction.amount, + transaction.bill.currencyCode + ), + })); + }; +} diff --git a/server/src/services/Purchases/LandedCost/TransctionLandedCost.ts b/server/src/services/Purchases/LandedCost/TransctionLandedCost.ts new file mode 100644 index 000000000..9362aae1d --- /dev/null +++ b/server/src/services/Purchases/LandedCost/TransctionLandedCost.ts @@ -0,0 +1,84 @@ +import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import { Model } from 'objection'; +import { IBill, IExpense, ILandedCostTransaction, ILandedCostTransactionEntry } from 'interfaces'; +import { ServiceError } from 'exceptions'; +import BillLandedCost from './BillLandedCost'; +import ExpenseLandedCost from './ExpenseLandedCost'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { ERRORS } from './utils'; + +@Service() +export default class TransactionLandedCost { + @Inject() + billLandedCost: BillLandedCost; + + @Inject() + expenseLandedCost: ExpenseLandedCost; + + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the cost transaction code model. + * @param {number} tenantId - Tenant id. + * @param {string} transactionType - Transaction type. + * @returns + */ + public getModel = ( + tenantId: number, + transactionType: string + ): Model => { + const Models = this.tenancy.models(tenantId); + const Model = Models[transactionType]; + + if (!Model) { + throw new ServiceError(ERRORS.COST_TYPE_UNDEFINED); + } + return Model; + } + + /** + * Mappes the given expense or bill transaction to landed cost transaction. + * @param {string} transactionType - Transaction type. + * @param {IBill|IExpense} transaction - Expense or bill transaction. + * @returns {ILandedCostTransaction} + */ + public transformToLandedCost = ( + transactionType: string, + transaction: IBill | IExpense + ): ILandedCostTransaction => { + return R.compose( + R.when( + R.always(transactionType === 'Bill'), + this.billLandedCost.transformToLandedCost, + ), + R.when( + R.always(transactionType === 'Expense'), + this.expenseLandedCost.transformToLandedCost, + ), + )(transaction); + } + + /** + * Transformes the given expense or bill entry to landed cost transaction entry. + * @param {string} transactionType + * @param {} transactionEntry + * @returns {ILandedCostTransactionEntry} + */ + public transformToLandedCostEntry = ( + transactionType: 'Bill' | 'Expense', + transactionEntry, + ): ILandedCostTransactionEntry => { + return R.compose( + R.when( + R.always(transactionType === 'Bill'), + this.billLandedCost.transformToLandedCostEntry, + ), + R.when( + R.always(transactionType === 'Expense'), + this.expenseLandedCost.transformToLandedCostEntry, + ), + )(transactionEntry); + } +} diff --git a/server/src/services/Purchases/LandedCost/index.ts b/server/src/services/Purchases/LandedCost/index.ts new file mode 100644 index 000000000..b189610ee --- /dev/null +++ b/server/src/services/Purchases/LandedCost/index.ts @@ -0,0 +1,479 @@ +import { Inject, Service } from 'typedi'; +import { difference, sumBy } from 'lodash'; +import { + EventDispatcher, + EventDispatcherInterface, +} from 'decorators/eventDispatcher'; +import BillsService from '../Bills'; +import { ServiceError } from 'exceptions'; +import { + IItemEntry, + IBill, + IBillLandedCost, + ILandedCostItemDTO, + ILandedCostDTO, + IBillLandedCostTransaction, + ILandedCostTransaction, + ILandedCostTransactionEntry, +} from 'interfaces'; +import events from 'subscribers/events'; +import InventoryService from 'services/Inventory/Inventory'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import TransactionLandedCost from './TransctionLandedCost'; +import { ERRORS, mergeLocatedWithBillEntries } from './utils'; + +const CONFIG = { + COST_TYPES: { + Expense: { + entries: 'categories', + }, + Bill: { + entries: 'entries', + }, + }, +}; + +@Service() +export default class AllocateLandedCostService { + @Inject() + public billsService: BillsService; + + @Inject() + public inventoryService: InventoryService; + + @Inject() + public tenancy: HasTenancyService; + + @Inject('logger') + public logger: any; + + @Inject() + public transactionLandedCost: TransactionLandedCost; + + @EventDispatcher() + eventDispatcher: EventDispatcherInterface; + + /** + * Validates allocate cost items association with the purchase invoice entries. + * @param {IItemEntry[]} purchaseInvoiceEntries + * @param {ILandedCostItemDTO[]} landedCostItems + */ + private validateAllocateCostItems = ( + purchaseInvoiceEntries: IItemEntry[], + landedCostItems: ILandedCostItemDTO[] + ): void => { + // Purchase invoice entries items ids. + const purchaseInvoiceItems = purchaseInvoiceEntries.map((e) => e.id); + const landedCostItemsIds = landedCostItems.map((item) => item.entryId); + + // Not found items ids. + const notFoundItemsIds = difference( + purchaseInvoiceItems, + landedCostItemsIds + ); + // Throw items ids not found service error. + if (notFoundItemsIds.length > 0) { + throw new ServiceError(ERRORS.LANDED_COST_ITEMS_IDS_NOT_FOUND); + } + }; + + /** + * Transformes DTO to bill landed cost model object. + * @param landedCostDTO + * @param bill + * @param costTransaction + * @param costTransactionEntry + * @returns + */ + private transformToBillLandedCost( + landedCostDTO: ILandedCostDTO, + bill: IBill, + costTransaction: ILandedCostTransaction, + costTransactionEntry: ILandedCostTransactionEntry + ) { + const amount = sumBy(landedCostDTO.items, 'cost'); + + return { + billId: bill.id, + fromTransactionType: landedCostDTO.transactionType, + fromTransactionId: landedCostDTO.transactionId, + fromTransactionEntryId: landedCostDTO.transactionEntryId, + amount, + allocationMethod: landedCostDTO.allocationMethod, + description: landedCostDTO.description, + allocateEntries: landedCostDTO.items, + costAccountId: costTransactionEntry.costAccountId, + }; + } + + /** + * Allocate the landed cost amount to cost transactions. + * @param {number} tenantId - + * @param {string} transactionType + * @param {number} transactionId + */ + private incrementLandedCostAmount = async ( + tenantId: number, + transactionType: string, + transactionId: number, + transactionEntryId: number, + amount: number + ): Promise => { + const Model = this.transactionLandedCost.getModel( + tenantId, + transactionType + ); + const relation = CONFIG.COST_TYPES[transactionType].entries; + + // Increment the landed cost transaction amount. + await Model.query() + .where('id', transactionId) + .increment('allocatedCostAmount', amount); + + // Increment the landed cost entry. + await Model.relatedQuery(relation) + .for(transactionId) + .where('id', transactionEntryId) + .increment('allocatedCostAmount', amount); + }; + + /** + * Reverts the landed cost amount to cost transaction. + * @param {number} tenantId - Tenant id. + * @param {string} transactionType - Transaction type. + * @param {number} transactionId - Transaction id. + * @param {number} amount - Amount + */ + private revertLandedCostAmount = ( + tenantId: number, + transactionType: string, + transactionId: number, + amount: number + ) => { + const Model = this.transactionLandedCost.getModel( + tenantId, + transactionType + ); + // Decrement the allocate cost amount of cost transaction. + return Model.query() + .where('id', transactionId) + .decrement('allocatedCostAmount', amount); + }; + + /** + * Retrieve the cost transaction or throw not found error. + * @param {number} tenantId + * @param {transactionType} transactionType - + * @param {transactionId} transactionId - + */ + public getLandedCostOrThrowError = async ( + tenantId: number, + transactionType: string, + transactionId: number + ) => { + const Model = this.transactionLandedCost.getModel( + tenantId, + transactionType + ); + const model = await Model.query().findById(transactionId); + + if (!model) { + throw new ServiceError(ERRORS.LANDED_COST_TRANSACTION_NOT_FOUND); + } + return this.transactionLandedCost.transformToLandedCost( + transactionType, + model + ); + }; + + /** + * Retrieve the landed cost entries. + * @param {number} tenantId + * @param {string} transactionType + * @param {number} transactionId + * @returns + */ + public getLandedCostEntry = async ( + tenantId: number, + transactionType: string, + transactionId: number, + transactionEntryId: number + ): Promise => { + const Model = this.transactionLandedCost.getModel( + tenantId, + transactionType + ); + const relation = CONFIG.COST_TYPES[transactionType].entries; + + const entry = await Model.relatedQuery(relation) + .for(transactionId) + .findOne('id', transactionEntryId) + .where('landedCost', true) + .onBuild((q) => { + if (transactionType === 'Bill') { + q.withGraphFetched('item'); + } else if (transactionType === 'Expense') { + q.withGraphFetched('expenseAccount'); + } + }); + + if (!entry) { + throw new ServiceError(ERRORS.LANDED_COST_ENTRY_NOT_FOUND); + } + return this.transactionLandedCost.transformToLandedCostEntry( + transactionType, + entry + ); + }; + + /** + * Retrieve allocate items cost total. + * @param {ILandedCostDTO} landedCostDTO + * @returns {number} + */ + private getAllocateItemsCostTotal = ( + landedCostDTO: ILandedCostDTO + ): number => { + return sumBy(landedCostDTO.items, 'cost'); + }; + + /** + * Validates the landed cost entry amount. + * @param {number} unallocatedCost - + * @param {number} amount - + */ + private validateLandedCostEntryAmount = ( + unallocatedCost: number, + amount: number + ): void => { + if (unallocatedCost < amount) { + throw new ServiceError(ERRORS.COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT); + } + }; + + /** + * Records inventory transactions. + * @param {number} tenantId + * @param {} allocateEntries + */ + private recordInventoryTransactions = async ( + tenantId: number, + billLandedCost: IBillLandedCostTransaction, + bill: IBill + ) => { + // Retrieve the merged allocated entries with bill entries. + const allocateEntries = mergeLocatedWithBillEntries( + billLandedCost.allocateEntries, + bill.entries + ); + // Mappes the allocate cost entries to inventory transactions. + const inventoryTransactions = allocateEntries.map((allocateEntry) => ({ + date: bill.billDate, + itemId: allocateEntry.entry.itemId, + direction: 'IN', + quantity: 0, + rate: allocateEntry.cost, + transactionType: 'LandedCost', + transactionId: billLandedCost.id, + entryId: allocateEntry.entryId, + })); + + return this.inventoryService.recordInventoryTransactions( + tenantId, + inventoryTransactions + ); + }; + + /** + * ================================= + * Allocate landed cost. + * ================================= + * - Validates the allocate cost not the same purchase invoice id. + * - Get the given bill (purchase invoice) or throw not found error. + * - Get the given landed cost transaction or throw not found error. + * - Validate landed cost transaction has enough unallocated cost amount. + * - Validate landed cost transaction entry has enough unallocated cost amount. + * - Validate allocate entries existance and associated with cost bill transaction. + * - Writes inventory landed cost transaction. + * - Increment the allocated landed cost transaction. + * - Increment the allocated landed cost transaction entry. + * + * @param {ILandedCostDTO} landedCostDTO - Landed cost DTO. + * @param {number} tenantId - Tenant id. + * @param {number} billId - Purchase invoice id. + */ + public allocateLandedCost = async ( + tenantId: number, + allocateCostDTO: ILandedCostDTO, + billId: number + ): Promise<{ + billLandedCost: IBillLandedCost; + }> => { + const { BillLandedCost } = this.tenancy.models(tenantId); + + // Retrieve total cost of allocated items. + const amount = this.getAllocateItemsCostTotal(allocateCostDTO); + + // Retrieve the purchase invoice or throw not found error. + const bill = await this.billsService.getBillOrThrowError( + tenantId, + billId + ); + // Retrieve landed cost transaction or throw not found service error. + const landedCostTransaction = await this.getLandedCostOrThrowError( + tenantId, + allocateCostDTO.transactionType, + allocateCostDTO.transactionId + ); + // Retrieve landed cost transaction entries. + const landedCostEntry = await this.getLandedCostEntry( + tenantId, + allocateCostDTO.transactionType, + allocateCostDTO.transactionId, + allocateCostDTO.transactionEntryId + ); + // Validates allocate cost items association with the purchase invoice entries. + this.validateAllocateCostItems( + bill.entries, + allocateCostDTO.items + ); + // Validate the amount of cost with unallocated landed cost. + this.validateLandedCostEntryAmount( + landedCostEntry.unallocatedCostAmount, + amount + ); + // Transformes DTO to bill landed cost model object. + const billLandedCostObj = this.transformToBillLandedCost( + allocateCostDTO, + bill, + landedCostTransaction, + landedCostEntry + ); + // Save the bill landed cost model. + const billLandedCost = await BillLandedCost.query().insertGraph( + billLandedCostObj + ); + // Triggers the event `onBillLandedCostCreated`. + await this.eventDispatcher.dispatch(events.billLandedCost.onCreated, { + tenantId, + billId, + billLandedCostId: billLandedCost.id, + }); + // Records the inventory transactions. + await this.recordInventoryTransactions( + tenantId, + billLandedCost, + bill + ); + // Increment landed cost amount on transaction and entry. + await this.incrementLandedCostAmount( + tenantId, + allocateCostDTO.transactionType, + allocateCostDTO.transactionId, + allocateCostDTO.transactionEntryId, + amount + ); + return { billLandedCost }; + }; + + /** + * Retrieve the give bill landed cost or throw not found service error. + * @param {number} tenantId - Tenant id. + * @param {number} landedCostId - Landed cost id. + * @returns {Promise} + */ + public getBillLandedCostOrThrowError = async ( + tenantId: number, + landedCostId: number + ): Promise => { + const { BillLandedCost } = this.tenancy.models(tenantId); + + // Retrieve the bill landed cost model. + const billLandedCost = await BillLandedCost.query().findById(landedCostId); + + if (!billLandedCost) { + throw new ServiceError(ERRORS.BILL_LANDED_COST_NOT_FOUND); + } + return billLandedCost; + }; + + /** + * Deletes the landed cost transaction with assocaited allocate entries. + * @param {number} tenantId + * @param {number} landedCostId + */ + public deleteLandedCost = async ( + tenantId: number, + landedCostId: number + ): Promise => { + const { BillLandedCost, BillLandedCostEntry } = + this.tenancy.models(tenantId); + + // Deletes the bill landed cost allocated entries associated to landed cost. + await BillLandedCostEntry.query() + .where('bill_located_cost_id', landedCostId) + .delete(); + + // Delete the bill landed cost from the storage. + await BillLandedCost.query().where('id', landedCostId).delete(); + }; + + /** + * Deletes the allocated landed cost. + * ================================== + * - Delete bill landed cost transaction with associated allocate entries. + * - Delete the associated inventory transactions. + * - Decrement allocated amount of landed cost transaction and entry. + * - Revert journal entries. + * ---------------------------------- + * @param {number} tenantId - Tenant id. + * @param {number} landedCostId - Landed cost id. + * @return {Promise} + */ + public deleteAllocatedLandedCost = async ( + tenantId: number, + landedCostId: number + ): Promise<{ + landedCostId: number; + }> => { + // Retrieves the bill landed cost. + const oldBillLandedCost = await this.getBillLandedCostOrThrowError( + tenantId, + landedCostId + ); + // Delete landed cost transaction with assocaited locate entries. + await this.deleteLandedCost(tenantId, landedCostId); + + // Triggers the event `onBillLandedCostCreated`. + await this.eventDispatcher.dispatch(events.billLandedCost.onDeleted, { + tenantId, + billLandedCostId: oldBillLandedCost.id, + billId: oldBillLandedCost.billId, + }); + // Removes the inventory transactions. + await this.removeInventoryTransactions(tenantId, landedCostId); + + // Reverts the landed cost amount to the cost transaction. + await this.revertLandedCostAmount( + tenantId, + oldBillLandedCost.fromTransactionType, + oldBillLandedCost.fromTransactionId, + oldBillLandedCost.amount + ); + return { landedCostId }; + }; + + /** + * Deletes the inventory transaction. + * @param {number} tenantId + * @param {number} landedCostId + * @returns + */ + private removeInventoryTransactions = (tenantId, landedCostId: number) => { + return this.inventoryService.deleteInventoryTransactions( + tenantId, + landedCostId, + 'LandedCost' + ); + }; +} diff --git a/server/src/services/Purchases/LandedCost/utils.ts b/server/src/services/Purchases/LandedCost/utils.ts new file mode 100644 index 000000000..48d8cb683 --- /dev/null +++ b/server/src/services/Purchases/LandedCost/utils.ts @@ -0,0 +1,34 @@ +import { IItemEntry, IBillLandedCostTransactionEntry } from 'interfaces'; +import { transformToMap } from 'utils'; + +export const ERRORS = { + COST_TYPE_UNDEFINED: 'COST_TYPE_UNDEFINED', + LANDED_COST_ITEMS_IDS_NOT_FOUND: 'LANDED_COST_ITEMS_IDS_NOT_FOUND', + COST_TRANSACTION_HAS_NO_ENOUGH_TO_LOCATE: + 'COST_TRANSACTION_HAS_NO_ENOUGH_TO_LOCATE', + BILL_LANDED_COST_NOT_FOUND: 'BILL_LANDED_COST_NOT_FOUND', + COST_ENTRY_ID_NOT_FOUND: 'COST_ENTRY_ID_NOT_FOUND', + LANDED_COST_TRANSACTION_NOT_FOUND: 'LANDED_COST_TRANSACTION_NOT_FOUND', + LANDED_COST_ENTRY_NOT_FOUND: 'LANDED_COST_ENTRY_NOT_FOUND', + COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT: + 'COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT', + ALLOCATE_COST_SHOULD_NOT_BE_BILL: 'ALLOCATE_COST_SHOULD_NOT_BE_BILL', +}; + +/** + * Merges item entry to bill located landed cost entry. + * @param {IBillLandedCostTransactionEntry[]} locatedEntries - + * @param {IItemEntry[]} billEntries - + * @returns {(IBillLandedCostTransactionEntry & { entry: IItemEntry })[]} + */ +export const mergeLocatedWithBillEntries = ( + locatedEntries: IBillLandedCostTransactionEntry[], + billEntries: IItemEntry[] +): (IBillLandedCostTransactionEntry & { entry: IItemEntry })[] => { + const billEntriesByEntryId = transformToMap(billEntries, 'id'); + + return locatedEntries.map((entry) => ({ + ...entry, + entry: billEntriesByEntryId.get(entry.entryId), + })); +}; diff --git a/server/src/services/Purchases/constants.ts b/server/src/services/Purchases/constants.ts index 09b31979d..be1874a86 100644 --- a/server/src/services/Purchases/constants.ts +++ b/server/src/services/Purchases/constants.ts @@ -9,5 +9,9 @@ export const ERRORS = { BILL_ALREADY_OPEN: 'BILL_ALREADY_OPEN', BILL_NO_IS_REQUIRED: 'BILL_NO_IS_REQUIRED', BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES: 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES', - VENDOR_HAS_BILLS: 'VENDOR_HAS_BILLS' + VENDOR_HAS_BILLS: 'VENDOR_HAS_BILLS', + BILL_HAS_ASSOCIATED_LANDED_COSTS: 'BILL_HAS_ASSOCIATED_LANDED_COSTS', + BILL_ENTRIES_ALLOCATED_COST_COULD_DELETED: 'BILL_ENTRIES_ALLOCATED_COST_COULD_DELETED', + LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES: 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES', + LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS: 'LANDED_COST_ENTRIES_SHOULD_BE_INVENTORY_ITEMS' }; diff --git a/server/src/subscribers/Bills/WriteJournalEntries.ts b/server/src/subscribers/Bills/WriteJournalEntries.ts index 3a94f2c71..0741f6255 100644 --- a/server/src/subscribers/Bills/WriteJournalEntries.ts +++ b/server/src/subscribers/Bills/WriteJournalEntries.ts @@ -23,20 +23,20 @@ export default class BillSubscriber { * Handles writing journal entries once bill created. */ @On(events.bill.onCreated) - async handlerWriteJournalEntriesOnCreate({ tenantId, bill }) { + async handlerWriteJournalEntriesOnCreate({ tenantId, billId }) { // Writes the journal entries for the given bill transaction. this.logger.info('[bill] writing bill journal entries.', { tenantId }); - await this.billsService.recordJournalTransactions(tenantId, bill); + await this.billsService.recordJournalTransactions(tenantId, billId); } /** * Handles the overwriting journal entries once bill edited. */ @On(events.bill.onEdited) - async handleOverwriteJournalEntriesOnEdit({ tenantId, bill }) { + async handleOverwriteJournalEntriesOnEdit({ tenantId, billId }) { // Overwrite the journal entries for the given bill transaction. this.logger.info('[bill] overwriting bill journal entries.', { tenantId }); - await this.billsService.recordJournalTransactions(tenantId, bill, true); + await this.billsService.recordJournalTransactions(tenantId, billId, true); } /** diff --git a/server/src/subscribers/LandedCost/index.ts b/server/src/subscribers/LandedCost/index.ts new file mode 100644 index 000000000..ec31b83d9 --- /dev/null +++ b/server/src/subscribers/LandedCost/index.ts @@ -0,0 +1,37 @@ +import { Container } from 'typedi'; +import { On, EventSubscriber } from 'event-dispatch'; +import events from 'subscribers/events'; +import TenancyService from 'services/Tenancy/TenancyService'; +import BillsService from 'services/Purchases/Bills'; + +@EventSubscriber() +export default class BillLandedCostSubscriber { + logger: any; + tenancy: TenancyService; + billsService: BillsService; + + /** + * Constructor method. + */ + constructor() { + this.logger = Container.get('logger'); + this.tenancy = Container.get(TenancyService); + this.billsService = Container.get(BillsService); + } + + /** + * Marks the rewrite bill journal entries once the landed cost transaction + * be deleted or created. + */ + @On(events.billLandedCost.onCreated) + @On(events.billLandedCost.onDeleted) + public async handleRewriteBillJournalEntries({ + tenantId, + billId, + bilLandedCostId, + }) { + // Overwrite the journal entries for the given bill transaction. + this.logger.info('[bill] overwriting bill journal entries.', { tenantId }); + await this.billsService.recordJournalTransactions(tenantId, billId, true); + } +} diff --git a/server/src/subscribers/events.ts b/server/src/subscribers/events.ts index 48f8836b5..fe6a74f0f 100644 --- a/server/src/subscribers/events.ts +++ b/server/src/subscribers/events.ts @@ -203,5 +203,15 @@ export default { onQuickCreated: 'onInventoryAdjustmentQuickCreated', onDeleted: 'onInventoryAdjustmentDeleted', onPublished: 'onInventoryAdjustmentPublished', + }, + + /** + * Bill landed cost. + */ + billLandedCost: { + onCreate: 'onBillLandedCostCreate', + onCreated: 'onBillLandedCostCreated', + onDelete: 'onBillLandedCostDelete', + onDeleted: 'onBillLandedCostDeleted' } } diff --git a/server/src/utils/index.ts b/server/src/utils/index.ts index a5f894765..a18bc93e2 100644 --- a/server/src/utils/index.ts +++ b/server/src/utils/index.ts @@ -373,6 +373,11 @@ const accumSum = (data, callback) => { }, 0) } +const mergeObjectsBykey = (object1, object2, key) => { + var merged = _.merge(_.keyBy(object1, key), _.keyBy(object2, key)); + return _.values(merged); +} + export { accumSum, increment, @@ -400,5 +405,6 @@ export { transactionIncrement, transformToMapBy, dateRangeFromToCollection, - transformToMapKeyValue + transformToMapKeyValue, + mergeObjectsBykey };