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

View File

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

View File

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

View File

@ -1,11 +1,8 @@
// 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 { AuthContext } from '../context/AuthContext'
import ObjectTable from '../common/ObjectTable'
import NewProduct from './Products/NewProduct'
@ -20,14 +17,12 @@ import useViewMode from '../hooks/useViewMode'
import ColumnViewButton from '../common/ColumnViewButton'
const Parts = () => {
const Parts = (filter) => {
const [messageApi, contextHolder] = message.useMessage()
const [newProductOpen, setNewProductOpen] = useState(false)
const tableRef = useRef()
const { authenticated } = useContext(AuthContext)
const [viewMode, setViewMode] = useViewMode('part')
const [columnVisibility, setColumnVisibility] = useColumnVisibility('part')
const actionItems = {
@ -82,8 +77,8 @@ const Parts = () => {
ref={tableRef}
visibleColumns={columnVisibility}
type='part'
authenticated={authenticated}
cards={viewMode === 'cards'}
filter={filter}
/>
</Flex>
<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 axios from 'axios'
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 { Space, Flex, Card } from 'antd'
import useCollapseState from '../../hooks/useCollapseState'
import TimeDisplay from '../../common/TimeDisplay.jsx'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel'
import config from '../../../../config.js'
import BoolDisplay from '../../common/BoolDisplay.jsx'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
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 PartIcon from '../../../Icons/PartIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('PartInfo')
logger.setLevel(config.logLevel)
const { Title, Text } = Typography
import ActionHandler from '../../common/ActionHandler.jsx'
import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import { ApiServerContext } from '../../context/ApiServerContext'
const PartInfo = () => {
const [partData, setPartData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
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', {
info: true,
preview: true,
parts: true,
notes: 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 (
<>
{contextHolder}
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
disabled={loading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Part not found'}</p>
<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'
<EditObjectForm
id={partId}
type='part'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{({
loading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
formValid,
objectData,
editLoading,
lock,
fetchObject
}) => {
const actions = {
reload: () => {
fetchObject()
return true
},
edit: () => {
startEditing()
return false
},
cancelEdit: () => {
cancelEditing()
return true
},
finishEdit: () => {
handleUpdate()
return true
},
download: () => {
if (partId) {
handleDownloadContent(partId, 'part', `${objectData.name}.stl`)
return true
}
}
}
return (
<ActionHandler actions={actions} loading={loading}>
{({ callAction }) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Part Information
</Title>
</Flex>
}
key='1'
>
<Form
form={partForm}
layout='vertical'
onValuesChange={(changedValues) =>
setPartFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: partData?.name || '',
version: partData?.version || '',
tags: partData?.tags || []
}}
>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<ObjectActions
type='part'
id={partId}
disabled={loading}
/>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'Part 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='Part Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
key='info'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
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>
<ObjectInfo
loading={loading}
isEditing={isEditing}
type='part'
objectData={objectData}
/>
</InfoCollapse>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : partData?.name ? (
<Text>{partData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
key='notes'
>
<Card>
<NotesPanel _id={partId} />
</Card>
</InfoCollapse>
<Descriptions.Item label='Updated At'>
{partData?.updatedAt ? (
<TimeDisplay
dateTime={partData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData?.product?.name ? (
<Text>{partData.product.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{partData?.product?._id ? (
<IdDisplay
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</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>
</>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': partId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</ActionHandler>
)
}}
</EditObjectForm>
)
}

View File

@ -1,9 +1,7 @@
import React from 'react'
import { useLocation } from 'react-router-dom'
import { Space, Button, Flex, Dropdown, Card } from 'antd'
import ReloadIcon from '../../../Icons/ReloadIcon'
import { Space, Flex, Card } from 'antd'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
@ -11,11 +9,14 @@ import ViewButton from '../../common/ViewButton'
import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator'
import PartsTable from '../../common/PartsTable'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.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 location = useLocation()
@ -44,191 +45,153 @@ const ProductInfo = () => {
editLoading,
lock,
fetchObject
}) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown
menu={{
items: [
{
label: 'Reload Product',
key: 'reload',
icon: <ReloadIcon />
}) => {
const actions = {
reload: () => {
fetchObject()
return true
},
edit: () => {
startEditing()
return false
},
cancelEdit: () => {
cancelEditing()
return true
},
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='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)
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
key='info'
>
<ObjectInfo
loading={loading}
isEditing={isEditing}
type='product'
objectData={objectData}
/>
</InfoCollapse>
<InfoCollapse
title='Product Parts'
icon={<ProductIcon />}
active={collapseState.parts}
onToggle={(expanded) =>
updateCollapseState('parts', expanded)
}
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
sections={[
{ key: 'info', label: 'Product Information' },
{ key: 'parts', label: 'Product Parts' },
{ 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>
key='parts'
>
<ObjectTable
type='part'
visibleColumns={{
product: false,
'product._id': false
}}
masterFilter={{ 'product._id': productId }}
/>
</InfoCollapse>
<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'
>
<ObjectInfo
loading={loading}
isEditing={isEditing}
indicator={null}
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
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
key='notes'
>
<Card>
<NotesPanel _id={productId} />
</Card>
</InfoCollapse>
<InfoCollapse
title='Product Parts'
icon={<ProductIcon />}
active={collapseState.parts}
onToggle={(expanded) => updateCollapseState('parts', expanded)}
key='parts'
>
<PartsTable data={objectData?.parts || []} />
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
key='notes'
>
<Card>
<NotesPanel _id={productId} />
</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>
)}
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': productId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</ActionHandler>
)
}}
</EditObjectForm>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,8 @@
import React, { useContext } from 'react'
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 ReloadIcon from '../../../Icons/ReloadIcon'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
@ -18,10 +16,9 @@ import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
import { ApiServerContext } from '../../context/ApiServerContext'
import {
getModelProperties,
getPropertyValue
} from '../../../../database/ObjectModels.js'
import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const { Text } = Typography
@ -31,7 +28,7 @@ const GCodeFileInfo = () => {
const { handleDownloadContent } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState(
'GCodeFileInfo',
'gcodeFileInfo',
{
info: 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 (
<>
<ActionHandler actions={actions} />
<EditObjectForm
id={gcodeFileId}
type='gcodefile'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{({
loading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
formValid,
objectData,
editLoading,
lock,
fetchObject
}) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown
menu={{
items: [
{
label: 'Reload GCode File',
key: 'reload',
icon: <ReloadIcon />
},
{
label: 'Download GCode File',
key: 'download',
icon: <GCodeFileIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
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>
<EditObjectForm
id={gcodeFileId}
type='gcodefile'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{({
loading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
formValid,
objectData,
editLoading,
lock,
fetchObject
}) => {
// Define actions that can be triggered via URL, now with access to startEditing
const actions = {
reload: () => {
fetchObject()
return true
},
edit: () => {
startEditing()
return false
},
cancelEdit: () => {
cancelEditing()
return true
},
finishEdit: () => {
handleUpdate()
return true
},
download: () => {
if (gcodeFileId) {
handleDownloadContent(
gcodeFileId,
'gcodeFile',
`${objectData.name}.gcode`
)
return true
}
}
}
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='GCode File Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
key='info'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
items={getModelProperties('gcodeFile').map((prop) => ({
...prop,
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%' }}
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='gcodeFile'
id={gcodeFileId}
disabled={loading}
/>
) : (
<Text>n/a</Text>
)}
</Card>
</InfoCollapse>
<ViewButton
disabled={loading}
items={[
{ key: 'info', label: 'GCode File Information' },
{ 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
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
key='notes'
>
<Card>
<NotesPanel _id={gcodeFileId} />
</Card>
</InfoCollapse>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='GCode File Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) =>
updateCollapseState('info', expanded)
}
key='info'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
objectData={objectData}
type='gcodeFile'
/>
</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>
<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%' }}
/>
) : (
<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>
</div>
</Flex>
)}
</EditObjectForm>
</>
)}
</ActionHandler>
)
}}
</EditObjectForm>
)
}

View File

@ -1,313 +1,27 @@
// src/Jobs.js
import React, { useState, useContext, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Button,
Flex,
Space,
Modal,
Dropdown,
message,
notification,
Input,
Typography,
Checkbox,
Popover
} from 'antd'
import { Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import { AuthContext } from '../context/AuthContext.js'
import { PrintServerContext } from '../context/PrintServerContext.js'
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 JobIcon from '../../Icons/JobIcon.jsx'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import PlusIcon from '../../Icons/PlusIcon.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 ListIcon from '../../Icons/ListIcon.jsx'
import GridIcon from '../../Icons/GridIcon.jsx'
import useViewMode from '../hooks/useViewMode.js'
const { Text } = Typography
import ColumnViewButton from '../common/ColumnViewButton.jsx'
const Jobs = () => {
const [messageApi, contextHolder] = message.useMessage()
const [notificationApi, notificationContextHolder] =
notification.useNotification()
const navigate = useNavigate()
const [newJobOpen, setNewJobOpen] = useState(false)
const tableRef = useRef()
const [viewMode, setViewMode] = useViewMode('Jobs')
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 [viewMode, setViewMode] = useViewMode('job')
const { authenticated } = useContext(AuthContext)
const { printServer } = useContext(PrintServerContext)
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'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 [columnVisibility, setColumnVisibility] = useColumnVisibility('job')
const actionItems = {
items: [
@ -336,33 +50,8 @@ const Jobs = () => {
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 (
<>
{notificationContextHolder}
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
{contextHolder}
<Flex justify={'space-between'}>
@ -370,14 +59,14 @@ const Jobs = () => {
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
<ColumnViewButton
type='job'
disabled={false}
visibleState={columnVisibility}
updateVisibleState={setColumnVisibility}
/>
</Space>
<Space>
<Button
icon={viewMode === 'cards' ? <ListIcon /> : <GridIcon />}
@ -391,6 +80,7 @@ const Jobs = () => {
<ObjectTable
ref={tableRef}
type={'job'}
visibleColumns={columnVisibility}
authenticated={authenticated}
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 { Space, Button, Flex, Dropdown, Card } from 'antd'
import { Space, Flex, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
@ -17,15 +16,15 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import JobIcon from '../../../Icons/JobIcon'
import AuditLogIcon from '../../../Icons/AuditLogIcon'
import NoteIcon from '../../../Icons/NoteIcon'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import {
getModelProperties,
getPropertyValue
} from '../../../../database/ObjectModels.js'
import ObjectActions from '../../common/ObjectActions.jsx'
import { ApiServerContext } from '../../context/ApiServerContext'
import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const JobInfo = () => {
const location = useLocation()
const jobId = new URLSearchParams(location.search).get('jobId')
const { handleDownloadContent } = useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState('JobInfo', {
info: true,
subJobs: true,
@ -33,153 +32,173 @@ const JobInfo = () => {
auditLogs: true
})
// Define actions that can be triggered via URL
const actions = {
// Add job-specific actions here as needed
}
return (
<>
<ActionHandler actions={actions} />
<EditObjectForm
id={jobId}
type='job'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{({
loading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
formValid,
objectData,
editLoading,
lock,
fetchObject
}) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown
menu={{
items: [
{
label: 'Reload Job',
key: 'reload',
icon: <GCodeFileIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
}
<EditObjectForm
id={jobId}
type='job'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{({
loading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
formValid,
objectData,
editLoading,
lock,
fetchObject
}) => {
// Define actions that can be triggered via URL, now with access to startEditing
const actions = {
reload: () => {
fetchObject()
return true
},
edit: () => {
startEditing()
return false
},
cancelEdit: () => {
cancelEditing()
return true
},
finishEdit: () => {
handleUpdate()
return true
},
download: () => {
if (jobId) {
handleDownloadContent(
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)
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
sections={[
{ key: 'info', label: 'Job Information' },
{ key: 'subJobs', label: 'Sub 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 || true}
loading={editLoading}
/>
</Space>
</Flex>
key='info'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='job'
objectData={objectData}
/>
</InfoCollapse>
<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'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='job'
items={getModelProperties('job').map((prop) => ({
...prop,
value: getPropertyValue(objectData, prop.name)
}))}
/>
</InfoCollapse>
<InfoCollapse
title='Sub Jobs'
icon={<JobIcon />}
active={collapseState.subJobs}
onToggle={(expanded) =>
updateCollapseState('subJobs', expanded)
}
key='subJobs'
>
<SubJobsTree jobData={objectData} loading={loading} />
</InfoCollapse>
<InfoCollapse
title='Sub Jobs'
icon={<JobIcon />}
active={collapseState.subJobs}
onToggle={(expanded) =>
updateCollapseState('subJobs', expanded)
}
key='subJobs'
>
<SubJobsTree jobData={objectData} loading={loading} />
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
key='notes'
>
<Card>
<NotesPanel _id={jobId} />
</Card>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
key='notes'
>
<Card>
<NotesPanel _id={jobId} />
</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>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': jobId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</div>
</Flex>
</div>
</Flex>
)}
</EditObjectForm>
</>
)}
</ActionHandler>
)
}}
</EditObjectForm>
)
}

View File

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

View File

@ -1,9 +1,8 @@
import React from 'react'
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 useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import NotesPanel from '../../common/NotesPanel'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
@ -16,10 +15,10 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import {
getModelProperties,
getPropertyValue
} from '../../../../database/ObjectModels.js'
import ActionHandler from '../../common/ActionHandler'
import ObjectActions from '../../common/ObjectActions.jsx'
import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
const PrinterInfo = () => {
const location = useLocation()
@ -28,7 +27,8 @@ const PrinterInfo = () => {
info: true,
jobs: true,
notes: true,
auditLogs: true
auditLogsParent: true,
auditLogsOwner: true
})
return (
@ -48,126 +48,178 @@ const PrinterInfo = () => {
editLoading,
lock,
fetchObject
}) => (
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown
menu={{
items: [
{
label: 'Reload Printer',
key: 'reload',
icon: <AuditLogIcon />
}) => {
// Define actions for ActionHandler
const actions = {
reload: () => {
fetchObject()
return true
},
edit: () => {
startEditing()
return false
},
cancelEdit: () => {
cancelEditing()
return true
},
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)
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
key='info'
>
<ObjectInfo
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)
}
}
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
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>
key='jobs'
>
<PrinterJobsTree
subJobs={objectData?.subJobs}
loading={loading}
/>
</InfoCollapse>
<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'
>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='printer'
items={getModelProperties('printer').map((prop) => ({
...prop,
value: getPropertyValue(objectData, prop.name)
}))}
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) =>
updateCollapseState('notes', expanded)
}
key='notes'
>
<Card>
<NotesPanel _id={printerId} />
</Card>
</InfoCollapse>
<InfoCollapse
title='Printer Jobs'
icon={<PrinterIcon />}
active={collapseState.jobs}
onToggle={(expanded) => updateCollapseState('jobs', expanded)}
key='jobs'
>
<PrinterJobsTree
subJobs={objectData?.subJobs}
loading={loading}
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
key='notes'
>
<Card>
<NotesPanel _id={printerId} />
</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>
)}
<InfoCollapse
title='Audit Logs (By Parent)'
icon={<AuditLogIcon />}
active={collapseState.auditLogsParent}
onToggle={(expanded) =>
updateCollapseState('auditLogsParent', expanded)
}
key='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'parent._id': printerId }}
visibleColumns={{ _id: false, 'parent._id': false }}
/>
)}
</InfoCollapse>
<InfoCollapse
title='Audit Logs (By Owner)'
icon={<AuditLogIcon />}
active={collapseState.auditLogsOwner}
onToggle={(expanded) =>
updateCollapseState('auditLogsOwner', expanded)
}
key='auditLogs'
>
{loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='auditLog'
masterFilter={{ 'owner._id': printerId }}
visibleColumns={{ _id: false, 'owner._id': false }}
/>
)}
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</ActionHandler>
)
}}
</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 PropTypes from 'prop-types'
const ActionHandler = ({
children,
actions = {},
actionParam = 'action',
clearAfterExecute = true,
onAction
onAction,
loading = true
}) => {
const location = useLocation()
const navigate = useNavigate()
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
useEffect(() => {
if (action && actions[action]) {
if (
!loading &&
action &&
actions[action] &&
lastExecutedAction.current !== action
) {
// Execute the action
const result = actions[action]()
// Mark this action as executed
lastExecutedAction.current = action
// Call optional callback
if (onAction) {
onAction(action, result)
}
// Clear action from URL if requested
if (clearAfterExecute) {
// Clear action from URL if requested and result is true
if (clearAfterExecute && result == true) {
const searchParams = new URLSearchParams(location.search)
searchParams.delete(actionParam)
const newSearch = searchParams.toString()
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
navigate(newPath, { replace: true })
}
} else if (!action) {
// Reset lastExecutedAction if no action is present
lastExecutedAction.current = null
}
}, [
loading,
action,
actions,
actionParam,
@ -44,14 +67,16 @@ const ActionHandler = ({
])
// Return null as this is a utility component
return null
return <>{children({ callAction })}</>
}
ActionHandler.propTypes = {
children: PropTypes.func,
actions: PropTypes.objectOf(PropTypes.func),
actionParam: PropTypes.string,
clearAfterExecute: PropTypes.bool,
onAction: PropTypes.func
onAction: PropTypes.func,
loading: PropTypes.bool
}
export default ActionHandler

View File

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

View File

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

View File

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

View File

@ -29,9 +29,8 @@ const IdDisplay = ({
var hyperlink = null
const defaultModelActions = model.actions.filter(
(action) => action.default == true
)
const defaultModelActions =
model.actions?.filter((action) => action.default == true) || []
if (defaultModelActions.length >= 1) {
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 { Spin, Descriptions } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty'
import { getModelProperties } from '../../../database/ObjectModels'
const ObjectInfo = ({
loading = false,
indicator = null,
bordered = true,
isEditing = false,
items = []
type = 'unknown',
objectData = null
}) => {
const items = getModelProperties(type)
// Map items to Descriptions 'items' prop format
const descriptionItems = items.map((item, idx) => {
const key = item.name || item.label || idx
return {
key,
label: item.label,
children: <ObjectProperty {...item} isEditing={isEditing} />
children: (
<ObjectProperty
{...item}
isEditing={isEditing}
objectData={objectData}
/>
)
}
})
return (
<Spin spinning={loading} indicator={indicator}>
<Spin spinning={loading} indicator={<LoadingOutlined />}>
<Descriptions
bordered={bordered}
column={{
@ -44,7 +54,8 @@ ObjectInfo.propTypes = {
bordered: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object),
isEditing: PropTypes.bool,
type: PropTypes.string.isRequired
type: PropTypes.string.isRequired,
objectData: PropTypes.object
}
export default ObjectInfo

View File

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

View File

@ -38,6 +38,7 @@ import XMarkIcon from '../../Icons/XMarkIcon'
import CheckIcon from '../../Icons/CheckIcon'
import { useNavigate } from 'react-router-dom'
import QuestionCircleIcon from '../../Icons/QuestionCircleIcon'
import { AuthContext } from '../context/AuthContext'
const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
@ -49,13 +50,14 @@ const ObjectTable = forwardRef(
pageSize = 25,
scrollHeight = 'calc(var(--unit-100vh) - 270px)',
onDataChange,
authenticated,
initialPage = 1,
cards = false,
visibleColumns = {}
visibleColumns = {},
masterFilter = {}
},
ref
) => {
const { authenticated } = useContext(AuthContext)
const { fetchTableData } = useContext(ApiServerContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const navigate = useNavigate()
@ -107,7 +109,7 @@ const ObjectTable = forwardRef(
const result = await fetchTableData(type, {
page: pageNum,
limit: pageSize,
filter,
filter: { ...filter, ...masterFilter },
sorter,
onDataChange
})
@ -408,17 +410,15 @@ const ObjectTable = forwardRef(
<ObjectProperty
{...prop}
longId={false}
type={prop.type}
objectType={prop.objectType}
value={getPropertyValue(record, prop.name)}
objectData={record}
isEditing={false}
/>
)
}
}
// Add filter configuration if the property is filterable
if (isFilterable) {
// Add filter configuration if the property is filterable and not in masterFilter
if (isFilterable && !Object.keys(masterFilter).includes(prop.name)) {
columnConfig.filterDropdown = ({
setSelectedKeys,
selectedKeys,
@ -606,7 +606,8 @@ ObjectTable.propTypes = {
initialPage: PropTypes.number,
cards: PropTypes.bool,
cardRenderer: PropTypes.func,
visibleColumns: PropTypes.object
visibleColumns: PropTypes.object,
masterFilter: PropTypes.object
}
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>
) : (
<>
<Text style={{ marginRight: 8 }}>{url}</Text>
<Text style={{ marginRight: 8 }} ellipsis>
{url}
</Text>
<Tooltip title='Open URL' arrow={false}>
<Button
icon={<LinkIcon style={{ fontSize: '14px' }} />}
icon={<LinkIcon />}
type='text'
style={{ height: '22px' }}
size='small'
onClick={(e) => {
e.preventDefault()
window.open(url, '_blank', 'noopener,noreferrer')

View File

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

View File

@ -331,14 +331,25 @@ const ApiServerProvider = ({ children }) => {
fileLink.parentNode.removeChild(fileLink)
} catch (error) {
logger.error('Failed to download GCode file content:', error)
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 {
messageApi.error(
'An unexpected error occurred while downloading. Please try again later.'
showError(
'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
maskClosable={true}
footer={[
<Button
key='retry'
onClick={() => {
setShowErrorModal(false)
}}
>
Close
</Button>,
<Button key='retry' icon={<ReloadIcon />} onClick={handleRetry}>
Retry
</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) {
const model = getModelByName(name)
@ -132,3 +142,53 @@ export const getPropertyValue = (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 InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
export const AuditLog = {
name: 'auditlog',
name: 'auditLog',
label: 'Audit Log',
prefix: 'ADL',
icon: AuditLogIcon,
actions: [
actions: [],
columns: ['_id', 'owner', 'owner._id', 'parent._id', 'changes', 'createdAt'],
filters: ['_id', 'owner._id', 'parent._id'],
sorters: [],
properties: [
{
name: 'info',
label: 'Info',
default: true,
row: true,
icon: InfoCircleIcon,
url: (_id) => `/dashboard/management/auditlogs/info?auditLogId=${_id}`
name: '_id',
label: 'ID',
type: 'id',
objectType: 'auditLog',
columnFixed: 'left',
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 InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const Filament = {
name: 'filament',
@ -14,6 +16,21 @@ export const Filament = {
row: true,
icon: InfoCircleIcon,
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: [

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 InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const GCodeFile = {
name: 'gcodeFile',
@ -15,12 +18,28 @@ export const GCodeFile = {
icon: InfoCircleIcon,
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',
label: 'Download',
row: true,
icon: DownloadIcon,
url: (_id) =>
`/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 InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const Job = {
name: 'job',
@ -14,6 +16,19 @@ export const Job = {
row: true,
icon: InfoCircleIcon,
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: [
@ -25,7 +40,7 @@ export const Job = {
'createdAt'
],
filters: ['state', '_id', 'gcodeFile._id', 'quantity'],
sorters: ['createdAt', 'state', 'quantity', '_id'],
sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
properties: [
{
name: '_id',

View File

@ -1,5 +1,7 @@
import NoteTypeIcon from '../../components/Icons/NoteTypeIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const NoteType = {
name: 'noteType',
@ -14,6 +16,21 @@ export const NoteType = {
row: true,
icon: InfoCircleIcon,
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'],

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 PartIcon from '../../components/Icons/PartIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const Part = {
name: 'part',
@ -14,10 +17,39 @@ export const Part = {
row: true,
icon: InfoCircleIcon,
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'],
filters: ['name', '_id', 'product', 'product._id'],
columns: [
'name',
'_id',
'product',
'product._id',
'globalPricing',
'createdAt'
],
filters: ['name', '_id', 'product', 'product._id', 'globalPricing'],
sorters: ['name', 'email', 'role', 'createdAt', '_id'],
properties: [
{
@ -25,8 +57,9 @@ export const Part = {
label: 'ID',
columnFixed: 'left',
type: 'id',
objectType: 'user',
showCopy: true
objectType: 'part',
showCopy: true,
readOnly: true
},
{
name: 'createdAt',
@ -52,13 +85,58 @@ export const Part = {
name: 'product',
label: 'Product',
type: 'object',
required: true,
objectType: 'product'
},
{
name: 'product._id',
label: 'Product ID',
type: 'id',
readOnly: true,
showHyperlink: true,
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 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 = {
name: 'printer',
@ -14,12 +17,34 @@ export const Printer = {
row: true,
icon: InfoCircleIcon,
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'],
filters: ['name', '_id', 'state', 'tags'],
sorters: ['name', 'state', 'connectedAt', '_id'],
sorters: ['name', 'state', 'connectedAt'],
properties: [
{
name: '_id',

View File

@ -1,5 +1,7 @@
import ProductIcon from '../../components/Icons/ProductIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
import EditIcon from '../../components/Icons/EditIcon'
export const Product = {
name: 'product',
@ -14,16 +16,43 @@ export const Product = {
row: true,
icon: InfoCircleIcon,
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: [
{
name: '_id',
label: 'ID',
type: 'id',
objectType: 'printer',
showCopy: true
objectType: 'product',
showCopy: true,
readOnly: true
},
{
name: 'createdAt',
@ -42,6 +71,59 @@ export const Product = {
label: 'Updated At',
type: 'dateTime',
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 InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const User = {
name: 'user',
@ -14,9 +15,15 @@ export const User = {
row: true,
icon: InfoCircleIcon,
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'],
filters: ['name', '_id', 'email', 'role'],
sorters: ['name', 'email', 'role', 'createdAt', '_id'],

View File

@ -1,5 +1,7 @@
import VendorIcon from '../../components/Icons/VendorIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import EditIcon from '../../components/Icons/EditIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon'
export const Vendor = {
name: 'vendor',
@ -14,10 +16,25 @@ export const Vendor = {
row: true,
icon: InfoCircleIcon,
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}`,
columns: ['name', '_id', 'country', 'email', 'createdAt'],
columns: ['name', '_id', 'country', 'email', 'website', 'createdAt'],
filters: ['name', '_id', 'country', 'email'],
sorters: ['name', 'country', 'email', 'createdAt', '_id'],
properties: [