From e0a5ff3cf8eb197c0b73f2a918b06fed0cc7d34c Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 24 Aug 2026 22:40:11 +0100 Subject: [PATCH] Refactor Product Stock Components and Update Property Names - 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. --- .../ProductStocks/NewProductStock.jsx | 4 +- .../ProductStocks/ProductStockInfo.jsx | 36 +-- .../Dashboard/common/NewObjectForm.jsx | 29 +-- .../Dashboard/common/ObjectForm.jsx | 233 ++++++++++-------- .../Dashboard/common/ObjectInfo.jsx | 22 +- .../Dashboard/common/ObjectList.jsx | 8 +- .../Dashboard/common/ObjectProperty.jsx | 1 + .../Dashboard/common/ObjectSelect.jsx | 18 +- src/components/Dashboard/common/StateTag.jsx | 4 + src/components/Dashboard/utils/Utils.js | 39 ++- src/database/models/Listing.js | 19 +- src/database/models/ListingVarient.js | 30 ++- src/database/models/PartStock.js | 23 +- src/database/models/ProductStock.js | 198 +++++++++++++-- 14 files changed, 447 insertions(+), 217 deletions(-) diff --git a/src/components/Dashboard/Inventory/ProductStocks/NewProductStock.jsx b/src/components/Dashboard/Inventory/ProductStocks/NewProductStock.jsx index d5906af2..2a18c96a 100644 --- a/src/components/Dashboard/Inventory/ProductStocks/NewProductStock.jsx +++ b/src/components/Dashboard/Inventory/ProductStocks/NewProductStock.jsx @@ -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} diff --git a/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx b/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx index be497362..fe2b0492 100644 --- a/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx +++ b/src/components/Dashboard/Inventory/ProductStocks/ProductStockInfo.jsx @@ -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 }} /> } - active={collapseState.partStocks} + active={collapseState.partStockList} onToggle={(expanded) => - updateCollapseState('partStocks', expanded) + updateCollapseState('partStockList', expanded) } - collapseKey='partStocks' + collapseKey='partStockList' > { ) : ( )} @@ -281,7 +287,7 @@ const ProductStockInfo = () => { indicator={} > - + @@ -299,10 +305,8 @@ const ProductStockInfo = () => { diff --git a/src/components/Dashboard/common/NewObjectForm.jsx b/src/components/Dashboard/common/NewObjectForm.jsx index 0b11e1fc..66bac8f0 100644 --- a/src/components/Dashboard/common/NewObjectForm.jsx +++ b/src/components/Dashboard/common/NewObjectForm.jsx @@ -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({ diff --git a/src/components/Dashboard/common/ObjectForm.jsx b/src/components/Dashboard/common/ObjectForm.jsx index 8c91ac7b..084f49d6 100644 --- a/src/components/Dashboard/common/ObjectForm.jsx +++ b/src/components/Dashboard/common/ObjectForm.jsx @@ -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({ diff --git a/src/components/Dashboard/common/ObjectInfo.jsx b/src/components/Dashboard/common/ObjectInfo.jsx index f80c5216..99558e2e 100644 --- a/src/components/Dashboard/common/ObjectInfo.jsx +++ b/src/components/Dashboard/common/ObjectInfo.jsx @@ -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 diff --git a/src/components/Dashboard/common/ObjectList.jsx b/src/components/Dashboard/common/ObjectList.jsx index 719dac19..4192eb59 100644 --- a/src/components/Dashboard/common/ObjectList.jsx +++ b/src/components/Dashboard/common/ObjectList.jsx @@ -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 ( - - {listContents} - - ) + return {listContents} } else { return listContents } diff --git a/src/components/Dashboard/common/ObjectProperty.jsx b/src/components/Dashboard/common/ObjectProperty.jsx index 3ef0cb5a..b7a86675 100644 --- a/src/components/Dashboard/common/ObjectProperty.jsx +++ b/src/components/Dashboard/common/ObjectProperty.jsx @@ -970,6 +970,7 @@ const ObjectProperty = ({ type={objectType} multiple showHyperlink={showHyperlink} + masterFilter={masterFilter} {...inputProps} /> ) diff --git a/src/components/Dashboard/common/ObjectSelect.jsx b/src/components/Dashboard/common/ObjectSelect.jsx index ab433731..396da1d4 100644 --- a/src/components/Dashboard/common/ObjectSelect.jsx +++ b/src/components/Dashboard/common/ObjectSelect.jsx @@ -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] diff --git a/src/components/Dashboard/common/StateTag.jsx b/src/components/Dashboard/common/StateTag.jsx index a812f959..156b3b94 100644 --- a/src/components/Dashboard/common/StateTag.jsx +++ b/src/components/Dashboard/common/StateTag.jsx @@ -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' diff --git a/src/components/Dashboard/utils/Utils.js b/src/components/Dashboard/utils/Utils.js index 36ec8646..3d1ba514 100644 --- a/src/components/Dashboard/utils/Utils.js +++ b/src/components/Dashboard/utils/Utils.js @@ -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 + ) +} diff --git a/src/database/models/Listing.js b/src/database/models/Listing.js index 4a0dc5e6..82715c2b 100644 --- a/src/database/models/Listing.js +++ b/src/database/models/Listing.js @@ -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' }, diff --git a/src/database/models/ListingVarient.js b/src/database/models/ListingVarient.js index 11f3cae7..0666805a 100644 --- a/src/database/models/ListingVarient.js +++ b/src/database/models/ListingVarient.js @@ -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', diff --git a/src/database/models/PartStock.js b/src/database/models/PartStock.js index 8d6e304d..0f7d8b2e 100644 --- a/src/database/models/PartStock.js +++ b/src/database/models/PartStock.js @@ -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', diff --git a/src/database/models/ProductStock.js b/src/database/models/ProductStock.js index eba24fcc..19dfa9dc 100644 --- a/src/database/models/ProductStock.js +++ b/src/database/models/ProductStock.js @@ -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' },