chore: prepare for webpack configuration

This commit is contained in:
Tom Butcher 2025-05-31 11:44:36 +01:00
parent e56a563e98
commit 4e11392f03
38 changed files with 2803 additions and 1315 deletions

738
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,9 @@
"eslint-plugin-react-hooks": "^4.6.2",
"prettier": "^3.3.3",
"prettier-eslint": "^16.3.0",
"standard": "^17.1.0"
"standard": "^17.1.0",
"svgo-loader": "^4.0.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1"
}
}

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 12 12" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.14501 0 0 .14501 .60055 -.62488)">
<path d="m50 13.094 17.219 17.531c2.5 2.563 2.969 4.313 2.969 8.344v28.375c0 6.5-3.219 9.781-9.688 9.781h-22.939c0.923-1.552 1.627-3.242 2.066-5.031h20.623c3.25 0 4.906-1.719 4.906-4.844v-28.062h-17.812c-3.906 0-5.875-1.938-5.875-5.875v-18.157h-13.906c-3.25 0-4.907 1.782-4.907 4.875v27.144c-0.816-0.122-1.652-0.175-2.5-0.175-0.858 0-1.705 0.055-2.531 0.179v-27.241c0-6.5 3.25-9.813 9.688-9.813h14.843c3.5 0 5.469 0.531 7.844 2.969zm-3.812 19.625c0 1.25 0.468 1.75 1.718 1.75h16.282l-18-18.344v16.594z" fill-rule="nonzero"/>
<g transform="matrix(6.896 0 0 6.896 .57772 4.3091)">
<path d="m5.141 9.114c0 1.255-1.056 2.302-2.302 2.302-1.264 0-2.302-1.038-2.302-2.302 0-1.265 1.038-2.302 2.302-2.302s2.302 1.037 2.302 2.302zm-3.473 0.174c0 0.782 0.448 1.252 1.209 1.252 0.677 0 1.133-0.41 1.133-1.015v-0.166c0-0.255-0.116-0.37-0.381-0.37h-0.493c-0.176 0-0.271 0.082-0.271 0.233 0 0.153 0.097 0.237 0.271 0.237h0.248v0.117c0 0.251-0.193 0.419-0.481 0.419-0.369 0-0.572-0.251-0.572-0.709v-0.331c0-0.464 0.199-0.707 0.578-0.707 0.26 0 0.41 0.156 0.573 0.319 0.058 0.058 0.118 0.085 0.197 0.085 0.159 0 0.269-0.109 0.269-0.268 0-0.158-0.119-0.331-0.299-0.462-0.201-0.15-0.476-0.235-0.788-0.235-0.739 0-1.193 0.476-1.193 1.236v0.365z" fill-rule="nonzero"/>
<?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 12 12" 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.164595,0,0,0.164595,-0.128594,-1.66652)">
<path d="M50,13.094L67.219,30.625C69.719,33.188 70.188,34.938 70.188,38.969L70.188,67.344C70.188,73.844 66.969,77.125 60.5,77.125L37.561,77.125C38.484,75.573 39.188,73.883 39.627,72.094L60.25,72.094C63.5,72.094 65.156,70.375 65.156,67.25L65.156,39.188L47.344,39.188C43.438,39.188 41.469,37.25 41.469,33.313L41.469,15.156L27.563,15.156C24.313,15.156 22.656,16.938 22.656,20.031L22.656,47.175C21.84,47.053 21.004,47 20.156,47C19.298,47 18.451,47.055 17.625,47.179L17.625,19.938C17.625,13.438 20.875,10.125 27.313,10.125L42.156,10.125C45.656,10.125 47.625,10.656 50,13.094ZM46.188,32.719C46.188,33.969 46.656,34.469 47.906,34.469L64.188,34.469L46.188,16.125L46.188,32.719Z" style="fill-rule:nonzero;"/>
<g transform="matrix(6.89596,0,0,6.89596,0.577725,4.30912)">
<path d="M5.141,9.114C5.141,10.369 4.085,11.416 2.839,11.416C1.575,11.416 0.537,10.378 0.537,9.114C0.537,7.849 1.575,6.812 2.839,6.812C4.103,6.812 5.141,7.849 5.141,9.114ZM1.668,9.288C1.668,10.07 2.116,10.54 2.877,10.54C3.554,10.54 4.01,10.13 4.01,9.525L4.01,9.359C4.01,9.104 3.894,8.989 3.629,8.989L3.136,8.989C2.96,8.989 2.865,9.071 2.865,9.222C2.865,9.375 2.962,9.459 3.136,9.459L3.384,9.459L3.384,9.576C3.384,9.827 3.191,9.995 2.903,9.995C2.534,9.995 2.331,9.744 2.331,9.286L2.331,8.955C2.331,8.491 2.53,8.248 2.909,8.248C3.169,8.248 3.319,8.404 3.482,8.567C3.54,8.625 3.6,8.652 3.679,8.652C3.838,8.652 3.948,8.543 3.948,8.384C3.948,8.226 3.829,8.053 3.649,7.922C3.448,7.772 3.173,7.687 2.861,7.687C2.122,7.687 1.668,8.163 1.668,8.923L1.668,9.288Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

View File

@ -0,0 +1,21 @@
<?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(1,0,0,1,12.4182,0)">
<g transform="matrix(0.83795,0,0,0.83795,-2.8271,-3.06585)">
<path d="M53.485,22.46L53.485,69.739C53.485,76.482 49.963,80.036 43.319,80.036L10.16,80.036C3.517,80.036 0,76.482 0,69.739L0,22.46C0,15.717 3.517,12.164 10.16,12.164L10.52,12.164C10.45,12.571 10.417,12.998 10.417,13.44L10.417,16.721C10.417,17.219 10.46,17.699 10.543,18.157L10.427,18.157C7.509,18.157 5.993,19.749 5.993,22.523L5.993,69.677C5.993,72.476 7.509,74.043 10.427,74.043L43.058,74.043C45.971,74.043 47.492,72.476 47.492,69.677L47.492,22.523C47.492,19.749 45.971,18.157 43.058,18.157L42.937,18.157C43.02,17.699 43.063,17.219 43.063,16.721L43.063,13.44C43.063,12.998 43.029,12.571 42.959,12.164L43.319,12.164C49.963,12.164 53.485,15.717 53.485,22.46Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.83795,0,0,0.83795,-2.8271,-3.06585)">
<path d="M17.109,19.927L36.371,19.927C38.251,19.927 39.384,18.726 39.384,16.721L39.384,13.44C39.384,11.429 38.251,10.234 36.371,10.234L33.721,10.234C33.485,6.601 30.469,3.659 26.743,3.659C23.016,3.659 20,6.601 19.764,10.234L17.109,10.234C15.234,10.234 14.101,11.429 14.101,13.44L14.101,16.721C14.101,18.726 15.234,19.927 17.109,19.927ZM26.743,13.248C25.22,13.248 23.984,11.981 23.984,10.495C23.984,8.952 25.22,7.717 26.743,7.717C28.265,7.717 29.495,8.952 29.495,10.495C29.495,11.981 28.265,13.248 26.743,13.248Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.83795,0,0,0.83795,-2.8271,-3.06585)">
<path d="M13.473,60.281L27.067,60.281C28.27,60.281 29.263,59.294 29.263,58.085C29.263,56.882 28.301,55.9 27.067,55.9L13.473,55.9C12.244,55.9 11.251,56.882 11.251,58.085C11.251,59.294 12.27,60.281 13.473,60.281Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.83795,0,0,0.83795,-2.8271,-3.06585)">
<path d="M13.473,48.622L40.018,48.622C41.252,48.622 42.234,47.64 42.234,46.406C42.234,45.197 41.246,44.241 40.018,44.241L13.473,44.241C12.244,44.241 11.251,45.197 11.251,46.406C11.251,47.64 12.233,48.622 13.473,48.622Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.83795,0,0,0.83795,-2.8271,-3.06585)">
<path d="M13.473,37.562L40.018,37.562C41.221,37.562 42.234,36.569 42.234,35.366C42.234,34.157 41.221,33.144 40.018,33.144L13.473,33.144C12.27,33.144 11.251,34.157 11.251,35.366C11.251,36.569 12.27,37.562 13.473,37.562Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 60 60" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.95686 0 0 .95686 0 1.1133)">
<path d="m0 15.926c0 1.983 1.613 3.596 3.627 3.596h4.983v4.967c0 2.014 1.621 3.644 3.62 3.62 1.99 0 3.604-1.614 3.604-3.62v-4.967h4.966c2.007 0 3.627-1.613 3.627-3.596 0-2.006-1.62-3.627-3.627-3.627h-4.966v-4.99c0-2.007-1.621-3.628-3.604-3.628-2.006 0-3.62 1.621-3.62 3.628v4.99h-4.983c-2.014 0-3.627 1.621-3.627 3.627zm34.679 14.927 16.526-24.521c0.604-0.928 0.975-1.769 0.975-2.729 0-2.085-1.69-3.603-3.807-3.603-1.953 0-2.929 0.702-4.162 2.532l-16.935 25.384-16.962 25.079c-0.574 0.856-0.96 1.619-0.96 2.807 0 2.18 1.937 3.516 4.028 3.516 1.652 0 2.652-0.629 3.846-2.406l17.451-26.059zm2.693 14.552c0 2.006 1.613 3.627 3.62 3.627h17.204c1.982 0 3.603-1.621 3.603-3.627 0-1.983-1.621-3.62-3.603-3.62h-17.204c-2.007 0-3.62 1.637-3.62 3.62z" fill-rule="nonzero"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -93,14 +93,14 @@ const FilamentStocks = () => {
// Add WebSocket event listener for real-time updates
if (socket && !initialized) {
setInitialized(true)
socket.on('notify_filamentstock_update', (statusUpdate) => {
console.log('Received filament stock update:', statusUpdate)
socket.on('notify_filamentstock_update', (updateData) => {
console.log('Received filament stock update:', updateData)
setFilamentStocksData((prevData) => {
return prevData.map((stock) => {
if (stock._id === statusUpdate.id) {
if (stock._id === updateData._id) {
return {
...stock,
...statusUpdate
...updateData
}
}
return stock
@ -163,7 +163,12 @@ const FilamentStocks = () => {
<IdText id={text} type={'filamentstock'} longId={false} />
)
},
{
title: 'State',
key: 'state',
width: 350,
render: (record) => <FilamentStockState filamentStock={record} />
},
{
title: 'Current (g)',
dataIndex: 'currentNetWeight',
@ -182,13 +187,6 @@ const FilamentStocks = () => {
<Text ellipsis>{startingNetWeight.toFixed(2) + 'g'}</Text>
)
},
{
title: 'State',
key: 'state',
width: 350,
render: (record) => <FilamentStockState filamentStock={record} />
},
{
title: 'Created At',
dataIndex: 'createdAt',
@ -203,6 +201,20 @@ const FilamentStocks = () => {
}
}
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (updatedAt) => {
if (updatedAt) {
const formattedDate = moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',

View File

@ -8,18 +8,23 @@ import {
Button,
message,
Typography,
Flex,
Form,
Badge
Badge,
Collapse
} from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import {
LoadingOutlined,
ReloadOutlined,
CaretRightOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import moment from 'moment'
import { SocketContext } from '../../context/SocketContext'
import FilamentStockState from '../../common/FilamentStockState'
import StockEventTable from '../../common/StockEventTable'
import useCollapseState from '../../hooks/useCollapseState'
const { Title, Text } = Typography
const { Text, Title } = Typography
const FilamentStockInfo = () => {
const [filamentStockData, setFilamentStockData] = useState(null)
@ -33,6 +38,13 @@ const FilamentStockInfo = () => {
)
const [form] = Form.useForm()
const { socket } = useContext(SocketContext)
const [collapseState, updateCollapseState] = useCollapseState(
'FilamentStockInfo',
{
info: true,
events: true
}
)
useEffect(() => {
if (filamentStockId) {
@ -64,6 +76,11 @@ const FilamentStockInfo = () => {
return prevData
})
})
// Add WebSocket event listener for filament stock updates
socket.on('notify_filamentstock_update', (filamentStockUpdate) => {
console.log('GOT FILAMENT STOCK UPDATE', filamentStockUpdate)
})
}
return () => {
if (socket && initialized) {
@ -120,111 +137,142 @@ const FilamentStockInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
<Collapse
ghost
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
>
<Title level={5} style={{ margin: 0 }}>
Filament Stock Information
</Title>
</Flex>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Filament Stock Information
</Title>
}
key='1'
>
<Form
form={form}
layout='vertical'
initialValues={{
filament: filamentStockData.filament || {}
}}
>
<Descriptions bordered column={2}>
{/* Read-only fields */}
<Descriptions.Item label='ID' span={1}>
{filamentStockData.id ? (
<IdText id={filamentStockData.id} type={'filamentstock'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(filamentStockData.createdAt).format(
'YYYY-MM-DD HH:mm:ss'
)}
</Descriptions.Item>
<Form
form={form}
layout='vertical'
initialValues={{
filament: filamentStockData.filament || {}
}}
<Descriptions.Item label='State'>
<FilamentStockState filamentStock={filamentStockData} />
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(filamentStockData.updatedAt).format(
'YYYY-MM-DD HH:mm:ss'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{filamentStockData.filament ? (
<Badge
color={filamentStockData.filament.color}
text={filamentStockData.filament.name}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID' span={1}>
{filamentStockData.filament ? (
<IdText
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Gross Weight'>
{filamentStockData.currentGrossWeight ? (
<Text>
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Gross Weight'>
{filamentStockData.startingGrossWeight ? (
<Text>
{filamentStockData.startingGrossWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Net Weight'>
{filamentStockData.currentNetWeight ? (
<Text>
{filamentStockData.currentNetWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Net Weight'>
{filamentStockData.startingNetWeight ? (
<Text>
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
activeKey={collapseState.events ? ['2'] : []}
onChange={(keys) => updateCollapseState('events', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
>
<Descriptions bordered column={2}>
{/* Read-only fields */}
<Descriptions.Item label='ID' span={1}>
{filamentStockData.id ? (
<IdText id={filamentStockData.id} type={'filamentstock'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(filamentStockData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='State'>
<FilamentStockState filamentStock={filamentStockData} />
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(filamentStockData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{filamentStockData.filament ? (
<Badge
color={filamentStockData.filament.color}
text={filamentStockData.filament.name}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID' span={1}>
{filamentStockData.filament ? (
<IdText
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Gross Weight'>
{filamentStockData.currentGrossWeight ? (
<Text>
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Gross Weight'>
{filamentStockData.startingGrossWeight ? (
<Text>
{filamentStockData.startingGrossWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Net Weight'>
{filamentStockData.currentNetWeight ? (
<Text>{filamentStockData.currentNetWeight.toFixed(2) + 'g'}</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Net Weight'>
{filamentStockData.startingNetWeight ? (
<Text>
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
<Flex
align={'center'}
style={{ marginTop: 32, marginBottom: 12, minHeight: '32px' }}
>
<Title level={5} style={{ margin: 0 }}>
Filament Stock Events
</Title>
</Flex>
<StockEventTable stockEvents={filamentStockData.stockEvents} />
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Filament Stock Events
</Title>
}
key='2'
>
<StockEventTable stockEvents={filamentStockData.stockEvents} />
</Collapse.Panel>
</Collapse>
</div>
)
}

View File

@ -91,7 +91,8 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
setNextEnabled(
Boolean(unloadFilamentStockFormValues.printer) &&
!unloadFilamentStockLoading &&
currentTemperature > targetTemperature
currentTemperature + 1 > targetTemperature &&
targetTemperature != 0
)
})
.catch(() => setNextEnabled(false))
@ -144,7 +145,7 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
<>
{targetTemperature == 0 ? (
<Alert
message={'Heat the extruder to begin unloading filament.'}
message={'Heat the extruder to start unloading filament.'}
type='info'
showIcon
/>
@ -161,7 +162,7 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
) : null}
{targetTemperature > 0 &&
currentTemperature >= targetTemperature &&
currentTemperature + 1 > targetTemperature &&
filamentSensorDetected ? (
<Alert
message={'Ready to unload filament stock.'}
@ -233,21 +234,11 @@ const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
loading={unloadFilamentStockLoading}
disabled={!nextEnabled}
onClick={() => {
unloadFilamentStockForm.submit()
}}

View File

@ -1,6 +1,6 @@
// src/filaments.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
@ -14,21 +14,27 @@ import {
Modal,
message,
Dropdown,
Typography
Typography,
Checkbox,
Popover,
Input,
Spin
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
ReloadOutlined,
InfoCircleOutlined
InfoCircleOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import NewFilament from './Filaments/NewFilament'
import IdText from '../common/IdText'
import FilamentIcon from '../../Icons/FilamentIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
const { Text } = Typography
@ -56,40 +62,172 @@ const Filaments = () => {
const { styles } = useStyle()
const [filamentsData, setFilamentsData] = useState([])
const [newFilamentOpen, setNewFilamentOpen] = useState(false)
//const [newFilament, setNewFilament] = useState(null)
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext)
const fetchFilamentsData = useCallback(async () => {
try {
const response = await axios.get('http://localhost:8080/filaments', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setFilamentsData(response.data)
setLoading(false)
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (err) {
messageApi.info(err)
}
}, [messageApi])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
useEffect(() => {
// Fetch initial data
if (authenticated) {
fetchFilamentsData()
const fetchFilamentsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get('http://localhost:8080/filaments', {
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setFilamentsData((prev) => [...prev, ...newData])
} else {
setFilamentsData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (err) {
messageApi.info(err)
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchFilamentsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchFilamentsData]
)
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<CloseOutlined />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckOutlined />}
/>
</Space.Compact>
</div>
)
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const actionItems = {
items: [
{
label: 'New Filament',
key: 'newFilament',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchFilamentsData()
} else if (key === 'newFilament') {
setNewFilamentOpen(true)
}
}
}, [authenticated, fetchFilamentsData])
}
const getFilamentActionItems = (id) => {
return {
@ -123,14 +261,46 @@ const Filaments = () => {
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
fixed: 'left',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'filament'} longId={false} />
render: (text) => <IdText id={text} type={'filament'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'ID'
}),
onFilter: (value, record) =>
record._id.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Vendor',
@ -139,13 +309,45 @@ const Filaments = () => {
width: 200,
render: (vendor) => {
return vendor.name
}
},
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'vendor'
}),
onFilter: (value, record) =>
record.vendor.name.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Material',
dataIndex: 'type',
width: 90,
key: 'material'
width: 150,
key: 'material',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'material'
}),
onFilter: (value, record) =>
record.type.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Cost',
@ -154,7 +356,8 @@ const Filaments = () => {
key: 'cost',
render: (cost) => {
return <Text ellipsis>{'£' + cost + ' per kg'}</Text>
}
},
sorter: true
},
{
title: 'Colour',
@ -163,7 +366,23 @@ const Filaments = () => {
width: 120,
render: (color) => {
return <Badge color={color} text={color} />
}
},
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'color'
}),
onFilter: (value, record) =>
record.color.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Created At',
@ -177,7 +396,9 @@ const Filaments = () => {
} else {
return 'n/a'
}
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Updated At',
@ -191,7 +412,9 @@ const Filaments = () => {
} else {
return 'n/a'
}
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Actions',
@ -218,46 +441,51 @@ const Filaments = () => {
}
]
const actionItems = {
items: [
{
label: 'New Filament',
key: 'newFilament',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchFilamentsData()
} else if (key === 'newFilament') {
setNewFilamentOpen(true)
}
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'Filaments',
columns
)
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
useEffect(() => {
if (authenticated) {
fetchFilamentsData()
}
}
}, [authenticated, fetchFilamentsData])
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<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>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={filamentsData}
columns={visibleColumns}
className={styles.customTable}
columns={columns}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/>
</Flex>
<Modal

View File

@ -15,7 +15,8 @@ import {
InputNumber,
ColorPicker,
Select,
Modal
Modal,
Collapse
} from 'antd'
import {
LoadingOutlined,
@ -23,12 +24,13 @@ import {
EditOutlined,
CheckOutlined,
CloseOutlined,
ExportOutlined,
DeleteOutlined
DeleteOutlined,
CaretRightOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import moment from 'moment'
import VendorSelect from '../../common/VendorSelect'
import useCollapseState from '../../hooks/useCollapseState'
const { Title, Link } = Typography
@ -44,6 +46,13 @@ const FilamentInfo = () => {
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const navigate = useNavigate()
const [collapseState, updateCollapseState] = useCollapseState(
'FilamentInfo',
{
info: true,
details: true
}
)
useEffect(() => {
if (filamentId) {
@ -198,297 +207,286 @@ const FilamentInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Collapse
ghost
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Filament Information
</Title>
<Space>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => setIsDeleteModalOpen(true)}
loading={loading}
/>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateFilamentInfo}
loading={loading}
/>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing} />
)}
</Space>
</Flex>
}
key='1'
>
<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 || ''
}}
>
<Descriptions bordered column={2}>
<Descriptions.Item label='ID' span={1}>
{filamentData.id ? (
<IdText id={filamentData.id} type={'filament'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(filamentData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a filament name'
},
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter filament name' />
</Form.Item>
) : (
filamentData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(filamentData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</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 || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={filamentData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</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 || 'n/a'
)}
</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 ? (
`£${filamentData.cost}/kg`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Color'>
{isEditing ? (
<Form.Item
name='color'
style={{ margin: 0 }}
rules={[
{ required: true, message: 'Please select a color' }
]}
>
<ColorPicker />
</Form.Item>
) : (
<Badge color={filamentData.color} text={filamentData.color} />
)}
</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 ? (
`${filamentData.diameter}mm`
) : (
'n/a'
)}
</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 ? (
`${filamentData.density}g/cm³`
) : (
'n/a'
)}
</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>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Barcode'>
{isEditing ? (
<Form.Item name='barcode' style={{ margin: 0 }}>
<Input placeholder='Enter barcode' />
</Form.Item>
) : (
filamentData.barcode || 'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
activeKey={collapseState.details ? ['2'] : []}
onChange={(keys) => updateCollapseState('details', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Additional Details
</Title>
}
key='2'
>
{/* Add any additional details sections here */}
</Collapse.Panel>
</Collapse>
<Modal
title='Delete Filament'
open={isDeleteModalOpen}
onOk={handleDelete}
onCancel={() => setIsDeleteModalOpen(false)}
okText='Yes, Delete'
cancelText='No, Cancel'
okType='danger'
confirmLoading={loading}
>
<p>
Are you sure you want to delete this filament? This action cannot be
undone.
</p>
<p>Are you sure you want to delete this filament?</p>
</Modal>
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Filament Information
</Title>
<Space>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => setIsDeleteModalOpen(true)}
loading={loading}
/>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateFilamentInfo}
loading={loading}
></Button>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
></Button>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing} />
)}
</Space>
</Flex>
<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 || ''
}}
>
<Descriptions bordered column={2}>
{/* Read-only fields */}
<Descriptions.Item label='ID' span={1}>
{filamentData.id ? (
<IdText id={filamentData.id} type={'filament'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(filamentData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
{/* Editable fields */}
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='name'
rules={[
{ required: true, message: 'Please enter a filament name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter filament name' />
</Form.Item>
) : (
filamentData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(filamentData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</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 || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={filamentData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</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 || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{isEditing ? (
<Form.Item
name='cost'
style={{ margin: 0 }}
rules={[{ required: true, message: 'Please enter a cost' }]}
>
<InputNumber
placeholder='Enter cost'
addonBefore='£'
addonAfter='per kg'
min={0}
step={0.01}
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.cost ? (
`£${filamentData.cost} per kg`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Colour'>
{isEditing ? (
<Form.Item
name='color'
style={{ margin: 0 }}
getValueFromEvent={(color) => {
return '#' + color.toHex()
}}
>
<ColorPicker showText disabledAlpha />
</Form.Item>
) : filamentData.color ? (
<Badge color={filamentData.color} text={filamentData.color} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Diameter'>
{isEditing ? (
<Form.Item
name='diameter'
style={{ margin: 0 }}
rules={[{ required: true, message: 'Please enter a diameter' }]}
>
<InputNumber
placeholder='Enter diameter'
addonAfter='mm'
min={0}
step={0.01}
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.diameter ? (
`${filamentData.diameter}mm`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Density'>
{isEditing ? (
<Form.Item
name='density'
style={{ margin: 0 }}
rules={[{ required: true, message: 'Please enter a density' }]}
>
<InputNumber
placeholder='Enter density'
addonAfter='g/cm³'
min={0}
step={0.01}
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.density ? (
`${filamentData.density}g/cm³`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Empty Spool Weight'>
{isEditing ? (
<Form.Item
name='emptySpoolWeight'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please enter a empty spool weight'
}
]}
>
<InputNumber
placeholder='Enter empty spool weight'
addonAfter='g'
min={0}
step={0.01}
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.emptySpoolWeight ? (
`${filamentData.emptySpoolWeight}g`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='URL'>
{isEditing ? (
<Form.Item
name='url'
rules={[{ type: 'url', message: 'Please enter a valid URL' }]}
style={{ margin: 0 }}
>
<Input placeholder='Enter URL' />
</Form.Item>
) : filamentData.url ? (
<Link
href={filamentData.url}
target='_blank'
rel='noopener noreferrer'
>
{new URL(filamentData.url).hostname + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Barcode'>
{isEditing ? (
<Form.Item name='barcode' style={{ margin: 0 }}>
<Input placeholder='Enter barcode' />
</Form.Item>
) : (
filamentData.barcode || 'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</div>
)
}

View File

@ -31,10 +31,10 @@ import {
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewProduct from './Products/NewProduct'
import PartIcon from '../../Icons/PartIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
const { Text } = Typography
@ -201,13 +201,9 @@ const Parts = () => {
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [columnVisibility, setColumnVisibility] = useState(
columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'Parts',
columns
)
const { authenticated } = useContext(AuthContext)
@ -391,10 +387,7 @@ const Parts = () => {
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
setColumnVisibility((prev) => ({
...prev,
[col.key]: e.target.checked
}))
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}

View File

@ -15,20 +15,23 @@ import {
Checkbox,
InputNumber,
Switch,
Tag
Tag,
Collapse
} from 'antd'
import {
LoadingOutlined,
EditOutlined,
ReloadOutlined,
CheckOutlined,
CloseOutlined
CloseOutlined,
CaretRightOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import moment from 'moment'
import { StlViewer } from 'react-stl-viewer'
import useCollapseState from '../../hooks/useCollapseState'
const { Title } = Typography
import { StlViewer } from 'react-stl-viewer'
const PartInfo = () => {
const [partData, setPartData] = useState(null)
@ -39,6 +42,10 @@ const PartInfo = () => {
const partId = new URLSearchParams(location.search).get('partId')
const [marginOrPrice, setMarginOrPrice] = useState(false)
const [useGlobalPricing, setUseGlobalPricing] = useState(true)
const [collapseState, updateCollapseState] = useCollapseState('PartInfo', {
info: true,
preview: true
})
const [partForm] = Form.useForm()
const [partFormValues, setPartFormValues] = useState({})
@ -231,222 +238,270 @@ const PartInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
<Collapse
ghost
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
>
<Title level={5} style={{ margin: 0 }}>
Part Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateInfo}
loading={loading}
></Button>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
></Button>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing}></Button>
)}
</Space>
</Flex>
<Form
form={partForm}
layout='vertical'
onValuesChange={(changedValues) =>
setPartFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: partData.name || ''
}}
>
<Descriptions bordered column={2}>
<Descriptions.Item label='ID' span={1}>
{partData.id ? (
<IdText id={partData.id} type='part'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(partData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{ required: true, message: 'Please enter a product name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
partData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(partData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData.product.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{(
<IdText
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Part Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
</Form.Item>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
<Button icon={<EditOutlined />} onClick={startEditing} />
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData.margin &&
marginOrPrice == false &&
partData.useGlobalPricing == false ? (
partData.margin + '%'
) : partData.price &&
marginOrPrice == true &&
partData.useGlobalPricing == false ? (
'£' + partData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData.useGlobalPricing == true ? (
<Tag color='success' icon={<CheckOutlined />}>
Yes
</Tag>
) : partData.useGlobalPricing == false ? (
<Tag icon={<CloseOutlined />}>No</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
<Flex
align={'center'}
style={{ marginTop: 32, marginBottom: 12, minHeight: '32px' }}
>
<Title level={5} style={{ margin: 0 }}>
Part Preview
</Title>
</Flex>
<Card styles={{ body: { padding: '10px' } }}>
{stlLoadError ? (
<div
style={{
height: '40vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5'
</Space>
</Flex>
}
key='1'
>
<Form
form={partForm}
layout='vertical'
onValuesChange={(changedValues) =>
setPartFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: partData.name || '',
version: partData.version || '',
tags: partData.tags || []
}}
>
<Space direction='vertical' align='center'>
<CloseOutlined style={{ fontSize: '24px', color: '#ff4d4f' }} />
<Typography.Text type='danger'>{stlLoadError}</Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
)
<Descriptions bordered column={2}>
<Descriptions.Item label='ID' span={1}>
{partData.id ? (
<IdText id={partData.id} type='part'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(partData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{
required: true,
message: 'Please enter a product name'
},
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
partData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(partData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData.product.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{(
<IdText
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData.margin &&
marginOrPrice == false &&
partData.useGlobalPricing == false ? (
partData.margin + '%'
) : partData.price &&
marginOrPrice == true &&
partData.useGlobalPricing == false ? (
'£' + partData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData.useGlobalPricing == true ? (
<Tag color='success' icon={<CheckOutlined />}>
Yes
</Tag>
) : partData.useGlobalPricing == false ? (
<Tag icon={<CloseOutlined />}>No</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{partData.version || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{partData.tags &&
partData.tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
activeKey={collapseState.preview ? ['2'] : []}
onChange={(keys) => updateCollapseState('preview', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
</Card>
>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Part Preview
</Title>
}
key='2'
>
<Card styles={{ body: { padding: '10px' } }}>
{stlLoadError ? (
<div
style={{
height: '40vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5'
}}
>
<Space direction='vertical' align='center'>
<CloseOutlined
style={{ fontSize: '24px', color: '#ff4d4f' }}
/>
<Typography.Text type='danger'>
{stlLoadError}
</Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
)
)}
</Card>
</Collapse.Panel>
</Collapse>
</div>
)
}

View File

@ -14,7 +14,10 @@ import {
Dropdown,
message,
Spin,
Tag
Tag,
Checkbox,
Popover,
Input
} from 'antd'
import { createStyles } from 'antd-style'
import {
@ -22,7 +25,9 @@ import {
PlusOutlined,
DownloadOutlined,
ReloadOutlined,
InfoCircleOutlined
InfoCircleOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
@ -30,6 +35,7 @@ import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewProduct from './Products/NewProduct'
import ProductIcon from '../../Icons/ProductIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
@ -58,6 +64,8 @@ const Products = () => {
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [newProductOpen, setNewProductOpen] = useState(false)
@ -71,16 +79,19 @@ const Products = () => {
const response = await axios.get('http://localhost:8080/products', {
params: {
page: pageNum,
limit: 25
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
setHasMore(newData.length === 25)
if (append) {
setProductsData((prev) => [...prev, ...newData])
@ -105,7 +116,7 @@ const Products = () => {
setLazyLoading(false)
}
},
[messageApi]
[messageApi, filters, sorter]
)
useEffect(() => {
@ -121,7 +132,6 @@ const Products = () => {
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
@ -173,7 +183,23 @@ const Products = () => {
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
fixed: 'left',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'ID',
@ -181,7 +207,23 @@ const Products = () => {
key: 'id',
fixed: 'left',
width: 165,
render: (text) => <IdText id={text} type={'product'} longId={false} />
render: (text) => <IdText id={text} type={'product'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'ID'
}),
onFilter: (value, record) =>
record._id.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Tags',
@ -200,14 +242,48 @@ const Products = () => {
))}
</Space>
)
}
},
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'tags'
}),
onFilter: (value, record) =>
record.tags?.some((tag) =>
tag.toLowerCase().includes(value.toLowerCase())
),
sorter: true
},
{
title: 'Version',
dataIndex: 'version',
key: 'version',
width: 120,
render: (text) => (text ? <Tag>{text}</Tag> : 'n/a')
render: (text) => (text ? <Tag>{text}</Tag> : 'n/a'),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'version'
}),
onFilter: (value, record) =>
record.version?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Created At',
@ -221,7 +297,9 @@ const Products = () => {
} else {
return 'n/a'
}
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Updated At',
@ -235,7 +313,9 @@ const Products = () => {
} else {
return 'n/a'
}
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Actions',
@ -262,6 +342,11 @@ const Products = () => {
}
]
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'Products',
columns
)
const actionItems = {
items: [
{
@ -285,29 +370,115 @@ const Products = () => {
}
}
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<CloseOutlined />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckOutlined />}
/>
</Space.Compact>
</div>
)
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Flex justify={'space-between'}>
<Space>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
{lazyLoading == true ? (
<Spin indicator={<LoadingOutlined />}></Spin>
) : null}
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={productsData}
columns={columns}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/>
</Flex>
<Modal

View File

@ -13,7 +13,8 @@ import {
Input,
Tag,
Checkbox,
InputNumber
InputNumber,
Collapse
} from 'antd'
import {
LoadingOutlined,
@ -21,12 +22,14 @@ import {
ReloadOutlined,
CheckOutlined,
CloseOutlined,
PlusOutlined
PlusOutlined,
CaretRightOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import moment from 'moment'
import VendorSelect from '../../common/VendorSelect.jsx'
import PartsTable from '../../common/PartsTable.jsx'
import useCollapseState from '../../hooks/useCollapseState'
const { Title } = Typography
@ -40,6 +43,10 @@ const ProductInfo = () => {
const [isEditing, setIsEditing] = useState(false)
const [fetchLoading, setFetchLoading] = useState(true)
const [marginOrPrice, setMarginOrPrice] = useState(false)
const [collapseState, updateCollapseState] = useCollapseState('ProductInfo', {
info: true,
parts: true
})
const [productForm] = Form.useForm()
const [productFormValues, setProductFormValues] = useState({})
@ -183,233 +190,268 @@ const ProductInfo = () => {
}
return (
<Flex style={{ height: '100%', minHeight: 0, overflowY: 'auto' }} vertical>
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
<Collapse
ghost
activeKey={collapseState.info ? ['1'] : []}
onChange={(keys) => updateCollapseState('info', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
>
<Title level={5} style={{ margin: 0 }}>
Product Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateInfo}
loading={loading}
></Button>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
></Button>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing}></Button>
)}
</Space>
</Flex>
<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 || []
}}
>
<Descriptions bordered column={2}>
<Descriptions.Item label='ID' span={1}>
{productData.id ? (
<IdText id={productData.id} type='product'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(productData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{ required: true, message: 'Please enter a product name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
productData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(productData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</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>
) : (
productData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Product Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing} />
)}
</Space>
</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 || []
}}
>
{isEditing ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Descriptions bordered column={2}>
<Descriptions.Item label='ID' span={1}>
{productData.id ? (
<IdText id={productData.id} type='product'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(productData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
name='name'
rules={[
{
required: true,
message: 'Please enter a margin.'
}
message: 'Please enter a product name'
},
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
<Input placeholder='Enter product name' />
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
productData.name || 'n/a'
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : productData.margin && marginOrPrice == false ? (
productData.margin + '%'
) : productData.price && marginOrPrice == true ? (
'£' + productData.price
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions.Item>
<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>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(productData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<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' />
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[
{ required: true, message: 'Please enter a vendor' }
]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusOutlined />} />
</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>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
<Flex
align={'center'}
style={{ marginTop: 24, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
) : (
productData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</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={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : productData.margin && marginOrPrice == false ? (
productData.margin + '%'
) : productData.price && marginOrPrice == true ? (
'£' + productData.price
) : (
'n/a'
)}
</Descriptions.Item>
<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>
) : (
'n/a'
)}
</Descriptions.Item>
<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={<PlusOutlined />} />
</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>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
activeKey={collapseState.parts ? ['2'] : []}
onChange={(keys) => updateCollapseState('parts', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretRightOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
>
<Title level={5} style={{ margin: 0 }}>
Product Parts
</Title>
</Flex>
<PartsTable data={productData.parts}></PartsTable>
</Flex>
<Collapse.Panel
header={
<Title level={5} style={{ margin: 0 }}>
Product Parts
</Title>
}
key='2'
>
<PartsTable data={productData.parts} />
</Collapse.Panel>
</Collapse>
</div>
)
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useContext, useCallback } from 'react'
import React, { useState, useContext, useCallback, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
@ -10,7 +10,11 @@ import {
Modal,
Dropdown,
message,
Typography
Typography,
Checkbox,
Popover,
Input,
Spin
} from 'antd'
import { createStyles } from 'antd-style'
import {
@ -18,13 +22,16 @@ import {
PlusOutlined,
ReloadOutlined,
InfoCircleOutlined,
ExportOutlined
ExportOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewVendor from './Vendors/NewVendor'
import CountryDisplay from '../common/CountryDisplay'
import VendorIcon from '../../Icons/VendorIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
const { Link } = Typography
@ -53,38 +60,152 @@ const Vendors = () => {
const [vendorsData, setVendorsData] = useState([])
const [newVendorOpen, setNewVendorOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const { authenticated } = useContext(AuthContext)
const fetchVendorsData = useCallback(async () => {
try {
const response = await axios.get('http://localhost:8080/vendors', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setVendorsData(response.data)
setLoading(false)
} catch (error) {
if (error.response) {
messageApi.error('Error fetching vendor data:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [messageApi])
const fetchVendorsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get('http://localhost:8080/vendors', {
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
useEffect(() => {
if (authenticated) {
fetchVendorsData()
}
}, [authenticated, fetchVendorsData])
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setVendorsData((prev) => [...prev, ...newData])
} else {
setVendorsData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
if (error.response) {
messageApi.error('Error fetching vendor data:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchVendorsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchVendorsData]
)
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<CloseOutlined />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckOutlined />}
/>
</Space.Compact>
</div>
)
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getVendorActionItems = (id) => {
return {
@ -117,14 +238,46 @@ const Vendors = () => {
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
fixed: 'left',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'vendor'} longId={false} />
render: (text) => <IdText id={text} type={'vendor'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'ID'
}),
onFilter: (value, record) =>
record._id.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Website',
@ -139,21 +292,69 @@ const Vendors = () => {
</Link>
) : (
'n/a'
)
),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'website'
}),
onFilter: (value, record) =>
record.website?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Country',
dataIndex: 'country',
key: 'country',
width: 200,
render: (text) => (text ? <CountryDisplay countryCode={text} /> : 'n/a')
render: (text) => (text ? <CountryDisplay countryCode={text} /> : 'n/a'),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'country'
}),
onFilter: (value, record) =>
record.country?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Contact',
dataIndex: 'contact',
key: 'contact',
width: 200,
render: (text) => (text ? text : 'n/a')
render: (text) => (text ? text : 'n/a'),
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'contact'
}),
onFilter: (value, record) =>
record.contact?.toLowerCase().includes(value.toLowerCase()),
sorter: true
},
{
title: 'Created At',
@ -167,7 +368,9 @@ const Vendors = () => {
} else {
return 'n/a'
}
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Updated At',
@ -181,7 +384,9 @@ const Vendors = () => {
} else {
return 'n/a'
}
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Actions',
@ -208,6 +413,15 @@ const Vendors = () => {
}
]
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'Vendors',
columns
)
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
const actionItems = {
items: [
{
@ -231,23 +445,42 @@ const Vendors = () => {
}
}
useEffect(() => {
if (authenticated) {
fetchVendorsData()
}
}, [authenticated, fetchVendorsData])
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<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>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={vendorsData}
columns={columns}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/>
</Flex>
<Modal

View File

@ -33,10 +33,10 @@ import {
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import NewGCodeFile from './GCodeFiles/NewGCodeFile'
import IdText from '../common/IdText'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import useColumnVisibility from '../hooks/useColumnVisibility'
const { Text } = Typography
@ -247,13 +247,9 @@ const GCodeFiles = () => {
const [showDeleted, setShowDeleted] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [columnVisibility, setColumnVisibility] = useState(
columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'GCodeFiles',
columns
)
const { authenticated } = useContext(AuthContext)
@ -442,16 +438,13 @@ const GCodeFiles = () => {
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '') // Filter out empty title columns and ensure key exists
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
setColumnVisibility((prev) => ({
...prev,
[col.key]: e.target.checked
}))
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}

View File

@ -41,6 +41,7 @@ import NewPrintJob from './PrintJobs/NewPrintJob'
import JobState from '../common/JobState'
import SubJobCounter from '../common/SubJobCounter'
import IdText from '../common/IdText'
import useColumnVisibility from '../hooks/useColumnVisibility'
const { Text } = Typography
@ -287,18 +288,14 @@ const PrintJobs = () => {
}
]
const [columnVisibility, setColumnVisibility] = useState(
columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
)
const { authenticated } = useContext(AuthContext)
const { socket } = useContext(SocketContext)
const [columnVisibility, updateColumnVisibility] = useColumnVisibility(
'PrintJobs',
columns
)
const handleDeployPrintJob = (printJobId) => {
if (socket) {
messageApi.info(`Print job ${printJobId} deployment initiated`)
@ -473,16 +470,13 @@ const PrintJobs = () => {
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '') // Filter out empty title columns and ensure key exists
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
setColumnVisibility((prev) => ({
...prev,
[col.key]: e.target.checked
}))
updateColumnVisibility(col.key, e.target.checked)
}}
>
{col.title}

View File

@ -8,7 +8,8 @@ import {
Button,
message,
Progress,
Typography
Typography,
Flex
} from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import moment from 'moment'
@ -37,7 +38,7 @@ const PrintJobInfo = () => {
useEffect(() => {
if (socket && printJobId) {
socket.on('notify_job_update', (updateData) => {
if (updateData.id === printJobId) {
if (updateData._id === printJobId) {
setPrintJobData((prevData) => {
if (!prevData) return prevData
return {
@ -103,7 +104,16 @@ const PrintJobInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Descriptions title='Print Job Information' bordered column={2}>
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Print Job Information
</Title>
</Flex>
<Descriptions bordered column={2}>
<Descriptions.Item label='ID'>
<IdText id={printJobData._id} type={'job'} />
</Descriptions.Item>
@ -163,9 +173,15 @@ const PrintJobInfo = () => {
)}
</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginBottom: 20 }}>
Sub Job Information
</Title>
<Flex
align={'center'}
style={{ marginTop: 24, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Sub Job Information
</Title>
</Flex>
<SubJobsTree printJobData={printJobData} />
</div>
)

View File

@ -13,7 +13,8 @@ import {
Progress,
Modal,
Typography,
Badge
Badge,
Alert
} from 'antd'
import {
LoadingOutlined,
@ -38,6 +39,7 @@ import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
import LoadFilamentStock from '../../Inventory/FilamentStocks/LoadFilamentStock'
import UnloadFilamentStock from '../../Inventory/FilamentStocks/UnloadFilamentStock'
import FilamentStockState from '../../common/FilamentStockState'
const { Text } = Typography
@ -57,6 +59,9 @@ const ControlPrinter = () => {
useState(false)
const [unloadFilamentStockModalOpen, setUnloadFilamentStockModalOpen] =
useState(false)
const [klippyErrorModalOpen, setKlippyErrorModalOpen] = useState(false)
const [klippyErrorMessage, setKlippyErrorMessage] = useState('')
const [klippyStartupMessage, setKlippyStartupMessage] = useState('')
const { socket } = useContext(SocketContext)
const { authenticated } = useContext(AuthContext)
@ -98,7 +103,7 @@ const ControlPrinter = () => {
socket.on('notify_printer_update', (statusUpdate) => {
console.log('GOT STATUS', statusUpdate)
setPrinterData((prevData) => {
if (statusUpdate?.id === printerId) {
if (statusUpdate?._id === printerId) {
return {
...prevData,
...statusUpdate
@ -146,13 +151,35 @@ const ControlPrinter = () => {
}, [authenticated, fetchPrinterDetails])
useEffect(() => {
if (
printerData?.alerts?.some((alert) => alert.type === 'loadFilamentStock')
) {
const loadFilamentStock = printerData?.alerts?.find(
(alert) => alert.type === 'loadFilamentStock'
)
const klippyError = printerData?.alerts?.find(
(alert) => alert.type === 'klippyError'
)
const klippyStartup = printerData?.alerts?.find(
(alert) => alert.type === 'klippyStartup'
)
if (loadFilamentStock) {
setLoadFilamentStockModalOpen(true)
} else {
setLoadFilamentStockModalOpen(false)
}
if (klippyError) {
setKlippyErrorModalOpen(true)
setKlippyErrorMessage(klippyError.message)
} else {
setKlippyErrorModalOpen(false)
setKlippyErrorMessage('')
}
if (klippyStartup) {
setKlippyStartupMessage(klippyStartup.message)
} else {
setKlippyStartupMessage('')
}
}, [printerData?.alerts])
const actionItems = {
@ -344,6 +371,12 @@ const ControlPrinter = () => {
{printerData ? (
<Flex gap={16}>
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
{printerData?.alerts?.some(
(alert) => alert.type === 'klippyError'
) && <Alert message={klippyErrorMessage} type='error' />}
{printerData?.alerts?.some(
(alert) => alert.type === 'klippyStartup'
) && <Alert message={klippyStartupMessage} type='warning' />}
<Descriptions bordered column={2}>
<Descriptions.Item label='Printer Name'>
{printerData.name}
@ -406,6 +439,14 @@ const ControlPrinter = () => {
)}
</Descriptions.Item>
{printerData?.currentFilamentStock && (
<Descriptions.Item label='Filament Stock' span={2}>
<FilamentStockState
filamentStock={printerData?.currentFilamentStock}
/>
</Descriptions.Item>
)}
<Descriptions.Item label='Filament Stock Net Weight'>
{printerData.currentFilamentStock?.currentNetWeight ? (
<Text>
@ -455,6 +496,17 @@ const ControlPrinter = () => {
)}
</Descriptions.Item>
{printerData?.state.type === 'printing' && (
<Descriptions.Item label='Progress' span={2}>
<Progress
percent={Math.round(
(printerData.state.progress || 0) * 100
)}
status='active'
/>
</Descriptions.Item>
)}
<Descriptions.Item label='Est. Print Time'>
{(() => {
if (
@ -493,27 +545,18 @@ const ControlPrinter = () => {
}
})()}
</Descriptions.Item>
{printerData?.state.type === 'printing' && (
<Descriptions.Item label='Progress'>
<Progress
percent={Math.round(
(printerData.state.progress || 0) * 100
)}
/>
</Descriptions.Item>
)}
</Descriptions>
<PrinterSubJobsTree subJobs={printerData.subJobs} />
</Flex>
<Flex gap={16} vertical>
<Card bordered={true}>
<Card>
<PrinterTemperaturePanel
printerId={printerId}
disabled={!printerData.online}
></PrinterTemperaturePanel>
</Card>
<Card bordered={true}>
<Card>
<PrinterMovementPanel
printerId={printerId}
></PrinterMovementPanel>
@ -560,6 +603,38 @@ const ControlPrinter = () => {
reset={unloadFilamentStockModalOpen}
/>
</Modal>
<Modal
open={klippyErrorModalOpen}
title={
<Space size={'middle'}>
<ExclamationCircleOutlined />
Klipper Error
</Space>
}
onCancel={() => setKlippyErrorModalOpen(false)}
footer={[
<Button
key='close'
onClick={() => {
setKlippyErrorModalOpen(false)
}}
>
Close
</Button>,
<Button
key='firmwareRestart'
icon={<ReloadOutlined />}
onClick={() => {
socket.emit('printer.firmware_restart', { printerId })
setKlippyErrorModalOpen(false)
}}
>
Restart Firmware
</Button>
]}
>
<Typography.Paragraph>{klippyErrorMessage}</Typography.Paragraph>
</Modal>
</>
)
}

View File

@ -26,7 +26,7 @@ const breadcrumbNameMap = {
'/dashboard/management/vendors/info': 'Info',
'/dashboard/management/materials': 'Materials',
'/dashboard/management/materials/info': 'Info',
'/dashboard/inventory/filamentstocks': 'Filaments',
'/dashboard/inventory/filamentstocks': 'Filament Stocks',
'/dashboard/inventory/filamentstocks/info': 'Info',
'/dashboard/inventory/partstocks': 'Parts',
'/dashboard/inventory/partstocks/info': 'Info'

View File

@ -1,20 +1,17 @@
// DashboardLayout.js
import PropTypes from 'prop-types'
import React, { useContext } from 'react'
import { Layout, Flex, Spin } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import React from 'react'
import { Layout, Flex } from 'antd'
import { useLocation } from 'react-router-dom'
import DashboardNavigation from './DashboardNavigation'
import ProductionSidebar from './ProductionSidebar'
import InventorySidebar from './InventorySidebar'
import ManagementSidebar from './ManagementSidebar'
import DashboardBreadcrumb from './DashboardBreadcrumb'
import { SocketContext } from '../context/SocketContext'
const { Content } = Layout
const DashboardLayout = ({ children }) => {
const { connecting } = useContext(SocketContext)
const location = useLocation()
const isProduction = location.pathname.startsWith('/dashboard/production')
const isInventory = location.pathname.startsWith('/dashboard/inventory')
@ -38,15 +35,6 @@ const DashboardLayout = ({ children }) => {
<Flex vertical style={{ height: '100%' }} gap='20px'>
<Flex justify='space-between'>
<DashboardBreadcrumb style={{ margin: '16px 0' }} />
{connecting ? (
<Spin
indicator={<LoadingOutlined spin />}
size='middle'
style={{ color: '#808080' }}
/>
) : (
<></>
)}
</Flex>
{children}

View File

@ -1,14 +1,29 @@
import PropTypes from 'prop-types'
import { Badge, Progress, Flex, Space, Tag, Typography } from 'antd'
import { green, red } from '@ant-design/colors'
import { green } from '@ant-design/colors'
import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext'
const { Text } = Typography
const getProgressColor = (percent) => {
if (percent <= 50) {
return green[5]
} else if (percent <= 80) {
// Interpolate between green and yellow
const ratio = (percent - 50) / 30
return `rgb(${Math.round(255 * ratio)}, ${Math.round(255 * (1 - ratio))}, 0)`
} else {
// Interpolate between yellow and red
const ratio = (percent - 80) / 20
return `rgb(255, ${Math.round(255 * (1 - ratio))}, 0)`
}
}
const FilamentStockState = ({
filamentStock,
showProgress = true,
showStatus = true,
showFilamentStockName = true
showStatus = true
}) => {
const { socket } = useContext(SocketContext)
const [badgeStatus, setBadgeStatus] = useState('unknown')
@ -20,7 +35,6 @@ const FilamentStockState = ({
}
)
const [initialized, setInitialized] = useState(false)
const { Text } = Typography
useEffect(() => {
if (socket && !initialized && filamentStock?._id) {
@ -64,7 +78,6 @@ const FilamentStockState = ({
return (
<Flex gap='middle' align={'center'}>
{showFilamentStockName && <Text>{filamentStock.name}</Text>}
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
@ -76,20 +89,21 @@ const FilamentStockState = ({
</Space>
)}
{showProgress && currentState.type === 'partiallyconsumed' ? (
<Progress
percent={Math.round(currentState.percent * 100)}
style={{ width: '150px', marginBottom: '2px' }}
steps={7}
strokeColor={[
green[5],
green[5],
green[5],
green[4],
green[3],
red[4],
red[5]
]}
/>
<Flex style={{ width: '150px' }} gap={'small'}>
<div style={{ flexGrow: '1' }}>
<Progress
percent={Math.round(currentState.percent * 100)}
style={{ marginBottom: '2px', width: '100%' }}
strokeColor={getProgressColor(
Math.round(currentState.percent * 100)
)}
showInfo={false}
/>
</div>
<Text style={{ marginTop: '1px' }}>
{Math.round(currentState.percent * 100) + '%'}
</Text>
</Flex>
) : null}
</Flex>
)
@ -111,8 +125,7 @@ FilamentStockState.propTypes = {
})
}),
showProgress: PropTypes.bool,
showStatus: PropTypes.bool,
showFilamentStockName: PropTypes.bool
showStatus: PropTypes.bool
}
export default FilamentStockState

View File

@ -61,6 +61,10 @@ const IdText = ({
prefix = 'FLS'
hyperlink = `/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
break
case 'stockaudit':
prefix = 'SAU'
hyperlink = `/dashboard/inventory/stockaudits/info?stockAuditId=${id}`
break
case 'partstock':
prefix = 'PTS'
hyperlink = `/dashboard/management/partstocks/info?partStockId=${id}`

View File

@ -40,19 +40,30 @@ const InventorySidebar = () => {
label: <Link to='/dashboard/inventory/overview'>Overview</Link>,
icon: <DashboardOutlined />
},
{ type: 'divider' },
{
key: 'filamentstocks',
label: <Link to='/dashboard/inventory/filamentstocks'>Filaments</Link>,
label: (
<Link to='/dashboard/inventory/filamentstocks'>Filament Stocks</Link>
),
icon: <FilamentStockIcon />
},
{
key: 'partstocks',
label: <Link to='/dashboard/inventory/partstocks'>Parts</Link>,
label: <Link to='/dashboard/inventory/partstocks'>Part Stocks</Link>,
icon: <PartStockIcon />
},
{
key: 'productstocks',
label: <Link to='/dashboard/inventory/productstocks'>Products</Link>,
label: (
<Link to='/dashboard/inventory/productstocks'>Product Stocks</Link>
),
icon: <ProductStockIcon />
},
{ type: 'divider' },
{
key: 'stockaudits',
label: <Link to='/dashboard/inventory/stockaudits'>Stock Audits</Link>,
icon: <ProductStockIcon />
}
]

View File

@ -71,7 +71,7 @@ const JobState = ({
<Flex gap='small' align={'center'}>
{showId && (
<>
{'Sub Job '}
{'Job '}
<IdText id={job.id} showCopy={false} type='job' longId={false} />
</>
)}
@ -91,6 +91,7 @@ const JobState = ({
currentState.type === 'processing') ? (
<Progress
percent={Math.round(currentState.progress * 100)}
status='active'
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}

View File

@ -64,6 +64,7 @@ const ManagementSidebar = () => {
label: <Link to='/dashboard/management/materials'>Materials</Link>,
icon: <MaterialIcon />
},
{ type: 'divider' },
{
key: 'settings',
label: <Link to='/dashboard/management/settings'>Settings</Link>,

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types'
import { Card, Tree, Spin, Space, Button, message, Typography } from 'antd'
import { Card, Tree, Spin, Space, Button, message } from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import React, { useState, useEffect, useContext } from 'react'
import SubJobState from './SubJobState'
@ -15,8 +15,6 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
const [expandedKeys, setExpandedKeys] = useState([])
const [treeData, setTreeData] = useState([])
const { Text } = Typography
const buildTreeData = (subJobsData) => {
if (!subJobsData?.length) {
setTreeData([])
@ -42,20 +40,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
({ printJob, subJobs }) => {
setExpandedKeys((prev) => [...prev, `printjob-${printJob._id}`])
return {
title: (
<JobState
job={printJob}
text={
<>
Print Job
<Text code>
{printJob._id.substring(printJob._id.length - 6)}
</Text>
printJob.quantity
</>
}
/>
),
title: <JobState job={printJob} />,
key: `printjob-${printJob._id}`,
children: subJobs.map((subJob) => ({
title: (
@ -112,7 +97,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
if (updateData.subJobId) {
setSubJobs((prevSubJobs) =>
prevSubJobs.map((subJob) => {
if (subJob._id === updateData.id) {
if (subJob._id === updateData._id) {
return {
...subJob,
state: updateData.state,
@ -161,6 +146,7 @@ const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
showLine={true}
/>
</Card>
)

View File

@ -32,7 +32,7 @@ const PrinterState = ({
if (socket && !initialized && printer?.id) {
setInitialized(true)
socket.on('notify_printer_update', (statusUpdate) => {
if (statusUpdate?.id === printer.id && statusUpdate?.state) {
if (statusUpdate?._id === printer.id && statusUpdate?.state) {
setCurrentState(statusUpdate.state)
}
})
@ -98,6 +98,10 @@ const PrinterState = ({
setBadgeStatus('error')
setBadgeText('Error')
break
case 'startup':
setBadgeStatus('warning')
setBadgeText('Startup')
break
default:
setBadgeStatus('default')
setBadgeText(currentState.type)
@ -122,6 +126,7 @@ const PrinterState = ({
currentState.type === 'deploying') ? (
<Progress
percent={Math.round(currentState.progress * 100)}
status='active'
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}

View File

@ -40,6 +40,7 @@ const ProductionSidebar = () => {
label: <Link to='/dashboard/production/overview'>Overview</Link>,
icon: <DashboardOutlined />
},
{ type: 'divider' },
{
key: 'printers',
label: <Link to='/dashboard/production/printers'>Printers</Link>,

View File

@ -1,52 +1,197 @@
import React from 'react'
import { Table } from 'antd'
import React, { useEffect, useContext, useState } from 'react'
import { Table, Typography } from 'antd'
import PropTypes from 'prop-types'
import moment from 'moment'
import IdText from './IdText'
import { AuditOutlined, PlayCircleOutlined } from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext'
import PlusMinusIcon from '../../Icons/PlusMinusIcon'
const { Text } = Typography
const StockEventTable = ({ stockEvents }) => {
const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false)
const [stockEventsData, setStockEventsData] = useState(stockEvents)
useEffect(() => {
// Add WebSocket event listener for real-time updates
if (socket && !initialized) {
setInitialized(true)
socket.on('notify_stockevent_update', (updateData) => {
console.log('Received stock event update:', updateData)
setStockEventsData((prevData) => {
return prevData.map((stockEvent) => {
if (stockEvent?._id) {
console.log('UD', updateData)
console.log('SE', stockEvent)
if (stockEvent._id === updateData._id) {
return {
...stockEvent,
...updateData
}
}
}
})
})
})
}
return () => {
if (socket && initialized) {
console.log('Deregistering stock event update listener')
socket.off('notify_stockevent_update')
}
}
}, [socket, initialized])
const getTypeFilterProps = () => {
// Get unique types from the data
const uniqueTypes = [
...new Set(
stockEventsData.map((record) => {
const type = record.type.toLowerCase()
if (type === 'subjob') return 'Sub Job'
if (type === 'audit') return 'Audit Adjustment'
return type.charAt(0).toUpperCase() + type.slice(1)
})
)
]
return {
filters: uniqueTypes.map((type) => ({ text: type, value: type })),
onFilter: (value, record) => {
const recordType = record.type.toLowerCase()
if (recordType === 'subjob') {
return value === 'Sub Job'
} else if (recordType === 'audit') {
return value === 'Audit Adjustment'
}
return (
value === recordType.charAt(0).toUpperCase() + recordType.slice(1)
)
}
}
}
const columns = [
{
title: '',
key: 'icon',
width: 50,
render: (record) => {
switch (record.type.toLowerCase()) {
case 'subjob':
return <PlayCircleOutlined />
case 'audit':
return <AuditOutlined />
default:
return null
}
}
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (type) => type.charAt(0).toUpperCase() + type.slice(1)
width: 200,
sorter: (a, b) => a.type.localeCompare(b.type),
...getTypeFilterProps(),
render: (type) => {
switch (type.toLowerCase()) {
case 'subjob':
return 'Sub Job'
case 'audit':
return 'Audit Adjustment'
default:
return type.charAt(0).toUpperCase() + type.slice(1)
}
}
},
{
title: 'Value',
title: <PlusMinusIcon />,
dataIndex: 'value',
key: 'value',
render: (value) => value.toFixed(2) + 'g'
width: 100,
sorter: (a, b) => a.value - b.value,
render: (value) => {
const formattedValue = value.toFixed(2) + 'g'
return (
<Text type={value < 0 ? 'danger' : 'success'}>
{value > 0 ? '+' + formattedValue : formattedValue}
</Text>
)
}
},
{
title: 'Sub Job ID',
render: (record) =>
record.subJob ? (
<IdText
id={record.subJob.number.toString().padStart(6, '0')}
longId={false}
type={'subjob'}
/>
) : (
'n/a'
)
title: 'ID',
width: 100,
render: (record) => {
if (record.subJob) {
return (
<IdText
id={record.subJob.number.toString().padStart(6, '0')}
longId={false}
type={'subjob'}
/>
)
}
if (record.stockAudit) {
return (
<IdText
id={record.stockAudit._id}
longId={false}
type={'stockaudit'}
showHyperlink={true}
/>
)
}
return 'n/a'
}
},
{
title: 'Job ID',
render: (record) =>
record.subJob ? (
<IdText id={record.job._id} longId={false} type={'job'} />
) : (
'n/a'
)
width: 100,
render: (record) => {
if (record.subJob) {
return (
<IdText
id={record.job._id}
longId={false}
type={'job'}
showHyperlink={true}
/>
)
}
return 'n/a'
}
},
{
title: 'Timestamp',
dataIndex: ['timestamp', '$date'],
key: 'timestamp',
render: (timestamp) => {
if (timestamp) {
const formattedDate = moment(timestamp).format('YYYY-MM-DD HH:mm:ss')
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
defaultSortOrder: 'descend',
sorter: (a, b) => moment(a.createdAt).unix() - moment(b.createdAt).unix(),
render: (createdAt) => {
if (createdAt) {
const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
sorter: (a, b) => moment(a.updatedAt).unix() - moment(b.updatedAt).unix(),
render: (updatedAt) => {
if (updatedAt) {
const formattedDate = moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
@ -57,9 +202,9 @@ const StockEventTable = ({ stockEvents }) => {
return (
<Table
dataSource={stockEvents}
dataSource={stockEventsData}
columns={columns}
rowKey={(record) => record._id.$oid}
rowKey={(record) => record._id}
pagination={false}
/>
)

View File

@ -110,6 +110,7 @@ const SubJobState = ({
currentState.type === 'processing') ? (
<Progress
percent={Math.round(currentState.progress * 100)}
status='active'
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}

View File

@ -140,7 +140,7 @@ const SubJobsTree = ({ printJobData }) => {
...prevData,
// eslint-disable-next-line camelcase
subJobs: prevData.subJobs.map((subJob) => {
if (subJob._id === updateData.id) {
if (subJob._id === updateData._id) {
return {
...subJob,
state: updateData.state,
@ -193,6 +193,7 @@ const SubJobsTree = ({ printJobData }) => {
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
showLine={true}
/>
</Card>
)

View File

@ -0,0 +1,31 @@
import { useState, useEffect } from 'react'
const useCollapseState = (componentName, defaultState = {}) => {
const getInitialState = () => {
const stored = sessionStorage.getItem(`${componentName}_collapseState`)
if (stored) {
return JSON.parse(stored)
}
return defaultState
}
const [collapseState, setCollapseState] = useState(getInitialState)
useEffect(() => {
sessionStorage.setItem(
`${componentName}_collapseState`,
JSON.stringify(collapseState)
)
}, [collapseState, componentName])
const updateCollapseState = (key, value) => {
setCollapseState((prev) => ({
...prev,
[key]: value
}))
}
return [collapseState, updateCollapseState]
}
export default useCollapseState

View File

@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
const useColumnVisibility = (componentName, columns) => {
const getInitialVisibility = () => {
const stored = sessionStorage.getItem(`${componentName}_columnVisibility`)
if (stored) {
return JSON.parse(stored)
}
// Default visibility - all columns visible
return columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
}
const [columnVisibility, setColumnVisibility] = useState(getInitialVisibility)
useEffect(() => {
sessionStorage.setItem(
`${componentName}_columnVisibility`,
JSON.stringify(columnVisibility)
)
}, [columnVisibility, componentName])
const updateColumnVisibility = (key, value) => {
setColumnVisibility((prev) => ({
...prev,
[key]: value
}))
}
return [columnVisibility, updateColumnVisibility]
}
export default useColumnVisibility

View File

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