Refactored management components to enhance data handling and UI consistency. Integrated ObjectTable for audit logs across various entities, including Parts, Products, and Users. Updated column visibility management and removed unused imports for cleaner code. Improved error handling in API interactions and streamlined component structures for better maintainability.

This commit is contained in:
Tom Butcher 2025-07-07 00:30:38 +01:00
parent fdc862d16c
commit 3c2d3ec858
45 changed files with 2442 additions and 2271 deletions

Binary file not shown.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 64 64"><path d="M56.476 33.038v30.613c0 6.668-3.507 10.148-10.252 10.148H10.251C3.506 73.799 0 70.344 0 63.651V33.038c0-6.7 3.532-10.149 10.251-10.149h9.197v6.122h-8.816c-2.906 0-4.51 1.499-4.51 4.535v29.59c0 3.042 1.579 4.541 4.484 4.541h35.238c2.88 0 4.509-1.499 4.509-4.541v-29.59c0-3.036-1.629-4.535-4.509-4.535h-8.842v-6.122h9.222c6.745 0 10.252 3.475 10.252 10.149" style="fill-rule:nonzero" transform="translate(5.274 -5.849)scale(.94647)"/><path d="M28.251 6.179c-1.586 0-2.905 1.297-2.905 2.823v31.365l.232 4.654-1.643-1.985-4.478-4.762a2.6 2.6 0 0 0-1.955-.857c-1.431 0-2.559 1.024-2.559 2.486 0 .771.309 1.333.828 1.853l10.29 9.903c.743.732 1.415.97 2.19.97.748 0 1.426-.238 2.163-.97l10.29-9.903c.525-.52.829-1.082.829-1.853 0-1.462-1.18-2.486-2.585-2.486-.732 0-1.43.295-1.929.857l-4.478 4.762-1.643 1.985.232-4.654V9.002c0-1.526-1.294-2.823-2.879-2.823" style="fill-rule:nonzero" transform="translate(5.274 -5.849)scale(.94647)"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.946467,0,0,0.946467,5.27386,-5.84858)">
<path d="M56.476,33.038L56.476,63.651C56.476,70.319 52.969,73.799 46.224,73.799L10.251,73.799C3.506,73.799 0,70.344 0,63.651L0,33.038C0,26.338 3.532,22.889 10.251,22.889L19.448,22.889L19.448,29.011L10.632,29.011C7.726,29.011 6.122,30.51 6.122,33.546L6.122,63.136C6.122,66.178 7.701,67.677 10.606,67.677L45.844,67.677C48.724,67.677 50.353,66.178 50.353,63.136L50.353,33.546C50.353,30.51 48.724,29.011 45.844,29.011L37.002,29.011L37.002,22.889L46.224,22.889C52.969,22.889 56.476,26.364 56.476,33.038Z" style="fill-rule:nonzero;"/>
<path d="M28.251,6.179C26.665,6.179 25.346,7.476 25.346,9.002L25.346,40.367L25.578,45.021L23.935,43.036L19.457,38.274C18.958,37.712 18.234,37.417 17.502,37.417C16.071,37.417 14.943,38.441 14.943,39.903C14.943,40.674 15.252,41.236 15.771,41.756L26.061,51.659C26.804,52.391 27.476,52.629 28.251,52.629C28.999,52.629 29.677,52.391 30.414,51.659L40.704,41.756C41.229,41.236 41.533,40.674 41.533,39.903C41.533,38.441 40.353,37.417 38.948,37.417C38.216,37.417 37.518,37.712 37.019,38.274L32.541,43.036L30.898,45.021L31.13,40.367L31.13,9.002C31.13,7.476 29.836,6.179 28.251,6.179Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,4 +1,4 @@
import React, { useContext, useRef } from 'react' import React, { useRef } from 'react'
import { import {
Button, Button,
Flex, Flex,
@ -12,14 +12,12 @@ import {
Badge Badge
} from 'antd' } from 'antd'
import { AuthContext } from '../context/AuthContext'
import IdDisplay from '../common/IdDisplay' import IdDisplay from '../common/IdDisplay'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import TimeDisplay from '../common/TimeDisplay' import TimeDisplay from '../common/TimeDisplay'
import ObjectTable from '../common/ObjectTable' import ObjectTable from '../common/ObjectTable'
import config from '../../../config'
import AuditLogIcon from '../../Icons/AuditLogIcon' import AuditLogIcon from '../../Icons/AuditLogIcon'
import XMarkIcon from '../../Icons/XMarkIcon' import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
@ -253,8 +251,6 @@ const AuditLogs = () => {
columns columns
) )
const { authenticated } = useContext(AuthContext)
const actionItems = { const actionItems = {
items: [ items: [
{ {
@ -294,10 +290,6 @@ const AuditLogs = () => {
) )
} }
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
return ( return (
<> <>
<Flex vertical={'true'} gap='large'> <Flex vertical={'true'} gap='large'>
@ -318,9 +310,8 @@ const AuditLogs = () => {
<ObjectTable <ObjectTable
ref={tableRef} ref={tableRef}
columns={visibleColumns} visibleColumns={columnVisibility}
url={`${config.backendUrl}/auditlogs`} type='auditLog'
authenticated={authenticated}
/> />
</Flex> </Flex>
</> </>

View File

@ -1,12 +1,10 @@
import React from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import config from '../../../../config' import config from '../../../../config'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
@ -17,10 +15,10 @@ import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from './LockIndicator' import LockIndicator from './LockIndicator'
import { import ActionHandler from '../../common/ActionHandler'
getModelProperties, import ObjectActions from '../../common/ObjectActions.jsx'
getPropertyValue import ObjectTable from '../../common/ObjectTable.jsx'
} from '../../../../database/ObjectModels' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const log = loglevel.getLogger('FilamentInfo') const log = loglevel.getLogger('FilamentInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -51,111 +49,139 @@ const FilamentInfo = () => {
editLoading, editLoading,
lock, lock,
fetchObject fetchObject
}) => ( }) => {
<Flex // Define actions for ActionHandler
gap='large' const actions = {
vertical='true' reload: () => {
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }} fetchObject()
> return true
<Flex justify={'space-between'}> },
<Space size='middle'> edit: () => {
<Space size='small'> startEditing()
<Dropdown return false
menu={{ },
items: [ cancelEdit: () => {
{ cancelEditing()
label: 'Reload Filament', return true
key: 'reload', },
icon: <ReloadIcon /> finishEdit: () => {
handleUpdate()
return true
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{
height: 'calc(var(--unit-100vh) - 155px)',
minHeight: 0
}}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='filament'
id={filamentId}
disabled={loading}
/>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'Filament Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflowY: 'scroll' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Filament Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
} }
], key='info'
onClick: ({ key }) => { >
if (key === 'reload') { <ObjectInfo
fetchObject() loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='filament'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
} }
} key='notes'
}} >
> <Card>
<Button disabled={loading}>Actions</Button> <NotesPanel _id={filamentId} />
</Dropdown> </Card>
<ViewButton </InfoCollapse>
loading={loading}
sections={[
{ key: 'info', label: 'Filament Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflowY: 'scroll' }}> <InfoCollapse
<Flex vertical gap={'large'}> title='Audit Logs'
<InfoCollapse icon={<AuditLogIcon />}
title='Filament Information' active={collapseState.auditLogs}
icon={<InfoCircleIcon />} onToggle={(expanded) =>
active={collapseState.info} updateCollapseState('auditLogs', expanded)
onToggle={(expanded) => updateCollapseState('info', expanded)} }
key='info' key='auditLogs'
> >
<ObjectInfo {loading ? (
loading={loading} <InfoCollapsePlaceholder />
indicator={<LoadingOutlined />} ) : (
isEditing={isEditing} <ObjectTable
items={getModelProperties('filament').map((prop) => ({ type='auditLog'
...prop, masterFilter={{ 'parent._id': filamentId }}
value: getPropertyValue(objectData, prop.name) visibleColumns={{ _id: false, 'parent._id': false }}
}))} />
/> )}
</InfoCollapse> </InfoCollapse>
</Flex>
<InfoCollapse </div>
title='Notes' </Flex>
icon={<NoteIcon />} )}
active={collapseState.notes} </ActionHandler>
onToggle={(expanded) => updateCollapseState('notes', expanded)} )
key='notes' }}
>
<Card>
<NotesPanel _id={filamentId} />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</EditObjectForm> </EditObjectForm>
) )
} }

View File

@ -1,10 +1,8 @@
import React from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown } from 'antd' import { Space, Flex } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton' import ViewButton from '../../common/ViewButton'
@ -13,10 +11,10 @@ import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../Filaments/LockIndicator'
import { import ActionHandler from '../../common/ActionHandler.jsx'
getModelProperties, import ObjectActions from '../../common/ObjectActions.jsx'
getPropertyValue import ObjectTable from '../../common/ObjectTable.jsx'
} from '../../../../database/ObjectModels.js' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const NoteTypeInfo = () => { const NoteTypeInfo = () => {
const location = useLocation() const location = useLocation()
@ -32,7 +30,7 @@ const NoteTypeInfo = () => {
return ( return (
<EditObjectForm <EditObjectForm
id={noteTypeId} id={noteTypeId}
type='notetype' type='noteType'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }} style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
> >
{({ {({
@ -46,99 +44,121 @@ const NoteTypeInfo = () => {
editLoading, editLoading,
lock, lock,
fetchObject fetchObject
}) => ( }) => {
<Flex // Define actions for ActionHandler
gap='large' const actions = {
vertical='true' reload: () => {
style={{ height: '100%', minHeight: 0 }} fetchObject()
> return true
<Flex justify={'space-between'}> },
<Space size='middle'> edit: () => {
<Space size='small'> startEditing()
<Dropdown return false
menu={{ },
items: [ cancelEdit: () => {
{ cancelEditing()
label: 'Reload Note Type', return true
key: 'reload', },
icon: <ReloadIcon /> finishEdit: () => {
} handleUpdate()
], return true
onClick: ({ key }) => { }
if (key === 'reload') { }
fetchObject()
}
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
sections={[
{ key: 'info', label: 'Note Type Information' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}> return (
<Flex vertical gap={'large'}> <ActionHandler actions={actions} loading={loading}>
<InfoCollapse {({ callAction }) => (
title='Note Type Information' <Flex
icon={<InfoCircleIcon />} gap='large'
active={collapseState.info} vertical='true'
onToggle={(expanded) => updateCollapseState('info', expanded)} style={{ height: '100%', minHeight: 0 }}
key='info'
> >
<ObjectInfo <Flex justify={'space-between'}>
loading={loading} <Space size='middle'>
indicator={<LoadingOutlined />} <Space size='small'>
isEditing={isEditing} <ObjectActions
type='noteType' type='noteType'
items={getModelProperties('noteType').map((prop) => ({ id={noteTypeId}
...prop, disabled={loading}
value: getPropertyValue(objectData, prop.name) />
}))} <ViewButton
/> disabled={loading}
</InfoCollapse> items={[
{ key: 'info', label: 'Note Type Information' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<InfoCollapse <div style={{ height: '100%', overflow: 'auto' }}>
title='Audit Logs' <Flex vertical gap={'large'}>
icon={<AuditLogIcon />} <InfoCollapse
active={collapseState.auditLogs} title='Note Type Information'
onToggle={(expanded) => icon={<InfoCircleIcon />}
updateCollapseState('auditLogs', expanded) active={collapseState.info}
} onToggle={(expanded) =>
key='auditLogs' updateCollapseState('info', expanded)
> }
<AuditLogTable key='info'
items={objectData?.auditLogs || []} >
loading={loading} <ObjectInfo
showTargetColumn={false} loading={loading}
/> indicator={<LoadingOutlined />}
</InfoCollapse> isEditing={isEditing}
</Flex> type='noteType'
</div> objectData={objectData}
</Flex> />
)} </InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': noteTypeId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</ActionHandler>
)
}}
</EditObjectForm> </EditObjectForm>
) )
} }

View File

@ -1,11 +1,8 @@
// src/gcodefiles.js // src/gcodefiles.js
import React, { useState, useContext, useRef } from 'react' import React, { useState, useRef } from 'react'
import { Button, Flex, Space, Modal, Dropdown, message } from 'antd' import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import { AuthContext } from '../context/AuthContext'
import ObjectTable from '../common/ObjectTable' import ObjectTable from '../common/ObjectTable'
import NewProduct from './Products/NewProduct' import NewProduct from './Products/NewProduct'
@ -20,14 +17,12 @@ import useViewMode from '../hooks/useViewMode'
import ColumnViewButton from '../common/ColumnViewButton' import ColumnViewButton from '../common/ColumnViewButton'
const Parts = () => { const Parts = (filter) => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const [newProductOpen, setNewProductOpen] = useState(false) const [newProductOpen, setNewProductOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('part') const [viewMode, setViewMode] = useViewMode('part')
const [columnVisibility, setColumnVisibility] = useColumnVisibility('part') const [columnVisibility, setColumnVisibility] = useColumnVisibility('part')
const actionItems = { const actionItems = {
@ -82,8 +77,8 @@ const Parts = () => {
ref={tableRef} ref={tableRef}
visibleColumns={columnVisibility} visibleColumns={columnVisibility}
type='part' type='part'
authenticated={authenticated}
cards={viewMode === 'cards'} cards={viewMode === 'cards'}
filter={filter}
/> />
</Flex> </Flex>
<Modal <Modal

View File

@ -1,709 +1,187 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useContext } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import axios from 'axios' import { Space, Flex, Card } from 'antd'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Card,
Flex,
Form,
Input,
Checkbox,
InputNumber,
Switch,
Tag,
Collapse,
Dropdown,
Popover
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdDisplay from '../../common/IdDisplay.jsx'
import { StlViewer } from 'react-stl-viewer'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import TimeDisplay from '../../common/TimeDisplay.jsx'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import config from '../../../../config.js' import ObjectInfo from '../../common/ObjectInfo'
import BoolDisplay from '../../common/BoolDisplay.jsx' import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import PartIcon from '../../../Icons/PartIcon.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'
import loglevel from 'loglevel' import ActionHandler from '../../common/ActionHandler.jsx'
const logger = loglevel.getLogger('PartInfo') import ObjectActions from '../../common/ObjectActions.jsx'
logger.setLevel(config.logLevel) import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const { Title, Text } = Typography import { ApiServerContext } from '../../context/ApiServerContext'
const PartInfo = () => { const PartInfo = () => {
const [partData, setPartData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation() const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const partId = new URLSearchParams(location.search).get('partId') const partId = new URLSearchParams(location.search).get('partId')
const [marginOrPrice, setMarginOrPrice] = useState(false)
const [useGlobalPricing, setUseGlobalPricing] = useState(true) const { handleDownloadContent } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', { const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
info: true, info: true,
preview: true, parts: true,
notes: true, notes: true,
auditLogs: true auditLogs: true
}) })
const [partForm] = Form.useForm()
const [partFormValues, setPartFormValues] = useState({})
// Add a ref to store the object URL
const objectUrlRef = useRef(null)
// Add a ref to store the array buffer
const arrayBufferRef = useRef(null)
const [isEditing, setIsEditing] = useState(false)
const [fetchLoading, setFetchLoading] = useState(true)
const [partFileObjectId, setPartFileObjectId] = useState(null)
const [stlLoadError, setStlLoadError] = useState(null)
useEffect(() => {
async function fetchData() {
await fetchPartDetails()
setTimeout(async () => {
await fetchPartContent()
}, 1000)
}
if (partId) {
fetchData()
}
}, [partId])
useEffect(() => {
if (partData) {
partForm.setFieldsValue({
name: partData.name || '',
price: partData.price || null,
margin: partData.margin || null,
marginOrPrice: partData.marginOrPrice,
useGlobalPricing: partData.useGlobalPricing,
createdAt: partData.createdAt || null,
updatedAt: partData.updatedAt || null
})
setPartFormValues(partData)
}
}, [partData, partForm])
useEffect(() => {
setMarginOrPrice(partFormValues.marginOrPrice)
setUseGlobalPricing(partFormValues.useGlobalPricing)
}, [partFormValues])
const fetchPartDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(`${config.backendUrl}/parts/${partId}`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setPartData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch part details')
logger.debug(err)
messageApi.error('Failed to fetch part details')
} finally {
setFetchLoading(false)
}
}
const fetchPartContent = async () => {
if (fetchLoading == true) {
return
}
try {
setFetchLoading(true)
// Cleanup previous object URL if it exists
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
const response = await axios.get(
`${config.backendUrl}/parts/${partId}/content`,
{
withCredentials: true,
responseType: 'blob'
}
)
// Check file size before processing
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
if (response.data.size > MAX_FILE_SIZE) {
throw new Error(
`File size exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit`
)
}
// Convert blob to array buffer for better memory management
const arrayBuffer = await response.data.arrayBuffer()
// Store array buffer in ref for later cleanup
arrayBufferRef.current = arrayBuffer
// Create a new blob from the array buffer
const blob = new Blob([arrayBuffer], { type: response.data.type })
try {
// Create and store object URL
const objectUrl = URL.createObjectURL(blob)
objectUrlRef.current = objectUrl
// Update state with the new object URL
setPartFileObjectId(objectUrl)
setStlLoadError(null)
setError(null)
} catch (allocErr) {
setStlLoadError(
'Failed to load STL file: Array buffer allocation failed'
)
console.error('STL allocation error:', allocErr)
}
} catch (err) {
setError('Failed to fetch part content')
logger.debug(err)
messageApi.error('Failed to fetch part content')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
// Reset form values to original data
if (partData) {
partForm.setFieldsValue({
name: partData.name || '',
price: partData.price || null,
margin: partData.margin || null,
marginOrPrice: partData.marginOrPrice,
useGlobalPricing: partData.useGlobalPricing
})
}
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await partForm.validateFields()
setLoading(true)
await axios.put(`${config.backendUrl}/parts/${partId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
// Update the local state with the new values
setPartData({ ...partData, ...values })
setIsEditing(false)
messageApi.success('Part information updated successfully')
} catch (err) {
if (err.errorFields) {
// This is a form validation error
return
}
console.error('Failed to update part information:', err)
messageApi.error('Failed to update part information')
} finally {
await fetchPartDetails()
setLoading(false)
}
}
const actionItems = {
items: [
{
label: 'Reload Part',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchPartDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Part Information' },
{ key: 'preview', label: 'Part Preview' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{sections.map((section) => (
<Checkbox
checked={collapseState[section.key]}
key={section.key}
onChange={(e) => {
updateCollapseState(section.key, e.target.checked)
}}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
)
}
if (error) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Part not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchPartDetails}>
Retry
</Button>
</Space>
)
}
return ( return (
<> <EditObjectForm
{contextHolder} id={partId}
<Flex type='part'
gap='large' style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
vertical='true' >
style={{ height: '100%', minHeight: 0 }} {({
> loading,
<Flex justify={'space-between'}> isEditing,
<Space size='small'> startEditing,
<Dropdown menu={actionItems}> cancelEditing,
<Button>Actions</Button> handleUpdate,
</Dropdown> formValid,
<Popover objectData,
content={getViewDropdownItems()} editLoading,
placement='bottomLeft' lock,
arrow={false} fetchObject
> }) => {
<Button>View</Button> const actions = {
</Popover> reload: () => {
</Space> fetchObject()
<Space> return true
{isEditing ? ( },
<> edit: () => {
<Button startEditing()
icon={<CheckIcon />} return false
type='primary' },
onClick={updateInfo} cancelEdit: () => {
loading={loading} cancelEditing()
disabled={loading} return true
/> },
<Button finishEdit: () => {
icon={<XMarkIcon />} handleUpdate()
onClick={cancelEditing} return true
disabled={loading} },
/> download: () => {
</> if (partId) {
) : ( handleDownloadContent(partId, 'part', `${objectData.name}.stl`)
<Button icon={<EditIcon />} onClick={startEditing} /> return true
)} }
</Space> }
</Flex> }
return (
{error ? ( <ActionHandler actions={actions} loading={loading}>
<Space {({ callAction }) => (
direction='vertical' <Flex
style={{ width: '100%', textAlign: 'center' }} gap='large'
> vertical='true'
<p>{error || 'Part not found'}</p> style={{ height: '100%', minHeight: 0 }}
<Button icon={<ReloadIcon />} onClick={fetchPartDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
> >
<Collapse.Panel <Flex justify={'space-between'}>
header={ <Space size='middle'>
<Flex align='center' gap={'middle'}> <Space size='small'>
<InfoCircleIcon /> <ObjectActions
<Title level={5} style={{ margin: 0 }}> type='part'
Part Information id={partId}
</Title> disabled={loading}
</Flex> />
} <ViewButton
key='1' disabled={loading}
> items={[
<Form { key: 'info', label: 'Part Information' },
form={partForm} { key: 'notes', label: 'Notes' },
layout='vertical' { key: 'auditLogs', label: 'Audit Logs' }
onValuesChange={(changedValues) => ]}
setPartFormValues((prevValues) => ({ visibleState={collapseState}
...prevValues, updateVisibleState={updateCollapseState}
...changedValues />
})) </Space>
} <LockIndicator lock={lock} />
initialValues={{ </Space>
name: partData?.name || '', <Space>
version: partData?.version || '', <EditButtons
tags: partData?.tags || [] isEditing={isEditing}
}} handleUpdate={() => {
> callAction('finishEdit')
<Spin }}
indicator={<LoadingOutlined />} cancelEditing={() => {
spinning={fetchLoading} callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Part Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
key='info'
> >
<Descriptions <ObjectInfo
bordered loading={loading}
column={{ isEditing={isEditing}
xs: 1, type='part'
sm: 1, objectData={objectData}
md: 1, />
lg: 2, </InfoCollapse>
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{partData?.id ? (
<IdDisplay id={partData.id} type='part'></IdDisplay>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{partData?.createdAt ? (
<TimeDisplay
dateTime={partData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}> <InfoCollapse
{isEditing ? ( title='Notes'
<Form.Item icon={<NoteIcon />}
name='name' active={collapseState.notes}
rules={[ onToggle={(expanded) =>
{ updateCollapseState('notes', expanded)
required: true, }
message: 'Please enter a product name' key='notes'
}, >
{ <Card>
max: 100, <NotesPanel _id={partId} />
message: 'Name cannot exceed 100 characters' </Card>
} </InfoCollapse>
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : partData?.name ? (
<Text>{partData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'> <InfoCollapse
{partData?.updatedAt ? ( title='Audit Logs'
<TimeDisplay icon={<AuditLogIcon />}
dateTime={partData.updatedAt} active={collapseState.auditLogs}
showSince={true} onToggle={(expanded) =>
/> updateCollapseState('auditLogs', expanded)
) : ( }
<Text>n/a</Text> key='auditLogs'
)} >
</Descriptions.Item> {loading ? (
<InfoCollapsePlaceholder />
<Descriptions.Item label='Product Name' span={1}> ) : (
{partData?.product?.name ? ( <ObjectTable
<Text>{partData.product.name}</Text> type='auditLog'
) : ( masterFilter={{ 'parent._id': partId }}
<Text>n/a</Text> visibleColumns={{ _id: false, 'parent._id': false }}
)} />
</Descriptions.Item> )}
<Descriptions.Item label='Product ID' span={1}> </InfoCollapse>
{partData?.product?._id ? ( </Flex>
<IdDisplay </div>
id={partData.product._id} </Flex>
type={'product'} )}
showHyperlink={true} </ActionHandler>
/> )
) : ( }}
<Text>n/a</Text> </EditObjectForm>
)}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData?.margin &&
marginOrPrice == false &&
partData?.useGlobalPricing == false ? (
<Text>{partData.margin + '%'}</Text>
) : partData?.price &&
marginOrPrice == true &&
partData?.useGlobalPricing == false ? (
<Text>{'£' + partData.price}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData ? (
<BoolDisplay
value={partData.useGlobalPricing}
yesNo={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{partData?.version ? (
<Text>{partData.version}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{partData?.tags && partData.tags.length > 0 ? (
partData.tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('preview', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<PartIcon />
<Title level={5} style={{ margin: 0 }}>
Part Preview
</Title>
</Flex>
}
key='2'
>
<Card styles={{ body: { padding: '10px' } }}>
{stlLoadError ? (
<div
style={{
height: '40vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5'
}}
>
<Space direction='vertical' align='center'>
<XMarkIcon
style={{ fontSize: '24px', color: '#ff4d4f' }}
/>
<Typography.Text type='danger'>
{stlLoadError}
</Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
)
)}
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.notes ? ['notes'] : []}
onChange={(keys) =>
updateCollapseState('notes', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
}
key='notes'
>
<Card>
<NotesPanel _id={partId} />
</Card>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['auditLogs'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='auditLogs'
>
<AuditLogTable
items={partData?.auditLogs || []}
loading={fetchLoading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)}
</Flex>
</>
) )
} }

View File

@ -1,9 +1,7 @@
import React from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
@ -11,11 +9,14 @@ import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../Filaments/LockIndicator'
import PartsTable from '../../common/PartsTable'
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'
import ProductIcon from '../../../Icons/ProductIcon.jsx' import ProductIcon from '../../../Icons/ProductIcon.jsx'
import ObjectTable from '../../common/ObjectTable.jsx'
import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const ProductInfo = () => { const ProductInfo = () => {
const location = useLocation() const location = useLocation()
@ -44,191 +45,153 @@ const ProductInfo = () => {
editLoading, editLoading,
lock, lock,
fetchObject fetchObject
}) => ( }) => {
<Flex const actions = {
gap='large' reload: () => {
vertical='true' fetchObject()
style={{ height: '100%', minHeight: 0 }} return true
> },
<Flex justify={'space-between'}> edit: () => {
<Space size='middle'> startEditing()
<Space size='small'> return false
<Dropdown },
menu={{ cancelEdit: () => {
items: [ cancelEditing()
{ return true
label: 'Reload Product', },
key: 'reload', finishEdit: () => {
icon: <ReloadIcon /> handleUpdate()
return true
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='product'
id={productId}
disabled={loading}
/>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'Product Information' },
{ key: 'parts', label: 'Product Parts' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Product Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
} }
], key='info'
onClick: ({ key }) => { >
if (key === 'reload') { <ObjectInfo
fetchObject() loading={loading}
isEditing={isEditing}
type='product'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Product Parts'
icon={<ProductIcon />}
active={collapseState.parts}
onToggle={(expanded) =>
updateCollapseState('parts', expanded)
} }
} key='parts'
}} >
> <ObjectTable
<Button disabled={loading}>Actions</Button> type='part'
</Dropdown> visibleColumns={{
<ViewButton product: false,
loading={loading} 'product._id': false
sections={[ }}
{ key: 'info', label: 'Product Information' }, masterFilter={{ 'product._id': productId }}
{ key: 'parts', label: 'Product Parts' }, />
{ key: 'notes', label: 'Notes' }, </InfoCollapse>
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}> <InfoCollapse
<Flex vertical gap={'large'}> title='Notes'
<InfoCollapse icon={<NoteIcon />}
title='Product Information' active={collapseState.notes}
icon={<InfoCircleIcon />} onToggle={(expanded) =>
active={collapseState.info} updateCollapseState('notes', expanded)
onToggle={(expanded) => updateCollapseState('info', expanded)} }
key='info' key='notes'
> >
<ObjectInfo <Card>
loading={loading} <NotesPanel _id={productId} />
isEditing={isEditing} </Card>
indicator={null} </InfoCollapse>
type='product'
items={[
{
name: '_id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'product',
showCopy: true,
readOnly: true
},
{
name: 'createdAt',
label: 'Created At',
value: objectData?.createdAt,
type: 'dateTime',
readOnly: true
},
{
name: 'name',
label: 'Name',
value: objectData?.name,
required: true,
type: 'text'
},
{
name: 'updatedAt',
label: 'Updated At',
value: objectData?.updatedAt,
type: 'dateTime',
readOnly: true
},
{
name: 'vendor',
label: 'Vendor',
value: objectData?.vendor,
required: true,
type: 'object',
objectType: 'vendor'
},
{
name: 'version',
label: 'Version',
value: objectData?.version,
type: 'text'
},
{
name: 'tags',
label: 'Tags',
value: objectData?.tags,
type: 'tags'
},
{
name: 'marginOrPrice',
label: 'Price Mode',
value: objectData?.marginOrPrice,
type: 'bool'
},
{
name: 'margin',
label: 'Margin',
value: objectData?.margin,
type: 'number',
formItemProps: { min: 0, max: 100, step: 0.01 }
},
{
name: 'price',
label: 'Price',
value: objectData?.price,
type: 'number',
formItemProps: { min: 0, step: 0.01 }
}
]}
/>
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Product Parts' title='Audit Logs'
icon={<ProductIcon />} icon={<AuditLogIcon />}
active={collapseState.parts} active={collapseState.auditLogs}
onToggle={(expanded) => updateCollapseState('parts', expanded)} onToggle={(expanded) =>
key='parts' updateCollapseState('auditLogs', expanded)
> }
<PartsTable data={objectData?.parts || []} /> key='auditLogs'
</InfoCollapse> >
{loading ? (
<InfoCollapse <InfoCollapsePlaceholder />
title='Notes' ) : (
icon={<NoteIcon />} <ObjectTable
active={collapseState.notes} type='auditLog'
onToggle={(expanded) => updateCollapseState('notes', expanded)} masterFilter={{ 'parent._id': productId }}
key='notes' visibleColumns={{ _id: false, 'parent._id': false }}
> />
<Card> )}
<NotesPanel _id={productId} /> </InfoCollapse>
</Card> </Flex>
</InfoCollapse> </div>
</Flex>
<InfoCollapse )}
title='Audit Logs' </ActionHandler>
icon={<AuditLogIcon />} )
active={collapseState.auditLogs} }}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</EditObjectForm> </EditObjectForm>
) )
} }

View File

@ -40,9 +40,9 @@ const Users = () => {
</Dropdown> </Dropdown>
<ColumnViewButton <ColumnViewButton
type='user' type='user'
loading={false} disabled={false}
collapseState={columnVisibility} visibleState={columnVisibility}
updateCollapseState={setColumnVisibility} updateVisibleState={setColumnVisibility}
/> />
</Space> </Space>
<Space> <Space>

View File

@ -1,10 +1,8 @@
import React from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
@ -15,10 +13,10 @@ import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../Filaments/LockIndicator'
import { import ActionHandler from '../../common/ActionHandler'
getModelProperties, import ObjectActions from '../../common/ObjectActions.jsx'
getPropertyValue import ObjectTable from '../../common/ObjectTable.jsx'
} from '../../../../database/ObjectModels.js' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const UserInfo = () => { const UserInfo = () => {
const location = useLocation() const location = useLocation()
@ -46,112 +44,136 @@ const UserInfo = () => {
editLoading, editLoading,
lock, lock,
fetchObject fetchObject
}) => ( }) => {
<Flex // Define actions for ActionHandler
gap='large' const actions = {
vertical='true' reload: () => {
style={{ height: '100%', minHeight: 0 }} fetchObject()
> return true
<Flex justify={'space-between'}> },
<Space size='middle'> edit: () => {
<Space size='small'> startEditing()
<Dropdown return false
menu={{ },
items: [ cancelEdit: () => {
{ cancelEditing()
label: 'Reload User', return true
key: 'reload', },
icon: <ReloadIcon /> finishEdit: () => {
handleUpdate()
return true
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='user'
id={userId}
disabled={loading}
/>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'User Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading || true}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='User Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
} }
], key='info'
onClick: ({ key }) => { >
if (key === 'reload') { <ObjectInfo
fetchObject() loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='user'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
} }
} key='notes'
}} >
> <Card>
<Button disabled={loading}>Actions</Button> <NotesPanel _id={userId} />
</Dropdown> </Card>
<ViewButton </InfoCollapse>
loading={loading}
sections={[
{ key: 'info', label: 'User Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading || true}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}> <InfoCollapse
<Flex vertical gap={'large'}> title='Audit Logs'
<InfoCollapse icon={<AuditLogIcon />}
title='User Information' active={collapseState.auditLogs}
icon={<InfoCircleIcon />} onToggle={(expanded) =>
active={collapseState.info} updateCollapseState('auditLogs', expanded)
onToggle={(expanded) => updateCollapseState('info', expanded)} }
key='info' key='auditLogs'
> >
<ObjectInfo {loading ? (
loading={loading} <InfoCollapsePlaceholder />
indicator={<LoadingOutlined />} ) : (
isEditing={isEditing} <ObjectTable
type='user' type='auditLog'
items={getModelProperties('user').map((prop) => ({ masterFilter={{ 'parent._id': userId }}
...prop, visibleColumns={{ _id: false, 'parent._id': false }}
value: getPropertyValue(objectData, prop.name) />
}))} )}
/> </InfoCollapse>
</InfoCollapse> </Flex>
</div>
<InfoCollapse </Flex>
title='Notes' )}
icon={<NoteIcon />} </ActionHandler>
active={collapseState.notes} )
onToggle={(expanded) => updateCollapseState('notes', expanded)} }}
key='notes'
>
<Card>
<NotesPanel _id={userId} />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</EditObjectForm> </EditObjectForm>
) )
} }

View File

@ -1,12 +1,9 @@
import React from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import config from '../../../../config' import config from '../../../../config'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
@ -17,10 +14,10 @@ import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm' import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons' import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator' import LockIndicator from '../Filaments/LockIndicator'
import { import ActionHandler from '../../common/ActionHandler.jsx'
getModelProperties, import ObjectActions from '../../common/ObjectActions.jsx'
getPropertyValue import ObjectTable from '../../common/ObjectTable.jsx'
} from '../../../../database/ObjectModels' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const log = loglevel.getLogger('VendorInfo') const log = loglevel.getLogger('VendorInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -51,111 +48,135 @@ const VendorInfo = () => {
editLoading, editLoading,
lock, lock,
fetchObject fetchObject
}) => ( }) => {
<Flex // Define actions for ActionHandler
gap='large' const actions = {
vertical='true' reload: () => {
style={{ height: '100%', minHeight: 0 }} fetchObject()
> return true
<Flex justify={'space-between'}> },
<Space size='middle'> edit: () => {
<Space size='small'> startEditing()
<Dropdown return false
menu={{ },
items: [ cancelEdit: () => {
{ cancelEditing()
label: 'Reload Vendor', return true
key: 'reload', },
icon: <ReloadIcon /> finishEdit: () => {
handleUpdate()
return true
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='vendor'
id={vendorId}
disabled={loading}
/>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'Vendor Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Vendor Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
} }
], key='info'
onClick: ({ key }) => { >
if (key === 'reload') { <ObjectInfo
fetchObject() loading={loading}
isEditing={isEditing}
type='vendor'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
} }
} key='notes'
}} >
> <Card>
<Button disabled={loading}>Actions</Button> <NotesPanel _id={vendorId} />
</Dropdown> </Card>
<ViewButton </InfoCollapse>
loading={loading}
sections={[
{ key: 'info', label: 'Vendor Information' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}> <InfoCollapse
<Flex vertical gap={'large'}> title='Audit Logs'
<InfoCollapse icon={<AuditLogIcon />}
title='Vendor Information' active={collapseState.auditLogs}
icon={<InfoCircleIcon />} onToggle={(expanded) =>
active={collapseState.info} updateCollapseState('auditLogs', expanded)
onToggle={(expanded) => updateCollapseState('info', expanded)} }
key='info' key='auditLogs'
> >
<ObjectInfo {loading ? (
loading={loading} <InfoCollapsePlaceholder />
indicator={<LoadingOutlined />} ) : (
isEditing={isEditing} <ObjectTable
items={getModelProperties('vendor').map((prop) => ({ type='auditLog'
...prop, masterFilter={{ 'parent._id': vendorId }}
value: getPropertyValue(objectData, prop.name) visibleColumns={{ _id: false, 'parent._id': false }}
}))} />
/> )}
</InfoCollapse> </InfoCollapse>
</Flex>
<InfoCollapse </div>
title='Notes' </Flex>
icon={<NoteIcon />} )}
active={collapseState.notes} </ActionHandler>
onToggle={(expanded) => updateCollapseState('notes', expanded)} )
key='notes' }}
>
<Card>
<NotesPanel _id={vendorId} />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</EditObjectForm> </EditObjectForm>
) )
} }

View File

@ -1,8 +1,7 @@
// src/gcodefiles.js // src/gcodefiles.js
import React, { useState, useContext, useRef } from 'react' import React, { useState, useRef } from 'react'
import { Button, Flex, Space, Modal, Dropdown, message } from 'antd' import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import { AuthContext } from '../context/AuthContext'
import NewGCodeFile from './GCodeFiles/NewGCodeFile' import NewGCodeFile from './GCodeFiles/NewGCodeFile'
import useColumnVisibility from '../hooks/useColumnVisibility' import useColumnVisibility from '../hooks/useColumnVisibility'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
@ -23,8 +22,6 @@ const GCodeFiles = () => {
const [columnVisibility, setColumnVisibility] = const [columnVisibility, setColumnVisibility] =
useColumnVisibility('gcodeFile') useColumnVisibility('gcodeFile')
const { authenticated } = useContext(AuthContext)
const actionItems = { const actionItems = {
items: [ items: [
{ {
@ -59,9 +56,9 @@ const GCodeFiles = () => {
</Dropdown> </Dropdown>
<ColumnViewButton <ColumnViewButton
type='gcodeFile' type='gcodeFile'
loading={false} disabled={false}
collapseState={columnVisibility} visibleState={columnVisibility}
updateCollapseState={setColumnVisibility} updateVisibleState={setColumnVisibility}
/> />
</Space> </Space>
<Space> <Space>
@ -75,8 +72,7 @@ const GCodeFiles = () => {
</Flex> </Flex>
<ObjectTable <ObjectTable
ref={tableRef} ref={tableRef}
type={'gcodeFile'} type='gcodeFile'
authenticated={authenticated}
cards={viewMode === 'cards'} cards={viewMode === 'cards'}
visibleColumns={columnVisibility} visibleColumns={columnVisibility}
/> />

View File

@ -1,10 +1,8 @@
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card, Typography } from 'antd' import { Space, Flex, Card, Typography } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
@ -18,10 +16,9 @@ import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx' import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
import { ApiServerContext } from '../../context/ApiServerContext' import { ApiServerContext } from '../../context/ApiServerContext'
import { import ObjectActions from '../../common/ObjectActions.jsx'
getModelProperties, import ObjectTable from '../../common/ObjectTable.jsx'
getPropertyValue import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
} from '../../../../database/ObjectModels.js'
const { Text } = Typography const { Text } = Typography
@ -31,7 +28,7 @@ const GCodeFileInfo = () => {
const { handleDownloadContent } = useContext(ApiServerContext) const { handleDownloadContent } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState( const [collapseState, updateCollapseState] = useCollapseState(
'GCodeFileInfo', 'gcodeFileInfo',
{ {
info: true, info: true,
preview: true, preview: true,
@ -40,183 +37,187 @@ const GCodeFileInfo = () => {
} }
) )
// Define actions that can be triggered via URL
const actions = {
download: () => {
if (gcodeFileId) {
handleDownloadContent(
gcodeFileId,
'gcodeFile',
`gcodeFile-${gcodeFileId}.gcode`
)
}
}
}
return ( return (
<> <EditObjectForm
<ActionHandler actions={actions} /> id={gcodeFileId}
<EditObjectForm type='gcodefile'
id={gcodeFileId} style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
type='gcodefile' >
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }} {({
> loading,
{({ isEditing,
loading, startEditing,
isEditing, cancelEditing,
startEditing, handleUpdate,
cancelEditing, formValid,
handleUpdate, objectData,
formValid, editLoading,
objectData, lock,
editLoading, fetchObject
lock, }) => {
fetchObject // Define actions that can be triggered via URL, now with access to startEditing
}) => ( const actions = {
<Flex reload: () => {
gap='large' fetchObject()
vertical='true' return true
style={{ height: '100%', minHeight: 0 }} },
> edit: () => {
<Flex justify={'space-between'}> startEditing()
<Space size='middle'> return false
<Space size='small'> },
<Dropdown cancelEdit: () => {
menu={{ cancelEditing()
items: [ return true
{ },
label: 'Reload GCode File', finishEdit: () => {
key: 'reload', handleUpdate()
icon: <ReloadIcon /> return true
}, },
{ download: () => {
label: 'Download GCode File', if (gcodeFileId) {
key: 'download', handleDownloadContent(
icon: <GCodeFileIcon /> gcodeFileId,
} 'gcodeFile',
], `${objectData.name}.gcode`
onClick: ({ key }) => { )
if (key === 'reload') { return true
fetchObject() }
} else if (key === 'download' && gcodeFileId) { }
handleDownloadContent( }
gcodeFileId,
'gcodefile',
`gcodefile-${gcodeFileId}.gcode`
)
}
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
sections={[
{ key: 'info', label: 'GCode File Information' },
{ key: 'preview', label: 'GCode File Preview' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}> return (
<Flex vertical gap={'large'}> <ActionHandler actions={actions} loading={loading}>
<InfoCollapse {({ callAction }) => (
title='GCode File Information' <Flex
icon={<InfoCircleIcon />} gap='large'
active={collapseState.info} vertical='true'
onToggle={(expanded) => updateCollapseState('info', expanded)} style={{ height: '100%', minHeight: 0 }}
key='info' >
> <Flex justify={'space-between'}>
<ObjectInfo <Space size='middle'>
loading={loading} <Space size='small'>
indicator={<LoadingOutlined />} <ObjectActions
isEditing={isEditing} type='gcodeFile'
items={getModelProperties('gcodeFile').map((prop) => ({ id={gcodeFileId}
...prop, disabled={loading}
value: getPropertyValue(objectData, prop.name)
}))}
objectData={objectData}
type='gcodefile'
/>
</InfoCollapse>
<InfoCollapse
title='GCode File Preview'
icon={<GCodeFileIcon />}
active={collapseState.preview}
onToggle={(expanded) =>
updateCollapseState('preview', expanded)
}
key='preview'
>
<Card>
{objectData?.gcodeFileInfo?.thumbnail ? (
<img
src={`data:image/png;base64,${objectData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '100%' }}
/> />
) : ( <ViewButton
<Text>n/a</Text> disabled={loading}
)} items={[
</Card> { key: 'info', label: 'GCode File Information' },
</InfoCollapse> { key: 'preview', label: 'GCode File Preview' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<InfoCollapse <div style={{ height: '100%', overflow: 'auto' }}>
title='Notes' <Flex vertical gap={'large'}>
icon={<NoteIcon />} <InfoCollapse
active={collapseState.notes} title='GCode File Information'
onToggle={(expanded) => icon={<InfoCircleIcon />}
updateCollapseState('notes', expanded) active={collapseState.info}
} onToggle={(expanded) =>
key='notes' updateCollapseState('info', expanded)
> }
<Card> key='info'
<NotesPanel _id={gcodeFileId} /> >
</Card> <ObjectInfo
</InfoCollapse> loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
objectData={objectData}
type='gcodeFile'
/>
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Audit Logs' title='GCode File Preview'
icon={<AuditLogIcon />} icon={<GCodeFileIcon />}
active={collapseState.auditLogs} active={collapseState.preview}
onToggle={(expanded) => onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded) updateCollapseState('preview', expanded)
} }
key='auditLogs' key='preview'
> >
<AuditLogTable <Card>
items={objectData?.auditLogs || []} {objectData?.gcodeFileInfo?.thumbnail ? (
loading={loading} <img
showTargetColumn={false} src={`data:image/png;base64,${objectData.gcodeFileInfo.thumbnail.data}`}
/> alt='GCodeFile'
</InfoCollapse> style={{ maxWidth: '100%' }}
/>
) : (
<Text>n/a</Text>
)}
</Card>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
key='notes'
>
<Card>
<NotesPanel _id={gcodeFileId} />
</Card>
</InfoCollapse>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': gcodeFileId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</div>
</Flex> </Flex>
</div> )}
</Flex> </ActionHandler>
)} )
</EditObjectForm> }}
</> </EditObjectForm>
) )
} }

View File

@ -1,313 +1,27 @@
// src/Jobs.js // src/Jobs.js
import React, { useState, useContext, useRef } from 'react' import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import {
Button,
Flex,
Space,
Modal,
Dropdown,
message,
notification,
Input,
Typography,
Checkbox,
Popover
} from 'antd'
import { AuthContext } from '../context/AuthContext.js' import { AuthContext } from '../context/AuthContext.js'
import { PrintServerContext } from '../context/PrintServerContext.js'
import NewJob from './Jobs/NewJob.jsx' import NewJob from './Jobs/NewJob.jsx'
import JobState from '../common/JobState.jsx'
import SubJobCounter from '../common/SubJobCounter.jsx'
import TimeDisplay from '../common/TimeDisplay.jsx'
import IdDisplay from '../common/IdDisplay.jsx'
import useColumnVisibility from '../hooks/useColumnVisibility.js' import useColumnVisibility from '../hooks/useColumnVisibility.js'
import JobIcon from '../../Icons/JobIcon.jsx'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import PlusIcon from '../../Icons/PlusIcon.jsx' import PlusIcon from '../../Icons/PlusIcon.jsx'
import ReloadIcon from '../../Icons/ReloadIcon.jsx' import ReloadIcon from '../../Icons/ReloadIcon.jsx'
import EditIcon from '../../Icons/EditIcon.jsx'
import XMarkIcon from '../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../Icons/CheckIcon.jsx'
import PlayCircleIcon from '../../Icons/PlayCircleIcon.jsx'
import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx'
import PauseCircleIcon from '../../Icons/PauseCircleIcon.jsx'
import XMarkCircleIcon from '../../Icons/XMarkCircleIcon.jsx'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon.jsx'
import ObjectTable from '../common/ObjectTable.jsx' import ObjectTable from '../common/ObjectTable.jsx'
import ListIcon from '../../Icons/ListIcon.jsx' import ListIcon from '../../Icons/ListIcon.jsx'
import GridIcon from '../../Icons/GridIcon.jsx' import GridIcon from '../../Icons/GridIcon.jsx'
import useViewMode from '../hooks/useViewMode.js' import useViewMode from '../hooks/useViewMode.js'
import ColumnViewButton from '../common/ColumnViewButton.jsx'
const { Text } = Typography
const Jobs = () => { const Jobs = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const [notificationApi, notificationContextHolder] =
notification.useNotification()
const navigate = useNavigate()
const [newJobOpen, setNewJobOpen] = useState(false) const [newJobOpen, setNewJobOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('Jobs') const [viewMode, setViewMode] = useViewMode('job')
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<XMarkIcon />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckIcon />}
/>
</Space.Compact>
</div>
)
}
// Column definitions
const columns = [
{
title: <JobIcon />,
key: 'icon',
width: 40,
fixed: 'left',
render: () => <JobIcon />
},
{
title: 'GCode File Name',
key: 'gcodeFileName',
width: 200,
fixed: 'left',
render: (record) => <Text ellipsis>{record?.gcodeFile?.name}</Text>,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'GCode file name'
}),
onFilter: (value, record) =>
record.gcodeFile.name.toLowerCase().includes(value.toLowerCase())
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 180,
render: (text) => <IdDisplay id={text} type={'job'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'ID'
}),
onFilter: (value, record) =>
record.id.toLowerCase().includes(value.toLowerCase())
},
{
title: 'State',
key: 'state',
dataIndex: 'state',
width: 240,
render: (state) => {
return <JobState state={state} showQuantity={false} showId={false} />
},
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'state'
}),
onFilter: (value, record) =>
record?.state?.type?.toLowerCase().includes(value.toLowerCase())
},
{
title: <CheckCircleIcon />,
key: 'complete',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'complete' }} />
}
},
{
title: <PauseCircleIcon />,
key: 'queued',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'queued' }} />
}
},
{
title: <XMarkCircleIcon />,
key: 'failed',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'failed' }} />
}
},
{
title: <QuestionCircleIcon />,
key: 'draft',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'draft' }} />
}
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
return <TimeDisplay dateTime={createdAt} />
} else {
return 'n/a'
}
},
sorter: true
},
{
title: 'Started At',
dataIndex: 'startedAt',
key: 'startedAt',
width: 180,
render: (startedAt) => {
if (startedAt) {
return <TimeDisplay dateTime={startedAt} />
} else {
return 'n/a'
}
},
sorter: true
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (record) => {
return (
<Space size='small'>
{record?.state?.type === 'draft' ? (
<Button
icon={<PlayCircleIcon />}
onClick={() => handleDeployJob(record.id)}
/>
) : (
<Button
icon={<InfoCircleIcon />}
onClick={() =>
navigate(`/dashboard/production/jobs/info?jobId=${record.id}`)
}
/>
)}
<Dropdown menu={getJobActionItems(record.id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const { authenticated } = useContext(AuthContext) const { authenticated } = useContext(AuthContext)
const { printServer } = useContext(PrintServerContext)
const [columnVisibility, updateColumnVisibility] = useColumnVisibility( const [columnVisibility, setColumnVisibility] = useColumnVisibility('job')
'Jobs',
columns
)
const handleDeployJob = (jobId) => {
if (printServer) {
messageApi.info(`Print job ${jobId} deployment initiated`)
printServer.emit('server.job_queue.deploy', { jobId }, (response) => {
if (response == false) {
notificationApi.error({
message: 'Print job deployment failed',
description: 'Please try again later'
})
} else {
notificationApi.success({
message: 'Print job deployment initiated',
description: 'Please wait for the print job to start'
})
}
})
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
} else {
messageApi.error('Socket connection not available')
}
}
const getJobActionItems = (jobId) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleIcon />
},
{
label: 'Edit',
key: 'edit',
icon: <EditIcon />
}
],
onClick: ({ key }) => {
if (key === 'edit') {
showNewJobModal(jobId)
} else if (key === 'info') {
navigate(`/dashboard/production/jobs/info?jobId=${jobId}`)
}
}
}
}
const actionItems = { const actionItems = {
items: [ items: [
@ -336,33 +50,8 @@ const Jobs = () => {
setNewJobOpen(true) setNewJobOpen(true)
} }
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
return ( return (
<> <>
{notificationContextHolder}
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}> <Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
{contextHolder} {contextHolder}
<Flex justify={'space-between'}> <Flex justify={'space-between'}>
@ -370,14 +59,14 @@ const Jobs = () => {
<Dropdown menu={actionItems}> <Dropdown menu={actionItems}>
<Button>Actions</Button> <Button>Actions</Button>
</Dropdown> </Dropdown>
<Popover <ColumnViewButton
content={getViewDropdownItems()} type='job'
placement='bottomLeft' disabled={false}
arrow={false} visibleState={columnVisibility}
> updateVisibleState={setColumnVisibility}
<Button>View</Button> />
</Popover>
</Space> </Space>
<Space> <Space>
<Button <Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />} icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
@ -391,6 +80,7 @@ const Jobs = () => {
<ObjectTable <ObjectTable
ref={tableRef} ref={tableRef}
type={'job'} type={'job'}
visibleColumns={columnVisibility}
authenticated={authenticated} authenticated={authenticated}
cards={viewMode === 'cards'} cards={viewMode === 'cards'}
/> />

View File

@ -1,9 +1,8 @@
import React from 'react' import React, { useContext } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
@ -17,15 +16,15 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import JobIcon from '../../../Icons/JobIcon' import JobIcon from '../../../Icons/JobIcon'
import AuditLogIcon from '../../../Icons/AuditLogIcon' import AuditLogIcon from '../../../Icons/AuditLogIcon'
import NoteIcon from '../../../Icons/NoteIcon' import NoteIcon from '../../../Icons/NoteIcon'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon' import ObjectActions from '../../common/ObjectActions.jsx'
import { import { ApiServerContext } from '../../context/ApiServerContext'
getModelProperties, import ObjectTable from '../../common/ObjectTable.jsx'
getPropertyValue import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
} from '../../../../database/ObjectModels.js'
const JobInfo = () => { const JobInfo = () => {
const location = useLocation() const location = useLocation()
const jobId = new URLSearchParams(location.search).get('jobId') const jobId = new URLSearchParams(location.search).get('jobId')
const { handleDownloadContent } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState('JobInfo', { const [collapseState, updateCollapseState] = useCollapseState('JobInfo', {
info: true, info: true,
subJobs: true, subJobs: true,
@ -33,153 +32,173 @@ const JobInfo = () => {
auditLogs: true auditLogs: true
}) })
// Define actions that can be triggered via URL
const actions = {
// Add job-specific actions here as needed
}
return ( return (
<> <EditObjectForm
<ActionHandler actions={actions} /> id={jobId}
<EditObjectForm type='job'
id={jobId} style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
type='job' >
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }} {({
> loading,
{({ isEditing,
loading, startEditing,
isEditing, cancelEditing,
startEditing, handleUpdate,
cancelEditing, formValid,
handleUpdate, objectData,
formValid, editLoading,
objectData, lock,
editLoading, fetchObject
lock, }) => {
fetchObject // Define actions that can be triggered via URL, now with access to startEditing
}) => ( const actions = {
<Flex reload: () => {
gap='large' fetchObject()
vertical='true' return true
style={{ height: '100%', minHeight: 0 }} },
> edit: () => {
<Flex justify={'space-between'}> startEditing()
<Space size='middle'> return false
<Space size='small'> },
<Dropdown cancelEdit: () => {
menu={{ cancelEditing()
items: [ return true
{ },
label: 'Reload Job', finishEdit: () => {
key: 'reload', handleUpdate()
icon: <GCodeFileIcon /> return true
} },
], download: () => {
onClick: ({ key }) => { if (jobId) {
if (key === 'reload') { handleDownloadContent(
fetchObject() jobId,
} 'job',
`${objectData.name || 'job'}.json`
)
return true
}
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions type='job' id={jobId} disabled={loading} />
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'Job Information' },
{ key: 'subJobs', label: 'Sub Jobs' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Job Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
} }
}} key='info'
> >
<Button disabled={loading}>Actions</Button> <ObjectInfo
</Dropdown> loading={loading}
<ViewButton indicator={<LoadingOutlined />}
loading={loading} isEditing={isEditing}
sections={[ type='job'
{ key: 'info', label: 'Job Information' }, objectData={objectData}
{ key: 'subJobs', label: 'Sub Jobs' }, />
{ key: 'notes', label: 'Notes' }, </InfoCollapse>
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading || true}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}> <InfoCollapse
<Flex vertical gap={'large'}> title='Sub Jobs'
<InfoCollapse icon={<JobIcon />}
title='Job Information' active={collapseState.subJobs}
icon={<InfoCircleIcon />} onToggle={(expanded) =>
active={collapseState.info} updateCollapseState('subJobs', expanded)
onToggle={(expanded) => updateCollapseState('info', expanded)} }
key='info' key='subJobs'
> >
<ObjectInfo <SubJobsTree jobData={objectData} loading={loading} />
loading={loading} </InfoCollapse>
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='job'
items={getModelProperties('job').map((prop) => ({
...prop,
value: getPropertyValue(objectData, prop.name)
}))}
/>
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Sub Jobs' title='Notes'
icon={<JobIcon />} icon={<NoteIcon />}
active={collapseState.subJobs} active={collapseState.notes}
onToggle={(expanded) => onToggle={(expanded) =>
updateCollapseState('subJobs', expanded) updateCollapseState('notes', expanded)
} }
key='subJobs' key='notes'
> >
<SubJobsTree jobData={objectData} loading={loading} /> <Card>
</InfoCollapse> <NotesPanel _id={jobId} />
</Card>
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Notes' title='Audit Logs'
icon={<NoteIcon />} icon={<AuditLogIcon />}
active={collapseState.notes} active={collapseState.auditLogs}
onToggle={(expanded) => onToggle={(expanded) =>
updateCollapseState('notes', expanded) updateCollapseState('auditLogs', expanded)
} }
key='notes' key='auditLogs'
> >
<Card> {loading ? (
<NotesPanel _id={jobId} /> <InfoCollapsePlaceholder />
</Card> ) : (
</InfoCollapse> <ObjectTable
type='auditLog'
<InfoCollapse masterFilter={{ 'parent._id': jobId }}
title='Audit Logs' visibleColumns={{ _id: false, 'parent._id': false }}
icon={<AuditLogIcon />} />
active={collapseState.auditLogs} )}
onToggle={(expanded) => </InfoCollapse>
updateCollapseState('auditLogs', expanded) </Flex>
} </div>
key='auditLogs'
>
<AuditLogTable
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</InfoCollapse>
</Flex> </Flex>
</div> )}
</Flex> </ActionHandler>
)} )
</EditObjectForm> }}
</> </EditObjectForm>
) )
} }

View File

@ -1,9 +1,7 @@
// src/Printers.js // src/Printers.js
import React, { useState, useContext, useRef } from 'react' import React, { useState, useRef } from 'react'
import { Button, message, Dropdown, Space, Flex, Modal } from 'antd' import { Button, message, Dropdown, Space, Flex, Modal } from 'antd'
import { AuthContext } from '../context/AuthContext'
import NewPrinter from './Printers/NewPrinter' import NewPrinter from './Printers/NewPrinter'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import ReloadIcon from '../../Icons/ReloadIcon' import ReloadIcon from '../../Icons/ReloadIcon'
@ -17,7 +15,6 @@ import useColumnVisibility from '../hooks/useColumnVisibility'
const Printers = () => { const Printers = () => {
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const [newPrinterOpen, setNewPrinterOpen] = useState(false) const [newPrinterOpen, setNewPrinterOpen] = useState(false)
const tableRef = useRef() const tableRef = useRef()
@ -61,9 +58,9 @@ const Printers = () => {
</Dropdown> </Dropdown>
<ColumnViewButton <ColumnViewButton
type='printer' type='printer'
loading={false} disabled={false}
collapseState={columnVisibility} visibleState={columnVisibility}
updateCollapseState={setColumnVisibility} updateVisibleState={setColumnVisibility}
/> />
</Space> </Space>
<Space> <Space>
@ -78,8 +75,7 @@ const Printers = () => {
<ObjectTable <ObjectTable
ref={tableRef} ref={tableRef}
type={'printer'} type='printer'
authenticated={authenticated}
cards={viewMode === 'cards'} cards={viewMode === 'cards'}
visibleColumns={columnVisibility} visibleColumns={columnVisibility}
/> />

View File

@ -1,9 +1,8 @@
import React from 'react' import React from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card } from 'antd' import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import useCollapseState from '../../hooks/useCollapseState' import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel' import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse' import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
@ -16,10 +15,10 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import PrinterIcon from '../../../Icons/PrinterIcon.jsx' import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import { import ActionHandler from '../../common/ActionHandler'
getModelProperties, import ObjectActions from '../../common/ObjectActions.jsx'
getPropertyValue import ObjectTable from '../../common/ObjectTable.jsx'
} from '../../../../database/ObjectModels.js' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const PrinterInfo = () => { const PrinterInfo = () => {
const location = useLocation() const location = useLocation()
@ -28,7 +27,8 @@ const PrinterInfo = () => {
info: true, info: true,
jobs: true, jobs: true,
notes: true, notes: true,
auditLogs: true auditLogsParent: true,
auditLogsOwner: true
}) })
return ( return (
@ -48,126 +48,178 @@ const PrinterInfo = () => {
editLoading, editLoading,
lock, lock,
fetchObject fetchObject
}) => ( }) => {
<Flex // Define actions for ActionHandler
gap='large' const actions = {
vertical='true' reload: () => {
style={{ height: '100%', minHeight: 0 }} fetchObject()
> return true
<Flex justify={'space-between'}> },
<Space size='middle'> edit: () => {
<Space size='small'> startEditing()
<Dropdown return false
menu={{ },
items: [ cancelEdit: () => {
{ cancelEditing()
label: 'Reload Printer', return true
key: 'reload', },
icon: <AuditLogIcon /> finishEdit: () => {
handleUpdate()
return true
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='printer'
id={printerId}
disabled={loading}
/>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'Printer Information' },
{ key: 'jobs', label: 'Printer Jobs' },
{ key: 'notes', label: 'Notes' },
{
key: 'auditLogsParent',
label: 'Audit Logs (By Parent)'
},
{
key: 'auditLogsOwner',
label: 'Audit Logs (By Owner)'
}
]}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={() => {
callAction('finishEdit')
}}
cancelEditing={() => {
callAction('cancelEdit')
}}
startEditing={() => {
callAction('edit')
}}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Printer Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
} }
], key='info'
onClick: ({ key }) => { >
if (key === 'reload') { <ObjectInfo
fetchObject() loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='printer'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Printer Jobs'
icon={<PrinterIcon />}
active={collapseState.jobs}
onToggle={(expanded) =>
updateCollapseState('jobs', expanded)
} }
} key='jobs'
}} >
> <PrinterJobsTree
<Button disabled={loading}>Actions</Button> subJobs={objectData?.subJobs}
</Dropdown> loading={loading}
<ViewButton />
loading={loading} </InfoCollapse>
sections={[
{ key: 'info', label: 'Printer Information' },
{ key: 'jobs', label: 'Printer Jobs' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}> <InfoCollapse
<Flex vertical gap={'large'}> title='Notes'
<InfoCollapse icon={<NoteIcon />}
title='Printer Information' active={collapseState.notes}
icon={<InfoCircleIcon />} onToggle={(expanded) =>
active={collapseState.info} updateCollapseState('notes', expanded)
onToggle={(expanded) => updateCollapseState('info', expanded)} }
key='info' key='notes'
> >
<ObjectInfo <Card>
loading={loading} <NotesPanel _id={printerId} />
indicator={<LoadingOutlined />} </Card>
isEditing={isEditing} </InfoCollapse>
type='printer'
items={getModelProperties('printer').map((prop) => ({
...prop,
value: getPropertyValue(objectData, prop.name)
}))}
/>
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Printer Jobs' title='Audit Logs (By Parent)'
icon={<PrinterIcon />} icon={<AuditLogIcon />}
active={collapseState.jobs} active={collapseState.auditLogsParent}
onToggle={(expanded) => updateCollapseState('jobs', expanded)} onToggle={(expanded) =>
key='jobs' updateCollapseState('auditLogsParent', expanded)
> }
<PrinterJobsTree key='auditLogs'
subJobs={objectData?.subJobs} >
loading={loading} {loading ? (
/> <InfoCollapsePlaceholder />
</InfoCollapse> ) : (
<ObjectTable
<InfoCollapse type='auditLog'
title='Notes' masterFilter={{ 'parent._id': printerId }}
icon={<NoteIcon />} visibleColumns={{ _id: false, 'parent._id': false }}
active={collapseState.notes} />
onToggle={(expanded) => updateCollapseState('notes', expanded)} )}
key='notes' </InfoCollapse>
> <InfoCollapse
<Card> title='Audit Logs (By Owner)'
<NotesPanel _id={printerId} /> icon={<AuditLogIcon />}
</Card> active={collapseState.auditLogsOwner}
</InfoCollapse> onToggle={(expanded) =>
updateCollapseState('auditLogsOwner', expanded)
<InfoCollapse }
title='Audit Logs' key='auditLogs'
icon={<AuditLogIcon />} >
active={collapseState.auditLogs} {loading ? (
onToggle={(expanded) => <InfoCollapsePlaceholder />
updateCollapseState('auditLogs', expanded) ) : (
} <ObjectTable
key='auditLogs' type='auditLog'
> masterFilter={{ 'owner._id': printerId }}
<AuditLogTable visibleColumns={{ _id: false, 'owner._id': false }}
items={objectData?.auditLogs || []} />
loading={loading} )}
showTargetColumn={false} </InfoCollapse>
/> </Flex>
</InfoCollapse> </div>
</Flex> </Flex>
</div> )}
</Flex> </ActionHandler>
)} )
}}
</EditObjectForm> </EditObjectForm>
) )
} }

View File

@ -1,38 +1,61 @@
import { useEffect } from 'react' import React, { useEffect, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
const ActionHandler = ({ const ActionHandler = ({
children,
actions = {}, actions = {},
actionParam = 'action', actionParam = 'action',
clearAfterExecute = true, clearAfterExecute = true,
onAction onAction,
loading = true
}) => { }) => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const action = new URLSearchParams(location.search).get(actionParam) const action = new URLSearchParams(location.search).get(actionParam)
// Ref to track last executed action
const lastExecutedAction = useRef(null)
// Method to add action as URL param
const callAction = (actionName) => {
const searchParams = new URLSearchParams(location.search)
searchParams.set(actionParam, actionName)
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
}
// Execute action and clear from URL // Execute action and clear from URL
useEffect(() => { useEffect(() => {
if (action && actions[action]) { if (
!loading &&
action &&
actions[action] &&
lastExecutedAction.current !== action
) {
// Execute the action // Execute the action
const result = actions[action]() const result = actions[action]()
// Mark this action as executed
lastExecutedAction.current = action
// Call optional callback // Call optional callback
if (onAction) { if (onAction) {
onAction(action, result) onAction(action, result)
} }
// Clear action from URL if requested and result is true
// Clear action from URL if requested if (clearAfterExecute && result == true) {
if (clearAfterExecute) {
const searchParams = new URLSearchParams(location.search) const searchParams = new URLSearchParams(location.search)
searchParams.delete(actionParam) searchParams.delete(actionParam)
const newSearch = searchParams.toString() const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '') const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true }) navigate(newPath, { replace: true })
} }
} else if (!action) {
// Reset lastExecutedAction if no action is present
lastExecutedAction.current = null
} }
}, [ }, [
loading,
action, action,
actions, actions,
actionParam, actionParam,
@ -44,14 +67,16 @@ const ActionHandler = ({
]) ])
// Return null as this is a utility component // Return null as this is a utility component
return null return <>{children({ callAction })}</>
} }
ActionHandler.propTypes = { ActionHandler.propTypes = {
children: PropTypes.func,
actions: PropTypes.objectOf(PropTypes.func), actions: PropTypes.objectOf(PropTypes.func),
actionParam: PropTypes.string, actionParam: PropTypes.string,
clearAfterExecute: PropTypes.bool, clearAfterExecute: PropTypes.bool,
onAction: PropTypes.func onAction: PropTypes.func,
loading: PropTypes.bool
} }
export default ActionHandler export default ActionHandler

View File

@ -5,9 +5,9 @@ import { getModelByName } from '../../../database/ObjectModels'
const ColumnViewButton = ({ const ColumnViewButton = ({
type, type,
loading = false, disabled = false,
collapseState = {}, visibleState = {},
updateCollapseState = () => {}, updateVisibleState = () => {},
...buttonProps ...buttonProps
}) => { }) => {
// Get the model by name // Get the model by name
@ -31,10 +31,10 @@ const ColumnViewButton = ({
return ( return (
<ViewButton <ViewButton
loading={loading} disabled={disabled}
properties={columnProperties} items={columnProperties}
collapseState={collapseState} visibleState={visibleState}
updateCollapseState={updateCollapseState} updateVisibleState={updateVisibleState}
{...buttonProps} {...buttonProps}
/> />
) )
@ -42,9 +42,9 @@ const ColumnViewButton = ({
ColumnViewButton.propTypes = { ColumnViewButton.propTypes = {
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
loading: PropTypes.bool, disabled: PropTypes.bool,
collapseState: PropTypes.object, visibleState: PropTypes.object,
updateCollapseState: PropTypes.func updateVisibleState: PropTypes.func
} }
export default ColumnViewButton export default ColumnViewButton

View File

@ -17,6 +17,7 @@ import PropTypes from 'prop-types'
*/ */
const EditObjectForm = ({ id, type, style, children }) => { const EditObjectForm = ({ id, type, style, children }) => {
const [objectData, setObjectData] = useState(null) const [objectData, setObjectData] = useState(null)
const [serverObjectData, setServerObjectData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true) const [fetchLoading, setFetchLoading] = useState(true)
const [editLoading, setEditLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
const [lock, setLock] = useState({}) const [lock, setLock] = useState({})
@ -59,12 +60,6 @@ const EditObjectForm = ({ id, type, style, children }) => {
} }
}, [id, type, unlockObject]) }, [id, type, unlockObject])
useEffect(() => {
if (objectData) {
form.setFieldsValue(objectData)
}
}, [objectData, form])
const fetchObject = useCallback(async () => { const fetchObject = useCallback(async () => {
try { try {
setFetchLoading(true) setFetchLoading(true)
@ -72,6 +67,7 @@ const EditObjectForm = ({ id, type, style, children }) => {
const lockEvent = await fetchObjectLock(id, type) const lockEvent = await fetchObjectLock(id, type)
setLock(lockEvent) setLock(lockEvent)
setObjectData(data) setObjectData(data)
setServerObjectData(data)
form.setFieldsValue(data) form.setFieldsValue(data)
setFetchLoading(false) setFetchLoading(false)
} catch (err) { } catch (err) {
@ -120,8 +116,9 @@ const EditObjectForm = ({ id, type, style, children }) => {
} }
const cancelEditing = () => { const cancelEditing = () => {
if (objectData) { if (serverObjectData) {
form.setFieldsValue(objectData) form.setFieldsValue(serverObjectData)
setObjectData(serverObjectData)
} }
setIsEditing(false) setIsEditing(false)
unlockObject(id, type) unlockObject(id, type)
@ -151,7 +148,14 @@ const EditObjectForm = ({ id, type, style, children }) => {
} }
return ( return (
<Form form={form} layout='vertical' style={style}> <Form
form={form}
layout='vertical'
style={style}
onValuesChange={(values) => {
setObjectData((prev) => ({ ...prev, ...values }))
}}
>
{contextHolder} {contextHolder}
{children({ {children({
loading: fetchLoading, loading: fetchLoading,

View File

@ -24,9 +24,9 @@ const EmailDisplay = ({ email, showCopy = true, showLink = false }) => {
</Text> </Text>
<Tooltip title='Email' arrow={false}> <Tooltip title='Email' arrow={false}>
<Button <Button
icon={<NewMailIcon style={{ fontSize: '14px' }} />} icon={<NewMailIcon />}
type='text' type='text'
style={{ height: '22px' }} size='small'
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
window.location.href = `mailto:${email}` window.location.href = `mailto:${email}`
@ -40,7 +40,6 @@ const EmailDisplay = ({ email, showCopy = true, showLink = false }) => {
text={email} text={email}
tooltip='Copy Email' tooltip='Copy Email'
style={{ marginLeft: 0 }} style={{ marginLeft: 0 }}
iconStyle={{ fontSize: '14px' }}
/> />
)} )}
</Flex> </Flex>

View File

@ -29,9 +29,8 @@ const IdDisplay = ({
var hyperlink = null var hyperlink = null
const defaultModelActions = model.actions.filter( const defaultModelActions =
(action) => action.default == true model.actions?.filter((action) => action.default == true) || []
)
if (defaultModelActions.length >= 1) { if (defaultModelActions.length >= 1) {
hyperlink = defaultModelActions[0].url(id) hyperlink = defaultModelActions[0].url(id)

View File

@ -0,0 +1,13 @@
import React from 'react'
import { Card, Spin } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
const InfoCollapsePlaceholder = () => {
return (
<Spin indicator={<LoadingOutlined />}>
<Card style={{ minHeight: 260 }} />
</Spin>
)
}
export default InfoCollapsePlaceholder

View File

@ -0,0 +1,84 @@
import React, { useState, useEffect, useContext } from 'react'
import { Form, message } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext'
import PropTypes from 'prop-types'
/**
* NewObjectForm is a reusable form component for creating new objects.
* It handles form validation, submission, and error handling logic.
*
* Props:
* - type: string (required)
* - formItems: array (for ObjectInfo/ObjectProperty items)
* - children: function({
* loading, isSubmitting, handleSubmit, form, formValid, objectData, setObjectData
* }) => ReactNode
*/
const NewObjectForm = ({ type, style, children }) => {
const [objectData, setObjectData] = useState({})
const [submitLoading, setSubmitLoading] = useState(false)
const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm()
const formUpdateValues = Form.useWatch([], form)
const [messageApi, contextHolder] = message.useMessage()
const { createObjectInfo, showError } = useContext(ApiServerContext)
// Validate form on change
useEffect(() => {
form
.validateFields({ validateOnly: true })
.then(() => setFormValid(true))
.catch(() => setFormValid(false))
}, [form, formUpdateValues])
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setSubmitLoading(true)
const newObject = await createObjectInfo(type, values)
messageApi.success('Object created successfully')
return newObject
} catch (err) {
if (err.errorFields) {
return
}
messageApi.error('Failed to create object')
showError(
`Failed to create object. Message: ${err.message}. Code: ${err.code}`,
() => handleSubmit()
)
} finally {
setSubmitLoading(false)
}
}
return (
<Form
form={form}
layout='vertical'
style={style}
onValuesChange={(values) => {
setObjectData((prev) => ({ ...prev, ...values }))
}}
>
{contextHolder}
{children({
loading: false,
isSubmitting: submitLoading,
handleSubmit,
form,
formValid,
objectData,
setObjectData
})}
</Form>
)
}
NewObjectForm.propTypes = {
type: PropTypes.string.isRequired,
children: PropTypes.func.isRequired,
style: PropTypes.object
}
export default NewObjectForm

View File

@ -0,0 +1,104 @@
import React from 'react'
import { Dropdown, Button } from 'antd'
import { getModelByName } from '../../../database/ObjectModels'
import PropTypes from 'prop-types'
import { useNavigate, useLocation } from 'react-router-dom'
// Recursively map actions to AntD Dropdown items
function mapActionsToMenuItems(actions, currentUrlWithActions, id) {
return actions.map((action) => {
if (action.type === 'divider') {
return { type: 'divider' }
}
const actionUrl = action.url ? action.url(id) : undefined
const item = {
key: action.key || action.name,
label: action.label,
icon: action.icon ? React.createElement(action.icon) : undefined,
disabled: actionUrl && actionUrl === currentUrlWithActions
}
if (action.children && Array.isArray(action.children)) {
item.children = mapActionsToMenuItems(
action.children,
currentUrlWithActions,
id
)
}
return item
})
}
const stripActionParam = (pathname, search) => {
const params = new URLSearchParams(search)
params.delete('action')
const query = params.toString()
return pathname + (query ? `?${query}` : '')
}
const ObjectActions = ({
type,
id,
disabled = false,
buttonProps = {},
...dropdownProps
}) => {
const model = getModelByName(type)
const actions = model.actions || []
const navigate = useNavigate()
const location = useLocation()
// Get current url without 'action' param
const currentUrlWithoutActions = stripActionParam(
location.pathname,
location.search
)
console.log('curr url', currentUrlWithoutActions)
// Filter out actions whose url matches currentUrl
const filteredActions = actions.filter(
(action) => !(action.url(id) && action.url(id) === currentUrlWithoutActions)
)
const currentUrlWithActions = location.pathname + location.search
// Compose AntD Dropdown menu items
const menu = {
items: mapActionsToMenuItems(filteredActions, currentUrlWithActions, id),
onClick: (info) => {
// Find the action by key
const findAction = (acts, key) => {
for (const act of acts) {
if ((act.key || act.name) === key) return act
if (act.children) {
const found = findAction(act.children, key)
if (found) return found
}
}
return null
}
const action = findAction(filteredActions, info.key)
if (action && action.url) {
navigate(action.url(id))
}
}
}
return (
<Dropdown menu={menu} {...dropdownProps}>
<Button {...buttonProps} disabled={disabled}>
Actions
</Button>
</Dropdown>
)
}
ObjectActions.propTypes = {
type: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
disabled: PropTypes.bool,
buttonProps: PropTypes.object,
buttonLabel: PropTypes.string
}
export default ObjectActions

View File

@ -1,27 +1,37 @@
import React from 'react' import React from 'react'
import { Spin, Descriptions } from 'antd' import { Spin, Descriptions } from 'antd'
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'
const ObjectInfo = ({ const ObjectInfo = ({
loading = false, loading = false,
indicator = null,
bordered = true, bordered = true,
isEditing = false, isEditing = false,
items = [] type = 'unknown',
objectData = null
}) => { }) => {
const items = getModelProperties(type)
// Map items to Descriptions 'items' prop format // Map items to Descriptions 'items' prop format
const descriptionItems = items.map((item, idx) => { const descriptionItems = items.map((item, idx) => {
const key = item.name || item.label || idx const key = item.name || item.label || idx
return { return {
key, key,
label: item.label, label: item.label,
children: <ObjectProperty {...item} isEditing={isEditing} /> children: (
<ObjectProperty
{...item}
isEditing={isEditing}
objectData={objectData}
/>
)
} }
}) })
return ( return (
<Spin spinning={loading} indicator={indicator}> <Spin spinning={loading} indicator={<LoadingOutlined />}>
<Descriptions <Descriptions
bordered={bordered} bordered={bordered}
column={{ column={{
@ -44,7 +54,8 @@ ObjectInfo.propTypes = {
bordered: PropTypes.bool, bordered: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object), items: PropTypes.arrayOf(PropTypes.object),
isEditing: PropTypes.bool, isEditing: PropTypes.bool,
type: PropTypes.string.isRequired type: PropTypes.string.isRequired,
objectData: PropTypes.object
} }
export default ObjectInfo export default ObjectInfo

View File

@ -33,6 +33,8 @@ import SecretDisplay from './SecretDisplay'
import EyeIcon from '../../Icons/EyeIcon' import EyeIcon from '../../Icons/EyeIcon'
import EyeSlashIcon from '../../Icons/EyeSlashIcon' import EyeSlashIcon from '../../Icons/EyeSlashIcon'
import FilamentStockState from './FilamentStockState' import FilamentStockState from './FilamentStockState'
import { getPropertyValue } from '../../../database/ObjectModels'
import PropertyChanges from './PropertyChanges'
const { Text } = Typography const { Text } = Typography
@ -47,17 +49,40 @@ const MATERIAL_OPTIONS = [
const ObjectProperty = ({ const ObjectProperty = ({
type = 'text', type = 'text',
prefix,
suffix,
value, value,
min,
max,
step,
isEditing = false, isEditing = false,
formItemProps = {}, formItemProps = {},
required = false, required = false,
name, name,
label, label,
showLabel = false, showLabel = false,
objectData = null,
objectType = 'unknown', objectType = 'unknown',
readOnly = false, readOnly = false,
disabled = false,
...rest ...rest
}) => { }) => {
if (typeof value == 'function' && objectData) {
value = disabled(objectData)
}
if (objectType && typeof objectType == 'function' && objectData) {
objectType = objectType(objectData)
}
if (disabled && typeof disabled == 'function' && objectData) {
disabled = disabled(objectData)
}
if (!value) {
value = getPropertyValue(objectData, name)
}
// Split the name by "." to handle nested object properties // Split the name by "." to handle nested object properties
var formItemName = name var formItemName = name
@ -65,6 +90,12 @@ const ObjectProperty = ({
formItemName = name ? name.split('.') : undefined formItemName = name ? name.split('.') : undefined
} }
var textParams = {}
if (disabled == true) {
textParams = { ...textParams, delete: true, type: 'secondary' }
}
const renderProperty = () => { const renderProperty = () => {
if (!isEditing || readOnly) { if (!isEditing || readOnly) {
switch (type) { switch (type) {
@ -72,93 +103,154 @@ const ObjectProperty = ({
if (value != null) { if (value != null) {
return <SecretDisplay value={value} {...rest} /> return <SecretDisplay value={value} {...rest} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
case 'wsprotocol': case 'wsprotocol':
switch (value) { switch (value) {
case 'ws': case 'ws':
return <Text>Websocket</Text> return <Text {...textParams}>Websocket</Text>
case 'wss': case 'wss':
return <Text>Websocket Secure</Text> return <Text {...textParams}>Websocket Secure</Text>
default: default:
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
}
case 'priceMode':
switch (value) {
case 'margin':
return <Text {...textParams}>Margin %</Text>
case 'amount':
return <Text {...textParams}>£ Amount</Text>
default:
return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
case 'bool': { case 'bool': {
if (value != null) { if (value != null) {
return <BoolDisplay value={value} yesNo={true} {...rest} /> return <BoolDisplay value={value} yesNo={true} {...rest} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'dateTime': { case 'dateTime': {
if (value != null) { if (value != null) {
return <TimeDisplay dateTime={value} {...rest} /> return <TimeDisplay dateTime={value} {...rest} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
} <Text type='secondary' {...textParams}>
} n/a
case 'currency': { </Text>
if (value != null) { )
return <Text>{`£${value}/kg`}</Text>
} else {
return <Text type='secondary'>n/a</Text>
} }
} }
case 'country': { case 'country': {
if (value != null) { if (value != null) {
return <CountryDisplay countryCode={value} /> return <CountryDisplay countryCode={value} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'color': { case 'color': {
if (value) { if (value) {
return <Badge color={value} text={value} /> return <Badge color={value} text={value} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
} <Text type='secondary' {...textParams}>
} n/a
case 'weight': { </Text>
if (value != null) { )
return <Text>{`${value}g`}</Text>
} else {
return <Text type='secondary'>n/a</Text>
} }
} }
case 'number': { case 'number': {
if (value != null) { if (value != null) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return <Text>{value.length}</Text> return (
<Text {...textParams}>
{prefix}
{value.length}
{suffix}
</Text>
)
} else { } else {
return <Text>{value}</Text> return (
<Text {...textParams}>
{prefix}
{value}
{suffix}
</Text>
)
} }
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'text': case 'text':
if (value != null && value != '') { if (value != null && value != '') {
return <Text ellipsis>{value}</Text> return (
<Text ellipsis>
{prefix}
{value}
{suffix}
</Text>
)
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
case 'email': case 'email':
if (value != null && value != '') { if (value != null && value != '') {
return <EmailDisplay email={value} /> return <EmailDisplay email={value} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
case 'url': case 'url':
if (value != null && value != '') { if (value != null && value != '') {
return <UrlDisplay url={value} /> return <UrlDisplay url={value} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
case 'object': { case 'object': {
if (value && value.name) { if (value && value.name) {
return <Text ellipsis>{value.name}</Text> return <Text ellipsis>{value.name}</Text>
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'state': { case 'state': {
@ -173,59 +265,87 @@ const ObjectProperty = ({
case 'filamentStock': case 'filamentStock':
return <FilamentStockState state={value} {...rest} /> return <FilamentStockState state={value} {...rest} />
default: default:
return <Text type='secondary'>No Object Type Specified</Text> return (
<Text type='secondary' {...textParams}>
No Object Type Specified
</Text>
)
} }
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'material': { case 'material': {
if (value) { if (value) {
return <Text>{value}</Text> return <Text {...textParams}>{value}</Text>
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'id': { case 'id': {
if (value) { if (value) {
return <IdDisplay id={value} type={objectType} {...rest} /> return <IdDisplay id={value} type={objectType} {...rest} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'density': { case 'density': {
if (value != null) { if (value != null) {
return <Text>{`${value} g/cm³`}</Text> return <Text {...textParams}>{`${value} g/cm³`}</Text>
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'mm': { case 'mm': {
if (value != null) { if (value != null) {
return <Text>{`${value} mm`}</Text> return <Text {...textParams}>{`${value} mm`}</Text>
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'tags': { case 'tags': {
if (value != null || value?.length != 0) { if (value != null || value?.length != 0) {
return <TagsDisplay tags={value} /> return <TagsDisplay tags={value} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
case 'version': { case 'propertyChanges': {
if (value != null) { return <PropertyChanges type={objectType} value={value} />
return <Text>{`${value} mm`}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
} }
default: { default: {
if (value) { if (value) {
return <Text>{value}</Text> return <Text {...textParams}>{value}</Text>
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
} }
} }
@ -266,6 +386,7 @@ const ObjectProperty = ({
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<Input.Password <Input.Password
placeholder={label} placeholder={label}
disabled={disabled}
{...mergedFormItemProps} {...mergedFormItemProps}
iconRender={(visible) => iconRender={(visible) =>
visible ? <EyeSlashIcon /> : <EyeIcon /> visible ? <EyeSlashIcon /> : <EyeIcon />
@ -278,6 +399,7 @@ const ObjectProperty = ({
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<Select <Select
defaultValue='ws' defaultValue='ws'
disabled={disabled}
options={[ options={[
{ value: 'ws', label: 'Websocket' }, { value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' } { value: 'wss', label: 'Websocket Secure' }
@ -285,6 +407,19 @@ const ObjectProperty = ({
/> />
</Form.Item> </Form.Item>
) )
case 'priceMode':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<Select
defaultValue='margin'
disabled={disabled}
options={[
{ value: 'margin', label: 'Margin %' },
{ value: 'amount', label: '£ Amount' }
]}
/>
</Form.Item>
)
case 'bool': case 'bool':
return ( return (
<Form.Item <Form.Item
@ -292,7 +427,7 @@ const ObjectProperty = ({
{...mergedFormItemProps} {...mergedFormItemProps}
valuePropName='checked' valuePropName='checked'
> >
<Switch /> <Switch disabled={disabled} />
</Form.Item> </Form.Item>
) )
case 'dateTime': case 'dateTime':
@ -302,24 +437,17 @@ const ObjectProperty = ({
{...mergedFormItemProps} {...mergedFormItemProps}
getValueProps={(v) => ({ value: v ? dayjs(v) : null })} getValueProps={(v) => ({ value: v ? dayjs(v) : null })}
> >
<DatePicker showTime style={{ width: '100%' }} /> <DatePicker
</Form.Item> showTime
)
case 'currency':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<InputNumber
prefix='£'
suffix='/kg'
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={label} disabled={disabled}
/> />
</Form.Item> </Form.Item>
) )
case 'country': case 'country':
return ( return (
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<CountrySelect /> <CountrySelect disabled={disabled} />
</Form.Item> </Form.Item>
) )
case 'color': case 'color':
@ -330,7 +458,7 @@ const ObjectProperty = ({
valuePropName='value' valuePropName='value'
getValueFromEvent={(v) => v} getValueFromEvent={(v) => v}
> >
<ColorSelector required={required} /> <ColorSelector required={required} disabled={disabled} />
</Form.Item> </Form.Item>
) )
case 'weight': case 'weight':
@ -340,6 +468,7 @@ const ObjectProperty = ({
suffix='g' suffix='g'
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={label} placeholder={label}
disabled={disabled}
/> />
</Form.Item> </Form.Item>
) )
@ -347,22 +476,36 @@ const ObjectProperty = ({
return ( return (
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<InputNumber <InputNumber
style={{ width: '100%' }}
placeholder={label} placeholder={label}
disabled={disabled}
prefix={prefix}
suffix={suffix}
min={min}
max={max}
step={step}
{...mergedFormItemProps} {...mergedFormItemProps}
style={{ width: '100%' }}
/> />
</Form.Item> </Form.Item>
) )
case 'text': case 'text':
return ( return (
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<Input placeholder={label} {...mergedFormItemProps} /> <Input
placeholder={label}
{...mergedFormItemProps}
disabled={disabled}
/>
</Form.Item> </Form.Item>
) )
case 'material': case 'material':
return ( return (
<Form.Item name={formItemName} {...mergedFormItemProps}> <Form.Item name={formItemName} {...mergedFormItemProps}>
<Select options={MATERIAL_OPTIONS} placeholder={label} /> <Select
options={MATERIAL_OPTIONS}
placeholder={label}
disabled={disabled}
/>
</Form.Item> </Form.Item>
) )
case 'id': case 'id':
@ -370,7 +513,11 @@ const ObjectProperty = ({
if (value) { if (value) {
return <IdDisplay id={value} type={objectType} {...rest} /> return <IdDisplay id={value} type={objectType} {...rest} />
} else { } else {
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
case 'object': case 'object':
switch (objectType) { switch (objectType) {
@ -405,7 +552,11 @@ const ObjectProperty = ({
</Form.Item> </Form.Item>
) )
default: default:
return <Text type='secondary'>n/a</Text> return (
<Text type='secondary' {...textParams}>
n/a
</Text>
)
} }
case 'density': case 'density':
@ -455,10 +606,15 @@ ObjectProperty.propTypes = {
formItemProps: PropTypes.object, formItemProps: PropTypes.object,
required: PropTypes.bool, required: PropTypes.bool,
name: PropTypes.string, name: PropTypes.string,
label: PropTypes.string, prefix: PropTypes.string,
suffix: PropTypes.string,
min: PropTypes.number,
max: PropTypes.number,
step: PropTypes.number,
showLabel: PropTypes.bool, showLabel: PropTypes.bool,
objectType: PropTypes.string, objectType: PropTypes.string,
readOnly: PropTypes.bool readOnly: PropTypes.bool,
disabled: PropTypes.bool
} }
export default ObjectProperty export default ObjectProperty

View File

@ -38,6 +38,7 @@ import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon' import CheckIcon from '../../Icons/CheckIcon'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon' import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext'
const logger = loglevel.getLogger('DasboardTable') const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
@ -49,13 +50,14 @@ const ObjectTable = forwardRef(
pageSize = 25, pageSize = 25,
scrollHeight = 'calc(var(--unit-100vh) - 270px)', scrollHeight = 'calc(var(--unit-100vh) - 270px)',
onDataChange, onDataChange,
authenticated,
initialPage = 1, initialPage = 1,
cards = false, cards = false,
visibleColumns = {} visibleColumns = {},
masterFilter = {}
}, },
ref ref
) => { ) => {
const { authenticated } = useContext(AuthContext)
const { fetchTableData } = useContext(ApiServerContext) const { fetchTableData } = useContext(ApiServerContext)
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate() const navigate = useNavigate()
@ -107,7 +109,7 @@ const ObjectTable = forwardRef(
const result = await fetchTableData(type, { const result = await fetchTableData(type, {
page: pageNum, page: pageNum,
limit: pageSize, limit: pageSize,
filter, filter: { ...filter, ...masterFilter },
sorter, sorter,
onDataChange onDataChange
}) })
@ -408,17 +410,15 @@ const ObjectTable = forwardRef(
<ObjectProperty <ObjectProperty
{...prop} {...prop}
longId={false} longId={false}
type={prop.type} objectData={record}
objectType={prop.objectType}
value={getPropertyValue(record, prop.name)}
isEditing={false} isEditing={false}
/> />
) )
} }
} }
// Add filter configuration if the property is filterable // Add filter configuration if the property is filterable and not in masterFilter
if (isFilterable) { if (isFilterable && !Object.keys(masterFilter).includes(prop.name)) {
columnConfig.filterDropdown = ({ columnConfig.filterDropdown = ({
setSelectedKeys, setSelectedKeys,
selectedKeys, selectedKeys,
@ -606,7 +606,8 @@ ObjectTable.propTypes = {
initialPage: PropTypes.number, initialPage: PropTypes.number,
cards: PropTypes.bool, cards: PropTypes.bool,
cardRenderer: PropTypes.func, cardRenderer: PropTypes.func,
visibleColumns: PropTypes.object visibleColumns: PropTypes.object,
masterFilter: PropTypes.object
} }
export default ObjectTable export default ObjectTable

View File

@ -0,0 +1,62 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Descriptions, Typography, Space } from 'antd'
import { getModelProperty } from '../../../database/ObjectModels'
import ObjectProperty from './ObjectProperty'
import ArrowRightIcon from '../../Icons/ArrowRightIcon'
const { Text } = Typography
const PropertyChanges = ({ type, value }) => {
if (!value || !value.new) {
return <Text type='secondary'>n/a</Text>
}
console.log('combined', { ...value?.old, ...value?.new })
return (
<Descriptions size='small' column={1}>
{Object.keys({ ...value?.old, ...value?.new }).map((key) => {
console.log('tc', type, key)
var changeProperty = getModelProperty(type, key)
console.log('change prop', changeProperty)
if (changeProperty?.type == 'object') {
changeProperty = {
...changeProperty,
name: changeProperty.name + '._id',
type: 'id',
showHyperlink: true
}
}
return (
<Descriptions.Item key={key} label={changeProperty.label}>
<Space>
<ObjectProperty
{...changeProperty}
longId={false}
objectData={value?.old}
/>
<Text type='secondary'>
<ArrowRightIcon />
</Text>
<ObjectProperty
{...changeProperty}
longId={false}
objectData={value?.new}
/>
</Space>
</Descriptions.Item>
)
})}
</Descriptions>
)
}
PropertyChanges.propTypes = {
type: PropTypes.string.isRequired,
value: PropTypes.shape({
old: PropTypes.object,
new: PropTypes.object
})
}
export default PropertyChanges

View File

@ -23,12 +23,14 @@ const UrlDisplay = ({ url, showCopy = true, showLink = false }) => {
</Link> </Link>
) : ( ) : (
<> <>
<Text style={{ marginRight: 8 }}>{url}</Text> <Text style={{ marginRight: 8 }} ellipsis>
{url}
</Text>
<Tooltip title='Open URL' arrow={false}> <Tooltip title='Open URL' arrow={false}>
<Button <Button
icon={<LinkIcon style={{ fontSize: '14px' }} />} icon={<LinkIcon />}
type='text' type='text'
style={{ height: '22px' }} size='small'
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')

View File

@ -3,10 +3,10 @@ import { Button, Popover, Checkbox, Flex } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
const ViewButton = ({ const ViewButton = ({
loading = false, disabled = false,
properties = [], items = [],
collapseState = {}, visibleState = {},
updateCollapseState = () => {}, updateVisibleState = () => {},
...buttonProps ...buttonProps
}) => { }) => {
return ( return (
@ -15,15 +15,15 @@ const ViewButton = ({
return ( return (
<Flex vertical> <Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}> <Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{properties.map((property) => ( {items.map((item) => (
<Checkbox <Checkbox
checked={collapseState[property.key]} checked={visibleState[item.key]}
key={property.key} key={item.key}
onChange={(e) => { onChange={(e) => {
updateCollapseState(property.key, e.target.checked) updateVisibleState(item.key, e.target.checked)
}} }}
> >
{property.label} {item.label}
</Checkbox> </Checkbox>
))} ))}
</Flex> </Flex>
@ -33,7 +33,7 @@ const ViewButton = ({
placement='bottomLeft' placement='bottomLeft'
arrow={false} arrow={false}
> >
<Button disabled={loading} {...buttonProps}> <Button disabled={disabled} {...buttonProps}>
View View
</Button> </Button>
</Popover> </Popover>
@ -41,15 +41,15 @@ const ViewButton = ({
} }
ViewButton.propTypes = { ViewButton.propTypes = {
loading: PropTypes.bool, disabled: PropTypes.bool,
properties: PropTypes.arrayOf( items: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
key: PropTypes.string.isRequired, key: PropTypes.string.isRequired,
label: PropTypes.string.isRequired label: PropTypes.string.isRequired
}) })
), ),
collapseState: PropTypes.object, visibleState: PropTypes.object,
updateCollapseState: PropTypes.func updateVisibleState: PropTypes.func
} }
export default ViewButton export default ViewButton

View File

@ -331,14 +331,25 @@ const ApiServerProvider = ({ children }) => {
fileLink.parentNode.removeChild(fileLink) fileLink.parentNode.removeChild(fileLink)
} catch (error) { } catch (error) {
logger.error('Failed to download GCode file content:', error) logger.error('Failed to download GCode file content:', error)
if (error.response) { if (error.response) {
messageApi.error('Error downloading GCode file:', error.response.status) if (error.response.status === 404) {
showError(
`The ${type} file "${fileName}" was not found on the server. It may have been deleted or moved.`,
() => handleDownloadContent(id, type, fileName)
)
} else {
showError(
`Error downloading ${type} file: ${error.response.status} - ${error.response.statusText}`,
() => handleDownloadContent(id, type, fileName)
)
}
} else { } else {
messageApi.error( showError(
'An unexpected error occurred while downloading. Please try again later.' 'An unexpected error occurred while downloading. Please check your connection and try again.',
() => handleDownloadContent(id, type, fileName)
) )
} }
throw error
} }
} }
@ -378,6 +389,14 @@ const ApiServerProvider = ({ children }) => {
centered centered
maskClosable={true} maskClosable={true}
footer={[ footer={[
<Button
key='retry'
onClick={() => {
setShowErrorModal(false)
}}
>
Close
</Button>,
<Button key='retry' icon={<ReloadIcon />} onClick={handleRetry}> <Button key='retry' icon={<ReloadIcon />} onClick={handleRetry}>
Retry Retry
</Button> </Button>

View File

@ -0,0 +1,7 @@
import React from 'react'
import Icon from '@ant-design/icons'
import { ReactComponent as CustomIconSvg } from '../../assets/icons/downloadicon.min.svg'
const DownloadIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default DownloadIcon

View File

@ -77,6 +77,16 @@ export function getModelByName(name) {
) )
} }
export function getModelProperty(name, property) {
const model = getModelByName(name)
if (!model || !model.properties) {
return undefined
}
return model.properties.find((prop) => prop.name == property)
}
export function getModelProperties(name, propertyList) { export function getModelProperties(name, propertyList) {
const model = getModelByName(name) const model = getModelByName(name)
@ -132,3 +142,53 @@ export const getPropertyValue = (obj, path) => {
return obj[path] return obj[path]
} }
} }
export const evaluateVariable = (expression, data) => {
if (!expression) return false
// Only treat as an expression if it starts and ends with ()
const expr = expression.trim()
if (!(expr.startsWith('(') && expr.endsWith(')'))) return false
// Remove the outer parentheses
const innerExpr = expr.slice(1, -1)
// Helper to evaluate a single condition like 'foo == "bar"' or 'foo.bar == 42' or 'foo == true'
const evalCondition = (cond, data) => {
const match = cond.trim().match(/^([a-zA-Z0-9_.]+)\s*==\s*(.+)$/)
if (!match) return false
const [, path, valueRaw] = match
let value
let raw = valueRaw.trim()
// Check for quoted string
if (
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"))
) {
value = raw.slice(1, -1)
} else if (raw === 'true') {
value = true
} else if (raw === 'false') {
value = false
} else if (!isNaN(Number(raw))) {
value = Number(raw)
} else {
value = raw
}
// Resolve nested property
const propValue = path
.split('.')
.reduce((acc, key) => (acc ? acc[key] : undefined), data)
return propValue === value
}
// Split by '||' first (lowest precedence)
const orParts = innerExpr.split(/\|\|/)
for (let orPart of orParts) {
// Each orPart may have '&&' (higher precedence)
const andParts = orPart.split(/&&/)
const andResult = andParts.every((andPart) => evalCondition(andPart, data))
if (andResult) return true // If any OR group is true, return true
}
return false // None of the OR groups were true
}

View File

@ -1,20 +1,89 @@
import AuditLogIcon from '../../components/Icons/AuditLogIcon' import AuditLogIcon from '../../components/Icons/AuditLogIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
export const AuditLog = { export const AuditLog = {
name: 'auditlog', name: 'auditLog',
label: 'Audit Log', label: 'Audit Log',
prefix: 'ADL', prefix: 'ADL',
icon: AuditLogIcon, icon: AuditLogIcon,
actions: [ actions: [],
columns: ['_id', 'owner', 'owner._id', 'parent._id', 'changes', 'createdAt'],
filters: ['_id', 'owner._id', 'parent._id'],
sorters: [],
properties: [
{ {
name: 'info', name: '_id',
label: 'Info', label: 'ID',
default: true, type: 'id',
row: true, objectType: 'auditLog',
icon: InfoCircleIcon, columnFixed: 'left',
url: (_id) => `/dashboard/management/auditlogs/info?auditLogId=${_id}` value: null,
showCopy: true
},
{
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
value: null,
readOnly: true
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
value: null,
readOnly: true
},
{
name: 'owner',
label: 'Owner',
type: 'object',
objectType: (objectData) => {
return objectData.ownerType
},
columnFixed: 'left',
value: null,
showCopy: true
},
{
name: 'owner._id',
label: 'Owner ID',
type: 'id',
objectType: (objectData) => {
return objectData.ownerType
},
columnFixed: 'left',
showHyperlink: true,
showCopy: true
},
{
name: 'parent',
label: 'Parent',
type: 'object',
objectType: (objectData) => {
return objectData.parentType
},
value: null,
showCopy: true
},
{
name: 'parent._id',
label: 'Parent ID',
type: 'id',
objectType: (objectData) => {
return objectData.parentType
},
showHyperlink: true,
showCopy: true
},
{
name: 'changes',
label: 'Changes',
columnWidth: 500,
type: 'propertyChanges',
objectType: (objectData) => {
return objectData.parentType
},
showCopy: true
} }
], ]
url: () => `#`
} }

View File

@ -1,5 +1,7 @@
import EditIcon from '../../components/Icons/EditIcon'
import FilamentIcon from '../../components/Icons/FilamentIcon' import FilamentIcon from '../../components/Icons/FilamentIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const Filament = { export const Filament = {
name: 'filament', name: 'filament',
@ -14,6 +16,21 @@ export const Filament = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/management/filaments/info?filamentId=${_id}` url: (_id) => `/dashboard/management/filaments/info?filamentId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/management/filaments/info?filamentId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/management/filaments/info?filamentId=${_id}&action=edit`
} }
], ],
columns: [ columns: [

View File

@ -1,5 +1,8 @@
import DownloadIcon from '../../components/Icons/DownloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
import GCodeFileIcon from '../../components/Icons/GCodeFileIcon' import GCodeFileIcon from '../../components/Icons/GCodeFileIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const GCodeFile = { export const GCodeFile = {
name: 'gcodeFile', name: 'gcodeFile',
@ -15,12 +18,28 @@ export const GCodeFile = {
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/production/gcodefiles/info?gcodeFileId=${_id}` url: (_id) => `/dashboard/production/gcodefiles/info?gcodeFileId=${_id}`
}, },
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/production/gcodefiles/info?gcodeFileId=${_id}&action=reload`
},
{ {
name: 'download', name: 'download',
label: 'Download', label: 'Download',
row: true, row: true,
icon: DownloadIcon,
url: (_id) => url: (_id) =>
`/dashboard/production/gcodefiles/info?gcodeFileId=${_id}&action=download` `/dashboard/production/gcodefiles/info?gcodeFileId=${_id}&action=download`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/production/gcodefiles/info?gcodeFileId=${_id}&action=edit`
} }
], ],

View File

@ -1,5 +1,7 @@
import JobIcon from '../../components/Icons/JobIcon' import JobIcon from '../../components/Icons/JobIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const Job = { export const Job = {
name: 'job', name: 'job',
@ -14,6 +16,19 @@ export const Job = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}` url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=edit`
} }
], ],
columns: [ columns: [
@ -25,7 +40,7 @@ export const Job = {
'createdAt' 'createdAt'
], ],
filters: ['state', '_id', 'gcodeFile._id', 'quantity'], filters: ['state', '_id', 'gcodeFile._id', 'quantity'],
sorters: ['createdAt', 'state', 'quantity', '_id'], sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
properties: [ properties: [
{ {
name: '_id', name: '_id',

View File

@ -1,5 +1,7 @@
import NoteTypeIcon from '../../components/Icons/NoteTypeIcon' import NoteTypeIcon from '../../components/Icons/NoteTypeIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const NoteType = { export const NoteType = {
name: 'noteType', name: 'noteType',
@ -14,6 +16,21 @@ export const NoteType = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/management/notetypes/info?noteTypeId=${_id}` url: (_id) => `/dashboard/management/notetypes/info?noteTypeId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/management/notetypes/info?noteTypeId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/management/notetypes/info?noteTypeId=${_id}&action=edit`
} }
], ],
columns: ['name', '_id', 'color', 'active', 'createdAt', 'updatedAt'], columns: ['name', '_id', 'color', 'active', 'createdAt', 'updatedAt'],

View File

@ -1,5 +1,8 @@
import DownloadIcon from '../../components/Icons/DownloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import PartIcon from '../../components/Icons/PartIcon' import PartIcon from '../../components/Icons/PartIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const Part = { export const Part = {
name: 'part', name: 'part',
@ -14,10 +17,39 @@ export const Part = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/management/parts/info?partId=${_id}` url: (_id) => `/dashboard/management/parts/info?partId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/management/parts/info?partId=${_id}&action=reload`
},
{
name: 'download',
label: 'Download',
row: true,
icon: DownloadIcon,
url: (_id) =>
`/dashboard/management/parts/info?partId=${_id}&action=download`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) => `/dashboard/management/parts/info?partId=${_id}&action=edit`
} }
], ],
columns: ['name', '_id', 'product', 'product._id', 'createdAt'], columns: [
filters: ['name', '_id', 'product', 'product._id'], 'name',
'_id',
'product',
'product._id',
'globalPricing',
'createdAt'
],
filters: ['name', '_id', 'product', 'product._id', 'globalPricing'],
sorters: ['name', 'email', 'role', 'createdAt', '_id'], sorters: ['name', 'email', 'role', 'createdAt', '_id'],
properties: [ properties: [
{ {
@ -25,8 +57,9 @@ export const Part = {
label: 'ID', label: 'ID',
columnFixed: 'left', columnFixed: 'left',
type: 'id', type: 'id',
objectType: 'user', objectType: 'part',
showCopy: true showCopy: true,
readOnly: true
}, },
{ {
name: 'createdAt', name: 'createdAt',
@ -52,13 +85,58 @@ export const Part = {
name: 'product', name: 'product',
label: 'Product', label: 'Product',
type: 'object', type: 'object',
required: true,
objectType: 'product' objectType: 'product'
}, },
{ {
name: 'product._id', name: 'product._id',
label: 'Product ID', label: 'Product ID',
type: 'id', type: 'id',
readOnly: true,
showHyperlink: true,
objectType: 'product' objectType: 'product'
},
{
name: 'globalPricing',
label: 'Global Price',
columnWidth: 150,
required: true,
type: 'bool'
},
{
name: 'priceMode',
label: 'Price Mode',
type: 'priceMode',
disabled: (objectData) => {
return objectData.globalPricing == true
}
},
{
name: 'margin',
label: 'Margin',
type: 'number',
disabled: (objectData) => {
return (
objectData.globalPricing == true || objectData.priceMode == 'amount'
)
},
suffix: '%',
min: 0,
max: 100,
step: 0.01
},
{
name: 'amount',
label: 'Amount',
disabled: (objectData) => {
return (
objectData.globalPricing == true || objectData.priceMode == 'margin'
)
},
type: 'number',
prefix: '£',
min: 0,
step: 0.1
} }
] ]
} }

View File

@ -1,5 +1,8 @@
import PrinterIcon from '../../components/Icons/PrinterIcon' import PrinterIcon from '../../components/Icons/PrinterIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
import PlayCircleIcon from '../../components/Icons/PlayCircleIcon'
export const Printer = { export const Printer = {
name: 'printer', name: 'printer',
@ -14,12 +17,34 @@ export const Printer = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/production/printers/info?printerId=${_id}` url: (_id) => `/dashboard/production/printers/info?printerId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/production/printers/info?printerId=${_id}&action=reload`
},
{
name: 'control',
label: 'Control',
row: true,
icon: PlayCircleIcon,
url: (_id) => `/dashboard/production/printers/control?printerId=${_id}`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/production/printers/info?printerId=${_id}&action=edit`
} }
], ],
url: (id) => `/dashboard/production/printers/info?printerId=${id}`,
columns: ['name', '_id', 'state', 'tags', 'connectedAt'], columns: ['name', '_id', 'state', 'tags', 'connectedAt'],
filters: ['name', '_id', 'state', 'tags'], filters: ['name', '_id', 'state', 'tags'],
sorters: ['name', 'state', 'connectedAt', '_id'], sorters: ['name', 'state', 'connectedAt'],
properties: [ properties: [
{ {
name: '_id', name: '_id',

View File

@ -1,5 +1,7 @@
import ProductIcon from '../../components/Icons/ProductIcon' import ProductIcon from '../../components/Icons/ProductIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const Product = { export const Product = {
name: 'product', name: 'product',
@ -14,16 +16,43 @@ export const Product = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/management/products/info?productId=${_id}` url: (_id) => `/dashboard/management/products/info?productId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/management/products/info?productId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/management/products/info?productId=${_id}&action=edit`
} }
], ],
url: (id) => `/dashboard/management/products/info?productId=${id}`, columns: [
'_id',
'name',
'tags',
'vendor',
'vendor._id',
'price',
'createdAt',
'updatedAt'
],
filters: ['_id', 'name', 'type', 'color', 'cost', 'vendor', 'vendor._id'],
sorters: ['name', 'createdAt', 'type', 'vendor', 'cost', 'updatedAt'],
properties: [ properties: [
{ {
name: '_id', name: '_id',
label: 'ID', label: 'ID',
type: 'id', type: 'id',
objectType: 'printer', objectType: 'product',
showCopy: true showCopy: true,
readOnly: true
}, },
{ {
name: 'createdAt', name: 'createdAt',
@ -42,6 +71,59 @@ export const Product = {
label: 'Updated At', label: 'Updated At',
type: 'dateTime', type: 'dateTime',
readOnly: true readOnly: true
},
{
name: 'vendor',
label: 'Vendor',
required: true,
type: 'object',
objectType: 'vendor'
},
{
name: 'vendor._id',
label: 'Vendor ID',
readOnly: true,
type: 'id',
showHyperlink: true,
objectType: 'vendor'
},
{
name: 'version',
label: 'Version',
type: 'text'
},
{
name: 'tags',
label: 'Tags',
type: 'tags'
},
{
name: 'priceMode',
label: 'Price Mode',
type: 'priceMode'
},
{
name: 'margin',
label: 'Margin',
type: 'number',
disabled: (objectData) => {
return objectData.priceMode == 'amount'
},
suffix: '%',
min: 0,
max: 100,
step: 0.01
},
{
name: 'amount',
label: 'Amount',
disabled: (objectData) => {
return objectData.priceMode == 'margin'
},
type: 'number',
prefix: '£',
min: 0,
step: 0.1
} }
] ]
} }

View File

@ -1,5 +1,6 @@
import PersonIcon from '../../components/Icons/PersonIcon' import PersonIcon from '../../components/Icons/PersonIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const User = { export const User = {
name: 'user', name: 'user',
@ -14,9 +15,15 @@ export const User = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/management/users/info?userId=${_id}` url: (_id) => `/dashboard/management/users/info?userId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/management/users/info?userId=${_id}&action=reload`
} }
], ],
url: (id) => `/dashboard/management/users/info?userId=${id}`,
columns: ['name', '_id', 'username', 'email', 'role', 'createdAt'], columns: ['name', '_id', 'username', 'email', 'role', 'createdAt'],
filters: ['name', '_id', 'email', 'role'], filters: ['name', '_id', 'email', 'role'],
sorters: ['name', 'email', 'role', 'createdAt', '_id'], sorters: ['name', 'email', 'role', 'createdAt', '_id'],

View File

@ -1,5 +1,7 @@
import VendorIcon from '../../components/Icons/VendorIcon' import VendorIcon from '../../components/Icons/VendorIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const Vendor = { export const Vendor = {
name: 'vendor', name: 'vendor',
@ -14,10 +16,25 @@ export const Vendor = {
row: true, row: true,
icon: InfoCircleIcon, icon: InfoCircleIcon,
url: (_id) => `/dashboard/management/vendors/info?vendorId=${_id}` url: (_id) => `/dashboard/management/vendors/info?vendorId=${_id}`
},
{
name: 'reload',
label: 'Reload',
icon: ReloadIcon,
url: (_id) =>
`/dashboard/management/vendors/info?vendorId=${_id}&action=reload`
},
{
name: 'edit',
label: 'Edit',
row: true,
icon: EditIcon,
url: (_id) =>
`/dashboard/management/vendors/info?vendorId=${_id}&action=edit`
} }
], ],
url: (id) => `/dashboard/management/vendors/info?vendorId=${id}`, url: (id) => `/dashboard/management/vendors/info?vendorId=${id}`,
columns: ['name', '_id', 'country', 'email', 'createdAt'], columns: ['name', '_id', 'country', 'email', 'website', 'createdAt'],
filters: ['name', '_id', 'country', 'email'], filters: ['name', '_id', 'country', 'email'],
sorters: ['name', 'country', 'email', 'createdAt', '_id'], sorters: ['name', 'country', 'email', 'createdAt', '_id'],
properties: [ properties: [