diff --git a/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx b/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx index 01d210e..c66d4e0 100644 --- a/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx +++ b/src/components/Dashboard/Finance/Invoices/InvoiceInfo.jsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react' import { useLocation } from 'react-router-dom' -import { Space, Flex, Card } from 'antd' +import { Space, Flex, Card, Modal } from 'antd' import { LoadingOutlined } from '@ant-design/icons' import loglevel from 'loglevel' import config from '../../../../config.js' @@ -8,6 +8,7 @@ import useCollapseState from '../../hooks/useCollapseState.js' import NotesPanel from '../../common/NotesPanel.jsx' import InfoCollapse from '../../common/InfoCollapse.jsx' import ObjectInfo from '../../common/ObjectInfo.jsx' +import ObjectProperty from '../../common/ObjectProperty.jsx' import ViewButton from '../../common/ViewButton.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx' @@ -21,7 +22,13 @@ import ObjectTable from '../../common/ObjectTable.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import ScrollBox from '../../common/ScrollBox.jsx' -import { getModelByName } from '../../../../database/ObjectModels.js' +import { + getModelByName, + getModelProperty +} from '../../../../database/ObjectModels.js' +import OrderItemIcon from '../../../Icons/OrderItemIcon.jsx' +import ShipmentIcon from '../../../Icons/ShipmentIcon.jsx' +import PostInvoice from './PostInvoice.jsx' const log = loglevel.getLogger('InvoiceInfo') log.setLevel(config.logLevel) @@ -31,14 +38,13 @@ const InvoiceInfo = () => { const objectFormRef = useRef(null) const actionHandlerRef = useRef(null) const invoiceId = new URLSearchParams(location.search).get('invoiceId') - const [collapseState, updateCollapseState] = useCollapseState( - 'InvoiceInfo', - { - info: true, - notes: true, - auditLogs: true - } - ) + const [collapseState, updateCollapseState] = useCollapseState('InvoiceInfo', { + info: true, + invoiceOrderItems: true, + invoiceShipments: true, + notes: true, + auditLogs: true + }) const [objectFormState, setEditFormState] = useState({ isEditing: false, @@ -48,6 +54,7 @@ const InvoiceInfo = () => { loading: false, objectData: {} }) + const [postInvoiceOpen, setPostInvoiceOpen] = useState(false) const actions = { reload: () => { @@ -69,12 +76,17 @@ const InvoiceInfo = () => { delete: () => { objectFormRef?.current?.handleDelete?.() return true + }, + post: () => { + setPostInvoiceOpen(true) + return true } } - const editDisabled = getModelByName('invoice') - ?.actions?.find((action) => action.name === 'edit') - ?.disabled(objectFormState.objectData) ?? false + const editDisabled = + getModelByName('invoice') + ?.actions?.find((action) => action.name === 'edit') + ?.disabled(objectFormState.objectData) ?? false return ( <> @@ -99,6 +111,8 @@ const InvoiceInfo = () => { disabled={objectFormState.loading} items={[ { key: 'info', label: 'Invoice Information' }, + { key: 'invoiceOrderItems', label: 'Invoice Order Items' }, + { key: 'invoiceShipments', label: 'Invoice Shipments' }, { key: 'notes', label: 'Notes' }, { key: 'auditLogs', label: 'Audit Logs' } ]} @@ -171,6 +185,42 @@ const InvoiceInfo = () => { type='invoice' labelWidth='225px' objectData={objectData} + visibleProperties={{ + invoiceOrderItems: false, + invoiceShipments: false + }} + /> + + } + active={collapseState.invoiceOrderItems} + onToggle={(expanded) => + updateCollapseState('invoiceOrderItems', expanded) + } + collapseKey='invoiceOrderItems' + > + + + } + active={collapseState.invoiceShipments} + onToggle={(expanded) => + updateCollapseState('invoiceShipments', expanded) + } + collapseKey='invoiceShipments' + > + @@ -210,9 +260,26 @@ const InvoiceInfo = () => { + { + setPostInvoiceOpen(false) + }} + width={500} + footer={null} + destroyOnHidden={true} + centered={true} + > + { + setPostInvoiceOpen(false) + actions.reload() + }} + objectData={objectFormState.objectData} + /> + ) } export default InvoiceInfo - diff --git a/src/components/Dashboard/Finance/Invoices/NewInvoice.jsx b/src/components/Dashboard/Finance/Invoices/NewInvoice.jsx index 620cdaf..ebe0699 100644 --- a/src/components/Dashboard/Finance/Invoices/NewInvoice.jsx +++ b/src/components/Dashboard/Finance/Invoices/NewInvoice.jsx @@ -1,4 +1,5 @@ import PropTypes from 'prop-types' +import dayjs from 'dayjs' import ObjectInfo from '../../common/ObjectInfo' import NewObjectForm from '../../common/NewObjectForm' import WizardView from '../../common/WizardView' @@ -10,7 +11,8 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => { reset={reset} defaultValues={{ state: { type: 'draft' }, - invoiceType: 'sales', + issuedAt: new Date(), + dueAt: dayjs().add(3, 'day').toDate(), ...defaultValues }} > @@ -30,27 +32,10 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => { visibleProperties={{ orderType: true, order: true, - vendor: true, - invoiceDate: true, - dueDate: true - }} - /> - ) - }, - { - title: 'Optional', - key: 'optional', - content: ( - ) @@ -66,6 +51,8 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => { visibleProperties={{ _id: false, createdAt: false, + invoiceOrderItems: false, + invoiceShipments: false, updatedAt: false, _reference: false, totalAmount: false, diff --git a/src/components/Dashboard/Finance/Invoices/PostInvoice.jsx b/src/components/Dashboard/Finance/Invoices/PostInvoice.jsx new file mode 100644 index 0000000..8f18dd5 --- /dev/null +++ b/src/components/Dashboard/Finance/Invoices/PostInvoice.jsx @@ -0,0 +1,42 @@ +import { useState, useContext } from 'react' +import PropTypes from 'prop-types' +import { ApiServerContext } from '../../context/ApiServerContext' +import { message } from 'antd' +import MessageDialogView from '../../common/MessageDialogView.jsx' + +const PostInvoice = ({ onOk, objectData }) => { + const [postLoading, setPostLoading] = useState(false) + const { sendObjectFunction } = useContext(ApiServerContext) + + const handlePost = async () => { + setPostLoading(true) + try { + const result = await sendObjectFunction(objectData._id, 'Invoice', 'post') + if (result) { + message.success('Invoice posted successfully') + onOk(result) + } + } catch (error) { + console.error('Error posting invoice:', error) + } finally { + setPostLoading(false) + } + } + + return ( + + ) +} + +PostInvoice.propTypes = { + onOk: PropTypes.func.isRequired, + objectData: PropTypes.object +} + +export default PostInvoice diff --git a/src/database/models/Invoice.js b/src/database/models/Invoice.js index eed44be..2522707 100644 --- a/src/database/models/Invoice.js +++ b/src/database/models/Invoice.js @@ -72,30 +72,16 @@ export const Invoice = { }, { type: 'divider' }, { - name: 'send', - label: 'Send', + name: 'post', + label: 'Post', type: 'button', icon: CheckIcon, url: (_id) => - `/dashboard/finance/invoices/info?invoiceId=${_id}&action=send`, + `/dashboard/finance/invoices/info?invoiceId=${_id}&action=post`, visible: (objectData) => { return objectData?.state?.type == 'draft' } }, - { - name: 'markPaid', - label: 'Mark Paid', - type: 'button', - icon: CheckIcon, - url: (_id) => - `/dashboard/finance/invoices/info?invoiceId=${_id}&action=markPaid`, - visible: (objectData) => { - return ( - objectData?.state?.type == 'sent' || - objectData?.state?.type == 'partiallyPaid' - ) - } - }, { name: 'cancel', label: 'Cancel', @@ -111,10 +97,7 @@ export const Invoice = { ) }, visible: (objectData) => { - return ( - objectData?.state?.type == 'draft' || - objectData?.state?.type == 'sent' - ) + return objectData?.state?.type == 'sent' } } ], @@ -172,16 +155,11 @@ export const Invoice = { }, { name: 'state', label: 'State', type: 'state', readOnly: true }, { - name: 'invoiceDate', - label: 'Invoice Date', - type: 'date', - readOnly: false - }, - { - name: 'dueDate', - label: 'Due Date', - type: 'date', - readOnly: false + name: 'issuedAt', + label: 'Issued At', + type: 'dateTime', + readOnly: false, + required: true }, { name: 'orderType', @@ -190,6 +168,12 @@ export const Invoice = { masterFilter: ['purchaseOrder', 'salesOrder'], required: true }, + { + name: 'dueAt', + label: 'Due At', + type: 'dateTime', + required: true + }, { name: 'order', label: 'Order', @@ -201,8 +185,14 @@ export const Invoice = { showHyperlink: true }, { - name: 'vendor', - label: 'Vendor', + name: 'postedAt', + label: 'Posted At', + type: 'dateTime', + readOnly: true + }, + { + name: 'from', + label: 'From', required: true, type: 'object', objectType: 'vendor', @@ -212,34 +202,39 @@ export const Invoice = { if (objectData?.orderType == 'purchaseOrder') { return objectData?.order?.vendor } else { - return objectData?.vendor + return null } } }, - { - name: 'sentAt', - label: 'Sent At', - type: 'dateTime', - readOnly: true - }, { name: 'paidAt', label: 'Paid At', type: 'dateTime', readOnly: true }, + { + name: 'to', + label: 'To', + required: true, + type: 'object', + objectType: 'client', + showHyperlink: true, + readOnly: true, + value: (objectData) => { + if (objectData?.orderType == 'salesOrder') { + return objectData?.order?.client + } else { + return null + } + } + }, { name: 'cancelledAt', label: 'Cancelled At', type: 'dateTime', readOnly: true }, - { - name: 'overdueAt', - label: 'Overdue At', - type: 'dateTime', - readOnly: true - }, + { name: 'totalTaxAmount', label: 'Total Tax Amount', @@ -293,44 +288,277 @@ export const Invoice = { roundNumber: 2, columnWidth: 175, readOnly: true + }, + { + name: 'invoiceOrderItems', + label: 'Invoice Order Items', + type: 'objectChildren', + objectType: 'orderItem', + properties: [ + { + name: 'orderItem', + label: 'Order Item', + type: 'object', + objectType: 'orderItem', + required: true, + showHyperlink: true + }, + { + name: 'invoiceQuantity', + label: 'Quantity', + type: 'number', + required: true + }, + { + name: 'invoiceAmount', + label: 'Invoice Amount', + type: 'number', + prefix: '£', + roundNumber: 2, + required: true + }, + { + name: 'taxRate', + label: 'Tax Rate', + type: 'object', + objectType: 'taxRate', + required: false, + showHyperlink: true + }, + { + name: 'invoiceAmountWithTax', + label: 'Invoice Amount w/ Tax', + type: 'number', + prefix: '£', + roundNumber: 2, + required: true, + readOnly: true, + value: (objectData) => { + const invoiceAmount = objectData?.invoiceAmount || 0 + if (objectData?.taxRate?.rateType == 'percentage') { + return ( + (invoiceAmount * (1 + objectData?.taxRate?.rate / 100)).toFixed( + 2 + ) || undefined + ) + } else if (objectData?.taxRate?.rateType == 'amount') { + return ( + (invoiceAmount + objectData?.taxRate?.rate).toFixed(2) || + undefined + ) + } else { + return invoiceAmount || 0 + } + } + } + ], + rollups: [ + { + name: 'totalQuantity', + label: 'Total Quantity', + type: 'number', + property: 'invoiceQuantity', + value: (objectData) => { + return objectData?.invoiceOrderItems?.reduce( + (acc, item) => acc + (item.invoiceQuantity || 0), + 0 + ) + } + }, + { + name: 'totalAmount', + label: 'Total Amount', + type: 'number', + property: 'invoiceAmount', + prefix: '£', + fixedNumber: 2, + value: (objectData) => { + return objectData?.invoiceOrderItems + ?.reduce( + (acc, item) => + acc + (Number.parseFloat(item.invoiceAmount) || 0), + 0 + ) + .toFixed(2) + } + }, + { + name: 'totalAmountWithTax', + label: 'Total Amount w/ Tax', + type: 'number', + property: 'invoiceAmountWithTax', + prefix: '£', + fixedNumber: 2, + value: (objectData) => { + return objectData?.invoiceOrderItems + ?.reduce( + (acc, item) => + acc + (Number.parseFloat(item.invoiceAmountWithTax) || 0), + 0 + ) + .toFixed(2) + } + } + ] + }, + { + name: 'invoiceShipments', + label: 'Invoice Shipments', + type: 'objectChildren', + objectType: 'shipment', + properties: [ + { + name: 'shipment', + label: 'Shipment', + type: 'object', + objectType: 'shipment', + required: true, + showHyperlink: true + }, + { + name: 'invoiceAmount', + label: 'Invoice Amount', + type: 'number', + prefix: '£', + roundNumber: 2, + required: true + }, + { + name: 'taxRate', + label: 'Tax Rate', + type: 'object', + objectType: 'taxRate', + required: false, + showHyperlink: true + }, + { + name: 'invoiceAmountWithTax', + label: 'Invoice Amount w/ Tax', + type: 'number', + prefix: '£', + roundNumber: 2, + required: true, + readOnly: true, + value: (objectData) => { + const invoiceAmount = objectData?.invoiceAmount || 0 + if (objectData?.taxRate?.rateType == 'percentage') { + return ( + (invoiceAmount * (1 + objectData?.taxRate?.rate / 100)).toFixed( + 2 + ) || undefined + ) + } else if (objectData?.taxRate?.rateType == 'amount') { + return ( + (invoiceAmount + objectData?.taxRate?.rate).toFixed(2) || + undefined + ) + } else { + return invoiceAmount || 0 + } + } + } + ], + rollups: [ + { + name: 'totalAmount', + label: 'Total Amount', + type: 'number', + property: 'invoiceAmount', + prefix: '£', + roundNumber: 2, + value: (objectData) => { + return objectData?.invoiceShipments + ?.reduce( + (acc, shipment) => acc + (shipment.invoiceAmount || 0), + 0 + ) + .toFixed(2) + } + }, + { + name: 'totalAmountWithTax', + label: 'Total Amount w/ Tax', + type: 'number', + property: 'invoiceAmountWithTax', + prefix: '£', + roundNumber: 2, + value: (objectData) => { + return objectData?.invoiceShipments + ?.reduce( + (acc, shipment) => + acc + (Number.parseFloat(shipment.invoiceAmountWithTax) || 0), + 0 + ) + .toFixed(2) + } + } + ] } ], stats: [ { - name: 'draft.count', + name: 'draft.draftCount.count', label: 'Draft', type: 'number', color: 'default' }, { - name: 'sent.count', + name: 'draft.draftGrandTotalAmount.sum', + label: 'Draft Grand Total Amount', + type: 'number', + prefix: '£', + roundNumber: 2, + color: 'default' + }, + { + name: 'sent.sentCount.count', label: 'Sent', type: 'number', color: 'cyan' }, { - name: 'partiallyPaid.count', + name: 'due.dueCount.count', + label: 'Due', + type: 'number', + color: 'warning', + sum: [ + 'sent.sentCount.count', + 'partiallyPaid.partiallyPaidCount.count', + 'overdue.overdueCount.count' + ] + }, + { + name: 'due.dueGrandTotalAmount.sum', + label: 'Due Grand Total Amount', + type: 'number', + prefix: '£', + roundNumber: 2, + color: 'warning', + sum: [ + 'sent.sentGrandTotalAmount.sum', + 'partiallyPaid.partiallyPaidGrandTotalAmount.sum', + 'overdue.overdueGrandTotalAmount.sum' + ] + }, + { + name: 'partiallyPaid.partiallyPaidCount.count', label: 'Partially Paid', type: 'number', color: 'processing' }, { - name: 'paid.count', - label: 'Paid', - type: 'number', - color: 'success' - }, - { - name: 'overdue.count', + name: 'overdue.overdueCount.count', label: 'Overdue', type: 'number', color: 'error' }, { - name: 'cancelled.count', - label: 'Cancelled', + name: 'overdue.overdueGrandTotalAmount.sum', + label: 'Overdue Grand Total Amount', type: 'number', - color: 'default' + prefix: '£', + roundNumber: 2, + color: 'error' } ] }