Compare commits

..

21 Commits

Author SHA1 Message Date
94406a1bfc Add Invoice model to objectModels and re-export for direct access. 2025-12-27 13:55:21 +00:00
a0e09508f4 Update Job and Printer models to change 'info' color to 'processing' for printing count. 2025-12-27 13:55:07 +00:00
2bbb068621 Add FinanceIcon component. 2025-12-27 13:54:44 +00:00
8b6dbff9ff Add Purchase Order Statistics section to InventoryOverview component
- Introduced a new collapsible section for Purchase Order Statistics.
- Updated collapse state management to include purchaseOrderStats.
- Integrated StatsDisplay component to show relevant purchase order data.
2025-12-27 13:54:31 +00:00
769229aacb Updated NewOrderItem component to set default state to 'draft' and added an optional section for shipment details.
New actions for editing, canceling edits, finishing edits, and deleting items, along with visibility and disabled states based on item status.
2025-12-27 13:54:14 +00:00
4a605ddc09 Enhance Purchase Order management with new actions and UI updates
- Updated NewPurchaseOrder component to set default state to 'draft'.
- Removed unnecessary item property display and added new properties for total amounts and shipping details.
- Introduced new PurchaseOrderInfo component to manage order items and shipments, including modals for creating new items and shipments, posting, acknowledging, and canceling orders.
- Enhanced PurchaseOrder model with new actions for editing, canceling edits, finishing edits, and deleting orders, along with updated visibility and disabled states based on order status.
- Added new properties to the PurchaseOrder model for better tracking of order states and financial details.
2025-12-27 13:52:50 +00:00
62a494509a Refactor shipment components and update shipment model
- Changed default shipment state from 'pending' to 'draft' in NewShipment component.
- Updated shipment information display to include new pricing and optional sections.
- Added modal dialogs for shipping, receiving, and canceling shipments in ShipmentInfo component.
- Enhanced Shipment model with new actions for editing, canceling edits, finishing edits, and managing shipment states.
- Introduced new properties for amount, tax rate, and tax amount in the Shipment model, along with updated sorting and filtering options.
2025-12-27 13:52:13 +00:00
1c586daf3b Add shipment management components
- Introduced CancelShipment, ReceiveShipment, and ShipShipment components for handling shipment actions.
- Each component includes a confirmation dialog and integrates with the ApiServerContext for respective operations.
- Added loading states and success messages for user feedback upon successful actions.
2025-12-27 13:51:43 +00:00
57e90e2b6f Add additional states to StateTag component
- Introduced new states: 'sent', 'acknowledged', 'ordered', 'received', 'invoiced', 'planned', 'partiallyShipped', 'shipped', 'delivered', and 'paid' with corresponding badge statuses and texts.
- Updated badge properties to handle custom colors for non-standard statuses.
2025-12-27 13:51:10 +00:00
50bc816e97 Added edit mode to tables. 2025-12-27 13:50:30 +00:00
38cafdb4a4 Bug fix: re-render tree when type, masterFiter etc changes. 2025-12-27 13:50:17 +00:00
556b16a5dc Add fixedNumber prop to ObjectProperty component for number formatting. 2025-12-27 13:49:26 +00:00
ab56a3abd4 Add navigation after object deletion in ObjectForm component. 2025-12-27 13:49:13 +00:00
100bfb69dc Initialize objectData state with defaultValues and set _isEditing to true in NewObjectForm component 2025-12-27 13:48:55 +00:00
1b6137fe77 Add Acknowledge, Cancel, and Post Purchase Order components
- Introduced AcknowledgePurchaseOrder, CancelPurchaseOrder, and PostPurchaseOrder components for managing purchase order actions.
- Each component includes a confirmation dialog and integrates with the ApiServerContext for handling respective operations.
- Added loading states and success messages for user feedback upon successful actions.
2025-12-27 13:48:42 +00:00
f83069a7fb Add updateMultipleObjects and sendObjectFunction methods to ApiServerContext
- Implemented updateMultipleObjects for batch updates of objects via PUT request.
- Added sendObjectFunction to invoke specific functions on objects with POST requests.
- Enhanced error handling for both methods to retry on failure.
2025-12-27 13:47:45 +00:00
a7cd374375 Refactor StatsDisplay component to use Tag and Badge for visual indicators
- Replaced Alert with Tag for displaying stat colors.
- Updated color mapping function to return Tag colors instead of Alert types.
- Enhanced layout and styling for better visual representation of stats.
- Introduced Badge component for status indication alongside stat labels.
2025-12-27 13:47:19 +00:00
b3c5357064 Enhance color theme in HistoryDisplay and ThemeContext
- Updated color mapping in HistoryDisplay to include new colors: cyan, pink, purple, magenta, and volcano.
- Added corresponding color definitions in ThemeContext for improved theme customization.
2025-12-27 13:46:58 +00:00
0a780b6d85 Add MessageDialogView component for displaying messages. 2025-12-27 13:46:26 +00:00
c03a47a833 Integrate Finance routes and sidebar into the dashboard layout
- Added FinanceRoutes to App component for navigation.
- Included FinanceSidebar in DashboardLayout to support finance-related views.
- Updated layout logic to handle finance-specific paths.
2025-12-27 13:46:06 +00:00
cabc68c932 Add finance dashboard components and routes
- Introduced FinanceOverview, FinanceSidebar, and Invoices components for the finance dashboard.
- Added InvoiceInfo and NewInvoice components for managing invoices.
- Created SVG icons for finance and invoice.
- Updated routing to include finance-related paths.
- Enhanced DashboardBreadcrumb and DashboardNavigation to support finance navigation.
- Defined Invoice model with actions and properties for invoice management.
2025-12-27 13:45:26 +00:00
46 changed files with 2944 additions and 381 deletions

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M29.953,40.788l10.342,0c1.761,0 3.18,1.258 3.18,3.055c0,1.788 -1.349,3.03 -3.18,3.03l-18.004,0c-1.785,0 -3.086,-1.234 -3.086,-3.075c0,-1.581 0.953,-2.598 2.336,-3.193l0.024,-0.01c2.054,-0.833 3.153,-2.551 3.153,-4.757c0,-0.425 -0.052,-0.856 -0.133,-1.305l-2.8,0c-1.588,0 -2.701,-1.131 -2.701,-2.591c0,-1.408 1.105,-2.56 2.701,-2.56l1.544,-0c-0.168,-0.87 -0.242,-1.672 -0.242,-2.485c0,-6.288 4.756,-9.887 11.404,-9.887c2.234,0 3.578,0.148 5.274,0.766c1.53,0.469 2.646,1.469 2.646,3.106c0,0.878 -0.321,1.553 -0.828,2.023c-0.48,0.446 -1.159,0.725 -2.011,0.725c-0.479,0 -1.089,-0.115 -1.715,-0.248l-0.045,-0.01c-0.697,-0.176 -1.631,-0.301 -2.775,-0.301c-2.932,0 -5.078,1.33 -5.078,3.98c0,0.734 0.074,1.394 0.272,2.332l7.292,0c1.585,0 2.701,1.163 2.701,2.56c0,1.449 -1.124,2.591 -2.701,2.591l-6.16,0c0.024,0.361 0.034,0.743 0.034,1.147c0,1.832 -0.479,3.671 -1.444,5.108Z"/><path d="M12.892,61l38.215,0c6.616,0 9.892,-3.245 9.892,-9.735l0,-38.499c0,-6.49 -3.276,-9.766 -9.892,-9.766l-38.215,-0c-6.584,0 -9.892,3.276 -9.892,9.766l0,38.499c0,6.49 3.308,9.735 9.892,9.735Zm0.063,-5.072c-3.15,0 -4.883,-1.67 -4.883,-4.946l0,-37.932c0,-3.276 1.733,-4.978 4.883,-4.978l38.089,0c3.119,0 4.883,1.701 4.883,4.978l0,37.932c0,3.276 -1.764,4.946 -4.883,4.946l-38.089,0Z" style="fill-rule:nonzero;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.58577,0,0,0.58577,29,29)">
<path d="M11.031,59.75L48.719,59.75C55.859,59.75 59.75,55.891 59.75,48.797L59.75,10.969C59.75,3.891 55.859,-0 48.719,-0L11.031,-0C3.906,-0 -0,3.891 -0,10.969L-0,48.797C-0,55.891 3.906,59.75 11.031,59.75ZM11.906,51.688C9.391,51.688 8.063,50.469 8.063,47.813L8.063,11.969C8.063,9.313 9.391,8.078 11.906,8.078L47.844,8.078C50.344,8.078 51.688,9.313 51.688,11.969L51.688,47.813C51.688,50.469 50.344,51.688 47.844,51.688L11.906,51.688Z" style="fill-rule:nonzero;"/>
<g transform="matrix(0.664312,0,0,0.664312,5.121464,12.135807)">
<path d="M37.594,52.969C39.062,52.969 40.625,52.281 41.875,51L61.844,31.094C62.938,30 63.656,28.188 63.656,26.5C63.656,24.812 62.938,23 61.844,21.906L41.875,1.969C40.625,0.688 39.062,0 37.594,0C33.812,0 31.406,2.562 31.406,5.906C31.406,7.875 32.312,9.25 33.5,10.406L40.5,17.344L50.219,26.5L40.5,35.656L33.5,42.562C32.312,43.688 31.406,45.094 31.406,47.062C31.406,50.406 33.812,52.969 37.594,52.969ZM1.485,32.781L37.75,32.781L51.969,32.094C55.531,31.938 57.906,29.844 57.906,26.5C57.906,23.156 55.531,21.062 51.969,20.906L37.75,20.219L1.485,20.219C-2.515,20.219 -5.14,22.719 -5.14,26.5C-5.14,30.281 -2.515,32.781 1.485,32.781Z" style="fill-rule:nonzero;"/>
</g>
</g>
<g transform="matrix(0.739137,0,0,0.739137,-0,2.611674)">
<path d="M35.853,52.047L25.734,52.047C20.578,52.047 17.797,48.969 17.047,43.844L12.484,12.719L3.906,12.719C1.812,12.719 0,10.906 0,8.766C0,6.625 1.812,4.828 3.906,4.828L14.172,4.828C18.125,4.828 19.609,6.437 20.047,9.625L20.402,12.109L69.062,12.109C71.922,12.109 73.438,13.75 73.438,16.031C73.438,16.5 73.344,17.062 73.281,17.516L71.172,31.781C71.146,31.963 71.118,32.143 71.087,32.319L63.084,32.319C63.272,32.028 63.394,31.657 63.453,31.219L65.078,19.094L21.4,19.094L23.397,33.062L43.157,33.063C41.542,33.617 40.191,34.464 39.103,35.547C37.914,36.73 37.007,38.224 36.457,40.031L24.393,40.031L24.844,43.188C25,44.312 25.656,45.094 26.719,45.094L35.853,45.094L35.853,52.047ZM28.422,68.437C25.078,68.437 22.359,65.766 22.359,62.391C22.359,59.063 25.078,56.359 28.422,56.359C31.781,56.359 34.453,59.063 34.453,62.391C34.453,65.766 31.781,68.437 28.422,68.437Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -31,6 +31,7 @@ import AuthCallback from './components/App/AuthCallback.jsx'
import {
ProductionRoutes,
InventoryRoutes,
FinanceRoutes,
ManagementRoutes,
DeveloperRoutes
} from './routes'
@ -96,6 +97,7 @@ const AppContent = () => {
>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{ManagementRoutes}
{DeveloperRoutes}
</Route>

View File

@ -0,0 +1,61 @@
import { useContext } from 'react'
import { Flex } from 'antd'
import useCollapseState from '../hooks/useCollapseState'
import StatsDisplay from '../common/StatsDisplay'
import InfoCollapse from '../common/InfoCollapse'
import ScrollBox from '../common/ScrollBox'
import { ApiServerContext } from '../context/ApiServerContext'
const FinanceOverview = () => {
const { connected } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState(
'FinanceOverview',
{
invoiceStats: true
}
)
if (!connected) {
return null
}
return (
<Flex
gap='large'
vertical='true'
style={{
maxHeight: '100%',
minHeight: 0
}}
>
<ScrollBox>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Invoice Statistics'
icon={null}
active={collapseState.invoiceStats}
onToggle={(isActive) =>
updateCollapseState('invoiceStats', isActive)
}
className='no-t-padding-collapse'
collapseKey='invoiceStats'
>
<Flex
justify='flex-start'
gap='middle'
wrap='wrap'
align='flex-start'
>
<StatsDisplay objectType='invoice' />
</Flex>
</InfoCollapse>
</Flex>
</ScrollBox>
</Flex>
)
}
export default FinanceOverview

View File

@ -0,0 +1,46 @@
import { useLocation } from 'react-router-dom'
import DashboardSidebar from '../common/DashboardSidebar'
import InvoiceIcon from '../../Icons/InvoiceIcon'
import FinanceIcon from '../../Icons/FinanceIcon'
const items = [
{
key: 'overview',
label: 'Overview',
icon: <FinanceIcon />,
path: '/dashboard/finance/overview'
},
{ type: 'divider' },
{
key: 'invoices',
label: 'Invoices',
icon: <InvoiceIcon />,
path: '/dashboard/finance/invoices'
}
]
const routeKeyMap = {
'/dashboard/finance/overview': 'overview',
'/dashboard/finance/invoices': 'invoices'
}
const FinanceSidebar = (props) => {
const location = useLocation()
const selectedKey = (() => {
const match = Object.keys(routeKeyMap).find((path) => {
const pathSplit = path.split('/')
const locationPathSplit = location.pathname.split('/')
if (pathSplit.length > locationPathSplit.length) return false
for (let i = 0; i < pathSplit.length; i++) {
if (pathSplit[i] !== locationPathSplit[i]) return false
}
return true
})
return match ? routeKeyMap[match] : 'overview'
})()
return <DashboardSidebar items={items} selectedKey={selectedKey} {...props} />
}
export default FinanceSidebar

View File

@ -0,0 +1,98 @@
import { useState, useRef } from 'react'
import { Button, Flex, Space, Dropdown, Modal } from 'antd'
import NewInvoice from './Invoices/NewInvoice'
import ObjectTable from '../common/ObjectTable'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import GridIcon from '../../Icons/GridIcon'
import ListIcon from '../../Icons/ListIcon'
import useViewMode from '../hooks/useViewMode'
import ColumnViewButton from '../common/ColumnViewButton'
const Invoices = () => {
const [newInvoiceOpen, setNewInvoiceOpen] = useState(false)
const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('invoices')
const [columnVisibility, setColumnVisibility] =
useColumnVisibility('invoices')
const actionItems = {
items: [
{
label: 'New Invoice',
key: 'newInvoice',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newInvoice') {
setNewInvoiceOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<ColumnViewButton
type='invoice'
loading={false}
visibleState={columnVisibility}
updateVisibleState={setColumnVisibility}
/>
</Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex>
<ObjectTable
ref={tableRef}
visibleColumns={columnVisibility}
type='invoice'
cards={viewMode === 'cards'}
/>
</Flex>
<Modal
open={newInvoiceOpen}
styles={{ content: { paddingBottom: '24px' } }}
footer={null}
width={800}
onCancel={() => {
setNewInvoiceOpen(false)
}}
destroyOnHidden={true}
>
<NewInvoice
onOk={() => {
setNewInvoiceOpen(false)
tableRef.current?.reload()
}}
reset={newInvoiceOpen}
/>
</Modal>
</>
)
}
export default Invoices

View File

@ -0,0 +1,218 @@
import { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel'
import config from '../../../../config.js'
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 ViewButton from '../../common/ViewButton.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ObjectForm from '../../common/ObjectForm.jsx'
import EditButtons from '../../common/EditButtons.jsx'
import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx'
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'
const log = loglevel.getLogger('InvoiceInfo')
log.setLevel(config.logLevel)
const InvoiceInfo = () => {
const location = useLocation()
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 [objectFormState, setEditFormState] = useState({
isEditing: false,
editLoading: false,
formValid: false,
lock: null,
loading: false,
objectData: {}
})
const actions = {
reload: () => {
objectFormRef?.current?.handleFetchObject?.()
return true
},
edit: () => {
objectFormRef?.current?.startEditing?.()
return false
},
cancelEdit: () => {
objectFormRef?.current?.cancelEditing?.()
return true
},
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
delete: () => {
objectFormRef?.current?.handleDelete?.()
return true
}
}
const editDisabled = getModelByName('invoice')
?.actions?.find((action) => action.name === 'edit')
?.disabled(objectFormState.objectData) ?? false
return (
<>
<Flex
gap='large'
vertical='true'
style={{
maxHeight: '100%',
minHeight: 0
}}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='invoice'
id={invoiceId}
disabled={objectFormState.loading}
objectData={objectFormState.objectData}
/>
<ViewButton
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Invoice Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
<DocumentPrintButton
type='invoice'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
</Space>
<LockIndicator lock={objectFormState.lock} />
</Space>
<Space>
<EditButtons
isEditing={objectFormState.isEditing}
handleUpdate={() => {
actionHandlerRef.current.callAction('finishEdit')
}}
cancelEditing={() => {
actionHandlerRef.current.callAction('cancelEdit')
}}
startEditing={() => {
actionHandlerRef.current.callAction('edit')
}}
editLoading={objectFormState.editLoading}
formValid={objectFormState.formValid}
disabled={
objectFormState.lock?.locked ||
objectFormState.loading ||
editDisabled
}
loading={objectFormState.editLoading}
/>
</Space>
</Flex>
<ScrollBox>
<Flex vertical gap={'large'}>
<ActionHandler
actions={actions}
loading={objectFormState.loading}
ref={actionHandlerRef}
>
<ObjectForm
id={invoiceId}
type='invoice'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<Flex vertical gap={'large'}>
<InfoCollapse
title='Invoice Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='invoice'
labelWidth='225px'
objectData={objectData}
/>
</InfoCollapse>
</Flex>
)}
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={invoiceId} type='invoice' />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': invoiceId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</ScrollBox>
</Flex>
</>
)
}
export default InvoiceInfo

View File

@ -0,0 +1,113 @@
import PropTypes from 'prop-types'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const NewInvoice = ({ onOk, reset, defaultValues }) => {
return (
<NewObjectForm
type={'invoice'}
reset={reset}
defaultValues={{
state: { type: 'draft' },
invoiceType: 'sales',
...defaultValues
}}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='invoice'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
visibleProperties={{
orderType: true,
order: true,
vendor: true,
invoiceDate: true,
dueDate: true
}}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='invoice'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
visibleProperties={{
relatedOrderType: true,
relatedOrder: true
}}
/>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<ObjectInfo
type='invoice'
column={1}
bordered={false}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false,
_reference: false,
totalAmount: false,
totalAmountWithTax: false,
totalTaxAmount: false,
shippingAmount: false,
shippingAmountWithTax: false,
grandTotalAmount: false,
sentAt: false,
paidAt: false,
cancelledAt: false,
overdueAt: false
}}
isEditing={false}
objectData={objectData}
/>
)
}
]
return (
<WizardView
steps={steps}
loading={submitLoading}
formValid={formValid}
title='New Invoice'
onSubmit={async () => {
const result = await handleSubmit()
if (result) {
onOk()
}
}}
/>
)
}}
</NewObjectForm>
)
}
NewInvoice.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool,
defaultValues: PropTypes.object
}
export default NewInvoice

View File

@ -13,7 +13,8 @@ const InventoryOverview = () => {
const [collapseState, updateCollapseState] = useCollapseState(
'InventoryOverview',
{
inventoryStats: true
inventoryStats: true,
purchaseOrderStats: true
}
)
@ -55,6 +56,27 @@ const InventoryOverview = () => {
</Flex>
</InfoCollapse>
<InfoCollapse
title='Purchase Order Statistics'
icon={null}
canCollapse={false}
active={collapseState.purchaseOrderStats}
onToggle={(isActive) =>
updateCollapseState('purchaseOrderStats', isActive)
}
className='no-t-padding-collapse'
collapseKey='purchaseOrderStats'
>
<Flex
justify='flex-start'
gap='middle'
wrap='wrap'
align='flex-start'
>
<StatsDisplay objectType='purchaseOrder' />
</Flex>
</InfoCollapse>
<Flex gap='large' wrap='wrap'>
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
<InfoCollapse

View File

@ -8,7 +8,12 @@ const NewOrderItem = ({ onOk, reset, defaultValues }) => {
<NewObjectForm
type={'orderItem'}
reset={reset}
defaultValues={{ syncAmount: null, quantity: 1, ...defaultValues }}
defaultValues={{
state: { type: 'draft' },
syncAmount: null,
quantity: 1,
...defaultValues
}}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
@ -54,6 +59,23 @@ const NewOrderItem = ({ onOk, reset, defaultValues }) => {
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='orderItem'
column={1}
bordered={false}
isEditing={true}
required={false}
objectData={objectData}
visibleProperties={{
shipment: true
}}
/>
)
},
{
title: 'Summary',
key: 'summary',
@ -65,7 +87,8 @@ const NewOrderItem = ({ onOk, reset, defaultValues }) => {
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false
updatedAt: false,
_reference: false
}}
isEditing={false}
objectData={objectData}

View File

@ -0,0 +1,46 @@
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 AcknowledgePurchaseOrder = ({ onOk, objectData }) => {
const [acknowledgeLoading, setAcknowledgeLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleAcknowledge = async () => {
setAcknowledgeLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'PurchaseOrder',
'acknowledge'
)
if (result) {
message.success('Purchase order acknowledged successfully')
onOk(result)
}
} catch (error) {
console.error('Error acknowledging purchase order:', error)
} finally {
setAcknowledgeLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to acknowledge this purchase order?'}
description={`Acknowledging purchase order ${objectData?.name || objectData?._reference || objectData?._id} will update its status to acknowledged.`}
onOk={handleAcknowledge}
okText='Acknowledge'
okLoading={acknowledgeLoading}
/>
)
}
AcknowledgePurchaseOrder.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default AcknowledgePurchaseOrder

View File

@ -0,0 +1,46 @@
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 CancelPurchaseOrder = ({ onOk, objectData }) => {
const [cancelLoading, setCancelLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleCancel = async () => {
setCancelLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'PurchaseOrder',
'cancel'
)
if (result) {
message.success('Purchase order cancelled successfully')
onOk(result)
}
} catch (error) {
console.error('Error cancelling purchase order:', error)
} finally {
setCancelLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to cancel this purchase order?'}
description={`Cancelling purchase order ${objectData?.name || objectData?._reference || objectData?._id} will update its status to cancelled.`}
onOk={handleCancel}
okText='Cancel'
okLoading={cancelLoading}
/>
)
}
CancelPurchaseOrder.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default CancelPurchaseOrder

View File

@ -2,15 +2,15 @@ import PropTypes from 'prop-types'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
import { getModelProperty } from '../../../../database/ObjectModels.js'
import ObjectProperty from '../../common/ObjectProperty.jsx'
const NewPurchaseOrder = ({ onOk, reset, defaultValues }) => {
return (
<NewObjectForm
type={'purchaseOrder'}
reset={reset}
defaultValues={{ state: { type: 'new' }, ...defaultValues }}
defaultValues={{
state: { type: 'draft' },
...defaultValues
}}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
@ -26,24 +26,13 @@ const NewPurchaseOrder = ({ onOk, reset, defaultValues }) => {
required={true}
objectData={objectData}
visibleProperties={{
_reference: false,
items: false,
cost: false
}}
/>
)
},
{
title: 'Items',
key: 'items',
content: (
<ObjectProperty
{...getModelProperty('purchaseOrder', 'items')}
isEditing={true}
objectData={objectData}
loading={submitLoading}
/>
)
},
{
title: 'Summary',
key: 'summary',
@ -56,7 +45,15 @@ const NewPurchaseOrder = ({ onOk, reset, defaultValues }) => {
_id: false,
createdAt: false,
updatedAt: false,
items: false
_reference: false,
totalAmount: false,
totalAmountWithTax: false,
totalTaxAmount: false,
postedAt: false,
acknowledgedAt: false,
shippingAmount: false,
shippingAmountWithTax: false,
grandTotalAmount: false
}}
isEditing={false}
objectData={objectData}

View File

@ -0,0 +1,46 @@
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 PostPurchaseOrder = ({ onOk, objectData }) => {
const [postLoading, setPostLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handlePost = async () => {
setPostLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'PurchaseOrder',
'post'
)
if (result) {
message.success('Purchase order posted successfully')
onOk(result)
}
} catch (error) {
console.error('Error posting purchase order:', error)
} finally {
setPostLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to post this purchase order?'}
description={`Posting purchase order ${objectData?.name || objectData?._reference || objectData?._id} will finalize it and update inventory levels where applicable.`}
onOk={handlePost}
okText='Post'
okLoading={postLoading}
/>
)
}
PostPurchaseOrder.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default PostPurchaseOrder

View File

@ -23,6 +23,12 @@ import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
import OrderItemsIcon from '../../../Icons/OrderItemIcon.jsx'
import NewOrderItem from '../OrderItems/NewOrderItem.jsx'
import NewShipment from '../Shipments/NewShipment.jsx'
import PostPurchaseOrder from './PostPurchaseOrder.jsx'
import AcknowledgePurchaseOrder from './AcknowledgePurchaseOrder.jsx'
import CancelPurchaseOrder from './CancelPurchaseOrder.jsx'
import ShipmentIcon from '../../../Icons/ShipmentIcon.jsx'
import { getModelByName } from '../../../../database/ObjectModels.js'
const log = loglevel.getLogger('PurchaseOrderInfo')
log.setLevel(config.logLevel)
@ -30,8 +36,15 @@ log.setLevel(config.logLevel)
const PurchaseOrderInfo = () => {
const location = useLocation()
const objectFormRef = useRef(null)
const orderItemsTableRef = useRef(null)
const shipmentsTableRef = useRef(null)
const actionHandlerRef = useRef(null)
const [newOrderItemOpen, setNewOrderItemOpen] = useState(false)
const [newShipmentOpen, setNewShipmentOpen] = useState(false)
const [postPurchaseOrderOpen, setPostPurchaseOrderOpen] = useState(false)
const [acknowledgePurchaseOrderOpen, setAcknowledgePurchaseOrderOpen] =
useState(false)
const [cancelPurchaseOrderOpen, setCancelPurchaseOrderOpen] = useState(false)
const purchaseOrderId = new URLSearchParams(location.search).get(
'purchaseOrderId'
)
@ -59,14 +72,17 @@ const PurchaseOrderInfo = () => {
return true
},
edit: () => {
orderItemsTableRef?.current?.startEditing?.()
objectFormRef?.current?.startEditing?.()
return false
},
cancelEdit: () => {
orderItemsTableRef?.current?.cancelEditing?.()
objectFormRef?.current?.cancelEditing?.()
return true
},
finishEdit: () => {
orderItemsTableRef?.current?.handleUpdate?.()
objectFormRef?.current?.handleUpdate?.()
return true
},
@ -77,9 +93,29 @@ const PurchaseOrderInfo = () => {
newOrderItem: () => {
setNewOrderItemOpen(true)
return true
},
newShipment: () => {
setNewShipmentOpen(true)
return true
},
post: () => {
setPostPurchaseOrderOpen(true)
return true
},
acknowledge: () => {
setAcknowledgePurchaseOrderOpen(true)
return true
},
cancel: () => {
setCancelPurchaseOrderOpen(true)
return true
}
}
const editDisabled = getModelByName('purchaseOrder')
.actions.find((action) => action.name === 'edit')
.disabled(objectFormState.objectData)
return (
<>
<Flex
@ -103,6 +139,8 @@ const PurchaseOrderInfo = () => {
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Purchase Order Information' },
{ key: 'orderItems', label: 'Order Items' },
{ key: 'shipments', label: 'Shipments' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
@ -131,7 +169,11 @@ const PurchaseOrderInfo = () => {
}}
editLoading={objectFormState.editLoading}
formValid={objectFormState.formValid}
disabled={objectFormState.lock?.locked || objectFormState.loading}
disabled={
objectFormState.lock?.locked ||
objectFormState.loading ||
editDisabled
}
loading={objectFormState.editLoading}
/>
</Space>
@ -169,6 +211,7 @@ const PurchaseOrderInfo = () => {
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='purchaseOrder'
labelWidth='225px'
objectData={objectData}
visibleProperties={{
items: false
@ -178,16 +221,39 @@ const PurchaseOrderInfo = () => {
<InfoCollapse
title='Purchase Order Items'
icon={<OrderItemsIcon />}
active={collapseState.info}
active={collapseState.orderItems}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
updateCollapseState('orderItems', expanded)
}
collapseKey='info'
collapseKey='orderItems'
>
<ObjectTable
type='orderItem'
masterFilter={{ 'order._id': purchaseOrderId }}
masterFilter={{
'order._id': purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
ref={orderItemsTableRef}
/>
</InfoCollapse>
<InfoCollapse
title='Shipments'
icon={<ShipmentIcon />}
active={collapseState.shipments}
onToggle={(expanded) =>
updateCollapseState('shipments', expanded)
}
collapseKey='shipments'
>
<ObjectTable
type='shipment'
masterFilter={{
'order._id': purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
ref={shipmentsTableRef}
/>
</InfoCollapse>
</Flex>
@ -248,6 +314,80 @@ const PurchaseOrderInfo = () => {
}}
/>
</Modal>
<Modal
open={newShipmentOpen}
onCancel={() => {
setNewShipmentOpen(false)
}}
width={800}
footer={null}
destroyOnHidden={true}
>
<NewShipment
onOk={() => {
setNewShipmentOpen(false)
}}
reset={newShipmentOpen}
defaultValues={{
orderType: 'purchaseOrder',
order: { _id: purchaseOrderId }
}}
/>
</Modal>
<Modal
open={postPurchaseOrderOpen}
onCancel={() => {
setPostPurchaseOrderOpen(false)
}}
width={500}
footer={null}
destroyOnHidden={true}
centered={true}
>
<PostPurchaseOrder
onOk={() => {
setPostPurchaseOrderOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={acknowledgePurchaseOrderOpen}
onCancel={() => {
setAcknowledgePurchaseOrderOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<AcknowledgePurchaseOrder
onOk={() => {
setAcknowledgePurchaseOrderOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={cancelPurchaseOrderOpen}
onCancel={() => {
setCancelPurchaseOrderOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<CancelPurchaseOrder
onOk={() => {
setCancelPurchaseOrderOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
</>
)
}

View File

@ -0,0 +1,46 @@
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 CancelShipment = ({ onOk, objectData }) => {
const [cancelLoading, setCancelLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleCancel = async () => {
setCancelLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'Shipment',
'cancel'
)
if (result) {
message.success('Shipment cancelled successfully')
onOk(result)
}
} catch (error) {
console.error('Error cancelling shipment:', error)
} finally {
setCancelLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to cancel this shipment?'}
description={`Cancelling shipment ${objectData?.name || objectData?._reference || objectData?._id} will update its status to cancelled.`}
onOk={handleCancel}
okText='Cancel'
okLoading={cancelLoading}
/>
)
}
CancelShipment.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default CancelShipment

View File

@ -2,15 +2,13 @@ import PropTypes from 'prop-types'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
import { getModelProperty } from '../../../../database/ObjectModels.js'
import ObjectProperty from '../../common/ObjectProperty.jsx'
const NewShipment = ({ onOk, reset, defaultValues }) => {
return (
<NewObjectForm
type={'shipment'}
reset={reset}
defaultValues={{ state: { type: 'pending' }, ...defaultValues }}
defaultValues={{ state: { type: 'draft' }, ...defaultValues }}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
@ -26,25 +24,48 @@ const NewShipment = ({ onOk, reset, defaultValues }) => {
required={true}
objectData={objectData}
visibleProperties={{
items: false,
cost: false,
shippedDate: false,
expectedDeliveryDate: false,
actualDeliveryDate: false,
notes: false
amount: false,
taxRate: false,
taxAmount: false,
amountWithTax: false,
syncAmount: false
}}
/>
)
},
{
title: 'Items',
key: 'items',
title: 'Pricing',
key: 'pricing',
content: (
<ObjectProperty
{...getModelProperty('shipment', 'items')}
<ObjectInfo
type='shipment'
column={1}
bordered={false}
isEditing={true}
objectData={objectData}
loading={submitLoading}
visibleProperties={{
amount: true,
taxRate: true,
taxAmount: true,
amountWithTax: true,
syncAmount: true
}}
/>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<ObjectInfo
type='shipment'
column={1}
bordered={false}
isEditing={true}
objectData={objectData}
visibleProperties={{
trackingNumber: true
}}
/>
)
},
@ -58,9 +79,13 @@ const NewShipment = ({ onOk, reset, defaultValues }) => {
bordered={false}
visibleProperties={{
_id: false,
_reference: false,
createdAt: false,
updatedAt: false,
items: false
shippedAt: false,
expectedAt: false,
deliveredAt: false,
taxRecord: false
}}
isEditing={false}
objectData={objectData}

View File

@ -0,0 +1,362 @@
import { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Flex, Card, Modal } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel'
import config from '../../../../config.js'
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 ViewButton from '../../common/ViewButton.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ObjectForm from '../../common/ObjectForm.jsx'
import EditButtons from '../../common/EditButtons.jsx'
import LockIndicator from '../../common/LockIndicator.jsx'
import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx'
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 OrderItemsIcon from '../../../Icons/OrderItemIcon.jsx'
import NewOrderItem from '../OrderItems/NewOrderItem.jsx'
import NewShipment from './NewShipment.jsx'
import PostPurchaseOrder from '../PurchaseOrders/PostPurchaseOrder.jsx'
import AcknowledgePurchaseOrder from '../PurchaseOrders/AcknowledgePurchaseOrder.jsx'
import ShipmentIcon from '../../../Icons/ShipmentIcon.jsx'
const log = loglevel.getLogger('PurchaseOrderInfo')
log.setLevel(config.logLevel)
const PurchaseOrderInfo = () => {
const location = useLocation()
const objectFormRef = useRef(null)
const orderItemsTableRef = useRef(null)
const shipmentsTableRef = useRef(null)
const actionHandlerRef = useRef(null)
const [newOrderItemOpen, setNewOrderItemOpen] = useState(false)
const [newShipmentOpen, setNewShipmentOpen] = useState(false)
const [postPurchaseOrderOpen, setPostPurchaseOrderOpen] = useState(false)
const [acknowledgePurchaseOrderOpen, setAcknowledgePurchaseOrderOpen] =
useState(false)
const purchaseOrderId = new URLSearchParams(location.search).get(
'purchaseOrderId'
)
const [collapseState, updateCollapseState] = useCollapseState(
'PurchaseOrderInfo',
{
info: true,
notes: true,
auditLogs: true
}
)
const [objectFormState, setEditFormState] = useState({
isEditing: false,
editLoading: false,
formValid: false,
lock: null,
loading: false,
objectData: {}
})
const actions = {
reload: () => {
objectFormRef?.current?.handleFetchObject?.()
return true
},
edit: () => {
orderItemsTableRef?.current?.startEditing?.()
objectFormRef?.current?.startEditing?.()
return false
},
cancelEdit: () => {
orderItemsTableRef?.current?.cancelEditing?.()
objectFormRef?.current?.cancelEditing?.()
return true
},
finishEdit: () => {
orderItemsTableRef?.current?.handleUpdate?.()
objectFormRef?.current?.handleUpdate?.()
return true
},
delete: () => {
objectFormRef?.current?.handleDelete?.()
return true
},
newOrderItem: () => {
setNewOrderItemOpen(true)
return true
},
newShipment: () => {
setNewShipmentOpen(true)
return true
},
post: () => {
setPostPurchaseOrderOpen(true)
return true
},
acknowledge: () => {
setAcknowledgePurchaseOrderOpen(true)
return true
}
}
return (
<>
<Flex
gap='large'
vertical='true'
style={{
maxHeight: '100%',
minHeight: 0
}}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='purchaseOrder'
id={purchaseOrderId}
disabled={objectFormState.loading}
objectData={objectFormState.objectData}
/>
<ViewButton
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Purchase Order Information' },
{ key: 'orderItems', label: 'Order Items' },
{ key: 'shipments', label: 'Shipments' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
<DocumentPrintButton
type='purchaseOrder'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
</Space>
<LockIndicator lock={objectFormState.lock} />
</Space>
<Space>
<EditButtons
isEditing={objectFormState.isEditing}
handleUpdate={() => {
actionHandlerRef.current.callAction('finishEdit')
}}
cancelEditing={() => {
actionHandlerRef.current.callAction('cancelEdit')
}}
startEditing={() => {
actionHandlerRef.current.callAction('edit')
}}
editLoading={objectFormState.editLoading}
formValid={objectFormState.formValid}
disabled={objectFormState.lock?.locked || objectFormState.loading}
loading={objectFormState.editLoading}
/>
</Space>
</Flex>
<ScrollBox>
<Flex vertical gap={'large'}>
<ActionHandler
actions={actions}
loading={objectFormState.loading}
ref={actionHandlerRef}
>
<ObjectForm
id={purchaseOrderId}
type='purchaseOrder'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<Flex vertical gap={'large'}>
<InfoCollapse
title='Purchase Order Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='purchaseOrder'
labelWidth='225px'
objectData={objectData}
visibleProperties={{
items: false
}}
/>
</InfoCollapse>
<InfoCollapse
title='Purchase Order Items'
icon={<OrderItemsIcon />}
active={collapseState.orderItems}
onToggle={(expanded) =>
updateCollapseState('orderItems', expanded)
}
collapseKey='orderItems'
>
<ObjectTable
type='orderItem'
masterFilter={{
'order._id': purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
ref={orderItemsTableRef}
/>
</InfoCollapse>
<InfoCollapse
title='Shipments'
icon={<ShipmentIcon />}
active={collapseState.shipments}
onToggle={(expanded) =>
updateCollapseState('shipments', expanded)
}
collapseKey='shipments'
>
<ObjectTable
type='shipment'
masterFilter={{
'order._id': purchaseOrderId,
orderType: 'purchaseOrder'
}}
visibleColumns={{ order: false }}
ref={shipmentsTableRef}
/>
</InfoCollapse>
</Flex>
)}
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={purchaseOrderId} type='purchaseOrder' />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
collapseKey='auditLogs'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': purchaseOrderId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</ScrollBox>
</Flex>
<Modal
open={newOrderItemOpen}
onCancel={() => {
setNewOrderItemOpen(false)
}}
width={800}
footer={null}
destroyOnHidden={true}
>
<NewOrderItem
onOk={() => {
setNewOrderItemOpen(false)
}}
reset={newOrderItemOpen}
defaultValues={{
order: { _id: purchaseOrderId },
orderType: 'purchaseOrder',
syncAmount: 'itemCost'
}}
/>
</Modal>
<Modal
open={newShipmentOpen}
onCancel={() => {
setNewShipmentOpen(false)
}}
width={800}
footer={null}
destroyOnHidden={true}
>
<NewShipment
onOk={() => {
setNewShipmentOpen(false)
}}
reset={newShipmentOpen}
defaultValues={{
orderType: 'purchaseOrder',
order: { _id: purchaseOrderId }
}}
/>
</Modal>
<Modal
open={postPurchaseOrderOpen}
onCancel={() => {
setPostPurchaseOrderOpen(false)
}}
width={500}
footer={null}
destroyOnHidden={true}
centered={true}
>
<PostPurchaseOrder
onOk={() => {
setPostPurchaseOrderOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={acknowledgePurchaseOrderOpen}
onCancel={() => {
setAcknowledgePurchaseOrderOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<AcknowledgePurchaseOrder
onOk={() => {
setAcknowledgePurchaseOrderOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
</>
)
}
export default PurchaseOrderInfo

View File

@ -0,0 +1,46 @@
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 ReceiveShipment = ({ onOk, objectData }) => {
const [receiveLoading, setReceiveLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleReceive = async () => {
setReceiveLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'Shipment',
'receive'
)
if (result) {
message.success('Shipment received successfully')
onOk(result)
}
} catch (error) {
console.error('Error receiving shipment:', error)
} finally {
setReceiveLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to receive this shipment?'}
description={`Receiving shipment ${objectData?.name || objectData?._reference || objectData?._id} will update its status to delivered.`}
onOk={handleReceive}
okText='Receive'
okLoading={receiveLoading}
/>
)
}
ReceiveShipment.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default ReceiveShipment

View File

@ -0,0 +1,46 @@
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 ShipShipment = ({ onOk, objectData }) => {
const [shipLoading, setShipLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleShip = async () => {
setShipLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'Shipment',
'ship'
)
if (result) {
message.success('Shipment shipped successfully')
onOk(result)
}
} catch (error) {
console.error('Error shipping shipment:', error)
} finally {
setShipLoading(false)
}
}
return (
<MessageDialogView
title={'Are you sure you want to ship this shipment?'}
description={`Shipping shipment ${objectData?.name || objectData?._reference || objectData?._id} will update its status to shipped.`}
onOk={handleShip}
okText='Ship'
okLoading={shipLoading}
/>
)
}
ShipShipment.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default ShipShipment

View File

@ -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'
@ -21,9 +21,10 @@ 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 { getModelProperty } from '../../../../database/ObjectModels.js'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import OrderItemsIcon from '../../../Icons/OrderItemIcon.jsx'
import OrderItemIcon from '../../../Icons/OrderItemIcon.jsx'
import ShipShipment from './ShipShipment.jsx'
import ReceiveShipment from './ReceiveShipment.jsx'
import CancelShipment from './CancelShipment.jsx'
const log = loglevel.getLogger('ShipmentInfo')
log.setLevel(config.logLevel)
@ -51,6 +52,9 @@ const ShipmentInfo = () => {
objectData: {}
})
const [shipShipmentOpen, setShipShipmentOpen] = useState(false)
const [receiveShipmentOpen, setReceiveShipmentOpen] = useState(false)
const [cancelShipmentOpen, setCancelShipmentOpen] = useState(false)
const actions = {
reload: () => {
objectFormRef?.current?.handleFetchObject?.()
@ -71,6 +75,18 @@ const ShipmentInfo = () => {
delete: () => {
objectFormRef?.current?.handleDelete?.()
return true
},
ship: () => {
setShipShipmentOpen(true)
return true
},
receive: () => {
setReceiveShipmentOpen(true)
return true
},
cancel: () => {
setCancelShipmentOpen(true)
return true
}
}
@ -167,22 +183,26 @@ const ShipmentInfo = () => {
visibleProperties={{
items: false
}}
labelWidth='175px'
/>
</InfoCollapse>
<InfoCollapse
title='Shipment Items'
icon={<OrderItemsIcon />}
active={collapseState.info}
title='Shipment Order Items'
icon={<OrderItemIcon />}
active={collapseState.orderItems}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
updateCollapseState('orderItems', expanded)
}
collapseKey='info'
collapseKey='orderItems'
>
<ObjectProperty
{...getModelProperty('shipment', 'items')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
<ObjectTable
type='orderItem'
masterFilter={{ 'shipment._id': shipmentId }}
visibleColumns={{
shipment: false,
order: false,
orderType: false
}}
/>
</InfoCollapse>
</Flex>
@ -222,6 +242,60 @@ const ShipmentInfo = () => {
</Flex>
</ScrollBox>
</Flex>
<Modal
open={shipShipmentOpen}
onCancel={() => {
setShipShipmentOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<ShipShipment
onOk={() => {
setShipShipmentOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={receiveShipmentOpen}
onCancel={() => {
setReceiveShipmentOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<ReceiveShipment
onOk={() => {
setReceiveShipmentOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
<Modal
open={cancelShipmentOpen}
onCancel={() => {
setCancelShipmentOpen(false)
}}
width={515}
footer={null}
destroyOnHidden={true}
centered={true}
>
<CancelShipment
onOk={() => {
setCancelShipmentOpen(false)
actions.reload()
}}
objectData={objectFormState.objectData}
/>
</Modal>
</>
)
}

View File

@ -4,6 +4,7 @@ import { Layout, Flex } from 'antd'
import { useLocation } from 'react-router-dom'
import ProductionSidebar from './Production/ProductionSidebar'
import InventorySidebar from './Inventory/InventorySidebar'
import FinanceSidebar from './Finance/FinanceSidebar'
import ManagementSidebar from './Management/ManagementSidebar'
import DashboardNavigation from './common/DashboardNavigation'
import DashboardBreadcrumb from './common/DashboardBreadcrumb'
@ -17,6 +18,7 @@ const DashboardLayout = ({ children }) => {
const location = useLocation()
const isProduction = location.pathname.startsWith('/dashboard/production')
const isInventory = location.pathname.startsWith('/dashboard/inventory')
const isFinance = location.pathname.startsWith('/dashboard/finance')
const isManagement = location.pathname.startsWith('/dashboard/management')
const isDeveloper = location.pathname.startsWith('/dashboard/developer')
@ -34,6 +36,8 @@ const DashboardLayout = ({ children }) => {
<ProductionSidebar />
) : isInventory ? (
<InventorySidebar />
) : isFinance ? (
<FinanceSidebar />
) : isManagement ? (
<ManagementSidebar />
) : isDeveloper ? (

View File

@ -10,6 +10,7 @@ const breadcrumbNameMap = {
inventory: 'Inventory',
management: 'Management',
developer: 'Developer',
finance: 'Finance',
overview: 'Overview',
info: 'Info',
design: 'Design',

View File

@ -30,6 +30,7 @@ import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
import MenuIcon from '../../Icons/MenuIcon'
import ProductionIcon from '../../Icons/ProductionIcon'
import InventoryIcon from '../../Icons/InventoryIcon'
import FinanceIcon from '../../Icons/FinanceIcon'
import PersonIcon from '../../Icons/PersonIcon'
import CloudIcon from '../../Icons/CloudIcon'
import BellIcon from '../../Icons/BellIcon'
@ -69,6 +70,11 @@ const DashboardNavigation = () => {
label: 'Inventory',
icon: <InventoryIcon />
},
{
key: 'finance',
label: 'Finance',
icon: <FinanceIcon />
},
{
key: 'management',
label: 'Management',
@ -130,6 +136,8 @@ const DashboardNavigation = () => {
navigate('/dashboard/production/overview')
} else if (key === 'inventory') {
navigate('/dashboard/inventory/overview')
} else if (key === 'finance') {
navigate('/dashboard/finance/overview')
} else if (key === 'management') {
navigate('/dashboard/management/filaments')
}

View File

@ -253,10 +253,15 @@ const HistoryDisplay = ({
// Create color mapping from model stats
const colors = {
success: themeColors.colorSuccess,
info: themeColors.colorInfo,
processing: themeColors.colorInfo,
error: themeColors.colorError,
warning: themeColors.colorWarning,
default: '#8c8c8c'
default: '#8c8c8c',
cyan: themeColors.colorCyan,
pink: themeColors.colorPink,
purple: themeColors.colorPurple,
magenta: themeColors.colorMagenta,
volcano: themeColors.colorVolcano
}
// Build color range array based on model stats order

View File

@ -0,0 +1,40 @@
import PropTypes from 'prop-types'
import { Flex, Typography, Button } from 'antd'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
const { Text } = Typography
const MessageDialogView = ({
icon,
title,
description,
onOk,
okText,
okLoading
}) => {
return (
<Flex vertical gap={'middle'}>
<Flex gap={'middle'}>
{icon || <InfoCircleIcon />}
<Text strong>{title}</Text>
</Flex>
{description && <Text>{description}</Text>}
<Flex justify={'end'}>
<Button type='primary' onClick={onOk} loading={okLoading}>
{okText || 'OK'}
</Button>
</Flex>
</Flex>
)
}
MessageDialogView.propTypes = {
icon: PropTypes.node,
title: PropTypes.node.isRequired,
description: PropTypes.node,
onOk: PropTypes.func.isRequired,
okText: PropTypes.string,
okLoading: PropTypes.bool
}
export default MessageDialogView

View File

@ -31,7 +31,10 @@ const buildObjectFromEntries = (entries = []) => {
* }) => ReactNode
*/
const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const [objectData, setObjectData] = useState(defaultValues)
const [objectData, setObjectData] = useState({
...defaultValues,
_isEditing: true
})
const [submitLoading, setSubmitLoading] = useState(false)
const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm()

View File

@ -16,6 +16,7 @@ import DeleteObjectModal from './DeleteObjectModal'
import merge from 'lodash/merge'
import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels'
import { useNavigate } from 'react-router-dom'
const buildObjectFromEntries = (entries = []) => {
return entries.reduce((acc, entry) => {
@ -72,7 +73,7 @@ const ObjectForm = forwardRef(
flushFile
} = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const navigate = useNavigate()
// Get the model definition for this object type
const model = getModelByName(type)
@ -462,6 +463,7 @@ const ObjectForm = forwardRef(
await deleteObject(id, type)
setDeleteModalOpen(false)
showSuccess('Deleted successfully')
navigate(-2)
// Optionally: trigger a callback to parent to remove this object from view
} catch (err) {
console.error(err)

View File

@ -87,6 +87,7 @@ const ObjectProperty = ({
showPreview = true,
options = [],
roundNumber = false,
fixedNumber = false,
showHyperlink,
showSince,
properties = [],
@ -272,6 +273,9 @@ const ObjectProperty = ({
if (roundNumber != false && typeof value === 'number') {
roundedValue = round(value, roundNumber)
}
if (fixedNumber != false && typeof value === 'number') {
roundedValue = value.toFixed(fixedNumber)
}
return (
<Text {...textParams}>

View File

@ -47,6 +47,8 @@ const ObjectSelect = ({
const [objectList, setObjectList] = useState([])
const [treeSelectValue, setTreeSelectValue] = useState(null)
const [initialLoading, setInitialLoading] = useState(true)
const [expandedKeys, setExpandedKeys] = useState([])
const [treeVersion, setTreeVersion] = useState(0)
const valueRef = useRef(null)
// Refs to track value changes
@ -211,6 +213,7 @@ const ObjectSelect = ({
objectType={type}
objectData={object}
isEditing={false}
showHyperlink={false}
style={{ top: '-0.5px' }}
/>
</div>
@ -246,6 +249,15 @@ const ObjectSelect = ({
value: valueString
})
var nodeChildren = buildTreeData(
children,
pIdx + 1,
parentKeys.concat(valueString),
newFilterPath
)
if (nodeChildren.length == 0) {
nodeChildren = undefined
}
const modelProperty = getModelProperty(type, property)
return {
title: <ObjectProperty {...modelProperty} value={value} />,
@ -257,12 +269,7 @@ const ObjectSelect = ({
filterPath: newFilterPath,
selectable: false,
isLeaf: false,
children: buildTreeData(
children,
pIdx + 1,
parentKeys.concat(valueString),
newFilterPath
)
children: nodeChildren
}
})
.filter(Boolean)
@ -272,6 +279,7 @@ const ObjectSelect = ({
// --- loadData for async loading on expand ---
const loadData = async (node) => {
console.log('loading data for node', node)
// node.property is the property name, node.value is the value key
if (!node.property) return
if (type == 'unknown') return
@ -384,24 +392,39 @@ const ObjectSelect = ({
// console.log('fullValue', fullValue)
// Build a new filter from value's properties that are in the properties list
const valueFilter = { ...filter }
const pathKeys = []
const parentKeys = []
properties.forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(fullValue, prop)) {
const filterValue = fullValue[prop]
let valueString = filterValue
if (
filterValue &&
typeof filterValue === 'object' &&
filterValue._id
) {
valueFilter[prop] = filterValue._id
valueString = filterValue._id
} else if (filterValue?.name) {
valueFilter[prop] = filterValue.name
valueString = filterValue.name
} else if (Array.isArray(filterValue)) {
valueFilter[prop] = filterValue.join(',')
valueString = filterValue.join(',')
} else {
valueFilter[prop] = filterValue
valueString = filterValue
}
// Build the path key for this property level
const nodeKey = parentKeys
.concat(prop + ':' + valueString)
.join('-')
pathKeys.push(nodeKey)
parentKeys.push(valueString)
}
})
// Expand the path to the object
setExpandedKeys(pathKeys)
// Fetch with the new filter
handleFetchObjectsProperties(valueFilter)
// console.log('setting treeSelectValue', valueRef.current._id)
@ -462,6 +485,8 @@ const ObjectSelect = ({
setObjectPropertiesTree({})
setObjectList([])
setTreeData([])
setTreeVersion((v) => v + 1)
setExpandedKeys([])
setInitialized(false)
onTreeSelectChange(null)
setTreeSelectValue(null)
@ -529,8 +554,11 @@ const ObjectSelect = ({
// --- Main TreeSelect UI ---
return (
<TreeSelect
key={treeVersion}
treeDataSimpleMode={false}
treeDefaultExpandAll={true}
treeExpandedKeys={expandedKeys}
onTreeExpand={setExpandedKeys}
treeData={treeData}
showSearch={showSearch}
multiple={multiple}

View File

@ -5,6 +5,7 @@ import {
useEffect,
useState,
useCallback,
useMemo,
createElement
} from 'react'
import {
@ -19,7 +20,8 @@ import {
Button,
Input,
Space,
Tooltip
Tooltip,
Form
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
@ -43,6 +45,46 @@ import { ElectronContext } from '../context/ElectronContext'
const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
const RowForm = ({ record, isEditing, onRegister, children }) => {
const [form] = Form.useForm()
useEffect(() => {
if (isEditing && record && !record.isSkeleton) {
form.setFieldsValue(record)
onRegister(record._id, form)
}
return () => {
if (record?._id) onRegister(record._id, null)
}
}, [isEditing, record, form, onRegister])
return (
<Form form={form} component={false}>
{children}
</Form>
)
}
RowForm.propTypes = {
record: PropTypes.object,
isEditing: PropTypes.bool,
onRegister: PropTypes.func,
children: PropTypes.node
}
const EditableRow = ({ record, isEditing, onRegister, ...props }) => {
return (
<RowForm record={record} isEditing={isEditing} onRegister={onRegister}>
<tr {...props} />
</RowForm>
)
}
EditableRow.propTypes = {
record: PropTypes.object,
isEditing: PropTypes.bool,
onRegister: PropTypes.func
}
const ObjectTable = forwardRef(
(
{
@ -54,17 +96,26 @@ const ObjectTable = forwardRef(
cards = false,
visibleColumns = {},
masterFilter = {},
size = 'middle'
size = 'middle',
onStateChange
},
ref
) => {
const { token } = useContext(AuthContext)
const { isElectron } = useContext(ElectronContext)
const onStateChangeRef = useRef(onStateChange)
useEffect(() => {
onStateChangeRef.current = onStateChange
}, [onStateChange])
const {
fetchObjects,
connected,
subscribeToObjectUpdates,
subscribeToObjectTypeUpdates
subscribeToObjectTypeUpdates,
updateMultipleObjects,
lockObject,
unlockObject
} = useContext(ApiServerContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate()
@ -98,6 +149,21 @@ const ObjectTable = forwardRef(
const [lazyLoading, setLazyLoading] = useState(false)
const [tableData, setTableData] = useState([])
const [isEditing, setIsEditing] = useState(false)
const [editLoading, setEditLoading] = useState(false)
const rowFormsRef = useRef({})
const registerForm = useCallback((id, form) => {
if (form) {
rowFormsRef.current[id] = form
} else {
delete rowFormsRef.current[id]
}
}, [])
useEffect(() => {
onStateChangeRef.current?.({ isEditing, editLoading })
}, [isEditing, editLoading])
const subscribedIdsRef = useRef([])
// const [typeSubscribed, setTypeSubscribed] = useState(false)
const unsubscribesRef = useRef([])
@ -300,6 +366,49 @@ const ObjectTable = forwardRef(
}
}, [fetchData])
const startEditing = useCallback(() => {
setIsEditing(true)
tableData.forEach((item) => {
if (!item.isSkeleton) {
console.log('Locking object:', item)
lockObject(item._id, type)
}
})
}, [tableData, lockObject, type])
const cancelEditing = useCallback(() => {
setIsEditing(false)
tableData.forEach((item) => {
if (!item.isSkeleton) {
unlockObject(item._id, type)
}
})
}, [tableData, unlockObject, type])
const handleUpdate = useCallback(async () => {
setEditLoading(true)
try {
const updates = await Promise.all(
Object.entries(rowFormsRef.current).map(async ([id, form]) => {
const values = await form.validateFields()
return { _id: id, ...values }
})
)
await updateMultipleObjects(type, updates)
setIsEditing(false)
reload()
tableData.forEach((item) => {
if (!item.isSkeleton) {
unlockObject(item._id, type)
}
})
} catch (err) {
console.error(err)
} finally {
setEditLoading(false)
}
}, [type, updateMultipleObjects, reload, tableData, unlockObject])
// Update event handler for real-time updates
const updateEventHandler = useCallback((id, updatedData) => {
setPages((prevPages) =>
@ -316,6 +425,12 @@ const ObjectTable = forwardRef(
}
})
)
console.log('updatedData', updatedData)
if (rowFormsRef.current[id]) {
rowFormsRef.current[id].setFieldsValue(updatedData)
}
}, [])
// Store the latest updateEventHandler in a ref
@ -349,6 +464,9 @@ const ObjectTable = forwardRef(
updateEventHandlerRef.current(itemId, updateData)
}
)
console.log('unsubscribe', unsubscribe)
console.log('subscribedIdsRef', subscribedIdsRef.current)
console.log('unsubscribesRef', unsubscribesRef.current)
subscribedIdsRef.current.push(itemId)
if (unsubscribe) {
unsubscribesRef.current.push(unsubscribe)
@ -408,8 +526,8 @@ const ObjectTable = forwardRef(
}, [type, subscribeToObjectTypeUpdates, connected, newEventHandler])
const updateData = useCallback(
(_id, updatedData) => {
updateEventHandler({ _id, ...updatedData })
(id, updatedData) => {
updateEventHandler(id, updatedData)
},
[updateEventHandler]
)
@ -445,7 +563,12 @@ const ObjectTable = forwardRef(
setData: (newData) => {
setPages([{ pageNum: 1, items: newData }])
},
updateData
updateData,
startEditing,
cancelEditing,
handleUpdate,
isEditing,
editLoading
}))
useEffect(() => {
@ -614,7 +737,7 @@ const ObjectTable = forwardRef(
{...prop}
longId={false}
objectData={record}
isEditing={false}
isEditing={isEditing}
/>
)
}
@ -715,61 +838,71 @@ const ObjectTable = forwardRef(
loading={record.isSkeleton}
variant={'borderless'}
>
<Flex align={'center'} vertical gap={'middle'}>
<Descriptions
column={1}
size='small'
bordered={true}
style={{ width: '100%', tableLayout: 'fixed' }}
className='objectTableDescritions'
>
{(() => {
const descriptionItems = []
<RowForm
record={record}
isEditing={isEditing}
onRegister={registerForm}
>
<Flex align={'center'} vertical gap={'middle'}>
<Descriptions
column={1}
size='small'
bordered={true}
style={{ width: '100%', tableLayout: 'fixed' }}
className='objectTableDescritions'
>
{(() => {
const descriptionItems = []
// Add columns in the order specified by model.columns (same logic as table)
model.columns.forEach((colName) => {
const prop = modelProperties.find(
(p) => p.name === colName
)
if (prop) {
// Check if column should be visible based on visibleColumns prop
if (
Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false
) {
return // Skip this column if it's not visible
// Add columns in the order specified by model.columns (same logic as table)
model.columns.forEach((colName) => {
const prop = modelProperties.find(
(p) => p.name === colName
)
if (prop) {
// Check if column should be visible based on visibleColumns prop
if (
Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false
) {
return // Skip this column if it's not visible
}
descriptionItems.push(
<Descriptions.Item
label={prop.label}
key={prop.name}
colspan={2}
>
<ObjectProperty
{...prop}
longId={false}
objectData={record}
isEditing={isEditing}
name={prop.name}
/>
</Descriptions.Item>
)
}
})
// Add actions if they exist (same as table)
if (rowActions.length > 0) {
descriptionItems.push(
<Descriptions.Item
label={prop.label}
key={prop.name}
colspan={2}
label={'Actions'}
key={'actions'}
>
<ObjectProperty
{...prop}
longId={false}
objectData={record}
isEditing={false}
/>
{renderActions(record)}
</Descriptions.Item>
)
}
})
// Add actions if they exist (same as table)
if (rowActions.length > 0) {
descriptionItems.push(
<Descriptions.Item label={'Actions'} key={'actions'}>
{renderActions(record)}
</Descriptions.Item>
)
}
return descriptionItems
})()}
</Descriptions>
</Flex>
return descriptionItems
})()}
</Descriptions>
</Flex>
</RowForm>
</Card>
</Col>
))}
@ -777,32 +910,52 @@ const ObjectTable = forwardRef(
)
}
return (
<>
<Flex gap={'middle'} vertical>
<Table
ref={tableRef}
dataSource={tableData}
columns={columnsWithSkeleton}
className={cards ? 'dashboard-cards-header' : 'dashboard-table'}
pagination={false}
scroll={{ y: adjustedScrollHeight }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
style={{ height: '100%' }}
size={size}
/>
{cards ? (
<Spin indicator={<LoadingOutlined />} spinning={loading}>
{renderCards()}
</Spin>
) : null}
</Flex>
</>
const components = useMemo(
() => ({
body: {
row: EditableRow
}
}),
[]
)
const onRow = useCallback(
(record) => ({
record,
isEditing,
onRegister: registerForm
}),
[isEditing, registerForm]
)
const tableContent = (
<Flex gap={'middle'} vertical>
<Table
ref={tableRef}
dataSource={tableData}
columns={columnsWithSkeleton}
className={cards ? 'dashboard-cards-header' : 'dashboard-table'}
pagination={false}
scroll={{ y: adjustedScrollHeight }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
style={{ height: '100%' }}
size={size}
components={components}
onRow={onRow}
/>
{cards ? (
<Spin indicator={<LoadingOutlined />} spinning={loading}>
{renderCards()}
</Spin>
) : null}
</Flex>
)
return tableContent
}
)
@ -818,7 +971,8 @@ ObjectTable.propTypes = {
cardRenderer: PropTypes.func,
visibleColumns: PropTypes.object,
masterFilter: PropTypes.object,
size: PropTypes.string
size: PropTypes.string,
onStateChange: PropTypes.func
}
export default ObjectTable

View File

@ -96,6 +96,46 @@ const StateTag = ({ state, showBadge = true, style = {} }) => {
status = 'success'
text = 'Unconsumed'
break
case 'sent':
status = 'cyan'
text = 'Sent'
break
case 'acknowledged':
status = 'processing'
text = 'Acknowledged'
break
case 'ordered':
status = 'cyan'
text = 'Ordered'
break
case 'received':
status = 'success'
text = 'Received'
break
case 'invoiced':
status = 'warning'
text = 'Invoiced'
break
case 'planned':
status = 'warning'
text = 'Planned'
break
case 'partiallyShipped':
status = 'processing'
text = 'Partially Shipped'
break
case 'shipped':
status = 'processing'
text = 'Shipped'
break
case 'delivered':
status = 'success'
text = 'Delivered'
break
case 'paid':
status = 'success'
text = 'Paid'
break
default:
status = 'default'
text = state || 'Unknown'
@ -104,10 +144,22 @@ const StateTag = ({ state, showBadge = true, style = {} }) => {
return { badgeStatus: status, badgeText: text }
}, [state])
var badgeProps = {
status: badgeStatus
}
if (
!['success', 'warning', 'error', 'processing', 'default'].includes(
badgeStatus
)
) {
badgeProps = { color: badgeStatus }
}
return (
<Tag color={badgeStatus} style={{ marginRight: 0, ...style }}>
<Flex gap={6}>
{showBadge && <Badge status={badgeStatus} />}
{showBadge && <Badge {...badgeProps} />}
{badgeText}
</Flex>
</Tag>

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useContext, useRef } from 'react'
import { Flex, Alert, Card, Typography, Skeleton } from 'antd'
import { Flex, Tag, Card, Typography, Skeleton, Badge } from 'antd'
import PropTypes from 'prop-types'
import { getModelByName } from '../../../database/ObjectModels'
import { ApiServerContext } from '../context/ApiServerContext'
@ -9,9 +9,9 @@ import { round } from '../utils/Utils'
const { Text } = Typography
/**
* Maps stat names to Alert types for visual indication
* Maps stat names to Tag colors for visual indication
*/
const getAlertType = (statName) => {
const getTagColor = (statName) => {
const name = statName.toLowerCase()
// Success states
@ -43,14 +43,14 @@ const getAlertType = (statName) => {
}
if (name.includes('printing')) {
return 'info'
return 'processing'
}
// Default states
return 'default'
}
/**
/*i*
* Gets a nested value from an object using dot notation
* e.g., getNestedValue(obj, 'states.ready') -> obj.states.ready
*/
@ -255,18 +255,41 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
{modelStats.map((statDef) => {
const baseStatName = extractBaseStatName(statDef.name)
var statValue = getStatValue(stats, baseStatName, statDef)
const alertType = getAlertType(statDef.name)
const tagColor = statDef.color || getTagColor(statDef.name)
const label = statDef.label || statDef.name
if (statDef?.roundNumber) {
statValue = round(statValue, statDef?.roundNumber)
}
const statusColors = [
'success',
'warning',
'error',
'processing',
'default'
]
var badgeProps = {
status: tagColor
}
if (!statusColors.includes(tagColor)) {
badgeProps = { color: tagColor }
}
const content = (
<Flex vertical>
<Text type='secondary'>{label}</Text>
<Flex gap={'small'}>
{Icon && <Icon style={{ fontSize: 26 }} />}
<Flex vertical gap='3px'>
<Flex gap={'12px'} align='center'>
<Badge {...badgeProps} />
<Text>{label}</Text>
</Flex>
<Flex gap={'12px'} align='center'>
{Icon && (
<Text>
<Icon style={{ fontSize: 26 }} />
</Text>
)}
{loading ? (
<Flex justify='center' align='center' style={{ height: 44 }}>
<Skeleton.Button
@ -286,12 +309,12 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
</Flex>
)
if (alertType === 'default') {
if (tagColor === 'default') {
return (
<Card
key={statDef.name}
style={{ minWidth: statDef?.cardWidth || 175 }}
styles={{ body: { padding: '20px 24px' } }}
styles={{ body: { padding: '16px 24px' } }}
>
{content}
</Card>
@ -299,12 +322,17 @@ const StatsDisplay = ({ objectType, stats: statsProp }) => {
}
return (
<Alert
<Tag
key={statDef.name}
type={alertType}
style={{ minWidth: statDef?.cardWidth || 175 }}
description={content}
/>
color={tagColor}
style={{
minWidth: statDef?.cardWidth || 175,
padding: '16px 24px',
margin: 0
}}
>
{content}
</Tag>
)
})}
</Flex>

View File

@ -797,6 +797,28 @@ const ApiServerProvider = ({ children }) => {
}
}
// Update multiple objects
const updateMultipleObjects = async (type, objects) => {
const updateUrl = `${config.backendUrl}/${type.toLowerCase()}s`
logger.debug('Updating multiple objects for ' + type)
try {
const response = await axios.put(updateUrl, objects, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
})
logger.debug('Objects updated successfully')
return response.data
} catch (err) {
console.error(err)
showError(err, () => {
updateMultipleObjects(type, objects)
})
return []
}
}
// Update filament information
const deleteObject = async (id, type) => {
const deleteUrl = `${config.backendUrl}/${type.toLowerCase()}s/${id}`
@ -840,6 +862,27 @@ const ApiServerProvider = ({ children }) => {
}
}
// Call a function on an object
const sendObjectFunction = async (id, type, functionName, value = {}) => {
const url = `${config.backendUrl}/${type.toLowerCase()}s/${id}/${functionName}`
logger.debug(`Calling object function ${functionName} for ${id} at ${url}`)
try {
const response = await axios.post(url, value, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
})
return response.data
} catch (err) {
console.error(err)
showError(err, () => {
sendObjectFunction(id, type, functionName, value)
})
return {}
}
}
// Download GCode file content
const fetchFileContent = async (file, download = false) => {
try {
@ -1221,7 +1264,9 @@ const ApiServerProvider = ({ children }) => {
unlockObject,
fetchObjectLock,
updateObject,
updateMultipleObjects,
createObject,
sendObjectFunction,
deleteObject,
subscribeToObjectUpdates,
subscribeToObjectEvent,

View File

@ -88,7 +88,12 @@ export const ThemeProvider = ({ children }) => {
colorWarning: '#FF9230',
colorError: '#FF4245',
colorInfo: '#0A84FF',
colorLink: '#5AC8F5'
colorLink: '#5AC8F5',
colorCyan: '#5AC8F5',
colorPink: '#FF69B4',
colorPurple: '#800080',
colorMagenta: '#FF00FF',
colorVolcano: '#FF4500'
}
const getColors = () => {

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/financeicon.svg?react'
const FinanceIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default FinanceIcon

View File

@ -0,0 +1,9 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/invoiceicon.svg?react'
const InvoiceIcon = (props) => (
<Icon component={CustomIconSvg} {...props} />
)
export default InvoiceIcon

View File

@ -30,6 +30,7 @@ import { DocumentPrinter } from './models/DocumentPrinter.js'
import { DocumentJob } from './models/DocumentJob.js'
import { TaxRate } from './models/TaxRate.js'
import { TaxRecord } from './models/TaxRecord.js'
import { Invoice } from './models/Invoice.js'
import QuestionCircleIcon from '../components/Icons/QuestionCircleIcon'
export const objectModels = [
@ -64,7 +65,8 @@ export const objectModels = [
DocumentPrinter,
DocumentJob,
TaxRate,
TaxRecord
TaxRecord,
Invoice
]
// Re-export individual models for direct access
@ -100,7 +102,8 @@ export {
DocumentPrinter,
DocumentJob,
TaxRate,
TaxRecord
TaxRecord,
Invoice
}
export function getModelByName(name, ignoreCase = false) {

View File

@ -0,0 +1,336 @@
import InvoiceIcon from '../../components/Icons/InvoiceIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import EditIcon from '../../components/Icons/EditIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
import BinIcon from '../../components/Icons/BinIcon'
export const Invoice = {
name: 'invoice',
label: 'Invoice',
prefix: 'INV',
icon: InvoiceIcon,
actions: [
{
name: 'info',
label: 'Info',
default: true,
row: true,
icon: InfoCircleIcon,
url: (_id) => `/dashboard/finance/invoices/info?invoiceId=${_id}`
},
{
name: 'edit',
label: 'Edit',
type: 'button',
icon: EditIcon,
url: (_id) =>
`/dashboard/finance/invoices/info?invoiceId=${_id}&action=edit`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'cancelEdit',
label: 'Cancel Edit',
type: 'button',
icon: XMarkIcon,
url: (_id) =>
`/dashboard/finance/invoices/info?invoiceId=${_id}&action=cancelEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'finishEdit',
label: 'Finish Edit',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/finance/invoices/info?invoiceId=${_id}&action=finishEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'delete',
label: 'Delete',
type: 'button',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/finance/invoices/info?invoiceId=${_id}&action=delete`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{ type: 'divider' },
{
name: 'send',
label: 'Send',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/finance/invoices/info?invoiceId=${_id}&action=send`,
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',
type: 'button',
icon: XMarkIcon,
danger: true,
url: (_id) =>
`/dashboard/finance/invoices/info?invoiceId=${_id}&action=cancel`,
disabled: (objectData) => {
return (
objectData?.state?.type == 'cancelled' ||
objectData?.state?.type == 'paid'
)
},
visible: (objectData) => {
return (
objectData?.state?.type == 'draft' ||
objectData?.state?.type == 'sent'
)
}
}
],
group: ['vendor', 'customer', 'invoiceType'],
filters: ['vendor', 'customer', 'invoiceType'],
sorters: ['createdAt', 'state', 'updatedAt', 'invoiceDate', 'dueDate'],
columns: [
'_id',
'_reference',
'state',
'invoiceType',
'vendor',
'customer',
'invoiceDate',
'dueDate',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount',
'createdAt',
'updatedAt'
],
properties: [
{
name: '_id',
label: 'ID',
type: 'id',
columnFixed: 'left',
objectType: 'invoice',
columnWidth: 140,
showCopy: true
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true
},
{
name: '_reference',
label: 'Reference',
type: 'reference',
required: true,
objectType: 'invoice',
showCopy: true,
readOnly: true
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true
},
{ 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: 'vendor',
label: 'Vendor',
required: true,
type: 'object',
objectType: 'vendor',
showHyperlink: true,
visible: (objectData) => {
return objectData?.invoiceType === 'purchase' || objectData?.vendor
}
},
{
name: 'orderType',
label: 'Order Type',
type: 'objectType',
masterFilter: ['purchaseOrder', 'salesOrder'],
required: true
},
{
name: 'order',
label: 'Order',
type: 'object',
objectType: (objectData) => {
return objectData?.orderType
},
masterFilter: (objectData) => {
return {
vendor: objectData?.vendor?._id
}
},
required: true,
showHyperlink: true
},
{
name: 'sentAt',
label: 'Sent At',
type: 'dateTime',
readOnly: true
},
{
name: 'paidAt',
label: 'Paid At',
type: 'dateTime',
readOnly: true
},
{
name: 'cancelledAt',
label: 'Cancelled At',
type: 'dateTime',
readOnly: true
},
{
name: 'overdueAt',
label: 'Overdue At',
type: 'dateTime',
readOnly: true
},
{
name: 'totalTaxAmount',
label: 'Total Tax Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
readOnly: true,
columnWidth: 175
},
{
name: 'totalAmountWithTax',
label: 'Total Amount w/ Tax',
type: 'number',
prefix: '£',
readOnly: true,
columnWidth: 175,
roundNumber: 2
},
{
name: 'shippingAmount',
label: 'Shipping Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
readOnly: true,
columnWidth: 150
},
{
name: 'shippingAmountWithTax',
label: 'Shipping Amount w/ Tax',
type: 'number',
prefix: '£',
readOnly: true,
roundNumber: 2,
columnWidth: 200
},
{
name: 'totalAmount',
label: 'Total Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
readOnly: true,
columnWidth: 150
},
{
name: 'grandTotalAmount',
label: 'Grand Total Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
columnWidth: 175,
readOnly: true
}
],
stats: [
{
name: 'draft.count',
label: 'Draft',
type: 'number',
color: 'default'
},
{
name: 'sent.count',
label: 'Sent',
type: 'number',
color: 'cyan'
},
{
name: 'partiallyPaid.count',
label: 'Partially Paid',
type: 'number',
color: 'processing'
},
{
name: 'paid.count',
label: 'Paid',
type: 'number',
color: 'success'
},
{
name: 'overdue.count',
label: 'Overdue',
type: 'number',
color: 'error'
},
{
name: 'cancelled.count',
label: 'Cancelled',
type: 'number',
color: 'default'
}
]
}

View File

@ -155,7 +155,7 @@ export const Job = {
name: 'printing.count',
label: 'Printing',
type: 'number',
color: 'info'
color: 'processing'
},
{
name: 'failed.count',

View File

@ -1,5 +1,9 @@
import OrderItemIcon from '../../components/Icons/OrderItemIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import BinIcon from '../../components/Icons/BinIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
export const OrderItem = {
name: 'orderItem',
@ -14,6 +18,63 @@ export const OrderItem = {
row: true,
icon: InfoCircleIcon,
url: (_id) => `/dashboard/inventory/orderitems/info?orderItemId=${_id}`
},
{
name: 'edit',
label: 'Edit',
type: 'button',
icon: EditIcon,
url: (_id) =>
`/dashboard/inventory/orderitems/info?orderItemId=${_id}&action=edit`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'cancelEdit',
label: 'Cancel Edit',
type: 'button',
icon: XMarkIcon,
url: (_id) =>
`/dashboard/inventory/orderitems/info?orderItemId=${_id}&action=cancelEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'finishEdit',
label: 'Finish Edit',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/orderitems/info?orderItemId=${_id}&action=finishEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'delete',
label: 'Delete',
type: 'button',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/inventory/orderitems/info?orderItemId=${_id}&action=delete`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
}
],
group: [],
@ -21,7 +82,8 @@ export const OrderItem = {
sorters: ['createdAt', 'updatedAt', 'itemAmount', 'quantity'],
columns: [
'_id',
'_reference',
'state',
'itemType',
'item',
'itemAmount',
@ -30,6 +92,7 @@ export const OrderItem = {
'taxRate',
'totalAmountWithTax',
'order',
'shipment',
'createdAt',
'updatedAt'
],
@ -88,7 +151,16 @@ export const OrderItem = {
type: 'object',
objectType: 'shipment',
showHyperlink: true,
required: true
required: false,
columnWidth: 250,
readOnly: (objectData) => {
return objectData?.state?.type != 'draft'
},
masterFilter: (objectData) => {
return {
order: objectData?.order?._id
}
}
},
{
name: 'itemType',
@ -96,7 +168,7 @@ export const OrderItem = {
type: 'objectType',
masterFilter: ['part', 'packaging', 'filament'],
required: true,
columnWidth: 125
columnWidth: 175
},
{
name: 'item',

View File

@ -378,7 +378,7 @@ export const Printer = {
name: 'printing.count',
label: 'Printing',
type: 'number',
color: 'info'
color: 'processing'
},
{
name: 'error.count',

View File

@ -1,6 +1,10 @@
import PurchaseOrderIcon from '../../components/Icons/PurchaseOrderIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import PlusIcon from '../../components/Icons/PlusIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import EditIcon from '../../components/Icons/EditIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
import BinIcon from '../../components/Icons/BinIcon'
export const PurchaseOrder = {
name: 'purchaseOrder',
@ -17,19 +21,168 @@ export const PurchaseOrder = {
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}`
},
{
name: 'edit',
label: 'Edit',
type: 'button',
icon: EditIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=edit`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'cancelEdit',
label: 'Cancel Edit',
type: 'button',
icon: XMarkIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=cancelEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'finishEdit',
label: 'Finish Edit',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=finishEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'delete',
label: 'Delete',
type: 'button',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=delete`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{ type: 'divider' },
{
name: 'New Order Item',
label: 'New Order Item',
type: 'button',
icon: PlusIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=newOrderItem`
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=newOrderItem`,
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'New Shipment',
label: 'New Shipment',
type: 'button',
icon: PlusIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=newShipment`,
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'New Invoice',
label: 'New Invoice',
type: 'button',
icon: PlusIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=newInvoice`,
disabled: (objectData) => {
return objectData?.state?.type != 'received'
}
},
{
type: 'divider'
},
{
name: 'post',
label: 'Post',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=post`,
visible: (objectData) => {
return objectData?.state?.type == 'draft'
}
},
{
name: 'acknowledge',
label: 'Acknowledge',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=acknowledge`,
visible: (objectData) => {
return objectData?.state?.type == 'sent'
}
},
{
name: 'complete',
label: 'Complete',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/shipments/info?shipmentId=${_id}&action=complete`,
disabled: (objectData) => {
return objectData?.state?.type != 'received'
},
visible: (objectData) => {
return objectData?.state?.type == 'received'
}
},
{
name: 'cancel',
label: 'Cancel',
type: 'button',
icon: XMarkIcon,
danger: true,
url: (_id) =>
`/dashboard/inventory/purchaseorders/info?purchaseOrderId=${_id}&action=cancel`,
disabled: (objectData) => {
return objectData?.state?.type == 'cancelled'
},
visible: (objectData) => {
return (
objectData?.state?.type != 'draft' &&
objectData?.state?.type != 'completed' &&
objectData?.state?.type != 'received'
)
}
}
],
group: ['vendor'],
filters: ['vendor'],
sorters: ['createdAt', 'state', 'updatedAt'],
columns: ['_id', 'createdAt', 'state', 'updatedAt', 'vendor'],
columns: [
'_id',
'_reference',
'state',
'vendor',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount',
'createdAt',
'updatedAt',
'vendor'
],
properties: [
{
name: '_id',
@ -62,6 +215,7 @@ export const PurchaseOrder = {
readOnly: true
},
{ name: 'state', label: 'State', type: 'state', readOnly: true },
{ name: 'postedAt', label: 'Posted At', type: 'dateTime', readOnly: true },
{
name: 'vendor',
label: 'Vendor',
@ -71,17 +225,9 @@ export const PurchaseOrder = {
showHyperlink: true
},
{
name: 'totalAmount',
label: 'Total Amount',
type: 'number',
prefix: '£',
readOnly: true
},
{
name: 'totalAmountWithTax',
label: 'Total Amount w/ Tax',
type: 'number',
prefix: '£',
name: 'acknowledgedAt',
label: 'Acknowledged At',
type: 'dateTime',
readOnly: true
},
{
@ -89,7 +235,104 @@ export const PurchaseOrder = {
label: 'Total Tax Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
readOnly: true,
columnWidth: 175
},
{
name: 'CompletedAt',
label: 'Completed At',
type: 'dateTime',
readOnly: true
},
{
name: 'totalAmountWithTax',
label: 'Total Amount w/ Tax',
type: 'number',
prefix: '£',
readOnly: true,
columnWidth: 175,
roundNumber: 2
},
{
name: 'shippingAmount',
label: 'Shipping Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
readOnly: true,
columnWidth: 150
},
{
name: 'shippingAmountWithTax',
label: 'Shipping Amount w/ Tax',
type: 'number',
prefix: '£',
readOnly: true,
roundNumber: 2,
columnWidth: 200
},
{
name: 'totalAmount',
label: 'Total Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
readOnly: true,
columnWidth: 150
},
{
name: 'grandTotalAmount',
label: 'Grand Total Amount',
type: 'number',
prefix: '£',
roundNumber: 2,
columnWidth: 175,
readOnly: true
}
],
stats: [
{
name: 'draft.count',
label: 'Draft',
type: 'number',
color: 'default'
},
{
name: 'sent.count',
label: 'Sent',
type: 'number',
color: 'cyan'
},
{
name: 'acknowledged.count',
label: 'Acknowledged',
type: 'number',
color: 'processing'
},
{
name: 'partiallyShipped.count',
label: 'Partially Shipped',
type: 'number',
color: 'processing'
},
{
name: 'shipped.count',
label: 'Shipped',
type: 'number',
color: 'processing'
},
{
name: 'partiallyReceived.count',
label: 'Partially Received',
type: 'number',
color: 'success'
},
{
name: 'received.count',
label: 'Received',
type: 'number',
color: 'success'
}
]
}

View File

@ -1,10 +1,14 @@
import ShipmentIcon from '../../components/Icons/ShipmentIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import BinIcon from '../../components/Icons/BinIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
export const Shipment = {
name: 'shipment',
label: 'Shipment',
prefix: 'SHM',
prefix: 'SHP',
icon: ShipmentIcon,
actions: [
{
@ -14,25 +18,126 @@ export const Shipment = {
row: true,
icon: InfoCircleIcon,
url: (_id) => `/dashboard/inventory/shipments/info?shipmentId=${_id}`
},
{
name: 'edit',
label: 'Edit',
type: 'button',
icon: EditIcon,
url: (_id) =>
`/dashboard/inventory/shipments/info?shipmentId=${_id}&action=edit`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'cancelEdit',
label: 'Cancel Edit',
type: 'button',
icon: XMarkIcon,
url: (_id) =>
`/dashboard/inventory/shipments/info?shipmentId=${_id}&action=cancelEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'finishEdit',
label: 'Finish Edit',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/shipments/info?shipmentId=${_id}&action=finishEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{
name: 'delete',
label: 'Delete',
type: 'button',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/inventory/shipments/info?shipmentId=${_id}&action=delete`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
},
disabled: (objectData) => {
return objectData?.state?.type != 'draft'
}
},
{ type: 'divider' },
{
name: 'ship',
label: 'Ship',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/shipments/info?shipmentId=${_id}&action=ship`,
disabled: (objectData) => {
return objectData?.state?.type != 'planned'
},
visible: (objectData) => {
return (
objectData?.state?.type == 'planned' ||
objectData?.state?.type == 'draft'
)
}
},
{
name: 'receive',
label: 'Receive',
type: 'button',
icon: CheckIcon,
url: (_id) =>
`/dashboard/inventory/shipments/info?shipmentId=${_id}&action=receive`,
disabled: (objectData) => {
return objectData?.state?.type != 'shipped'
},
visible: (objectData) => {
return (
objectData?.state?.type == 'shipped' ||
objectData?.state?.type == 'delivered'
)
}
}
],
group: ['vendor', 'purchaseOrder'],
filters: ['vendor', 'purchaseOrder', 'state', 'courierService'],
group: [],
filters: ['orderType', 'order', 'state', 'courierService'],
sorters: [
'createdAt',
'state',
'updatedAt',
'shippedDate',
'expectedDeliveryDate'
'shippedAt',
'expectedAt',
'deliveredAt'
],
columns: [
'_id',
'createdAt',
'_reference',
'state',
'orderType',
'order',
'amount',
'amountWithTax',
'taxRate',
'taxAmount',
'trackingNumber',
'shippedAt',
'expectedAt',
'deliveredAt',
'updatedAt',
'vendor',
'purchaseOrder',
'trackingNumber'
'createdAt'
],
properties: [
{
@ -50,231 +155,139 @@ export const Shipment = {
type: 'dateTime',
readOnly: true
},
{ name: 'state', label: 'State', type: 'state', readOnly: true },
{
name: '_reference',
label: 'Reference',
type: 'reference',
objectType: 'shipment',
showCopy: true,
readOnly: true
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true
},
{ name: 'state', label: 'State', type: 'state', readOnly: true },
{
name: 'purchaseOrder',
label: 'Purchase Order',
required: true,
type: 'object',
objectType: 'purchaseOrder',
showHyperlink: true
name: 'shippedAt',
label: 'Shipped At',
type: 'dateTime',
required: false
},
{
name: 'vendor',
label: 'Vendor',
name: 'orderType',
label: 'Order Type',
required: true,
type: 'objectType',
masterFilter: ['purchaseOrder', 'salesOrder'],
showHyperlink: true
},
{
name: 'expectedAt',
label: 'Expected At',
type: 'dateTime',
required: false
},
{
name: 'order',
label: 'Order',
required: true,
type: 'object',
objectType: 'vendor',
showHyperlink: true
showHyperlink: true,
objectType: (objectData) => {
return objectData?.orderType
}
},
{
name: 'deliveredAt',
label: 'Delivered At',
type: 'dateTime',
required: false
},
{
name: 'courierService',
label: 'Courier Service',
required: false,
required: true,
type: 'object',
objectType: 'courierService'
},
{
name: 'trackingNumber',
label: 'Tracking Number',
type: 'string',
type: 'text',
required: false
},
{
name: 'items',
label: 'Shipment Items',
type: 'objectChildren',
required: true,
properties: [
{
name: 'itemType',
label: 'Item Type',
type: 'objectType',
masterFilter: ['part', 'packaging'],
required: true
},
{
name: 'item',
label: 'Item',
type: 'object',
objectType: (objectData) => {
return objectData?.itemType
},
required: true,
showHyperlink: true
},
{
name: 'itemCost',
label: 'Item Cost',
type: 'number',
required: true,
prefix: '£',
min: 0,
step: 0.01,
columnWidth: 150,
value: (objectData) => {
if (objectData?.item) {
return objectData?.item?.cost || undefined
} else {
return undefined
}
}
},
{
name: 'quantity',
label: 'Quantity',
type: 'number',
required: true,
columnWidth: 150
},
{
name: 'totalCost',
label: 'Total Cost',
type: 'number',
required: true,
prefix: '£',
min: 0,
step: 0.01,
columnWidth: 150,
value: (objectData) => {
return (
(objectData?.itemCost || 0) * (objectData?.quantity || 0) ||
undefined
)
}
},
{
name: 'taxRate',
label: 'Tax Rate',
type: 'object',
objectType: 'taxRate',
showHyperlink: true,
value: (objectData) => {
if (objectData?.item) {
return objectData?.item?.costTaxRate || undefined
} else {
return undefined
}
}
},
{
name: 'totalCostWithTax',
label: 'Total Cost w/ Tax',
type: 'number',
required: true,
readOnly: true,
prefix: '£',
min: 0,
step: 0.01,
columnWidth: 175,
value: (objectData) => {
if (objectData?.taxRate?.rateType == 'percentage') {
return (
(
(objectData?.totalCost || 0) *
(1 + objectData?.taxRate?.rate / 100)
).toFixed(2) || undefined
)
} else if (objectData?.taxRate?.rateType == 'amount') {
return (
(
(objectData?.totalCost || 0) + objectData?.taxRate?.rate
).toFixed(2) || undefined
)
} else {
return objectData?.totalCost || undefined
}
}
}
],
rollups: [
{
name: 'totalQuantity',
label: 'Total',
type: 'number',
property: 'quantity',
value: (objectData) => {
return objectData?.items?.reduce(
(acc, item) => acc + item.quantity,
0
)
}
},
{
name: 'totalCost',
label: 'Total',
type: 'number',
prefix: '£',
property: 'totalCost',
value: (objectData) => {
return objectData?.items
?.reduce((acc, item) => acc + (item.totalCost || 0), 0)
.toFixed(2)
}
},
{
name: 'totalCostWithTax',
label: 'Total',
type: 'number',
prefix: '£',
property: 'totalCostWithTax',
value: (objectData) => {
return objectData?.items
?.reduce((acc, item) => acc + (item.totalCostWithTax || 0), 0)
.toFixed(2)
}
}
]
name: 'taxRate',
label: 'Tax Rate',
type: 'object',
objectType: 'taxRate',
showHyperlink: true
},
{
name: 'cost',
label: 'Cost',
type: 'netGross',
name: 'taxRecord',
label: 'Tax Record',
type: 'object',
objectType: 'taxRecord',
showHyperlink: true
},
{
name: 'amount',
label: 'Amount',
type: 'number',
required: true,
prefix: '£',
min: 0,
step: 0.01,
columnWidth: 150
},
{
name: 'taxAmount',
label: 'Tax Amount',
type: 'number',
required: true,
prefix: '£',
fixedNumber: 2,
min: 0,
step: 0.01,
readOnly: true,
value: (objectData) => {
const net = objectData?.items?.reduce(
(acc, item) => acc + (item.totalCost || 0),
0
)
const gross = objectData?.items?.reduce(
(acc, item) => acc + (item.totalCostWithTax || 0),
0
)
return { net: net, gross: gross }
return (objectData?.amount * objectData?.taxRate?.rate) / 100 || 0
}
},
{
name: 'shippedDate',
label: 'Shipped Date',
type: 'dateTime',
required: false
},
{
name: 'expectedDeliveryDate',
label: 'Expected Delivery Date',
type: 'dateTime',
required: false
},
{
name: 'actualDeliveryDate',
label: 'Actual Delivery Date',
type: 'dateTime',
required: false
},
{
name: 'notes',
label: 'Notes',
type: 'textarea',
required: false
name: 'amountWithTax',
label: 'Amount w/ Tax',
type: 'number',
required: true,
prefix: '£',
min: 0,
step: 0.01,
readOnly: true,
columnWidth: 175,
value: (objectData) => {
if (objectData?._isEditing == true) {
return (
(objectData?.amount || 0) + (objectData?.taxAmount || 0)
).toFixed(2)
}
if (
Number.parseFloat(objectData?.amount) &&
(objectData.taxRate == undefined || objectData.taxRate == null)
) {
return Number.parseFloat(objectData?.amount).toFixed(2)
}
if (Number.parseFloat(objectData?.amountWithTax)) {
return Number.parseFloat(objectData?.amountWithTax).toFixed(2)
}
return 0
}
}
]
}

View File

@ -0,0 +1,29 @@
import { lazy } from 'react'
import { Route } from 'react-router-dom'
const Invoices = lazy(
() => import('../components/Dashboard/Finance/Invoices.jsx')
)
const InvoiceInfo = lazy(
() => import('../components/Dashboard/Finance/Invoices/InvoiceInfo.jsx')
)
const FinanceOverview = lazy(
() => import('../components/Dashboard/Finance/FinanceOverview.jsx')
)
const FinanceRoutes = [
<Route
key='overview'
path='finance/overview'
element={<FinanceOverview />}
/>,
<Route key='invoices' path='finance/invoices' element={<Invoices />} />,
<Route
key='invoices-info'
path='finance/invoices/info'
element={<InvoiceInfo />}
/>
]
export default FinanceRoutes

View File

@ -1,4 +1,5 @@
export { default as ProductionRoutes } from './ProductionRoutes'
export { default as InventoryRoutes } from './InventoryRoutes'
export { default as FinanceRoutes } from './FinanceRoutes'
export { default as ManagementRoutes } from './ManagementRoutes'
export { default as DeveloperRoutes } from './DeveloperRoutes'