Refactor Product Stock Components and Update Property Names
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Renamed 'partStocks' to 'partStockList' in NewProductStock and ProductStockInfo components for consistency and clarity.
- Updated visible properties and object data handling to reflect the new naming convention.
- Refactored utility functions in NewObjectForm and ObjectForm to utilize a new mergeFormData function for improved data management.
- Enhanced ObjectInfo and ObjectProperty components by removing unnecessary state management and ensuring proper data flow.
- Adjusted layout properties in ObjectList and ObjectSelect components for better responsiveness and usability.
This commit is contained in:
Tom Butcher 2026-08-24 22:40:11 +01:00
parent c4beb3d3a0
commit e0a5ff3cf8
14 changed files with 447 additions and 217 deletions

View File

@ -24,7 +24,7 @@ const NewProductStock = ({ onOk, reset, defaultValues }) => {
required={true} required={true}
objectData={objectData} objectData={objectData}
visibleProperties={{ visibleProperties={{
partStocks: false partStockList: false
}} }}
/> />
) )
@ -42,7 +42,7 @@ const NewProductStock = ({ onOk, reset, defaultValues }) => {
_reference: false, _reference: false,
createdAt: false, createdAt: false,
updatedAt: false, updatedAt: false,
partStocks: false partStockList: false
}} }}
isEditing={false} isEditing={false}
objectData={objectData} objectData={objectData}

View File

@ -10,7 +10,10 @@ import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx' import ObjectInfo from '../../common/ObjectInfo.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx' import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton.jsx' import ViewButton from '../../common/ViewButton.jsx'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js' import {
getModelProperty,
getModelByName
} from '../../../../database/ObjectModels.js'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
@ -54,7 +57,7 @@ const ProductStockInfo = () => {
'ProductStockInfo', 'ProductStockInfo',
{ {
info: true, info: true,
partStocks: true, partStockList: true,
events: true, events: true,
history: true, history: true,
notes: true, notes: true,
@ -97,7 +100,7 @@ const ProductStockInfo = () => {
finishEdit: () => { finishEdit: () => {
objectFormRef?.current.handleUpdate() objectFormRef?.current.handleUpdate()
return true return true
}, }
} }
return ( return (
@ -123,7 +126,7 @@ const ProductStockInfo = () => {
disabled={objectFormState.loading} disabled={objectFormState.loading}
items={[ items={[
{ key: 'info', label: 'Product Stock Information' }, { key: 'info', label: 'Product Stock Information' },
{ key: 'partStocks', label: 'Part Stocks' }, { key: 'partStockList', label: 'Part Stock List' },
{ key: 'events', label: 'Product Stock Events' }, { key: 'events', label: 'Product Stock Events' },
{ key: 'history', label: 'Product Stock History' }, { key: 'history', label: 'Product Stock History' },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },
@ -207,20 +210,20 @@ const ProductStockInfo = () => {
type='productStock' type='productStock'
objectData={objectData} objectData={objectData}
labelWidth='175px' labelWidth='175px'
visibleProperties={{ partStocks: false }} visibleProperties={{ partStockList: false }}
/> />
</InfoCollapse> </InfoCollapse>
<InfoCollapse <InfoCollapse
title='Part Stocks' title='Part Stock List'
icon={<PartStockIcon />} icon={<PartStockIcon />}
active={collapseState.partStocks} active={collapseState.partStockList}
onToggle={(expanded) => onToggle={(expanded) =>
updateCollapseState('partStocks', expanded) updateCollapseState('partStockList', expanded)
} }
collapseKey='partStocks' collapseKey='partStockList'
> >
<ObjectProperty <ObjectProperty
{...getModelProperty('productStock', 'partStocks')} {...getModelProperty('productStock', 'partStockList')}
isEditing={isEditing} isEditing={isEditing}
objectData={objectData} objectData={objectData}
loading={loading} loading={loading}
@ -244,7 +247,10 @@ const ProductStockInfo = () => {
) : ( ) : (
<ObjectTable <ObjectTable
type='stockEvent' type='stockEvent'
masterFilter={{ parent: getModelByName('productStock').prefix + ':' + productStockId }} masterFilter={{
parent:
getModelByName('productStock').prefix + ':' + productStockId
}}
visibleColumns={{ parent: false }} visibleColumns={{ parent: false }}
/> />
)} )}
@ -281,7 +287,7 @@ const ProductStockInfo = () => {
indicator={<LoadingOutlined />} indicator={<LoadingOutlined />}
> >
<Card> <Card>
<NotesPanel _id={productStockId} type='productStock' /> <NotesPanel _id={productStockId} type='productStock' />
</Card> </Card>
</Spin> </Spin>
</InfoCollapse> </InfoCollapse>
@ -299,10 +305,8 @@ const ProductStockInfo = () => {
<ObjectTable <ObjectTable
type='auditLog' type='auditLog'
masterFilter={{ masterFilter={{
parent: parent:
getModelByName('productStock').prefix + getModelByName('productStock').prefix + ':' + productStockId
':' +
productStockId
}} }}
visibleColumns={{ _id: false, parent: false }} visibleColumns={{ _id: false, parent: false }}
/> />

View File

@ -3,10 +3,9 @@ import { Form } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { useMessageContext } from '../context/MessageContext' import { useMessageContext } from '../context/MessageContext'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import merge from 'lodash/merge'
import mergeWith from 'lodash/mergeWith'
import set from 'lodash/set' import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { mergeFormData } from '../utils/Utils'
const buildObjectFromEntries = (entries = []) => { const buildObjectFromEntries = (entries = []) => {
return entries.reduce((acc, entry) => { return entries.reduce((acc, entry) => {
@ -20,7 +19,7 @@ const buildObjectFromEntries = (entries = []) => {
} }
const applyComputedEntries = (base, entries = []) => { const applyComputedEntries = (base, entries = []) => {
const result = merge({}, base || {}) const result = mergeFormData(base || {})
entries.forEach((entry) => { entries.forEach((entry) => {
const { namePath, value } = entry || {} const { namePath, value } = entry || {}
if (!Array.isArray(namePath) || value === undefined) return if (!Array.isArray(namePath) || value === undefined) return
@ -29,15 +28,6 @@ const applyComputedEntries = (base, entries = []) => {
return result return result
} }
const arrayReplaceCustomizer = (objValue, srcValue, key) => {
if (Array.isArray(srcValue)) {
return srcValue
}
if (key === 'permissions' && srcValue !== undefined) {
return srcValue
}
}
/** /**
* NewObjectForm is a reusable form component for creating new objects. * NewObjectForm is a reusable form component for creating new objects.
* It handles form validation, submission, and error handling logic. * It handles form validation, submission, and error handling logic.
@ -188,9 +178,7 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
computedEntries computedEntries
) )
form.setFieldsValue(initialFormData) form.setFieldsValue(initialFormData)
setObjectData((prev) => setObjectData((prev) => mergeFormData(prev, initialFormData))
mergeWith({}, prev, initialFormData, arrayReplaceCustomizer)
)
return validateForm() return validateForm()
} }
}, [form, defaultValues, calculateComputedValues, model, validateForm]) }, [form, defaultValues, calculateComputedValues, model, validateForm])
@ -233,12 +221,7 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
style={style} style={style}
onValuesChange={(_changedValues, allFormValues) => { onValuesChange={(_changedValues, allFormValues) => {
// Calculate computed values based on current form data // Calculate computed values based on current form data
const currentFormData = mergeWith( const currentFormData = mergeFormData(objectData || {}, allFormValues)
{},
objectData || {},
allFormValues,
arrayReplaceCustomizer
)
const computedEntries = calculateComputedValues(currentFormData, model) const computedEntries = calculateComputedValues(currentFormData, model)
if (Array.isArray(computedEntries) && computedEntries.length > 0) { if (Array.isArray(computedEntries) && computedEntries.length > 0) {
@ -259,9 +242,7 @@ const NewObjectForm = ({ type, style, defaultValues = {}, children }) => {
} }
const allValues = applyComputedEntries(allFormValues, computedEntries) const allValues = applyComputedEntries(allFormValues, computedEntries)
setObjectData((prev) => { setObjectData((prev) => mergeFormData(prev, allValues))
return mergeWith({}, prev, allValues, arrayReplaceCustomizer)
})
}} }}
> >
{children({ {children({

View File

@ -11,22 +11,12 @@ import { Form } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { useMessageContext } from '../context/MessageContext' import { useMessageContext } from '../context/MessageContext'
import merge from 'lodash/merge'
import mergeWith from 'lodash/mergeWith'
import set from 'lodash/set' import set from 'lodash/set'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useActions } from '../context/ActionsContext' import { useActions } from '../context/ActionsContext'
import { mergeFormData } from '../utils/Utils'
const arrayReplaceCustomizer = (objValue, srcValue, key) => {
if (Array.isArray(srcValue)) {
return srcValue
}
if (key === 'permissions' && srcValue !== undefined) {
return srcValue
}
}
const getUserId = (user) => user?._id || user const getUserId = (user) => user?._id || user
@ -82,7 +72,7 @@ const buildObjectFromEntries = (entries = []) => {
// merge-replacing arrays would wipe stored child-row fields (shipment, amount, // merge-replacing arrays would wipe stored child-row fields (shipment, amount,
// etc.) while leaving calculated columns intact. // etc.) while leaving calculated columns intact.
const applyComputedEntries = (base, entries = []) => { const applyComputedEntries = (base, entries = []) => {
const result = merge({}, base || {}) const result = mergeFormData(base || {})
entries.forEach((entry) => { entries.forEach((entry) => {
const { namePath, value } = entry || {} const { namePath, value } = entry || {}
if (!Array.isArray(namePath) || value === undefined) return if (!Array.isArray(namePath) || value === undefined) return
@ -91,6 +81,21 @@ const applyComputedEntries = (base, entries = []) => {
return result return result
} }
const getObjectChildrenNames = (modelDefinition) =>
(modelDefinition?.properties || [])
.filter((property) => property?.type === 'objectChildren' && property?.name)
.map((property) => property.name)
const copyObjectChildren = (target, source, modelDefinition) => {
if (!target || !source) return target
getObjectChildrenNames(modelDefinition).forEach((name) => {
if (Object.prototype.hasOwnProperty.call(source, name)) {
target[name] = source[name]
}
})
return target
}
const getEditDisabled = (model, objectData, userProfile) => { const getEditDisabled = (model, objectData, userProfile) => {
if (model?.readOnly === true) { if (model?.readOnly === true) {
return true return true
@ -261,14 +266,16 @@ const ObjectForm = forwardRef(
// Function to calculate computed values from model properties // Function to calculate computed values from model properties
const calculateComputedValues = useCallback( const calculateComputedValues = useCallback(
(currentData, modelDefinition) => { (currentData, modelDefinition, options = {}) => {
if (!modelDefinition || !Array.isArray(modelDefinition.properties)) { if (!modelDefinition || !Array.isArray(modelDefinition.properties)) {
return [] return []
} }
const skipObjectChildrenValue = options.skipObjectChildrenValue === true
// Clone currentData to allow sequential updates // Clone currentData to allow sequential updates
// We use this working copy to calculate subsequent dependent values // We use this working copy to calculate subsequent dependent values
const workingData = merge({}, currentData) const workingData = mergeFormData(currentData)
const normalizedPath = (name, parentPath = []) => { const normalizedPath = (name, parentPath = []) => {
if (Array.isArray(name)) { if (Array.isArray(name)) {
@ -307,21 +314,25 @@ const ObjectForm = forwardRef(
: getValueAtPath(workingData, parentPath) : getValueAtPath(workingData, parentPath)
if (property.value && typeof property.value === 'function') { if (property.value && typeof property.value === 'function') {
try { const skipValue =
const computedValue = property.value(scopeData || {}) skipObjectChildrenValue && property.type === 'objectChildren'
if (computedValue !== undefined) { if (!skipValue) {
computedEntries.push({ try {
namePath: propertyPath, const computedValue = property.value(scopeData || {})
value: computedValue if (computedValue !== undefined) {
}) computedEntries.push({
// Update workingData so subsequent properties can use this value namePath: propertyPath,
set(workingData, propertyPath, computedValue) value: computedValue
})
// Update workingData so subsequent properties can use this value
set(workingData, propertyPath, computedValue)
}
} catch (error) {
console.warn(
`Error calculating value for property ${property.name}:`,
error
)
} }
} catch (error) {
console.warn(
`Error calculating value for property ${property.name}:`,
error
)
} }
} }
@ -478,62 +489,81 @@ const ObjectForm = forwardRef(
} }
}, []) // Empty dependency array - only run on mount/unmount }, []) // Empty dependency array - only run on mount/unmount
const handleFetchObject = useCallback(async () => { const handleFetchObject = useCallback(
const objectKey = `${type}:${id}` async (options = {}) => {
const objectKey = `${type}:${id}`
const skipObjectChildrenValue = options.skipObjectChildrenValue === true
try { try {
setFetchLoading(true) setFetchLoading(true)
onStateChangeRef.current({ loading: true }) onStateChangeRef.current({ loading: true })
const data = await fetchObject(id, type) const data = await fetchObject(id, type)
const initialActivities = await fetchObjectActivities(id, type) const initialActivities = await fetchObjectActivities(id, type)
if (fetchedObjectRef.current !== objectKey) { if (fetchedObjectRef.current !== objectKey) {
return return
}
setActivities(initialActivities)
if (
isEditingRef.current &&
getBeingEditedByOther(initialActivities, userProfile?._id)
) {
releaseEditingState()
}
serverObjectData.current = data
// Calculate and set computed values on initial load
const computedEntries = calculateComputedValues(data, model, {
skipObjectChildrenValue
})
const initialFormData = applyComputedEntries(data, computedEntries)
if (skipObjectChildrenValue) {
copyObjectChildren(initialFormData, data, model)
}
setObjectData({
...initialFormData,
_isEditing: isEditingRef.current
})
form.setFieldsValue(initialFormData)
setFetchLoading(false)
onStateChangeRef.current({ loading: false })
} catch (err) {
console.error(err)
showError(
`Failed to fetch ${getModelMessageLabel(type).toLowerCase()} info.`
)
} }
},
setActivities(initialActivities) [
fetchObject,
if ( fetchObjectActivities,
isEditingRef.current && id,
getBeingEditedByOther(initialActivities, userProfile?._id) type,
) { form,
releaseEditingState() showError,
} calculateComputedValues,
model,
serverObjectData.current = data userProfile?._id,
releaseEditingState
// Calculate and set computed values on initial load ]
const computedEntries = calculateComputedValues(data, model) )
const initialFormData = applyComputedEntries(data, computedEntries)
setObjectData({ ...initialFormData, _isEditing: isEditingRef.current })
form.setFieldsValue(initialFormData)
setFetchLoading(false)
onStateChangeRef.current({ loading: false })
} catch (err) {
console.error(err)
showError(
`Failed to fetch ${getModelMessageLabel(type).toLowerCase()} info.`
)
}
}, [
fetchObject,
fetchObjectActivities,
id,
type,
form,
showError,
calculateComputedValues,
model,
userProfile?._id,
releaseEditingState
])
// Update event handler // Update event handler
const updateObjectEventHandler = useCallback((value) => { const updateObjectEventHandler = useCallback(
setObjectData((prev) => (value) => {
mergeWith({}, prev, value, arrayReplaceCustomizer) setObjectData((prev) => {
) const next = mergeFormData(prev, value)
}, []) if (!isEditingRef.current) {
copyObjectChildren(next, value, model)
}
return next
})
},
[model]
)
useEffect(() => { useEffect(() => {
notifyActivityState(activities) notifyActivityState(activities)
@ -728,6 +758,7 @@ const ObjectForm = forwardRef(
const handleUpdate = async () => { const handleUpdate = async () => {
let error let error
let savedSuccessfully = false
const value = await form.validateFields().catch((err) => { const value = await form.validateFields().catch((err) => {
error = err error = err
}) })
@ -746,16 +777,29 @@ const ObjectForm = forwardRef(
console.log('THERE IS AN ERROR') console.log('THERE IS AN ERROR')
error = updatedObject error = updatedObject
} else { } else {
savedSuccessfully = true
setIsEditing(false) setIsEditing(false)
isEditingRef.current = false isEditingRef.current = false
onStateChangeRef.current({ isEditing: isEditingRef.current }) onStateChangeRef.current({ isEditing: isEditingRef.current })
serverObjectData.current = updatedObject
const computedEntries = calculateComputedValues(
updatedObject,
model,
{
skipObjectChildrenValue: true
}
)
const nextObjectData = applyComputedEntries(
updatedObject,
computedEntries
)
copyObjectChildren(nextObjectData, updatedObject, model)
setObjectData({ setObjectData({
...objectData, ...nextObjectData,
...currentFormData, _isEditing: false
...updatedObject,
_isEditing: isEditingRef.current
}) })
form.setFieldsValue(nextObjectData)
setObjectActivity(id, type, 'viewing') setObjectActivity(id, type, 'viewing')
showSuccess(`${getModelMessageLabel(type)} edited successfully!`) showSuccess(`${getModelMessageLabel(type)} edited successfully!`)
} }
@ -768,7 +812,7 @@ const ObjectForm = forwardRef(
) )
} }
handleFetchObject() handleFetchObject({ skipObjectChildrenValue: savedSuccessfully })
setEditLoading(false) setEditLoading(false)
onStateChangeRef.current({ editLoading: false }) onStateChangeRef.current({ editLoading: false })
} }
@ -799,14 +843,18 @@ const ObjectForm = forwardRef(
onEdit(allFormValues) onEdit(allFormValues)
} }
// After save, Form.Items for objectChildren unmount and a merge would
// keep the previous child rows instead of the updated object.
if (!isEditingRef.current) {
return
}
// Recompute derived fields from the full current form snapshot so // Recompute derived fields from the full current form snapshot so
// toggles like overridePrice/overrideCost are preserved while typing. // toggles like overridePrice/overrideCost are preserved while typing.
const currentFormData = mergeWith( const currentFormData = mergeFormData(
{},
serverObjectData.current || {}, serverObjectData.current || {},
objectData || {}, objectData || {},
allFormValues, allFormValues
arrayReplaceCustomizer
) )
const computedEntries = calculateComputedValues( const computedEntries = calculateComputedValues(
currentFormData, currentFormData,
@ -837,14 +885,7 @@ const ObjectForm = forwardRef(
mergedFormValues._isEditing = isEditingRef.current mergedFormValues._isEditing = isEditingRef.current
setObjectData((prev) => { setObjectData((prev) => mergeFormData(prev, mergedFormValues))
return mergeWith(
{},
prev,
mergedFormValues,
arrayReplaceCustomizer
)
})
}} }}
> >
{children({ {children({

View File

@ -1,16 +1,8 @@
import { Spin, Descriptions, Flex } from 'antd' import { Spin, Descriptions, Flex } from 'antd'
import { useState, useEffect } from 'react'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { getModelProperties } from '../../../database/ObjectModels' import { getModelProperties } from '../../../database/ObjectModels'
import mergeWith from 'lodash/mergeWith'
const arrayReplaceCustomizer = (objValue, srcValue) => {
if (Array.isArray(srcValue)) {
return srcValue
}
}
const ObjectInfo = ({ const ObjectInfo = ({
loading = false, loading = false,
@ -39,14 +31,6 @@ const ObjectInfo = ({
}) => { }) => {
const allItems = getModelProperties(type) const allItems = getModelProperties(type)
const [combinedObjectData, setCombinedObjectData] = useState(objectData)
useEffect(() => {
setCombinedObjectData((prev) =>
mergeWith({}, prev, objectData, arrayReplaceCustomizer)
)
}, [objectData])
// If properties array is empty, show all properties // If properties array is empty, show all properties
// Otherwise, filter and order by the properties array // Otherwise, filter and order by the properties array
let items let items
@ -78,7 +62,7 @@ const ObjectInfo = ({
const propertyName = item.name const propertyName = item.name
// Support property.visible as a function (objectData) => boolean // Support property.visible as a function (objectData) => boolean
if (typeof item.visible === 'function') { if (typeof item.visible === 'function') {
const visible = item.visible(objectData || combinedObjectData || {}) const visible = item.visible(objectData || {})
if (!visible) return false if (!visible) return false
} }
if (isWhitelistMode) { if (isWhitelistMode) {
@ -112,11 +96,11 @@ const ObjectInfo = ({
{...item} {...item}
{...objectPropertyProps} {...objectPropertyProps}
isEditing={isEditing} isEditing={isEditing}
objectData={combinedObjectData} objectData={objectData}
parentData={parentData} parentData={parentData}
showSince={true} showSince={true}
useFormItem={isControlled ? false : objectPropertyProps.useFormItem} useFormItem={isControlled ? false : objectPropertyProps.useFormItem}
value={isControlled ? combinedObjectData?.[item.name] : undefined} value={isControlled ? objectData?.[item.name] : undefined}
modelType={type} modelType={type}
onChange={ onChange={
isControlled isControlled

View File

@ -22,7 +22,7 @@ const ObjectList = ({
wrap={!scrollHorizontal} wrap={!scrollHorizontal}
style={{ style={{
...style, ...style,
width: 'fit-content' width: '100%'
}} }}
justify={'start'} justify={'start'}
> >
@ -39,11 +39,7 @@ const ObjectList = ({
) )
if (scrollHorizontal) { if (scrollHorizontal) {
return ( return <ScrollBox horizontalBottomPadding={9}>{listContents}</ScrollBox>
<ScrollBox horizontalBottomPadding={9}>
{listContents}
</ScrollBox>
)
} else { } else {
return listContents return listContents
} }

View File

@ -970,6 +970,7 @@ const ObjectProperty = ({
type={objectType} type={objectType}
multiple multiple
showHyperlink={showHyperlink} showHyperlink={showHyperlink}
masterFilter={masterFilter}
{...inputProps} {...inputProps}
/> />
) )

View File

@ -13,7 +13,6 @@ import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import merge from 'lodash/merge'
import { getModelProperty } from '../../../database/ObjectModels' import { getModelProperty } from '../../../database/ObjectModels'
const { SHOW_CHILD } = TreeSelect const { SHOW_CHILD } = TreeSelect
@ -187,8 +186,7 @@ const ObjectSelect = ({
if (Array.isArray(data)) { if (Array.isArray(data)) {
setObjectPropertiesTree((prev) => mergeGroups(prev, data)) setObjectPropertiesTree((prev) => mergeGroups(prev, data))
} else { } else {
// Fallback if API returns something unexpected setObjectPropertiesTree(data)
setObjectPropertiesTree((prev) => merge([], prev, data))
} }
setInitialLoading(false) setInitialLoading(false)
@ -382,10 +380,18 @@ const ObjectSelect = ({
setTreeSelectValue(value) setTreeSelectValue(value)
onChange?.(selectedObjects) onChange?.(selectedObjects)
} else { } else {
// Single selection // Single selection: replace the previous object instead of emitting
const selectedObject = objectList.find((obj) => obj._id === value) // undefined (lodash merge skips undefined and would keep the old value).
if (value == null || value === '') {
setTreeSelectValue(null)
onChange?.(null)
return
}
const selectedObject = objectList.find((obj) =>
areValuesEqual(obj._id, value)
)
setTreeSelectValue(value) setTreeSelectValue(value)
onChange?.(selectedObject) onChange?.(selectedObject ?? null)
} }
}, },
[multiple, objectList, onChange] [multiple, objectList, onChange]

View File

@ -116,6 +116,10 @@ const StateTag = ({ state, showBadge = true, showTag = true, style = {} }) => {
status = 'warning' status = 'warning'
text = 'Used' text = 'Used'
break break
case 'consumed':
status = 'default'
text = 'Consumed'
break
case 'unconsumed': case 'unconsumed':
status = 'success' status = 'success'
text = 'Unconsumed' text = 'Unconsumed'

View File

@ -1,3 +1,5 @@
import mergeWith from 'lodash/mergeWith'
export function capitalizeFirstLetter(string) { export function capitalizeFirstLetter(string) {
try { try {
return string[0].toUpperCase() + string.slice(1) return string[0].toUpperCase() + string.slice(1)
@ -34,5 +36,38 @@ export function round(num, decimals) {
return Math.round(num * 10 ** decimals) / 10 ** decimals return Math.round(num * 10 ** decimals) / 10 ** decimals
} }
// Re-export the functions for backward compatibility const isIdRecord = (value) =>
export {} value != null &&
typeof value === 'object' &&
!Array.isArray(value) &&
value._id != null
// lodash merge combines arrays by index and deep-merges objects with an _id.
// Form state must replace those wholesale so a newly selected object (e.g.
// productSku) does not keep nested arrays like parts from the previous one.
export function mergeFormCustomizer(objValue, srcValue, key) {
if (srcValue === null) {
return null
}
if (Array.isArray(srcValue)) {
return srcValue
}
if (
isIdRecord(objValue) &&
isIdRecord(srcValue) &&
String(objValue._id) !== String(srcValue._id)
) {
return srcValue
}
if (key === 'permissions' && srcValue !== undefined) {
return srcValue
}
}
export function mergeFormData(...sources) {
return mergeWith(
{},
...sources.filter((source) => source != null),
mergeFormCustomizer
)
}

View File

@ -40,7 +40,11 @@ export const Listing = {
label: 'New Listing', label: 'New Listing',
icon: PlusIcon, icon: PlusIcon,
content: (objectData, { onOk } = {}) => { content: (objectData, { onOk } = {}) => {
return createElement(NewListing, { defaultValues: objectData, onOk, reset: true }) return createElement(NewListing, {
defaultValues: objectData,
onOk,
reset: true
})
} }
}, },
{ {
@ -188,13 +192,7 @@ export const Listing = {
'updatedAt', 'updatedAt',
'_id' '_id'
], ],
group: [ group: ['marketplace', 'vendor', 'product', 'stockLocation'],
'marketplace',
'product',
'vendor',
'stockLocation',
'courierServices'
],
properties: [ properties: [
{ {
name: '_id', name: '_id',
@ -304,7 +302,7 @@ export const Listing = {
min: 0, min: 0,
readOnly: true, readOnly: true,
required: false, required: false,
columnWidth: 150 columnWidth: 170
}, },
{ {
name: 'marketplace', name: 'marketplace',
@ -329,7 +327,8 @@ export const Listing = {
label: 'Condition', label: 'Condition',
type: 'select', type: 'select',
required: true, required: true,
extra: 'Required by eBay for most categories. Stored in camel case and converted when publishing.', extra:
'Required by eBay for most categories. Stored in camel case and converted when publishing.',
options: [ options: [
{ value: 'new', label: 'New' }, { value: 'new', label: 'New' },
{ value: 'likeNew', label: 'Like New' }, { value: 'likeNew', label: 'Like New' },

View File

@ -1,16 +1,20 @@
import { createElement, lazy } from 'react' import { createElement, lazy } from 'react'
const ListingVarientInfo = lazy( const ListingVarientInfo = lazy(
() => import('../../components/Dashboard/Sales/ListingVarients/ListingVarientInfo') () =>
import('../../components/Dashboard/Sales/ListingVarients/ListingVarientInfo')
) )
const NewListingVarient = lazy( const NewListingVarient = lazy(
() => import('../../components/Dashboard/Sales/ListingVarients/NewListingVarient') () =>
import('../../components/Dashboard/Sales/ListingVarients/NewListingVarient')
) )
const PublishListingVarient = lazy( const PublishListingVarient = lazy(
() => import('../../components/Dashboard/Sales/ListingVarients/PublishListingVarient') () =>
import('../../components/Dashboard/Sales/ListingVarients/PublishListingVarient')
) )
const UnpublishListingVarient = lazy( const UnpublishListingVarient = lazy(
() => import('../../components/Dashboard/Sales/ListingVarients/UnpublishListingVarient') () =>
import('../../components/Dashboard/Sales/ListingVarients/UnpublishListingVarient')
) )
const DeleteObject = lazy( const DeleteObject = lazy(
() => import('../../components/Dashboard/common/DeleteObject') () => import('../../components/Dashboard/common/DeleteObject')
@ -40,7 +44,11 @@ export const ListingVarient = {
label: 'New Listing Varient', label: 'New Listing Varient',
icon: PlusIcon, icon: PlusIcon,
content: (objectData, { onOk } = {}) => { content: (objectData, { onOk } = {}) => {
return createElement(NewListingVarient, { defaultValues: objectData, onOk, reset: true }) return createElement(NewListingVarient, {
defaultValues: objectData,
onOk,
reset: true
})
} }
}, },
{ {
@ -162,7 +170,15 @@ export const ListingVarient = {
'updatedAt', 'updatedAt',
'_reference' '_reference'
], ],
sorters: ['state', 'stockQuantity', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'], sorters: [
'state',
'stockQuantity',
'price',
'lastSyncedAt',
'createdAt',
'updatedAt',
'_id'
],
group: ['listing', 'state'], group: ['listing', 'state'],
properties: [ properties: [
{ {
@ -241,7 +257,7 @@ export const ListingVarient = {
min: 0, min: 0,
readOnly: true, readOnly: true,
required: false, required: false,
columnWidth: 150 columnWidth: 170
}, },
{ {
name: 'state', name: 'state',

View File

@ -28,7 +28,11 @@ export const PartStock = {
label: 'New Part Stock', label: 'New Part Stock',
icon: PlusIcon, icon: PlusIcon,
content: (objectData, { onOk } = {}) => { content: (objectData, { onOk } = {}) => {
return createElement(NewPartStock, { defaultValues: objectData, onOk, reset: true }) return createElement(NewPartStock, {
defaultValues: objectData,
onOk,
reset: true
})
} }
}, },
{ {
@ -55,6 +59,7 @@ export const PartStock = {
} }
], ],
filters: [ filters: [
'part',
'partSku', 'partSku',
'state', 'state',
'startingQuantity', 'startingQuantity',
@ -65,6 +70,7 @@ export const PartStock = {
'_reference' '_reference'
], ],
sorters: [ sorters: [
'part',
'partSku', 'partSku',
'startingQuantity', 'startingQuantity',
'currentQuantity', 'currentQuantity',
@ -77,6 +83,7 @@ export const PartStock = {
'state', 'state',
'startingQuantity', 'startingQuantity',
'currentQuantity', 'currentQuantity',
'part',
'partSku', 'partSku',
'stockLocation', 'stockLocation',
'createdAt', 'createdAt',
@ -132,6 +139,15 @@ export const PartStock = {
required: true, required: true,
masterFilter: ['subJob', 'stockTransfer'] masterFilter: ['subJob', 'stockTransfer']
}, },
{
name: 'part',
label: 'Part',
type: 'object',
objectType: 'part',
required: true,
showHyperlink: true,
columnWidth: 200
},
{ {
name: 'partSku', name: 'partSku',
label: 'Part SKU', label: 'Part SKU',
@ -139,7 +155,10 @@ export const PartStock = {
objectType: 'partSku', objectType: 'partSku',
required: true, required: true,
showHyperlink: true, showHyperlink: true,
columnWidth: 200 columnWidth: 200,
masterFilter: (objectData) => {
return { part: objectData?.part?._id }
}
}, },
{ {
name: 'stockLocation', name: 'stockLocation',

View File

@ -1,20 +1,22 @@
import { createElement, lazy } from 'react' import { createElement, lazy } from 'react'
const ProductStockInfo = lazy( const ProductStockInfo = lazy(
() => import('../../components/Dashboard/Inventory/ProductStocks/ProductStockInfo') () =>
import('../../components/Dashboard/Inventory/ProductStocks/ProductStockInfo')
) )
const NewProductStock = lazy( const NewProductStock = lazy(
() => import('../../components/Dashboard/Inventory/ProductStocks/NewProductStock') () =>
import('../../components/Dashboard/Inventory/ProductStocks/NewProductStock')
) )
const PostProductStock = lazy( const PostProductStock = lazy(
() => import('../../components/Dashboard/Inventory/ProductStocks/PostProductStock') () =>
import('../../components/Dashboard/Inventory/ProductStocks/PostProductStock')
) )
const DeleteObject = lazy( const DeleteObject = lazy(
() => import('../../components/Dashboard/common/DeleteObject') () => import('../../components/Dashboard/common/DeleteObject')
) )
import ProductStockIcon from '../../components/Icons/ProductStockIcon' import ProductStockIcon from '../../components/Icons/ProductStockIcon'
import PlusIcon from '../../components/Icons/PlusIcon' import PlusIcon from '../../components/Icons/PlusIcon'
import { getModelByName } from '../ObjectModels.js'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' 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'
@ -38,7 +40,11 @@ export const ProductStock = {
label: 'New Product Stock', label: 'New Product Stock',
icon: PlusIcon, icon: PlusIcon,
content: (objectData, { onOk } = {}) => { content: (objectData, { onOk } = {}) => {
return createElement(NewProductStock, { defaultValues: objectData, onOk, reset: true }) return createElement(NewProductStock, {
defaultValues: objectData,
onOk,
reset: true
})
} }
}, },
{ {
@ -116,6 +122,12 @@ export const ProductStock = {
modalWidth: 520, modalWidth: 520,
label: 'Post', label: 'Post',
icon: CheckIcon, icon: CheckIcon,
disabled: (objectData) => {
if (objectData?._isEditing == true) return true
return objectData?.partStockList?.some(
(row) => row?.remainingQuantity > 0
)
},
visible: (objectData) => { visible: (objectData) => {
return objectData?.state?.type == 'draft' return objectData?.state?.type == 'draft'
}, },
@ -131,6 +143,7 @@ export const ProductStock = {
} }
], ],
filters: [ filters: [
'product',
'productSku', 'productSku',
'state', 'state',
'currentQuantity', 'currentQuantity',
@ -140,16 +153,19 @@ export const ProductStock = {
'_reference' '_reference'
], ],
sorters: [ sorters: [
'product',
'productSku', 'productSku',
'currentQuantity', 'currentQuantity',
'state', 'state',
'createdAt', 'createdAt',
'updatedAt' 'updatedAt',
'stockLocation'
], ],
columns: [ columns: [
'_reference', '_reference',
'state', 'state',
'currentQuantity', 'currentQuantity',
'product',
'productSku', 'productSku',
'stockLocation', 'stockLocation',
'createdAt', 'createdAt',
@ -182,6 +198,13 @@ export const ProductStock = {
readOnly: true, readOnly: true,
columnWidth: 180 columnWidth: 180
}, },
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{ {
name: 'state', name: 'state',
label: 'State', label: 'State',
@ -196,12 +219,15 @@ export const ProductStock = {
readOnly: true, readOnly: true,
columnWidth: 175 columnWidth: 175
}, },
{ {
name: 'updatedAt', name: 'product',
label: 'Updated At', label: 'Product',
type: 'dateTime', type: 'object',
readOnly: true, objectType: 'product',
columnWidth: 175 required: true,
showHyperlink: true,
columnWidth: 200
}, },
{ {
name: 'productSku', name: 'productSku',
@ -210,14 +236,17 @@ export const ProductStock = {
objectType: 'productSku', objectType: 'productSku',
required: true, required: true,
showHyperlink: true, showHyperlink: true,
columnWidth: 200 columnWidth: 200,
masterFilter: (objectData) => {
return { product: objectData?.product?._id }
}
}, },
{ {
name: 'stockLocation', name: 'stockLocation',
label: 'Stock location', label: 'Stock location',
type: 'object', type: 'object',
objectType: 'stockLocation', objectType: 'stockLocation',
required: false, required: true,
showHyperlink: true, showHyperlink: true,
columnWidth: 200, columnWidth: 200,
readOnly: (objectData) => { readOnly: (objectData) => {
@ -232,11 +261,99 @@ export const ProductStock = {
required: true required: true
}, },
{ {
name: 'partStocks', name: 'partStockList',
label: 'Part Stocks', label: 'Part Stock List',
type: 'objectChildren', type: 'objectChildren',
canAddRemove: false, canAddRemove: false,
size: 'medium',
value: (objectData) => {
if (
objectData?._isEditing == false ||
objectData?.state?.type != 'draft'
) {
return objectData?.partStockList || []
}
const getId = (val) => {
if (val == null) return null
if (typeof val === 'object') {
return val._id != null ? String(val._id) : null
}
return String(val)
}
const toPartStocks = (row) => {
if (Array.isArray(row?.partStocks)) return row.partStocks
if (row?.partStock) return [row.partStock]
return []
}
const skuParts = objectData?.productSku?.parts
const existing = objectData?.partStockList
const currentQuantity = Number(objectData?.currentQuantity) || 0
const requiredFromPart = (part) =>
(Number(part?.quantity) || 0) * currentQuantity
const buildRow = (part, existingRow) => ({
...existingRow,
part: part?.part?._id ? part.part : { _id: part.part },
partSku: part.partSku?._id ? part.partSku : { _id: part.partSku },
requiredQuantity: requiredFromPart(part),
partStocks: toPartStocks(existingRow)
})
const rowsMatchSkuParts = (rows, parts) => {
if (!Array.isArray(rows) || rows.length !== parts.length) return false
return parts.every((part, index) => {
const row = rows[index]
return (
getId(row?.partSku) === getId(part.partSku) &&
getId(row?.part) === getId(part.part) &&
Number(row?.requiredQuantity) === requiredFromPart(part) &&
Array.isArray(row?.partStocks)
)
})
}
if (Array.isArray(skuParts)) {
if (skuParts.length === 0) return []
if (rowsMatchSkuParts(existing, skuParts)) return undefined
return skuParts.map((part) => {
const existingRow = Array.isArray(existing)
? existing.find(
(row) => getId(row.partSku) === getId(part.partSku)
)
: undefined
return buildRow(part, existingRow)
})
}
if (!Array.isArray(existing)) return []
const needsNormalize = existing.some(
(row) =>
(row.requiredQuantity == null && row.quantity != null) ||
(row.part == null && row.partSku?.part != null) ||
!Array.isArray(row.partStocks)
)
if (!needsNormalize) return []
return existing.map((row) => ({
...row,
part: row.part ?? row.partSku?.part,
requiredQuantity: row.requiredQuantity ?? row.quantity,
partStocks: toPartStocks(row)
}))
},
properties: [ properties: [
{
name: 'part',
label: 'Part',
type: 'object',
objectType: 'part',
readOnly: true,
required: true,
showHyperlink: true
},
{ {
name: 'partSku', name: 'partSku',
label: 'Part SKU', label: 'Part SKU',
@ -247,25 +364,52 @@ export const ProductStock = {
showHyperlink: true showHyperlink: true
}, },
{ {
name: 'partStock', name: 'partStocks',
label: 'Part Stock', label: 'Part Stocks',
type: 'object', type: 'objectList',
objectType: 'partStock', objectType: 'partStock',
required: true, required: false,
showHyperlink: true, showHyperlink: true,
columnWidth: 260,
masterFilter: (objectData) => { masterFilter: (objectData) => {
const partSkuId = objectData?.partSku?._id
if (partSkuId == null) return {}
return { return {
partSku: getModelByName('partSku').prefix + ':' + partSkuId part: objectData?.part?._id,
partSku: objectData?.partSku?._id,
$or: [{ 'state.type': 'new' }, { 'state.type': 'used' }]
} }
} }
}, },
{ {
name: 'quantity', name: 'requiredQuantity',
label: 'Quantity', label: 'Required Quantity',
type: 'number', type: 'number',
required: true required: true,
columnWidth: 190,
readOnly: true,
value: (objectData) =>
objectData?.requiredQuantity ?? objectData?.quantity
},
{
name: 'remainingQuantity',
label: 'Remaining Quantity',
type: 'number',
columnWidth: 190,
readOnly: true,
value: (objectData) => {
const required =
objectData?.requiredQuantity ?? objectData?.quantity ?? 0
const stocks = Array.isArray(objectData?.partStocks)
? objectData.partStocks
: []
const available = Math.max(
0,
stocks.reduce(
(sum, stock) => sum + (Number(stock?.currentQuantity) || 0),
0
)
)
return Math.max(0, required - available)
}
} }
] ]
} }
@ -278,8 +422,8 @@ export const ProductStock = {
color: 'default' color: 'default'
}, },
{ {
name: 'posted.count', name: 'new.count',
label: 'Posted', label: 'New',
type: 'number', type: 'number',
color: 'success' color: 'success'
}, },