From 3c2d3ec858e8624837e25214704e3da712d5877c Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 7 Jul 2025 00:30:38 +0100 Subject: [PATCH] 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. --- src/assets/icons/downloadicon.afdesign | Bin 0 -> 30331 bytes src/assets/icons/downloadicon.min.svg | 1 + src/assets/icons/downloadicon.svg | 8 + .../Dashboard/Management/AuditLogs.jsx | 15 +- .../Management/Filaments/FilamentInfo.jsx | 244 ++--- .../Management/NoteTypes/NoteTypeInfo.jsx | 216 +++-- src/components/Dashboard/Management/Parts.jsx | 11 +- .../Dashboard/Management/Parts/PartInfo.jsx | 848 ++++-------------- .../Management/Products/ProductInfo.jsx | 333 +++---- src/components/Dashboard/Management/Users.jsx | 6 +- .../Dashboard/Management/Users/UserInfo.jsx | 242 ++--- .../Management/Vendors/VendorInfo.jsx | 241 ++--- .../Dashboard/Production/GCodeFiles.jsx | 14 +- .../Production/GCodeFiles/GCodeFileInfo.jsx | 359 ++++---- src/components/Dashboard/Production/Jobs.jsx | 334 +------ .../Dashboard/Production/Jobs/JobInfo.jsx | 317 ++++--- .../Dashboard/Production/Printers.jsx | 14 +- .../Production/Printers/PrinterInfo.jsx | 298 +++--- .../Dashboard/common/ActionHandler.jsx | 43 +- .../Dashboard/common/ColumnViewButton.jsx | 20 +- .../Dashboard/common/EditObjectForm.jsx | 22 +- .../Dashboard/common/EmailDisplay.jsx | 5 +- src/components/Dashboard/common/IdDisplay.jsx | 5 +- .../common/InfoCollapsePlaceholder.jsx | 13 + .../Dashboard/common/NewObjectForm.jsx | 84 ++ .../Dashboard/common/ObjectActions.jsx | 104 +++ .../Dashboard/common/ObjectInfo.jsx | 21 +- .../Dashboard/common/ObjectProperty.jsx | 292 ++++-- .../Dashboard/common/ObjectTable.jsx | 19 +- .../Dashboard/common/PropertyChanges.jsx | 62 ++ .../Dashboard/common/UrlDisplay.jsx | 8 +- .../Dashboard/common/ViewButton.jsx | 28 +- .../Dashboard/context/ApiServerContext.js | 27 +- src/components/Icons/DownloadIcon.jsx | 7 + src/database/ObjectModels.js | 60 ++ src/database/models/AuditLog.js | 91 +- src/database/models/Filament.js | 17 + src/database/models/GCodeFile.js | 19 + src/database/models/Job.js | 17 +- src/database/models/NoteType.js | 17 + src/database/models/Part.js | 86 +- src/database/models/Printer.js | 29 +- src/database/models/Product.js | 88 +- src/database/models/User.js | 9 +- src/database/models/Vendor.js | 19 +- 45 files changed, 2442 insertions(+), 2271 deletions(-) create mode 100644 src/assets/icons/downloadicon.afdesign create mode 100644 src/assets/icons/downloadicon.min.svg create mode 100644 src/assets/icons/downloadicon.svg create mode 100644 src/components/Dashboard/common/InfoCollapsePlaceholder.jsx create mode 100644 src/components/Dashboard/common/NewObjectForm.jsx create mode 100644 src/components/Dashboard/common/ObjectActions.jsx create mode 100644 src/components/Dashboard/common/PropertyChanges.jsx create mode 100644 src/components/Icons/DownloadIcon.jsx diff --git a/src/assets/icons/downloadicon.afdesign b/src/assets/icons/downloadicon.afdesign new file mode 100644 index 0000000000000000000000000000000000000000..19c091a117eef8b98f8ea3492960ac0618461b6c GIT binary patch literal 30331 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 literal 0 HcmV?d00001 diff --git a/src/assets/icons/downloadicon.min.svg b/src/assets/icons/downloadicon.min.svg new file mode 100644 index 0000000..ef5cfa7 --- /dev/null +++ b/src/assets/icons/downloadicon.min.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/downloadicon.svg b/src/assets/icons/downloadicon.svg new file mode 100644 index 0000000..f6e0904 --- /dev/null +++ b/src/assets/icons/downloadicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/Dashboard/Management/AuditLogs.jsx b/src/components/Dashboard/Management/AuditLogs.jsx index e19882d..cd66fbd 100644 --- a/src/components/Dashboard/Management/AuditLogs.jsx +++ b/src/components/Dashboard/Management/AuditLogs.jsx @@ -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 ( <> @@ -318,9 +310,8 @@ const AuditLogs = () => { diff --git a/src/components/Dashboard/Management/Filaments/FilamentInfo.jsx b/src/components/Dashboard/Management/Filaments/FilamentInfo.jsx index 57a662b..000e79b 100644 --- a/src/components/Dashboard/Management/Filaments/FilamentInfo.jsx +++ b/src/components/Dashboard/Management/Filaments/FilamentInfo.jsx @@ -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 - }) => ( - - - - - + }) => { + // Define actions for ActionHandler + const actions = { + reload: () => { + fetchObject() + return true + }, + edit: () => { + startEditing() + return false + }, + cancelEdit: () => { + cancelEditing() + return true + }, + finishEdit: () => { + handleUpdate() + return true + } + } + + return ( + + {({ callAction }) => ( + + + + + + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading} + loading={editLoading} + /> + + + +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) } - ], - onClick: ({ key }) => { - if (key === 'reload') { - fetchObject() + key='info' + > + } + isEditing={isEditing} + type='filament' + objectData={objectData} + /> + + + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) } - } - }} - > - - - - - - - - - - + key='notes' + > + + + + -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' - > - } - isEditing={isEditing} - items={getModelProperties('filament').map((prop) => ({ - ...prop, - value: getPropertyValue(objectData, prop.name) - }))} - /> - - - } - active={collapseState.notes} - onToggle={(expanded) => updateCollapseState('notes', expanded)} - key='notes' - > - - - - - - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - - -
- - )} + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
+
+ )} +
+ ) + }} ) } diff --git a/src/components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx b/src/components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx index 211e85e..267918f 100644 --- a/src/components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx +++ b/src/components/Dashboard/Management/NoteTypes/NoteTypeInfo.jsx @@ -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 ( {({ @@ -46,99 +44,121 @@ const NoteTypeInfo = () => { editLoading, lock, fetchObject - }) => ( - - - - - - } - ], - onClick: ({ key }) => { - if (key === 'reload') { - fetchObject() - } - } - }} - > - - - - - - - - - - + }) => { + // Define actions for ActionHandler + const actions = { + reload: () => { + fetchObject() + return true + }, + edit: () => { + startEditing() + return false + }, + cancelEdit: () => { + cancelEditing() + return true + }, + finishEdit: () => { + handleUpdate() + return true + } + } -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' + return ( + + {({ callAction }) => ( + - } - isEditing={isEditing} - type='noteType' - items={getModelProperties('noteType').map((prop) => ({ - ...prop, - value: getPropertyValue(objectData, prop.name) - }))} - /> - + + + + + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading} + loading={editLoading} + /> + + - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - - -
-
- )} +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) + } + key='info' + > + } + isEditing={isEditing} + type='noteType' + objectData={objectData} + /> + + + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
+
+ )} + + ) + }} ) } diff --git a/src/components/Dashboard/Management/Parts.jsx b/src/components/Dashboard/Management/Parts.jsx index d690b1a..456c443 100644 --- a/src/components/Dashboard/Management/Parts.jsx +++ b/src/components/Dashboard/Management/Parts.jsx @@ -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} />
{ - 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: - } - ], - 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 ( - - - {sections.map((section) => ( - { - updateCollapseState(section.key, e.target.checked) - }} - > - {section.label} - - ))} - - - ) - } - - if (error) { - return ( - -

{error || 'Part not found'}

- -
- ) - } - return ( - <> - {contextHolder} - - - - - - - - - - - - {isEditing ? ( - <> - - - ) : ( -
- - - updateCollapseState('info', keys.length > 0) - } - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse no-t-padding-collapse' + + {({ + 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 ( + + {({ callAction }) => ( + - - - - Part Information - - - } - key='1' - > -
- setPartFormValues((prevValues) => ({ - ...prevValues, - ...changedValues - })) - } - initialValues={{ - name: partData?.name || '', - version: partData?.version || '', - tags: partData?.tags || [] - }} - > - } - spinning={fetchLoading} + + + + + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading} + loading={editLoading} + /> + + + +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) + } + key='info' > - - - {partData?.id ? ( - - ) : ( - n/a - )} - - - {partData?.createdAt ? ( - - ) : ( - n/a - )} - + + - - {isEditing ? ( - - - - ) : partData?.name ? ( - {partData.name} - ) : ( - n/a - )} - + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) + } + key='notes' + > + + + + - - {partData?.updatedAt ? ( - - ) : ( - n/a - )} - - - - {partData?.product?.name ? ( - {partData.product.name} - ) : ( - n/a - )} - - - {partData?.product?._id ? ( - - ) : ( - n/a - )} - - - {isEditing && useGlobalPricing == false ? ( - - {marginOrPrice == false ? ( - - - - ) : ( - - - - )} - - Price - - - ) : partData?.margin && - marginOrPrice == false && - partData?.useGlobalPricing == false ? ( - {partData.margin + '%'} - ) : partData?.price && - marginOrPrice == true && - partData?.useGlobalPricing == false ? ( - {'£' + partData.price} - ) : ( - n/a - )} - - - {isEditing ? ( - - - - ) : partData ? ( - - ) : ( - n/a - )} - - - {partData?.version ? ( - {partData.version} - ) : ( - n/a - )} - - - {partData?.tags && partData.tags.length > 0 ? ( - partData.tags.map((tag, index) => ( - {tag} - )) - ) : ( - n/a - )} - - - - - - - - - updateCollapseState('preview', keys.length > 0) - } - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse' - > - - - - Part Preview - - - } - key='2' - > - - {stlLoadError ? ( -
- - - - {stlLoadError} - - -
- ) : ( - partFileObjectId && ( - - ) - )} -
- - - - - updateCollapseState('notes', keys.length > 0) - } - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse' - > - - - - Notes - - - } - key='notes' - > - - - - - - - - updateCollapseState('auditLogs', keys.length > 0) - } - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse' - > - - - - Audit Logs - - - } - key='auditLogs' - > - - - - -
- )} -
- + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
+
+ )} + + ) + }} + ) } diff --git a/src/components/Dashboard/Management/Products/ProductInfo.jsx b/src/components/Dashboard/Management/Products/ProductInfo.jsx index a99de80..4dec3ac 100644 --- a/src/components/Dashboard/Management/Products/ProductInfo.jsx +++ b/src/components/Dashboard/Management/Products/ProductInfo.jsx @@ -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 - }) => ( - - - - - + }) => { + const actions = { + reload: () => { + fetchObject() + return true + }, + edit: () => { + startEditing() + return false + }, + cancelEdit: () => { + cancelEditing() + return true + }, + finishEdit: () => { + handleUpdate() + return true + } + } + return ( + + {({ callAction }) => ( + + + + + + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading} + loading={editLoading} + /> + + + +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) } - ], - onClick: ({ key }) => { - if (key === 'reload') { - fetchObject() + key='info' + > + + + + } + active={collapseState.parts} + onToggle={(expanded) => + updateCollapseState('parts', expanded) } - } - }} - > - - - - - - - - - - + key='parts' + > + + -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' - > - - + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) + } + key='notes' + > + + + + - } - active={collapseState.parts} - onToggle={(expanded) => updateCollapseState('parts', expanded)} - key='parts' - > - - - - } - active={collapseState.notes} - onToggle={(expanded) => updateCollapseState('notes', expanded)} - key='notes' - > - - - - - - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - - -
- - )} + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
+
+ )} +
+ ) + }} ) } diff --git a/src/components/Dashboard/Management/Users.jsx b/src/components/Dashboard/Management/Users.jsx index 4c92a28..de8613f 100644 --- a/src/components/Dashboard/Management/Users.jsx +++ b/src/components/Dashboard/Management/Users.jsx @@ -40,9 +40,9 @@ const Users = () => {
diff --git a/src/components/Dashboard/Management/Users/UserInfo.jsx b/src/components/Dashboard/Management/Users/UserInfo.jsx index 667598c..135850d 100644 --- a/src/components/Dashboard/Management/Users/UserInfo.jsx +++ b/src/components/Dashboard/Management/Users/UserInfo.jsx @@ -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 - }) => ( - - - - - + }) => { + // Define actions for ActionHandler + const actions = { + reload: () => { + fetchObject() + return true + }, + edit: () => { + startEditing() + return false + }, + cancelEdit: () => { + cancelEditing() + return true + }, + finishEdit: () => { + handleUpdate() + return true + } + } + + return ( + + {({ callAction }) => ( + + + + + + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading || true} + loading={editLoading} + /> + + + +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) } - ], - onClick: ({ key }) => { - if (key === 'reload') { - fetchObject() + key='info' + > + } + isEditing={isEditing} + type='user' + objectData={objectData} + /> + + + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) } - } - }} - > - - - - - - - - - - + key='notes' + > + + + + -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' - > - } - isEditing={isEditing} - type='user' - items={getModelProperties('user').map((prop) => ({ - ...prop, - value: getPropertyValue(objectData, prop.name) - }))} - /> - - - } - active={collapseState.notes} - onToggle={(expanded) => updateCollapseState('notes', expanded)} - key='notes' - > - - - - - - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - - -
- - )} + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
+
+ )} +
+ ) + }} ) } diff --git a/src/components/Dashboard/Management/Vendors/VendorInfo.jsx b/src/components/Dashboard/Management/Vendors/VendorInfo.jsx index 7c7a661..1b2ebe2 100644 --- a/src/components/Dashboard/Management/Vendors/VendorInfo.jsx +++ b/src/components/Dashboard/Management/Vendors/VendorInfo.jsx @@ -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 - }) => ( - - - - - + }) => { + // Define actions for ActionHandler + const actions = { + reload: () => { + fetchObject() + return true + }, + edit: () => { + startEditing() + return false + }, + cancelEdit: () => { + cancelEditing() + return true + }, + finishEdit: () => { + handleUpdate() + return true + } + } + + return ( + + {({ callAction }) => ( + + + + + + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading} + loading={editLoading} + /> + + + +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) } - ], - onClick: ({ key }) => { - if (key === 'reload') { - fetchObject() + key='info' + > + + + + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) } - } - }} - > - - - - - - - - - - + key='notes' + > + + + + -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' - > - } - isEditing={isEditing} - items={getModelProperties('vendor').map((prop) => ({ - ...prop, - value: getPropertyValue(objectData, prop.name) - }))} - /> - - - } - active={collapseState.notes} - onToggle={(expanded) => updateCollapseState('notes', expanded)} - key='notes' - > - - - - - - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - - -
- - )} + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
+
+ )} +
+ ) + }} ) } diff --git a/src/components/Dashboard/Production/GCodeFiles.jsx b/src/components/Dashboard/Production/GCodeFiles.jsx index 354a2db..49c0e7e 100644 --- a/src/components/Dashboard/Production/GCodeFiles.jsx +++ b/src/components/Dashboard/Production/GCodeFiles.jsx @@ -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 = () => {
@@ -75,8 +72,7 @@ const GCodeFiles = () => {
diff --git a/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx b/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx index c2ca031..1d3f11f 100644 --- a/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx +++ b/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx @@ -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 ( - <> - - - {({ - loading, - isEditing, - startEditing, - cancelEditing, - handleUpdate, - formValid, - objectData, - editLoading, - lock, - fetchObject - }) => ( - - - - - - }, - { - label: 'Download GCode File', - key: 'download', - icon: - } - ], - onClick: ({ key }) => { - if (key === 'reload') { - fetchObject() - } else if (key === 'download' && gcodeFileId) { - handleDownloadContent( - gcodeFileId, - 'gcodefile', - `gcodefile-${gcodeFileId}.gcode` - ) - } - } - }} - > - - - - - - - - - - + + {({ + 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 + } + } + } -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' - > - } - isEditing={isEditing} - items={getModelProperties('gcodeFile').map((prop) => ({ - ...prop, - value: getPropertyValue(objectData, prop.name) - }))} - objectData={objectData} - type='gcodefile' - /> - - - } - active={collapseState.preview} - onToggle={(expanded) => - updateCollapseState('preview', expanded) - } - key='preview' - > - - {objectData?.gcodeFileInfo?.thumbnail ? ( - GCodeFile + {({ callAction }) => ( + + + + + - ) : ( - n/a - )} - - + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading} + loading={editLoading} + /> + + - } - active={collapseState.notes} - onToggle={(expanded) => - updateCollapseState('notes', expanded) - } - key='notes' - > - - - - +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) + } + key='info' + > + } + isEditing={isEditing} + objectData={objectData} + type='gcodeFile' + /> + - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - + } + active={collapseState.preview} + onToggle={(expanded) => + updateCollapseState('preview', expanded) + } + key='preview' + > + + {objectData?.gcodeFileInfo?.thumbnail ? ( + GCodeFile + ) : ( + n/a + )} + + + + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) + } + key='notes' + > + + + + + + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
-
-
- )} -
- + )} +
+ ) + }} + ) } diff --git a/src/components/Dashboard/Production/Jobs.jsx b/src/components/Dashboard/Production/Jobs.jsx index 2ee0074..dfb04f1 100644 --- a/src/components/Dashboard/Production/Jobs.jsx +++ b/src/components/Dashboard/Production/Jobs.jsx @@ -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 ( -
- - - setSelectedKeys(e.target.value ? [e.target.value] : []) - } - onPressEnter={() => confirm()} - style={{ width: 200, display: 'block' }} - /> -
- ) - } - - // Column definitions - const columns = [ - { - title: , - key: 'icon', - width: 40, - fixed: 'left', - render: () => - }, - { - title: 'GCode File Name', - key: 'gcodeFileName', - width: 200, - fixed: 'left', - render: (record) => {record?.gcodeFile?.name}, - 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) => , - 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 - }, - filterDropdown: ({ - setSelectedKeys, - selectedKeys, - confirm, - clearFilters - }) => - getFilterDropdown({ - setSelectedKeys, - selectedKeys, - confirm, - clearFilters, - propertyName: 'state' - }), - onFilter: (value, record) => - record?.state?.type?.toLowerCase().includes(value.toLowerCase()) - }, - { - title: , - key: 'complete', - width: 70, - render: (record) => { - return - } - }, - { - title: , - key: 'queued', - width: 70, - render: (record) => { - return - } - }, - { - title: , - key: 'failed', - width: 70, - render: (record) => { - return - } - }, - { - title: , - key: 'draft', - width: 70, - render: (record) => { - return - } - }, - { - title: 'Created At', - dataIndex: 'createdAt', - key: 'createdAt', - width: 180, - render: (createdAt) => { - if (createdAt) { - return - } else { - return 'n/a' - } - }, - sorter: true - }, - { - title: 'Started At', - dataIndex: 'startedAt', - key: 'startedAt', - width: 180, - render: (startedAt) => { - if (startedAt) { - return - } else { - return 'n/a' - } - }, - sorter: true - }, - { - title: 'Actions', - key: 'actions', - fixed: 'right', - width: 150, - render: (record) => { - return ( - - {record?.state?.type === 'draft' ? ( - -
-
- ) - } - } - ] - + 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: - }, - { - label: 'Edit', - key: 'edit', - icon: - } - ], - 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) => ( - { - updateColumnVisibility(col.key, e.target.checked) - }} - > - {col.title} - - )) - - return ( - - - {columnItems} - - - ) - } - return ( <> - {notificationContextHolder} {contextHolder} @@ -370,14 +59,14 @@ const Jobs = () => { - - - +
+ - - - - -
- - - -
+ key='info' + > + } + isEditing={isEditing} + type='job' + objectData={objectData} + /> + -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' - > - } - isEditing={isEditing} - type='job' - items={getModelProperties('job').map((prop) => ({ - ...prop, - value: getPropertyValue(objectData, prop.name) - }))} - /> - + } + active={collapseState.subJobs} + onToggle={(expanded) => + updateCollapseState('subJobs', expanded) + } + key='subJobs' + > + + - } - active={collapseState.subJobs} - onToggle={(expanded) => - updateCollapseState('subJobs', expanded) - } - key='subJobs' - > - - + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) + } + key='notes' + > + + + + - } - active={collapseState.notes} - onToggle={(expanded) => - updateCollapseState('notes', expanded) - } - key='notes' - > - - - - - - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - + } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
- -
- )} - - + )} + + ) + }} + ) } diff --git a/src/components/Dashboard/Production/Printers.jsx b/src/components/Dashboard/Production/Printers.jsx index 518de6f..a61e8a0 100644 --- a/src/components/Dashboard/Production/Printers.jsx +++ b/src/components/Dashboard/Production/Printers.jsx @@ -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 = () => { @@ -78,8 +75,7 @@ const Printers = () => { diff --git a/src/components/Dashboard/Production/Printers/PrinterInfo.jsx b/src/components/Dashboard/Production/Printers/PrinterInfo.jsx index 60c15c4..99d6fd9 100644 --- a/src/components/Dashboard/Production/Printers/PrinterInfo.jsx +++ b/src/components/Dashboard/Production/Printers/PrinterInfo.jsx @@ -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 - }) => ( - - - - - + }) => { + // Define actions for ActionHandler + const actions = { + reload: () => { + fetchObject() + return true + }, + edit: () => { + startEditing() + return false + }, + cancelEdit: () => { + cancelEditing() + return true + }, + finishEdit: () => { + handleUpdate() + return true + } + } + + return ( + + {({ callAction }) => ( + + + + + + + + + + + { + callAction('finishEdit') + }} + cancelEditing={() => { + callAction('cancelEdit') + }} + startEditing={() => { + callAction('edit') + }} + editLoading={editLoading} + formValid={formValid} + disabled={lock?.locked || loading} + loading={editLoading} + /> + + + +
+ + } + active={collapseState.info} + onToggle={(expanded) => + updateCollapseState('info', expanded) } - ], - onClick: ({ key }) => { - if (key === 'reload') { - fetchObject() + key='info' + > + } + isEditing={isEditing} + type='printer' + objectData={objectData} + /> + + + } + active={collapseState.jobs} + onToggle={(expanded) => + updateCollapseState('jobs', expanded) } - } - }} - > - - - - - - - - - - + key='jobs' + > + + -
- - } - active={collapseState.info} - onToggle={(expanded) => updateCollapseState('info', expanded)} - key='info' - > - } - isEditing={isEditing} - type='printer' - items={getModelProperties('printer').map((prop) => ({ - ...prop, - value: getPropertyValue(objectData, prop.name) - }))} - /> - + } + active={collapseState.notes} + onToggle={(expanded) => + updateCollapseState('notes', expanded) + } + key='notes' + > + + + + - } - active={collapseState.jobs} - onToggle={(expanded) => updateCollapseState('jobs', expanded)} - key='jobs' - > - - - - } - active={collapseState.notes} - onToggle={(expanded) => updateCollapseState('notes', expanded)} - key='notes' - > - - - - - - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - key='auditLogs' - > - - - -
- - )} + } + active={collapseState.auditLogsParent} + onToggle={(expanded) => + updateCollapseState('auditLogsParent', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + } + active={collapseState.auditLogsOwner} + onToggle={(expanded) => + updateCollapseState('auditLogsOwner', expanded) + } + key='auditLogs' + > + {loading ? ( + + ) : ( + + )} + + +
+
+ )} +
+ ) + }} ) } diff --git a/src/components/Dashboard/common/ActionHandler.jsx b/src/components/Dashboard/common/ActionHandler.jsx index 3a33ef2..1d22524 100644 --- a/src/components/Dashboard/common/ActionHandler.jsx +++ b/src/components/Dashboard/common/ActionHandler.jsx @@ -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 diff --git a/src/components/Dashboard/common/ColumnViewButton.jsx b/src/components/Dashboard/common/ColumnViewButton.jsx index 6e5826f..455c900 100644 --- a/src/components/Dashboard/common/ColumnViewButton.jsx +++ b/src/components/Dashboard/common/ColumnViewButton.jsx @@ -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 ( ) @@ -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 diff --git a/src/components/Dashboard/common/EditObjectForm.jsx b/src/components/Dashboard/common/EditObjectForm.jsx index 54b53ae..4f4cda0 100644 --- a/src/components/Dashboard/common/EditObjectForm.jsx +++ b/src/components/Dashboard/common/EditObjectForm.jsx @@ -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 ( -
+ { + setObjectData((prev) => ({ ...prev, ...values })) + }} + > {contextHolder} {children({ loading: fetchLoading, diff --git a/src/components/Dashboard/common/EmailDisplay.jsx b/src/components/Dashboard/common/EmailDisplay.jsx index 809539f..7ed8f91 100644 --- a/src/components/Dashboard/common/EmailDisplay.jsx +++ b/src/components/Dashboard/common/EmailDisplay.jsx @@ -24,9 +24,9 @@ const EmailDisplay = ({ email, showCopy = true, showLink = false }) => { +
+ ) +} + +ObjectActions.propTypes = { + type: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + disabled: PropTypes.bool, + buttonProps: PropTypes.object, + buttonLabel: PropTypes.string +} + +export default ObjectActions diff --git a/src/components/Dashboard/common/ObjectInfo.jsx b/src/components/Dashboard/common/ObjectInfo.jsx index ba16461..b2a0cd1 100644 --- a/src/components/Dashboard/common/ObjectInfo.jsx +++ b/src/components/Dashboard/common/ObjectInfo.jsx @@ -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: + children: ( + + ) } }) return ( - + }> { + 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 } else { - return n/a + return ( + + n/a + + ) } case 'wsprotocol': switch (value) { case 'ws': - return Websocket + return Websocket case 'wss': - return Websocket Secure + return Websocket Secure default: - return n/a + return ( + + n/a + + ) + } + case 'priceMode': + switch (value) { + case 'margin': + return Margin % + case 'amount': + return £ Amount + default: + return ( + + n/a + + ) } case 'bool': { if (value != null) { return } else { - return n/a + return ( + + n/a + + ) } } case 'dateTime': { if (value != null) { return } else { - return n/a - } - } - case 'currency': { - if (value != null) { - return {`£${value}/kg`} - } else { - return n/a + return ( + + n/a + + ) } } case 'country': { if (value != null) { return } else { - return n/a + return ( + + n/a + + ) } } case 'color': { if (value) { return } else { - return n/a - } - } - case 'weight': { - if (value != null) { - return {`${value}g`} - } else { - return n/a + return ( + + n/a + + ) } } case 'number': { if (value != null) { if (Array.isArray(value)) { - return {value.length} + return ( + + {prefix} + {value.length} + {suffix} + + ) } else { - return {value} + return ( + + {prefix} + {value} + {suffix} + + ) } } else { - return n/a + return ( + + n/a + + ) } } case 'text': if (value != null && value != '') { - return {value} + return ( + + {prefix} + {value} + {suffix} + + ) } else { - return n/a + return ( + + n/a + + ) } case 'email': if (value != null && value != '') { return } else { - return n/a + return ( + + n/a + + ) } case 'url': if (value != null && value != '') { return } else { - return n/a + return ( + + n/a + + ) } case 'object': { if (value && value.name) { return {value.name} } else { - return n/a + return ( + + n/a + + ) } } case 'state': { @@ -173,59 +265,87 @@ const ObjectProperty = ({ case 'filamentStock': return default: - return No Object Type Specified + return ( + + No Object Type Specified + + ) } } else { - return n/a + return ( + + n/a + + ) } } case 'material': { if (value) { - return {value} + return {value} } else { - return n/a + return ( + + n/a + + ) } } case 'id': { if (value) { return } else { - return n/a + return ( + + n/a + + ) } } case 'density': { if (value != null) { - return {`${value} g/cm³`} + return {`${value} g/cm³`} } else { - return n/a + return ( + + n/a + + ) } } case 'mm': { if (value != null) { - return {`${value} mm`} + return {`${value} mm`} } else { - return n/a + return ( + + n/a + + ) } } case 'tags': { if (value != null || value?.length != 0) { return } else { - return n/a + return ( + + n/a + + ) } } - case 'version': { - if (value != null) { - return {`${value} mm`} - } else { - return n/a - } + case 'propertyChanges': { + return } default: { if (value) { - return {value} + return {value} } else { - return n/a + return ( + + n/a + + ) } } } @@ -266,6 +386,7 @@ const ObjectProperty = ({ visible ? : @@ -278,6 +399,7 @@ const ObjectProperty = ({ + + ) case 'bool': return ( - + ) case 'dateTime': @@ -302,24 +437,17 @@ const ObjectProperty = ({ {...mergedFormItemProps} getValueProps={(v) => ({ value: v ? dayjs(v) : null })} > - - - ) - case 'currency': - return ( - - ) case 'country': return ( - + ) case 'color': @@ -330,7 +458,7 @@ const ObjectProperty = ({ valuePropName='value' getValueFromEvent={(v) => v} > - + ) case 'weight': @@ -340,6 +468,7 @@ const ObjectProperty = ({ suffix='g' style={{ width: '100%' }} placeholder={label} + disabled={disabled} /> ) @@ -347,22 +476,36 @@ const ObjectProperty = ({ return ( ) case 'text': return ( - + ) case 'material': return ( - ) case 'id': @@ -370,7 +513,11 @@ const ObjectProperty = ({ if (value) { return } else { - return n/a + return ( + + n/a + + ) } case 'object': switch (objectType) { @@ -405,7 +552,11 @@ const ObjectProperty = ({ ) default: - return n/a + return ( + + n/a + + ) } 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 diff --git a/src/components/Dashboard/common/ObjectTable.jsx b/src/components/Dashboard/common/ObjectTable.jsx index 4f0ffbd..42be76c 100644 --- a/src/components/Dashboard/common/ObjectTable.jsx +++ b/src/components/Dashboard/common/ObjectTable.jsx @@ -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( ) } } - // 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 diff --git a/src/components/Dashboard/common/PropertyChanges.jsx b/src/components/Dashboard/common/PropertyChanges.jsx new file mode 100644 index 0000000..3051dc1 --- /dev/null +++ b/src/components/Dashboard/common/PropertyChanges.jsx @@ -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 n/a + } + console.log('combined', { ...value?.old, ...value?.new }) + return ( + + {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 ( + + + + + + + + + + ) + })} + + ) +} + +PropertyChanges.propTypes = { + type: PropTypes.string.isRequired, + value: PropTypes.shape({ + old: PropTypes.object, + new: PropTypes.object + }) +} + +export default PropertyChanges diff --git a/src/components/Dashboard/common/UrlDisplay.jsx b/src/components/Dashboard/common/UrlDisplay.jsx index 926f1f7..a1bbe0c 100644 --- a/src/components/Dashboard/common/UrlDisplay.jsx +++ b/src/components/Dashboard/common/UrlDisplay.jsx @@ -23,12 +23,14 @@ const UrlDisplay = ({ url, showCopy = true, showLink = false }) => { ) : ( <> - {url} + + {url} + @@ -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 diff --git a/src/components/Dashboard/context/ApiServerContext.js b/src/components/Dashboard/context/ApiServerContext.js index 44499bb..ca511c2 100644 --- a/src/components/Dashboard/context/ApiServerContext.js +++ b/src/components/Dashboard/context/ApiServerContext.js @@ -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={[ + , diff --git a/src/components/Icons/DownloadIcon.jsx b/src/components/Icons/DownloadIcon.jsx new file mode 100644 index 0000000..81c8ca7 --- /dev/null +++ b/src/components/Icons/DownloadIcon.jsx @@ -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) => + +export default DownloadIcon diff --git a/src/database/ObjectModels.js b/src/database/ObjectModels.js index 0fb8e26..b986b24 100644 --- a/src/database/ObjectModels.js +++ b/src/database/ObjectModels.js @@ -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 +} diff --git a/src/database/models/AuditLog.js b/src/database/models/AuditLog.js index ac868dc..edab88b 100644 --- a/src/database/models/AuditLog.js +++ b/src/database/models/AuditLog.js @@ -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: () => `#` + ] } diff --git a/src/database/models/Filament.js b/src/database/models/Filament.js index 1dae231..310bb08 100644 --- a/src/database/models/Filament.js +++ b/src/database/models/Filament.js @@ -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: [ diff --git a/src/database/models/GCodeFile.js b/src/database/models/GCodeFile.js index 96b526b..587d5ff 100644 --- a/src/database/models/GCodeFile.js +++ b/src/database/models/GCodeFile.js @@ -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` } ], diff --git a/src/database/models/Job.js b/src/database/models/Job.js index 002577f..bac89b3 100644 --- a/src/database/models/Job.js +++ b/src/database/models/Job.js @@ -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', diff --git a/src/database/models/NoteType.js b/src/database/models/NoteType.js index dc1cc05..caf4f4e 100644 --- a/src/database/models/NoteType.js +++ b/src/database/models/NoteType.js @@ -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'], diff --git a/src/database/models/Part.js b/src/database/models/Part.js index 1652474..f59a242 100644 --- a/src/database/models/Part.js +++ b/src/database/models/Part.js @@ -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 } ] } diff --git a/src/database/models/Printer.js b/src/database/models/Printer.js index d384225..2fd69bb 100644 --- a/src/database/models/Printer.js +++ b/src/database/models/Printer.js @@ -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', diff --git a/src/database/models/Product.js b/src/database/models/Product.js index ac0c75a..3f28ed8 100644 --- a/src/database/models/Product.js +++ b/src/database/models/Product.js @@ -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 } ] } diff --git a/src/database/models/User.js b/src/database/models/User.js index d72f529..0a451c5 100644 --- a/src/database/models/User.js +++ b/src/database/models/User.js @@ -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'], diff --git a/src/database/models/Vendor.js b/src/database/models/Vendor.js index 9ce6131..b38c2f0 100644 --- a/src/database/models/Vendor.js +++ b/src/database/models/Vendor.js @@ -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: [