Enhance Invoice management features with new PostInvoice functionality

- Added PostInvoice component for posting invoices with confirmation dialog.
- Updated InvoiceInfo component to include new invoice order items and shipments sections.
- Modified NewInvoice component to set default issued and due dates.
- Refactored Invoice model to include new fields for issuedAt, dueAt, invoiceOrderItems, and invoiceShipments.
- Updated action names from 'send' to 'post' for clarity in the invoice workflow.
This commit is contained in:
Tom Butcher 2025-12-28 01:09:36 +00:00
parent 0bf16d844e
commit bace57b436
4 changed files with 419 additions and 95 deletions

View File

@ -1,6 +1,6 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' 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 { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import config from '../../../../config.js' import config from '../../../../config.js'
@ -8,6 +8,7 @@ import useCollapseState from '../../hooks/useCollapseState.js'
import NotesPanel from '../../common/NotesPanel.jsx' import NotesPanel from '../../common/NotesPanel.jsx'
import InfoCollapse from '../../common/InfoCollapse.jsx' import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx' import ObjectInfo from '../../common/ObjectInfo.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton.jsx' import ViewButton from '../../common/ViewButton.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
@ -21,7 +22,13 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx' import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import ScrollBox from '../../common/ScrollBox.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') const log = loglevel.getLogger('InvoiceInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -31,14 +38,13 @@ const InvoiceInfo = () => {
const objectFormRef = useRef(null) const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null) const actionHandlerRef = useRef(null)
const invoiceId = new URLSearchParams(location.search).get('invoiceId') const invoiceId = new URLSearchParams(location.search).get('invoiceId')
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState('InvoiceInfo', {
'InvoiceInfo', info: true,
{ invoiceOrderItems: true,
info: true, invoiceShipments: true,
notes: true, notes: true,
auditLogs: true auditLogs: true
} })
)
const [objectFormState, setEditFormState] = useState({ const [objectFormState, setEditFormState] = useState({
isEditing: false, isEditing: false,
@ -48,6 +54,7 @@ const InvoiceInfo = () => {
loading: false, loading: false,
objectData: {} objectData: {}
}) })
const [postInvoiceOpen, setPostInvoiceOpen] = useState(false)
const actions = { const actions = {
reload: () => { reload: () => {
@ -69,12 +76,17 @@ const InvoiceInfo = () => {
delete: () => { delete: () => {
objectFormRef?.current?.handleDelete?.() objectFormRef?.current?.handleDelete?.()
return true return true
},
post: () => {
setPostInvoiceOpen(true)
return true
} }
} }
const editDisabled = getModelByName('invoice') const editDisabled =
?.actions?.find((action) => action.name === 'edit') getModelByName('invoice')
?.disabled(objectFormState.objectData) ?? false ?.actions?.find((action) => action.name === 'edit')
?.disabled(objectFormState.objectData) ?? false
return ( return (
<> <>
@ -99,6 +111,8 @@ const InvoiceInfo = () => {
disabled={objectFormState.loading} disabled={objectFormState.loading}
items={[ items={[
{ key: 'info', label: 'Invoice Information' }, { key: 'info', label: 'Invoice Information' },
{ key: 'invoiceOrderItems', label: 'Invoice Order Items' },
{ key: 'invoiceShipments', label: 'Invoice Shipments' },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' } { key: 'auditLogs', label: 'Audit Logs' }
]} ]}
@ -171,6 +185,42 @@ const InvoiceInfo = () => {
type='invoice' type='invoice'
labelWidth='225px' labelWidth='225px'
objectData={objectData} objectData={objectData}
visibleProperties={{
invoiceOrderItems: false,
invoiceShipments: false
}}
/>
</InfoCollapse>
<InfoCollapse
title='Invoice Order Items'
icon={<OrderItemIcon />}
active={collapseState.invoiceOrderItems}
onToggle={(expanded) =>
updateCollapseState('invoiceOrderItems', expanded)
}
collapseKey='invoiceOrderItems'
>
<ObjectProperty
{...getModelProperty('invoice', 'invoiceOrderItems')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
/>
</InfoCollapse>
<InfoCollapse
title='Invoice Shipments'
icon={<ShipmentIcon />}
active={collapseState.invoiceShipments}
onToggle={(expanded) =>
updateCollapseState('invoiceShipments', expanded)
}
collapseKey='invoiceShipments'
>
<ObjectProperty
{...getModelProperty('invoice', 'invoiceShipments')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
/> />
</InfoCollapse> </InfoCollapse>
</Flex> </Flex>
@ -210,9 +260,26 @@ const InvoiceInfo = () => {
</Flex> </Flex>
</ScrollBox> </ScrollBox>
</Flex> </Flex>
<Modal
open={postInvoiceOpen}
onCancel={() => {
setPostInvoiceOpen(false)
}}
width={500}
footer={null}
destroyOnHidden={true}
centered={true}
>
<PostInvoice
onOk={() => {
setPostInvoiceOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
</> </>
) )
} }
export default InvoiceInfo export default InvoiceInfo

View File

@ -1,4 +1,5 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import dayjs from 'dayjs'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm' import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView' import WizardView from '../../common/WizardView'
@ -10,7 +11,8 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => {
reset={reset} reset={reset}
defaultValues={{ defaultValues={{
state: { type: 'draft' }, state: { type: 'draft' },
invoiceType: 'sales', issuedAt: new Date(),
dueAt: dayjs().add(3, 'day').toDate(),
...defaultValues ...defaultValues
}} }}
> >
@ -30,27 +32,10 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => {
visibleProperties={{ visibleProperties={{
orderType: true, orderType: true,
order: true, order: true,
vendor: true, to: true,
invoiceDate: true, from: true,
dueDate: true issuedAt: true,
}} dueAt: true
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='invoice'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
visibleProperties={{
relatedOrderType: true,
relatedOrder: true
}} }}
/> />
) )
@ -66,6 +51,8 @@ const NewInvoice = ({ onOk, reset, defaultValues }) => {
visibleProperties={{ visibleProperties={{
_id: false, _id: false,
createdAt: false, createdAt: false,
invoiceOrderItems: false,
invoiceShipments: false,
updatedAt: false, updatedAt: false,
_reference: false, _reference: false,
totalAmount: false, totalAmount: false,

View File

@ -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 (
<MessageDialogView
title={'Are you sure you want to post this invoice?'}
description={`Posting invoice ${objectData?.name || objectData?._reference || objectData?._id} will set it to sent status.`}
onOk={handlePost}
okText='Post'
okLoading={postLoading}
/>
)
}
PostInvoice.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default PostInvoice

View File

@ -72,30 +72,16 @@ export const Invoice = {
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
name: 'send', name: 'post',
label: 'Send', label: 'Post',
type: 'button', type: 'button',
icon: CheckIcon, icon: CheckIcon,
url: (_id) => url: (_id) =>
`/dashboard/finance/invoices/info?invoiceId=${_id}&action=send`, `/dashboard/finance/invoices/info?invoiceId=${_id}&action=post`,
visible: (objectData) => { visible: (objectData) => {
return objectData?.state?.type == 'draft' 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', name: 'cancel',
label: 'Cancel', label: 'Cancel',
@ -111,10 +97,7 @@ export const Invoice = {
) )
}, },
visible: (objectData) => { visible: (objectData) => {
return ( return objectData?.state?.type == 'sent'
objectData?.state?.type == 'draft' ||
objectData?.state?.type == 'sent'
)
} }
} }
], ],
@ -172,16 +155,11 @@ export const Invoice = {
}, },
{ name: 'state', label: 'State', type: 'state', readOnly: true }, { name: 'state', label: 'State', type: 'state', readOnly: true },
{ {
name: 'invoiceDate', name: 'issuedAt',
label: 'Invoice Date', label: 'Issued At',
type: 'date', type: 'dateTime',
readOnly: false readOnly: false,
}, required: true
{
name: 'dueDate',
label: 'Due Date',
type: 'date',
readOnly: false
}, },
{ {
name: 'orderType', name: 'orderType',
@ -190,6 +168,12 @@ export const Invoice = {
masterFilter: ['purchaseOrder', 'salesOrder'], masterFilter: ['purchaseOrder', 'salesOrder'],
required: true required: true
}, },
{
name: 'dueAt',
label: 'Due At',
type: 'dateTime',
required: true
},
{ {
name: 'order', name: 'order',
label: 'Order', label: 'Order',
@ -201,8 +185,14 @@ export const Invoice = {
showHyperlink: true showHyperlink: true
}, },
{ {
name: 'vendor', name: 'postedAt',
label: 'Vendor', label: 'Posted At',
type: 'dateTime',
readOnly: true
},
{
name: 'from',
label: 'From',
required: true, required: true,
type: 'object', type: 'object',
objectType: 'vendor', objectType: 'vendor',
@ -212,34 +202,39 @@ export const Invoice = {
if (objectData?.orderType == 'purchaseOrder') { if (objectData?.orderType == 'purchaseOrder') {
return objectData?.order?.vendor return objectData?.order?.vendor
} else { } else {
return objectData?.vendor return null
} }
} }
}, },
{
name: 'sentAt',
label: 'Sent At',
type: 'dateTime',
readOnly: true
},
{ {
name: 'paidAt', name: 'paidAt',
label: 'Paid At', label: 'Paid At',
type: 'dateTime', type: 'dateTime',
readOnly: true 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', name: 'cancelledAt',
label: 'Cancelled At', label: 'Cancelled At',
type: 'dateTime', type: 'dateTime',
readOnly: true readOnly: true
}, },
{
name: 'overdueAt',
label: 'Overdue At',
type: 'dateTime',
readOnly: true
},
{ {
name: 'totalTaxAmount', name: 'totalTaxAmount',
label: 'Total Tax Amount', label: 'Total Tax Amount',
@ -293,44 +288,277 @@ export const Invoice = {
roundNumber: 2, roundNumber: 2,
columnWidth: 175, columnWidth: 175,
readOnly: true 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: [ stats: [
{ {
name: 'draft.count', name: 'draft.draftCount.count',
label: 'Draft', label: 'Draft',
type: 'number', type: 'number',
color: 'default' 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', label: 'Sent',
type: 'number', type: 'number',
color: 'cyan' 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', label: 'Partially Paid',
type: 'number', type: 'number',
color: 'processing' color: 'processing'
}, },
{ {
name: 'paid.count', name: 'overdue.overdueCount.count',
label: 'Paid',
type: 'number',
color: 'success'
},
{
name: 'overdue.count',
label: 'Overdue', label: 'Overdue',
type: 'number', type: 'number',
color: 'error' color: 'error'
}, },
{ {
name: 'cancelled.count', name: 'overdue.overdueGrandTotalAmount.sum',
label: 'Cancelled', label: 'Overdue Grand Total Amount',
type: 'number', type: 'number',
color: 'default' prefix: '£',
roundNumber: 2,
color: 'error'
} }
] ]
} }