diff --git a/packages/webapp/src/components/Forms/BlueprintFormik.tsx b/packages/webapp/src/components/Forms/BlueprintFormik.tsx index 09b14967d..93313abcf 100644 --- a/packages/webapp/src/components/Forms/BlueprintFormik.tsx +++ b/packages/webapp/src/components/Forms/BlueprintFormik.tsx @@ -10,7 +10,7 @@ import { EditableText, TextArea, } from '@blueprintjs-formik/core'; -import { MultiSelect } from '@blueprintjs-formik/select'; +import { MultiSelect, SuggestField } from '@blueprintjs-formik/select'; import { DateInput } from '@blueprintjs-formik/datetime'; import { FSelect } from './Select'; @@ -24,6 +24,7 @@ export { FSelect, MultiSelect as FMultiSelect, EditableText as FEditableText, + SuggestField as FSuggest, TextArea as FTextArea, DateInput as FDateInput, }; diff --git a/packages/webapp/src/components/TaxRates/TaxRatesSuggestInputCell.tsx b/packages/webapp/src/components/TaxRates/TaxRatesSuggestInputCell.tsx new file mode 100644 index 000000000..e6fed6bb4 --- /dev/null +++ b/packages/webapp/src/components/TaxRates/TaxRatesSuggestInputCell.tsx @@ -0,0 +1,41 @@ +// @ts-nocheck +import React, { useCallback } from 'react'; +import { Suggest } from '@blueprintjs-formik/select'; +import { FormGroup } from '@blueprintjs/core'; +import { CellType } from '@/constants'; + +export function TaxRatesSuggestInputCell({ + column: { id, suggestProps, formGroupProps }, + row: { index }, + cell: { value: cellValue }, + payload: { errors, updateData, taxRates }, +}) { + const error = errors?.[index]?.[id]; + + // Handle the item selected. + const handleItemSelected = useCallback( + (value, taxRate) => { + updateData(index, id, taxRate.id); + }, + [updateData, index, id], + ); + + return ( + + + selectedValue={cellValue} + items={taxRates} + valueAccessor={'id'} + labelAccessor={'code'} + textAccessor={'name'} + popoverProps={{ minimal: true, boundary: 'window' }} + inputProps={{ placeholder: '' }} + fill={true} + onItemChange={handleItemSelected} + {...suggestProps} + /> + + ); +} + +TaxRatesSuggestInputCell.cellType = CellType.Field; diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx index 69f512145..c9a9fe704 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx @@ -23,22 +23,30 @@ export function InvoiceDetailTableFooter() { } - value={} + value={} borderStyle={TotalLineBorderStyle.SingleDark} /> + {invoice.taxes.map((taxRate) => ( + + ))} } - value={invoice.formatted_amount} + value={invoice.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> } - value={invoice.formatted_payment_amount} + value={invoice.payment_amount_formatted} /> } - value={invoice.formatted_due_amount} + value={invoice.due_amount_formatted} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx b/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx new file mode 100644 index 000000000..9d9c88edd --- /dev/null +++ b/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx @@ -0,0 +1,20 @@ +// @ts-nocheck +import React, { createContext } from 'react'; + +const ItemEntriesTableContext = createContext(); + +function ItemEntriesTableProvider({ children, value }) { + const provider = { + ...value, + }; + return ( + + {children} + + ); +} + +const useItemEntriesTableContext = () => + React.useContext(ItemEntriesTableContext); + +export { ItemEntriesTableProvider, useItemEntriesTableContext }; diff --git a/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx b/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx index 6b51f8566..4a881cfec 100644 --- a/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx +++ b/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx @@ -1,103 +1,104 @@ // @ts-nocheck -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import classNames from 'classnames'; import { CLASSES } from '@/constants/classes'; import { DataTableEditable } from '@/components'; import { useEditableItemsEntriesColumns } from './components'; -import { - saveInvoke, - compose, - updateMinEntriesLines, - updateRemoveLineByIndex, -} from '@/utils'; import { useFetchItemRow, composeRowsOnNewRow, - composeRowsOnEditCell, + useComposeRowsOnEditTableCell, + useComposeRowsOnRemoveTableRow, } from './utils'; +import { + ItemEntriesTableProvider, + useItemEntriesTableContext, +} from './ItemEntriesTableProvider'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; /** * Items entries table. */ -function ItemsEntriesTable({ - // #ownProps - items, - entries, - initialEntries, - defaultEntry, - errors, - onUpdateData, - currencyCode, - itemType, // sellable or purchasable - landedCost = false, - minLinesNumber -}) { - const [rows, setRows] = React.useState(initialEntries); +function ItemsEntriesTable(props) { + const { value, initialValue, onChange } = props; - // Allows to observes `entries` to make table rows outside controlled. - useEffect(() => { - if (entries && entries !== rows) { - setRows(entries); - } - }, [entries, rows]); + const [localValue, handleChange] = useUncontrolled({ + value, + initialValue, + finalValue: [], + onChange, + }); + return ( + + + + ); +} + +/** + * Items entries table logic. + * @returns {JSX.Element} + */ +function ItemEntriesTableRoot() { + const { + localValue, + defaultEntry, + handleChange, + items, + errors, + currencyCode, + landedCost, + taxRates, + } = useItemEntriesTableContext(); // Editiable items entries columns. - const columns = useEditableItemsEntriesColumns({ landedCost }); + const columns = useEditableItemsEntriesColumns(); + + const composeRowsOnEditCell = useComposeRowsOnEditTableCell(); + const composeRowsOnDeleteRow = useComposeRowsOnRemoveTableRow(); // Handle the fetch item row details. const { setItemRow, cellsLoading, isItemFetching } = useFetchItemRow({ landedCost, - itemType, + itemType: null, notifyNewRow: (newRow, rowIndex) => { // Update the rate, description and quantity data of the row. - const newRows = composeRowsOnNewRow(rowIndex, newRow, rows); - - setRows(newRows); - onUpdateData(newRows); + const newRows = composeRowsOnNewRow(rowIndex, newRow, localValue); + handleChange(newRows); }, }); - // Handles the editor data update. const handleUpdateData = useCallback( (rowIndex, columnId, value) => { if (columnId === 'item_id') { setItemRow({ rowIndex, columnId, itemId: value }); } - const composeEditCell = composeRowsOnEditCell(rowIndex, columnId); - const newRows = composeEditCell(value, defaultEntry, rows); - - setRows(newRows); - onUpdateData(newRows); + const newRows = composeRowsOnEditCell(rowIndex, columnId, value); + handleChange(newRows); }, - [rows, defaultEntry, onUpdateData, setItemRow], + [localValue, defaultEntry, handleChange], ); // Handle table rows removing by index. const handleRemoveRow = (rowIndex) => { - const newRows = compose( - // Ensure minimum lines count. - updateMinEntriesLines(minLinesNumber, defaultEntry), - // Remove the line by the given index. - updateRemoveLineByIndex(rowIndex), - )(rows); - - setRows(newRows); - saveInvoke(onUpdateData, newRows); + const newRows = composeRowsOnDeleteRow(rowIndex); + handleChange(newRows); }; return ( { removeRow(index); }; - const exampleMenu = ( { /** * Retrieve editable items entries columns. */ -export function useEditableItemsEntriesColumns({ landedCost }) { +export function useEditableItemsEntriesColumns() { const { featureCan } = useFeatureCan(); + const { landedCost } = useItemEntriesTableContext(); + const isProjectsFeatureEnabled = featureCan(Features.Projects); return React.useMemo( () => [ { - Header: ItemHeaderCell, id: 'item_id', + Header: ItemHeaderCell, accessor: 'item_id', Cell: ItemsListCell, disableSortBy: true, @@ -129,6 +132,13 @@ export function useEditableItemsEntriesColumns({ landedCost }) { width: 70, align: Align.Right, }, + { + Header: 'Tax rate', + accessor: 'tax_rate_id', + Cell: TaxRatesSuggestInputCell, + disableSortBy: true, + width: 110, + }, { Header: intl.get('discount'), accessor: 'discount', diff --git a/packages/webapp/src/containers/Entries/utils.tsx b/packages/webapp/src/containers/Entries/utils.tsx index 6c8f8b01e..51ff04412 100644 --- a/packages/webapp/src/containers/Entries/utils.tsx +++ b/packages/webapp/src/containers/Entries/utils.tsx @@ -1,7 +1,7 @@ // @ts-nocheck -import React from 'react'; +import React, { useCallback } from 'react'; import * as R from 'ramda'; -import { sumBy, isEmpty, last } from 'lodash'; +import { sumBy, isEmpty, last, keyBy } from 'lodash'; import { useItem } from '@/hooks/query'; import { @@ -13,6 +13,12 @@ import { orderingLinesIndexes, updateTableRow, } from '@/utils'; +import { useItemEntriesTableContext } from './ItemEntriesTableProvider'; + +export const ITEM_TYPE = { + SELLABLE: 'SELLABLE', + PURCHASABLE: 'PURCHASABLE', +}; /** * Retrieve item entry total from the given rate, quantity and discount. @@ -39,11 +45,6 @@ export function updateItemsEntriesTotal(rows) { })); } -export const ITEM_TYPE = { - SELLABLE: 'SELLABLE', - PURCHASABLE: 'PURCHASABLE', -}; - /** * Retrieve total of the given items entries. */ @@ -150,12 +151,7 @@ export function useFetchItemRow({ landedCost, itemType, notifyNewRow }) { */ export const composeRowsOnEditCell = R.curry( (rowIndex, columnId, value, defaultEntry, rows) => { - return compose( - orderingLinesIndexes, - updateAutoAddNewLine(defaultEntry, ['item_id']), - updateItemsEntriesTotal, - updateTableCell(rowIndex, columnId, value), - )(rows); + return compose()(rows); }, ); @@ -171,10 +167,102 @@ export const composeRowsOnNewRow = R.curry((rowIndex, newRow, rows) => { }); /** - * - * @param {*} entries + * Associate tax rate to entries. + */ +export const assignEntriesTaxRate = R.curry((taxRates, entries) => { + const taxRatesById = keyBy(taxRates, 'id'); + + return entries.map((entry) => { + const taxRate = taxRatesById[entry.tax_rate_id]; + + return { + ...entry, + tax_rate: taxRate?.rate || 0, + }; + }); +}); + +/** + * Assign tax amount to entries. + * @param {boolean} isInclusiveTax + * @param entries * @returns */ -export const composeControlledEntries = (entries) => { - return R.compose(orderingLinesIndexes, updateItemsEntriesTotal)(entries); +export const assignEntriesTaxAmount = R.curry( + (isInclusiveTax: boolean, entries) => { + return entries.map((entry) => { + const taxAmount = isInclusiveTax + ? getInclusiveTaxAmount(entry.amount, entry.tax_rate) + : getExlusiveTaxAmount(entry.amount, entry.tax_rate); + + return { + ...entry, + tax_amount: taxAmount, + }; + }); + }, +); + +/** + * Get inclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getInclusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / (100 + taxRate); +}; + +/** + * Get exclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getExlusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / 100; +}; + +/** + * Compose rows when edit a table cell. + * @returns {Function} + */ +export const useComposeRowsOnEditTableCell = () => { + const { taxRates, isInclusiveTax, localValue, defaultEntry } = + useItemEntriesTableContext(); + + return useCallback( + (rowIndex, columnId, value) => { + return R.compose( + assignEntriesTaxAmount(isInclusiveTax), + assignEntriesTaxRate(taxRates), + orderingLinesIndexes, + updateAutoAddNewLine(defaultEntry, ['item_id']), + updateItemsEntriesTotal, + updateTableCell(rowIndex, columnId, value), + )(localValue); + }, + [taxRates, isInclusiveTax, localValue, defaultEntry], + ); +}; + +/** + * Compose rows when remove a table row. + * @returns {Function} + */ +export const useComposeRowsOnRemoveTableRow = () => { + const { minLinesNumber, defaultEntry, localValue } = + useItemEntriesTableContext(); + + return useCallback( + (rowIndex) => { + return compose( + // Ensure minimum lines count. + updateMinEntriesLines(minLinesNumber, defaultEntry), + // Remove the line by the given index. + updateRemoveLineByIndex(rowIndex), + )(localValue); + }, + [minLinesNumber, defaultEntry, localValue], + ); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx index c247b7b5a..66d35fa30 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx @@ -4,6 +4,7 @@ import moment from 'moment'; import intl from 'react-intl-universal'; import { DATATYPES_LENGTH } from '@/constants/dataTypes'; import { isBlank } from '@/utils'; +import { TaxType } from '@/interfaces/TaxRates'; const getSchema = () => Yup.object().shape({ @@ -35,6 +36,10 @@ const getSchema = () => .max(DATATYPES_LENGTH.TEXT) .label(intl.get('note')), exchange_rate: Yup.number(), + inclusive_exclusive_tax: Yup.string().oneOf([ + TaxType.Inclusive, + TaxType.Exclusive, + ]), branch_id: Yup.string(), warehouse_id: Yup.string(), project_id: Yup.string(), diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx index 983d1d001..a8463619b 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React, { useMemo } from 'react'; +import React from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; @@ -26,6 +26,7 @@ import withCurrentOrganization from '@/containers/Organization/withCurrentOrgani import { AppToaster } from '@/components'; import { compose, orderingLinesIndexes, transactionNumber } from '@/utils'; import { useInvoiceFormContext } from './InvoiceFormProvider'; +import { InvoiceFormActions } from './InvoiceFormActions'; import { transformToEditForm, defaultInvoice, @@ -71,7 +72,7 @@ function InvoiceForm({ ? { ...transformToEditForm(invoice) } : { ...defaultInvoice, - // If the auto-increment mode is enabled, take the next invoice + // If the auto-increment mode is enabled, take the next invoice // number from the settings. ...(invoiceAutoIncrementMode && { invoice_no: invoiceNumber, @@ -166,7 +167,11 @@ function InvoiceForm({
- + +
+ + +
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx new file mode 100644 index 000000000..e185455f9 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx @@ -0,0 +1,79 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import { useFormikContext } from 'formik'; +import { InclusiveButtonOptions } from './constants'; +import { Box, FFormGroup, FSelect } from '@/components'; +import { composeEntriesOnEditInclusiveTax } from './utils'; + +/** + * Invoice form actions. + * @returns {React.ReactNode} + */ +export function InvoiceFormActions() { + return ( + + + + ); +} + +/** + * Invoice exclusive/inclusive select. + * @returns {React.ReactNode} + */ +export function InvoiceExclusiveInclusiveSelect(props) { + const { values, setFieldValue } = useFormikContext(); + + const handleItemSelect = (item) => { + const newEntries = composeEntriesOnEditInclusiveTax( + item.key, + values.entries, + ); + setFieldValue('inclusive_exclusive_tax', item.key); + setFieldValue('entries', newEntries); + }; + + return ( + + ''} + valueAccessor={'key'} + popoverProps={{ minimal: true, usePortal: true, inline: false }} + buttonProps={{ small: true }} + onItemSelect={handleItemSelect} + filterable={false} + {...props} + /> + + ); +} + +const InclusiveFormGroup = styled(FFormGroup)` + margin-bottom: 0; + margin-left: auto; + + &.bp3-form-group.bp3-inline label.bp3-label { + line-height: 1.25; + opacity: 0.6; + margin-right: 8px; + } +`; + +const InclusiveSelect = styled(FSelect)` + .bp3-button { + padding-right: 24px; + } +`; + +const InvoiceFormActionsRoot = styled(Box)` + padding-bottom: 12px; + display: flex; +`; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx index 4a9c6cfe4..c9ba4b687 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; +import { useFormikContext } from 'formik'; import { T, @@ -9,7 +10,7 @@ import { TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; -import { useInvoiceTotals } from './utils'; +import { useInvoiceAggregatedTaxRates, useInvoiceTotals } from './utils'; export function InvoiceFormFooterRight() { // Calculate the total due amount of invoice entries. @@ -20,15 +21,34 @@ export function InvoiceFormFooterRight() { formattedPaymentTotal, } = useInvoiceTotals(); + const { + values: { inclusive_exclusive_tax }, + } = useFormikContext(); + + const taxEntries = useInvoiceAggregatedTaxRates(); + return ( } + title={ + <> + {inclusive_exclusive_tax === 'inclusive' + ? 'Subtotal (Tax Inclusive)' + : 'Subtotal'} + + } value={formattedSubtotal} - borderStyle={TotalLineBorderStyle.None} /> + {taxEntries.map((tax, index) => ( + + ))} } + title={'Total (USD)'} value={formattedTotal} borderStyle={TotalLineBorderStyle.SingleDark} textStyle={TotalLineTextStyle.Bold} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx index 82f57a266..66d1ed48c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx @@ -8,7 +8,7 @@ import InvoiceFormHeaderFields from './InvoiceFormHeaderFields'; import { CLASSES } from '@/constants/classes'; import { PageFormBigNumber } from '@/components'; -import { useInvoiceTotal } from './utils'; +import { useInvoiceSubtotal } from './utils'; /** * Invoice form header section. @@ -32,7 +32,7 @@ function InvoiceFormBigTotal() { } = useFormikContext(); // Calculate the total due amount of invoice entries. - const totalDueAmount = useInvoiceTotal(); + const totalDueAmount = useInvoiceSubtotal(); return ( - - {({ - form: { values, setFieldValue }, - field: { value }, - meta: { error, touched }, - }) => ( - { - setFieldValue('entries', entries); - }} - items={items} - errors={error} - linesNumber={4} - currencyCode={values.currency_code} - /> - )} - - + + {({ + form: { values, setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + { + setFieldValue('entries', entries); + }} + items={items} + taxRates={taxRates} + errors={error} + linesNumber={4} + currencyCode={values.currency_code} + isInclusiveTax={values.inclusive_exclusive_tax === 'inclusive'} + /> + )} + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts new file mode 100644 index 000000000..5b35f08a3 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts @@ -0,0 +1,6 @@ + + +export const InclusiveButtonOptions = [ + { key: 'inclusive', label: 'Inclusive of Tax' }, + { key: 'exclusive', label: 'Exclusive of Tax' }, +]; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index 4af9dabb9..66effa014 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -1,27 +1,27 @@ // @ts-nocheck -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import intl from 'react-intl-universal'; import moment from 'moment'; +import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; -import { omit, first } from 'lodash'; -import { - compose, - transformToForm, - repeatValue, - transactionNumber, -} from '@/utils'; +import { omit, first, keyBy, sumBy, groupBy } from 'lodash'; +import { compose, transformToForm, repeatValue } from '@/utils'; import { useFormikContext } from 'formik'; import { formattedAmount, defaultFastFieldShouldUpdate } from '@/utils'; import { ERROR } from '@/constants/errors'; import { AppToaster } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { getEntriesTotal } from '@/containers/Entries/utils'; +import { + assignEntriesTaxAmount, + getEntriesTotal, +} from '@/containers/Entries/utils'; import { useInvoiceFormContext } from './InvoiceFormProvider'; import { updateItemsEntriesTotal, ensureEntriesHaveEmptyLine, } from '@/containers/Entries/utils'; +import { TaxType } from '@/interfaces/TaxRates'; export const MIN_LINES_NUMBER = 1; @@ -34,6 +34,9 @@ export const defaultInvoiceEntry = { quantity: '', description: '', amount: '', + tax_rate_id: '', + tax_rate: '', + tax_amount: '', }; // Default invoice object. @@ -43,6 +46,7 @@ export const defaultInvoice = { due_date: moment().format('YYYY-MM-DD'), delivered: '', invoice_no: '', + inclusive_exclusive_tax: 'inclusive', // Holds the invoice number that entered manually only. invoice_no_manually: '', reference_no: '', @@ -114,7 +118,7 @@ export const transformErrors = (errors, { setErrors }) => { */ export const customerNameFieldShouldUpdate = (newProps, oldProps) => { return ( - newProps.shouldUpdateDeps.items !== oldProps.shouldUpdateDeps.items|| + newProps.shouldUpdateDeps.items !== oldProps.shouldUpdateDeps.items || defaultFastFieldShouldUpdate(newProps, oldProps) ); }; @@ -125,6 +129,7 @@ export const customerNameFieldShouldUpdate = (newProps, oldProps) => { export const entriesFieldShouldUpdate = (newProps, oldProps) => { return ( newProps.items !== oldProps.items || + newProps.taxRates !== oldProps.taxRates || defaultFastFieldShouldUpdate(newProps, oldProps) ); }; @@ -154,12 +159,17 @@ export function transformValueToRequest(values) { (item) => item.item_id && item.quantity, ); return { - ...omit(values, ['invoice_no', 'invoice_no_manually']), + ...omit(values, [ + 'invoice_no', + 'invoice_no_manually', + 'inclusive_exclusive_tax', + ]), // The `invoice_no_manually` will be presented just if the auto-increment // is disable, always both attributes hold the same value in manual mode. ...(values.invoice_no_manually && { invoice_no: values.invoice_no, }), + is_inclusive_tax: values.inclusive_exclusive_tax === 'inclusive', entries: entries.map((entry) => ({ ...omit(entry, ['amount']) })), delivered: false, }; @@ -196,7 +206,11 @@ export const useSetPrimaryBranchToForm = () => { }, [isBranchesSuccess, setFieldValue, branches]); }; -export const useInvoiceTotal = () => { +/** + * Retrieves the invoice subtotal. + * @returns {number} + */ +export const useInvoiceSubtotal = () => { const { values: { entries }, } = useFormikContext(); @@ -216,10 +230,12 @@ export const useInvoiceTotals = () => { // Retrieves the invoice entries total. const total = React.useMemo(() => getEntriesTotal(entries), [entries]); + const total_ = useInvoiceTotal(); + // Retrieves the formatted total money. const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], + () => formattedAmount(total_, currencyCode), + [total_, currencyCode], ); // Retrieves the formatted subtotal. const formattedSubtotal = React.useMemo( @@ -271,6 +287,9 @@ export const useInvoiceIsForeignCustomer = () => { return isForeignCustomer; }; +/** + * Resets the form state to initial values + */ export const resetFormState = ({ initialValues, values, resetForm }) => { resetForm({ values: { @@ -281,3 +300,105 @@ export const resetFormState = ({ initialValues, values, resetForm }) => { }, }); }; + +/** + * Re-calcualte the entries tax amount when editing. + * @returns {string} + */ +export const composeEntriesOnEditInclusiveTax = ( + inclusiveExclusiveTax: string, + entries, +) => { + return R.compose( + assignEntriesTaxAmount(inclusiveExclusiveTax === 'inclusive'), + )(entries); +}; + +/** + * Retreives the invoice aggregated tax rates. + * @returns {Array} + */ +export const useInvoiceAggregatedTaxRates = () => { + const { values } = useFormikContext(); + const { taxRates } = useInvoiceFormContext(); + + const taxRatesById = useMemo(() => keyBy(taxRates, 'id'), [taxRates]); + + // Calculate the total tax amount of invoice entries. + return React.useMemo(() => { + const filteredEntries = values.entries.filter((e) => e.tax_rate_id); + const groupedTaxRates = groupBy(filteredEntries, 'tax_rate_id'); + + return Object.keys(groupedTaxRates).map((taxRateId) => { + const taxRate = taxRatesById[taxRateId]; + const taxRates = groupedTaxRates[taxRateId]; + const totalTaxAmount = sumBy(taxRates, 'tax_amount'); + const taxAmountFormatted = formattedAmount(totalTaxAmount, 'USD'); + + return { + taxRateId, + taxRate: taxRate.rate, + label: `${taxRate.name} [${taxRate.rate}%]`, + taxAmount: totalTaxAmount, + taxAmountFormatted, + }; + }); + }, [values.entries]); +}; + +/** + * Retreives the invoice total tax amount. + * @returns {number} + */ +export const useInvoiceTotalTaxAmount = () => { + const { values } = useFormikContext(); + + return React.useMemo(() => { + const filteredEntries = values.entries.filter((entry) => entry.tax_amount); + return sumBy(filteredEntries, 'tax_amount'); + }, [values.entries]); +}; + +/** + * Retreives the invoice total. + * @returns {number} + */ +export const useInvoiceTotal = () => { + const subtotal = useInvoiceSubtotal(); + const totalTaxAmount = useInvoiceTotalTaxAmount(); + const isExclusiveTax = useIsInvoiceTaxExclusive(); + + return R.compose(R.when(R.always(isExclusiveTax), R.add(totalTaxAmount)))( + subtotal, + ); +}; + +/** + * Retreives the invoice due amount. + * @returns {number} + */ +export const useInvoiceDueAmount = () => { + const total = useInvoiceTotal(); + + return total; +}; + +/** + * Detrmines whether the tax is inclusive. + * @returns {boolean} + */ +export const useIsInvoiceTaxInclusive = () => { + const { values } = useFormikContext(); + + return values.inclusive_exclusive_tax === TaxType.Inclusive; +}; + +/** + * Detrmines whether the tax is exclusive. + * @returns {boolean} + */ +export const useIsInvoiceTaxExclusive = () => { + const { values } = useFormikContext(); + + return values.inclusive_exclusive_tax === TaxType.Exclusive; +}; diff --git a/packages/webapp/src/hooks/query/taxRates.ts b/packages/webapp/src/hooks/query/taxRates.ts new file mode 100644 index 000000000..f43a15078 --- /dev/null +++ b/packages/webapp/src/hooks/query/taxRates.ts @@ -0,0 +1,22 @@ +// @ts-nocheck +import { useRequestQuery } from '../useQueryRequest'; +import QUERY_TYPES from './types'; + +/** + * Retrieves tax rates. + * @param {number} customerId - Customer id. + */ +export function useTaxRates(props) { + return useRequestQuery( + [QUERY_TYPES.TAX_RATES], + { + method: 'get', + url: `tax-rates`, + }, + { + select: (res) => res.data.data, + defaultData: [], + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index c557228db..67ddd0761 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -224,6 +224,10 @@ const ORGANIZATION = { ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES: 'ORGANIZATION_MUTATE_BASE_CURRENCY_ABILITIES', }; +export const TAX_RATES = { + TAX_RATES: 'TAX_RATES', +} + export default { ...Authentication, ...ACCOUNTS, @@ -257,4 +261,5 @@ export default { ...BRANCHES, ...DASHBOARD, ...ORGANIZATION, + ...TAX_RATES }; diff --git a/packages/webapp/src/hooks/useUncontrolled.ts b/packages/webapp/src/hooks/useUncontrolled.ts new file mode 100644 index 000000000..6d441fb8b --- /dev/null +++ b/packages/webapp/src/hooks/useUncontrolled.ts @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; + +interface UseUncontrolledInput { + /** Value for controlled state */ + value?: T; + + /** Initial value for uncontrolled state */ + initialValue?: T; + + /** Final value for uncontrolled state when value and initialValue are not provided */ + finalValue?: T; + + /** Controlled state onChange handler */ + onChange?(value: T): void; +} + +export function useUncontrolled({ + value, + initialValue, + finalValue, + onChange = () => {}, +}: UseUncontrolledInput) { + const [uncontrolledValue, setUncontrolledValue] = useState( + initialValue !== undefined ? initialValue : finalValue, + ); + + const handleUncontrolledChange = (val: T) => { + setUncontrolledValue(val); + onChange?.(val); + }; + + if (value !== undefined) { + return [value as T, onChange, true]; + } + return [uncontrolledValue as T, handleUncontrolledChange, false]; +} diff --git a/packages/webapp/src/interfaces/ItemEntries.ts b/packages/webapp/src/interfaces/ItemEntries.ts new file mode 100644 index 000000000..ef52dcbfc --- /dev/null +++ b/packages/webapp/src/interfaces/ItemEntries.ts @@ -0,0 +1,11 @@ +export interface ItemEntry { + index: number; + item_id: number; + description: string; + quantity: number; + rate: number; + discount: number; + tax_rate_id: number; + tax_rate: number; + tax_amount: number; +} diff --git a/packages/webapp/src/interfaces/TaxRates.ts b/packages/webapp/src/interfaces/TaxRates.ts new file mode 100644 index 000000000..6c55b7b49 --- /dev/null +++ b/packages/webapp/src/interfaces/TaxRates.ts @@ -0,0 +1,4 @@ +export enum TaxType { + Inclusive = 'inclusive', + Exclusive = 'exclusive', +}