Implemented product stocks.
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-03-07 13:37:26 +00:00
parent 9b3de96be3
commit 112cbb5ce8
8 changed files with 625 additions and 69 deletions

View File

@ -0,0 +1,107 @@
// src/components/Dashboard/Inventory/ProductStocks.jsx
// ProductStocks - tracks assembled products consisting of part stocks
import { useState, useRef } from 'react'
import { Button, Flex, Space, Modal, Dropdown } from 'antd'
import NewProductStock from './ProductStocks/NewProductStock'
import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import ObjectTable from '../common/ObjectTable'
import ListIcon from '../../Icons/ListIcon'
import GridIcon from '../../Icons/GridIcon'
import useViewMode from '../hooks/useViewMode'
import ColumnViewButton from '../common/ColumnViewButton'
import ExportListButton from '../common/ExportListButton'
const ProductStocks = () => {
const tableRef = useRef()
const [newProductStockOpen, setNewProductStockOpen] = useState(false)
const [viewMode, setViewMode] = useViewMode('productStocks')
const [columnVisibility, setColumnVisibility] =
useColumnVisibility('productStock')
const actionItems = {
items: [
{
label: 'New Product Stock',
key: 'newProductStock',
icon: <PlusIcon />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
tableRef.current?.reload()
} else if (key === 'newProductStock') {
setNewProductStockOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<ColumnViewButton
type='productStock'
loading={false}
visibleState={columnVisibility}
updateVisibleState={setColumnVisibility}
/>
<ExportListButton objectType='productStock' />
</Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
onClick={() =>
setViewMode(viewMode === 'cards' ? 'list' : 'cards')
}
/>
</Space>
</Flex>
<ObjectTable
ref={tableRef}
visibleColumns={columnVisibility}
type='productStock'
cards={viewMode === 'cards'}
/>
</Flex>
<Modal
open={newProductStockOpen}
styles={{ content: { paddingBottom: '24px' } }}
footer={null}
width={800}
onCancel={() => {
setNewProductStockOpen(false)
}}
destroyOnHidden={true}
>
<NewProductStock
onOk={() => {
setNewProductStockOpen(false)
tableRef.current?.reload()
}}
reset={newProductStockOpen}
/>
</Modal>
</>
)
}
export default ProductStocks

View File

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

View File

@ -0,0 +1,237 @@
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.jsx'
import NotesPanel from '../../common/NotesPanel.jsx'
import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton.jsx'
import { getModelProperty } from '../../../../database/ObjectModels.js'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import PartStockIcon from '../../../Icons/PartStockIcon.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 UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('ProductStockInfo')
log.setLevel(config.logLevel)
const ProductStockInfo = () => {
const location = useLocation()
const objectFormRef = useRef(null)
const actionHandlerRef = useRef(null)
const productStockId = new URLSearchParams(location.search).get(
'productStockId'
)
const [collapseState, updateCollapseState] = useCollapseState(
'ProductStockInfo',
{
info: true,
partStocks: true,
notes: true,
auditLogs: true
}
)
const [objectFormState, setEditFormState] = useState({
isEditing: false,
editLoading: false,
formValid: false,
locked: false,
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
}
}
return (
<>
<Flex
gap='large'
vertical='true'
style={{
maxHeight: '100%',
minHeight: 0
}}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='productStock'
id={productStockId}
disabled={objectFormState.loading}
objectData={objectFormState.objectData}
/>
<ViewButton
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Product Stock Information' },
{ key: 'partStocks', label: 'Part Stocks' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
<UserNotifierToggle
type='productStock'
objectData={objectFormState.objectData}
disabled={objectFormState.loading}
/>
<DocumentPrintButton
type='productStock'
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={productStockId}
type='productStock'
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<Flex vertical gap={'large'}>
<InfoCollapse
title='Product Stock Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='productStock'
objectData={objectData}
labelWidth='175px'
visibleProperties={{ partStocks: false }}
/>
</InfoCollapse>
<InfoCollapse
title='Part Stocks'
icon={<PartStockIcon />}
active={collapseState.partStocks}
onToggle={(expanded) =>
updateCollapseState('partStocks', expanded)
}
collapseKey='partStocks'
>
<ObjectProperty
{...getModelProperty('productStock', 'partStocks')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
size='medium'
/>
</InfoCollapse>
</Flex>
)}
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
collapseKey='notes'
>
<Card>
<NotesPanel _id={productStockId} type='productStock' />
</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': productStockId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</ScrollBox>
</Flex>
</>
)
}
export default ProductStockInfo

View File

@ -51,6 +51,7 @@ const ObjectChildTable = ({
properties = [], properties = [],
columns = [], columns = [],
visibleColumns = {}, visibleColumns = {},
canAddRemove = true,
objectData = null, objectData = null,
scrollHeight = 240, scrollHeight = 240,
size = 'small', size = 'small',
@ -154,56 +155,57 @@ const ObjectChildTable = ({
} }
})) }))
const deleteColumn = isEditing const deleteColumn =
? { isEditing && canAddRemove
title: '', ? {
key: 'delete', title: '',
width: 10, key: 'delete',
fixed: 'right', width: 10,
render: (_text, record, index) => { fixed: 'right',
if (record?.isSkeleton) { render: (_text, record, index) => {
return null if (record?.isSkeleton) {
return null
}
return (
<Button
type='text'
danger
size='small'
icon={<BinIcon />}
onClick={(e) => {
e.stopPropagation()
const currentItems = Array.isArray(itemsSource)
? itemsSource
: []
// Use record's unique identifier if available, otherwise use index
let newItems
if (typeof rowKey === 'string' && record[rowKey] != null) {
// Use the unique key to find and remove the item
newItems = currentItems.filter(
(item) => item[rowKey] !== record[rowKey]
)
} else if (typeof rowKey === 'function') {
// If rowKey is a function, find the item by comparing the resolved keys
const recordKey = rowKey(record, index)
newItems = currentItems.filter((item, i) => {
const itemKey = rowKey(item, i)
return itemKey !== recordKey
})
} else {
// Fallback to index-based removal
newItems = currentItems.filter((_, i) => i !== index)
}
if (typeof onChange === 'function') {
onChange(newItems)
}
}}
/>
)
} }
return (
<Button
type='text'
danger
size='small'
icon={<BinIcon />}
onClick={(e) => {
e.stopPropagation()
const currentItems = Array.isArray(itemsSource)
? itemsSource
: []
// Use record's unique identifier if available, otherwise use index
let newItems
if (typeof rowKey === 'string' && record[rowKey] != null) {
// Use the unique key to find and remove the item
newItems = currentItems.filter(
(item) => item[rowKey] !== record[rowKey]
)
} else if (typeof rowKey === 'function') {
// If rowKey is a function, find the item by comparing the resolved keys
const recordKey = rowKey(record, index)
newItems = currentItems.filter((item, i) => {
const itemKey = rowKey(item, i)
return itemKey !== recordKey
})
} else {
// Fallback to index-based removal
newItems = currentItems.filter((_, i) => i !== index)
}
if (typeof onChange === 'function') {
onChange(newItems)
}
}}
/>
)
} }
} : null
: null
return [ return [
...propertyColumns, ...propertyColumns,
@ -335,17 +337,18 @@ const ObjectChildTable = ({
} }
} }
}) })
const blankDeleteColumn = isEditing const blankDeleteColumn =
? { isEditing && canAddRemove
title: '', ? {
key: 'delete', title: '',
width: 40, key: 'delete',
fixed: 'right', width: 40,
render: () => { fixed: 'right',
return <Flex></Flex> render: () => {
return <Flex></Flex>
}
} }
} : null
: null
return [ return [
...propertyColumns, ...propertyColumns,
...(blankDeleteColumn ? [blankDeleteColumn] : []) ...(blankDeleteColumn ? [blankDeleteColumn] : [])
@ -410,7 +413,7 @@ const ObjectChildTable = ({
dataSource={rollupDataSource} dataSource={rollupDataSource}
showHeader={false} showHeader={false}
columns={rollupColumns} columns={rollupColumns}
loading={loading} loading={{ spinning: loading, indicator: <></> }}
pagination={false} pagination={false}
size={size} size={size}
rowKey={resolvedRowKey} rowKey={resolvedRowKey}
@ -449,12 +452,16 @@ const ObjectChildTable = ({
<Flex vertical gap={'middle'}> <Flex vertical gap={'middle'}>
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
<Button>Actions</Button> <Button>Actions</Button>
<Button
type='primary' {canAddRemove && (
icon={<PlusIcon />} <Button
onClick={handleAddItem} type='primary'
/> icon={<PlusIcon />}
onClick={handleAddItem}
/>
)}
</Flex> </Flex>
{tableComponent} {tableComponent}
</Flex> </Flex>
</Card> </Card>
@ -487,7 +494,8 @@ ObjectChildTable.propTypes = {
onChange: PropTypes.func, onChange: PropTypes.func,
maxWidth: PropTypes.string, maxWidth: PropTypes.string,
rollups: PropTypes.arrayOf(PropTypes.object), rollups: PropTypes.arrayOf(PropTypes.object),
objectData: PropTypes.object objectData: PropTypes.object,
canAddRemove: PropTypes.bool
} }
export default ObjectChildTable export default ObjectChildTable

View File

@ -91,6 +91,7 @@ const ObjectProperty = ({
options = [], options = [],
roundNumber = false, roundNumber = false,
fixedNumber = false, fixedNumber = false,
canAddRemove = true,
showHyperlink, showHyperlink,
showSince, showSince,
properties = [], properties = [],
@ -415,6 +416,7 @@ const ObjectProperty = ({
loading={loading} loading={loading}
rollups={rollups} rollups={rollups}
size={size} size={size}
canAddRemove={canAddRemove}
/> />
) )
} }
@ -804,6 +806,7 @@ const ObjectProperty = ({
isEditing={true} isEditing={true}
rollups={rollups} rollups={rollups}
size={size} size={size}
canAddRemove={canAddRemove}
{...inputProps} {...inputProps}
/> />
) )
@ -868,7 +871,8 @@ ObjectProperty.propTypes = {
options: PropTypes.array, options: PropTypes.array,
showSince: PropTypes.bool, showSince: PropTypes.bool,
loading: PropTypes.bool, loading: PropTypes.bool,
rollups: PropTypes.arrayOf(PropTypes.object) rollups: PropTypes.arrayOf(PropTypes.object),
canAddRemove: PropTypes.bool
} }
export default ObjectProperty export default ObjectProperty

View File

@ -55,6 +55,11 @@ const ObjectSelect = ({
const prevValueRef = useRef(value) const prevValueRef = useRef(value)
const isInternalChangeRef = useRef(false) const isInternalChangeRef = useRef(false)
useEffect(() => {
console.log('type', type)
console.log('value', value)
}, [value, type])
// Normalize a value to an identity string so we can detect in-place _id updates // Normalize a value to an identity string so we can detect in-place _id updates
const getValueIdentity = useCallback((val) => { const getValueIdentity = useCallback((val) => {
if (val && typeof val === 'object') { if (val && typeof val === 'object') {

View File

@ -2,7 +2,7 @@ import ProductStockIcon from '../../components/Icons/ProductStockIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
export const ProductStock = { export const ProductStock = {
name: 'productstock', name: 'productStock',
label: 'Product Stock', label: 'Product Stock',
prefix: 'PDS', prefix: 'PDS',
icon: ProductStockIcon, icon: ProductStockIcon,
@ -14,8 +14,107 @@ export const ProductStock = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => url: (_id) =>
`/dashboard/management/productstocks/info?productStockId=${_id}` `/dashboard/inventory/productstocks/info?productStockId=${_id}`
} }
], ],
url: (id) => `/dashboard/management/productstocks/info?productStockId=${id}` url: (id) => `/dashboard/inventory/productstocks/info?productStockId=${id}`,
filters: ['_id', 'product', 'currentQuantity'],
sorters: ['product', 'currentQuantity'],
columns: [
'_reference',
'state',
'currentQuantity',
'product',
'createdAt',
'updatedAt'
],
properties: [
{
name: '_id',
label: 'ID',
type: 'id',
objectType: 'productStock',
showCopy: true,
readOnly: true
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true
},
{
name: 'state',
label: 'State',
type: 'state',
readOnly: true,
columnWidth: 120
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true
},
{
name: 'product',
label: 'Product',
type: 'object',
objectType: 'product',
required: true,
showHyperlink: true
},
{
name: 'currentQuantity',
label: 'Current Quantity',
type: 'number',
columnWidth: 200,
required: true
},
{
name: 'partStocks',
label: 'Part Stocks',
type: 'objectChildren',
canAddRemove: false,
properties: [
{
name: 'part',
label: 'Part',
type: 'object',
objectType: 'part',
readOnly: true,
required: true,
showHyperlink: true
},
{
name: 'partStock',
label: 'Part Stock',
type: 'object',
objectType: 'partStock',
required: true,
showHyperlink: true,
masterFilter: (objectData) => {
const partId = objectData?.part?._id
if (partId == null) return {}
return { 'part._id': partId }
}
},
{
name: 'quantity',
label: 'Quantity',
type: 'number',
required: true
}
]
}
],
stats: [
{
name: 'totalCurrentQuantity.sum',
label: 'Total Current Quantity',
type: 'number',
roundNumber: 2,
cardWidth: 200
}
]
} }

View File

@ -11,6 +11,15 @@ const FilamentStockInfo = lazy(
const PartStocks = lazy( const PartStocks = lazy(
() => import('../components/Dashboard/Inventory/PartStocks.jsx') () => import('../components/Dashboard/Inventory/PartStocks.jsx')
) )
const ProductStocks = lazy(
() => import('../components/Dashboard/Inventory/ProductStocks.jsx')
)
const ProductStockInfo = lazy(
() =>
import(
'../components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx'
)
)
const PartStockInfo = lazy( const PartStockInfo = lazy(
() => import('../components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx') () => import('../components/Dashboard/Inventory/PartStocks/PartStockInfo.jsx')
) )
@ -73,6 +82,16 @@ const InventoryRoutes = [
path='inventory/partstocks/info' path='inventory/partstocks/info'
element={<PartStockInfo />} element={<PartStockInfo />}
/>, />,
<Route
key='productstocks'
path='inventory/productstocks'
element={<ProductStocks />}
/>,
<Route
key='productstocks-info'
path='inventory/productstocks/info'
element={<ProductStockInfo />}
/>,
<Route <Route
key='stockevents' key='stockevents'
path='inventory/stockevents' path='inventory/stockevents'