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}
objectData={objectData}
visibleProperties={{
partStocks: false
partStockList: false
}}
/>
)
@ -42,7 +42,7 @@ const NewProductStock = ({ onOk, reset, defaultValues }) => {
_reference: false,
createdAt: false,
updatedAt: false,
partStocks: false
partStockList: false
}}
isEditing={false}
objectData={objectData}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import mergeWith from 'lodash/mergeWith'
export function capitalizeFirstLetter(string) {
try {
return string[0].toUpperCase() + string.slice(1)
@ -34,5 +36,38 @@ export function round(num, decimals) {
return Math.round(num * 10 ** decimals) / 10 ** decimals
}
// Re-export the functions for backward compatibility
export {}
const isIdRecord = (value) =>
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',
icon: PlusIcon,
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',
'_id'
],
group: [
'marketplace',
'product',
'vendor',
'stockLocation',
'courierServices'
],
group: ['marketplace', 'vendor', 'product', 'stockLocation'],
properties: [
{
name: '_id',
@ -304,7 +302,7 @@ export const Listing = {
min: 0,
readOnly: true,
required: false,
columnWidth: 150
columnWidth: 170
},
{
name: 'marketplace',
@ -329,7 +327,8 @@ export const Listing = {
label: 'Condition',
type: 'select',
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: [
{ value: 'new', label: 'New' },
{ value: 'likeNew', label: 'Like New' },

View File

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

View File

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

View File

@ -1,20 +1,22 @@
import { createElement, lazy } from 'react'
const ProductStockInfo = lazy(
() => import('../../components/Dashboard/Inventory/ProductStocks/ProductStockInfo')
() =>
import('../../components/Dashboard/Inventory/ProductStocks/ProductStockInfo')
)
const NewProductStock = lazy(
() => import('../../components/Dashboard/Inventory/ProductStocks/NewProductStock')
() =>
import('../../components/Dashboard/Inventory/ProductStocks/NewProductStock')
)
const PostProductStock = lazy(
() => import('../../components/Dashboard/Inventory/ProductStocks/PostProductStock')
() =>
import('../../components/Dashboard/Inventory/ProductStocks/PostProductStock')
)
const DeleteObject = lazy(
() => import('../../components/Dashboard/common/DeleteObject')
)
import ProductStockIcon from '../../components/Icons/ProductStockIcon'
import PlusIcon from '../../components/Icons/PlusIcon'
import { getModelByName } from '../ObjectModels.js'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import CheckIcon from '../../components/Icons/CheckIcon'
@ -38,7 +40,11 @@ export const ProductStock = {
label: 'New Product Stock',
icon: PlusIcon,
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,
label: 'Post',
icon: CheckIcon,
disabled: (objectData) => {
if (objectData?._isEditing == true) return true
return objectData?.partStockList?.some(
(row) => row?.remainingQuantity > 0
)
},
visible: (objectData) => {
return objectData?.state?.type == 'draft'
},
@ -131,6 +143,7 @@ export const ProductStock = {
}
],
filters: [
'product',
'productSku',
'state',
'currentQuantity',
@ -140,16 +153,19 @@ export const ProductStock = {
'_reference'
],
sorters: [
'product',
'productSku',
'currentQuantity',
'state',
'createdAt',
'updatedAt'
'updatedAt',
'stockLocation'
],
columns: [
'_reference',
'state',
'currentQuantity',
'product',
'productSku',
'stockLocation',
'createdAt',
@ -182,6 +198,13 @@ export const ProductStock = {
readOnly: true,
columnWidth: 180
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: 'state',
label: 'State',
@ -196,12 +219,15 @@ export const ProductStock = {
readOnly: true,
columnWidth: 175
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
name: 'product',
label: 'Product',
type: 'object',
objectType: 'product',
required: true,
showHyperlink: true,
columnWidth: 200
},
{
name: 'productSku',
@ -210,14 +236,17 @@ export const ProductStock = {
objectType: 'productSku',
required: true,
showHyperlink: true,
columnWidth: 200
columnWidth: 200,
masterFilter: (objectData) => {
return { product: objectData?.product?._id }
}
},
{
name: 'stockLocation',
label: 'Stock location',
type: 'object',
objectType: 'stockLocation',
required: false,
required: true,
showHyperlink: true,
columnWidth: 200,
readOnly: (objectData) => {
@ -232,11 +261,99 @@ export const ProductStock = {
required: true
},
{
name: 'partStocks',
label: 'Part Stocks',
name: 'partStockList',
label: 'Part Stock List',
type: 'objectChildren',
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: [
{
name: 'part',
label: 'Part',
type: 'object',
objectType: 'part',
readOnly: true,
required: true,
showHyperlink: true
},
{
name: 'partSku',
label: 'Part SKU',
@ -247,25 +364,52 @@ export const ProductStock = {
showHyperlink: true
},
{
name: 'partStock',
label: 'Part Stock',
type: 'object',
name: 'partStocks',
label: 'Part Stocks',
type: 'objectList',
objectType: 'partStock',
required: true,
required: false,
showHyperlink: true,
columnWidth: 260,
masterFilter: (objectData) => {
const partSkuId = objectData?.partSku?._id
if (partSkuId == null) return {}
return {
partSku: getModelByName('partSku').prefix + ':' + partSkuId
part: objectData?.part?._id,
partSku: objectData?.partSku?._id,
$or: [{ 'state.type': 'new' }, { 'state.type': 'used' }]
}
}
},
{
name: 'quantity',
label: 'Quantity',
name: 'requiredQuantity',
label: 'Required Quantity',
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'
},
{
name: 'posted.count',
label: 'Posted',
name: 'new.count',
label: 'New',
type: 'number',
color: 'success'
},