Enhance Dashboard Components with Marketplaces and Description Features
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Updated CourierServiceInfo and ListingInfo components to include collapsible sections for Marketplaces and Listing Description, improving data organization and user experience.
- Introduced ObjectProperty component to display marketplace details and listing descriptions dynamically.
- Added new properties to the Courier and Listing models to support marketplaces and stock quantity, enhancing data management capabilities.
- Implemented SyncMarketplace component for refreshing marketplace metadata, streamlining the synchronization process.
- Enhanced CustomSelect and MarkdownDisplay components for better usability and content rendering.
This commit is contained in:
Tom Butcher 2026-08-24 02:49:33 +01:00
parent 1b98f6a716
commit c4beb3d3a0
14 changed files with 345 additions and 64 deletions

View File

@ -8,10 +8,12 @@ import useCollapseState from '../../hooks/useCollapseState'
import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import MarketplaceIcon from '../../../Icons/MarketplaceIcon.jsx'
import ObjectForm from '../../common/ObjectForm'
import EditButtons from '../../common/EditButtons'
import ObjectTableNavigationButtons from '../../common/ObjectTableNavigationButtons.jsx'
@ -23,7 +25,7 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelByName } from '../../../../database/ObjectModels.js'
import { getModelProperty, getModelByName } from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('CourierServiceInfo')
@ -40,6 +42,7 @@ const CourierServiceInfo = () => {
'CourierServiceInfo',
{
info: true,
marketplaces: true,
notes: true,
auditLogs: false
}
@ -103,6 +106,7 @@ const CourierServiceInfo = () => {
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Courier Service Information' },
{ key: 'marketplaces', label: 'Marketplaces' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
@ -155,33 +159,56 @@ const CourierServiceInfo = () => {
loading={objectFormState.loading}
ref={actionHandlerRef}
>
<InfoCollapse
title='Courier Service Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
collapseKey='info'
<ObjectForm
id={courierServiceId}
type='courierService'
style={{ height: '100%' }}
ref={objectFormRef}
setCurrentObject={true}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
<ObjectForm
id={courierServiceId}
type='courierService'
style={{ height: '100%' }}
ref={objectFormRef}
setCurrentObject={true}
onStateChange={(state) => {
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
{({ loading, isEditing, objectData }) => (
<ObjectInfo
loading={loading}
isEditing={isEditing}
type='courierService'
objectData={objectData}
/>
)}
</ObjectForm>
</InfoCollapse>
{({ loading, isEditing, objectData }) => (
<Flex vertical gap={'large'}>
<InfoCollapse
title='Courier Service Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
collapseKey='info'
>
<ObjectInfo
loading={loading}
isEditing={isEditing}
type='courierService'
objectData={objectData}
visibleProperties={{
marketplaces: false
}}
/>
</InfoCollapse>
<InfoCollapse
title='Marketplaces'
icon={<MarketplaceIcon />}
active={collapseState.marketplaces}
onToggle={(expanded) =>
updateCollapseState('marketplaces', expanded)
}
collapseKey='marketplaces'
>
<ObjectProperty
{...getModelProperty('courierService', 'marketplaces')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
/>
</InfoCollapse>
</Flex>
)}
</ObjectForm>
</ActionHandler>
<InfoCollapse
title='Notes'

View File

@ -53,7 +53,8 @@ const NewListingVarient = ({ onOk, defaultValues }) => {
_reference: false,
createdAt: false,
updatedAt: false,
lastSyncedAt: false
lastSyncedAt: false,
stockQuantity: false
}}
isEditing={false}
objectData={objectData}

View File

@ -8,8 +8,10 @@ import useCollapseState from '../../hooks/useCollapseState'
import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import TextIcon from '../../../Icons/TextIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ListingVarientIcon from '../../../Icons/ListingVarientIcon.jsx'
@ -25,7 +27,7 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelByName } from '../../../../database/ObjectModels.js'
import { getModelByName, getModelProperty } from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx'
const log = loglevel.getLogger('ListingInfo')
@ -47,6 +49,7 @@ const ListingInfo = () => {
const listingId = new URLSearchParams(location.search).get('listingId')
const [collapseState, updateCollapseState] = useCollapseState('ListingInfo', {
info: true,
description: true,
listingVarients: true,
notes: true,
auditLogs: false
@ -109,6 +112,7 @@ const ListingInfo = () => {
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Listing Information' },
{ key: 'description', label: 'Listing Description' },
{ key: 'listingVarients', label: 'Listing Varients' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
@ -189,8 +193,29 @@ const ListingInfo = () => {
type='listing'
labelWidth={165}
objectData={objectData}
visibleProperties={{ description: false }}
/>
</InfoCollapse>
<InfoCollapse
title='Listing Description'
icon={<TextIcon />}
active={collapseState.description}
onToggle={(expanded) =>
updateCollapseState('description', expanded)
}
collapseKey='description'
>
<Card>
<ObjectProperty
{...getModelProperty('listing', 'description')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
size='medium'
showCard={false}
/>
</Card>
</InfoCollapse>
<InfoCollapse
title='Listing Varients'
icon={<ListingVarientIcon />}

View File

@ -53,7 +53,8 @@ const NewListing = ({ onOk, defaultValues }) => {
_reference: false,
createdAt: false,
updatedAt: false,
lastSyncedAt: false
lastSyncedAt: false,
stockQuantity: false
}}
isEditing={false}
objectData={objectData}

View File

@ -0,0 +1,46 @@
import { useState, useContext } from 'react'
import PropTypes from 'prop-types'
import { ApiServerContext } from '../../context/ApiServerContext'
import { message } from 'antd'
import MessageDialogView from '../../common/MessageDialogView.jsx'
const SyncMarketplace = ({ onOk, objectData }) => {
const [syncLoading, setSyncLoading] = useState(false)
const { sendObjectFunction } = useContext(ApiServerContext)
const handleSync = async () => {
setSyncLoading(true)
try {
const result = await sendObjectFunction(
objectData._id,
'Marketplace',
'sync'
)
if (result?.success) {
message.success('Marketplace synced successfully')
onOk(result)
}
} catch (error) {
console.error('Error syncing marketplace:', error)
} finally {
setSyncLoading(false)
}
}
return (
<MessageDialogView
title='Sync marketplace?'
description={`This will refresh marketplace metadata from ${objectData?.name || objectData?._reference || objectData?._id}, including available shipping services.`}
onOk={handleSync}
okText='Sync'
okLoading={syncLoading}
/>
)
}
SyncMarketplace.propTypes = {
onOk: PropTypes.func.isRequired,
objectData: PropTypes.object
}
export default SyncMarketplace

View File

@ -40,6 +40,8 @@ const CustomSelect = ({
options={Array.isArray(options) ? options : []}
value={value}
onChange={onChange}
showSearch
optionFilterProp='label'
{...rest}
/>
)

View File

@ -30,6 +30,7 @@ const BlockquoteComponent = ({ children }) => (
BlockquoteComponent.propTypes = { children: PropTypes.node }
const MarkdownDisplay = ({ content }) => {
const markdown = typeof content === 'string' ? content : ''
const components = {
h1: (props) => <h1 {...props} />,
h2: (props) => <h2 {...props} />,
@ -57,14 +58,14 @@ const MarkdownDisplay = ({ content }) => {
return (
<div className='markdown markdown-display'>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
{markdown}
</ReactMarkdown>
</div>
)
}
MarkdownDisplay.propTypes = {
content: PropTypes.string.isRequired
content: PropTypes.string
}
export default MarkdownDisplay

View File

@ -138,8 +138,8 @@ const ObjectProperty = ({
scrollHeight,
...rest
}) => {
if (value && typeof value == 'function' && objectData) {
value = value(objectData, parentData)
if (typeof value === 'function') {
value = value(objectData || {}, parentData)
}
if (max && typeof max == 'function' && objectData) {
@ -417,7 +417,7 @@ const ObjectProperty = ({
)
}
case 'markdown':
if (value != null && value != '') {
if (typeof value === 'string' && value !== '') {
return <MarkdownDisplay content={value} />
} else {
return (

View File

@ -153,6 +153,15 @@ export const Courier = {
required: true,
columnWidth: 180
},
{
name: 'externalReference',
label: 'External Reference',
type: 'text',
placeholder: 'RoyalMail',
extra: 'Marketplace shipping carrier code used when publishing to eBay.',
showCopy: true,
columnWidth: 200
},
{
name: 'name',
label: 'Name',

View File

@ -1,10 +1,12 @@
import { createElement, lazy } from 'react'
const CourierServiceInfo = lazy(
() => import('../../components/Dashboard/Management/CourierServices/CourierServiceInfo')
() =>
import('../../components/Dashboard/Management/CourierServices/CourierServiceInfo')
)
const NewCourierService = lazy(
() => import('../../components/Dashboard/Management/CourierServices/NewCourierService')
() =>
import('../../components/Dashboard/Management/CourierServices/NewCourierService')
)
const DeleteObject = lazy(
() => import('../../components/Dashboard/common/DeleteObject')
@ -34,7 +36,11 @@ export const CourierService = {
label: 'New Courier Service',
icon: PlusIcon,
content: (objectData, { onOk } = {}) => {
return createElement(NewCourierService, { defaultValues: objectData, onOk, reset: true })
return createElement(NewCourierService, {
defaultValues: objectData,
onOk,
reset: true
})
}
},
{
@ -193,6 +199,48 @@ export const CourierService = {
required: true,
columnWidth: 200
},
{
name: 'marketplaces',
label: 'Marketplaces',
type: 'objectChildren',
required: false,
canAddRemove: true,
size: 'medium',
span: 2,
columns: ['marketplace', 'externalReference'],
properties: [
{
name: 'marketplace',
label: 'Marketplace',
type: 'object',
objectType: 'marketplace',
required: true,
showHyperlink: true,
columnWidth: 220
},
{
name: 'externalReference',
label: 'External Reference',
type: (objectData) =>
objectData?.marketplace?.provider === 'ebay' ? 'select' : 'text',
options: (objectData) => {
const codes =
objectData?.marketplace?.eBay?.availableShippingServices || []
const current = objectData?.externalReference
const unique = []
for (const code of [...codes, current]) {
if (code && !unique.includes(code)) unique.push(code)
}
return unique.map((code) => ({ label: code, value: code }))
},
extra:
'For eBay listings, choose a shipping service code from the selected marketplace. Sync the marketplace if the list is empty.',
showCopy: true,
required: true,
columnWidth: 260
}
]
},
{
name: 'deliveryTime',
label: 'Delivery Time',

View File

@ -152,9 +152,11 @@ export const Listing = {
'product',
'vendor',
'stockLocation',
'stockQuantity',
'marketplace',
'courierServices',
'state',
'condition',
'price',
'currency',
'lastSyncedAt',
@ -165,10 +167,12 @@ export const Listing = {
'product',
'vendor',
'stockLocation',
'stockQuantity',
'marketplace',
'courierServices',
'state',
'state.type',
'condition',
'createdAt',
'updatedAt',
'_reference'
@ -177,6 +181,7 @@ export const Listing = {
'title',
'vendor',
'state',
'stockQuantity',
'price',
'lastSyncedAt',
'createdAt',
@ -239,6 +244,16 @@ export const Listing = {
return objectData?.title
}
},
{
name: 'description',
label: 'Description',
type: 'markdown',
readOnly: false,
required: false,
span: 2,
extra:
'Used as the eBay listing description. If empty, the title is sent instead.'
},
{
name: 'lastSyncedAt',
label: 'Last Synced',
@ -282,6 +297,15 @@ export const Listing = {
required: true,
columnWidth: 200
},
{
name: 'stockQuantity',
label: 'Stock Quantity',
type: 'number',
min: 0,
readOnly: true,
required: false,
columnWidth: 150
},
{
name: 'marketplace',
label: 'Marketplace',
@ -300,6 +324,34 @@ export const Listing = {
readOnly: true,
columnWidth: 260
},
{
name: 'condition',
label: 'Condition',
type: 'select',
required: true,
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' },
{ value: 'newOther', label: 'New Other' },
{ value: 'newWithDefects', label: 'New With Defects' },
{ value: 'manufacturerRefurbished', label: 'Manufacturer Refurbished' },
{ value: 'certifiedRefurbished', label: 'Certified Refurbished' },
{ value: 'excellentRefurbished', label: 'Excellent Refurbished' },
{ value: 'veryGoodRefurbished', label: 'Very Good Refurbished' },
{ value: 'goodRefurbished', label: 'Good Refurbished' },
{ value: 'sellerRefurbished', label: 'Seller Refurbished' },
{ value: 'usedExcellent', label: 'Used Excellent' },
{ value: 'usedVeryGood', label: 'Used Very Good' },
{ value: 'usedGood', label: 'Used Good' },
{ value: 'usedAcceptable', label: 'Used Acceptable' },
{ value: 'forPartsOrNotWorking', label: 'For Parts Or Not Working' },
{ value: 'preOwnedExcellent', label: 'Pre Owned Excellent' },
{ value: 'preOwnedFair', label: 'Pre Owned Fair' }
],
value: (objectData) => objectData?.condition || 'new',
columnWidth: 200
},
{
name: 'price',
label: 'Price',

View File

@ -143,6 +143,7 @@ export const ListingVarient = {
'listing',
'product',
'productSku',
'stockQuantity',
'state',
'price',
'currency',
@ -154,13 +155,14 @@ export const ListingVarient = {
'listing',
'product',
'productSku',
'stockQuantity',
'state',
'state.type',
'createdAt',
'updatedAt',
'_reference'
],
sorters: ['state', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'],
sorters: ['state', 'stockQuantity', 'price', 'lastSyncedAt', 'createdAt', 'updatedAt', '_id'],
group: ['listing', 'state'],
properties: [
{
@ -232,6 +234,15 @@ export const ListingVarient = {
return { product: objectData?.listing?.product?._id }
}
},
{
name: 'stockQuantity',
label: 'Stock Quantity',
type: 'number',
min: 0,
readOnly: true,
required: false,
columnWidth: 150
},
{
name: 'state',
label: 'State',

View File

@ -6,6 +6,9 @@ const MarketplaceInfo = lazy(
const NewMarketplace = lazy(
() => import('../../components/Dashboard/Sales/Marketplaces/NewMarketplace')
)
const SyncMarketplace = lazy(
() => import('../../components/Dashboard/Sales/Marketplaces/SyncMarketplace')
)
const SyncListings = lazy(
() => import('../../components/Dashboard/Sales/Marketplaces/SyncListings')
)
@ -42,7 +45,11 @@ export const Marketplace = {
label: 'New Marketplace',
icon: PlusIcon,
content: (objectData, { onOk } = {}) => {
return createElement(NewMarketplace, { defaultValues: objectData, onOk, reset: true })
return createElement(NewMarketplace, {
defaultValues: objectData,
onOk,
reset: true
})
}
},
{
@ -94,30 +101,66 @@ export const Marketplace = {
},
{ type: 'divider' },
{
name: 'syncListings',
type: 'modal',
modalWidth: 520,
label: 'Sync Listings',
name: 'sync',
label: 'Sync',
icon: ReloadIcon,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
},
content: (objectData, { onOk } = {}) => {
return createElement(SyncListings, { objectData, onOk })
}
},
{
name: 'syncOrders',
type: 'modal',
modalWidth: 520,
label: 'Sync Orders',
icon: ReloadIcon,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
},
content: (objectData, { onOk } = {}) => {
return createElement(SyncOrders, { objectData, onOk })
}
children: [
{
name: 'syncMarketplace',
type: 'modal',
modalWidth: 520,
label: 'Sync Marketplace',
icon: ReloadIcon,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
},
content: (objectData, { onOk } = {}) => {
return createElement(SyncMarketplace, { objectData, onOk })
}
},
{
name: 'syncListings',
type: 'modal',
modalWidth: 520,
label: 'Sync Listings',
icon: ReloadIcon,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
},
content: (objectData, { onOk } = {}) => {
return createElement(SyncListings, { objectData, onOk })
}
},
{
name: 'syncMarketplace',
type: 'modal',
modalWidth: 520,
label: 'Sync Marketplace',
icon: ReloadIcon,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
},
content: (objectData, { onOk } = {}) => {
return createElement(SyncMarketplace, { objectData, onOk })
}
},
{
name: 'syncOrders',
type: 'modal',
modalWidth: 520,
label: 'Sync Orders',
icon: ReloadIcon,
disabled: (objectData) => {
return objectData?.state?.type != 'ready'
},
content: (objectData, { onOk } = {}) => {
return createElement(SyncOrders, { objectData, onOk })
}
}
]
},
{ type: 'divider' },
{
@ -270,6 +313,17 @@ export const Marketplace = {
],
columnWidth: 150
},
{
name: 'eBay.availableShippingServices',
label: 'Available Shipping Services',
type: 'stringList',
readOnly: true,
required: false,
visible: (objectData) => objectData?.provider === 'ebay',
extra: 'Loaded from eBay GeteBayDetails when this marketplace is synced.',
span: 2,
columnWidth: 280
},
{
name: 'config.accessTokenExpiresAt',
label: 'Access Token Expires At',

View File

@ -56,7 +56,11 @@ export const Printer = {
label: 'New Printer',
icon: PlusIcon,
content: (objectData, { onOk } = {}) => {
return createElement(NewPrinter, { defaultValues: objectData, onOk, reset: true })
return createElement(NewPrinter, {
defaultValues: objectData,
onOk,
reset: true
})
}
},
{
@ -123,7 +127,7 @@ export const Printer = {
},
{ type: 'divider' },
{
name: 'restartSubmenu',
name: 'restart',
label: 'Restart',
icon: ReloadIcon,
disabled: (objectData) => {
@ -131,11 +135,11 @@ export const Printer = {
},
children: [
{
name: 'restart',
name: 'restartPrinter',
type: 'modal',
modalWidth: 520,
modalCentered: true,
label: 'Restart',
label: 'Restart Printer',
icon: ReloadIcon,
disabled: (objectData) => {
return objectData?.online == false