Refactor Stock Transfers and Management Components

- Adjusted width of Stock Transfers modal for better UI consistency.
- Enhanced New Stock Transfer form with default transfer name generation and improved validation handling.
- Added Product Categories to Management Sidebar and updated routing for better navigation.
- Implemented delete functionality in Host Info component.
- Improved User Info layout for mobile responsiveness and added ObjectProperty component for better data display.
- Updated FileUpload and FileList components to support minimal display mode.
- Enhanced ObjectTable with subscription filtering and improved rendering logic.
- Added new properties and filters to Stock Transfer and Product models for better data management.
This commit is contained in:
Tom Butcher 2026-06-14 23:51:45 +01:00
parent 7a5ea5416b
commit fb9454d8e0
31 changed files with 1004 additions and 146 deletions

View File

@ -0,0 +1,16 @@
<?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.983114,0,0,0.983114,0.872652,-3.62694)">
<path d="M29.119,60.411L19.595,65.918C18.255,66.699 16.701,66.699 15.368,65.918L3.47,59.042C2.153,58.292 1.364,56.929 1.364,55.39L1.364,41.653C1.364,40.114 2.153,38.751 3.47,37.993L15.368,31.125C15.408,31.101 15.448,31.079 15.488,31.057C15.484,30.981 15.482,30.906 15.482,30.83L15.482,17.094C15.482,15.554 16.271,14.192 17.588,13.433L29.487,6.565C30.819,5.777 32.374,5.777 33.714,6.565L45.605,13.433C46.922,14.192 47.71,15.554 47.71,17.094L47.71,30.83C47.71,30.882 47.71,30.934 47.708,30.986C47.794,31.029 47.879,31.075 47.963,31.125L59.854,37.993C60.373,38.292 60.811,38.685 61.15,39.146C60.959,39.134 60.762,39.128 60.56,39.128L54.077,39.128L46.646,34.839C46.14,34.532 45.551,34.532 45.045,34.839L37.619,39.128L35.314,39.128C33.245,39.128 31.768,39.759 30.759,40.772C29.905,41.629 29.317,42.828 29.16,44.457L19.411,50.038L19.411,61.546C19.603,61.446 19.687,61.393 19.886,61.278L28.929,56.048C29.434,55.75 30.678,54.945 30.755,55.126L30.857,55.226L30.757,55.324C29.753,56.334 29.119,57.815 29.119,59.896L29.119,60.411ZM33.53,36.986C33.721,36.887 33.805,36.833 34.005,36.718L43.047,31.489C43.553,31.19 43.844,30.685 43.844,30.103L43.844,19.574L33.53,25.478L33.53,36.986ZM29.663,36.94L29.663,25.531L19.341,19.62L19.341,30.103C19.341,30.57 19.539,30.987 19.878,31.288L29.663,36.94ZM31.554,22.132L42.251,15.999C42.197,15.953 42.182,15.93 42.067,15.861L32.397,10.279C31.891,9.973 31.302,9.973 30.796,10.279L21.133,15.861C21.011,15.93 20.98,15.96 20.896,16.029L31.554,22.132ZM17.928,34.679C17.513,34.548 17.07,34.601 16.678,34.839L7.015,40.42C6.892,40.489 6.862,40.52 6.777,40.589L17.436,46.692L28.117,40.567L17.928,34.679ZM15.545,61.546L15.545,50.091L5.223,44.18L5.223,54.662C5.223,55.244 5.529,55.75 6.035,56.048L15.261,61.385C15.391,61.462 15.414,61.477 15.545,61.546Z"/>
</g>
<g transform="matrix(0.446187,0,0,0.446187,32,37.312437)">
<path d="M46.094,59.812L63.672,59.812C69.125,59.812 71.719,57.234 71.719,51.688L71.719,40.219C71.719,34.703 69.125,32.109 63.672,32.109L46.094,32.109C40.641,32.109 38.047,34.703 38.047,40.219L38.047,51.688C38.047,57.234 40.641,59.812 46.094,59.812ZM46.109,53.375C44.984,53.375 44.484,52.844 44.484,51.734L44.484,40.203C44.484,39.078 44.984,38.547 46.109,38.547L63.672,38.547C64.766,38.547 65.281,39.078 65.281,40.203L65.281,51.734C65.281,52.844 64.766,53.375 63.672,53.375L46.109,53.375Z" style="fill-rule:nonzero;"/>
<path d="M8.047,59.812L25.625,59.812C31.078,59.812 33.672,57.234 33.672,51.688L33.672,40.219C33.672,34.703 31.078,32.109 25.625,32.109L8.047,32.109C2.594,32.109 0,34.703 0,40.219L0,51.688C0,57.234 2.594,59.812 8.047,59.812ZM8.047,53.375C6.938,53.375 6.438,52.844 6.438,51.734L6.438,40.203C6.438,39.078 6.938,38.547 8.047,38.547L25.609,38.547C26.703,38.547 27.234,39.078 27.234,40.203L27.234,51.734C27.234,52.844 26.703,53.375 25.609,53.375L8.047,53.375Z" style="fill-rule:nonzero;"/>
<path d="M46.094,27.75L63.672,27.75C69.125,27.75 71.719,25.156 71.719,19.625L71.719,8.172C71.719,2.625 69.125,0.062 63.672,0.062L46.094,0.062C40.641,0.062 38.047,2.625 38.047,8.172L38.047,19.625C38.047,25.156 40.641,27.75 46.094,27.75ZM46.109,21.312C44.984,21.312 44.484,20.797 44.484,19.656L44.484,8.125C44.484,7.016 44.984,6.5 46.109,6.5L63.672,6.5C64.766,6.5 65.281,7.016 65.281,8.125L65.281,19.656C65.281,20.797 64.766,21.312 63.672,21.312L46.109,21.312Z" style="fill-rule:nonzero;"/>
<path d="M8.047,27.75L25.625,27.75C31.078,27.75 33.672,25.156 33.672,19.625L33.672,8.172C33.672,2.625 31.078,0.062 25.625,0.062L8.047,0.062C2.594,0.062 0,2.625 0,8.172L0,19.625C0,25.156 2.594,27.75 8.047,27.75ZM8.047,21.312C6.938,21.312 6.438,20.797 6.438,19.656L6.438,8.125C6.438,7.016 6.938,6.5 8.047,6.5L25.609,6.5C26.703,6.5 27.234,7.016 27.234,8.125L27.234,19.656C27.234,20.797 26.703,21.312 25.609,21.312L8.047,21.312Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.446187,0,0,0.446187,32,37.312437)">
<path d="M46.094,59.812L63.672,59.812C69.125,59.812 71.719,57.234 71.719,51.688L71.719,40.219C71.719,34.703 69.125,32.109 63.672,32.109L46.094,32.109C40.641,32.109 38.047,34.703 38.047,40.219L38.047,51.688C38.047,57.234 40.641,59.812 46.094,59.812ZM46.109,53.375C44.984,53.375 44.484,52.844 44.484,51.734L44.484,40.203C44.484,39.078 44.984,38.547 46.109,38.547L63.672,38.547C64.766,38.547 65.281,39.078 65.281,40.203L65.281,51.734C65.281,52.844 64.766,53.375 63.672,53.375L46.109,53.375Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -90,7 +90,7 @@ const StockTransfers = () => {
open={newOpen} open={newOpen}
styles={{ content: { paddingBottom: '24px' } }} styles={{ content: { paddingBottom: '24px' } }}
footer={null} footer={null}
width={960} width={740}
onCancel={() => { onCancel={() => {
setNewOpen(false) setNewOpen(false)
}} }}

View File

@ -1,17 +1,40 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import dayjs from 'dayjs'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm' import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView' import WizardView from '../../common/WizardView'
const defaultTransferName = () =>
`Transfer ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`
const NewStockTransfer = ({ onOk, reset }) => { const NewStockTransfer = ({ onOk, reset }) => {
return ( return (
<NewObjectForm <NewObjectForm
type={'stockTransfer'} type={'stockTransfer'}
reset={reset} reset={reset}
defaultValues={{ state: { type: 'draft' }, lines: [] }} defaultValues={{
name: defaultTransferName(),
state: { type: 'draft' },
lines: []
}}
> >
{({ handleSubmit, submitLoading, objectData }) => { {({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [ const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='stockTransfer'
column={1}
bordered={false}
labelWidth={80}
isEditing={true}
required={true}
objectData={objectData}
/>
)
},
{ {
title: 'Summary', title: 'Summary',
key: 'summary', key: 'summary',
@ -20,6 +43,7 @@ const NewStockTransfer = ({ onOk, reset }) => {
type='stockTransfer' type='stockTransfer'
column={1} column={1}
bordered={false} bordered={false}
labelWidth={80}
visibleProperties={{ visibleProperties={{
_id: false, _id: false,
createdAt: false, createdAt: false,
@ -38,7 +62,7 @@ const NewStockTransfer = ({ onOk, reset }) => {
<WizardView <WizardView
steps={steps} steps={steps}
loading={submitLoading} loading={submitLoading}
formValid={true} formValid={formValid}
title='New Stock Transfer' title='New Stock Transfer'
onSubmit={async () => { onSubmit={async () => {
const result = await handleSubmit() const result = await handleSubmit()

View File

@ -68,6 +68,10 @@ const HostInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current.handleUpdate() objectFormRef?.current.handleUpdate()
return true return true
},
delete: () => {
objectFormRef?.current?.handleDelete?.()
return true
} }
} }

View File

@ -5,6 +5,7 @@ import FilamentSkuIcon from '../../Icons/FilamentSkuIcon'
import PartIcon from '../../Icons/PartIcon' import PartIcon from '../../Icons/PartIcon'
import PartSkuIcon from '../../Icons/PartSkuIcon' import PartSkuIcon from '../../Icons/PartSkuIcon'
import ProductIcon from '../../Icons/ProductIcon' import ProductIcon from '../../Icons/ProductIcon'
import ProductCategoryIcon from '../../Icons/ProductCategoryIcon'
import ProductSkuIcon from '../../Icons/ProductSkuIcon' import ProductSkuIcon from '../../Icons/ProductSkuIcon'
import VendorIcon from '../../Icons/VendorIcon' import VendorIcon from '../../Icons/VendorIcon'
import MaterialIcon from '../../Icons/MaterialIcon' import MaterialIcon from '../../Icons/MaterialIcon'
@ -57,6 +58,12 @@ const items = [
label: 'Products', label: 'Products',
path: '/dashboard/management/products' path: '/dashboard/management/products'
}, },
{
key: 'productCategories',
icon: <ProductCategoryIcon />,
label: 'Product Categories',
path: '/dashboard/management/productcategories'
},
{ {
key: 'productSkus', key: 'productSkus',
icon: <ProductSkuIcon />, icon: <ProductSkuIcon />,
@ -199,6 +206,7 @@ const routeKeyMap = {
'/dashboard/management/users': 'users', '/dashboard/management/users': 'users',
'/dashboard/management/apppasswords': 'appPasswords', '/dashboard/management/apppasswords': 'appPasswords',
'/dashboard/management/products': 'products', '/dashboard/management/products': 'products',
'/dashboard/management/productcategories': 'productCategories',
'/dashboard/management/productskus': 'productSkus', '/dashboard/management/productskus': 'productSkus',
'/dashboard/management/vendors': 'vendors', '/dashboard/management/vendors': 'vendors',
'/dashboard/management/couriers': 'couriers', '/dashboard/management/couriers': 'couriers',

View File

@ -0,0 +1,109 @@
import { useRef, useState } from 'react'
import { Button, Flex, Space, Modal, Dropdown } from 'antd'
import NewProductCategory from './ProductCategories/NewProductCategory'
import useColumnVisibility from '../hooks/useColumnVisibility'
import ColumnViewButton from '../common/ColumnViewButton'
import ObjectTable from '../common/ObjectTable'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import ObjectTableViewButton from '../common/ObjectTableViewButton'
import FilterSidebarButton from '../common/FilterSidebarButton'
import useViewMode from '../hooks/useViewMode'
import useFilterSidebarVisibility from '../hooks/useFilterSidebarVisibility'
import ExportListButton from '../common/ExportListButton'
const ProductCategories = () => {
const [newProductCategoryOpen, setNewProductCategoryOpen] = useState(false)
const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('productCategory')
const [columnVisibility, setColumnVisibility] =
useColumnVisibility('productCategory')
const [showFilterSidebar, setShowFilterSidebar] =
useFilterSidebarVisibility('ProductCategories')
const actionItems = {
items: [
{
label: 'New Product Category',
key: 'newProductCategory',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newProductCategory') {
setNewProductCategoryOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large' className='h-100'>
<Flex justify={'space-between'}>
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<ColumnViewButton
type='productCategory'
loading={false}
visibleState={columnVisibility}
updateVisibleState={setColumnVisibility}
/>
<ExportListButton objectType='productCategory' />
</Space>
<Space>
<FilterSidebarButton
active={showFilterSidebar}
onClick={() => setShowFilterSidebar(!showFilterSidebar)}
/>
<ObjectTableViewButton
viewMode={viewMode}
setViewMode={setViewMode}
/>
</Space>
</Flex>
<ObjectTable
ref={tableRef}
type='productCategory'
cards={viewMode === 'cards'}
visibleColumns={columnVisibility}
showFilterSidebar={showFilterSidebar}
/>
<Modal
open={newProductCategoryOpen}
footer={null}
width={700}
onCancel={() => {
setNewProductCategoryOpen(false)
}}
>
<NewProductCategory
onOk={() => {
setNewProductCategoryOpen(false)
tableRef.current?.reload()
}}
reset={newProductCategoryOpen}
/>
</Modal>
</Flex>
</>
)
}
export default ProductCategories

View File

@ -0,0 +1,68 @@
import PropTypes from 'prop-types'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
const NewProductCategory = ({ onOk }) => {
return (
<NewObjectForm type={'productCategory'}>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='productCategory'
column={1}
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
/>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<ObjectInfo
type='productCategory'
column={1}
bordered={false}
visibleProperties={{
_id: false,
createdAt: false,
updatedAt: false
}}
isEditing={false}
objectData={objectData}
/>
)
}
]
return (
<WizardView
steps={steps}
loading={submitLoading}
formValid={formValid}
title='New Product Category'
onSubmit={async () => {
const result = await handleSubmit()
if (result) {
onOk()
}
}}
/>
)
}}
</NewObjectForm>
)
}
NewProductCategory.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool
}
export default NewProductCategory

View File

@ -0,0 +1,195 @@
import { useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Flex, Card } from 'antd'
import useCollapseState from '../../hooks/useCollapseState'
import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ObjectForm from '../../common/ObjectForm'
import EditButtons from '../../common/EditButtons'
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 UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
const ProductCategoryInfo = () => {
const location = useLocation()
const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null)
const productCategoryId = new URLSearchParams(location.search).get(
'productCategoryId'
)
const [collapseState, updateCollapseState] = useCollapseState(
'ProductCategoryInfo',
{
info: true,
notes: true,
auditLogs: true
}
)
const [objectFormState, setEditFormState] = useState({
isEditing: false,
editLoading: false,
formValid: false,
lock: null,
loading: false,
objectData: {}
})
const actions = {
edit: () => {
objectFormRef?.current?.startEditing?.()
return false
},
cancelEdit: () => {
objectFormRef?.current?.cancelEditing?.()
return true
},
finishEdit: () => {
objectFormRef?.current?.handleUpdate?.()
return true
},
delete: () => {
objectFormRef?.current?.handleDelete?.()
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='productCategory'
id={productCategoryId}
disabled={objectFormState.loading}
objectData={objectFormState.objectData}
/>
<ViewButton
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Product Category Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
<UserNotifierToggle
type='productCategory'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
<DocumentPrintButton
type='productCategory'
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}
>
<InfoCollapse
title='Product Category Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
collapseKey='info'
>
<ObjectForm
id={productCategoryId}
type='productCategory'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<ObjectInfo
loading={loading}
isEditing={isEditing}
type='productCategory'
objectData={objectData}
/>
)}
</ObjectForm>
</InfoCollapse>
</ActionHandler>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={productCategoryId} type='productCategory' />
</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': productCategoryId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</ScrollBox>
</Flex>
</>
)
}
export default ProductCategoryInfo

View File

@ -22,6 +22,9 @@ import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import NewAppPassword from '../AppPasswords/NewAppPassword.jsx' import NewAppPassword from '../AppPasswords/NewAppPassword.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import { getModelProperty } from '../../../../database/ObjectModels.js'
import { useMediaQuery } from 'react-responsive'
const UserInfo = () => { const UserInfo = () => {
const location = useLocation() const location = useLocation()
@ -44,6 +47,7 @@ const UserInfo = () => {
loading: false, loading: false,
objectData: {} objectData: {}
}) })
const isMobile = useMediaQuery({ maxWidth: 768 })
const actions = { const actions = {
newAppPassword: () => { newAppPassword: () => {
@ -147,13 +151,34 @@ const UserInfo = () => {
}} }}
> >
{({ loading, isEditing, objectData }) => ( {({ loading, isEditing, objectData }) => (
<ObjectInfo <Flex gap='large' vertical={isMobile}>
loading={loading} <div
indicator={<LoadingOutlined />} style={
isEditing={isEditing} isMobile
type='user' ? { width: '100%' }
objectData={objectData} : { width: '20%', maxWidth: '238px' }
/> }
>
<Card styles={{ body: { padding: 18 } }}>
<ObjectProperty
{...getModelProperty('user', 'profileImage')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
/>
</Card>
</div>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='user'
objectData={objectData}
visibleProperties={{
profileImage: false
}}
/>
</Flex>
)} )}
</ObjectForm> </ObjectForm>
</InfoCollapse> </InfoCollapse>

View File

@ -18,7 +18,7 @@ const NewGCodeFile = ({ onOk, defaultValues }) => {
const steps = [ const steps = [
{ {
title: 'Upload', title: 'Upload',
key: 'uplaod', key: 'upload',
content: ( content: (
<ObjectInfo <ObjectInfo
type='gcodeFile' type='gcodeFile'
@ -27,7 +27,7 @@ const NewGCodeFile = ({ onOk, defaultValues }) => {
isEditing={true} isEditing={true}
required={true} required={true}
objectData={objectData} objectData={objectData}
visibleProperties={{ name: false, filament: false }} visibleProperties={{ file: true }}
showLabels={false} showLabels={false}
/> />
) )
@ -57,6 +57,8 @@ const NewGCodeFile = ({ onOk, defaultValues }) => {
bordered={false} bordered={false}
visibleProperties={{ visibleProperties={{
_id: false, _id: false,
_reference: false,
parts: false,
createdAt: false, createdAt: false,
updatedAt: false, updatedAt: false,
startedAt: false startedAt: false

View File

@ -12,6 +12,7 @@ const breadcrumbNameMap = {
developer: 'Developer', developer: 'Developer',
finance: 'Finance', finance: 'Finance',
sales: 'Sales', sales: 'Sales',
productcategories: 'Product Categories',
overview: 'Overview', overview: 'Overview',
info: 'Info', info: 'Info',
design: 'Design', design: 'Design',

View File

@ -23,6 +23,7 @@ const FileList = ({
showInfo = true, showInfo = true,
showDownload = true, showDownload = true,
defaultPreviewOpen = false, defaultPreviewOpen = false,
minimal = false,
card = true card = true
}) => { }) => {
const { fetchFileContent, flushFile } = useContext(ApiServerContext) const { fetchFileContent, flushFile } = useContext(ApiServerContext)
@ -78,7 +79,7 @@ const FileList = ({
<Tag>{file.extension}</Tag> <Tag>{file.extension}</Tag>
</Flex> </Flex>
<Flex gap={'small'} align='center'> <Flex gap={'small'} align='center'>
{showDownload && ( {showDownload && !minimal && (
<Button <Button
icon={<DownloadIcon />} icon={<DownloadIcon />}
size='small' size='small'
@ -86,7 +87,7 @@ const FileList = ({
onClick={() => handleDownload(file)} onClick={() => handleDownload(file)}
/> />
)} )}
{showPreview && ( {showPreview && !minimal && (
<Button <Button
icon={previewOpen ? <EyeSlashIcon /> : <EyeIcon />} icon={previewOpen ? <EyeSlashIcon /> : <EyeIcon />}
size='small' size='small'
@ -100,7 +101,7 @@ const FileList = ({
}} }}
/> />
)} )}
{showInfo && ( {showInfo && !minimal && (
<Button <Button
icon={<InfoCircleIcon />} icon={<InfoCircleIcon />}
size='small' size='small'
@ -110,7 +111,7 @@ const FileList = ({
}} }}
/> />
)} )}
{editing && ( {editing && !minimal && (
<Button <Button
icon={<BinIcon />} icon={<BinIcon />}
size='small' size='small'
@ -120,7 +121,7 @@ const FileList = ({
)} )}
</Flex> </Flex>
</Flex> </Flex>
{previewOpen ? ( {previewOpen && !minimal ? (
<> <>
<Divider style={{ marginTop: 0, marginBottom: card ? 0 : '4px' }} /> <Divider style={{ marginTop: 0, marginBottom: card ? 0 : '4px' }} />
<FilePreview file={file} style={{ width: '100%' }} /> <FilePreview file={file} style={{ width: '100%' }} />
@ -174,7 +175,8 @@ FileList.propTypes = {
showInfo: PropTypes.bool, showInfo: PropTypes.bool,
showDownload: PropTypes.bool, showDownload: PropTypes.bool,
defaultPreviewOpen: PropTypes.bool, defaultPreviewOpen: PropTypes.bool,
card: PropTypes.bool card: PropTypes.bool,
minimal: PropTypes.bool
} }
export default FileList export default FileList

View File

@ -2,7 +2,14 @@ import { Upload, Button, Flex, Typography, Space, Progress, Card } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import UploadIcon from '../../Icons/UploadIcon' import UploadIcon from '../../Icons/UploadIcon'
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import ObjectSelect from './ObjectSelect' import ObjectSelect from './ObjectSelect'
import FileList from './FileList' import FileList from './FileList'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
@ -51,7 +58,9 @@ const FileUpload = ({
// Update currentFiles when value prop changes // Update currentFiles when value prop changes
useEffect(() => { useEffect(() => {
setCurrentFiles((prev) => { setCurrentFiles((prev) => {
if (getFileIdentity(prev, multiple) === getFileIdentity(value, multiple)) { if (
getFileIdentity(prev, multiple) === getFileIdentity(value, multiple)
) {
return prev return prev
} }
@ -128,7 +137,7 @@ const FileUpload = ({
return ( return (
<Flex gap={'small'} vertical> <Flex gap={'small'} vertical>
{hasNoItems && uploading == false ? ( {hasNoItems && uploading == false ? (
<Flex gap={'small'} align='center'> <Flex gap={'small'} align='center' wrap>
<Space.Compact style={{ flexGrow: 1 }}> <Space.Compact style={{ flexGrow: 1 }}>
<ObjectSelect <ObjectSelect
type={'file'} type={'file'}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useCallback } from 'react' import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { Form } from 'antd' import { Form } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { useMessageContext } from '../context/MessageContext' import { useMessageContext } from '../context/MessageContext'
@ -38,10 +38,35 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
const [formValid, setFormValid] = useState(false) const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const validationRunRef = useRef(0)
const formUpdateValues = Form.useWatch([], form) const formUpdateValues = Form.useWatch([], form)
const { showSuccess, showError: showMessageError } = useMessageContext() const { showSuccess, showError: showMessageError } = useMessageContext()
const { createObject, showError } = useContext(ApiServerContext) const { createObject, showError } = useContext(ApiServerContext)
const validateForm = useCallback(() => {
const validationRun = ++validationRunRef.current
let cancelled = false
const timeoutId = setTimeout(() => {
form
.validateFields({ validateOnly: true })
.then(() => {
if (!cancelled && validationRun === validationRunRef.current) {
setFormValid(true)
}
})
.catch(() => {
if (!cancelled && validationRun === validationRunRef.current) {
setFormValid(false)
}
})
}, 0)
return () => {
cancelled = true
clearTimeout(timeoutId)
}
}, [form])
// Get the model definition for this object type // Get the model definition for this object type
const model = getModelByName(type) const model = getModelByName(type)
@ -141,21 +166,15 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
const computedValuesObject = buildObjectFromEntries(computedEntries) const computedValuesObject = buildObjectFromEntries(computedEntries)
const initialFormData = merge({}, defaultValues, computedValuesObject) const initialFormData = merge({}, defaultValues, computedValuesObject)
form.setFieldsValue(initialFormData) form.setFieldsValue(initialFormData)
form
.validateFields({ validateOnly: true })
.then(() => setFormValid(true))
.catch(() => setFormValid(false))
setObjectData((prev) => merge({}, prev, initialFormData)) setObjectData((prev) => merge({}, prev, initialFormData))
return validateForm()
} }
}, [form, defaultValues, calculateComputedValues, model]) }, [form, defaultValues, calculateComputedValues, model, validateForm])
// Validate form on change // Validate form on change
useEffect(() => { useEffect(() => {
form return validateForm()
.validateFields({ validateOnly: true }) }, [validateForm, formUpdateValues])
.then(() => setFormValid(true))
.catch(() => setFormValid(false))
}, [form, formUpdateValues])
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
@ -217,7 +236,7 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
}} }}
> >
{children({ {children({
submitLoading: submitLoading, submitLoading,
handleSubmit, handleSubmit,
form, form,
formValid, formValid,

View File

@ -103,11 +103,10 @@ const NoteItem = ({
if (isExpanded == true) { if (isExpanded == true) {
subscribeToObjectTypeUpdatesRef.current = subscribeToObjectTypeUpdates( subscribeToObjectTypeUpdatesRef.current = subscribeToObjectTypeUpdates(
'note', 'note',
(noteData) => { { 'parent._id': note._id },
if (noteData.parent._id == note._id) { () => {
if (isExpanded == true) { if (isExpanded == true) {
handleNoteExpand() handleNoteExpand()
}
} }
} }
) )

View File

@ -95,11 +95,8 @@ const NotesPanel = ({ _id, type }) => {
if (connected == true && subscribeToObjectTypeUpdatesRef.current == null) { if (connected == true && subscribeToObjectTypeUpdatesRef.current == null) {
subscribeToObjectTypeUpdatesRef.current = subscribeToObjectTypeUpdates( subscribeToObjectTypeUpdatesRef.current = subscribeToObjectTypeUpdates(
'note', 'note',
(noteData) => { { 'parent._id': _id },
if (noteData.parent._id == _id) { () => handleReloadData()
handleReloadData()
}
}
) )
} }
return () => { return () => {

View File

@ -99,6 +99,14 @@ const ObjectProperty = ({
value = value(objectData) value = value(objectData)
} }
if (max && typeof max == 'function' && objectData) {
max = max(objectData)
}
if (min && typeof min == 'function' && objectData) {
min = min(objectData)
}
if (objectType && typeof objectType == 'function' && objectData) { if (objectType && typeof objectType == 'function' && objectData) {
objectType = objectType(objectData) objectType = objectType(objectData)
} }

View File

@ -446,6 +446,14 @@ const ObjectTable = forwardRef(
[reload] [reload]
) )
const subscriptionFilter = useMemo(() => {
const active = {}
Object.entries(sidebarFilter).forEach(([k, v]) => {
if (v !== '' && v !== undefined) active[k] = v
})
return { ...active, ...masterFilter }
}, [sidebarFilter, masterFilter])
// Subscribe to real-time updates for all items // Subscribe to real-time updates for all items
useEffect(() => { useEffect(() => {
if (pages.length > 0 && connected == true) { if (pages.length > 0 && connected == true) {
@ -517,16 +525,28 @@ const ObjectTable = forwardRef(
}, [connected]) }, [connected])
useEffect(() => { useEffect(() => {
if ( if (connected == true) {
connected == true && const unsubscribe = subscribeToObjectTypeUpdates(
subscribeToObjectTypeUpdatesRef.current == null
) {
subscribeToObjectTypeUpdatesRef.current = subscribeToObjectTypeUpdates(
type, type,
subscriptionFilter,
newEventHandler newEventHandler
) )
subscribeToObjectTypeUpdatesRef.current = unsubscribe
return () => {
if (unsubscribe) unsubscribe()
if (subscribeToObjectTypeUpdatesRef.current === unsubscribe) {
subscribeToObjectTypeUpdatesRef.current = null
}
}
} }
}, [type, subscribeToObjectTypeUpdates, connected, newEventHandler]) }, [
type,
subscriptionFilter,
subscribeToObjectTypeUpdates,
connected,
newEventHandler
])
const updateData = useCallback( const updateData = useCallback(
(id, updatedData) => { (id, updatedData) => {
@ -663,11 +683,13 @@ const ObjectTable = forwardRef(
} }
}) })
console.log('filters--', filters)
setSidebarFilter(next) setSidebarFilter(next)
setPages([]) setPages([])
setLoading(true) setLoading(true)
loadPage(initialPage, getActiveFilter(next), { loadPage(initialPage, getActiveFilter(next), {
field: sorter.field, field: sorter.columnKey,
order: sorter.order order: sorter.order
}) })
} }
@ -858,29 +880,42 @@ const ObjectTable = forwardRef(
style={{ overflowY: 'auto', maxHeight: adjustedScrollHeight }} style={{ overflowY: 'auto', maxHeight: adjustedScrollHeight }}
ref={cardsContainerRef} ref={cardsContainerRef}
> >
{tableData.map((record) => ( {tableData.map((record) => {
<Col xs={24} sm={12} md={12} lg={8} xl={6} xxl={6} key={record._id}> if (record?._id == undefined) {
<div style={{ width: '100%', overflow: 'hidden' }}> return null
<RowForm }
record={record} return (
isEditing={isEditing} <Col
onRegister={registerForm} xs={24}
> sm={12}
<Flex align={'center'} vertical gap={'middle'}> md={12}
<ObjectCard lg={8}
model={model} xl={6}
modelProperties={modelProperties} xxl={6}
visibleColumns={visibleColumns} key={record._id}
record={record} >
isEditing={isEditing} <div style={{ width: '100%', overflow: 'hidden' }}>
rowActions={rowActions} <RowForm
renderActions={renderActions} record={record}
/> isEditing={isEditing}
</Flex> onRegister={registerForm}
</RowForm> >
</div> <Flex align={'center'} vertical gap={'middle'}>
</Col> <ObjectCard
))} model={model}
modelProperties={modelProperties}
visibleColumns={visibleColumns}
record={record}
isEditing={isEditing}
rowActions={rowActions}
renderActions={renderActions}
/>
</Flex>
</RowForm>
</div>
</Col>
)
})}
</Row> </Row>
) )
} }

View File

@ -136,6 +136,10 @@ const StateTag = ({ state, showBadge = true, style = {} }) => {
status = 'cyan' status = 'cyan'
text = 'Ordered' text = 'Ordered'
break break
case 'posted':
status = 'magenta'
text = 'Posted'
break
case 'received': case 'received':
status = 'success' status = 'success'
text = 'Received' text = 'Received'

View File

@ -17,6 +17,7 @@ import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import config from '../../../config' import config from '../../../config'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import { getModelByName } from '../../../database/ObjectModels'
const logger = loglevel.getLogger('ApiServerContext') const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
@ -25,6 +26,35 @@ const SPOTLIGHT_CACHE_TTL_MS = 10_000
const spotlightCache = new Map() const spotlightCache = new Map()
const runningSpotlightFetches = new Map() const runningSpotlightFetches = new Map()
const stableStringify = (value) => {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`
}
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(',')}}`
}
return JSON.stringify(value)
}
const getObjectTypeSubscriptionKey = (objectType, filter = {}) =>
`${objectType}:${stableStringify(filter || {})}`
const getObjectTypeSubscriptionArgs = (filterOrCallback, callback) => {
if (typeof filterOrCallback === 'function') {
return { filter: {}, callback: filterOrCallback }
}
return { filter: filterOrCallback || {}, callback }
}
const getObjectEndpoint = (type) =>
getModelByName(type)?.endpoint || `${type.toLowerCase()}s`
const ApiServerContext = createContext() const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => { const ApiServerProvider = ({ children }) => {
@ -316,12 +346,16 @@ const ApiServerProvider = ({ children }) => {
const handleObjectNew = async (data) => { const handleObjectNew = async (data) => {
logger.debug('Notifying object new:', data) logger.debug('Notifying object new:', data)
const objectType = data.objectType || 'unknown' const objectType = data.objectType || 'unknown'
const callbacksRefKey = getObjectTypeSubscriptionKey(
objectType,
data.filter || {}
)
if (objectType && subscribedCallbacksRef.current.has(objectType)) { if (objectType && subscribedCallbacksRef.current.has(callbacksRefKey)) {
const callbacks = subscribedCallbacksRef.current.get(objectType) const callbacks = subscribedCallbacksRef.current.get(callbacksRefKey)
logger.debug( logger.debug(
`Calling ${callbacks.length} callbacks for type:`, `Calling ${callbacks.length} callbacks for type:`,
objectType callbacksRefKey
) )
callbacks.forEach((callback) => { callbacks.forEach((callback) => {
try { try {
@ -332,7 +366,7 @@ const ApiServerProvider = ({ children }) => {
}) })
} else { } else {
logger.debug( logger.debug(
`No callbacks found for object: ${objectType}, subscribed callbacks:`, `No callbacks found for object: ${callbacksRefKey}, subscribed callbacks:`,
Array.from(subscribedCallbacksRef.current.keys()) Array.from(subscribedCallbacksRef.current.keys())
) )
} }
@ -341,12 +375,16 @@ const ApiServerProvider = ({ children }) => {
const handleObjectDelete = async (data) => { const handleObjectDelete = async (data) => {
logger.debug('Notifying object delete:', data) logger.debug('Notifying object delete:', data)
const objectType = data.objectType || 'unknown' const objectType = data.objectType || 'unknown'
const callbacksRefKey = getObjectTypeSubscriptionKey(
objectType,
data.filter || {}
)
if (objectType && subscribedCallbacksRef.current.has(objectType)) { if (objectType && subscribedCallbacksRef.current.has(callbacksRefKey)) {
const callbacks = subscribedCallbacksRef.current.get(objectType) const callbacks = subscribedCallbacksRef.current.get(callbacksRefKey)
logger.debug( logger.debug(
`Calling ${callbacks.length} callbacks for type:`, `Calling ${callbacks.length} callbacks for type:`,
objectType callbacksRefKey
) )
callbacks.forEach((callback) => { callbacks.forEach((callback) => {
try { try {
@ -357,7 +395,7 @@ const ApiServerProvider = ({ children }) => {
}) })
} else { } else {
logger.debug( logger.debug(
`No callbacks found for object: ${objectType}, subscribed callbacks:`, `No callbacks found for object: ${callbacksRefKey}, subscribed callbacks:`,
Array.from(subscribedCallbacksRef.current.keys()) Array.from(subscribedCallbacksRef.current.keys())
) )
} }
@ -389,24 +427,29 @@ const ApiServerProvider = ({ children }) => {
} }
}, []) }, [])
const offObjectTypeUpdatesEvent = useCallback((objectType, callback) => { const offObjectTypeUpdatesEvent = useCallback(
if (socketRef.current && socketRef.current.connected == true) { (objectType, filter, callback) => {
// Remove callback from the subscribed callbacks map if (socketRef.current && socketRef.current.connected == true) {
if (subscribedCallbacksRef.current.has(objectType)) { const callbacksRefKey = getObjectTypeSubscriptionKey(objectType, filter)
const callbacks = subscribedCallbacksRef.current // Remove callback from the subscribed callbacks map
.get(objectType) if (subscribedCallbacksRef.current.has(callbacksRefKey)) {
.filter((cb) => cb !== callback) const callbacks = subscribedCallbacksRef.current
if (callbacks.length === 0) { .get(callbacksRefKey)
subscribedCallbacksRef.current.delete(objectType) .filter((cb) => cb !== callback)
socketRef.current.emit('unsubscribeObjectTypeUpdate', { if (callbacks.length === 0) {
objectType: objectType subscribedCallbacksRef.current.delete(callbacksRefKey)
}) socketRef.current.emit('unsubscribeObjectTypeUpdate', {
} else { objectType: objectType,
subscribedCallbacksRef.current.set(objectType, callbacks) filter: filter
})
} else {
subscribedCallbacksRef.current.set(callbacksRefKey, callbacks)
}
} }
} }
} },
}, []) []
)
const subscribeToObjectUpdates = useCallback( const subscribeToObjectUpdates = useCallback(
(id, objectType, callback) => { (id, objectType, callback) => {
@ -471,31 +514,46 @@ const ApiServerProvider = ({ children }) => {
}, [connected, userProfile?._id, subscribeToObjectUpdates, setUserProfile]) }, [connected, userProfile?._id, subscribeToObjectUpdates, setUserProfile])
const subscribeToObjectTypeUpdates = useCallback( const subscribeToObjectTypeUpdates = useCallback(
(objectType, callback) => { (objectType, filterOrCallback = {}, maybeCallback) => {
logger.debug('Subscribing to type updates:', objectType) const { filter, callback } = getObjectTypeSubscriptionArgs(
filterOrCallback,
maybeCallback
)
const callbacksRefKey = getObjectTypeSubscriptionKey(objectType, filter)
logger.debug('Subscribing to type updates:', objectType, filter)
if (socketRef.current && socketRef.current.connected == true) { if (socketRef.current && socketRef.current.connected == true) {
// Add callback to the subscribed callbacks map immediately // Add callback to the subscribed callbacks map immediately
if (!subscribedCallbacksRef.current.has(objectType)) { if (!subscribedCallbacksRef.current.has(callbacksRefKey)) {
subscribedCallbacksRef.current.set(objectType, []) subscribedCallbacksRef.current.set(callbacksRefKey, [])
} }
subscribedCallbacksRef.current.get(objectType).push(callback)
logger.debug(
`Added callback for type ${objectType}, total callbacks: ${subscribedCallbacksRef.current.get(objectType).length}`
)
socketRef.current.emit( const callbacksLength =
'subscribeToObjectTypeUpdate', subscribedCallbacksRef.current.get(callbacksRefKey).length
{ objectType: objectType },
(result) => { if (callbacksLength <= 0) {
if (result.success) { socketRef.current.emit(
logger.info('Subscribed to objectType:', objectType) 'subscribeToObjectTypeUpdate',
{ objectType: objectType, filter: filter },
(result) => {
if (result.success) {
logger.info('Subscribed to objectType:', objectType, filter)
}
} }
} )
}
subscribedCallbacksRef.current.get(callbacksRefKey).push(callback)
logger.debug(
`Added callback for type ${callbacksRefKey}, total callbacks: ${callbacksLength + 1}`
)
logger.debug(
'Registered type event listener for object:',
callbacksRefKey
) )
logger.debug('Registered type event listener for object:', objectType)
// Return cleanup function // Return cleanup function
return () => offObjectTypeUpdatesEvent(objectType, callback) return () => offObjectTypeUpdatesEvent(objectType, filter, callback)
} }
}, },
[offObjectTypeUpdatesEvent] [offObjectTypeUpdatesEvent]
@ -708,7 +766,7 @@ const ApiServerProvider = ({ children }) => {
// Generalized fetchObject function // Generalized fetchObject function
const fetchObject = async (id, type) => { const fetchObject = async (id, type) => {
const fetchUrl = `${config.backendUrl}/${type}s/${id}` const fetchUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}`
setFetchLoading(true) setFetchLoading(true)
logger.debug('Fetching from ' + fetchUrl) logger.debug('Fetching from ' + fetchUrl)
try { try {
@ -738,21 +796,37 @@ const ApiServerProvider = ({ children }) => {
sorter = {}, sorter = {},
onDataChange onDataChange
} = params } = params
let newFilter = { ...filter }
if (filter != null && Object.keys(filter).length > 0) {
const model = getModelByName(type)
for (const key of Object.keys(filter)) {
const property = model?.properties?.find((p) => p.name === key)
if (property && property.type === 'object') {
const value = filter[key]
newFilter[`${key}._id`] = value?._id ?? value
delete newFilter[key]
}
}
}
logger.debug('Fetching table data from:', type, { logger.debug('Fetching table data from:', type, {
page, page,
limit, limit,
filter, newFilter,
sorter sorter
}) })
try { try {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/${type.toLowerCase()}s`, `${config.backendUrl}/${getObjectEndpoint(type)}`,
{ {
params: { params: {
page, page,
limit, limit,
...filter, ...newFilter,
sort: sorter.field, sort: sorter.field,
order: sorter.order order: sorter.order
}, },
@ -799,7 +873,7 @@ const ApiServerProvider = ({ children }) => {
try { try {
const response = await axios.get( const response = await axios.get(
`${config.backendUrl}/${type.toLowerCase()}s/properties`, `${config.backendUrl}/${getObjectEndpoint(type)}/properties`,
{ {
params: { params: {
...Object.keys(filter).reduce((acc, key) => { ...Object.keys(filter).reduce((acc, key) => {
@ -833,7 +907,7 @@ const ApiServerProvider = ({ children }) => {
// Update filament information // Update filament information
const updateObject = async (id, type, value) => { const updateObject = async (id, type, value) => {
const updateUrl = `${config.backendUrl}/${type.toLowerCase()}s/${id}` const updateUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}`
logger.debug('Updating info for ' + id) logger.debug('Updating info for ' + id)
try { try {
const response = await axios.put(updateUrl, value, { const response = await axios.put(updateUrl, value, {
@ -855,7 +929,7 @@ const ApiServerProvider = ({ children }) => {
// Update multiple objects // Update multiple objects
const updateMultipleObjects = async (type, objects) => { const updateMultipleObjects = async (type, objects) => {
const updateUrl = `${config.backendUrl}/${type.toLowerCase()}s` const updateUrl = `${config.backendUrl}/${getObjectEndpoint(type)}`
logger.debug('Updating multiple objects for ' + type) logger.debug('Updating multiple objects for ' + type)
try { try {
const response = await axios.put(updateUrl, objects, { const response = await axios.put(updateUrl, objects, {
@ -877,7 +951,7 @@ const ApiServerProvider = ({ children }) => {
// Update filament information // Update filament information
const deleteObject = async (id, type) => { const deleteObject = async (id, type) => {
const deleteUrl = `${config.backendUrl}/${type.toLowerCase()}s/${id}` const deleteUrl = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}`
logger.debug('Deleting object ID: ' + id) logger.debug('Deleting object ID: ' + id)
try { try {
const response = await axios.delete(deleteUrl, { const response = await axios.delete(deleteUrl, {
@ -899,7 +973,7 @@ const ApiServerProvider = ({ children }) => {
// Update filament information // Update filament information
const createObject = async (type, value) => { const createObject = async (type, value) => {
const createUrl = `${config.backendUrl}/${type.toLowerCase()}s` const createUrl = `${config.backendUrl}/${getObjectEndpoint(type)}`
logger.debug('Creating object...') logger.debug('Creating object...')
try { try {
const response = await axios.post(createUrl, value, { const response = await axios.post(createUrl, value, {
@ -920,7 +994,7 @@ const ApiServerProvider = ({ children }) => {
// Call a function on an object // Call a function on an object
const sendObjectFunction = async (id, type, functionName, value = {}) => { const sendObjectFunction = async (id, type, functionName, value = {}) => {
const url = `${config.backendUrl}/${type.toLowerCase()}s/${id}/${functionName}` const url = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}/${functionName}`
logger.debug(`Calling object function ${functionName} for ${id} at ${url}`) logger.debug(`Calling object function ${functionName} for ${id} at ${url}`)
try { try {
const response = await axios.post(url, value, { const response = await axios.post(url, value, {
@ -940,7 +1014,7 @@ const ApiServerProvider = ({ children }) => {
} }
const getObjectFunction = async (id, type, functionName, params = {}) => { const getObjectFunction = async (id, type, functionName, params = {}) => {
const url = `${config.backendUrl}/${type.toLowerCase()}s/${id}/${functionName}` const url = `${config.backendUrl}/${getObjectEndpoint(type)}/${id}/${functionName}`
logger.debug(`Fetching object function ${functionName} for ${id} at ${url}`) logger.debug(`Fetching object function ${functionName} for ${id} at ${url}`)
try { try {
const response = await axios.get(url, { const response = await axios.get(url, {
@ -1165,7 +1239,7 @@ const ApiServerProvider = ({ children }) => {
if (objectType === 'history') { if (objectType === 'history') {
statsUrl = `${config.backendUrl}/stats/history` statsUrl = `${config.backendUrl}/stats/history`
} else { } else {
statsUrl = `${config.backendUrl}/${objectType.toLowerCase()}s/stats` statsUrl = `${config.backendUrl}/${getObjectEndpoint(objectType)}/stats`
} }
const response = await axios.get(statsUrl, { const response = await axios.get(statsUrl, {
@ -1190,7 +1264,7 @@ const ApiServerProvider = ({ children }) => {
const encodedStartDate = encodeURIComponent(startDate.toISOString()) const encodedStartDate = encodeURIComponent(startDate.toISOString())
const encodedEndDate = encodeURIComponent(endDate.toISOString()) const encodedEndDate = encodeURIComponent(endDate.toISOString())
try { try {
const historyUrl = `${config.backendUrl}/${objectType.toLowerCase()}s/history?from=${encodedStartDate}&to=${encodedEndDate}` const historyUrl = `${config.backendUrl}/${getObjectEndpoint(objectType)}/history?from=${encodedStartDate}&to=${encodedEndDate}`
const response = await axios.get(historyUrl, { const response = await axios.get(historyUrl, {
headers: { headers: {

View File

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

View File

@ -7,6 +7,7 @@ import { Spool } from './models/Spool'
import { GCodeFile } from './models/GCodeFile' import { GCodeFile } from './models/GCodeFile'
import { Job } from './models/Job' import { Job } from './models/Job'
import { Product } from './models/Product' import { Product } from './models/Product'
import { ProductCategory } from './models/ProductCategory'
import { ProductSku } from './models/ProductSku' import { ProductSku } from './models/ProductSku'
import { Part } from './models/Part.js' import { Part } from './models/Part.js'
import { PartSku } from './models/PartSku.js' import { PartSku } from './models/PartSku.js'
@ -56,6 +57,7 @@ export const objectModels = [
GCodeFile, GCodeFile,
Job, Job,
Product, Product,
ProductCategory,
ProductSku, ProductSku,
Part, Part,
PartSku, PartSku,
@ -106,6 +108,7 @@ export {
GCodeFile, GCodeFile,
Job, Job,
Product, Product,
ProductCategory,
ProductSku, ProductSku,
Part, Part,
PartSku, PartSku,

View File

@ -70,7 +70,15 @@ export const File = {
} }
], ],
url: (id) => `/dashboard/management/files/info?fileId=${id}`, url: (id) => `/dashboard/management/files/info?fileId=${id}`,
columns: ['_reference', 'name', 'type', 'size', 'temp', 'createdAt'], columns: [
'_reference',
'name',
'type',
'size',
'temp',
'createdAt',
'updatedAt'
],
filters: ['name', '_id', 'type', 'temp'], filters: ['name', '_id', 'type', 'temp'],
sorters: ['name', 'type', 'size', 'createdAt', 'temp'], sorters: ['name', 'type', 'size', 'createdAt', 'temp'],
group: ['type'], group: ['type'],
@ -121,7 +129,7 @@ export const File = {
type: 'text', type: 'text',
readOnly: true, readOnly: true,
required: true, required: true,
columnWidth: 120 columnWidth: 190
}, },
{ {
name: 'size', name: 'size',

View File

@ -82,8 +82,23 @@ export const GCodeFile = {
'gcodeFileInfo.hotPlateTemp', 'gcodeFileInfo.hotPlateTemp',
'updatedAt' 'updatedAt'
], ],
filters: ['_id', 'name', 'filament', 'filament._id', 'filamentSku', 'cost', 'updatedAt'], filters: [
sorters: ['name', 'filament', 'filamentSku', 'cost', 'createdAt', 'updatedAt'], '_id',
'name',
'filament',
'filament._id',
'filamentSku',
'cost',
'updatedAt'
],
sorters: [
'name',
'filament',
'filamentSku',
'cost',
'createdAt',
'updatedAt'
],
group: ['filament', 'filamentSku'], group: ['filament', 'filamentSku'],
properties: [ properties: [
{ {
@ -255,6 +270,7 @@ export const GCodeFile = {
label: 'Parts', label: 'Parts',
type: 'objectChildren', type: 'objectChildren',
objectType: 'part', objectType: 'part',
size: 'medium',
properties: [ properties: [
{ {
name: 'part', name: 'part',

View File

@ -3,6 +3,7 @@ import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon' import EditIcon from '../../components/Icons/EditIcon'
import CheckIcon from '../../components/Icons/CheckIcon' import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon' import XMarkIcon from '../../components/Icons/XMarkIcon'
import BinIcon from '../../components/Icons/BinIcon'
import OTPIcon from '../../components/Icons/OTPIcon' import OTPIcon from '../../components/Icons/OTPIcon'
export const Host = { export const Host = {
@ -60,6 +61,14 @@ export const Host = {
icon: OTPIcon, icon: OTPIcon,
url: (_id) => url: (_id) =>
`/dashboard/management/hosts/info?hostId=${_id}&action=hostOTP` `/dashboard/management/hosts/info?hostId=${_id}&action=hostOTP`
},
{
name: 'delete',
label: 'Delete',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/management/hosts/info?hostId=${_id}&action=delete`
} }
], ],
columns: ['_reference', 'name', 'state', 'tags', 'connectedAt'], columns: ['_reference', 'name', 'state', 'tags', 'connectedAt'],

View File

@ -68,6 +68,7 @@ export const Product = {
columns: [ columns: [
'_reference', '_reference',
'name', 'name',
'productCategory',
'tags', 'tags',
'vendor', 'vendor',
'cost', 'cost',
@ -77,8 +78,30 @@ export const Product = {
'createdAt', 'createdAt',
'updatedAt' 'updatedAt'
], ],
filters: ['_id', 'name', 'type', 'color', 'vendor', 'cost', 'costWithTax', 'price', 'priceWithTax'], filters: [
sorters: ['name', 'createdAt', 'type', 'vendor', 'cost', 'costWithTax', 'price', 'priceWithTax', 'updatedAt'], '_id',
'name',
'productCategory',
'type',
'color',
'vendor',
'cost',
'costWithTax',
'price',
'priceWithTax'
],
sorters: [
'name',
'productCategory',
'createdAt',
'type',
'vendor',
'cost',
'costWithTax',
'price',
'priceWithTax',
'updatedAt'
],
properties: [ properties: [
{ {
name: '_id', name: '_id',
@ -120,6 +143,15 @@ export const Product = {
readOnly: true, readOnly: true,
columnWidth: 175 columnWidth: 175
}, },
{
name: 'productCategory',
label: 'Product Category',
required: true,
type: 'object',
objectType: 'productCategory',
showHyperlink: true,
columnWidth: 200
},
{ {
name: 'vendor', name: 'vendor',
label: 'Vendor', label: 'Vendor',

View File

@ -0,0 +1,113 @@
import ProductCategoryIcon from '../../components/Icons/ProductCategoryIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon'
import BinIcon from '../../components/Icons/BinIcon'
export const ProductCategory = {
name: 'productCategory',
label: 'Product Category',
prefix: 'PCG',
endpoint: 'productcategories',
icon: ProductCategoryIcon,
actions: [
{
name: 'info',
label: 'Info',
default: true,
row: true,
icon: InfoCircleIcon,
url: (_id) =>
`/dashboard/management/productcategories/info?productCategoryId=${_id}`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/management/productcategories/info?productCategoryId=${_id}&action=edit`,
visible: (objectData) => {
return !(objectData?._isEditing && objectData?._isEditing == true)
}
},
{
name: 'finishEdit',
label: 'Save Edits',
icon: CheckIcon,
url: (_id) =>
`/dashboard/management/productcategories/info?productCategoryId=${_id}&action=finishEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{
name: 'cancelEdit',
label: 'Cancel Edits',
icon: XMarkIcon,
url: (_id) =>
`/dashboard/management/productcategories/info?productCategoryId=${_id}&action=cancelEdit`,
visible: (objectData) => {
return objectData?._isEditing && objectData?._isEditing == true
}
},
{ type: 'divider' },
{
name: 'delete',
label: 'Delete',
icon: BinIcon,
danger: true,
url: (_id) =>
`/dashboard/management/productcategories/info?productCategoryId=${_id}&action=delete`
}
],
url: (id) =>
`/dashboard/management/productcategories/info?productCategoryId=${id}`,
columns: ['_reference', 'name', 'createdAt', 'updatedAt'],
filters: ['_id', 'name'],
sorters: ['name', 'createdAt', 'updatedAt', '_id'],
properties: [
{
name: '_id',
label: 'ID',
columnFixed: 'left',
type: 'id',
objectType: 'productCategory',
showCopy: true,
readOnly: true,
columnWidth: 140
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: '_reference',
label: 'Reference',
type: 'reference',
columnFixed: 'left',
objectType: 'productCategory',
showCopy: true,
readOnly: true
},
{
name: 'name',
label: 'Name',
columnFixed: 'left',
required: true,
type: 'text',
columnWidth: 200
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
}
]
}

View File

@ -172,8 +172,26 @@ export const PurchaseOrder = {
} }
], ],
group: ['vendor'], group: ['vendor'],
filters: ['vendor'], filters: [
sorters: ['createdAt', 'state', 'updatedAt'], 'vendor',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount'
],
sorters: [
'createdAt',
'state',
'updatedAt',
'totalAmount',
'totalAmountWithTax',
'totalTaxAmount',
'shippingAmount',
'shippingAmountWithTax',
'grandTotalAmount'
],
columns: [ columns: [
'_reference', '_reference',
'state', 'state',
@ -260,7 +278,7 @@ export const PurchaseOrder = {
prefix: '£', prefix: '£',
roundNumber: 2, roundNumber: 2,
readOnly: true, readOnly: true,
columnWidth: 175 columnWidth: 185
}, },
{ {
name: 'completedAt', name: 'completedAt',
@ -275,7 +293,7 @@ export const PurchaseOrder = {
type: 'number', type: 'number',
prefix: '£', prefix: '£',
readOnly: true, readOnly: true,
columnWidth: 175, columnWidth: 215,
roundNumber: 2 roundNumber: 2
}, },
{ {
@ -285,7 +303,7 @@ export const PurchaseOrder = {
prefix: '£', prefix: '£',
roundNumber: 2, roundNumber: 2,
readOnly: true, readOnly: true,
columnWidth: 150 columnWidth: 190
}, },
{ {
name: 'shippingAmountWithTax', name: 'shippingAmountWithTax',
@ -294,7 +312,7 @@ export const PurchaseOrder = {
prefix: '£', prefix: '£',
readOnly: true, readOnly: true,
roundNumber: 2, roundNumber: 2,
columnWidth: 200 columnWidth: 240
}, },
{ {
name: 'totalAmount', name: 'totalAmount',
@ -303,7 +321,7 @@ export const PurchaseOrder = {
prefix: '£', prefix: '£',
roundNumber: 2, roundNumber: 2,
readOnly: true, readOnly: true,
columnWidth: 150 columnWidth: 170
}, },
{ {
name: 'grandTotalAmount', name: 'grandTotalAmount',
@ -311,7 +329,7 @@ export const PurchaseOrder = {
type: 'number', type: 'number',
prefix: '£', prefix: '£',
roundNumber: 2, roundNumber: 2,
columnWidth: 175, columnWidth: 215,
readOnly: true readOnly: true
} }
], ],

View File

@ -170,7 +170,7 @@ export const SalesOrder = {
} }
} }
], ],
group: ['client', 'marketplace'], group: ['client'],
filters: ['client', 'marketplace'], filters: ['client', 'marketplace'],
sorters: ['createdAt', 'state', 'updatedAt'], sorters: ['createdAt', 'state', 'updatedAt'],
columns: [ columns: [

View File

@ -85,9 +85,9 @@ export const StockTransfer = {
} }
], ],
url: (id) => `/dashboard/inventory/stocktransfers/info?stockTransferId=${id}`, url: (id) => `/dashboard/inventory/stocktransfers/info?stockTransferId=${id}`,
filters: ['_id', 'state'], filters: ['_id', 'name', 'state'],
sorters: ['createdAt', 'postedAt'], sorters: ['name', 'createdAt', 'postedAt'],
columns: ['_reference', 'state', 'postedAt', 'createdAt', 'updatedAt'], columns: ['_reference', 'name', 'state', 'postedAt', 'createdAt', 'updatedAt'],
properties: [ properties: [
{ {
name: '_id', name: '_id',
@ -114,6 +114,14 @@ export const StockTransfer = {
showCopy: true, showCopy: true,
readOnly: true readOnly: true
}, },
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
columnWidth: 220,
columnFixed: 'left'
},
{ {
name: 'state', name: 'state',
label: 'State', label: 'State',
@ -177,6 +185,38 @@ export const StockTransfer = {
suffix: (row) => suffix: (row) =>
row?.fromStockType === 'filamentStock' ? 'g net' : null row?.fromStockType === 'filamentStock' ? 'g net' : null
}, },
{
name: 'available',
label: 'Available',
type: 'number',
readOnly: true,
columnWidth: 140,
value: (row) => {
if (row?.fromStockType === 'filamentStock') {
return row?.fromStock?.currentWeight?.net ?? 0
} else {
return row?.fromStock?.currentQuantity ?? 0
}
},
suffix: (row) =>
row?.fromStockType === 'filamentStock' ? 'g net' : null
},
{
name: 'remaining',
label: 'Remaining',
type: 'number',
readOnly: true,
columnWidth: 140,
value: (row) => {
const quantity = row?.quantity ?? 0
if (row?.fromStockType === 'filamentStock') {
return (row?.fromStock?.currentWeight?.net ?? 0) - quantity
}
return (row?.fromStock?.currentQuantity ?? 0) - quantity
},
suffix: (row) =>
row?.fromStockType === 'filamentStock' ? 'g net' : null
},
{ {
name: 'toStockLocation', name: 'toStockLocation',
label: 'To location', label: 'To location',

View File

@ -11,6 +11,8 @@ const PartSkus = lazy(() => import('../components/Dashboard/Management/PartSkus.
const PartSkuInfo = lazy(() => import('../components/Dashboard/Management/PartSkus/PartSkuInfo.jsx')) const PartSkuInfo = lazy(() => import('../components/Dashboard/Management/PartSkus/PartSkuInfo.jsx'))
const Products = lazy(() => import('../components/Dashboard/Management/Products.jsx')) const Products = lazy(() => import('../components/Dashboard/Management/Products.jsx'))
const ProductInfo = lazy(() => import('../components/Dashboard/Management/Products/ProductInfo.jsx')) const ProductInfo = lazy(() => import('../components/Dashboard/Management/Products/ProductInfo.jsx'))
const ProductCategories = lazy(() => import('../components/Dashboard/Management/ProductCategories.jsx'))
const ProductCategoryInfo = lazy(() => import('../components/Dashboard/Management/ProductCategories/ProductCategoryInfo.jsx'))
const ProductSkus = lazy(() => import('../components/Dashboard/Management/ProductSkus.jsx')) const ProductSkus = lazy(() => import('../components/Dashboard/Management/ProductSkus.jsx'))
const ProductSkuInfo = lazy(() => import('../components/Dashboard/Management/ProductSkus/ProductSkuInfo.jsx')) const ProductSkuInfo = lazy(() => import('../components/Dashboard/Management/ProductSkus/ProductSkuInfo.jsx'))
const Vendors = lazy(() => import('../components/Dashboard/Management/Vendors')) const Vendors = lazy(() => import('../components/Dashboard/Management/Vendors'))
@ -79,6 +81,16 @@ const ManagementRoutes = [
path='management/products/info' path='management/products/info'
element={<ProductInfo />} element={<ProductInfo />}
/>, />,
<Route
key='productcategories'
path='management/productcategories'
element={<ProductCategories />}
/>,
<Route
key='productcategories-info'
path='management/productcategories/info'
element={<ProductCategoryInfo />}
/>,
<Route key='productskus' path='management/productskus' element={<ProductSkus />} />, <Route key='productskus' path='management/productskus' element={<ProductSkus />} />,
<Route <Route
key='productskus-info' key='productskus-info'