Added dayjs dependency for date manipulation and refactored components to replace IdText with IdDisplay for consistent ID representation. Updated logging to use loglevel for improved debugging and removed console logs for cleaner code.

This commit is contained in:
Tom Butcher 2025-06-29 22:38:11 +01:00
parent 0634919bb6
commit 9ccf7faa2f
87 changed files with 3109 additions and 4425 deletions

1
package-lock.json generated
View File

@ -18,6 +18,7 @@
"antd-style": "^3.7.1",
"axios": "^1.9.0",
"country-list": "^2.3.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^10.1.5",

View File

@ -13,6 +13,7 @@
"antd-style": "^3.7.1",
"axios": "^1.9.0",
"country-list": "^2.3.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^10.1.5",

View File

@ -110,7 +110,7 @@ code {
margin-bottom: 0.15em;
}
.idtext .ant-popover-inner {
.iddisplay .ant-popover-inner {
padding: 0 !important;
}

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="M43.116 54.548c25.906 0 43.105-20.844 43.105-27.249 0-6.406-17.22-27.228-43.105-27.228C17.604.071 0 20.893 0 27.299c0 6.405 17.583 27.249 43.116 27.249m0-6.806c-19.53 0-34.805-16.126-34.805-20.443 0-3.599 15.275-20.422 34.805-20.422 19.467 0 34.794 16.823 34.794 20.422 0 4.317-15.327 20.443-34.794 20.443m.01-4.136c9.062 0 16.359-7.357 16.359-16.359 0-9.042-7.297-16.339-16.359-16.339-9.051 0-16.38 7.287-16.38 16.339 0 9.002 7.329 16.359 16.38 16.359m-.02-11.141c-2.851 0-5.146-2.336-5.146-5.187s2.295-5.166 5.146-5.166a5.167 5.167 0 0 1 5.187 5.166 5.185 5.185 0 0 1-5.187 5.187" style="fill-rule:nonzero" transform="translate(1 12.362)scale(.71908)"/></svg>

After

Width:  |  Height:  |  Size: 837 B

View File

@ -0,0 +1,7 @@
<?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.719082,0,0,0.719082,1,12.3624)">
<path d="M43.116,54.548C69.022,54.548 86.221,33.704 86.221,27.299C86.221,20.893 69.001,0.071 43.116,0.071C17.604,0.071 0,20.893 0,27.299C0,33.704 17.583,54.548 43.116,54.548ZM43.116,47.742C23.586,47.742 8.311,31.616 8.311,27.299C8.311,23.7 23.586,6.877 43.116,6.877C62.583,6.877 77.91,23.7 77.91,27.299C77.91,31.616 62.583,47.742 43.116,47.742ZM43.126,43.606C52.188,43.606 59.485,36.249 59.485,27.247C59.485,18.205 52.188,10.908 43.126,10.908C34.075,10.908 26.746,18.195 26.746,27.247C26.746,36.249 34.075,43.606 43.126,43.606ZM43.106,32.465C40.255,32.465 37.96,30.129 37.96,27.278C37.96,24.427 40.255,22.112 43.106,22.112C45.987,22.112 48.293,24.427 48.293,27.278C48.293,30.129 45.987,32.465 43.106,32.465Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

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="M20.606 21.108C13.038 26.119 8.311 32.214 8.311 34.217c0 4.317 15.275 20.443 34.805 20.443 3.398 0 6.669-.492 9.743-1.348l5.6 5.591a46.8 46.8 0 0 1-15.343 2.562C17.583 61.465 0 40.622 0 34.217 0 30.576 5.686 22.279 15.29 15.8zm65.615 13.109c0 3.575-5.359 11.649-14.609 18.082l-5.253-5.248c7.132-4.794 11.551-10.516 11.551-12.834 0-3.6-15.327-20.423-34.794-20.423-3.083 0-6.06.42-8.87 1.175L28.592 9.32a46.3 46.3 0 0 1 14.524-2.332c25.885 0 43.105 20.823 43.105 27.229M48.97 49.429a15.9 15.9 0 0 1-5.844 1.094c-9.051 0-16.38-7.357-16.38-16.359 0-2.061.38-4.031 1.091-5.836zm10.515-15.265a15.9 15.9 0 0 1-.85 5.171L37.949 18.669a16.1 16.1 0 0 1 5.177-.843c9.062 0 16.359 7.297 16.359 16.338m8.524 28.02c1.019 1.018 2.597 1.061 3.605 0 1.052-1.061 1.019-2.587 0-3.605L18.182 5.177c-.997-.997-2.618-.997-3.636 0-.966.966-.966 2.649 0 3.605z" style="fill-rule:nonzero" transform="translate(1 7.77)scale(.71908)"/></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.719082,0,0,0.719082,1,7.76939)">
<path d="M20.606,21.108C13.038,26.119 8.311,32.214 8.311,34.217C8.311,38.534 23.586,54.66 43.116,54.66C46.514,54.66 49.785,54.168 52.859,53.312L58.459,58.903C53.82,60.508 48.668,61.465 43.116,61.465C17.583,61.465 0,40.622 0,34.217C0,30.576 5.686,22.279 15.29,15.8L20.606,21.108ZM86.221,34.217C86.221,37.792 80.862,45.866 71.612,52.299L66.359,47.051C73.491,42.257 77.91,36.535 77.91,34.217C77.91,30.617 62.583,13.794 43.116,13.794C40.033,13.794 37.056,14.214 34.246,14.969L28.592,9.32C33.034,7.855 37.91,6.988 43.116,6.988C69.001,6.988 86.221,27.811 86.221,34.217ZM48.97,49.429C47.163,50.141 45.19,50.523 43.126,50.523C34.075,50.523 26.746,43.166 26.746,34.164C26.746,32.103 27.126,30.133 27.837,28.328L48.97,49.429ZM59.485,34.164C59.485,35.972 59.191,37.713 58.635,39.335L37.949,18.669C39.572,18.118 41.315,17.826 43.126,17.826C52.188,17.826 59.485,25.123 59.485,34.164Z" style="fill-rule:nonzero;"/>
<path d="M68.009,62.184C69.028,63.202 70.606,63.245 71.614,62.184C72.666,61.123 72.633,59.597 71.614,58.579L18.182,5.177C17.185,4.18 15.564,4.18 14.546,5.177C13.58,6.143 13.58,7.826 14.546,8.782L68.009,62.184Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

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="m37.543 20.715-5.797 5.797c3.078.359 5.672 1.515 7.344 3.187 5.047 5.032 5.047 12.063.062 17.047L28.465 57.402c-5.016 5-12 5-17.016-.015-5.047-5.047-5.047-12.031-.047-17.047l4.016-4c-1.219-2.766-1.734-6.359-.813-9.438l-8.609 8.532c-8.016 7.953-7.984 19.328.031 27.343 8.047 8.063 19.375 8.032 27.344.063l11.188-11.188c7.984-7.984 8-19.328-.047-27.343-1.578-1.594-4-2.969-6.969-3.594m-6.25 27.375 5.797-5.797c-3.078-.344-5.672-1.5-7.344-3.172-5.031-5.047-5.047-12.062-.047-17.047l10.672-10.656c5.016-5.016 12-5.016 17.031.016 5.032 5.031 5.016 12.047.032 17.031l-4 4c1.203 2.797 1.703 6.359.812 9.453l8.61-8.547c8-7.953 7.984-19.312-.032-27.344-8.062-8.047-19.39-8.015-27.375-.031L24.293 17.152c-7.984 7.985-8 19.329.031 27.344 1.594 1.594 4 2.969 6.969 3.594" style="fill-rule:nonzero" transform="translate(2.99 3)scale(.84278)"/></svg>

After

Width:  |  Height:  |  Size: 1012 B

View File

@ -0,0 +1,7 @@
<?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.842775,0,0,0.842775,2.98927,3)">
<path d="M37.543,20.715L31.746,26.512C34.824,26.871 37.418,28.027 39.09,29.699C44.137,34.731 44.137,41.762 39.152,46.746L28.465,57.402C23.449,62.402 16.465,62.402 11.449,57.387C6.402,52.34 6.402,45.356 11.402,40.34L15.418,36.34C14.199,33.574 13.684,29.981 14.605,26.902L5.996,35.434C-2.02,43.387 -1.988,54.762 6.027,62.777C14.074,70.84 25.402,70.809 33.371,62.84L44.559,51.652C52.543,43.668 52.559,32.324 44.512,24.309C42.934,22.715 40.512,21.34 37.543,20.715ZM31.293,48.09L37.09,42.293C34.012,41.949 31.418,40.793 29.746,39.121C24.715,34.074 24.699,27.059 29.699,22.074L40.371,11.418C45.387,6.402 52.371,6.402 57.402,11.434C62.434,16.465 62.418,23.481 57.434,28.465L53.434,32.465C54.637,35.262 55.137,38.824 54.246,41.918L62.856,33.371C70.856,25.418 70.84,14.059 62.824,6.027C54.762,-2.02 43.434,-1.988 35.449,5.996L24.293,17.152C16.309,25.137 16.293,36.481 24.324,44.496C25.918,46.09 28.324,47.465 31.293,48.09Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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="M70.859 15.422q-.002.898.111 1.766H23.795L48.25 39.25c.813.719 1.625 1.094 2.484 1.094.875 0 1.688-.375 2.5-1.094l19.128-17.256c.934 2 2.3 3.761 3.972 5.168l-10.491 9.447 16.688 16.688V30.348c1.192.319 2.446.48 3.735.48 1.282 0 2.53-.159 3.718-.477v24.946c0 7.078-3.906 10.969-10.515 10.969H22.547c-7.109 0-11.031-3.891-11.031-10.969V20.703c0-7.078 3.906-10.969 10.515-10.969h49.936a15 15 0 0 0-1.108 5.688M57.234 44.359c-2.125 1.922-4.109 2.735-6.5 2.735-2.375 0-4.375-.813-6.484-2.735l-3.741-3.366-17.803 17.815q.067.006.138.005h55.781q.086 0 .166-.006L60.975 40.991zm-38.265 8.938 16.679-16.679-16.679-15.009z" style="fill-rule:nonzero" transform="translate(-7.354 6.26)scale(.72541)"/><path d="M86.266 26.125c5.843 0 10.718-4.859 10.718-10.703 0-5.859-4.875-10.719-10.718-10.719-5.844 0-10.703 4.86-10.703 10.719 0 5.844 4.859 10.703 10.703 10.703" style="fill-rule:nonzero" transform="translate(-7.354 6.26)scale(.72541)"/></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.725411,0,0,0.725411,-7.35354,6.25926)">
<path d="M70.859,15.422C70.859,16.02 70.894,16.61 70.97,17.188L23.795,17.188L48.25,39.25C49.063,39.969 49.875,40.344 50.734,40.344C51.609,40.344 52.422,39.969 53.234,39.25L72.362,21.994C73.296,23.994 74.662,25.755 76.334,27.162L65.843,36.609L82.531,53.297L82.531,30.348C83.723,30.667 84.977,30.828 86.266,30.828C87.548,30.828 88.796,30.669 89.984,30.351L89.984,55.297C89.984,62.375 86.078,66.266 79.469,66.266L22.547,66.266C15.438,66.266 11.516,62.375 11.516,55.297L11.516,20.703C11.516,13.625 15.422,9.734 22.031,9.734L71.967,9.734C71.249,11.493 70.859,13.415 70.859,15.422ZM57.234,44.359C55.109,46.281 53.125,47.094 50.734,47.094C48.359,47.094 46.359,46.281 44.25,44.359L40.509,40.993L22.706,58.808C22.75,58.812 22.797,58.813 22.844,58.813L78.625,58.813C78.682,58.813 78.738,58.812 78.791,58.807L60.975,40.991L57.234,44.359ZM18.969,53.297L35.648,36.618L18.969,21.609L18.969,53.297Z" style="fill-rule:nonzero;"/>
<path d="M86.266,26.125C92.109,26.125 96.984,21.266 96.984,15.422C96.984,9.563 92.109,4.703 86.266,4.703C80.422,4.703 75.563,9.563 75.563,15.422C75.563,21.266 80.422,26.125 86.266,26.125Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -29,9 +29,7 @@ const AppParticles = () => {
})
}, [])
const particlesLoaded = useCallback(() => {
console.log('Particles Loaded!')
}, [])
const particlesLoaded = useCallback(() => {}, [])
const options = useMemo(
() => ({

View File

@ -19,7 +19,7 @@ import { AuthContext } from '../context/AuthContext'
import { PrintServerContext } from '../context/PrintServerContext'
import NewFilamentStock from './FilamentStocks/NewFilamentStock'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
@ -124,7 +124,7 @@ const FilamentStocks = () => {
key: 'id',
width: 180,
render: (text) => (
<IdText id={text} type={'filamentstock'} longId={false} />
<IdDisplay id={text} type={'filamentstock'} longId={false} />
)
},
{
@ -216,7 +216,6 @@ const FilamentStocks = () => {
if (printServer && !initialized) {
setInitialized(true)
printServer.on('notify_filamentstock_update', (updateData) => {
console.log('Received filament stock update:', updateData)
if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData)
}
@ -225,7 +224,6 @@ const FilamentStocks = () => {
return () => {
if (printServer && initialized) {
console.log('Deregistering filament stock update listener')
printServer.off('notify_filamentstock_update')
}
}

View File

@ -18,7 +18,7 @@ import {
Checkbox
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText'
import IdDisplay from '../../common/IdDisplay'
import { PrintServerContext } from '../../context/PrintServerContext'
import FilamentStockState from '../../common/FilamentStockState'
import StockEventTable from '../../common/StockEventTable'
@ -78,7 +78,6 @@ const FilamentStockInfo = () => {
if (printServer && !initialized && filamentStockId) {
setInitialized(true)
printServer.on('notify_filamentstock_update', (statusUpdate) => {
console.log('GOT FILAMENT STOCK UPDATE', statusUpdate)
setFilamentStockData((prevData) => {
if (statusUpdate?._id === filamentStockId) {
return {
@ -92,7 +91,6 @@ const FilamentStockInfo = () => {
}
return () => {
if (printServer && initialized) {
console.log('Deregistering filament stock update listener')
printServer.off('notify_filamentstock_update')
}
}
@ -260,7 +258,7 @@ const FilamentStockInfo = () => {
{/* Read-only fields */}
<Descriptions.Item label='ID' span={1}>
{filamentStockData?.id ? (
<IdText
<IdDisplay
id={filamentStockData.id}
type={'filamentstock'}
/>
@ -316,7 +314,7 @@ const FilamentStockInfo = () => {
<Descriptions.Item label='Filament ID' span={1}>
{filamentStockData?.filament ? (
<IdText
<IdDisplay
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}

View File

@ -90,7 +90,7 @@ const LoadFilamentStock = ({
)
)
}
console.log(statusUpdate)
logger.debug(statusUpdate)
}
printServer.emit('printer.objects.subscribe', params)

View File

@ -7,7 +7,7 @@ import { Button, Flex, Space, Modal, message, Dropdown, Typography } from 'antd'
import { AuthContext } from '../context/AuthContext'
import NewPartStock from './PartStocks/NewPartStock'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import PartStockIcon from '../../Icons/PartStockIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
@ -69,7 +69,9 @@ const PartStocks = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'partstock'} longId={false} />
render: (text) => (
<IdDisplay id={text} type={'partstock'} longId={false} />
)
},
{
title: 'State',

View File

@ -5,7 +5,7 @@ import { Button, Flex, Space, message, Dropdown, Typography } from 'antd'
import { AuthContext } from '../context/AuthContext'
import { PrintServerContext } from '../context/PrintServerContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import StockAuditIcon from '../../Icons/StockAuditIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
@ -30,7 +30,6 @@ const StockAudits = () => {
if (printServer && !initialized) {
setInitialized(true)
printServer.on('notify_stockaudit_update', (updateData) => {
console.log('Received stock audit update:', updateData)
if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData)
}
@ -39,7 +38,6 @@ const StockAudits = () => {
return () => {
if (printServer && initialized) {
console.log('Deregistering stock audit update listener')
printServer.off('notify_stockaudit_update')
}
}
@ -76,7 +74,9 @@ const StockAudits = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'stockaudit'} longId={false} />
render: (text) => (
<IdDisplay id={text} type={'stockaudit'} longId={false} />
)
},
{
title: 'Status',

View File

@ -18,7 +18,7 @@ import {
} from '@ant-design/icons'
import { AuthContext } from '../../context/AuthContext'
import IdText from '../../common/IdText'
import IdDisplay from '../../common/IdDisplay'
import TimeDisplay from '../../common/TimeDisplay'
import config from '../../../../config'
@ -105,7 +105,7 @@ const StockAuditInfo = () => {
key: 'id',
width: 180,
render: (text) => (
<IdText id={text} type={'stockaudititem'} longId={false} />
<IdDisplay id={text} type={'stockaudititem'} longId={false} />
)
},
{
@ -180,7 +180,11 @@ const StockAuditInfo = () => {
<Title level={4}>Stock Audit Details</Title>
<Descriptions bordered>
<Descriptions.Item label='ID'>
<IdText id={stockAudit._id} type={'stockaudit'} longId={true} />
<IdDisplay
id={stockAudit._id}
type={'stockaudit'}
longId={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Status'>
{getStatusTag(stockAudit.status)}

View File

@ -12,7 +12,7 @@ import {
import { AuthContext } from '../context/AuthContext'
import { PrintServerContext } from '../context/PrintServerContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import TimeDisplay from '../common/TimeDisplay'
import ReloadIcon from '../../Icons/ReloadIcon'
import PlusMinusIcon from '../../Icons/PlusMinusIcon'
@ -75,7 +75,7 @@ const StockEvents = () => {
dataIndex: '_id',
width: 170,
render: (id) => {
return <IdText id={id} longId={false} type={'stockevent'} />
return <IdDisplay id={id} longId={false} type={'stockevent'} />
}
},
{
@ -100,7 +100,7 @@ const StockEvents = () => {
render: (record) => {
if (record.filamentStock?._id) {
return (
<IdText
<IdDisplay
id={record.filamentStock._id}
longId={false}
showHyperlink={true}
@ -119,7 +119,7 @@ const StockEvents = () => {
const ids = (
<Flex gap={'small'} wrap>
{record.job ? (
<IdText
<IdDisplay
id={record.job}
longId={false}
showHyperlink={true}
@ -127,14 +127,14 @@ const StockEvents = () => {
/>
) : null}
{record.subJob?.number ? (
<IdText
<IdDisplay
id={record.subJob.number.toString().padStart(6, '0')}
longId={false}
type={'subjob'}
/>
) : null}
{record.stockAudit ? (
<IdText
<IdDisplay
id={record.stockAudit._id}
longId={false}
type={'stockaudit'}
@ -228,7 +228,6 @@ const StockEvents = () => {
if (printServer && !initialized) {
setInitialized(true)
printServer.on('notify_stockevent_update', (updateData) => {
console.log('Received stock event update:', updateData)
if (tableRef.current) {
tableRef.current.updateData(updateData._id, updateData)
}
@ -237,7 +236,6 @@ const StockEvents = () => {
return () => {
if (printServer && initialized) {
console.log('Deregistering stock event update listener')
printServer.off('notify_stockevent_update')
}
}

View File

@ -13,7 +13,7 @@ import {
} from 'antd'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import ReloadIcon from '../../Icons/ReloadIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
import TimeDisplay from '../common/TimeDisplay'
@ -66,7 +66,7 @@ const formatValue = (value, propertyName) => {
if (isObjectId(value)) {
return (
<IdText
<IdDisplay
id={value}
type={propertyName.toLowerCase().replaceAll('current', '')}
longId={false}
@ -101,7 +101,9 @@ const AuditLogs = () => {
key: 'id',
fixed: 'left',
width: 180,
render: (text) => <IdText id={text} type={'auditlog'} longId={false} />,
render: (text) => (
<IdDisplay id={text} type={'auditlog'} longId={false} />
),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
@ -147,7 +149,7 @@ const AuditLogs = () => {
key: 'owner',
width: 180,
render: (record) => (
<IdText
<IdDisplay
id={record.owner._id}
type={record.ownerModel.toLowerCase()}
longId={false}
@ -160,7 +162,7 @@ const AuditLogs = () => {
key: 'target',
width: 180,
render: (record) => (
<IdText
<IdDisplay
id={record.target}
type={record.targetModel.toLowerCase()}
longId={false}

View File

@ -23,7 +23,7 @@ import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import NewFilament from './Filaments/NewFilament'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import FilamentIcon from '../../Icons/FilamentIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
@ -283,7 +283,9 @@ const Filaments = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'filament'} longId={false} />,
render: (text) => (
<IdDisplay id={text} type={'filament'} longId={false} />
),
filterDropdown: ({
setSelectedKeys,
selectedKeys,

View File

@ -1,62 +1,30 @@
import React, { useState, useEffect, useContext, useCallback } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import {
Descriptions,
Spin,
Space,
Button,
message,
Badge,
Typography,
Flex,
Form,
Input,
InputNumber,
ColorPicker,
Select,
Dropdown,
Popover,
Checkbox,
Card,
Tag
} from 'antd'
import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel'
import config from '../../../../config'
import IdText from '../../common/IdText'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import TimeDisplay from '../../common/TimeDisplay.jsx'
import VendorSelect from '../../common/VendorSelect'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import LockIcon from '../../../Icons/LockIcon.jsx'
import { ApiServerContext } from '../../context/ApiServerContext'
import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons'
import LockIndicator from './LockIndicator'
const log = loglevel.getLogger('FilamentInfo')
log.setLevel(config.logLevel)
const { Link, Text } = Typography
const FilamentInfo = () => {
const [filamentData, setFilamentData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [editLoading, setEditLoading] = useState(false)
const [lockUser, setLockUser] = useState(null)
const [initialized, setInitialized] = useState(false)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const filamentId = new URLSearchParams(location.search).get('filamentId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [collapseState, updateCollapseState] = useCollapseState(
'FilamentInfo',
{
@ -66,184 +34,31 @@ const FilamentInfo = () => {
auditLogs: true
}
)
const {
apiServer,
fetchObjectInfo,
updateObjectInfo,
lockObject,
unlockObject,
onLockEvent,
onUpdateEvent,
fetchObjectLock,
showError
} = useContext(ApiServerContext)
// Define the event handler function
const lockEventHandler = useCallback((lockEvent) => {
if (lockEvent.locked === true) {
setLockUser(lockEvent.user)
} else {
setLockUser(null)
}
}, [])
// Cleanup effect for component unmount
useEffect(() => {
return () => {
if (filamentId) {
// Ensure any remaining locks are released when component unmounts
unlockObject(filamentId, 'filament')
}
}
}, [filamentId, unlockObject])
useEffect(() => {
if (filamentData) {
form.setFieldsValue({
name: filamentData.name || '',
brand: filamentData.brand || '',
type: filamentData.type || '',
cost: filamentData.cost || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
url: filamentData.url || '',
barcode: filamentData.barcode || '',
emptySpoolWeight: filamentData.emptySpoolWeight || ''
})
}
}, [filamentData, form])
const fetchFilamentInfo = useCallback(async () => {
try {
setFetchLoading(true)
const data = await fetchObjectInfo(filamentId, 'filament')
const lockEvent = await fetchObjectLock(filamentId, 'filament')
setLockUser(lockEvent?.user || null)
setFilamentData(data)
form.setFieldsValue(data)
setFetchLoading(false)
} catch (err) {
messageApi.error('Failed to fetch filament info')
// Show error modal with retry functionality
showError(
`Failed to fetch filament information. Message: ${err.message}. Code: ${err.code}`,
fetchFilamentInfo
)
}
}, [
fetchObjectInfo,
fetchObjectLock,
filamentId,
form,
messageApi,
showError
])
const updateFilamentInfo = async () => {
const values = form.getFieldsValue()
const updateValue = {
name: values.name,
vendor: values.vendor,
type: values.type,
cost: values.cost,
color: values.color,
diameter: values.diameter,
density: values.density,
url: values.url,
barcode: values.barcode,
emptySpoolWeight: values.emptySpoolWeight
}
await updateObjectInfo(filamentId, 'filament', updateValue)
}
// Define the update event handler function
const updateEventHandler = useCallback(
(updateEvent) => {
log.debug('Update event received for filament:', updateEvent)
// Refresh the filament data when an update is received
fetchFilamentInfo()
},
[fetchFilamentInfo]
)
useEffect(() => {
if (initialized == false && filamentId && apiServer?.connected === true) {
setInitialized(true)
fetchFilamentInfo()
}
}, [filamentId, apiServer?.connected, initialized, fetchFilamentInfo])
useEffect(() => {
if (filamentId) {
const cleanup = onLockEvent(filamentId, lockEventHandler)
return cleanup
}
}, [filamentId, onLockEvent, lockEventHandler])
useEffect(() => {
if (filamentId) {
const cleanup = onUpdateEvent(filamentId, updateEventHandler)
return cleanup
}
}, [filamentId, onUpdateEvent, updateEventHandler])
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
lockObject(filamentId, 'filament')
}
const cancelEditing = () => {
// Reset form values to original data
if (filamentData) {
form.setFieldsValue({
name: filamentData.name || '',
brand: filamentData.brand || '',
type: filamentData.type || '',
cost: filamentData.cost || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
url: filamentData.url || '',
barcode: filamentData.barcode || '',
emptySpoolWeight: filamentData.emptySpoolWeight || ''
})
}
setIsEditing(false)
unlockObject(filamentId, 'filament')
}
const handleUpdateFilamentInfo = async () => {
try {
const values = await form.validateFields()
setEditLoading(true)
await updateFilamentInfo()
// Update the local state with the new values
setFilamentData({ ...filamentData, ...values })
setIsEditing(false)
messageApi.success('Filament information updated successfully')
} catch (err) {
if (err.errorFields) {
// This is a form validation error
return
}
console.error('Failed to update filament information:', err)
messageApi.error('Failed to update filament information')
// Show error modal with retry functionality
showError(
`Failed to update filament information. Message: ${err.message}. Code: ${err.code}`,
() => handleUpdateFilamentInfo()
)
} finally {
fetchFilamentInfo()
setEditLoading(false)
}
}
const actionItems = {
return (
<EditObjectForm id={filamentId} type='filament' style={{ height: '100%' }}>
{({
loading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
formValid,
objectData,
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',
@ -253,380 +68,149 @@ const FilamentInfo = () => {
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchFilamentInfo()
fetchObject()
}
}
}
const getViewDropdownItems = () => {
const sections = [
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
sections={[
{ key: 'info', label: 'Filament Information' },
{ 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>
)
}
return (
<>
{contextHolder}
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button disabled={fetchLoading}>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button disabled={fetchLoading}>View</Button>
</Popover>
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
{lockUser && (
<Flex gap={'small'} align='center'>
<Tag
icon={<LockIcon />}
style={{ margin: 0 }}
color={'orange'}
/>
<IdText
id={lockUser}
type={'user'}
longId={false}
showCopy={false}
/>
</Flex>
)}
<LockIndicator lock={lock} />
</Space>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={handleUpdateFilamentInfo}
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
disabled={editLoading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={editLoading}
/>
</>
) : (
<Button
icon={<EditIcon />}
onClick={startEditing}
disabled={lockUser !== null || fetchLoading}
/>
)}
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: '100%', overflowY: 'scroll' }}>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Filament Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
className='no-t-padding-collapse'
key='info'
>
<Form
form={form}
layout='vertical'
initialValues={{
name: filamentData?.name || '',
vendor: filamentData?.vendor || { id: null, name: '' },
type: filamentData?.type || '',
cost: filamentData?.cost || null,
color: filamentData?.color || '#000000',
diameter: filamentData?.diameter || null,
density: filamentData?.density || null,
url: filamentData?.url || '',
barcode: filamentData?.barcode || ''
}}
>
<Spin indicator={<LoadingOutlined />} spinning={fetchLoading}>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{filamentData?._id ? (
<IdText id={filamentData._id} type={'filament'} />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{filamentData?.createdAt ? (
<TimeDisplay
dateTime={filamentData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
items={[
{
required: true,
message: 'Please enter a filament name'
name: 'id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'filament',
showCopy: true
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
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: 'vendorId',
label: 'Vendor ID',
value: objectData?.vendor?.id,
type: 'id',
objectType: 'vendor',
showCopy: true,
showHyperlink: true
},
{
name: 'type',
label: 'Material',
value: objectData?.type,
required: true,
type: 'material'
},
{
name: 'cost',
label: 'Cost',
value: objectData?.cost,
required: true,
type: 'currency'
},
{
name: 'color',
label: 'Color',
value: objectData?.color,
required: true,
type: 'color'
},
{
name: 'diameter',
label: 'Diameter',
value: objectData?.diameter,
required: true,
type: 'mm'
},
{
name: 'density',
label: 'Density',
value: objectData?.density,
required: true,
type: 'density'
},
{
name: 'url',
label: 'URL',
value: objectData?.url,
type: 'text'
},
{
name: 'barcode',
label: 'Barcode',
value: objectData?.barcode,
type: 'text'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter filament name' />
</Form.Item>
) : filamentData?.name ? (
<Text>{filamentData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{filamentData?.updatedAt ? (
<TimeDisplay
dateTime={filamentData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{
required: true,
message: 'Please enter a vendor'
}
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : filamentData?.vendor?.name ? (
<Text>{filamentData.vendor.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{filamentData?.vendor?.id ? (
<IdText
id={filamentData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Material'>
{isEditing ? (
<Form.Item
name='type'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please select a material'
}
]}
>
<Select>
<Select.Option value='PLA'>PLA</Select.Option>
<Select.Option value='PETG'>PETG</Select.Option>
<Select.Option value='ABS'>ABS</Select.Option>
<Select.Option value='ASA'>ASA</Select.Option>
<Select.Option value='HIPS'>HIPS</Select.Option>
<Select.Option value='TPU'>TPU</Select.Option>
</Select>
</Form.Item>
) : filamentData?.type ? (
<Text>{filamentData.type}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{isEditing ? (
<Form.Item
name='cost'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please enter a cost'
}
]}
>
<InputNumber
prefix='£'
suffix='/kg'
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData?.cost ? (
<Text>{`£${filamentData.cost}/kg`}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Color'>
<Flex align={'center'} gap={'small'}>
{isEditing ? (
<Form.Item
name='color'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please select a color'
}
]}
getValueFromEvent={(color) => {
return '#' + color.toHex()
}}
>
<ColorPicker showText disabledAlpha />
</Form.Item>
) : filamentData?.color ? (
<Badge
color={filamentData.color}
text={filamentData.color}
/>
) : (
<Text>n/a</Text>
)}
</Flex>
</Descriptions.Item>
<Descriptions.Item label='Diameter'>
{isEditing ? (
<Form.Item
name='diameter'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please enter a diameter'
}
]}
>
<InputNumber suffix='mm' style={{ width: '100%' }} />
</Form.Item>
) : filamentData?.diameter ? (
<Text>{`${filamentData.diameter}mm`}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Density'>
{isEditing ? (
<Form.Item
name='density'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please enter a density'
}
]}
>
<InputNumber
suffix='g/cm³'
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData?.density ? (
<Text>{`${filamentData.density}g/cm³`}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='URL'>
{isEditing ? (
<Form.Item name='url' style={{ margin: 0 }}>
<Input placeholder='Enter URL' />
</Form.Item>
) : filamentData?.url ? (
<Link href={filamentData.url} target='_blank'>
{filamentData.url}
</Link>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Barcode'>
{isEditing ? (
<Form.Item name='barcode' style={{ margin: 0 }}>
<Input placeholder='Enter barcode' />
</Form.Item>
) : filamentData?.barcode ? (
<Text>{filamentData.barcode}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</InfoCollapse>
<InfoCollapse
@ -651,15 +235,16 @@ const FilamentInfo = () => {
key='auditLogs'
>
<AuditLogTable
items={filamentData?.auditLogs || []}
loading={fetchLoading}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</InfoCollapse>
</Flex>
</div>
</Flex>
</>
)}
</EditObjectForm>
)
}

View File

@ -0,0 +1,28 @@
import React from 'react'
import { Flex, Tag } from 'antd'
import IdDisplay from '../../common/IdDisplay'
import LockIcon from '../../../Icons/LockIcon'
import PropTypes from 'prop-types'
const LockIndicator = ({ lock }) => {
if (!lock?.locked || lock?.locked == false) {
return
}
return (
<Flex gap={'small'} align='center'>
<Tag icon={<LockIcon />} style={{ margin: 0 }} color={'orange'} />
<IdDisplay
id={lock?.user}
type={'user'}
longId={false}
showCopy={false}
/>
</Flex>
)
}
LockIndicator.propTypes = {
lock: PropTypes.object
}
export default LockIndicator

View File

@ -157,7 +157,6 @@ const NewFilament = ({ onOk, reset }) => {
}
const handleImageUpload = async ({ file, fileList }) => {
console.log(fileList)
if (fileList.length === 0) {
setImageList(fileList)
newFilamentForm.setFieldsValue({ image: '' })

View File

@ -19,7 +19,7 @@ import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import NewMaterial from './Materials/NewMaterial'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import MaterialIcon from '../../Icons/MaterialIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import PlusIcon from '../../Icons/PlusIcon'
@ -169,7 +169,7 @@ const Materials = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'material'} longId={false} />
render: (text) => <IdDisplay id={text} type={'material'} longId={false} />
},
{
title: 'Category',

View File

@ -14,7 +14,7 @@ import {
Typography
} from 'antd'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import NewNoteType from './NoteTypes/NewNoteType'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
@ -155,7 +155,9 @@ const NoteTypes = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'notetype'} longId={false} />,
render: (text) => (
<IdDisplay id={text} type={'notetype'} longId={false} />
),
filterDropdown: ({
setSelectedKeys,
selectedKeys,

View File

@ -1,51 +1,22 @@
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Flex,
Form,
Input,
Collapse,
Switch,
ColorPicker,
Checkbox,
Dropdown,
Popover,
Badge
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText'
import TimeDisplay from '../../common/TimeDisplay'
import { Space, Button, Flex, Dropdown } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
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 InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title, Text } = Typography
import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator'
const NoteTypeInfo = () => {
const [noteTypeData, setNoteTypeData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const noteTypeId = new URLSearchParams(location.search).get('noteTypeId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [colorEnabled, setColorEnabled] = useState(false)
const [collapseState, updateCollapseState] = useCollapseState(
'NoteTypeInfo',
{
@ -54,112 +25,34 @@ const NoteTypeInfo = () => {
}
)
useEffect(() => {
if (noteTypeId) {
fetchNoteTypeDetails()
}
}, [noteTypeId])
useEffect(() => {
if (noteTypeData) {
form.setFieldsValue({
name: noteTypeData.name || '',
color: noteTypeData.color || '#000000',
active: noteTypeData.active ?? true
})
setColorEnabled(!!noteTypeData.color)
}
}, [noteTypeData, form])
const fetchNoteTypeDetails = async () => {
try {
setLoading(true)
const response = await axios.get(
`${config.backendUrl}/noteTypes/${noteTypeId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setNoteTypeData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch note type details')
messageApi.error('Failed to fetch note type details')
} finally {
setLoading(false)
}
}
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
form.setFieldsValue({
name: noteTypeData?.name || '',
color: noteTypeData?.color || '#000000',
active: noteTypeData?.active ?? true
})
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(`${config.backendUrl}/noteTypes/${noteTypeId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
setNoteTypeData({ ...noteTypeData, ...values })
setIsEditing(false)
messageApi.success('Note type information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update note type information:', err)
messageApi.error('Failed to update note type information')
} finally {
fetchNoteTypeDetails()
setLoading(false)
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Note Type Information' },
{ 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)
}}
<EditObjectForm
id={noteTypeId}
type='notetype'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
)
}
const actionItems = {
{({
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 Note Type',
@ -169,238 +62,119 @@ const NoteTypeInfo = () => {
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchNoteTypeDetails()
fetchObject()
}
}
}
return (
<>
<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 size={'small'}>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={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 || 'Note type not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchNoteTypeDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<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}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'small'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Note Type Information
</Title>
</Flex>
}
key='1'
>
<Spin spinning={loading} indicator={<LoadingOutlined />}>
<Form form={form} layout='vertical'>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
<IdText id={noteTypeData?._id} type='notetype' />
</Descriptions.Item>
<Descriptions.Item label='Created At'>
<TimeDisplay
dateTime={noteTypeData?.createdAt}
showSince={true}
<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}
/>
</Descriptions.Item>
</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>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
<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'
items={[
{
required: true,
message: 'Please enter a note type name'
name: 'id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'notetype',
showCopy: true
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
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: 'color',
label: 'Color',
value: objectData?.color,
type: 'color'
},
{
name: 'active',
label: 'Active',
value: objectData?.active,
type: 'bool'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
noteTypeData?.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
<TimeDisplay
dateTime={noteTypeData?.updatedAt}
showSince={true}
/>
</Descriptions.Item>
</InfoCollapse>
<Descriptions.Item label='Color'>
{isEditing ? (
<Flex gap='middle' align='center'>
<Form.Item
name='color'
style={{ margin: 0 }}
getValueFromEvent={(color) => {
if (color != null) {
return '#' + color.toHex()
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
return null
}}
>
<ColorPicker
showText={true}
disabled={!colorEnabled}
/>
</Form.Item>
<Checkbox
checked={colorEnabled}
onChange={(e) => {
setColorEnabled(e.target.checked)
if (!e.target.checked) {
form.setFieldValue('color', null)
} else if (e.target.checked) {
form.setFieldValue('color', '#000000')
}
}}
/>
</Flex>
) : noteTypeData?.color ? (
<Badge
color={noteTypeData.color}
text={noteTypeData.color}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Active'>
{isEditing ? (
<Form.Item
name='active'
valuePropName='checked'
style={{ margin: 0 }}
>
<Switch />
</Form.Item>
) : noteTypeData ? (
<BoolDisplay
value={noteTypeData.active}
yesNo={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.auditLogs ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('auditLogs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'small'}>
<AuditLogIcon />
<Title level={5} style={{ margin: 0 }}>
Audit Logs
</Title>
</Flex>
}
key='2'
key='auditLogs'
>
<AuditLogTable
items={noteTypeData?.auditLogs || []}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
</Flex>
</div>
</Flex>
)}
</>
</EditObjectForm>
)
}

View File

@ -17,7 +17,7 @@ import {
import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct'
import PartIcon from '../../Icons/PartIcon'
@ -81,7 +81,7 @@ const Parts = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'part'} longId={false} />
render: (text) => <IdDisplay id={text} type={'part'} longId={false} />
},
{
title: 'Product Name',
@ -109,7 +109,7 @@ const Parts = () => {
key: 'productId',
width: 180,
render: (record) => (
<IdText
<IdDisplay
id={record?.product?._id}
type={'product'}
longId={false}

View File

@ -21,7 +21,7 @@ import {
Popover
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import IdDisplay from '../../common/IdDisplay.jsx'
import { StlViewer } from 'react-stl-viewer'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
@ -38,6 +38,9 @@ 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
@ -116,7 +119,7 @@ const PartInfo = () => {
setError(null)
} catch (err) {
setError('Failed to fetch part details')
console.log(err)
logger.debug(err)
messageApi.error('Failed to fetch part details')
} finally {
setFetchLoading(false)
@ -176,7 +179,7 @@ const PartInfo = () => {
}
} catch (err) {
setError('Failed to fetch part content')
console.log(err)
logger.debug(err)
messageApi.error('Failed to fetch part content')
} finally {
setFetchLoading(false)
@ -398,7 +401,7 @@ const PartInfo = () => {
>
<Descriptions.Item label='ID' span={1}>
{partData?.id ? (
<IdText id={partData.id} type='part'></IdText>
<IdDisplay id={partData.id} type='part'></IdDisplay>
) : (
<Text>n/a</Text>
)}
@ -459,7 +462,7 @@ const PartInfo = () => {
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{partData?.product?._id ? (
<IdText
<IdDisplay
id={partData.product._id}
type={'product'}
showHyperlink={true}

View File

@ -17,7 +17,7 @@ import {
import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import NewProduct from './Products/NewProduct'
@ -102,7 +102,7 @@ const Products = () => {
key: 'id',
fixed: 'left',
width: 180,
render: (text) => <IdText id={text} type={'product'} longId={false} />,
render: (text) => <IdDisplay id={text} type={'product'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,

View File

@ -1,56 +1,25 @@
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Flex,
Form,
Input,
Tag,
Checkbox,
InputNumber,
Collapse,
Dropdown,
Popover,
Card
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import VendorSelect from '../../common/VendorSelect.jsx'
import PartsTable from '../../common/PartsTable.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import PlusIcon from '../../../Icons/PlusIcon'
import { Space, Button, Flex, Dropdown, Card } from 'antd'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import TimeDisplay from '../../common/TimeDisplay.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import config from '../../../../config.js'
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 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 NoteIcon from '../../../Icons/NoteIcon.jsx'
const { Title, Text } = Typography
const ProductInfo = () => {
const [productData, setProductData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const productId = new URLSearchParams(location.search).get('productId')
const [isEditing, setIsEditing] = useState(false)
const [fetchLoading, setFetchLoading] = useState(true)
const [marginOrPrice, setMarginOrPrice] = useState(false)
const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
info: true,
parts: true,
@ -58,132 +27,34 @@ const ProductInfo = () => {
auditLogs: true
})
const [productForm] = Form.useForm()
const [productFormValues, setProductFormValues] = useState({})
const handleTagClose = (removedTag) => {
const newTags = productData.tags.filter((tag) => tag !== removedTag)
setProductData((prev) => ({ ...prev, tags: newTags }))
}
const handleTagAdd = () => {
const input = productForm.getFieldValue('newTag')
if (input) {
const newTag = input.trim()
if (newTag && !productData.tags.includes(newTag)) {
setProductData((prev) => ({ ...prev, tags: [...prev.tags, newTag] }))
productForm.setFieldValue('newTag', '')
}
}
}
useEffect(() => {
setMarginOrPrice(productFormValues.marginOrPrice)
}, [productFormValues])
useEffect(() => {
async function fetchData() {
console.log('hello')
await fetchProductDetails()
}
if (productId) {
fetchData()
}
}, [productId])
useEffect(() => {
if (productData) {
productForm.setFieldsValue({
name: productData.name || '',
vendor: productData.vendor || null,
version: productData.version || '',
tags: productData.tags || [],
price: productData.price || null,
margin: productData.margin || null,
marginOrPrice: productData.marginOrPrice || false
})
setProductFormValues(productData)
setMarginOrPrice(productData.marginOrPrice)
}
}, [productData, productForm])
const fetchProductDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`${config.backendUrl}/products/${productId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setProductData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch product details')
console.log(err)
messageApi.error('Failed to fetch product details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
// Reset form values to original data
if (productData) {
productForm.setFieldsValue({
name: productData.name || '',
vendor: productData.vendor || { id: null, name: '' },
version: productData.version || '',
tags: productData.tags || [],
cost: productData.cost || null,
price: productData.price || null,
margin: productData.margin || null,
marginOrPrice: productData.marginOrPrice || null
})
setMarginOrPrice(productData.marginOrPrice)
}
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await productForm.validateFields()
setLoading(true)
await axios.put(`${config.backendUrl}/products/${productId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
setProductData({
...productData,
...values
})
setIsEditing(false)
messageApi.success('Product information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update product information:', err)
messageApi.error('Failed to update product information')
} finally {
await fetchProductDetails()
setLoading(false)
}
}
const actionItems = {
return (
<EditObjectForm
id={productId}
type='product'
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 Product',
@ -193,465 +64,172 @@ const ProductInfo = () => {
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchProductDetails()
fetchObject()
}
}
}
const getViewDropdownItems = () => {
const sections = [
}}
>
<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' }
]
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 || 'Product not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchProductDetails}>
Retry
</Button>
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</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>
<LockIndicator lock={lock} />
</Space>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
disabled={loading}
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
<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 || 'Product not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchProductDetails}>
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'
<InfoCollapse
title='Product Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
key='info'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Product Information
</Title>
</Flex>
}
key='1'
>
<Form
form={productForm}
layout='vertical'
onValuesChange={(changedValues) =>
setProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: productData?.name || '',
vendor: productData?.vendor || { id: null, name: '' },
version: productData?.version || '',
tags: productData?.tags || []
}}
>
<Spin
indicator={<LoadingOutlined />}
spinning={fetchLoading}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{productData?.id ? (
<IdText id={productData.id} type='product'></IdText>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{productData?.createdAt ? (
<TimeDisplay
dateTime={productData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
<ObjectInfo
loading={loading}
isEditing={isEditing}
indicator={null}
type='product'
items={[
{
required: true,
message: 'Please enter a product name'
name: '_id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'product',
showCopy: true,
readOnly: true
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : productData?.name ? (
<Text>{productData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{productData?.updatedAt ? (
<TimeDisplay
dateTime={productData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
name: 'createdAt',
label: 'Created At',
value: objectData?.createdAt,
type: 'dateTime',
readOnly: true
},
{
name: 'name',
label: 'Name',
value: objectData?.name,
required: true,
message: 'Please enter a vendor'
}
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : productData?.vendor?.name ? (
<Text>{productData.vendor.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{productData?.vendor?.id ? (
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
type: 'text'
},
{
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={[
name: 'updatedAt',
label: 'Updated At',
value: objectData?.updatedAt,
type: 'dateTime',
readOnly: true
},
{
name: 'vendor',
label: 'Vendor',
value: objectData?.vendor,
required: true,
message: 'Please enter a price.'
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 }
}
]}
>
<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>
) : productData?.margin && marginOrPrice == false ? (
<Text>{productData.margin + '%'}</Text>
) : productData?.price && marginOrPrice == true ? (
<Text>{'£' + productData.price}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</InfoCollapse>
<Descriptions.Item label='Version' span={1}>
{isEditing ? (
<Form.Item name='version' style={{ margin: 0 }}>
<Input placeholder='Enter version' />
</Form.Item>
) : productData?.version ? (
<Tag>{productData.version}</Tag>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<InfoCollapse
title='Product Parts'
icon={<ProductIcon />}
active={collapseState.parts}
onToggle={(expanded) => updateCollapseState('parts', expanded)}
key='parts'
>
<PartsTable data={objectData?.parts || []} />
</InfoCollapse>
<Descriptions.Item label='Tags' span={1}>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{productData?.tags?.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button
onClick={handleTagAdd}
icon={<PlusIcon />}
/>
</Space.Compact>
</Form.Item>
) : productData?.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ maxWidth: '300px' }}
>
{productData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.parts ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('parts', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<ProductIcon />
<Title level={5} style={{ margin: 0 }}>
Product Parts
</Title>
</Flex>
}
key='2'
>
<PartsTable data={productData?.parts || []} />
</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>
}
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
key='notes'
>
<Card>
<DashboardNotes _id={productId} />
</Card>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={productData?.auditLogs || []}
loading={fetchLoading}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
</Flex>
</div>
)}
</Flex>
</>
)}
</EditObjectForm>
)
}

View File

@ -12,7 +12,7 @@ import {
} from 'antd'
import { ExportOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import TimeDisplay from '../common/TimeDisplay'
import DashboardTable from '../common/DashboardTable'
import PersonIcon from '../../Icons/PersonIcon'
@ -248,7 +248,7 @@ const Users = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'user'} longId={false} />,
render: (text) => <IdDisplay id={text} type={'user'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,

View File

@ -1,144 +1,58 @@
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Flex,
Form,
Input,
Collapse,
Dropdown,
Popover,
Card,
Checkbox
} from 'antd'
import {
LoadingOutlined,
ExportOutlined,
CaretLeftOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import TimeDisplay from '../../common/TimeDisplay'
import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import config from '../../../../config.js'
const { Title, Link, Text } = Typography
import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator'
const UserInfo = () => {
const [userData, setUserData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const userId = new URLSearchParams(location.search).get('userId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
const [collapseState, updateCollapseState] = useCollapseState('UserInfo', {
info: true,
notes: true,
auditLogs: true
})
useEffect(() => {
if (userId) {
fetchUserDetails()
}
}, [userId])
useEffect(() => {
if (userData) {
form.setFieldsValue({
username: userData.username || '',
name: userData.name || '',
firstName: userData.firstName || '',
lastName: userData.lastName || '',
email: userData.email || ''
})
}
}, [userData, form])
const fetchUserDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(`${config.backendUrl}/users/${userId}`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setUserData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch user details')
messageApi.error('Failed to fetch user details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
// Reset form values to original data
if (userData) {
form.setFieldsValue({
username: userData.username || '',
name: userData.name || '',
firstName: userData.firstName || '',
lastName: userData.lastName || '',
email: userData.email || ''
})
}
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(`${config.backendUrl}/users/${userId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
setUserData({ ...userData, ...values })
setIsEditing(false)
messageApi.success('User information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update user information:', err)
messageApi.error('Failed to update user information')
} finally {
fetchUserDetails()
setLoading(false)
}
}
const actionItems = {
return (
<EditObjectForm
id={userId}
type='user'
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 User',
@ -148,362 +62,146 @@ const UserInfo = () => {
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchUserDetails()
fetchObject()
}
}
}
const getViewDropdownItems = () => {
const sections = [
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
sections={[
{ key: 'info', label: 'User Information' },
{ 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 || 'User not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchUserDetails}>
Retry
</Button>
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</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>
<LockIndicator lock={lock} />
</Space>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
disabled={loading}
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading || true}
loading={editLoading}
/>
<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 || 'User not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchUserDetails}>
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}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
<InfoCollapse
title='User Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
key='info'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
User Information
</Title>
</Flex>
}
key='1'
>
<Form form={form} layout='vertical'>
<Spin
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
spinning={fetchLoading}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
{userData?._id ? (
<IdText id={userData._id} type='user' />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{userData?.createdAt ? (
<TimeDisplay
dateTime={userData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
isEditing={isEditing}
type='user'
items={[
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.name ? (
<Text>{userData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{userData?.updatedAt ? (
<TimeDisplay
dateTime={userData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Username'>
{isEditing ? (
<Form.Item
name='username'
rules={[
{
required: true,
message: 'Please enter a username'
name: '_id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'user',
showCopy: true
},
{
max: 50,
message:
'Username cannot exceed 50 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.username ? (
<Text>{userData.username}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='First Name'>
{isEditing ? (
<Form.Item
name='firstName'
rules={[
name: 'createdAt',
label: 'Created At',
value: objectData?.createdAt,
type: 'dateTime',
readOnly: true
},
{
max: 50,
message:
'First name cannot exceed 50 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.firstName ? (
<Text>{userData.firstName}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Email'>
{isEditing ? (
<Form.Item
name='email'
rules={[
name: 'name',
label: 'Name',
value: objectData?.name,
required: true,
type: 'text'
},
{
type: 'email',
message: 'Please enter a valid email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.email ? (
<Link href={`mailto:${userData.email}`}>
{userData.email + ' '}
<ExportOutlined />
</Link>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Last Name'>
{isEditing ? (
<Form.Item
name='lastName'
rules={[
{
max: 50,
message:
'Last name cannot exceed 50 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : userData?.lastName ? (
<Text>{userData.lastName}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
name: 'updatedAt',
label: 'Updated At',
value: objectData?.updatedAt,
type: 'dateTime',
readOnly: true
},
<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>
{
name: 'firstName',
label: 'First Name',
value: objectData?.firstName,
type: 'text'
},
{
name: 'username',
label: 'Username',
value: objectData?.username,
required: true,
type: 'text'
},
{
name: 'lastName',
label: 'Last Name',
value: objectData?.lastName,
type: 'text'
},
{
name: 'email',
label: 'Email',
value: objectData?.email,
type: 'email'
}
]}
/>
</InfoCollapse>
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
key='notes'
>
<Card>
<DashboardNotes _id={userId} />
</Card>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={userData?.auditLogs || []}
loading={fetchLoading}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
</Flex>
</div>
)}
</Flex>
</>
)}
</EditObjectForm>
)
}

View File

@ -14,7 +14,7 @@ import {
} from 'antd'
import { ExportOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import NewVendor from './Vendors/NewVendor'
import CountryDisplay from '../common/CountryDisplay'
import TimeDisplay from '../common/TimeDisplay'
@ -155,7 +155,7 @@ const Vendors = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'vendor'} longId={false} />,
render: (text) => <IdDisplay id={text} type={'vendor'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,

View File

@ -1,150 +1,64 @@
import React, { useState, useEffect, useCallback } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Flex,
Form,
Input,
Collapse,
Dropdown,
Popover,
Card,
Checkbox
} from 'antd'
import {
LoadingOutlined,
ExportOutlined,
CaretLeftOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import CountrySelect from '../../common/CountrySelect'
import CountryDisplay from '../../common/CountryDisplay'
import TimeDisplay from '../../common/TimeDisplay'
import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import loglevel from 'loglevel'
import config from '../../../../config'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
import InfoCollapse from '../../common/InfoCollapse'
import ObjectInfo from '../../common/ObjectInfo'
import ViewButton from '../../common/ViewButton'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import EditObjectForm from '../../common/EditObjectForm'
import EditButtons from '../../common/EditButtons'
import LockIndicator from '../Filaments/LockIndicator'
import config from '../../../../config.js'
const { Title, Link, Text } = Typography
const log = loglevel.getLogger('VendorInfo')
log.setLevel(config.logLevel)
const VendorInfo = () => {
const [vendorData, setVendorData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const vendorId = new URLSearchParams(location.search).get('vendorId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
const [collapseState, updateCollapseState] = useCollapseState('VendorInfo', {
info: true,
notes: true,
auditLogs: true
})
useEffect(() => {
if (vendorId) {
fetchVendorDetails()
}
}, [vendorId, fetchVendorDetails])
useEffect(() => {
if (vendorData) {
form.setFieldsValue({
name: vendorData.name || '',
website: vendorData.website || '',
contact: vendorData.contact || '',
country: vendorData.country || '',
phone: vendorData.phone || '',
email: vendorData.email || ''
})
}
}, [vendorData, form])
const fetchVendorDetails = useCallback(async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`${config.backendUrl}/vendors/${vendorId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setVendorData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch vendor details')
messageApi.error('Failed to fetch vendor details')
} finally {
setFetchLoading(false)
}
}, [messageApi, vendorId])
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
// Reset form values to original data
if (vendorData) {
form.setFieldsValue({
name: vendorData.name || '',
website: vendorData.website || '',
contact: vendorData.contact || '',
country: vendorData.country || '',
phone: vendorData.phone || '',
email: vendorData.email || ''
})
}
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(`${config.backendUrl}/vendors/${vendorId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
setVendorData({ ...vendorData, ...values })
setIsEditing(false)
messageApi.success('Vendor information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
messageApi.error('Failed to update vendor information')
} finally {
fetchVendorDetails()
setLoading(false)
}
}
const actionItems = {
return (
<EditObjectForm
id={vendorId}
type='vendor'
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 Vendor',
@ -154,386 +68,149 @@ const VendorInfo = () => {
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchVendorDetails()
fetchObject()
}
}
}
const getViewDropdownItems = () => {
const sections = [
}}
>
<Button disabled={loading}>Actions</Button>
</Dropdown>
<ViewButton
loading={loading}
sections={[
{ key: 'info', label: 'Vendor Information' },
{ 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 || 'Vendor not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchVendorDetails}>
Retry
</Button>
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</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>
<LockIndicator lock={lock} />
</Space>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateInfo}
loading={loading}
disabled={loading}
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
/>
<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 || 'Vendor not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchVendorDetails}>
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}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse no-t-padding-collapse'
<InfoCollapse
title='Vendor Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
key='info'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Vendor Information
</Title>
</Flex>
}
key='1'
>
<Form form={form} layout='vertical'>
<Spin
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
spinning={fetchLoading}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
{vendorData?._id ? (
<IdText id={vendorData._id} type='vendor' />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{vendorData?.createdAt ? (
<TimeDisplay
dateTime={vendorData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
isEditing={isEditing}
items={[
{
name: 'id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'vendor',
showCopy: true
},
{
name: 'createdAt',
label: 'Created At',
value: objectData?.createdAt,
type: 'dateTime',
readOnly: true
},
{
name: 'name',
label: 'Name',
value: objectData?.name,
required: true,
message: 'Please enter a vendor name'
type: 'text'
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.name ? (
<Text>{vendorData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{vendorData?.updatedAt ? (
<TimeDisplay
dateTime={vendorData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Website'>
{isEditing ? (
<Form.Item
name='website'
rules={[
{
type: 'url',
message: 'Please enter a valid URL'
name: 'updatedAt',
label: 'Updated At',
value: objectData?.updatedAt,
type: 'dateTime',
readOnly: true
},
{
max: 200,
message:
'Website URL cannot exceed 200 characters'
name: 'website',
label: 'Website',
value: objectData?.website,
type: 'url'
},
{
name: 'country',
label: 'Country',
value: objectData?.country,
type: 'country'
},
{
name: 'contact',
label: 'Contact',
value: objectData?.contact,
type: 'text'
},
{
name: 'phone',
label: 'Phone',
value: objectData?.phone,
type: 'text'
},
{
name: 'email',
label: 'Email',
value: objectData?.email,
type: 'email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.website ? (
<Link
href={vendorData.website}
target='_blank'
rel='noopener noreferrer'
>
{new URL(vendorData.website).hostname + ' '}
<ExportOutlined />
</Link>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Country'>
{isEditing ? (
<Form.Item name='country' style={{ margin: 0 }}>
<CountrySelect
countryCode={vendorData?.country}
/>
</Form.Item>
) : vendorData?.country ? (
<CountryDisplay countryCode={vendorData.country} />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</InfoCollapse>
<Descriptions.Item label='Contact'>
{isEditing ? (
<Form.Item
name='contact'
rules={[
{
max: 200,
message:
'Contact info cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.contact ? (
<Text>{vendorData.contact}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Phone'>
{isEditing ? (
<Form.Item
name='phone'
rules={[
{
type: 'phone',
message: 'Please enter a valid phone number'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.phone ? (
<Text>{vendorData.phone}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Email'>
{isEditing ? (
<Form.Item
name='email'
rules={[
{
type: 'email',
message: 'Please enter a valid email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData?.email ? (
<Link href={`mailto:${vendorData.email}`}>
{vendorData.email + ' '}
<ExportOutlined />
</Link>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</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>
}
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
key='notes'
>
<Card>
<DashboardNotes _id={vendorId} />
</Card>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={vendorData?.auditLogs || []}
loading={fetchLoading}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
</Flex>
</div>
)}
</Flex>
</>
)}
</EditObjectForm>
)
}

View File

@ -21,7 +21,7 @@ import { DownloadOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import NewGCodeFile from './GCodeFiles/NewGCodeFile'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
@ -120,7 +120,9 @@ const GCodeFiles = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'gcodefile'} longId={false} />
render: (text) => (
<IdDisplay id={text} type={'gcodefile'} longId={false} />
)
},
{
title: 'Filament',

View File

@ -1,159 +1,66 @@
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Badge,
Form,
Typography,
Flex,
Input,
Card,
Collapse,
Dropdown,
Popover,
Checkbox
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import { capitalizeFirstLetter } from '../../utils/Utils.js'
import FilamentSelect from '../../common/FilamentSelect'
import useCollapseState from '../../hooks/useCollapseState'
import FilamentIcon from '../../../Icons/FilamentIcon'
import TimeDisplay from '../../common/TimeDisplay.jsx'
import { Space, Button, Flex, Dropdown, Card, Typography } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import ReloadIcon from '../../../Icons/ReloadIcon'
import EditIcon from '../../../Icons/EditIcon.jsx'
import XMarkIcon from '../../../Icons/XMarkIcon.jsx'
import CheckIcon from '../../../Icons/CheckIcon.jsx'
import config from '../../../../config.js'
import AuditLogTable from '../../common/AuditLogTable.jsx'
import DashboardNotes from '../../common/DashboardNotes.jsx'
import BinIcon from '../../../Icons/BinIcon.jsx'
import useCollapseState from '../../hooks/useCollapseState'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
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 '../../Management/Filaments/LockIndicator'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon.jsx'
const { Title, Text } = Typography
const { Text } = Typography
const GCodeFileInfo = () => {
const [gcodeFileData, setGCodeFileData] = useState(null)
const [editLoading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
const [collapseState, updateCollapseState] = useCollapseState(
'GCodeFileInfo',
{
info: true,
preview: true
preview: true,
notes: true,
auditLogs: true
}
)
useEffect(() => {
if (gcodeFileId) {
fetchGCodeFileDetails()
}
}, [gcodeFileId])
useEffect(() => {
if (gcodeFileData) {
form.setFieldsValue({
name: gcodeFileData.name || '',
filament: gcodeFileData.filament || { id: null, name: '' }
})
}
}, [gcodeFileData, form])
const fetchGCodeFileDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`${config.backendUrl}/gcodefiles/${gcodeFileId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setGCodeFileData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch GCodeFile details')
messageApi.error('Failed to fetch GCodeFile details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
setIsEditing(true)
updateCollapseState('info', true)
}
const cancelEditing = () => {
form.setFieldsValue({
name: gcodeFileData?.name || '',
filament: gcodeFileData?.filament || { id: null, name: '' }
})
setIsEditing(false)
}
const updateGCodeFileInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(
`${config.backendUrl}/gcodefiles/${gcodeFileId}`,
values,
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
setGCodeFileData({ ...gcodeFileData, ...values })
setIsEditing(false)
messageApi.success('GCode File information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update gcode file information:', err)
messageApi.error('Failed to update gcode file information')
} finally {
fetchGCodeFileDetails()
setLoading(false)
}
}
const actionItems = {
return (
<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: 'Edit GCode File',
key: 'edit',
icon: <EditIcon />
},
{
label: 'Delete GCode File',
key: 'delete',
icon: <BinIcon />,
danger: true
},
{ type: 'divider' },
{
label: 'Reload GCode File',
key: 'reload',
@ -162,360 +69,186 @@ const GCodeFileInfo = () => {
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchGCodeFileDetails()
fetchObject()
}
}
}
const getViewDropdownItems = () => {
const sections = [
}}
>
<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' }
]
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>
)
}
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>
]}
collapseState={collapseState}
updateCollapseState={updateCollapseState}
/>
</Space>
<LockIndicator lock={lock} />
</Space>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updateGCodeFileInfo}
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
disabled={editLoading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={editLoading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchGCodeFileDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['info'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
GCode File Information
</Title>
</Flex>
}
<InfoCollapse
title='GCode File Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
key='info'
>
<Form form={form} layout='vertical'>
<Spin
spinning={fetchLoading}
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID' span={1}>
{gcodeFileData?._id ? (
<IdText
id={gcodeFileData._id}
type='gcodefile'
></IdText>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{gcodeFileData?.createdAt ? (
<TimeDisplay
dateTime={gcodeFileData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
isEditing={isEditing}
items={[
{
required: true,
message: 'Please enter a vendor name'
name: '_id',
label: 'ID',
type: 'id',
objectType: 'gcodefile',
value: objectData?._id,
showCopy: true
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
value: objectData?.createdAt,
readOnly: true
},
{
name: 'name',
label: 'Name',
type: 'text',
value: objectData?.name,
required: true
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
value: objectData?.updatedAt,
readOnly: true
},
{
name: 'filament',
label: 'Filament',
type: 'object',
value: objectData?.filament,
objectType: 'filament',
required: true
},
{
name: 'cost',
label: 'Cost',
type: 'currency',
value: objectData?.cost,
readOnly: true
},
{
name: [
'gcodeFileInfo',
'estimatedPrintingTimeNormalMode'
],
label: 'Est Print Time',
value:
objectData?.gcodeFileInfo
?.estimatedPrintingTimeNormalMode,
type: 'text',
readOnly: true
},
{
name: ['gcodeFileInfo', 'sparseInfillDensity'],
label: 'Infill Density',
value: objectData?.gcodeFileInfo?.sparseInfillDensity,
type: 'number',
readOnly: true
},
{
name: ['gcodeFileInfo', 'sparseInfillPattern'],
label: 'Infill Pattern',
value: objectData?.gcodeFileInfo?.sparseInfillPattern,
type: 'text',
readOnly: true
},
{
name: ['gcodeFileInfo', 'filamentUsedMm'],
label: 'Filament Used (mm)',
value: objectData?.gcodeFileInfo?.filamentUsedMm,
type: 'mm',
readOnly: true
},
{
name: ['gcodeFileInfo', 'filamentUsedG'],
label: 'Filament Used (g)',
value: objectData?.gcodeFileInfo?.filamentUsedG,
type: 'weight',
readOnly: true
},
{
name: ['gcodeFileInfo', 'nozzleTemperature'],
label: 'Hotend Temperature',
value: objectData?.gcodeFileInfo?.nozzleTemperature,
type: 'number',
readOnly: true
},
{
name: ['gcodeFileInfo', 'hotPlateTemp'],
label: 'Bed Temperature',
value: objectData?.gcodeFileInfo?.hotPlateTemp,
type: 'number',
readOnly: true
},
{
name: ['gcodeFileInfo', 'filamentSettingsId'],
label: 'Filament Profile',
value: objectData?.gcodeFileInfo?.filamentSettingsId,
type: 'text',
readOnly: true
},
{
name: ['gcodeFileInfo', 'printSettingsId'],
label: 'Print Profile',
value: objectData?.gcodeFileInfo?.printSettingsId,
type: 'text',
readOnly: true
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : gcodeFileData?.name ? (
<Text>{gcodeFileData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
objectData={objectData}
type='gcodefile'
/>
</InfoCollapse>
<Descriptions.Item label='Updated At'>
{gcodeFileData?.updatedAt ? (
<TimeDisplay
dateTime={gcodeFileData.updatedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{isEditing ? (
<Form.Item
name='filament'
rules={[
{
required: true,
message: 'Please enter a filament'
}
]}
style={{ margin: 0 }}
>
<FilamentSelect />
</Form.Item>
) : gcodeFileData?.filament ? (
<Space>
<FilamentIcon />
<Badge
color={gcodeFileData.filament.color}
text={gcodeFileData.filament.name}
/>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{gcodeFileData?.filament ? (
<IdText
id={gcodeFileData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Est Print Time'>
{gcodeFileData?.gcodeFileInfo
?.estimatedPrintingTimeNormalMode ? (
<Text>
{
gcodeFileData.gcodeFileInfo
.estimatedPrintingTimeNormalMode
}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{gcodeFileData?.cost ? (
<Text>{'£' + gcodeFileData.cost.toFixed(2)}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Infill Density'>
{gcodeFileData?.gcodeFileInfo?.sparseInfillDensity ? (
<Text>
{gcodeFileData.gcodeFileInfo.sparseInfillDensity}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Infill Pattern'>
{gcodeFileData?.gcodeFileInfo?.sparseInfillPattern ? (
<Text>
{capitalizeFirstLetter(
gcodeFileData.gcodeFileInfo.sparseInfillPattern
)}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (mm)'>
{gcodeFileData?.gcodeFileInfo?.filamentUsedMm ? (
<Text>
{gcodeFileData.gcodeFileInfo.filamentUsedMm}mm
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (g)'>
{gcodeFileData?.gcodeFileInfo?.filamentUsedG ? (
<Text>
{gcodeFileData.gcodeFileInfo.filamentUsedG}g
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Hotend Temperature'>
{gcodeFileData?.gcodeFileInfo?.nozzleTemperature ? (
<Text>
{gcodeFileData.gcodeFileInfo.nozzleTemperature}°
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Bed Temperature'>
{gcodeFileData?.gcodeFileInfo?.hotPlateTemp ? (
<Text>
{gcodeFileData.gcodeFileInfo.hotPlateTemp}°
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Profile'>
{gcodeFileData?.gcodeFileInfo?.filamentSettingsId ? (
<Text>
{gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll(
'"',
''
)}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Print Profile'>
{gcodeFileData?.gcodeFileInfo?.printSettingsId ? (
<Text>
{gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll(
'"',
''
)}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.preview ? ['preview'] : []}
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'}>
<GCodeFileIcon />
<Title level={5} style={{ margin: 0 }}>
GCode File Preview
</Title>
</Flex>
<InfoCollapse
title='GCode File Preview'
icon={<GCodeFileIcon />}
active={collapseState.preview}
onToggle={(expanded) =>
updateCollapseState('preview', expanded)
}
key='preview'
>
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
<Card styles={{ body: { padding: '10px' } }}>
{gcodeFileData?.gcodeFileInfo?.thumbnail ? (
<Card>
{objectData?.gcodeFileInfo?.thumbnail ? (
<img
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
src={`data:image/png;base64,${objectData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '100%' }}
/>
@ -523,74 +256,40 @@ const GCodeFileInfo = () => {
<Text>n/a</Text>
)}
</Card>
</Spin>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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>
}
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
key='notes'
>
<Card>
<DashboardNotes _id={gcodeFileId} />
</Card>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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 Log
</Title>
</Flex>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={gcodeFileData?.auditLogs || []}
loading={fetchLoading}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
</Flex>
</div>
)}
</Flex>
</>
)}
</EditObjectForm>
)
}

View File

@ -218,7 +218,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG
if (filamentCost && gcodeFilamentUsed) {
const cost = (filamentCost / 1000) * gcodeFilamentUsed
console.log('Setting cost')
setNewGCodeFileFormValues((prev) => ({ ...prev, cost: cost.toFixed(2) }))
newGCodeFileForm.setFieldValue('cost', cost.toFixed(2))
}
@ -304,8 +303,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
gcodeFileInfo: parsedConfig
})
console.log(parsedConfig)
// Update filter settings if filament info is available
if (parsedConfig.filament_type && parsedConfig.filament_diameter) {
setFilamentSelectFilter({
@ -525,7 +522,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
onClick={() => {
setCurrentStep(currentStep + 1)
setNextEnabled(false)
console.log(newGCodeFileFormValues)
}}
>
Next

View File

@ -22,7 +22,7 @@ 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 IdText from '../common/IdText.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'
@ -126,7 +126,7 @@ const Jobs = () => {
dataIndex: 'id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'job'} longId={false} />,
render: (text) => <IdDisplay id={text} type={'job'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,

View File

@ -1,48 +1,26 @@
import React, { useState, useEffect, useContext } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Progress,
Typography,
Collapse,
Flex,
Dropdown,
Popover,
Checkbox,
Card
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import TimeDisplay from '../../common/TimeDisplay'
import JobState from '../../common/JobState'
import IdText from '../../common/IdText'
import SubJobsTree from '../../common/SubJobsTree'
import { PrintServerContext } from '../../context/PrintServerContext'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import ReloadIcon from '../../../Icons/ReloadIcon'
import { Space, Button, Flex, Dropdown, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
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 '../../Management/Filaments/LockIndicator'
import SubJobsTree from '../../common/SubJobsTree'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon'
import JobIcon from '../../../Icons/JobIcon'
import AuditLogIcon from '../../../Icons/AuditLogIcon'
import NoteIcon from '../../../Icons/NoteIcon'
const { Title, Text } = Typography
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
const JobInfo = () => {
const [jobData, setJobData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi] = message.useMessage()
const jobId = new URLSearchParams(location.search).get('jobId')
const { printServer } = useContext(PrintServerContext)
const [collapseState, updateCollapseState] = useCollapseState('JobInfo', {
info: true,
subJobs: true,
@ -50,352 +28,205 @@ const JobInfo = () => {
auditLogs: true
})
useEffect(() => {
if (jobId) {
fetchJobDetails()
}
}, [jobId])
useEffect(() => {
if (printServer && jobId) {
printServer.on('notify_job_update', (updateData) => {
if (updateData._id === jobId) {
setJobData((prevData) => {
if (!prevData) return prevData
return {
...prevData,
state: updateData.state,
...updateData
}
})
}
})
}
return () => {
if (printServer) {
printServer.off('notify_job_update')
}
}
}, [printServer, jobId])
const fetchJobDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(`${config.backendUrl}/jobs/${jobId}`, {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setJobData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch print job details')
messageApi.error('Failed to fetch print job details')
} finally {
setFetchLoading(false)
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Job Information' },
{ key: 'subJobs', label: 'Sub Jobs' },
{ 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)
}}
<EditObjectForm
id={jobId}
type='job'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
)
}
const actionItems = {
items: [
{
label: 'Reload Job',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'edit') {
// TODO: Implement edit functionality
messageApi.info('Edit functionality coming soon')
} else if (key === 'reload') {
fetchJobDetails()
}
}
}
return (
<>
{({
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={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
</Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchJobDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['info'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
<Dropdown
menu={{
items: [
{
label: 'Reload Job',
key: 'reload',
icon: <GCodeFileIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Job Information
</Title>
</Flex>
}
key='info'
>
<Spin spinning={fetchLoading} indicator={<LoadingOutlined />}>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='ID'>
{jobData?._id ? (
<IdText id={jobData._id} type={'job'} />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
{jobData?.state ? (
<JobState
job={jobData}
showProgress={false}
showQuantity={false}
showId={false}
<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}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
{jobData?.gcodeFile ? (
<Space>
<GCodeFileIcon />
<Text>
{jobData.gcodeFile.name || 'Not specified'}
</Text>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
{jobData?.gcodeFile?._id ? (
<IdText
id={jobData.gcodeFile._id}
type={'gcodefile'}
showHyperlink={true}
<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}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Quantity'>
{jobData?.quantity ? (
<Text>{jobData.quantity}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{jobData?.createdAt ? (
<TimeDisplay
dateTime={jobData.createdAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Started At'>
{jobData?.startedAt ? (
<TimeDisplay
dateTime={jobData.startedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
{jobData?.state?.type === 'printing' && (
<Descriptions.Item label='Progress'>
<Progress
percent={Math.round(
(jobData.state.progress || 0) * 100
)}
/>
</Descriptions.Item>
)}
<Descriptions.Item label='Assigned Printers'>
{jobData?.printers ? (
<Text>
{jobData.printers.length} printers assigned
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.subJobs ? ['2'] : []}
onChange={(keys) =>
updateCollapseState('subJobs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<JobIcon />
<Title level={5} style={{ margin: 0 }}>
Sub Job Information
</Title>
</Space>
</Flex>
}
key='2'
>
<SubJobsTree jobData={jobData} loading={fetchLoading} />
</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'
<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'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<NoteIcon />
<Title level={5} style={{ margin: 0 }}>
Notes
</Title>
</Flex>
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
isEditing={isEditing}
type='job'
items={[
{
name: '_id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'job',
showCopy: true
},
{
name: 'state',
label: 'Status',
value: objectData,
type: 'state',
objectType: 'job',
showStatus: true,
showProgress: true,
showId: false,
showQuantity: false,
readOnly: true
},
{
name: 'gcodeFile',
label: 'GCode File',
value: objectData?.gcodeFile,
type: 'object',
objectType: 'gcodeFile',
readOnly: true
},
{
name: 'gcodeFileId',
label: 'GCode File ID',
value: objectData?.gcodeFile?._id,
type: 'id',
objectType: 'gcodefile',
showHyperlink: true
},
{
name: 'quantity',
label: 'Quantity',
value: objectData?.quantity,
type: 'number',
readOnly: true
},
{
name: 'createdAt',
label: 'Created At',
value: objectData?.createdAt,
type: 'dateTime',
readOnly: true
},
{
name: 'startedAt',
label: 'Started At',
value: objectData?.startedAt,
type: 'dateTime',
readOnly: true
},
{
name: 'assignedPrinters',
label: 'Assigned Printers',
value: objectData?.printers?.length,
type: 'number',
readOnly: true
}
]}
/>
</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>
<DashboardNotes _id={jobId} />
</Card>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={jobData?.auditLogs || []}
loading={fetchLoading}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
</Flex>
</div>
)}
</Flex>
</>
)}
</EditObjectForm>
)
}

View File

@ -18,7 +18,7 @@ import {
import { AuthContext } from '../context/AuthContext'
import PrinterState from '../common/PrinterState'
import NewPrinter from './Printers/NewPrinter'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import PrinterIcon from '../../Icons/PrinterIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import ControlIcon from '../../Icons/ControlIcon'
@ -79,7 +79,7 @@ const Printers = () => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type='printer' longId={false} />
render: (text) => <IdDisplay id={text} type='printer' longId={false} />
},
{
title: 'State',
@ -89,7 +89,7 @@ const Printers = () => {
return (
<PrinterState
printer={record}
showPrinterName={false}
showName={false}
showControls={false}
/>
)

View File

@ -31,7 +31,7 @@ import PrinterMiscPanel from '../../common/PrinterMiscPanel'
import PrinterState from '../../common/PrinterState'
import { AuthContext } from '../../context/AuthContext'
import PrinterSubJobsTree from '../../common/PrinterJobsTree'
import IdText from '../../common/IdText'
import IdDisplay from '../../common/IdDisplay'
import FilamentIcon from '../../../Icons/FilamentIcon'
import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
@ -179,7 +179,6 @@ const ControlPrinter = () => {
}
return () => {
if (printServer && initialized) {
console.log('Deregistering')
printServer.off('notify_printer_update')
printServer.off('notify_filamentstock_update')
}
@ -187,7 +186,6 @@ const ControlPrinter = () => {
}, [printServer, initialized, printerId])
function handleEmergencyStop() {
console.log('Emergency stop button clicked')
printServer.emit('printer.emergency_stop', { printerId })
}
@ -438,7 +436,7 @@ const ControlPrinter = () => {
<PrinterState
printer={printerData}
showProgress={false}
showPrinterName={false}
showName={false}
showControls={false}
/>
) : (
@ -548,7 +546,7 @@ const ControlPrinter = () => {
<Descriptions.Item label='Printer ID'>
{printerData?._id ? (
<IdText
<IdDisplay
id={printerData._id}
type='printer'
longId={false}
@ -573,7 +571,7 @@ const ControlPrinter = () => {
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
{printerData?.currentJob?.gcodeFile ? (
<IdText
<IdDisplay
id={printerData.currentJob.gcodeFile.id}
type='gcodeFile'
longId={false}
@ -586,7 +584,7 @@ const ControlPrinter = () => {
<Descriptions.Item label='Print Job ID'>
{printerData?.currentJob?.id ? (
<IdText
<IdDisplay
id={printerData.currentJob.id}
type='job'
longId={false}
@ -599,7 +597,7 @@ const ControlPrinter = () => {
<Descriptions.Item label='Sub Job ID'>
{printerData?.currentSubJob?.id ? (
<IdText
<IdDisplay
id={printerData.currentSubJob.number
.toString()
.padStart(6, '0')}
@ -719,7 +717,7 @@ const ControlPrinter = () => {
</Descriptions.Item>
<Descriptions.Item label='Filament Stock ID'>
{printerData?.currentFilamentStock?._id ? (
<IdText
<IdDisplay
id={printerData.currentFilamentStock._id}
type='filamentstock'
longId={false}
@ -748,7 +746,7 @@ const ControlPrinter = () => {
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{printerData?.currentFilamentStock?.filament ? (
<IdText
<IdDisplay
id={printerData.currentFilamentStock.filament._id}
type='filament'
longId={false}

View File

@ -1,689 +1,262 @@
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Tag,
Typography,
Flex,
Form,
Input,
InputNumber,
Select,
Collapse,
Dropdown,
Popover,
Checkbox,
Card
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import PrinterState from '../../common/PrinterState'
import TimeDisplay from '../../common/TimeDisplay'
import IdText from '../../common/IdText'
import PrinterSubJobsList from '../../common/PrinterJobsTree'
import VendorSelect from '../../common/VendorSelect'
import VendorIcon from '../../../Icons/VendorIcon'
import PlusIcon from '../../../Icons/PlusIcon'
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, Button, Flex, Dropdown, Card } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config.js'
import AuditLogTable from '../../common/AuditLogTable.jsx'
import DashboardNotes from '../../common/DashboardNotes.jsx'
import AuditLogTable from '../../common/AuditLogTable'
import DashboardNotes from '../../common/DashboardNotes'
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 '../../Management/Filaments/LockIndicator'
import PrinterJobsTree from '../../common/PrinterJobsTree'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
const { Title, Text } = Typography
const PrinterInfo = () => {
const [printerData, setPrinterData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [editLoading, setEditLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const printerId = new URLSearchParams(location.search).get('printerId')
const [messageApi, contextHolder] = message.useMessage()
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [collapseState, updateCollapseState] = useCollapseState('PrinterInfo', {
info: true,
jobs: true,
notes: true,
auditLogs: true
})
useEffect(() => {
if (printerId) {
fetchPrinterDetails()
}
}, [printerId])
useEffect(() => {
if (printerData) {
form.setFieldsValue({
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
})
}
}, [printerData, form])
const fetchPrinterDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`${config.backendUrl}/printers/${printerId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setPrinterData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch printer details')
messageApi.error('Failed to fetch printer details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
updateCollapseState('info', true)
setIsEditing(true)
}
const cancelEditing = () => {
// Reset form values to original data
if (printerData) {
form.setFieldsValue({
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
})
}
setIsEditing(false)
}
const updatePrinterInfo = async () => {
try {
const values = await form.validateFields()
setEditLoading(true)
await axios.put(
`${config.backendUrl}/printers/${printerId}`,
{
name: values.name,
vendor: values.vendor,
moonraker: {
host: values.moonraker.host,
port: values.moonraker.port,
protocol: values.moonraker.protocol,
apiKey: values.moonraker.apiKey
},
tags: values.tags
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
// Update the local state with the new values
setPrinterData({ ...printerData, ...values })
setIsEditing(false)
messageApi.success('Printer information updated successfully')
} catch (err) {
if (err.errorFields) {
// This is a form validation error
return
}
console.error('Failed to update printer information:', err)
messageApi.error('Failed to update printer information')
} finally {
setEditLoading(false)
}
}
const handleTagClose = (removedTag) => {
const newTags = printerData.tags.filter((tag) => tag !== removedTag)
setPrinterData((prev) => ({ ...prev, tags: newTags }))
}
const handleTagAdd = () => {
const input = form.getFieldValue('newTag')
if (input) {
const newTag = input.trim()
if (newTag && !printerData.tags.includes(newTag)) {
setPrinterData((prev) => ({ ...prev, tags: [...prev.tags, newTag] }))
form.setFieldValue('newTag', '')
}
}
}
const actionItems = {
items: [
{
label: 'Reload Printer',
key: 'reload',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchPrinterDetails()
}
}
}
const getViewDropdownItems = () => {
const sections = [
{ key: 'info', label: 'Printer Information' },
{ key: 'jobs', label: 'Printer Jobs' },
{ 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)
}}
<EditObjectForm
id={printerId}
type='printer'
style={{ height: 'calc(var(--unit-100vh) - 155px)', minHeight: 0 }}
>
{section.label}
</Checkbox>
))}
</Flex>
</Flex>
)
}
return (
<>
{contextHolder}
{({
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={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
<Dropdown
menu={{
items: [
{
label: 'Reload Printer',
key: 'reload',
icon: <AuditLogIcon />
}
],
onClick: ({ key }) => {
if (key === 'reload') {
fetchObject()
}
}
}}
>
<Button>View</Button>
</Popover>
<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>
{isEditing ? (
<>
<Button
icon={<CheckIcon />}
type='primary'
onClick={updatePrinterInfo}
<EditButtons
isEditing={isEditing}
handleUpdate={handleUpdate}
cancelEditing={cancelEditing}
startEditing={startEditing}
editLoading={editLoading}
formValid={formValid}
disabled={lock?.locked || loading}
loading={editLoading}
disabled={editLoading}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={editLoading}
/>
</>
) : (
<Button icon={<EditIcon />} onClick={startEditing} />
)}
</Space>
</Flex>
{error ? (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadIcon />} onClick={fetchPrinterDetails}>
Retry
</Button>
</Space>
) : (
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.info ? ['info'] : []}
onChange={(keys) =>
updateCollapseState('info', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<InfoCircleIcon />
<Title level={5} style={{ margin: 0 }}>
Printer Information
</Title>
</Flex>
}
<InfoCollapse
title='Printer Information'
icon={<InfoCircleIcon />}
active={collapseState.info}
onToggle={(expanded) => updateCollapseState('info', expanded)}
key='info'
>
<Form
form={form}
layout='vertical'
initialValues={{
name: printerData?.name || '',
vendor: printerData?.vendor || { id: null, name: '' },
moonraker: {
host: printerData?.moonraker?.host || '',
port: printerData?.moonraker?.port || null,
protocol: printerData?.moonraker?.protocol || 'ws',
apiKey: printerData?.moonraker?.apiKey || ''
},
tags: printerData?.tags || []
}}
>
<Spin
spinning={fetchLoading}
<ObjectInfo
loading={loading}
indicator={<LoadingOutlined />}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
{/* Read-only fields */}
<Descriptions.Item label='ID'>
{printerData?._id ? (
<IdText id={printerData._id} type={'printer'} />
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Connected At'>
{printerData?.connectedAt ? (
<TimeDisplay
dateTime={printerData.connectedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
{/* Editable fields */}
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
isEditing={isEditing}
type='printer'
items={[
{
required: true,
message: 'Please enter a printer name'
name: '_id',
label: 'ID',
value: objectData?._id,
type: 'id',
objectType: 'printer',
showCopy: true
},
{
max: 100,
message: 'Name cannot exceed 100 characters'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter printer name' />
</Form.Item>
) : printerData?.name ? (
<Text>{printerData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Host'>
{isEditing ? (
<Form.Item
name={['moonraker', 'host']}
rules={[
{
required: true,
message: 'Please enter a host'
name: 'connectedAt',
label: 'Connected At',
value: objectData?.connectedAt,
type: 'dateTime',
readOnly: true
},
{
pattern:
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
message:
'Please enter a valid hostname or IP address'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
</Form.Item>
) : printerData?.moonraker?.host ? (
<Text>{printerData.moonraker.host}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{
name: 'name',
label: 'Name',
value: objectData?.name,
required: true,
message: 'Please enter a vendor'
}
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : printerData?.vendor?.name ? (
<Space>
<VendorIcon />
{printerData?.vendor?.name || 'n/a'}
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{printerData?.vendor ? (
<IdText
id={printerData?.vendor?.id}
type={'vendor'}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Port'>
{isEditing ? (
<Form.Item
name={['moonraker', 'port']}
rules={[
{
required: true,
message: 'Please enter a port number'
type: 'text'
},
{
name: 'state',
label: 'Status',
value: objectData,
type: 'state',
objectType: 'printer',
showName: false,
readOnly: true
},
{
name: 'vendor',
label: 'Vendor',
value: objectData?.vendor,
type: 'object',
objectType: 'vendor',
required: true
},
{
name: ['moonraker', 'host'],
label: 'Host',
value: objectData?.moonraker?.host,
type: 'text',
required: true
},
{
name: 'vendorId',
label: 'Vendor ID',
value: objectData?.vendor?.id,
type: 'id',
objectType: 'vendor',
showHyperlink: true,
readOnly: true
},
{
name: ['moonraker', 'port'],
label: 'Port',
value: objectData?.moonraker?.port,
type: 'number',
min: 1,
max: 65535,
message: 'Port must be between 1 and 65535'
required: true
},
{
name: ['moonraker', 'apiKey'],
label: 'API Key',
value: objectData?.moonraker?.apiKey,
type: 'secret',
reveal: true,
required: false
},
{
name: ['moonraker', 'protocol'],
label: 'Protocol',
value: objectData?.moonraker?.protocol,
type: 'wsprotocol',
required: true
},
{
name: 'tags',
label: 'Tags',
value: objectData?.tags,
type: 'tags',
required: false
},
{
name: 'firmware',
label: 'Firmware Version',
value: objectData?.firmware,
type: 'text',
required: false,
readOnly: true
}
]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
max={65535}
placeholder='Enter port'
style={{ width: '100%' }}
/>
</Form.Item>
) : printerData?.moonraker?.port ? (
<Text>{printerData.moonraker.port}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</InfoCollapse>
<Descriptions.Item label='Protocol'>
{isEditing ? (
<Form.Item
name={['moonraker', 'protocol']}
rules={[
{ required: true, message: 'Port is required' }
]}
style={{ margin: 0 }}
>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
]}
/>
</Form.Item>
) : printerData?.moonraker?.protocol == 'ws' ? (
<Text>Websocket</Text>
) : printerData?.moonraker?.protocol == 'wss' ? (
<Text>Websocket Secure</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='API Key'>
{isEditing ? (
<Form.Item
name={['moonraker', 'apiKey']}
style={{ margin: 0 }}
>
<Input.Password placeholder='Enter API key' />
</Form.Item>
) : printerData?.moonraker?.apiKey ? (
<Text>Configured</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
{printerData?.state ? (
<PrinterState
printer={printerData}
showPrinterName={false}
showControls={false}
showProgress={false}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button
onClick={handleTagAdd}
icon={<PlusIcon />}
/>
</Space.Compact>
</Form.Item>
) : printerData?.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Firmware Version'>
{printerData?.firmware ? (
<Text>{printerData.firmware}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.jobs ? ['jobs'] : []}
onChange={(keys) =>
updateCollapseState('jobs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex align='center' gap={'middle'}>
<PrinterIcon />
<Title level={5} style={{ margin: 0 }}>
Printer Jobs
</Title>
</Flex>
}
<InfoCollapse
title='Printer Jobs'
icon={<PrinterIcon />}
active={collapseState.jobs}
onToggle={(expanded) => updateCollapseState('jobs', expanded)}
key='jobs'
>
<PrinterSubJobsList
subJobs={printerData?.subJobs}
loading={fetchLoading}
<PrinterJobsTree
subJobs={objectData?.subJobs}
loading={loading}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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>
}
<InfoCollapse
title='Notes'
icon={<NoteIcon />}
active={collapseState.notes}
onToggle={(expanded) => updateCollapseState('notes', expanded)}
key='notes'
>
<Card>
<DashboardNotes _id={printerId} />
</Card>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
<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 Log
</Title>
</Flex>
<InfoCollapse
title='Audit Logs'
icon={<AuditLogIcon />}
active={collapseState.auditLogs}
onToggle={(expanded) =>
updateCollapseState('auditLogs', expanded)
}
key='auditLogs'
>
<AuditLogTable
items={printerData?.auditLogs || []}
loading={fetchLoading}
items={objectData?.auditLogs || []}
loading={loading}
showTargetColumn={false}
/>
</Collapse.Panel>
</Collapse>
</InfoCollapse>
</Flex>
</div>
)}
</Flex>
</>
)}
</EditObjectForm>
)
}

View File

@ -61,7 +61,6 @@ const ProductionOverview = () => {
await fetchPrinterStats()
await fetchJobstats()
await fetchChartData()
console.log(stats)
}, [])
const fetchPrinterStats = async () => {
@ -74,7 +73,6 @@ const ProductionOverview = () => {
withCredentials: true
})
const printStats = response.data
console.log(printStats)
setStats((prev) => ({ ...prev, printers: printStats }))
setError(null)
} catch (err) {

View File

@ -1,7 +1,7 @@
import React, { forwardRef, useState } from 'react'
import { Typography, Space, Descriptions, Badge, Table } from 'antd'
import PropTypes from 'prop-types'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import { AuditOutlined, LoadingOutlined } from '@ant-design/icons'
import TimeDisplay from '../common/TimeDisplay'
import BoolDisplay from './BoolDisplay'
@ -51,7 +51,7 @@ const formatValue = (value, propertyName) => {
if (isObjectId(value)) {
return (
<IdText
<IdDisplay
id={value}
type={propertyName.toLowerCase().replaceAll('current', '')}
longId={false}
@ -90,7 +90,9 @@ const AuditLogTable = forwardRef(
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'auditlog'} longId={false} />,
render: (text) => (
<IdDisplay id={text} type={'auditlog'} longId={false} />
),
sorter: (a, b) => a._id.localeCompare(b._id)
}
]
@ -110,7 +112,7 @@ const AuditLogTable = forwardRef(
key: 'owner',
width: 180,
render: (record) => (
<IdText
<IdDisplay
id={record.owner._id}
type={record.ownerModel.toLowerCase()}
longId={false}
@ -127,7 +129,7 @@ const AuditLogTable = forwardRef(
key: 'target',
width: 180,
render: (record) => (
<IdText
<IdDisplay
id={record.target}
type={record.targetModel.toLowerCase()}
longId={false}

View File

@ -0,0 +1,55 @@
import React, { useState, useEffect } from 'react'
import { ColorPicker, Checkbox, Flex } from 'antd'
import PropTypes from 'prop-types'
const ColorSelector = ({ value, onChange, disabled, required = false }) => {
// Determine initial enabled state based on value
const [colorEnabled, setColorEnabled] = useState(!!value)
useEffect(() => {
setColorEnabled(!!value)
}, [value])
const handleCheckboxChange = (e) => {
const checked = e.target.checked
setColorEnabled(checked)
if (!checked) {
onChange(null)
} else if (checked && !value) {
onChange('#000000')
}
}
const handleColorChange = (color) => {
onChange('#' + color.toHex())
}
return (
<Flex gap={'middle'}>
<ColorPicker
showText
disabledAlpha
style={{ width: '100%', justifyContent: 'start' }}
disabled={!colorEnabled || disabled}
value={value || '#000000'}
onChange={handleColorChange}
/>
{!required && (
<Checkbox
checked={colorEnabled}
onChange={handleCheckboxChange}
disabled={disabled}
/>
)}
</Flex>
)
}
ColorSelector.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
required: PropTypes.bool
}
export default ColorSelector

View File

@ -0,0 +1,73 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Button, Tooltip, message } from 'antd'
import CopyIcon from '../../Icons/CopyIcon'
const CopyButton = ({
text,
style = {},
iconStyle = {},
tooltip = 'Copy',
size = 'small'
}) => {
const [messageApi, contextHolder] = message.useMessage()
const doCopy = (copyText) => {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(copyText)
.then(() => {
messageApi.success('Copied to clipboard')
})
.catch(() => {
messageApi.error('Failed to copy')
})
} else if (
document.queryCommandSupported &&
document.queryCommandSupported('copy')
) {
// Legacy fallback
const textarea = document.createElement('textarea')
textarea.value = copyText
textarea.setAttribute('readonly', '')
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
messageApi.success('Copied to clipboard')
} catch (err) {
messageApi.error('Failed to copy')
}
document.body.removeChild(textarea)
} else {
messageApi.error('Copy not supported in this browser')
}
}
return (
<>
{contextHolder}
<Tooltip title={tooltip} arrow={false}>
<Button
icon={<CopyIcon style={{ fontSize: '14px', ...iconStyle }} />}
type='text'
size={size}
style={{ height: '22px', ...style }}
onClick={() => doCopy(text)}
/>
</Tooltip>
</>
)
}
CopyButton.propTypes = {
text: PropTypes.string.isRequired,
style: PropTypes.object,
iconStyle: PropTypes.object,
tooltip: PropTypes.string,
size: PropTypes.string
}
export default CopyButton

View File

@ -28,7 +28,7 @@ import config from '../../../config'
import { AuthContext } from '../context/AuthContext'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import NoteTypeSelect from './NoteTypeSelect'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import ReloadIcon from '../../Icons/ReloadIcon'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
@ -177,7 +177,7 @@ const NoteItem = ({
</Space>
<Space size={'small'} style={{ marginRight: 8 }}>
<Text type='secondary'>User ID:</Text>
<IdText
<IdDisplay
longId={false}
id={note.user._id}
type={'user'}

View File

@ -21,6 +21,10 @@ import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import { useMediaQuery } from 'react-responsive'
import axios from 'axios'
import config from '../../../config'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('DasboardTable')
logger.setLevel(config.logLevel)
const DashboardTable = forwardRef(
(
@ -96,7 +100,7 @@ const DashboardTable = forwardRef(
const existingPageIndex = prev.findIndex(
(p) => p.pageNum === pageNum
)
console.log(prev.map((p) => p.pageNum))
logger.debug(prev.map((p) => p.pageNum))
if (existingPageIndex !== -1) {
// Update existing page
const newPages = [...prev]
@ -132,7 +136,7 @@ const DashboardTable = forwardRef(
const loadNextPage = useCallback(() => {
const highestPage = Math.max(...pages.map((p) => p.pageNum))
const nextPage = highestPage + 1
console.log('Next page', nextPage)
logger.debug('Next page', nextPage)
if (hasMore) {
setPages((prev) => {
@ -185,7 +189,7 @@ const DashboardTable = forwardRef(
const lowestPage = Math.min(...pages.map((p) => p.pageNum))
const prevPage = lowestPage - 1
console.log(
logger.debug(
'Down',
scrollHeight - scrollTop - clientHeight < 100,
lazyLoading
@ -197,7 +201,7 @@ const DashboardTable = forwardRef(
target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
console.log('Loading next page...')
logger.debug('Loading next page...')
loadNextPage()
}
@ -207,7 +211,7 @@ const DashboardTable = forwardRef(
target.scrollTop = scrollHeight / 2
}, 0)
setLazyLoading(true)
console.log('Loading previous page...')
logger.debug('Loading previous page...')
loadPreviousPage()
}
},

View File

@ -0,0 +1,52 @@
import React from 'react'
import { Button, Space } from 'antd'
import CheckIcon from '../../Icons/CheckIcon.jsx'
import XMarkIcon from '../../Icons/XMarkIcon.jsx'
import EditIcon from '../../Icons/EditIcon.jsx'
import PropTypes from 'prop-types'
const EditButtons = ({
isEditing,
handleUpdate,
cancelEditing,
startEditing,
formValid,
disabled,
loading
}) => {
return isEditing ? (
<Space size='small'>
<Button
icon={<CheckIcon />}
type='primary'
onClick={handleUpdate}
loading={loading}
disabled={loading || !formValid || disabled}
/>
<Button
icon={<XMarkIcon />}
onClick={cancelEditing}
disabled={loading || disabled}
/>
</Space>
) : (
<Button
icon={<EditIcon />}
onClick={startEditing}
loading={loading}
disabled={disabled || loading}
/>
)
}
EditButtons.propTypes = {
isEditing: PropTypes.bool.isRequired,
handleUpdate: PropTypes.func.isRequired,
cancelEditing: PropTypes.func.isRequired,
startEditing: PropTypes.func.isRequired,
formValid: PropTypes.bool.isRequired,
disabled: PropTypes.any,
loading: PropTypes.bool.isRequired
}
export default EditButtons

View File

@ -0,0 +1,182 @@
import React, { useState, useEffect, useContext, useCallback } from 'react'
import { Form, message } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext'
import PropTypes from 'prop-types'
/**
* EditObjectForm is a reusable form component for editing any object type.
* It handles fetching, updating, locking, unlocking, and validation logic.
*
* Props:
* - id: string (required)
* - type: string (required)
* - formItems: array (for ObjectInfo/ObjectProperty items)
* - children: function({
* loading, isEditing, startEditing, cancelEditing, handleUpdate, form, formValid, objectData, setIsEditing, setObjectData
* }) => ReactNode
*/
const EditObjectForm = ({ id, type, style, children }) => {
const [objectData, setObjectData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [editLoading, setEditLoading] = useState(false)
const [lock, setLock] = useState({})
const [initialized, setInitialized] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm()
const formUpdateValues = Form.useWatch([], form)
const [messageApi, contextHolder] = message.useMessage()
const {
fetchObjectInfo,
updateObjectInfo,
lockObject,
unlockObject,
onLockEvent,
onUpdateEvent,
fetchObjectLock,
showError
} = useContext(ApiServerContext)
// Validate form on change
useEffect(() => {
form
.validateFields({ validateOnly: true })
.then(() => setFormValid(true))
.catch(() => setFormValid(false))
}, [form, formUpdateValues])
// Lock event handler
const lockEventHandler = useCallback((lockEvent) => {
setLock(lockEvent)
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
if (id) {
unlockObject(id, type)
}
}
}, [id, type, unlockObject])
useEffect(() => {
if (objectData) {
form.setFieldsValue(objectData)
}
}, [objectData, form])
const fetchObject = useCallback(async () => {
try {
setFetchLoading(true)
const data = await fetchObjectInfo(id, type)
const lockEvent = await fetchObjectLock(id, type)
setLock(lockEvent)
setObjectData(data)
form.setFieldsValue(data)
setFetchLoading(false)
} catch (err) {
messageApi.error('Failed to fetch object info')
showError(
`Failed to fetch object information. Message: ${err.message}. Code: ${err.code}`,
fetchObject
)
}
}, [fetchObjectInfo, fetchObjectLock, id, type, form, messageApi, showError])
const updateObject = async () => {
const values = form.getFieldsValue()
await updateObjectInfo(id, type, values)
}
// Update event handler
const updateEventHandler = useCallback(() => {
fetchObject()
}, [fetchObject])
useEffect(() => {
if (!initialized && id) {
setInitialized(true)
fetchObject()
}
}, [id, initialized, fetchObject])
useEffect(() => {
if (id) {
const cleanup = onLockEvent(id, lockEventHandler)
return cleanup
}
}, [id, onLockEvent, lockEventHandler])
useEffect(() => {
if (id) {
const cleanup = onUpdateEvent(id, updateEventHandler)
return cleanup
}
}, [id, onUpdateEvent, updateEventHandler])
const startEditing = () => {
setIsEditing(true)
lockObject(id, type)
}
const cancelEditing = () => {
if (objectData) {
form.setFieldsValue(objectData)
}
setIsEditing(false)
unlockObject(id, type)
}
const handleUpdate = async () => {
try {
const values = await form.validateFields()
setEditLoading(true)
await updateObject()
setObjectData({ ...objectData, ...values })
setIsEditing(false)
messageApi.success('Information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
messageApi.error('Failed to update information')
showError(
`Failed to update information. Message: ${err.message}. Code: ${err.code}`,
() => handleUpdate()
)
} finally {
fetchObject()
setEditLoading(false)
}
}
return (
<Form form={form} layout='vertical' style={style}>
{contextHolder}
{children({
loading: fetchLoading,
isEditing,
startEditing,
cancelEditing,
handleUpdate,
form,
formValid,
objectData,
setIsEditing,
setObjectData,
editLoading,
lock,
fetchObject
})}
</Form>
)
}
EditObjectForm.propTypes = {
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
children: PropTypes.func.isRequired,
style: PropTypes.object
}
export default EditObjectForm

View File

@ -3,7 +3,7 @@ import { Flex, Typography, Badge } from 'antd'
import PropTypes from 'prop-types'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
const { Text } = Typography
@ -37,7 +37,7 @@ const FilamentStockDisplay = ({
{showColor && <Badge color={filamentStock.filament.color} />}
<Text>{`${filamentStock.filament?.name} (${filamentStock.currentNetWeight}g)`}</Text>
{showId && (
<IdText
<IdDisplay
id={filamentStock._id}
longId={longId}
type={'filamentstock'}

View File

@ -1,24 +1,16 @@
// PrinterSelect.js
import React from 'react'
import PropTypes from 'prop-types'
import {
Flex,
Typography,
Button,
Tooltip,
message,
Space,
Popover
} from 'antd'
import { Flex, Typography, Space, Popover } from 'antd'
import { useNavigate } from 'react-router-dom'
import { useMediaQuery } from 'react-responsive'
import CopyIcon from '../../Icons/CopyIcon'
import CopyButton from './CopyButton'
import SpotlightTooltip from './SpotlightTooltip'
import { getTypeMeta } from '../utils/Utils'
const { Text, Link } = Typography
const IdText = ({
const IdDisplay = ({
id,
type,
showCopy = true,
@ -26,7 +18,6 @@ const IdText = ({
showHyperlink = false,
showSpotlight = true
}) => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
@ -36,6 +27,10 @@ const IdText = ({
const IconComponent = meta.icon
const icon = <IconComponent style={{ paddingTop: '4px' }} />
if (!id) {
return <Text type='secondary'>n/a</Text>
}
id = typeof id === 'string' ? id.toString().toUpperCase() : 'XXXXXX'
var displayId = prefix + ':' + id
var copyId = prefix + ':' + id
@ -45,9 +40,7 @@ const IdText = ({
}
return (
<Flex align={'center'} gap={'small'} className='idtext'>
{contextHolder}
<Flex align={'center'} className='iddisplay'>
{showHyperlink &&
(showSpotlight ? (
<Popover
@ -67,6 +60,7 @@ const IdText = ({
navigate(hyperlink)
}
}}
style={{ marginRight: 6 }}
>
<Text code ellipsis>
<Space size={4}>
@ -105,7 +99,7 @@ const IdText = ({
placement='topLeft'
arrow={false}
>
<Text code ellipsis>
<Text code ellipsis style={{ marginRight: 6 }}>
<Space size={4}>
{icon}
{displayId}
@ -121,59 +115,18 @@ const IdText = ({
</Text>
))}
{showCopy && (
<Tooltip title='Copy ID' arrow={false}>
<Button
icon={<CopyIcon style={{ fontSize: '14px' }} />}
type='text'
style={{ height: '22px' }}
onClick={() => {
const doCopy = (text) => {
if (
navigator &&
navigator.clipboard &&
navigator.clipboard.writeText
) {
navigator.clipboard
.writeText(text)
.then(() => {
messageApi.success('ID copied to clipboard')
})
.catch(() => {
messageApi.error('Failed to copy ID')
})
} else if (
document.queryCommandSupported &&
document.queryCommandSupported('copy')
) {
// Legacy fallback
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
messageApi.success('ID copied to clipboard')
} catch (err) {
messageApi.error('Failed to copy ID')
}
document.body.removeChild(textarea)
} else {
messageApi.error('Copy not supported in this browser')
}
}
doCopy(copyId)
}}
<CopyButton
text={copyId}
tooltip='Copy ID'
style={{ marginLeft: 0 }}
iconStyle={{ fontSize: '14px' }}
/>
</Tooltip>
)}
</Flex>
)
}
IdText.propTypes = {
IdDisplay.propTypes = {
id: PropTypes.string,
type: PropTypes.string,
showCopy: PropTypes.bool,
@ -182,4 +135,4 @@ IdText.propTypes = {
showSpotlight: PropTypes.bool
}
export default IdText
export default IdDisplay

View File

@ -2,7 +2,7 @@ import PropTypes from 'prop-types'
import { Progress, Flex, Typography, Space } from 'antd'
import React, { useState, useContext, useEffect } from 'react'
import { PrintServerContext } from '../context/PrintServerContext'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import StateTag from './StateTag'
const JobState = ({
@ -38,7 +38,7 @@ const JobState = ({
return (
<Flex gap='small' align={'center'}>
{showId && (
<IdText id={job._id} showCopy={false} type='job' longId={false} />
<IdDisplay id={job._id} showCopy={false} type='job' longId={false} />
)}
{showQuantity && <Text>({job.quantity})</Text>}
{showStatus && (

View File

@ -0,0 +1,50 @@
import React from 'react'
import { Spin, Descriptions } from 'antd'
import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty'
const ObjectInfo = ({
loading = false,
indicator = null,
bordered = true,
isEditing = false,
items = []
}) => {
// 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} />
}
})
return (
<Spin spinning={loading} indicator={indicator}>
<Descriptions
bordered={bordered}
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
items={descriptionItems}
/>
</Spin>
)
}
ObjectInfo.propTypes = {
loading: PropTypes.bool,
indicator: PropTypes.node,
bordered: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object),
isEditing: PropTypes.bool,
type: PropTypes.string.isRequired
}
export default ObjectInfo

View File

@ -0,0 +1,465 @@
import React from 'react'
import PropTypes from 'prop-types'
import {
Typography,
Badge,
Input,
InputNumber,
Form,
Select,
DatePicker,
Switch
} from 'antd'
import VendorSelect from './VendorSelect'
import FilamentSelect from './FilamentSelect'
import IdDisplay from './IdDisplay'
import TimeDisplay from './TimeDisplay'
import dayjs from 'dayjs'
import PrinterSelect from './PrinterSelect'
import GCodeFileSelect from './GCodeFileSelect'
import PartSelect from './PartSelect'
import EmailDisplay from '../../Icons/EmailDisplay'
import UrlDisplay from '../../Icons/UrlDisplay'
import CountryDisplay from './CountryDisplay'
import CountrySelect from './CountrySelect'
import TagsDisplay from './TagsDisplay'
import TagsInput from './TagsInput'
import BoolDisplay from './BoolDisplay'
import PrinterState from './PrinterState'
import SubJobState from './SubJobState'
import JobState from './JobState'
import ColorSelector from './ColorSelector'
import SecretDisplay from './SecretDisplay'
import EyeIcon from '../../Icons/EyeIcon'
import EyeSlashIcon from '../../Icons/EyeSlashIcon'
const { Text } = Typography
const MATERIAL_OPTIONS = [
{ value: 'PLA', label: 'PLA' },
{ value: 'PETG', label: 'PETG' },
{ value: 'ABS', label: 'ABS' },
{ value: 'ASA', label: 'ASA' },
{ value: 'HIPS', label: 'HIPS' },
{ value: 'TPU', label: 'TPU' }
]
const ObjectProperty = ({
type = 'text',
value,
isEditing = false,
formItemProps = {},
required = false,
name,
label,
showLabel = false,
objectType = 'unknown',
readOnly = false,
...rest
}) => {
const renderProperty = () => {
console.log('Rendering')
if (!isEditing || readOnly) {
switch (type) {
case 'secret':
if (value != null) {
return <SecretDisplay value={value} {...rest} />
} else {
return <Text type='secondary'>n/a</Text>
}
case 'wsprotocol':
switch (value) {
case 'ws':
return <Text>Websocket</Text>
case 'wss':
return <Text>Websocket Secure</Text>
default:
return <Text type='secondary'>n/a</Text>
}
case 'bool': {
if (value != null) {
return <BoolDisplay value={value} yesNo={true} {...rest} />
} else {
return <Text type='secondary'>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>
}
}
case 'country': {
if (value != null) {
return <CountryDisplay countryCode={value} />
} else {
return <Text type='secondary'>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>
}
}
case 'number': {
if (value != null) {
return <Text>{value}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'text':
if (value != null && value != '') {
return <Text>{value}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
case 'email':
if (value != null && value != '') {
return <EmailDisplay email={value} />
} else {
return <Text type='secondary'>n/a</Text>
}
case 'url':
if (value != null && value != '') {
return <UrlDisplay url={value} />
} else {
return <Text type='secondary'>n/a</Text>
}
case 'object': {
if (value && value.name) {
return <Text>{value.name}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'state': {
if (value && value?.state) {
switch (objectType) {
case 'printer':
return <PrinterState printer={value} {...rest} />
case 'job':
return <JobState job={value} {...rest} />
case 'subjob':
return <SubJobState subJob={value} {...rest} />
default:
return <Text type='secondary'>n/a</Text>
}
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'material': {
if (value) {
return <Text>{value}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'id': {
if (value) {
return <IdDisplay id={value} type={objectType} {...rest} />
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'density': {
if (value != null) {
return <Text>{`${value} g/cm³`}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'mm': {
if (value != null) {
return <Text>{`${value} mm`}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'tags': {
if (value != null || value?.length != 0) {
return <TagsDisplay tags={value} />
} else {
return <Text type='secondary'>n/a</Text>
}
}
case 'version': {
if (value != null) {
return <Text>{`${value} mm`}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
}
default: {
if (value) {
return <Text>{value}</Text>
} else {
return <Text type='secondary'>n/a</Text>
}
}
}
}
// Editable mode: wrap in Form.Item
// Merge required rule if needed
let mergedFormItemProps = { ...formItemProps }
if (required) {
let rules
if (mergedFormItemProps.rules) {
rules = [...mergedFormItemProps.rules]
} else {
rules = []
}
const hasRequiredRule = rules.some((rule) => rule && rule.required)
if (!hasRequiredRule) {
rules.push({ required: true, message: 'This field is required' })
}
mergedFormItemProps.rules = rules
}
// Remove name from mergedFormItemProps if present
if (mergedFormItemProps.name) {
delete mergedFormItemProps.name
}
// If label is provided, set it on Form.Item
if (label && showLabel == true) {
mergedFormItemProps.label = label
}
// Always apply style: { margin: 0 } unless overridden
mergedFormItemProps.style = {
margin: 0,
...(mergedFormItemProps.style || {})
}
switch (type) {
case 'secret':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<Input.Password
placeholder={label}
{...mergedFormItemProps}
iconRender={(visible) =>
visible ? <EyeSlashIcon /> : <EyeIcon />
}
/>
</Form.Item>
)
case 'wsprotocol':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
]}
/>
</Form.Item>
)
case 'bool':
return (
<Form.Item
name={name}
{...mergedFormItemProps}
valuePropName='checked'
>
<Switch />
</Form.Item>
)
case 'dateTime':
return (
<Form.Item
name={name}
{...mergedFormItemProps}
getValueProps={(v) => ({ value: v ? dayjs(v) : null })}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
)
case 'currency':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<InputNumber
prefix='£'
suffix='/kg'
style={{ width: '100%' }}
placeholder={label}
/>
</Form.Item>
)
case 'country':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<CountrySelect />
</Form.Item>
)
case 'color':
return (
<Form.Item
name={name}
{...mergedFormItemProps}
valuePropName='value'
getValueFromEvent={(v) => v}
>
<ColorSelector required={required} />
</Form.Item>
)
case 'weight':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<InputNumber
suffix='g'
style={{ width: '100%' }}
placeholder={label}
/>
</Form.Item>
)
case 'number':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<InputNumber
style={{ width: '100%' }}
placeholder={label}
{...mergedFormItemProps}
/>
</Form.Item>
)
case 'text':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<Input placeholder={label} {...mergedFormItemProps} />
</Form.Item>
)
case 'material':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<Select options={MATERIAL_OPTIONS} placeholder={label} />
</Form.Item>
)
case 'id':
// id is not editable, just show view mode
if (value) {
return <IdDisplay id={value} type={objectType} {...rest} />
} else {
return <Text type='secondary'>n/a</Text>
}
case 'object':
switch (objectType) {
case 'vendor':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<VendorSelect placeholder={label} />
</Form.Item>
)
case 'printer':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<PrinterSelect placeholder={label} />
</Form.Item>
)
case 'gcodefile':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<GCodeFileSelect placeholder={label} />
</Form.Item>
)
case 'filament':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<FilamentSelect placeholder={label} />
</Form.Item>
)
case 'part':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<PartSelect placeholder={label} />
</Form.Item>
)
default:
return <Text type='secondary'>n/a</Text>
}
case 'density':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<InputNumber
suffix='g/cm³'
style={{ width: '100%' }}
placeholder={label}
/>
</Form.Item>
)
case 'mm':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<InputNumber
suffix='mm'
style={{ width: '100%' }}
placeholder={label}
/>
</Form.Item>
)
case 'tags':
return (
<Form.Item name={name} {...mergedFormItemProps}>
<TagsInput />
</Form.Item>
)
default:
return (
<Form.Item name={name} {...mergedFormItemProps}>
<Input placeholder={label} {...mergedFormItemProps} />
</Form.Item>
)
}
}
const property = renderProperty()
// Render the property directly (remove useDescriptions functionality)
return property
}
ObjectProperty.propTypes = {
type: PropTypes.oneOf([
'text',
'number',
'currency',
'color',
'weight',
'vendor',
'material',
'id',
'density',
'mm'
]),
value: PropTypes.any,
isEditing: PropTypes.bool,
formItemProps: PropTypes.object,
required: PropTypes.bool,
name: PropTypes.string,
label: PropTypes.string,
showLabel: PropTypes.bool,
objectType: PropTypes.string.isRequired,
readOnly: PropTypes.bool
}
ObjectProperty.defaultProps = {}
export default ObjectProperty

View File

@ -1,10 +1,11 @@
import React, { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import { TreeSelect, Typography, Flex, Badge } from 'antd'
import { TreeSelect, Typography, Flex, Badge, Space, Button, Input } from 'antd'
import axios from 'axios'
import { getTypeMeta } from '../utils/Utils'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import CountryDisplay from './CountryDisplay'
import ReloadIcon from '../../Icons/ReloadIcon'
const { Text } = Typography
const { SHOW_CHILD } = TreeSelect
/**
@ -121,7 +122,7 @@ const ObjectSelect = ({
{Icon && <Icon />}
{item?.color && <Badge color={item.color}></Badge>}
<Text ellipsis>{item.name || type.title}</Text>
<IdText id={item._id} longId={false} type={type} />
<IdDisplay id={item._id} longId={false} type={type} />
</Flex>
)
},
@ -139,7 +140,6 @@ const ObjectSelect = ({
// Build category nodes for each property level and load all available options
for (let i = 0; i < propertyOrder.length; i++) {
const propertyName = propertyOrder[i]
console.log('propname', propertyName)
let propertyValue
// Handle nested property access (e.g., 'filament.diameter')
@ -342,9 +342,6 @@ const ObjectSelect = ({
value = item
}
const title = renderTitle({ ...item, value }, isLeaf)
console.log('propname', propertyName)
console.log('value', value)
console.log(item)
return {
id: value,
pId: node.id,
@ -401,7 +398,6 @@ const ObjectSelect = ({
onChange(node ? node.raw : val, selectedOptions)
}
}
console.log('val', val)
setDefaultValue(val)
}
@ -486,17 +482,18 @@ const ObjectSelect = ({
])
return error ? (
<div style={{ color: 'red', padding: 8 }}>
Failed to load data.{' '}
<button
<Space.Compact style={{ width: '100%' }}>
<Input value='Failed to load data.' status='error' disabled />
<Button
icon={<ReloadIcon />}
onClick={() => {
setError(false)
setTreeData([])
}}
>
Retry
</button>
</div>
danger
/>
</Space.Compact>
) : (
<TreeSelect
treeDataSimpleMode
@ -505,7 +502,7 @@ const ObjectSelect = ({
treeData={treeData}
onChange={handleOnChange}
loading={loading}
value={defaultValue}
value={loading ? 'Loading...' : defaultValue}
showSearch={showSearch}
onSearch={showSearch ? handleSearch : undefined}
treeCheckable={treeCheckable}

View File

@ -2,7 +2,7 @@ import React from 'react'
import { Table } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import PartIcon from '../../Icons/PartIcon'
import PropTypes from 'prop-types'
@ -28,7 +28,9 @@ const PartsTable = ({ data = [], loading = false, showHeader = false }) => {
dataIndex: '_id',
key: 'id',
width: 180,
render: (text) => <IdText id={text} type={'part'} showHyperlink={true} />
render: (text) => (
<IdDisplay id={text} type={'part'} showHyperlink={true} />
)
}
]

View File

@ -41,7 +41,7 @@ const PrinterMovementPanel = ({ printerId }) => {
const handleHomeAxisClick = (axis) => {
if (printServer) {
console.log('Homeing Axis:', axis)
logger.debug('Homeing Axis:', axis)
printServer.emit('printer.gcode.script', {
printerId,
script: `G28 ${axis}`
@ -52,7 +52,7 @@ const PrinterMovementPanel = ({ printerId }) => {
const handleMoveAxisClick = (axis, minus) => {
const distanceValue = !minus ? posValue * -1 : posValue
if (printServer) {
console.log('Moving Axis:', axis, distanceValue)
logger.debug('Moving Axis:', axis, distanceValue)
printServer.emit('printer.gcode.script', {
printerId,
script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}`

View File

@ -12,7 +12,7 @@ const PrinterState = ({
printer,
showProgress = true,
showStatus = true,
showPrinterName = true,
showName = true,
showControls = true
}) => {
const { printServer } = useContext(PrintServerContext)
@ -43,7 +43,7 @@ const PrinterState = ({
return (
<Flex gap='small' align={'center'}>
{showPrinterName && <Text>{printer.name}</Text>}
{showName && <Text>{printer.name}</Text>}
{showStatus && (
<Space>
<StateTag state={currentState.type} />
@ -122,7 +122,7 @@ PrinterState.propTypes = {
}),
showProgress: PropTypes.bool,
showStatus: PropTypes.bool,
showPrinterName: PropTypes.bool,
showName: PropTypes.bool,
showControls: PropTypes.bool
}

View File

@ -97,14 +97,12 @@ const PrinterTemperaturePanel = ({
}
}
if (printServer?.connected == true) {
console.log('Printer Temperature Panel is subscribing...')
printServer.emit('printer.objects.subscribe', params)
printServer.emit('printer.objects.query', params)
printServer.on('notify_status_update', notifyTemperatureStatusUpdate)
}
return () => {
if (printServer && shouldUnsubscribe == true) {
console.log('Printer Temperature Panel is unsubscribing...')
printServer.off('notify_status_update', notifyTemperatureStatusUpdate)
printServer.emit('printer.objects.unsubscribe', params)
}
@ -113,7 +111,6 @@ const PrinterTemperaturePanel = ({
const handleSetTemperatureClick = (target, value) => {
if (printServer) {
console.log('printer.gcode.script', target, value)
printServer.emit('printer.gcode.script', {
printerId,
script: `SET_HEATER_TEMPERATURE HEATER=${target} TARGET=${value}`

View File

@ -0,0 +1,50 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { Typography, Tooltip, Button } from 'antd'
import CopyButton from './CopyButton'
import EyeIcon from '../../Icons/EyeIcon'
import EyeSlashIcon from '../../Icons/EyeSlashIcon'
const { Text } = Typography
const SecretDisplay = ({ value, reveal = false }) => {
const [visible, setVisible] = useState(false)
if (!value) {
return <Text type='secondary'>n/a</Text>
}
const masked = '•'.repeat(Math.max(8, value.length))
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Text code>{reveal && visible ? value : masked}</Text>
{reveal && (
<Tooltip title={visible ? 'Hide' : 'Show'} arrow={false}>
<Button
type='text'
icon={visible ? <EyeSlashIcon /> : <EyeIcon />}
onClick={() => setVisible((v) => !v)}
size='small'
aria-label={visible ? 'Hide secret' : 'Show secret'}
/>
</Tooltip>
)}
{reveal && value && (
<CopyButton
text={value}
tooltip='Copy Secret'
style={{ marginLeft: 0 }}
iconStyle={{ fontSize: '14px' }}
/>
)}
</span>
)
}
SecretDisplay.propTypes = {
value: PropTypes.string,
reveal: PropTypes.bool
}
export default SecretDisplay

View File

@ -15,7 +15,7 @@ import React, { useEffect, useState, useContext, useCallback } from 'react'
import axios from 'axios'
import { AuthContext } from '../context/AuthContext'
import config from '../../../config'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import TimeDisplay from './TimeDisplay'
import { Tag } from 'antd'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
@ -93,7 +93,7 @@ const SpotlightTooltip = ({ query, type }) => {
const renderValue = (key, value) => {
if (key === '_id' || key === 'id') {
return (
<IdText
<IdDisplay
id={value}
type={type}
showCopy={true}
@ -108,7 +108,7 @@ const SpotlightTooltip = ({ query, type }) => {
<PrinterState
printer={spotlightData}
showControls={false}
showPrinterName={false}
showName={false}
/>
)
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useContext, useState } from 'react'
import { Table, Typography } from 'antd'
import PropTypes from 'prop-types'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import { AuditOutlined } from '@ant-design/icons'
import { PrintServerContext } from '../context/PrintServerContext'
import moment from 'moment'
@ -22,7 +22,6 @@ const StockEventTable = ({ stockEvents }) => {
if (printServer && !initialized) {
setInitialized(true)
printServer.on('notify_stockevent_update', (updateData) => {
console.log('Received stock event update:', updateData)
setStockEventsData((prevData) => {
return prevData.map((stockEvent) => {
if (stockEvent?._id) {
@ -42,7 +41,6 @@ const StockEventTable = ({ stockEvents }) => {
return () => {
if (printServer && initialized) {
console.log('Deregistering stock event update listener')
printServer.off('notify_stockevent_update')
}
}
@ -138,7 +136,7 @@ const StockEventTable = ({ stockEvents }) => {
render: (record) => {
if (record.subJob) {
return (
<IdText
<IdDisplay
id={record.subJob.number.toString().padStart(6, '0')}
longId={false}
type={'subjob'}
@ -147,7 +145,7 @@ const StockEventTable = ({ stockEvents }) => {
}
if (record.stockAudit) {
return (
<IdText
<IdDisplay
id={record.stockAudit._id}
longId={false}
type={'stockaudit'}
@ -164,7 +162,7 @@ const StockEventTable = ({ stockEvents }) => {
render: (record) => {
if (record.subJob) {
return (
<IdText
<IdDisplay
id={record.job._id}
longId={false}
type={'job'}

View File

@ -23,11 +23,9 @@ const SubJobCounter = ({
useEffect(() => {
if (printServer && !initialized && job?.id) {
setInitialized(true)
console.log('on notify_subjob_update')
printServer.on('notify_subjob_update', (statusUpdate) => {
for (const subJob of job.subJobs) {
if (statusUpdate?._id === subJob.id && statusUpdate?.state) {
console.log('statusUpdate', statusUpdate)
setSubJobs((prev) => [...prev, statusUpdate])
}
}
@ -35,7 +33,6 @@ const SubJobCounter = ({
}
return () => {
if (printServer && initialized) {
console.log('off notify_subjob_update')
printServer.off('notify_subjob_update')
}
}

View File

@ -3,11 +3,15 @@ import { Progress, Flex, Button, Space, Tooltip } from 'antd' // eslint-disable-
import { CaretLeftOutlined } from '@ant-design/icons' // eslint-disable-line
import React, { useState, useContext, useEffect } from 'react'
import { PrintServerContext } from '../context/PrintServerContext'
import IdText from './IdText'
import IdDisplay from './IdDisplay'
import StateTag from './StateTag'
import XMarkIcon from '../../Icons/XMarkIcon'
import PauseIcon from '../../Icons/PauseIcon'
import BinIcon from '../../Icons/BinIcon'
import config from '../../../config'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('SubJobState')
logger.setLevel(config.logLevel)
const SubJobState = ({
subJob,
@ -28,17 +32,17 @@ const SubJobState = ({
useEffect(() => {
if (printServer && !initialized && subJob?._id) {
setInitialized(true)
console.log('on notify_subjob_update')
logger.debug('on notify_subjob_update')
printServer.on('notify_subjob_update', (statusUpdate) => {
if (statusUpdate?._id === subJob._id && statusUpdate?.state) {
console.log('statusUpdate', statusUpdate)
logger.debug('statusUpdate', statusUpdate)
setCurrentState(statusUpdate.state)
}
})
}
return () => {
if (printServer && initialized) {
console.log('off notify_subjob_update')
logger.debug('off notify_subjob_update')
printServer.off('notify_subjob_update')
}
}
@ -47,7 +51,12 @@ const SubJobState = ({
return (
<Flex gap='small' align={'center'}>
{showId && (
<IdText id={subJob._id} showCopy={false} type='subjob' longId={false} />
<IdDisplay
id={subJob._id}
showCopy={false}
type='subjob'
longId={false}
/>
)}
{showStatus && (
<Space>
@ -134,7 +143,7 @@ const SubJobState = ({
<Tooltip title='Delete' arrow={false}>
<Button
onClick={() => {
console.log('Hello')
logger.debug('Hello')
}}
type='text'
style={{ height: 'unset' }}

View File

@ -114,7 +114,7 @@ const SubJobsTree = ({ jobData, loading }) => {
// Add printServer.io event listener for deployment updates
if (printServer) {
printServer.on('notify_deployment_update', (updateData) => {
console.log('Received deployment update:', updateData)
logger.debug('Received deployment update:', updateData)
setCurrentJobData((prevData) => {
if (!prevData) return prevData
@ -151,7 +151,7 @@ const SubJobsTree = ({ jobData, loading }) => {
printServer.on('notify_subjob_update', (updateData) => {
// Handle sub-job updates
if (updateData.subJobId) {
console.log('Received subjob update:', updateData)
logger.debug('Received subjob update:', updateData)
setCurrentJobData((prevData) => {
if (!prevData) return prevData
return {

View File

@ -0,0 +1,28 @@
import React from 'react'
import { Tag, Space, Typography } from 'antd'
import PropTypes from 'prop-types'
const { Text } = Typography
const TagsDisplay = ({ tags = [], style }) => {
if (tags.length == 0) {
return <Text type='secondary'>n/a</Text>
}
return (
<Space size={'small'} wrap style={style}>
{tags.map((tag, index) => (
<Tag key={index} color='blue' style={{ margin: 0 }}>
{tag}
</Tag>
))}
</Space>
)
}
TagsDisplay.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string),
style: PropTypes.object
}
export default TagsDisplay

View File

@ -0,0 +1,56 @@
import React, { useState } from 'react'
import { Space, Tag, Input, Button } from 'antd'
import PlusIcon from '../../Icons/PlusIcon'
import PropTypes from 'prop-types'
const TagsInput = ({ value = [], onChange }) => {
const [inputValue, setInputValue] = useState('')
const handleTagClose = (removedTag) => {
const newTags = value.filter((tag) => tag !== removedTag)
onChange && onChange(newTags)
}
const handleTagAdd = () => {
const newTag = inputValue.trim()
if (newTag && !value.includes(newTag)) {
const newTags = [...value, newTag]
onChange && onChange(newTags)
setInputValue('')
}
}
return (
<>
<Space size={'small'} wrap style={{ marginBottom: 4, maxWidth: '300px' }}>
{value.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12, marginRight: 0 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Input
placeholder='Add new tag'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={handleTagAdd}
/>
<Button onClick={handleTagAdd} icon={<PlusIcon />} />
</Space.Compact>
</>
)
}
TagsInput.propTypes = {
value: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func
}
export default TagsInput

View File

@ -55,6 +55,10 @@ const TimeDisplay = ({
}
}, [dateTime, showSince])
if (!dateTime) {
return <Text type='secondary'>n/a</Text>
}
var dateFormat = ''
if (showDate == true) {
dateFormat += 'YYYY-MM-DD '

View File

@ -0,0 +1,55 @@
import React from 'react'
import { Button, Popover, Checkbox, Flex } from 'antd'
import PropTypes from 'prop-types'
const ViewButton = ({
loading = false,
sections = [],
collapseState = {},
updateCollapseState = () => {},
...buttonProps
}) => {
return (
<Popover
content={(() => {
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>
)
})()}
placement='bottomLeft'
arrow={false}
>
<Button disabled={loading} {...buttonProps}>
View
</Button>
</Popover>
)
}
ViewButton.propTypes = {
loading: PropTypes.bool,
sections: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
label: PropTypes.string.isRequired
})
),
collapseState: PropTypes.object,
updateCollapseState: PropTypes.func
}
export default ViewButton

View File

@ -10,13 +10,14 @@ import io from 'socket.io-client'
import { message, notification, Modal, Space, Button } from 'antd'
import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext'
import config from '../../../config'
import loglevel from 'loglevel'
import axios from 'axios'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
const log = loglevel.getLogger('Api Server')
log.setLevel(config.logLevel)
import config from '../../../config'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel)
const ApiServerContext = createContext()
@ -34,7 +35,7 @@ const ApiServerProvider = ({ children }) => {
useEffect(() => {
if (token) {
log.debug('Token is available, connecting to api server...')
logger.debug('Token is available, connecting to api server...')
const newSocket = io(config.apiServerUrl, {
reconnectionAttempts: 3,
@ -45,18 +46,18 @@ const ApiServerProvider = ({ children }) => {
setConnecting(true)
newSocket.on('connect', () => {
log.debug('Api Server connected')
logger.debug('Api Server connected')
setConnecting(false)
setError(null)
})
newSocket.on('disconnect', () => {
log.debug('Api Server disconnected')
logger.debug('Api Server disconnected')
setError('Api Server disconnected')
})
newSocket.on('connect_error', (err) => {
log.error('Api Server connection error:', err)
logger.error('Api Server connection error:', err)
messageApi.error('Api Server connection error: ' + err.message)
setError('Api Server connection error')
})
@ -69,7 +70,7 @@ const ApiServerProvider = ({ children }) => {
})
newSocket.on('error', (err) => {
log.error('Api Server error:', err)
logger.error('Api Server error:', err)
setError('Api Server error')
})
@ -78,37 +79,37 @@ const ApiServerProvider = ({ children }) => {
// Clean up function
return () => {
if (socketRef.current) {
log.debug('Cleaning up api server connection...')
logger.debug('Cleaning up api server connection...')
socketRef.current.disconnect()
socketRef.current = null
}
}
} else if (!token && socketRef.current) {
log.debug('Token not available, disconnecting api server...')
logger.debug('Token not available, disconnecting api server...')
socketRef.current.disconnect()
socketRef.current = null
}
}, [token, messageApi])
const lockObject = (id, type) => {
log.debug('Locking ' + id)
logger.debug('Locking ' + id)
if (socketRef.current && socketRef.current.connected) {
socketRef.current.emit('lock', { _id: id, type: type })
log.debug('Sent lock command for object:', id)
logger.debug('Sent lock command for object:', id)
}
}
const unlockObject = (id, type) => {
log.debug('Unlocking ' + id)
logger.debug('Unlocking ' + id)
if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.emit('unlock', { _id: id, type: type })
log.debug('Sent unlock command for object:', id)
logger.debug('Sent unlock command for object:', id)
}
}
const fetchObjectLock = async (id, type) => {
if (socketRef.current && socketRef.current.connected == true) {
log.debug('Fetching lock status for ' + id)
logger.debug('Fetching lock status for ' + id)
return new Promise((resolve) => {
socketRef.current.emit(
'getLock',
@ -117,11 +118,11 @@ const ApiServerProvider = ({ children }) => {
type: type
},
(lockEvent) => {
log.debug('Received lock event for object:', id, lockEvent)
logger.debug('Received lock event for object:', id, lockEvent)
resolve(lockEvent)
}
)
log.debug('Sent fetch lock command for object:', id)
logger.debug('Sent fetch lock command for object:', id)
})
}
}
@ -130,7 +131,7 @@ const ApiServerProvider = ({ children }) => {
if (socketRef.current && socketRef.current.connected == true) {
const eventHandler = (data) => {
if (data._id === id && data?.user !== userProfile._id) {
log.debug(
logger.debug(
'Lock update received for object:',
id,
'locked:',
@ -141,7 +142,7 @@ const ApiServerProvider = ({ children }) => {
}
socketRef.current.on('notify_lock_update', eventHandler)
log.debug('Registered lock event listener for object:', id)
logger.debug('Registered lock event listener for object:', id)
// Return cleanup function
return () => offLockEvent(id, eventHandler)
@ -151,7 +152,7 @@ const ApiServerProvider = ({ children }) => {
const offLockEvent = (id, eventHandler) => {
if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.off('notify_lock_update', eventHandler)
log.debug('Removed lock event listener for object:', id)
logger.debug('Removed lock event listener for object:', id)
}
}
@ -159,7 +160,7 @@ const ApiServerProvider = ({ children }) => {
if (socketRef.current && socketRef.current.connected == true) {
const eventHandler = (data) => {
if (data._id === id && data?.user !== userProfile._id) {
log.debug(
logger.debug(
'Update event received for object:',
id,
'updatedAt:',
@ -170,7 +171,7 @@ const ApiServerProvider = ({ children }) => {
}
socketRef.current.on('notify_object_update', eventHandler)
log.debug('Registered update event listener for object:', id)
logger.debug('Registered update event listener for object:', id)
// Return cleanup function
return () => offUpdateEvent(id, eventHandler)
@ -180,7 +181,7 @@ const ApiServerProvider = ({ children }) => {
const offUpdateEvent = (id, eventHandler) => {
if (socketRef.current && socketRef.current.connected == true) {
socketRef.current.off('notify_update', eventHandler)
log.debug('Removed update event listener for object:', id)
logger.debug('Removed update event listener for object:', id)
}
}
@ -203,7 +204,7 @@ const ApiServerProvider = ({ children }) => {
const fetchObjectInfo = async (id, type) => {
const fetchUrl = `${config.backendUrl}/${type}s/${id}`
setFetchLoading(true)
log.debug('Fetching from ' + fetchUrl)
logger.debug('Fetching from ' + fetchUrl)
try {
const response = await axios.get(fetchUrl, {
headers: {
@ -213,7 +214,7 @@ const ApiServerProvider = ({ children }) => {
})
return response.data
} catch (err) {
log.error('Failed to fetch object information:', err)
logger.error('Failed to fetch object information:', err)
// Don't automatically show error - let the component handle it
throw err
} finally {
@ -224,7 +225,7 @@ const ApiServerProvider = ({ children }) => {
// Update filament information
const updateObjectInfo = async (id, type, value) => {
const updateUrl = `${config.backendUrl}/${type}s/${id}`
log.debug('Updating info for ' + id)
logger.debug('Updating info for ' + id)
try {
const response = await axios.put(updateUrl, value, {
headers: {
@ -232,7 +233,7 @@ const ApiServerProvider = ({ children }) => {
},
withCredentials: true
})
log.debug('Filament updated successfully')
logger.debug('Filament updated successfully')
if (socketRef.current && socketRef.current.connected == true) {
await socketRef.current.emit('update', {
_id: id,
@ -242,7 +243,7 @@ const ApiServerProvider = ({ children }) => {
}
return response.data
} catch (err) {
log.error('Failed to update filament information:', err)
logger.error('Failed to update filament information:', err)
// Don't automatically show error - let the component handle it
throw err
}

View File

@ -7,6 +7,9 @@ import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import config from '../../../config'
import AppError from '../../App/AppError'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel)
const AuthContext = createContext()
@ -50,7 +53,7 @@ const AuthProvider = ({ children }) => {
})
if (response.status === 200 && response.data) {
console.log('User is authenticated!')
logger.debug('User is authenticated!')
setAuthenticated(true)
setToken(response.data.access_token)
setExpiresAt(response.data.expires_at)
@ -60,7 +63,7 @@ const AuthProvider = ({ children }) => {
setAuthError('Failed to authenticate user.')
}
} catch (error) {
console.log('Auth check failed', error)
logger.debug('Auth check failed', error)
if (error.response?.status === 401) {
setShowUnauthorizedModal(true)
} else {

View File

@ -16,7 +16,7 @@ import PropTypes from 'prop-types'
import { useNavigate } from 'react-router-dom'
import PrinterState from '../common/PrinterState'
import JobState from '../common/JobState'
import IdText from '../common/IdText'
import IdDisplay from '../common/IdDisplay'
import config from '../../../config'
import { getTypeMeta, getPrefixMeta } from '../utils/Utils'
@ -243,7 +243,6 @@ const SpotlightProvider = ({ children }) => {
if (!value || value.trim() === '') {
// Only clear the prefix if the input is completely empty
if (value === '') {
console.log('Clearing prefix')
setInputPrefix(null)
}
if (formRef.current) {
@ -278,7 +277,6 @@ const SpotlightProvider = ({ children }) => {
const handleKeyDown = (e) => {
// If backspace is pressed and there's a prefix but the input is empty
if (e.key === 'Backspace' && inputPrefix && query === '') {
console.log('Clearing prefix on backspace')
// Clear the prefix
setInputPrefix(null)
// Prevent the default backspace behavior in this case
@ -462,7 +460,6 @@ const SpotlightProvider = ({ children }) => {
// Add more inference as needed
}
const meta = getTypeMeta(type)
console.log('meta', inputPrefix?.type)
const Icon = meta.icon
// Determine shortcut text
@ -489,7 +486,7 @@ const SpotlightProvider = ({ children }) => {
{meta.type == 'printer' ? (
<PrinterState
printer={item}
showPrinterName={false}
showName={false}
showProgress={false}
showId={false}
/>
@ -520,7 +517,7 @@ const SpotlightProvider = ({ children }) => {
/>
</Flex>
) : null}
<IdText
<IdDisplay
id={item._id}
type={meta.type}
longId={false}

View File

@ -1,4 +1,8 @@
import { useCallback } from 'react'
import config from '../../../config'
import loglevel from 'loglevel'
const logger = loglevel.getLogger('useTableScroll')
logger.setLevel(config.logLevel)
export const useTableScroll = ({
lazyLoading,
@ -27,7 +31,7 @@ export const useTableScroll = ({
!lazyLoading &&
hasMore
) {
console.log(loadedPages)
logger.debug(loadedPages)
const lowestPage = Math.max(...loadedPages)
const nextPage = lowestPage + 1
if (!loadingPages.has(nextPage)) {
@ -39,7 +43,7 @@ export const useTableScroll = ({
items: page.items.filter((item) => !item.isSkeleton)
}))
const relevantPages = filteredPages.slice(-2)
console.log('Pages after scroll down:', {
logger.debug('Pages after scroll down:', {
current: currentLoadedPageNumber,
next: nextPage,
keeping: relevantPages.map((p) => p.pageNum)
@ -78,7 +82,7 @@ export const useTableScroll = ({
items: page.items.filter((item) => !item.isSkeleton)
}))
console.log('Pages after scroll up:', {
logger.debug('Pages after scroll up:', {
current: currentLoadedPageNumber,
prev: prevPage,
keeping: relevantPages.map((p) => p.pageNum)

View File

@ -55,14 +55,32 @@ export const TYPE_META = [
title: 'Printer',
prefix: 'PRN',
icon: PrinterIcon,
url: (id) => `/dashboard/production/printers/info?printerId=${id}`
url: (id) => `/dashboard/production/printers/info?printerId=${id}`,
properties: {
name: 'text'
}
},
{
type: 'filament',
title: 'Filament',
prefix: 'FIL',
icon: FilamentIcon,
url: (id) => `/dashboard/management/filaments/info?filamentId=${id}`
url: (id) => `/dashboard/management/filaments/info?filamentId=${id}`,
properties: {
id: 'id',
createdAt: 'dateTime',
name: 'text',
updatedAt: 'dateTime',
vendor: 'object', // objectType: vendor
vendorId: 'id', // objectType: vendor
type: 'material',
cost: 'currency',
color: 'color',
diameter: 'mm',
density: 'density',
url: 'text',
barcode: 'text'
}
},
{
type: 'spool',
@ -104,7 +122,18 @@ export const TYPE_META = [
title: 'Vendor',
prefix: 'VEN',
icon: VendorIcon,
url: (id) => `/dashboard/management/vendors/info?vendorId=${id}`
url: (id) => `/dashboard/management/vendors/info?vendorId=${id}`,
properties: {
id: 'id', // objectType: vendor
createdAt: 'dateTime',
name: 'text',
updatedAt: 'dateTime',
website: 'url',
country: 'country',
contact: 'text',
phone: 'text',
email: 'email'
}
},
{
type: 'subjob',

View File

@ -0,0 +1,55 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Typography, Flex, Button, Tooltip } from 'antd'
import NewMailIcon from './NewMailIcon'
// import CopyIcon from './CopyIcon'
import CopyButton from '../Dashboard/common/CopyButton'
const { Text, Link } = Typography
const EmailDisplay = ({ email, showCopy = true, showLink = false }) => {
if (!email) return <Text type='secondary'>n/a</Text>
return (
<>
<Flex>
{showLink ? (
<Link href={`mailto:${email}`} style={{ marginRight: 8 }}>
{email}
</Link>
) : (
<>
<Text style={{ marginRight: 8 }}>{email}</Text>
<Tooltip title='Email' arrow={false}>
<Button
icon={<NewMailIcon style={{ fontSize: '14px' }} />}
type='text'
style={{ height: '22px' }}
onClick={(e) => {
e.preventDefault()
window.location.href = `mailto:${email}`
}}
/>
</Tooltip>
</>
)}
{showCopy && (
<CopyButton
text={email}
tooltip='Copy Email'
style={{ marginLeft: 0 }}
iconStyle={{ fontSize: '14px' }}
/>
)}
</Flex>
</>
)
}
EmailDisplay.propTypes = {
email: PropTypes.string,
showCopy: PropTypes.bool,
showLink: PropTypes.bool
}
export default EmailDisplay

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,59 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Typography, Flex, Button, Tooltip } from 'antd'
import LinkIcon from './LinkIcon'
import CopyButton from '../Dashboard/common/CopyButton'
const { Text, Link } = Typography
const UrlDisplay = ({ url, showCopy = true, showLink = false }) => {
if (!url) return <Text type='secondary'>n/a</Text>
return (
<>
<Flex>
{showLink ? (
<Link
href={url}
target='_blank'
rel='noopener noreferrer'
style={{ marginRight: 8 }}
>
{url}
</Link>
) : (
<>
<Text style={{ marginRight: 8 }}>{url}</Text>
<Tooltip title='Open URL' arrow={false}>
<Button
icon={<LinkIcon style={{ fontSize: '14px' }} />}
type='text'
style={{ height: '22px' }}
onClick={(e) => {
e.preventDefault()
window.open(url, '_blank', 'noopener,noreferrer')
}}
/>
</Tooltip>
</>
)}
{showCopy && (
<CopyButton
text={url}
tooltip='Copy URL'
style={{ marginLeft: 0 }}
iconStyle={{ fontSize: '14px' }}
/>
)}
</Flex>
</>
)
}
UrlDisplay.propTypes = {
url: PropTypes.string,
showCopy: PropTypes.bool,
showLink: PropTypes.bool
}
export default UrlDisplay

View File

@ -11,6 +11,6 @@ root.render(
)
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// to log results (for example: reportWebVitals(logger.debug))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()