Added more functionality.

This commit is contained in:
Tom Butcher 2025-05-09 22:23:51 +01:00
parent fa34bda959
commit 47ce2dfe8e
72 changed files with 12225 additions and 0 deletions

31
.eslintrc.json Normal file
View File

@ -0,0 +1,31 @@
{
"extends": [
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"env": {
"browser": true, // Allows access to browser globals like `localStorage`
"node": true, // If you're also using Node.js
"es2021": true // Use ECMAScript 2021 features
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"version": "detect" // Automatically detect the React version
}
},
"rules": {
"camelcase": ["error", { "properties": "always" }],
"multiline-ternary": ["error", "never"],
"no-debugger": "off",
"no-console": "warn"
}
}

7
.prettierrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"eslint.options": {
"overrideConfigFile": "./.eslintrc.json"
}
}

84
public/gcode-worker.js Normal file
View File

@ -0,0 +1,84 @@
// gcode-worker.js
self.onmessage = function (event) {
const { configString } = event.data
const configObject = {}
let isThumbnailSection = false
let base64ImageData = ''
const lines = configString.split('\n')
const totalLines = lines.length
for (let i = 0; i < totalLines; i++) {
const line = lines[i]
let trimmedLine = line.trim()
// Skip empty lines or lines that are not part of the config
if (!trimmedLine || !trimmedLine.startsWith(';')) {
continue
}
// Remove the leading semicolon and trim the line
trimmedLine = trimmedLine.substring(1).trim()
// Handle thumbnail section
if (trimmedLine.startsWith('thumbnail begin')) {
isThumbnailSection = true
base64ImageData = '' // Reset image data
continue
} else if (trimmedLine.startsWith('thumbnail end')) {
isThumbnailSection = false
configObject.thumbnail = base64ImageData // Store base64 string as-is
continue
}
if (isThumbnailSection) {
base64ImageData += trimmedLine // Accumulate base64 data
continue
}
// Split the line into key and value parts
let [key, ...valueParts] = trimmedLine.split('=').map((part) => part.trim())
if (!key || !valueParts.length) {
continue
}
if (
key === 'end_gcode' ||
key === 'start_gcode' ||
key === 'start_filament_gcode' ||
key === 'end_filament_gcode'
) {
continue
}
const value = valueParts.join('=').trim()
// Handle multi-line values (assuming they start and end with curly braces)
if (value.startsWith('{')) {
let multiLineValue = value
while (!multiLineValue.endsWith('}')) {
// Read the next line
const nextLine = lines[++i].trim()
multiLineValue += '\n' + nextLine
}
// Remove the starting and ending braces
configObject[key.replace(/\s+/g, '_').replace('(', '').replace(')', '')] =
multiLineValue.substring(1, multiLineValue.length - 1).trim()
} else {
key = key.replace('[', '').replace(']', '')
key = key.replace('(', '').replace(')', '')
// Regular key-value pair
configObject[key.replace(/\s+/g, '_')] = value
.replace('"', '')
.replace('"', '')
}
// Report progress
const progress = ((i + 1) / totalLines) * 100
self.postMessage({ type: 'progress', progress })
}
// Post the result back to the main thread
self.postMessage({ type: 'result', configObject })
}

View File

@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<title>Silent SSO Check</title>
<script>
// This page is used for silent token refresh with Keycloak
parent.postMessage(location.href, location.origin)
</script>
</head>
<body>
<!-- This page intentionally left blank -->
</body>
</html>

Binary file not shown.

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 130 148" version="1.1" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.0275218,-4.21477e-17,-4.21477e-17,-0.0275218,-8.99903,147.87)">
<path d="M2121,5369C1637,5326 1270,5230 958,5066C606,4880 403,4658 343,4391C287,4149 381,3894 598,3692L665,3631L653,3579C626,3466 648,3311 707,3196L731,3149L701,3087C622,2923 623,2749 702,2578C731,2515 731,2514 712,2483C702,2466 685,2428 676,2400C663,2362 642,2333 596,2290C384,2094 292,1848 340,1610C349,1563 367,1502 380,1474C523,1146 928,877 1491,735C1618,703 1809,667 1918,656L1984,649L2025,574C2158,331 2379,145 2641,56C2804,0 2805,0 3970,0L5051,0L5051,945L3827,945L3864,967C4064,1088 4211,1219 4300,1353C4328,1396 4365,1471 4383,1521C4411,1602 4415,1625 4415,1733C4415,1832 4411,1866 4390,1927C4345,2063 4264,2185 4150,2290C4104,2333 4083,2362 4070,2400C4061,2428 4044,2466 4033,2483C4014,2514 4014,2516 4037,2562C4087,2662 4100,2720 4100,2835C4100,2950 4078,3041 4033,3113C4014,3144 4014,3145 4038,3194C4098,3312 4120,3465 4093,3579L4081,3631L4148,3692C4483,4003 4509,4415 4214,4748C3943,5054 3439,5270 2814,5350C2689,5366 2228,5379 2121,5369ZM2621,5050C3138,5009 3560,4873 3849,4656C3951,4579 4013,4507 4061,4411C4096,4342 4100,4324 4100,4253C4100,4146 4061,4063 3963,3960C3721,3702 3229,3524 2631,3476C1884,3416 1103,3621 783,3960C685,4063 646,4146 646,4253C646,4324 650,4342 685,4411C733,4507 795,4579 897,4656C1283,4947 1966,5103 2621,5050ZM1078,3393C1322,3286 1614,3213 1974,3169C2119,3151 2627,3151 2772,3169C3132,3213 3424,3286 3668,3393C3721,3416 3770,3438 3776,3440C3785,3444 3786,3436 3781,3411C3730,3186 3364,2969 2890,2883C2710,2850 2600,2841 2373,2841C2146,2841 2036,2850 1856,2883C1382,2969 1016,3186 965,3411C960,3436 961,3444 970,3440C976,3438 1025,3416 1078,3393ZM1034,2856C1251,2712 1559,2607 1927,2549C2052,2530 2117,2526 2373,2526C2629,2526 2694,2530 2819,2549C3197,2608 3509,2718 3733,2870C3769,2895 3775,2896 3783,2881C3797,2857 3782,2766 3757,2715C3702,2607 3594,2514 3425,2427C2975,2197 2271,2141 1694,2290C1411,2363 1151,2498 1045,2630C979,2712 944,2809 960,2872C967,2901 966,2901 1034,2856ZM1034,2226C1135,2157 1325,2074 1548,2000C1798,1918 1872,1885 1991,1805C2260,1624 2422,1390 2500,1070C2517,1003 2539,927 2550,902C2582,830 2658,744 2723,705C2848,631 2773,636 3804,632L4736,629L4736,314L2840,320L2756,349C2640,388 2551,443 2455,534C2317,665 2249,796 2201,1024C2147,1280 2095,1382 1952,1506C1866,1581 1781,1625 1612,1680C1451,1733 1290,1806 1196,1869C1034,1979 940,2124 960,2232C968,2272 966,2272 1034,2226ZM3786,2176C3764,2004 3547,1824 3229,1712C3115,1672 2915,1624 2793,1607C2744,1600 2689,1593 2671,1590C2639,1585 2634,1590 2579,1669C2547,1715 2492,1783 2457,1820L2395,1887L2528,1894C3006,1918 3445,2044 3733,2240L3774,2268L3783,2244C3788,2231 3790,2201 3786,2176ZM857,1741C942,1655 1094,1550 1219,1492C1267,1469 1376,1427 1460,1398C1634,1339 1705,1306 1759,1259C1816,1208 1858,1125 1884,1006C1891,973 1910,972 1694,1014C1280,1095 918,1271 748,1473C645,1596 617,1749 675,1874L704,1936L744,1875C767,1841 817,1781 857,1741ZM4072,1872C4095,1822 4100,1796 4100,1732C4100,1663 4095,1642 4064,1579C3996,1441 3811,1285 3599,1187C3418,1104 3103,1012 2928,991C2869,984 2868,985 2847,1018C2836,1036 2821,1081 2814,1116C2808,1152 2796,1204 2788,1231C2771,1289 2757,1281 2919,1308C3212,1356 3509,1464 3710,1595C3812,1662 3946,1790 3997,1870C4019,1904 4039,1932 4040,1932C4042,1932 4056,1905 4072,1872Z" style="fill-rule:nonzero;"/>
<path d="M2168,4709C1894,4666 1703,4555 1616,4389C1580,4321 1580,4184 1616,4116C1686,3983 1795,3902 1990,3838C2374,3713 2898,3806 3075,4030C3139,4112 3155,4156 3155,4253C3155,4349 3139,4393 3075,4475C2930,4659 2521,4765 2168,4709ZM2531,4395C2672,4372 2780,4328 2829,4275C2848,4253 2848,4252 2829,4230C2762,4157 2554,4095 2373,4095C2192,4095 1984,4157 1917,4230C1898,4252 1898,4253 1917,4275C2010,4378 2296,4434 2531,4395Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

View File

@ -0,0 +1,5 @@
<?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" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M4.325,9.565L1.035,9.565C0.985,9.565 0.936,9.557 0.892,9.541C0.82,9.516 0.754,9.471 0.702,9.408C0.638,9.328 0.606,9.232 0.606,9.137L0.606,9.137C0.606,9.077 0.618,9.021 0.64,8.969L0.641,8.968C0.667,8.906 0.708,8.85 0.764,8.805L8.462,2.531C8.645,2.382 8.915,2.409 9.065,2.592C9.214,2.776 9.186,3.046 9.003,3.195L6.524,5.215C7.33,6.173 7.847,7.383 7.939,8.708L10.965,8.708C11.202,8.708 11.394,8.9 11.394,9.137C11.394,9.373 11.202,9.565 10.965,9.565L4.325,9.565L4.325,8.708L7.058,8.708C6.967,7.593 6.526,6.577 5.844,5.77L2.239,8.708L4.325,8.708L4.325,9.565Z"/>
</svg>

After

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

View File

@ -0,0 +1,9 @@
<?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 94 94" version="1.1" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.855722,0,0,0.855722,6.74415,6.74415)">
<rect x="0" y="0" width="93.488" height="93.488" style="fill-opacity:0;"/>
<path d="M15.945,93.488L77.543,93.488C88.207,93.488 93.488,88.258 93.488,77.797L93.488,15.742C93.488,5.281 88.207,0 77.543,0L15.945,0C5.332,0 0,5.281 0,15.742L0,77.797C0,88.258 5.332,93.488 15.945,93.488ZM16.047,85.313C10.969,85.313 8.176,82.621 8.176,77.34L8.176,16.199C8.176,10.918 10.969,8.176 16.047,8.176L77.441,8.176C82.469,8.176 85.313,10.918 85.313,16.199L85.313,77.34C85.313,82.621 82.469,85.313 77.441,85.313L16.047,85.313Z" style="fill-rule:nonzero;"/>
<path d="M62.664,59.313C64.898,59.313 66.422,57.586 66.422,55.25L66.422,31.281C66.422,28.285 64.746,27.117 62.156,27.117L38.086,27.117C35.699,27.117 34.176,28.59 34.176,30.824C34.176,33.059 35.75,34.531 38.188,34.531L47.43,34.531L54.945,33.719L47.023,41.082L28.285,59.82C27.574,60.531 27.117,61.547 27.117,62.563C27.117,64.848 28.59,66.32 30.824,66.32C32.043,66.32 33.008,65.863 33.719,65.152L52.406,46.465L59.719,38.645L58.957,46.566L58.957,55.352C58.957,57.738 60.43,59.313 62.664,59.313Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 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 65 72" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<path d="m8.186 53.23 20.576 11.9c2.051 1.199 4.428 1.199 6.501 0l20.554-11.9c2.042-1.163 3.246-3.25 3.246-5.617v-23.756c0-2.367-1.204-4.454-3.246-5.639l-20.554-11.861c-2.073-1.221-4.45-1.221-6.501 0l-20.576 11.861c-2.042 1.185-3.245 3.272-3.245 5.639v23.756c0 2.367 1.203 4.454 3.245 5.617zm21.246 6.326c-0.2-0.093-0.232-0.12-0.448-0.252l-17.402-10.059c-0.942-0.56-1.506-1.502-1.506-2.577v-19.575l19.356 11.028v21.435zm2.552-25.979-20.092-11.452c0.138-0.128 0.191-0.198 0.42-0.325l18.199-10.517c0.942-0.564 2.039-0.564 2.981 0l18.221 10.517c0.208 0.127 0.251 0.192 0.369 0.306l-20.098 11.471zm2.588 25.979v-21.469l19.351-11.022v19.603c0 1.075-0.538 2.017-1.48 2.577l-17.203 9.941c-0.31 0.173-0.38 0.224-0.668 0.37z" fill-rule="nonzero"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

View File

@ -0,0 +1,5 @@
<?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 57 74" xmlns="http://www.w3.org/2000/svg">
<path d="m17.491 30.114v-16.567c0-1.875 0.961-3.535 2.566-4.459l14.496-8.367c1.623-0.961 3.517-0.961 5.15 0l14.487 8.367c1.604 0.924 2.565 2.584 2.565 4.459v36.215c0 1.876-0.961 3.536-2.565 4.45l-14.487 8.377c-0.521 0.304-1.068 0.51-1.625 0.62-0.38 0.452-0.844 0.84-1.38 1.146l-14.487 8.377c-1.632 0.951-3.526 0.951-5.149 0l-14.497-8.377c-1.604-0.915-2.565-2.575-2.565-4.45v-16.735c0-1.875 0.961-3.536 2.565-4.459l14.497-8.368c0.141-0.084 0.284-0.16 0.429-0.229zm-0.215 37.291c-0.158-0.084-0.186-0.103-0.345-0.196l-11.241-6.502c-0.615-0.364-0.988-0.979-0.988-1.688v-12.771l12.574 7.202v13.955zm17.277-7.259v-13.954l-12.566 7.192v14.021c0.234-0.121 0.336-0.186 0.579-0.326l11.987-6.933zm4.926-2.883v-14.021l12.565-7.192v12.826c0 0.709-0.354 1.325-0.97 1.689l-11.017 6.371c-0.242 0.14-0.345 0.205-0.578 0.327zm-32.595-15.595 12.748-7.418 12.757 7.418c0.14 0.084 0.158 0.112 0.223 0.168l-13.031 7.472-12.986-7.435c0.103-0.084 0.14-0.121 0.289-0.205zm45.16-25.098v13.983l-4.544 2.599-7.443 4.304c-0.242 0.14-0.345 0.206-0.578 0.327v-14.021l12.565-7.192zm-29.851 13.762 0.018 0.011 12.557 7.253v-13.769l-12.575-7.201v13.706zm14.879-10.646-12.985-7.435c0.102-0.084 0.14-0.122 0.289-0.206l11.772-6.8c0.616-0.373 1.334-0.373 1.95 0l11.782 6.8c0.14 0.084 0.159 0.112 0.224 0.168l-13.032 7.473z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 12 12" version="1.1" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.888414,0,0,0.888414,0.669537,0.586683)">
<path d="M11.819,5.906L6.644,0.734L6.297,0.387C6.134,0.225 5.866,0.225 5.703,0.387L0.181,5.906C0.017,6.069 -0.074,6.291 -0.071,6.522C-0.066,6.994 0.327,7.37 0.798,7.37L11.214,7.37C11.443,7.37 11.658,7.28 11.821,7.118C11.982,6.958 12.072,6.739 12.071,6.512C12.071,6.284 11.981,6.068 11.819,5.906ZM10.955,6.406L1.046,6.406L6.001,1.574L6.311,1.884L10.955,6.406Z" style="fill-rule:nonzero;"/>
</g>
<path d="M4.297,11.177L4.297,10.319L1.464,10.319L1.464,8.95L10.536,8.95L10.536,10.319L4.297,10.319L4.297,11.177L10.622,11.177C11.048,11.177 11.394,10.832 11.394,10.406L11.394,8.864C11.394,8.438 11.048,8.093 10.622,8.093L1.378,8.093C0.952,8.093 0.606,8.438 0.606,8.864L0.606,10.406C0.606,10.832 0.952,11.177 1.378,11.177L4.297,11.177Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,56 @@
import React, { useState, useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Typography, Flex } from 'antd'
import { LockOutlined } from '@ant-design/icons'
import { AuthContext } from './AuthContext'
import PassKeysIcon from '../Icons/PassKeysIcon' // Adjust the path if necessary
import './Auth.css'
import AuthLayout from './AuthLayout'
const { Text } = Typography
const RegisterPasskey = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const navigate = useNavigate()
const { registerPasskey } = useContext(AuthContext)
const [init, setInit] = useState(false)
const handleRegisterPasskey = async (e) => {
const result = await registerPasskey(email, password)
if (result.successful === true) {
setTimeout(() => {
navigate('/dashboard/overview')
}, 500)
} else {
}
}
return (
<AuthLayout>
<Flex vertical='true' align='center' style={{ marginBottom: 25 }}>
<PassKeysIcon style={{ fontSize: '64px' }} />
<h1 style={{ marginTop: 10, marginBottom: 10 }}>Register a Passkey</h1>
<Text style={{ textAlign: 'center' }}>
Please setup a passkey in order to continue. The passkey may use
another device for encryption.
</Text>
</Flex>
<Button
type='primary'
className='auth-form-button'
icon={<LockOutlined />}
onClick={() => {
handleRegisterPasskey()
}}
>
Continue
</Button>
</AuthLayout>
)
}
export default RegisterPasskey

View File

@ -0,0 +1,204 @@
import React, { useEffect, useState, useContext } from 'react'
import axios from 'axios'
import moment from 'moment'
import {
Table,
Button,
Flex,
Space,
Modal,
Drawer,
message,
Dropdown
} from 'antd'
import { EditOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import NewSpool from './Spools/NewSpool.jsx'
import EditSpool from './Spools/EditSpool.jsx'
const Spools = () => {
const [messageApi, contextHolder] = message.useMessage()
const [spoolsData, setSpoolsData] = useState([])
const [pagination] = useState({
current: 1,
pageSize: 10,
total: 0
})
const [newSpoolOpen, setNewSpoolOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [editSpoolOpen, setEditSpoolOpen] = useState(false)
const [editSpool, setEditSpool] = useState(null)
const { token } = useContext(AuthContext)
const fetchSpoolsData = async () => {
try {
const response = await axios.get('http://localhost:8080/spools', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setSpoolsData(response.data)
setLoading(false)
} catch (err) {
messageApi.info(err)
}
}
useEffect(() => {
// Fetch initial data
//fetchSpoolsData()
}, [token])
// Column definitions
const columns = [
{
title: 'Name',
dataIndex: 'name',
key: 'name'
},
{
title: 'Filament',
dataIndex: 'filament',
key: 'filament',
render: (filament) => {
return filament?.name || 'N/A'
}
},
{
title: 'Current Weight',
dataIndex: 'currentWeight',
key: 'currentWeight',
render: (weight) => {
return weight ? weight + 'g' : 'N/A'
}
},
{
title: 'Barcode',
dataIndex: 'barcode',
key: 'barcode'
},
{
title: 'Updated At',
dataIndex: 'updatedat',
key: 'updatedAt',
render: (updatedAt) => {
if (updatedAt !== null) {
const formattedDate = moment(updatedAt.$date).format(
'YYYY-MM-DD HH:mm:ss'
)
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'operation',
fixed: 'right',
width: 100,
render: (text, record) => {
return (
<Flex gap='small' horizontal='true'>
<Button
type='link'
icon={<EditOutlined />}
onClick={() => {
handleEdit(record._id)
}}
/>
</Flex>
)
}
}
]
const handleEdit = (id) => {
setEditSpool(
<EditSpool
id={id}
onOk={() => {
setEditSpoolOpen(false)
fetchSpoolsData()
setEditSpool(null)
}}
/>
)
setEditSpoolOpen(true)
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown
menu={{
items: [
{
label: 'New Spool',
key: '1',
icon: <PlusOutlined />
}
],
onClick: ({ key }) => {
if (key === '1') {
setNewSpoolOpen(true)
}
}
}}
>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={spoolsData}
columns={columns}
pagination={pagination}
rowKey='id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
/>
</Flex>
<Modal
open={newSpoolOpen}
footer={null}
width={700}
onCancel={() => {
setNewSpoolOpen(false)
}}
>
<NewSpool
onOk={() => {
setNewSpoolOpen(false)
fetchSpoolsData()
}}
reset={newSpoolOpen}
/>
</Modal>
<Drawer
open={editSpoolOpen}
title={'Edit Spool'}
onClose={() => {
setEditSpoolOpen(false)
}}
>
{editSpool}
</Drawer>
</>
)
}
export default Spools

View File

@ -0,0 +1,450 @@
import PropTypes from 'prop-types'
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import {
Form,
Input,
InputNumber,
Button,
message,
Typography,
Select,
Flex,
Steps,
Divider,
ColorPicker,
Upload,
Descriptions,
Badge
} from 'antd'
import { UploadOutlined, LinkOutlined } from '@ant-design/icons'
const { Text } = Typography
const EditSpool = ({ id, onOk }) => {
const [messageApi, contextHolder] = message.useMessage()
const [filaments, setFilaments] = useState([])
const [editSpoolLoading, setEditSpoolLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [editSpoolForm] = Form.useForm()
const [editSpoolFormValues, setEditSpoolFormValues] = useState(null)
const [imageList, setImageList] = useState([])
const editSpoolFormUpdateValues = Form.useWatch([], editSpoolForm)
useEffect(() => {
const fetchFilaments = async () => {
try {
const response = await axios.get('http://localhost:8080/filaments', {
withCredentials: true
})
setFilaments(response.data)
} catch (error) {
messageApi.error('Error fetching filaments: ' + error.message)
}
}
fetchFilaments()
}, [messageApi])
React.useEffect(() => {
editSpoolForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [editSpoolForm, editSpoolFormUpdateValues])
useEffect(() => {
const fetchSpoolData = async () => {
try {
const response = await axios.get(`http://localhost:8080/spools/${id}`, {
withCredentials: true
})
const spoolData = response.data
setEditSpoolFormValues(spoolData)
editSpoolForm.setFieldsValue(spoolData)
if (spoolData.image) {
setImageList([
{
uid: '-1',
name: 'Spool Image',
status: 'done',
url: spoolData.image
}
])
}
} catch (error) {
messageApi.error('Error fetching spool data: ' + error.message)
}
}
fetchSpoolData()
}, [id, editSpoolForm, messageApi])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: editSpoolFormValues?.name
},
{
key: 'brand',
label: 'Brand',
children: editSpoolFormValues?.brand
},
{
key: 'type',
label: 'Material',
children: editSpoolFormValues?.type
},
{
key: 'price',
label: 'Price',
children: '£' + editSpoolFormValues?.price + ' per kg'
},
{
key: 'color',
label: 'Colour',
children: (
<Badge
color={editSpoolFormValues?.color}
text={editSpoolFormValues?.color}
/>
)
},
{
key: 'diameter',
label: 'Diameter',
children: editSpoolFormValues?.diameter + 'mm'
},
{
key: 'density',
label: 'Density',
children: editSpoolFormValues?.diameter + 'g/cm³'
},
{
key: 'image',
label: 'Image',
children: editSpoolFormValues?.image ? (
<img src={editSpoolFormValues.image} style={{ width: 128 }}></img>
) : null
},
{
key: 'url',
label: 'URL',
children: editSpoolFormValues?.url
},
{
key: 'barcode',
label: 'Barcode',
children: editSpoolFormValues?.barcode
},
{
key: 'filament',
label: 'Filament',
children: editSpoolFormValues?.filament?.name || 'N/A'
},
{
key: 'currentWeight',
label: 'Current Weight',
children: editSpoolFormValues?.currentWeight + 'g'
}
]
const handleEditSpool = async () => {
setEditSpoolLoading(true)
try {
await axios.put(
`http://localhost:8080/spools/${id}`,
editSpoolFormValues,
{
withCredentials: true
}
)
messageApi.success('Spool updated successfully.')
onOk()
} catch (error) {
messageApi.error('Error updating spool: ' + error.message)
} finally {
setEditSpoolLoading(false)
}
}
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
const handleImageUpload = async ({ file, fileList }) => {
if (fileList.length === 0) {
setImageList(fileList)
editSpoolForm.setFieldsValue({ image: '' })
return
}
const base64 = await getBase64(file)
setEditSpoolFormValues((prevValues) => ({
...prevValues,
image: base64
}))
fileList[0].name = 'Spool Image'
setImageList(fileList)
editSpoolForm.setFieldsValue({ image: base64 })
}
const steps = [
{
title: 'Required',
key: 'required',
content: (
<>
<Form.Item>
<Text>Required information:</Text>
</Form.Item>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Brand'
name='brand'
rules={[
{
required: true,
message: 'Please enter a brand.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Material'
name='type'
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>
<Form.Item
label='Price'
name='price'
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return '£'
return `£${value}`
}}
step={0.01}
style={{ width: '100%' }}
addonAfter='per kg'
/>
</Form.Item>
<Form.Item
label='Colour'
name='color'
rules={[
{
required: true,
message: 'Please select a colour.'
}
]}
>
<ColorPicker />
</Form.Item>
<Form.Item
label='Filament'
name='filament'
rules={[
{
required: true,
message: 'Please select a filament.'
}
]}
>
<Select
showSearch
optionFilterProp='children'
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{filaments.map((filament) => (
<Select.Option key={filament._id} value={filament._id}>
{filament.name} ({filament.brand} - {filament.type})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label='Current Weight'
name='currentWeight'
rules={[
{
required: true,
message: 'Please enter the current weight.'
}
]}
>
<InputNumber
min={0}
step={1}
style={{ width: '100%' }}
addonAfter='g'
/>
</Form.Item>
<Form.Item
label='Barcode'
name='barcode'
rules={[
{
required: true,
message: 'Please enter a barcode.'
}
]}
>
<Input />
</Form.Item>
</>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<>
<Form.Item label='Diameter' name='diameter'>
<Select>
<Select.Option value='1.75'>1.75mm</Select.Option>
<Select.Option value='2.85'>2.85mm</Select.Option>
</Select>
</Form.Item>
<Form.Item label='Image' name='image'>
<Upload
listType='picture'
maxCount={1}
onChange={handleImageUpload}
fileList={imageList}
>
<Button icon={<UploadOutlined />}>Upload</Button>
</Upload>
</Form.Item>
<Form.Item label='URL' name='url'>
<Input
prefix={<LinkOutlined />}
placeholder='https://example.com'
/>
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<>
<Form.Item>
<Text>Please review the information:</Text>
</Form.Item>
<Descriptions items={summaryItems} column={1} bordered size='small' />
</>
)
}
]
return (
<>
{contextHolder}
<Form
form={editSpoolForm}
layout='vertical'
onValuesChange={(changedValues, allValues) => {
setEditSpoolFormValues(allValues)
}}
>
<Steps
current={currentStep}
items={steps}
onChange={(current) => {
setCurrentStep(current)
}}
/>
<Divider />
{steps[currentStep].content}
<Divider />
<Flex gap='small' justify='flex-end'>
{currentStep > 0 && (
<Button
onClick={() => {
setCurrentStep(currentStep - 1)
}}
>
Previous
</Button>
)}
{currentStep < steps.length - 1 && (
<Button
type='primary'
onClick={() => {
setCurrentStep(currentStep + 1)
}}
disabled={!nextEnabled}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
onClick={handleEditSpool}
loading={editSpoolLoading}
>
Update
</Button>
)}
</Flex>
</Form>
</>
)
}
EditSpool.propTypes = {
id: PropTypes.string.isRequired,
onOk: PropTypes.func.isRequired
}
export default EditSpool

View File

@ -0,0 +1,443 @@
import PropTypes from 'prop-types'
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import {
Form,
Input,
InputNumber,
Button,
message,
Typography,
Select,
Flex,
Steps,
Divider,
ColorPicker,
Upload,
Descriptions,
Badge
} from 'antd'
import { UploadOutlined, LinkOutlined } from '@ant-design/icons'
const { Text } = Typography
const initialNewSpoolForm = {
name: '',
brand: '',
type: '',
price: 0,
color: '#FFFFFF',
diameter: '1.75',
image: null,
url: '',
barcode: '',
filament: null,
currentWeight: 0
}
const NewSpool = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [filaments, setFilaments] = useState([])
const [newSpoolLoading, setNewSpoolLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newSpoolForm] = Form.useForm()
const [newSpoolFormValues, setNewSpoolFormValues] =
useState(initialNewSpoolForm)
const [imageList, setImageList] = useState([])
const newSpoolFormUpdateValues = Form.useWatch([], newSpoolForm)
useEffect(() => {
const fetchFilaments = async () => {
try {
const response = await axios.get('http://localhost:8080/filaments', {
withCredentials: true
})
setFilaments(response.data)
} catch (error) {
messageApi.error('Error fetching filaments: ' + error.message)
}
}
fetchFilaments()
}, [messageApi])
React.useEffect(() => {
newSpoolForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newSpoolForm, newSpoolFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newSpoolFormValues.name
},
{
key: 'brand',
label: 'Brand',
children: newSpoolFormValues.brand
},
{
key: 'type',
label: 'Material',
children: newSpoolFormValues.type
},
{
key: 'price',
label: 'Price',
children: '£' + newSpoolFormValues.price + ' per kg'
},
{
key: 'color',
label: 'Colour',
children: (
<Badge
color={newSpoolFormValues.color}
text={newSpoolFormValues.color}
/>
)
},
{
key: 'diameter',
label: 'Diameter',
children: newSpoolFormValues.diameter + 'mm'
},
{
key: 'density',
label: 'Density',
children: newSpoolFormValues.diameter + 'g/cm³'
},
{
key: 'image',
label: 'Image',
children: (
<img src={newSpoolFormValues.image} style={{ width: 128 }}></img>
)
},
{
key: 'url',
label: 'URL',
children: newSpoolFormValues.url
},
{
key: 'barcode',
label: 'Barcode',
children: newSpoolFormValues.barcode
},
{
key: 'filament',
label: 'Filament',
children: newSpoolFormValues.filament?.name || 'N/A'
},
{
key: 'currentWeight',
label: 'Current Weight',
children: newSpoolFormValues.currentWeight + 'g'
}
]
React.useEffect(() => {
if (reset) {
newSpoolForm.resetFields()
}
}, [reset, newSpoolForm])
const handleNewSpool = async () => {
setNewSpoolLoading(true)
try {
await axios.post(`http://localhost:8080/spools`, newSpoolFormValues, {
withCredentials: true
})
messageApi.success('New spool created successfully.')
onOk()
} catch (error) {
messageApi.error('Error creating new spool: ' + error.message)
} finally {
setNewSpoolLoading(false)
}
}
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
const handleImageUpload = async ({ file, fileList }) => {
if (fileList.length === 0) {
setImageList(fileList)
newSpoolForm.setFieldsValue({ image: '' })
return
}
const base64 = await getBase64(file)
setNewSpoolFormValues((prevValues) => ({
...prevValues,
image: base64
}))
fileList[0].name = 'Spool Image'
setImageList(fileList)
newSpoolForm.setFieldsValue({ image: base64 })
}
const steps = [
{
title: 'Required',
key: 'required',
content: (
<>
<Form.Item>
<Text>Required information:</Text>
</Form.Item>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Brand'
name='brand'
rules={[
{
required: true,
message: 'Please enter a brand.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Material'
name='type'
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>
<Form.Item
label='Price'
name='price'
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return '£'
return `£${value}`
}}
step={0.01}
style={{ width: '100%' }}
addonAfter='per kg'
/>
</Form.Item>
<Form.Item
label='Colour'
name='color'
rules={[
{
required: true,
message: 'Please select a colour.'
}
]}
>
<ColorPicker />
</Form.Item>
<Form.Item
label='Filament'
name='filament'
rules={[
{
required: true,
message: 'Please select a filament.'
}
]}
>
<Select
showSearch
optionFilterProp='children'
filterOption={(input, option) =>
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{filaments.map((filament) => (
<Select.Option key={filament._id} value={filament._id}>
{filament.name} ({filament.brand} - {filament.type})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label='Current Weight'
name='currentWeight'
rules={[
{
required: true,
message: 'Please enter the current weight.'
}
]}
>
<InputNumber
min={0}
step={1}
style={{ width: '100%' }}
addonAfter='g'
/>
</Form.Item>
<Form.Item
label='Barcode'
name='barcode'
rules={[
{
required: true,
message: 'Please enter a barcode.'
}
]}
>
<Input />
</Form.Item>
</>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<>
<Form.Item>
<Text>Optional information:</Text>
</Form.Item>
<Form.Item label='Diameter' name='diameter'>
<Select>
<Select.Option value='1.75'>1.75mm</Select.Option>
<Select.Option value='2.85'>2.85mm</Select.Option>
</Select>
</Form.Item>
<Form.Item label='Image' name='image'>
<Upload
listType='picture'
maxCount={1}
onChange={handleImageUpload}
fileList={imageList}
>
<Button icon={<UploadOutlined />}>Upload</Button>
</Upload>
</Form.Item>
<Form.Item label='URL' name='url'>
<Input
prefix={<LinkOutlined />}
placeholder='https://example.com'
/>
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<>
<Form.Item>
<Text>Please review the information:</Text>
</Form.Item>
<Descriptions items={summaryItems} column={1} bordered size='small' />
</>
)
}
]
return (
<>
{contextHolder}
<Form
form={newSpoolForm}
layout='vertical'
onValuesChange={(changedValues, allValues) => {
setNewSpoolFormValues(allValues)
}}
>
<Steps
current={currentStep}
items={steps}
onChange={(current) => {
setCurrentStep(current)
}}
/>
<Divider />
{steps[currentStep].content}
<Divider />
<Flex gap='small' justify='flex-end'>
{currentStep > 0 && (
<Button
onClick={() => {
setCurrentStep(currentStep - 1)
}}
>
Previous
</Button>
)}
{currentStep < steps.length - 1 && (
<Button
type='primary'
onClick={() => {
setCurrentStep(currentStep + 1)
}}
disabled={!nextEnabled}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
onClick={handleNewSpool}
loading={newSpoolLoading}
>
Create
</Button>
)}
</Flex>
</Form>
</>
)
}
NewSpool.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired
}
export default NewSpool

View File

@ -0,0 +1,262 @@
// src/filaments.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
import {
Table,
Badge,
Button,
Flex,
Space,
Modal,
message,
Dropdown
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
ReloadOutlined,
InfoCircleOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import NewFilament from './Filaments/NewFilament'
import IdText from '../common/IdText'
import FilamentIcon from '../../Icons/FilamentIcon'
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const Filaments = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
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])
useEffect(() => {
// Fetch initial data
if (authenticated) {
fetchFilamentsData()
}
}, [authenticated, fetchFilamentsData])
const getFilamentActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/filaments/info?filamentId=${id}`)
}
}
}
}
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: 'icon',
width: 40,
fixed: 'left',
render: () => <FilamentIcon></FilamentIcon>
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'filament'} longId={false} />
},
{
title: 'Vendor',
dataIndex: 'brand',
key: 'brand',
width: 200
},
{
title: 'Material',
dataIndex: 'type',
width: 90,
key: 'material'
},
{
title: 'Price',
dataIndex: 'price',
width: 120,
key: 'price',
render: (price) => {
return '£' + price + ' per kg'
}
},
{
title: 'Colour',
dataIndex: 'color',
key: 'color',
width: 120,
render: (color) => {
return <Badge color={color} text={color} />
}
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/management/filaments/info?filamentId=${record._id}`)
}
/>
<Dropdown menu={getFilamentActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
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)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={filamentsData}
className={styles.customTable}
columns={columns}
pagination={false}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
/>
</Flex>
<Modal
open={newFilamentOpen}
footer={null}
width={700}
onCancel={() => {
setNewFilamentOpen(false)
}}
destroyOnClose
>
<NewFilament
onOk={() => {
setNewFilamentOpen(false)
fetchFilamentsData()
}}
reset={newFilamentOpen}
/>
</Modal>
</>
)
}
export default Filaments

View File

@ -0,0 +1,445 @@
import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Badge,
Typography,
Flex,
Form,
Input,
InputNumber,
ColorPicker,
Select
} from 'antd'
import {
LoadingOutlined,
ReloadOutlined,
EditOutlined,
CheckOutlined,
CloseOutlined,
ExportOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import moment from 'moment'
const { Title, Link } = Typography
const FilamentInfo = () => {
const [filamentData, setFilamentData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const filamentId = new URLSearchParams(location.search).get('filamentId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
useEffect(() => {
if (filamentId) {
fetchFilamentDetails()
}
}, [filamentId])
useEffect(() => {
if (filamentData) {
form.setFieldsValue({
name: filamentData.name || '',
brand: filamentData.brand || '',
type: filamentData.type || '',
price: filamentData.price || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
url: filamentData.url || '',
barcode: filamentData.barcode || '',
emptySpoolWeight: filamentData.emptySpoolWeight || ''
})
}
}, [filamentData, form])
const fetchFilamentDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/filaments/${filamentId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setFilamentData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch filament details')
messageApi.error('Failed to fetch filament details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
setIsEditing(true)
}
const cancelEditing = () => {
// Reset form values to original data
if (filamentData) {
form.setFieldsValue({
name: filamentData.name || '',
brand: filamentData.brand || '',
type: filamentData.type || '',
price: filamentData.price || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
url: filamentData.url || '',
barcode: filamentData.barcode || '',
emptySpoolWeight: filamentData.emptySpoolWeight || ''
})
}
setIsEditing(false)
}
const updateFilamentInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/filaments/${filamentId}`,
{
name: values.name,
brand: values.brand,
type: values.type,
price: values.price,
color: values.color,
diameter: values.diameter,
density: values.density,
url: values.url,
barcode: values.barcode,
emptySpoolWeight: values.emptySpoolWeight
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
// Update the local state with the new values
setFilamentData({ ...filamentData, ...values })
setIsEditing(false)
messageApi.success('Filament information updated successfully')
} catch (err) {
if (err.errorFields) {
// This is a form validation error
return
}
console.error('Failed to update filament information:', err)
messageApi.error('Failed to update filament information')
} finally {
setLoading(false)
}
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !filamentData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Filament not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchFilamentDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Filament Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateFilamentInfo}
loading={loading}
></Button>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
></Button>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing}></Button>
)}
</Space>
</Flex>
<Form
form={form}
layout='vertical'
initialValues={{
name: filamentData.name || '',
brand: filamentData.brand || '',
type: filamentData.type || '',
price: filamentData.price || 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' span={1}>
{(() => {
if (filamentData.createdAt) {
return moment(filamentData.createdAt.$date).format(
'YYYY-MM-DD HH:mm:ss'
)
}
return 'N/A'
})()}
</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='Brand'>
{isEditing ? (
<Form.Item
name='brand'
rules={[
{ required: true, message: 'Please enter a brand' },
{ max: 100, message: 'Brand cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter brand' />
</Form.Item>
) : (
filamentData.brand || 'n/a'
)}
</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='Price'>
{isEditing ? (
<Form.Item
name='price'
style={{ margin: 0 }}
rules={[{ required: true, message: 'Please enter a price' }]}
>
<InputNumber
placeholder='Enter price'
addonBefore='£'
addonAfter='per kg'
min={0}
step={0.01}
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.price ? (
`£${filamentData.price} per kg`
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Colour'>
{isEditing ? (
<Form.Item name='color' style={{ margin: 0 }}>
<ColorPicker format='hex' showText />
</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>
)
}
export default FilamentInfo

View File

@ -0,0 +1,432 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import axios from 'axios'
import {
Form,
Input,
InputNumber,
Button,
message,
Typography,
Select,
Flex,
Steps,
Col,
Row,
Divider,
ColorPicker,
Upload,
Descriptions,
Badge
} from 'antd'
import { UploadOutlined, LinkOutlined } from '@ant-design/icons'
const { Title, Text } = Typography
const initialNewFilamentForm = {
name: '',
brand: '',
type: '',
price: 0,
color: '#FFFFFF',
diameter: '1.75',
image: null,
url: '',
barcode: ''
}
const NewFilament = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newFilamentLoading, setNewFilamentLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newFilamentForm] = Form.useForm()
const [newFilamentFormValues, setNewFilamentFormValues] = useState(
initialNewFilamentForm
)
const [imageList, setImageList] = useState([])
const newFilamentFormUpdateValues = Form.useWatch([], newFilamentForm)
React.useEffect(() => {
newFilamentForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newFilamentForm, newFilamentFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newFilamentFormValues.name
},
{
key: 'brand',
label: 'Brand',
children: newFilamentFormValues.brand
},
{
key: 'type',
label: 'Material',
children: newFilamentFormValues.type
},
{
key: 'price',
label: 'Price',
children: '£' + newFilamentFormValues.price + ' per kg'
},
{
key: 'color',
label: 'Colour',
children: (
<Badge
color={newFilamentFormValues.color}
text={newFilamentFormValues.color}
/>
)
},
{
key: 'diameter',
label: 'Diameter',
children: newFilamentFormValues.diameter + 'mm'
},
{
key: 'density',
label: 'Density',
children: newFilamentFormValues.diameter + 'g/cm³'
},
{
key: 'image',
label: 'Image',
children: (
<img src={newFilamentFormValues.image} style={{ width: 128 }}></img>
)
},
{
key: 'url',
label: 'URL',
children: newFilamentFormValues.url
},
{
key: 'barcode',
label: 'Barcode',
children: newFilamentFormValues.barcode
}
]
React.useEffect(() => {
if (reset) {
newFilamentForm.resetFields()
}
}, [reset, newFilamentForm])
const handleNewFilament = async () => {
setNewFilamentLoading(true)
try {
await axios.post(
`http://localhost:8080/filaments`,
newFilamentFormValues,
{
withCredentials: true // Important for including cookies
}
)
messageApi.success('New filament created successfully.')
onOk()
} catch (error) {
messageApi.error('Error creating new filament: ' + error.message)
} finally {
setNewFilamentLoading(false)
}
}
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
const handleImageUpload = async ({ file, fileList }) => {
console.log(fileList)
if (fileList.length === 0) {
setImageList(fileList)
newFilamentForm.setFieldsValue({ image: '' })
return
}
const base64 = await getBase64(file)
setNewFilamentFormValues((prevValues) => ({
...prevValues,
image: base64
}))
fileList[0].name = 'Filament Image'
setImageList(fileList)
newFilamentForm.setFieldsValue({ image: base64 })
}
const steps = [
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Brand'
name='brand'
rules={[
{
required: true,
message: 'Please enter a brand.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Material'
name='type'
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>
<Form.Item
label='Price'
name='price'
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return '£'
return `£${value}`
}}
step={0.01}
style={{ width: '100%' }}
addonAfter='per kg'
/>
</Form.Item>
<Form.Item
label='Diamenter'
name='diameter'
rules={[
{
required: true,
message: 'Please enter a density.'
}
]}
>
<Select>
<Select.Option value='1.75'>1.75mm</Select.Option>
<Select.Option value='2.85'>2.85mm</Select.Option>
<Select.Option value='3.00'>3.00mm</Select.Option>
</Select>
</Form.Item>
<Form.Item
label='Density'
name='density'
rules={[
{
required: true,
message: 'Please enter a density.'
}
]}
>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return ''
return `${value}`
}}
step={0.01}
style={{ width: '100%' }}
addonAfter='g/cm³'
/>
</Form.Item>
<Form.Item
label='Empty Spool Weight'
name='emptySpoolWeight'
rules={[
{
required: true,
message: 'Please enter an empty spool weight'
}
]}
>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return ''
return `${value}`
}}
step={0.01}
style={{ width: '100%' }}
addonAfter='g'
/>
</Form.Item>
</>
)
},
{
title: 'Optional',
key: 'optional',
content: (
<>
<Form.Item>
<Text>Optional information:</Text>
</Form.Item>
<Form.Item
label='Colour'
name='color'
getValueFromEvent={(color) => {
return '#' + color.toHex()
}}
>
<ColorPicker showText disabledAlpha />
</Form.Item>
<Form.Item
label='Image'
name='image'
getValueFromEvent={(e) => (Array.isArray(e) ? e : e && e.fileList)}
>
<Upload
listType='picture'
name='Filament Picture'
maxCount={1}
className='upload-list-inline'
fileList={imageList}
beforeUpload={() => false} // Prevent automatic upload
onChange={handleImageUpload}
>
<Button icon={<UploadOutlined />}>Upload</Button>
</Upload>
</Form.Item>
<Form.Item label='URL' name='url'>
<Input prefix={<LinkOutlined />} />
</Form.Item>
<Form.Item label='Barcode' name='barcode'>
<Input prefix={<LinkOutlined />} />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'done',
content: (
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
)
}
]
return (
<Row>
{contextHolder}
<Col flex={1}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</Col>
<Col>
<Divider type={'vertical'} style={{ height: '100%' }} />
</Col>
<Col flex={3} style={{ paddingLeft: 15, maxWidth: 450 }}>
<Flex vertical={'true'}>
<Title level={2} style={{ marginTop: 0 }}>
New Filament
</Title>
<Form
name='basic'
autoComplete='off'
form={newFilamentForm}
onFinish={handleNewFilament}
onValuesChange={(changedValues) =>
setNewFilamentFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewFilamentForm}
>
{steps[currentStep].content}
<Flex justify={'end'}>
<Button
style={{
margin: '0 8px'
}}
onClick={() => setCurrentStep(currentStep - 1)}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newFilamentLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Col>
</Row>
)
}
NewFilament.propTypes = {
reset: PropTypes.bool.isRequired,
onOk: PropTypes.func.isRequired
}
export default NewFilament

View File

@ -0,0 +1,235 @@
// src/gcodefiles.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
import { Table, Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
DownloadOutlined,
ReloadOutlined,
InfoCircleOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewPart from './Parts/NewPart'
import PartIcon from '../../Icons/PartIcon'
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const Parts = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const [partsData, setPartsData] = useState([])
const [newPartOpen, setNewPartOpen] = useState(false)
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext)
const fetchPartsData = useCallback(async () => {
try {
const response = await axios.get('http://localhost:8080/parts', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setPartsData(response.data)
setLoading(false)
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (error) {
if (error.response) {
messageApi.error(
'Error updating printer details:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [messageApi])
useEffect(() => {
if (authenticated) {
fetchPartsData()
}
}, [authenticated, fetchPartsData])
const getPartActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
},
{
label: 'Download',
key: 'download',
icon: <DownloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/parts/info?partId=${id}`)
}
}
}
}
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <PartIcon></PartIcon>
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'part'} longId={false} />
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/management/parts/info?partId=${record._id}`)
}
/>
<Dropdown menu={getPartActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const actionItems = {
items: [
{
label: 'New Part',
key: 'newPart',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchPartsData()
} else if (key === 'newPart') {
setNewPartOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={partsData}
columns={columns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
/>
</Flex>
<Modal
open={newPartOpen}
footer={null}
width={700}
onCancel={() => {
setNewPartOpen(false)
}}
>
<NewPart
onOk={() => {
setNewPartOpen(false)
fetchPartsData()
}}
reset={newPartOpen}
/>
</Modal>
</>
)
}
export default Parts

View File

@ -0,0 +1,471 @@
import PropTypes from 'prop-types'
import React, { useState, useContext, useEffect, useRef } from 'react'
import axios from 'axios'
import {
Form,
Input,
Button,
message,
Typography,
Flex,
Steps,
Divider,
Upload,
Descriptions,
Modal
} from 'antd'
import { DeleteOutlined, EyeOutlined } from '@ant-design/icons'
import { AuthContext } from '../../../Auth/AuthContext'
import PartIcon from '../../../Icons/PartIcon'
import { StlViewer } from 'react-stl-viewer'
const { Dragger } = Upload
const { Title } = Typography
const initialNewPartsForm = { parts: [{ name: 'Test' }] }
const NewPart = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newPartLoading, setNewPartLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newPartsForm] = Form.useForm()
const [newPartsFormValues, setNewPartsFormValues] =
useState(initialNewPartsForm)
// Store files and their object URLs
const [fileList, setFileList] = useState([])
const [fileObjectUrls, setFileObjectUrls] = useState({})
const [names, setNames] = useState({})
// Preview modal state
const [previewVisible, setPreviewVisible] = useState(false)
const [previewFile, setPreviewFile] = useState(null)
const [stlLoading, setStlLoading] = useState(false)
const newPartsFormUpdateValues = Form.useWatch([], newPartsForm)
const { token, authenticated } = useContext(AuthContext)
// Timer reference for delayed STL rendering
const stlTimerRef = useRef(null)
// Validate form fields
useEffect(() => {
if (currentStep === 0) {
// For combined upload/files step
setNextEnabled(fileList.length > 0)
} else {
newPartsForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}
}, [newPartsForm, newPartsFormUpdateValues, fileList, currentStep])
// Handle reset
useEffect(() => {
if (reset) {
newPartsForm.resetFields()
setFileList([])
setFileObjectUrls({})
setNames({})
setCurrentStep(0)
}
}, [reset, newPartsForm])
// Clean up object URLs when component unmounts
useEffect(() => {
return () => {
Object.values(fileObjectUrls).forEach((url) => {
URL.revokeObjectURL(url)
})
if (stlTimerRef.current) {
clearTimeout(stlTimerRef.current)
}
}
}, [fileObjectUrls])
// Create a summary of all parts for the final step
const summaryItems = fileList
.map((file, index) => ({
key: file.uid,
label: `Part ${index + 1}`,
children: names[file.uid] || file.name.replace(/\.[^/.]+$/, '')
}))
.concat([
{
key: 'name',
label: 'Product Name',
children: newPartsFormValues.name
}
])
// Handle file upload
const handleFileUpload = async (files) => {
if (!authenticated) {
return
}
setNewPartLoading(true)
try {
// First create the part entries
const partsData = []
for (const file of files) {
const partName = names[file.uid] || file.name.replace(/\.[^/.]+$/, '')
const partData = {
name: partName,
partInfo: {}
}
const response = await axios.post(
`http://localhost:8080/parts`,
partData,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
// Now upload the actual file content
const formData = new FormData()
formData.append('partFile', file)
await axios.post(
`http://localhost:8080/parts/${response.data._id}/content`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`
}
}
)
partsData.push({
id: response.data._id,
name: partName
})
}
// Create product with all the parts references
await axios.post(`http://localhost:8080/products`, newPartsFormValues, {
headers: {
Authorization: `Bearer ${token}`
}
})
messageApi.success(`Parts and product created successfully!`)
onOk()
} catch (error) {
messageApi.error('Error creating parts: ' + error.message)
} finally {
setNewPartLoading(false)
}
}
// Handle file name change
const handleFileNameChange = (uid, name) => {
setNames((prev) => ({
...prev,
[uid]: name
}))
}
// Preview STL file
const handlePreview = (file) => {
setPreviewFile(file)
setPreviewVisible(true)
setStlLoading(true)
// Delay the rendering of the STL viewer to fix glitch
if (stlTimerRef.current) {
clearTimeout(stlTimerRef.current)
}
stlTimerRef.current = setTimeout(() => {
setStlLoading(false)
}, 300)
}
// Add file to list
const handleAddFile = (file) => {
// Create object URL for preview
const objectUrl = URL.createObjectURL(file)
setNewPartsFormValues((prev) => ({
parts: [
...prev.parts,
{
name: file.name,
size: file.size,
objectUrl: objectUrl
}
]
}))
console.log(newPartsFormValues)
newPartsForm.setFormValues({
parts: [
...newPartsFormValues.parts,
{
name: file.name,
size: file.size,
objectUrl: objectUrl
}
]
})
// Set default name (filename without extension)
const defaultName = file.name.replace(/\.[^/.]+$/, '')
setNames((prev) => ({
...prev,
[file.uid]: defaultName
}))
return false // Prevent default upload behavior
}
// Combined upload and files content for step 1
const combinedUploadFilesContent = (
<>
{fileList.length > 0 && (
<Form.List name='parts'>
<Flex vertical>
{(parts, { remove }) => (
<>
{parts.map((part) => (
<Flex key={part.uid}>
<Form.Item name={['name']}>
<Input
placeholder='Part name'
value={names['file.uid']}
onChange={(e) =>
handleFileNameChange('file.uid', e.target.value)
}
/>
</Form.Item>
<Form.Item>
<Button
key='preview'
icon={<EyeOutlined />}
onClick={() => handlePreview(part.file)}
></Button>
,
<Button
key='delete'
danger
icon={<DeleteOutlined />}
onClick={() => remove(part.uid)}
/>
</Form.Item>
</Flex>
))}
</>
)}
</Flex>
</Form.List>
)}
<Form.Item
rules={[
{
required: true,
message: 'Please upload at least one 3D Model file.'
}
]}
style={{ height: '100%' }}
>
<Dragger
name='Parts'
multiple={true}
fileList={[]} // Hide file list in dragger since we're showing our custom list above
showUploadList={false}
beforeUpload={handleAddFile}
customRequest={({ onSuccess }) => {
setTimeout(() => {
onSuccess('ok')
}, 0)
}}
>
<Flex style={{ height: '100%' }} vertical>
<p className='ant-upload-drag-icon'>
<PartIcon />
</p>
<p className='ant-upload-text'>
Click or drag 3D Model files here.
</p>
<p className='ant-upload-hint'>
Supported file extensions: .stl, .3mf
</p>
</Flex>
</Dragger>
</Form.Item>
</>
)
// Steps for the form (now with combined step)
const steps = [
{
title: 'Upload Files',
key: 'upload-files',
content: combinedUploadFilesContent
},
{
title: 'Details',
key: 'details',
content: (
<Flex vertical gap='middle'>
<Form.Item
label='Product Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a product name.'
}
]}
>
<Input />
</Form.Item>
</Flex>
)
},
{
title: 'Summary',
key: 'done',
content: (
<>
<Form.Item>
<Descriptions column={1} items={summaryItems} size='small' />
</Form.Item>
</>
)
}
]
return (
<Flex gap='middle'>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
<Divider type='vertical' style={{ height: 'unset' }} />
<Flex vertical style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
New Part
</Title>
<Form
name='basic'
autoComplete='off'
form={newPartsForm}
onFinish={() => handleFileUpload(fileList)}
onValuesChange={(changedValues) => {
console.log(changedValues)
setNewPartsFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}}
initialValues={initialNewPartsForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
<Flex justify='end'>
<Button
style={{
margin: '0 8px'
}}
onClick={() => {
setCurrentStep(currentStep - 1)
setNextEnabled(true)
}}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
setNextEnabled(false) // Reset and let validation determine it
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button type='primary' htmlType='submit' loading={newPartLoading}>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
{/* STL Preview Modal */}
<Modal
open={previewVisible}
footer={null}
onCancel={() => {
setPreviewVisible(false)
setPreviewFile(null)
if (stlTimerRef.current) {
clearTimeout(stlTimerRef.current)
}
}}
style={{ top: 30 }}
width={'90%'}
>
<Flex style={{ minWidth: '100%', minHeight: '80vh' }}>
{previewFile && !stlLoading && (
<div style={{ flexGrow: 1 }}>
<StlViewer
url={fileObjectUrls[previewFile.uid]}
orbitControls
shadows
style={{ height: '80vh', width: '100%' }}
modelProps={{
color: '#008675'
}}
/>
</div>
)}
{stlLoading && (
<div
style={{
flexGrow: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '80vh'
}}
>
Loading 3D model...
</div>
)}
</Flex>
</Modal>
</Flex>
)
}
NewPart.propTypes = {
reset: PropTypes.bool.isRequired,
onOk: PropTypes.func.isRequired
}
export default NewPart

View File

@ -0,0 +1,273 @@
import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Card,
Flex,
Form,
Input
} from 'antd'
import {
LoadingOutlined,
EditOutlined,
ReloadOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import moment from 'moment'
const { Title } = Typography
import { StlViewer } from 'react-stl-viewer'
const PartInfo = () => {
const [partData, setPartData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const partId = new URLSearchParams(location.search).get('partId')
const [partFileObjectId, setPartFileObjectId] = useState(null)
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
useEffect(() => {
async function fetchData() {
await fetchPartDetails()
await fetchPartContent()
}
if (partId) {
fetchData()
}
}, [partId])
useEffect(() => {
if (partData) {
form.setFieldsValue({
name: partData.name || ''
})
}
}, [partData, form])
const fetchPartDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/parts/${partId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setPartData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch Part details')
console.log(err)
messageApi.error('Failed to fetch Part details')
} finally {
setFetchLoading(false)
}
}
const fetchPartContent = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/parts/${partId}/content`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true,
responseType: 'blob'
}
)
setPartFileObjectId(URL.createObjectURL(response.data))
setError(null)
} catch (err) {
setError('Failed to fetch Part content')
console.log(err)
messageApi.error('Failed to fetch Part content')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
setIsEditing(true)
}
const cancelEditing = () => {
form.setFieldsValue({
name: partData?.name || ''
})
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/parts/${partId}`,
{
name: values.name
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
// Update the local state with the new name
setPartData({ ...partData, name: values.name })
setIsEditing(false)
messageApi.success('Part information updated successfully')
} catch (err) {
if (err.errorFields) {
// This is a form validation error
return
}
console.error('Failed to update part information:', err)
messageApi.error('Failed to update part information')
} finally {
setLoading(false)
}
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !partData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Part not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchPartDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<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={form}
layout='vertical'
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' span={1}>
{(() => {
if (partData.createdAt) {
return moment(partData.createdAt.$date).format(
'YYYY-MM-DD HH:mm:ss'
)
}
return 'N/A'
})()}
</Descriptions.Item>
<Descriptions.Item label='Name' span={2}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{ required: true, message: 'Please enter a part name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter part name' />
</Form.Item>
) : (
partData.name || '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' } }}>
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
</Card>
</div>
)
}
export default PartInfo

View File

@ -0,0 +1,237 @@
// src/gcodefiles.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
import { Table, Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
DownloadOutlined,
ReloadOutlined,
InfoCircleOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewProduct from './Products/NewProduct'
import ProductIcon from '../../Icons/ProductIcon'
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const Products = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const [productsData, setProductsData] = useState([])
const [newProductOpen, setNewProductOpen] = useState(false)
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext)
const fetchProductsData = useCallback(async () => {
try {
const response = await axios.get('http://localhost:8080/products', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setProductsData(response.data)
setLoading(false)
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (error) {
if (error.response) {
messageApi.error(
'Error updating printer details:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [messageApi])
useEffect(() => {
if (authenticated) {
fetchProductsData()
}
}, [authenticated, fetchProductsData])
const getProductActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
},
{
label: 'Download',
key: 'download',
icon: <DownloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/products/info?productId=${id}`)
}
}
}
}
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <ProductIcon></ProductIcon>
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
fixed: 'left',
width: 165,
render: (text) => <IdText id={text} type={'product'} longId={false} />
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/management/products/info?productId=${record._id}`)
}
/>
<Dropdown menu={getProductActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const actionItems = {
items: [
{
label: 'New Product',
key: 'newProduct',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchProductsData()
} else if (key === 'newProduct') {
setNewProductOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={productsData}
columns={columns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
/>
</Flex>
<Modal
open={newProductOpen}
footer={null}
width={700}
onCancel={() => {
setNewProductOpen(false)
}}
destroyOnClose
>
<NewProduct
onOk={() => {
setNewProductOpen(false)
fetchProductsData()
}}
reset={newProductOpen}
/>
</Modal>
</>
)
}
export default Products

View File

@ -0,0 +1,213 @@
import PropTypes from 'prop-types'
import React, { useState, useContext } from 'react'
import axios from 'axios'
import {
Form,
Input,
Button,
message,
Typography,
Flex,
Steps,
Divider,
Descriptions
} from 'antd'
import { AuthContext } from '../../../Auth/AuthContext'
const { Title } = Typography
const initialNewProductForm = {
productInfo: {},
printTimeMins: 0,
price: 0
}
//const chunkSize = 5000
const NewProduct = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newProductLoading, setNewProductLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newProductForm] = Form.useForm()
const [newProductFormValues, setNewProductFormValues] = useState(
initialNewProductForm
)
const newProductFormUpdateValues = Form.useWatch([], newProductForm)
const { token, authenticated } = useContext(AuthContext)
React.useEffect(() => {
newProductForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newProductForm, newProductFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newProductFormValues.name
}
]
React.useEffect(() => {
if (reset) {
newProductForm.resetFields()
}
}, [reset, newProductForm])
const handleNewProduct = async () => {
if (!authenticated) {
return
}
setNewProductLoading(true)
try {
await axios.post(`http://localhost:8080/products`, newProductFormValues, {
headers: {
Authorization: `Bearer ${token}`
}
})
messageApi.success(`Product created successfully.`)
onOk()
} catch (error) {
messageApi.error('Error creating new product file: ' + error.message)
} finally {
setNewProductLoading(false)
}
}
const steps = [
{
title: 'Parts',
key: 'parts',
content: (
<>
<Form.Item
style={{ height: '100%' }}
getValueFromEvent={(e) => (Array.isArray(e) ? e : e && e.fileList)}
></Form.Item>
</>
)
},
{
title: 'Details',
key: 'details',
content: (
<Flex vertical gap={'middle'}>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
</Flex>
)
},
{
title: 'Summary',
key: 'done',
content: (
<>
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
</>
)
}
]
return (
<Flex gap={'middle'}>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
New Product
</Title>
<Form
name='basic'
autoComplete='off'
form={newProductForm}
onFinish={handleNewProduct}
onValuesChange={(changedValues) =>
setNewProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewProductForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
<Flex justify={'end'}>
<Button
style={{
margin: '0 8px'
}}
onClick={() => {
setCurrentStep(currentStep - 1)
setNextEnabled(true)
}}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
setNextEnabled(false)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newProductLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}
NewProduct.propTypes = {
reset: PropTypes.bool.isRequired,
onOk: PropTypes.func.isRequired
}
export default NewProduct

View File

@ -0,0 +1,273 @@
import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Card,
Flex,
Form,
Input
} from 'antd'
import {
LoadingOutlined,
EditOutlined,
ReloadOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import moment from 'moment'
const { Title } = Typography
import { StlViewer } from 'react-stl-viewer'
const ProductInfo = () => {
const [productData, setProductData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const productId = new URLSearchParams(location.search).get('productId')
const [productFileObjectId, setProductFileObjectId] = useState(null)
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
useEffect(() => {
async function fetchData() {
await fetchProductDetails()
await fetchProductContent()
}
if (productId) {
fetchData()
}
}, [productId])
useEffect(() => {
if (productData) {
form.setFieldsValue({
name: productData.name || ''
})
}
}, [productData, form])
const fetchProductDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/products/${productId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setProductData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch Product details')
console.log(err)
messageApi.error('Failed to fetch Product details')
} finally {
setFetchLoading(false)
}
}
const fetchProductContent = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/products/${productId}/content`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true,
responseType: 'blob'
}
)
setProductFileObjectId(URL.createObjectURL(response.data))
setError(null)
} catch (err) {
setError('Failed to fetch Product content')
console.log(err)
messageApi.error('Failed to fetch Product content')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
setIsEditing(true)
}
const cancelEditing = () => {
form.setFieldsValue({
name: productData?.name || ''
})
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/products/${productId}`,
{
name: values.name
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
// Update the local state with the new name
setProductData({ ...productData, name: values.name })
setIsEditing(false)
messageApi.success('Product information updated successfully')
} catch (err) {
if (err.errorFields) {
// This is a form validation error
return
}
console.error('Failed to update product information:', err)
messageApi.error('Failed to update product information')
} finally {
setLoading(false)
}
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !productData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Product not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchProductDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<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={form}
layout='vertical'
initialValues={{
name: productData.name || ''
}}
>
<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' span={1}>
{(() => {
if (productData.createdAt) {
return moment(productData.createdAt.$date).format(
'YYYY-MM-DD HH:mm:ss'
)
}
return 'N/A'
})()}
</Descriptions.Item>
<Descriptions.Item label='Name' span={2}>
{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>
</Form>
<Flex
align={'center'}
style={{ marginTop: 32, marginBottom: 12, minHeight: '32px' }}
>
<Title level={5} style={{ margin: 0 }}>
Product Preview
</Title>
</Flex>
<Card styles={{ body: { padding: '10px' } }}>
<StlViewer
url={productFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
</Card>
</div>
)
}
export default ProductInfo

View File

@ -0,0 +1,226 @@
import React, { useEffect, useState, useContext, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
import { Table, Button, Flex, Space, Modal, Dropdown, message } from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
ReloadOutlined,
InfoCircleOutlined,
ShopOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewVendor from './Vendors/NewVendor'
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const Vendors = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const [vendorsData, setVendorsData] = useState([])
const [newVendorOpen, setNewVendorOpen] = useState(false)
const [loading, setLoading] = useState(true)
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])
useEffect(() => {
if (authenticated) {
fetchVendorsData()
}
}, [authenticated, fetchVendorsData])
const getVendorActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/vendors/info?vendorId=${id}`)
}
}
}
}
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <ShopOutlined />
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'vendor'} longId={false} />
},
{
title: 'Website',
dataIndex: 'website',
key: 'website',
width: 200
},
{
title: 'Contact',
dataIndex: 'contact',
key: 'contact',
width: 200
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/management/vendors/info?vendorId=${record._id}`)
}
/>
<Dropdown menu={getVendorActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const actionItems = {
items: [
{
label: 'New Vendor',
key: 'newVendor',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchVendorsData()
} else if (key === 'newVendor') {
setNewVendorOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={vendorsData}
columns={columns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
/>
</Flex>
<Modal
open={newVendorOpen}
onCancel={() => setNewVendorOpen(false)}
footer={null}
destroyOnClose
width={700}
>
<NewVendor
onOk={() => {
setNewVendorOpen(false)
fetchVendorsData()
}}
reset={!newVendorOpen}
/>
</Modal>
</>
)
}
export default Vendors

View File

@ -0,0 +1,205 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import axios from 'axios'
import {
Form,
Input,
Button,
message,
Typography,
Flex,
Steps,
Descriptions,
Divider
} from 'antd'
const { Title } = Typography
const initialNewVendorForm = {
name: '',
website: '',
contact: ''
}
const NewVendor = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newVendorLoading, setNewVendorLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newVendorForm] = Form.useForm()
const [newVendorFormValues, setNewVendorFormValues] =
useState(initialNewVendorForm)
const newVendorFormUpdateValues = Form.useWatch([], newVendorForm)
React.useEffect(() => {
newVendorForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newVendorForm, newVendorFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newVendorFormValues.name
},
{
key: 'website',
label: 'Website',
children: newVendorFormValues.website
},
{
key: 'contact',
label: 'Contact',
children: newVendorFormValues.contact
}
]
React.useEffect(() => {
if (reset) {
newVendorForm.resetFields()
}
}, [reset, newVendorForm])
const handleNewVendor = async () => {
setNewVendorLoading(true)
try {
await axios.post('http://localhost:8080/vendors', newVendorFormValues, {
withCredentials: true
})
messageApi.success('New vendor created successfully.')
onOk()
} catch (error) {
messageApi.error('Error creating new vendor: ' + error.message)
} finally {
setNewVendorLoading(false)
}
}
const steps = [
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Website'
name='website'
rules={[
{
type: 'url',
message: 'Please enter a valid URL'
}
]}
>
<Input />
</Form.Item>
<Form.Item label='Contact' name='contact'>
<Input />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'summary',
content: <Descriptions column={1} items={summaryItems} size={'small'} />
}
]
return (
<Flex gap={'middle'}>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
New Vendor
</Title>
<Form
name='basic'
autoComplete='off'
form={newVendorForm}
onFinish={handleNewVendor}
onValuesChange={(changedValues) =>
setNewVendorFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewVendorForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
<Flex justify={'end'}>
<Button
style={{
margin: '0 8px'
}}
onClick={() => {
setCurrentStep(currentStep - 1)
setNextEnabled(true)
}}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
setNextEnabled(false)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newVendorLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}
NewVendor.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool
}
export default NewVendor

View File

@ -0,0 +1,247 @@
import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Flex,
Form,
Input
} from 'antd'
import {
LoadingOutlined,
EditOutlined,
ReloadOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import moment from 'moment'
const { Title } = Typography
const VendorInfo = () => {
const [vendorData, setVendorData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const vendorId = new URLSearchParams(location.search).get('vendorId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
useEffect(() => {
if (vendorId) {
fetchVendorDetails()
}
}, [vendorId])
useEffect(() => {
if (vendorData) {
form.setFieldsValue({
name: vendorData.name || '',
website: vendorData.website || '',
contact: vendorData.contact || ''
})
}
}, [vendorData, form])
const fetchVendorDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/vendors/${vendorId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setVendorData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch vendor details')
messageApi.error('Failed to fetch vendor details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
setIsEditing(true)
}
const cancelEditing = () => {
form.setFieldsValue({
name: vendorData?.name || '',
website: vendorData?.website || '',
contact: vendorData?.contact || ''
})
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/vendors/${vendorId}`,
{
name: values.name,
website: values.website,
contact: values.contact
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
setVendorData({ ...vendorData, ...values })
setIsEditing(false)
messageApi.success('Vendor information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update vendor information:', err)
messageApi.error('Failed to update vendor information')
} finally {
setLoading(false)
}
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !vendorData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Vendor not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchVendorDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Vendor 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>
<Form form={form} layout='vertical'>
<Descriptions bordered column={2}>
<Descriptions.Item label='ID'>
<IdText id={vendorData._id} type='vendor' />
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(vendorData.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 vendor name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
vendorData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Website'>
{isEditing ? (
<Form.Item
name='website'
rules={[
{ type: 'url', message: 'Please enter a valid URL' },
{
max: 200,
message: 'Website URL cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
vendorData.website
)}
</Descriptions.Item>
<Descriptions.Item label='Contact'>
{isEditing ? (
<Form.Item
name='contact'
rules={[
{
max: 200,
message: 'Contact info cannot exceed 200 characters'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : (
vendorData.contact
)}
</Descriptions.Item>
</Descriptions>
</Form>
</div>
)
}
export default VendorInfo

View File

@ -0,0 +1,325 @@
// src/gcodefiles.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
import {
Table,
Badge,
Button,
Flex,
Space,
Modal,
Dropdown,
Typography,
message
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
DownloadOutlined,
ReloadOutlined,
InfoCircleOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import NewGCodeFile from './GCodeFiles/NewGCodeFile'
import IdText from '../common/IdText'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
const { Text } = Typography
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const GCodeFiles = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const [gcodeFilesData, setGCodeFilesData] = useState([])
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext)
const fetchGCodeFilesData = useCallback(async () => {
try {
const response = await axios.get('http://localhost:8080/gcodefiles', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setGCodeFilesData(response.data)
setLoading(false)
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (error) {
if (error.response) {
messageApi.error(
'Error updating printer details:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [messageApi])
useEffect(() => {
if (authenticated) {
fetchGCodeFilesData()
}
}, [authenticated, fetchGCodeFilesData])
const getGCodeFileActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
},
{
label: 'Download',
key: 'download',
icon: <DownloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/production/gcodefiles/info?gcodeFileId=${id}`)
} else if (key === 'download') {
handleDownloadGCode(
id,
gcodeFilesData.find((file) => file._id === id)?.name + '.gcode'
)
}
}
}
}
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <GCodeFileIcon></GCodeFileIcon>
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left',
render: (text) => <Text ellipsis>{text}</Text>
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'gcodeFile'} longId={false} />
},
{
title: 'Filament',
dataIndex: 'filament',
key: 'filament',
width: 200,
render: (filament) => {
return <Badge color={filament.color} text={filament.name} />
}
},
{
title: 'Price / Cost',
dataIndex: 'price',
key: 'price',
width: 120,
render: (price) => {
return '£' + price.toFixed(2)
}
},
{
title: 'Est. Print Time',
key: 'estimatedPrintingTimeNormalMode',
width: 140,
render: (text, record) => {
return `${record.gcodeFileInfo.estimatedPrintingTimeNormalMode}`
}
},
{
title: 'Created At',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (createdAt) => {
if (createdAt) {
const formattedDate = moment(createdAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(
`/production/gcodefiles/info?gcodeFileId=${record._id}`
)
}
/>
<Dropdown menu={getGCodeFileActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const handleDownloadGCode = async (id, fileName) => {
if (!authenticated) {
return
}
try {
const response = await axios.get(
`http://localhost:8080/gcodefiles/${id}/content`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
setLoading(false)
const fileURL = window.URL.createObjectURL(new Blob([response.data]))
// Create an anchor element and simulate a click to download the file
const fileLink = document.createElement('a')
fileLink.href = fileURL
fileLink.setAttribute('download', fileName)
document.body.appendChild(fileLink)
// Simulate click to download the file
fileLink.click()
// Clean up and remove the anchor element
fileLink.parentNode.removeChild(fileLink)
} catch (error) {
if (error.response) {
messageApi.error(
'Error updating printer details:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
const actionItems = {
items: [
{
label: 'New GCodeFile',
key: 'newGCodeFile',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchGCodeFilesData()
} else if (key === 'newGCodeFile') {
setNewGCodeFileOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={gcodeFilesData}
columns={columns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
/>
</Flex>
<Modal
open={newGCodeFileOpen}
footer={null}
width={700}
onCancel={() => {
setNewGCodeFileOpen(false)
}}
>
<NewGCodeFile
onOk={() => {
setNewGCodeFileOpen(false)
fetchGCodeFilesData()
}}
reset={newGCodeFileOpen}
/>
</Modal>
</>
)
}
export default GCodeFiles

View File

@ -0,0 +1,228 @@
import React, { useEffect, useState, useContext } from 'react'
import axios from 'axios'
import PropTypes from 'prop-types'
import {
Form,
Input,
InputNumber,
Button,
message,
Spin,
Select,
Flex,
ColorPicker,
Upload,
Popconfirm
} from 'antd'
import {
LoadingOutlined,
UploadOutlined,
LinkOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../../Auth/AuthContext'
const EditFilament = ({ id, onOk }) => {
const [messageApi, contextHolder] = message.useMessage()
const [dataLoading, setDataLoading] = useState(false)
const [editFilamentLoading, setEditFilamentLoading] = useState(false)
const [imageList, setImageList] = useState([])
const [editFilamentForm] = Form.useForm()
const [editFilamentFormValues, setEditFilamentFormValues] = useState({})
const { token } = useContext(AuthContext)
useEffect(() => {
// Fetch printer details when the component mounts
const fetchFilamentDetails = async () => {
if (id) {
try {
setDataLoading(true)
const response = await axios.get(
`http://localhost:8080/filaments/${id}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
setDataLoading(false)
editFilamentForm.setFieldsValue(response.data) // Set form values with fetched data
setEditFilamentFormValues(response.data)
} catch (error) {
messageApi.error('Error fetching printer details:' + error.message)
}
}
}
fetchFilamentDetails()
}, [id, editFilamentForm, token, messageApi])
const handleEditFilament = async () => {
setEditFilamentLoading(true)
try {
await axios.put(
`http://localhost:8080/filaments/${id}`,
editFilamentFormValues,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
messageApi.success('Filament details updated successfully.')
onOk()
} catch (error) {
messageApi.error('Error updating filament details: ' + error.message)
} finally {
setEditFilamentLoading(false)
}
}
const handleDeleteFilament = async () => {
try {
await axios.delete(`http://localhost:8080/filaments/${id}`, '', {
headers: {
Authorization: `Bearer ${token}`
}
})
messageApi.success('Filament deleted successfully.')
onOk()
} catch (error) {
messageApi.error('Error updating filament details: ' + error.message)
}
}
const handleImageUpload = ({ file, onSuccess }) => {
const reader = new FileReader()
reader.onload = () => {
onSuccess('ok')
}
reader.readAsDataURL(file)
}
return (
<>
{contextHolder}
<Spin
spinning={dataLoading}
indicator={<LoadingOutlined spin />}
size='large'
>
<Form
name='editFilamentForm'
autoComplete='off'
form={editFilamentForm}
initialValues={editFilamentFormValues}
onFinish={handleEditFilament}
onValuesChange={(changedValues) =>
setEditFilamentFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
>
<Form.Item label='Name' name='name'>
<Input />
</Form.Item>
<Form.Item label='Brand' name='brand'>
<Input />
</Form.Item>
<Form.Item label='Material' name='type'>
<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>
<Form.Item label='Price' name='price'>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return '£'
return `£${value}`
}}
step={0.01}
style={{ width: '100%' }}
addonAfter='per kg'
/>
</Form.Item>
<Form.Item
label='Colour'
name='color'
getValueFromEvent={(color) => {
return '#' + color.toHex()
}}
>
<ColorPicker showText disabledAlpha />
</Form.Item>
<Form.Item label='Diamenter' name='diameter'>
<Select>
<Select.Option value='1.75'>1.75mm</Select.Option>
<Select.Option value='2.85'>2.85mm</Select.Option>
<Select.Option value='3.00'>3.00mm</Select.Option>
</Select>
</Form.Item>
<Form.Item label='Image' name='image'>
<Upload
listType='picture'
maxCount={1}
className='upload-list-inline'
fileList={imageList}
customRequest={handleImageUpload}
onChange={({ fileList }) => {
setImageList(fileList)
}}
>
<Button icon={<UploadOutlined />}>Upload</Button>
</Upload>
</Form.Item>
<Form.Item label='URL' name='url'>
<Input prefix={<LinkOutlined />} />
</Form.Item>
<Form.Item label='Barcode' name='barcode'>
<Input prefix={<LinkOutlined />} />
</Form.Item>
<Form.Item>
<Flex gap='middle' horizontal='true'>
<Button
type='primary'
htmlType='submit'
loading={editFilamentLoading}
>
Update
</Button>
<Popconfirm
title='Delete Filament'
description={
'Are you sure you want to delete ' +
editFilamentFormValues.name +
'?'
}
onConfirm={handleDeleteFilament}
okText='Yes'
cancelText='No'
>
<Button danger>Delete</Button>
</Popconfirm>
</Flex>
</Form.Item>
</Form>
</Spin>
</>
)
}
EditFilament.propTypes = {
id: PropTypes.string.isRequired,
onOk: PropTypes.func.isRequired
}
export default EditFilament

View File

@ -0,0 +1,205 @@
import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import { Descriptions, Spin, Space, Button, message, Badge } from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import moment from 'moment'
import { capitalizeFirstLetter } from '../../utils/Utils.js'
const GCodeFileInfo = () => {
const [gcodeFileData, setGCodeFileData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi] = message.useMessage()
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
useEffect(() => {
if (gcodeFileId) {
fetchFilamentDetails()
}
}, [gcodeFileId])
const fetchFilamentDetails = async () => {
try {
setLoading(true)
const response = await axios.get(
`http://localhost:8080/gcodefiles/${gcodeFileId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setGCodeFileData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch GCodeFile details')
messageApi.error('Failed to fetch GCodeFile details')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !gcodeFileData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'GCodeFile not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchFilamentDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Descriptions title='G Code File Information' bordered column={2}>
<Descriptions.Item label='ID' span={1}>
{gcodeFileData.id ? (
<IdText id={gcodeFileData.id} type='gcodeFile'></IdText>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At' span={1}>
{(() => {
if (gcodeFileData.createdAt) {
return moment(gcodeFileData.createdAt.$date).format(
'YYYY-MM-DD HH:mm:ss'
)
}
return 'N/A'
})()}
</Descriptions.Item>
<Descriptions.Item label='Name'>
{gcodeFileData.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Est Print Time'>
{gcodeFileData.gcodeFileInfo.estimatedPrintingTimeNormalMode || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{gcodeFileData.filament ? (
<Badge
color={gcodeFileData.filament.color}
text={gcodeFileData.filament.name}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID' span={3}>
{gcodeFileData.filament ? (
<IdText
id={gcodeFileData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Infill Density'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillDensity) {
return gcodeFileData.gcodeFileInfo.sparseInfillDensity
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Infill Pattern'>
{(() => {
if (gcodeFileData.gcodeFileInfo.sparseInfillPattern) {
return capitalizeFirstLetter(
gcodeFileData.gcodeFileInfo.sparseInfillPattern
)
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (mm)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedMm) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedMm}mm`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Used (g)'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentUsedG) {
return `${gcodeFileData.gcodeFileInfo.filamentUsedG}g`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Hotend Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.nozzleTemperature) {
return `${gcodeFileData.gcodeFileInfo.nozzleTemperature}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Bed Temperature'>
{(() => {
if (gcodeFileData.gcodeFileInfo.hotPlateTemp) {
return `${gcodeFileData.gcodeFileInfo.hotPlateTemp}°`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Filament Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.filamentSettingsId) {
return `${gcodeFileData.gcodeFileInfo.filamentSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Print Profile'>
{(() => {
if (gcodeFileData.gcodeFileInfo.printSettingsId) {
return `${gcodeFileData.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
<Descriptions.Item label='Image' span={'filled'}>
{gcodeFileData.gcodeFileInfo.thumbnail ? (
<img
src={`data:image/png;base64,${gcodeFileData.gcodeFileInfo.thumbnail.data}`}
alt='GCodeFile'
style={{ maxWidth: '200px' }}
/>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</div>
)
}
export default GCodeFileInfo

View File

@ -0,0 +1,501 @@
import PropTypes from 'prop-types'
import React, { useState, useContext } from 'react'
import axios from 'axios'
import {
capitalizeFirstLetter,
timeStringToMinutes
} from '../../utils/Utils.js'
import {
Form,
Input,
Button,
message,
Typography,
Flex,
Steps,
Divider,
Upload,
Descriptions,
Checkbox,
Spin
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../../../Auth/AuthContext'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import FilamentSelect from '../../common/FilamentSelect'
const { Dragger } = Upload
const { Title } = Typography
const initialNewGCodeFileForm = {
gcodeFileInfo: {},
name: '',
printTimeMins: 0,
price: 0,
file: null,
material: null
}
//const chunkSize = 5000
const NewGCodeFile = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
const [gcodeParsing, setGcodeParsing] = useState(false)
const [filamentSelectFilter, setFilamentSelectFilter] = useState(null)
const [useFilamentSelectFilter, setUseFilamentSelectFilter] = useState(true)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [nextLoading, setNextLoading] = useState(false)
const [newGCodeFileForm] = Form.useForm()
const [newGCodeFileFormValues, setNewGCodeFileFormValues] = useState(
initialNewGCodeFileForm
)
const [gcodeFile, setGCodeFile] = useState(null)
const newGCodeFileFormUpdateValues = Form.useWatch([], newGCodeFileForm)
const { token, authenticated } = useContext(AuthContext)
// eslint-disable-next-line
const fetchFilamentDetails = async () => {
if (!authenticated) {
return
}
if (
newGCodeFileFormValues.filament &&
newGCodeFileFormValues.gcodeFileInfo
) {
try {
setNextLoading(true)
const response = await axios.get(
`http://localhost:8080/filaments/${newGCodeFileFormValues.filament}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
setNextLoading(false)
const price =
(response.data.price / 1000) *
newGCodeFileFormValues.gcodeFileInfo.filament_used_g // convert kg to g and multiply
const printTimeMins = timeStringToMinutes(
newGCodeFileFormValues.gcodeFileInfo
.estimated_printing_time_normal_mode
)
setNewGCodeFileFormValues({
...newGCodeFileFormValues,
price,
printTimeMins
})
} catch (error) {
if (error.response) {
messageApi.error(
'Error fetching filament data:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
}
React.useEffect(() => {
newGCodeFileForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newGCodeFileForm, newGCodeFileFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newGCodeFileFormValues.name
},
{
key: 'price',
label: 'Price / Cost',
children: '£' + newGCodeFileFormValues.price.toFixed(2)
},
{
key: 'sparse_infill_density',
label: 'Infill Density',
children: newGCodeFileFormValues.gcodeFileInfo.sparseInfillDensity
},
{
key: 'sparse_infill_pattern',
label: 'Infill Pattern',
children: capitalizeFirstLetter(
newGCodeFileFormValues.gcodeFileInfo.sparseInfillPattern
)
},
{
key: 'layer_height',
label: 'Layer Height',
children: newGCodeFileFormValues.gcodeFileInfo.layerHeight + 'mm'
},
{
key: 'filamentType',
label: 'Filament Material',
children: newGCodeFileFormValues.gcodeFileInfo.filamentType
},
{
key: 'filamentUsedG',
label: 'Filament Used (g)',
children: newGCodeFileFormValues.gcodeFileInfo.filamentUsedG + 'g'
},
{
key: 'filamentVendor',
label: 'Filament Brand',
children: newGCodeFileFormValues.gcodeFileInfo.filamentVendor
},
{
key: 'hotendTemperature',
label: 'Hotend Temperature',
children: newGCodeFileFormValues.gcodeFileInfo.nozzleTemperature + '°'
},
{
key: 'bedTemperature',
label: 'Bed Temperature',
children: newGCodeFileFormValues.gcodeFileInfo.hotPlateTemp + '°'
},
{
key: 'estimated_printing_time_normal_mode',
label: 'Est. Print Time',
children:
newGCodeFileFormValues.gcodeFileInfo.estimatedPrintingTimeNormalMode
}
]
React.useEffect(() => {
if (reset) {
setCurrentStep(0)
newGCodeFileForm.resetFields()
}
}, [reset, newGCodeFileForm])
const handleNewGCodeFileUpload = async (id) => {
setNewGCodeFileLoading(true)
const formData = new FormData()
formData.append('gcodeFile', gcodeFile)
try {
await axios.post(
`http://localhost:8080/gcodefiles/${id}/content`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`
}
}
)
messageApi.success('Finished uploading!')
resetForm()
onOk()
} catch (error) {
messageApi.error('Error creating new gcode file: ' + error.message)
} finally {
setNewGCodeFileLoading(false)
}
}
const handleNewGCodeFile = async () => {
setNewGCodeFileLoading(true)
try {
const request = await axios.post(
`http://localhost:8080/gcodefiles`,
newGCodeFileFormValues,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
messageApi.info('New G Code file created successfully. Uploading...')
handleNewGCodeFileUpload(request.data._id)
} catch (error) {
messageApi.error('Error creating new gcode file: ' + error.message)
} finally {
setNewGCodeFileLoading(false)
}
}
const handleGetGCodeFileInfo = async (file) => {
try {
setGcodeParsing(true)
// Create a FormData object to send the file
const formData = new FormData()
formData.append('gcodeFile', file)
// Call the API to extract and parse the config block
const request = await axios.post(
`http://localhost:8080/gcodefiles/content`,
formData,
{
withCredentials: true // Important for including cookies
},
{
headers: {
Accept: 'application/json'
}
}
)
// Parse the API response
const parsedConfig = await request.data
// Update state with the parsed config from API
setNewGCodeFileFormValues({
...newGCodeFileFormValues,
gcodeFileInfo: parsedConfig
})
console.log(parsedConfig)
// Update filter settings if filament info is available
if (parsedConfig.filament_type && parsedConfig.filament_diameter) {
setFilamentSelectFilter({
type: parsedConfig.filament_type,
diameter: parsedConfig.filament_diameter
})
}
const fileName = file.name.replace(/\.[^/.]+$/, '')
newGCodeFileForm.setFieldValue('name', fileName)
setNewGCodeFileFormValues((prev) => ({
...prev,
name: fileName
}))
setGCodeFile(file)
setGcodeParsing(false)
setCurrentStep(currentStep + 1)
} catch (error) {
console.error('Error getting G-code file info:', error)
}
}
const resetForm = () => {
newGCodeFileForm.setFieldsValue(initialNewGCodeFileForm)
setNewGCodeFileFormValues(initialNewGCodeFileForm)
setGCodeFile(null)
setGcodeParsing(false)
setCurrentStep(0)
}
const steps = [
{
title: 'Upload',
key: 'upload',
content: (
<>
<Form.Item
rules={[
{
required: true,
message: 'Please upload a gcode file.'
}
]}
name='file'
style={{ height: '100%' }}
getValueFromEvent={(e) => (Array.isArray(e) ? e : e && e.fileList)}
>
<Dragger
name='G Code File'
maxCount={1}
showUploadList={false}
customRequest={({ file, onSuccess }) => {
handleGetGCodeFileInfo(file)
setTimeout(() => {
onSuccess('ok')
}, 0)
}}
>
<Flex style={{ height: '100%' }} vertical>
{gcodeParsing == true ? (
<Spin
indicator={
<LoadingOutlined style={{ fontSize: 24 }} spin />
}
/>
) : (
<>
<p className='ant-upload-drag-icon'>
<GCodeFileIcon />
</p>
<p className='ant-upload-text'>
Click or drag gcode file here.
</p>
<p className='ant-upload-hint'>
Supported file extentions: .gcode, .gco, .g
</p>
</>
)}
</Flex>
</Dragger>
</Form.Item>
</>
)
},
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
<Flex gap={'middle'}>
<Form.Item
label='Material'
name='filament'
style={{ width: '100%' }}
rules={[
{
required: true,
message: 'Please provide a materal.'
}
]}
>
<FilamentSelect
filter={filamentSelectFilter}
useFilter={useFilamentSelectFilter}
/>
</Form.Item>
<Form.Item>
<Checkbox
checked={useFilamentSelectFilter}
onChange={(e) => {
setUseFilamentSelectFilter(e.target.checked)
}}
>
Filter
</Checkbox>
</Form.Item>
</Flex>
</>
)
},
{
title: 'Summary',
key: 'done',
content: (
<>
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
</>
)
}
]
return (
<Flex gap={'middle'}>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</div>
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
New G Code File
</Title>
<Form
name='basic'
autoComplete='off'
form={newGCodeFileForm}
onFinish={handleNewGCodeFile}
onValuesChange={(changedValues) =>
setNewGCodeFileFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewGCodeFileForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
<Flex justify={'end'}>
<Button
style={{
margin: '0 8px'
}}
onClick={() => {
setCurrentStep(currentStep - 1)
setNextEnabled(true)
}}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
loading={nextLoading}
onClick={() => {
setCurrentStep(currentStep + 1)
setNextEnabled(false)
console.log(newGCodeFileFormValues)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newGCodeFileLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}
NewGCodeFile.propTypes = {
reset: PropTypes.bool.isRequired,
onOk: PropTypes.func.isRequired
}
export default NewGCodeFile

View File

@ -0,0 +1,273 @@
import React, { useEffect, useState, useContext } from 'react'
import {
Descriptions,
Progress,
Space,
Flex,
Alert,
Statistic,
Typography
} from 'antd'
import {
PrinterOutlined,
LoadingOutlined,
CheckCircleOutlined,
PlayCircleOutlined
} from '@ant-design/icons'
import axios from 'axios'
import { SocketContext } from '../context/SocketContext'
const { Title } = Typography
const ProductionOverview = () => {
const [stats, setStats] = useState({
totalPrinters: 0,
activePrinters: 0,
totalPrintJobs: 0,
activePrintJobs: 0,
completedPrintJobs: 0,
printerStatus: {
idle: 0,
printing: 0,
error: 0,
offline: 0
}
})
const { socket } = useContext(SocketContext)
useEffect(() => {
const fetchStats = async () => {
try {
const [printersResponse, printJobsResponse] = await Promise.all([
axios.get('/api/printers'),
axios.get('/api/print-jobs')
])
const printers = printersResponse.data
const printJobs = printJobsResponse.data
const printerStatus = printers.reduce((acc, printer) => {
acc[printer.status] = (acc[printer.status] || 0) + 1
return acc
}, {})
setStats({
totalPrinters: printers.length,
activePrinters: printers.filter((p) => p.status === 'printing')
.length,
totalPrintJobs: printJobs.length,
activePrintJobs: printJobs.filter((job) => job.status === 'printing')
.length,
completedPrintJobs: printJobs.filter(
(job) => job.status === 'completed'
).length,
printerStatus
})
} catch (error) {
console.error('Error fetching production stats:', error)
}
}
fetchStats()
if (socket) {
socket.on('printerUpdate', fetchStats)
socket.on('printJobUpdate', fetchStats)
}
return () => {
if (socket) {
socket.off('printerUpdate', fetchStats)
socket.off('printJobUpdate', fetchStats)
}
}
}, [socket])
const getPrinterStatusPercentage = (status) => {
const count = stats.printerStatus[status] || 0
if (stats.totalPrinters > 0) {
return Math.round((count / stats.totalPrinters) * 100)
}
return 0
}
const getCompletionRate = () => {
if (stats.totalPrintJobs > 0) {
return Math.round((stats.completedPrintJobs / stats.totalPrintJobs) * 100)
}
return 0
}
return (
<Flex vertical>
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Overview
</Title>
</Flex>
<Flex justify='space-between' gap='middle'>
<Alert
type='success'
style={{ flexGrow: 1 }}
description={
<Statistic
title='Printers Ready'
value={stats.totalPrinters}
prefix={<PrinterOutlined />}
/>
}
/>
<Alert
type='info'
style={{ flexGrow: 1 }}
description={
<Statistic
title='Printers Printing'
value={stats.totalPrinters}
prefix={<PrinterOutlined />}
/>
}
/>
<Alert
type='warning'
style={{ flexGrow: 1 }}
description={
<Statistic
title='Queued Jobs'
value={stats.totalPrinters}
prefix={<PlayCircleOutlined />}
/>
}
/>
<Alert
type='error'
style={{ flexGrow: 1 }}
description={
<Statistic
title='Failed Jobs'
value={stats.totalPrinters}
prefix={<PlayCircleOutlined />}
/>
}
/>
<Alert
type='success'
style={{ flexGrow: 1 }}
description={
<Statistic
title='Complete Jobs'
value={stats.totalPrinters}
prefix={<PlayCircleOutlined />}
/>
}
/>
</Flex>
<Flex gap='middle' wrap='wrap'>
<Flex flex={1} vertical>
<Flex
align={'center'}
style={{ marginTop: 24, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Printer Statistics
</Title>
</Flex>
<Space direction='vertical' style={{ width: '100%' }}>
<Descriptions column={1} bordered>
<Descriptions.Item
label={
<Space>
<PrinterOutlined /> Total Printers
</Space>
}
>
{stats.totalPrinters}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<LoadingOutlined /> Active Printers
</Space>
}
>
{stats.activePrinters}
</Descriptions.Item>
</Descriptions>
<Progress
percent={getPrinterStatusPercentage('printing')}
status='active'
format={() => `${stats.printerStatus.printing || 0} Printing`}
/>
<Progress
percent={getPrinterStatusPercentage('idle')}
status='normal'
format={() => `${stats.printerStatus.idle || 0} Idle`}
/>
<Progress
percent={getPrinterStatusPercentage('error')}
status='exception'
format={() => `${stats.printerStatus.error || 0} Error`}
/>
</Space>
</Flex>
<Flex flex={1} vertical>
<Flex
align={'center'}
style={{ marginTop: 24, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Job Statistics
</Title>
</Flex>
<Space direction='vertical' style={{ width: '100%' }}>
<Descriptions column={1} bordered>
<Descriptions.Item
label={
<Space>
<PrinterOutlined /> Total Print Jobs
</Space>
}
>
{stats.totalPrintJobs}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<LoadingOutlined /> Active Print Jobs
</Space>
}
>
{stats.activePrintJobs}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<CheckCircleOutlined /> Completed Print Jobs
</Space>
}
>
{stats.completedPrintJobs}
</Descriptions.Item>
<Descriptions.Item>
<Progress
percent={getCompletionRate()}
status='success'
format={() => 'Completion Rate'}
/>
</Descriptions.Item>
</Descriptions>
</Space>
</Flex>
</Flex>
</Flex>
)
}
export default ProductionOverview

View File

@ -0,0 +1,381 @@
// src/PrintJobs.js
import React, { useEffect, useState, useCallback, useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import moment from 'moment'
import {
Table,
Button,
Flex,
Space,
Modal,
Dropdown,
message,
notification,
Input,
Typography
} from 'antd'
import { createStyles } from 'antd-style'
import {
EditOutlined,
PlusOutlined,
LoadingOutlined,
InfoCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
FilterOutlined,
CloseOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
PauseCircleOutlined,
QuestionCircleOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import { SocketContext } from '../context/SocketContext'
import NewPrintJob from './PrintJobs/NewPrintJob'
import JobState from '../common/JobState'
import SubJobCounter from '../common/SubJobCounter'
import IdText from '../common/IdText'
const { Text } = Typography
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const PrintJobs = () => {
const { styles } = useStyle()
const [messageApi, contextHolder] = message.useMessage()
const [notificationApi, notificationContextHolder] =
notification.useNotification()
const navigate = useNavigate()
const [printJobsData, setPrintJobsData] = useState([])
const [showFilters, setShowFilters] = useState(false)
const [filters, setFilters] = useState({
id: '',
state: ''
})
const [newPrintJobOpen, setNewPrintJobOpen] = useState(false)
const [loading, setLoading] = useState(true)
const { authenticated } = useContext(AuthContext)
const { socket } = useContext(SocketContext)
const handleDeployPrintJob = (printJobId) => {
if (socket) {
messageApi.info(`Print job ${printJobId} deployment initiated`)
socket.emit('server.job_queue.deploy', { printJobId }, (response) => {
if (response == false) {
notificationApi.error({
message: 'Print job deployment failed',
description: 'Please try again later'
})
} else {
notificationApi.success({
message: 'Print job deployment initiated',
description: 'Please wait for the print job to start'
})
}
})
navigate(`/production/printjobs/info?printJobId=${printJobId}`)
} else {
messageApi.error('Socket connection not available')
}
}
const fetchPrintJobsData = useCallback(async () => {
if (!authenticated) {
return
}
try {
const response = await axios.get('http://localhost:8080/printjobs', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setLoading(false)
setPrintJobsData(response.data)
} catch (error) {
setLoading(false)
if (error.response) {
messageApi.error(
'Error fetching print jobs data:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [authenticated, messageApi])
useEffect(() => {
// Fetch initial data
if (authenticated) {
fetchPrintJobsData()
}
}, [authenticated, fetchPrintJobsData])
const handleFilterChange = (field, value) => {
setFilters((prev) => ({
...prev,
[field]: value
}))
}
const filteredData = printJobsData.filter((printJob) => {
const matchesId = printJob.id
.toLowerCase()
.includes(filters.id.toLowerCase())
const matchesState = printJob.state.type
.toLowerCase()
.includes(filters.state.toLowerCase())
return matchesId && matchesState
})
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <PlayCircleOutlined></PlayCircleOutlined>
},
{
title: 'GCode File Name',
dataIndex: 'gcodeFile',
key: 'gcodeFileName',
width: 200,
fixed: 'left',
render: (gcodeFile) => <Text ellipsis>{gcodeFile.name}</Text>
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'job'} longId={false} />
},
{
title: 'State',
key: 'state',
width: 240,
render: (record) => {
return <JobState job={record} showQuantity={false} showId={false} />
}
},
{
title: <CheckCircleOutlined />,
key: 'complete',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'complete' }} />
}
},
{
title: <PauseCircleOutlined />,
key: 'queued',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'queued' }} />
}
},
{
title: <CloseCircleOutlined />,
key: 'failed',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'failed' }} />
}
},
{
title: <QuestionCircleOutlined />,
key: 'draft',
width: 70,
render: (record) => {
return <SubJobCounter job={record} state={{ type: 'draft' }} />
}
},
{
title: 'Started At',
dataIndex: 'startedAt',
key: 'startedAt',
width: 180,
render: (startedAt) => {
if (startedAt) {
const formattedDate = moment(startedAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'operation',
fixed: 'right',
width: 150,
render: (record) => {
return (
<Space size='small'>
{record.state.type === 'draft' ? (
<Button
icon={<PlayCircleOutlined />}
onClick={() => handleDeployPrintJob(record.id)}
/>
) : (
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/production/printjobs/info?printJobId=${record.id}`)
}
/>
)}
<Dropdown menu={getPrintJobActionItems(record.id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const getPrintJobActionItems = (printJobId) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
},
{
label: 'Edit',
key: 'edit',
icon: <EditOutlined />
}
],
onClick: ({ key }) => {
if (key === 'edit') {
showNewPrintJobModal(printJobId)
} else if (key === 'info') {
navigate(`/production/printjobs/info?printJobId=${printJobId}`)
}
}
}
}
const actionItems = {
items: [
{
label: 'New Print Job',
key: 'newPrintJob',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'newPrintJob') {
showNewPrintJobModal()
} else if (key === 'reloadList') {
fetchPrintJobsData()
}
}
}
const showNewPrintJobModal = () => {
setNewPrintJobOpen(true)
}
return (
<>
{notificationContextHolder}
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
{contextHolder}
<Space size='middle'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Button
icon={showFilters ? <CloseOutlined /> : <FilterOutlined />}
onClick={() => setShowFilters(!showFilters)}
/>
{showFilters && (
<>
<Input
placeholder='Filter by ID'
value={filters.id}
onChange={(e) => handleFilterChange('id', e.target.value)}
style={{ width: 200 }}
/>
<Input
placeholder='Filter by state'
value={filters.state}
onChange={(e) => handleFilterChange('state', e.target.value)}
style={{ width: 200 }}
/>
</>
)}
</Space>
<Table
className={styles.customTable}
dataSource={filteredData}
columns={columns}
rowKey='id'
pagination={false}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
/>
</Flex>
<Modal
open={newPrintJobOpen}
footer={null}
width={700}
onCancel={() => {
setNewPrintJobOpen(false)
}}
>
<NewPrintJob
onOk={() => {
setNewPrintJobOpen(false)
fetchPrintJobsData()
}}
reset={newPrintJobOpen}
/>
</Modal>
</>
)
}
export default PrintJobs

View File

@ -0,0 +1,253 @@
import React, { useState } from 'react'
import axios from 'axios'
import {
Form,
Button,
message,
Typography,
Flex,
Steps,
Col,
Row,
Divider,
Checkbox,
Descriptions,
InputNumber
} from 'antd'
import PropTypes from 'prop-types'
import GCodeFileSelect from '../../common/GCodeFileSelect'
import PrinterSelect from '../../common/PrinterSelect'
const { Title, Text } = Typography
const initialNewPrintJobForm = {}
const NewPrintJob = ({ onOk, reset }) => {
NewPrintJob.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired
}
const [messageApi, contextHolder] = message.useMessage()
const [newPrintJobLoading, setNewPrintJobLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newPrintJobForm] = Form.useForm()
const [newPrintJobFormValues, setNewPrintJobFormValues] = useState(
initialNewPrintJobForm
)
const [useAnyPrinter, setUseAnyPrinter] = useState(true)
const newPrintJobFormUpdateValues = Form.useWatch([], newPrintJobForm)
React.useEffect(() => {
newPrintJobForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newPrintJobForm, newPrintJobFormUpdateValues])
const summaryItems = [
{
key: 'quantity',
label: 'Quantity',
children: newPrintJobFormValues.quantity
}
]
if (!useAnyPrinter && newPrintJobFormValues.printers) {
const printerList = newPrintJobFormValues.printers
summaryItems.splice(2, 0, {
key: 'printer',
label: 'Printers',
children: `${printerList.length} printer(s) selected`
})
}
React.useEffect(() => {
if (reset) {
newPrintJobForm.resetFields()
}
}, [reset, newPrintJobForm])
const handleUseAnyPrinterChecked = (e) => {
const checked = e.target.checked
setUseAnyPrinter(checked)
if (checked === true) {
newPrintJobForm.resetFields(['printer'])
setNewPrintJobFormValues({ ...newPrintJobFormValues, printer: null })
}
}
const handleNewPrintJob = async () => {
setNewPrintJobLoading(true)
try {
await axios.post(
`http://localhost:8080/printjobs`,
newPrintJobFormValues,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
messageApi.success('New print job created successfully.')
onOk()
} catch (error) {
messageApi.error('Error creating new print job: ' + error.message)
} finally {
setNewPrintJobLoading(false)
}
}
const steps = [
{
title: 'Required',
key: 'required',
content: (
<>
<Form.Item>
<Text>Please select a G Code File:</Text>
</Form.Item>
<Form.Item
label='G Code File:'
name='gcodeFile'
rules={[
{
required: true,
message: 'Please enter a G Code File.'
}
]}
>
<GCodeFileSelect />
</Form.Item>
<Form.Item
label='Quantity'
name='quantity'
defaultValue={1}
rules={[
{
required: true,
message: 'Please enter a quantity'
},
{
type: 'number',
min: 1,
message: 'Quantity must be at least 1'
}
]}
>
<InputNumber min={1} defaultValue={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item>
<Checkbox
checked={useAnyPrinter}
onChange={handleUseAnyPrinterChecked}
>
Use any printer configured.
</Checkbox>
</Form.Item>
<Form.Item
label='Printers'
name='printers'
rules={[
{
required: false
}
]}
>
<PrinterSelect disabled={useAnyPrinter} checkable={true} />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'done',
content: (
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
)
}
]
return (
<Row>
{contextHolder}
<Col flex={1}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</Col>
<Col>
<Divider type={'vertical'} style={{ height: '100%' }} />
</Col>
<Col flex={3} style={{ paddingLeft: 15, maxWidth: 450 }}>
<Flex vertical={'true'}>
<Title level={2} style={{ marginTop: 0 }}>
New PrintJob
</Title>
<Form
name='basic'
autoComplete='off'
form={newPrintJobForm}
onFinish={handleNewPrintJob}
onValuesChange={(changedValues) =>
setNewPrintJobFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewPrintJobForm}
>
{steps[currentStep].content}
<Flex justify={'end'}>
<Button
style={{
margin: '0 8px'
}}
onClick={() => setCurrentStep(currentStep - 1)}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newPrintJobLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Col>
</Row>
)
}
export default NewPrintJob

View File

@ -0,0 +1,174 @@
import React, { useState, useEffect, useContext } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Progress,
Typography
} from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import moment from 'moment'
import JobState from '../../common/JobState'
import IdText from '../../common/IdText'
import SubJobsTree from '../../common/SubJobsTree'
import { SocketContext } from '../../context/SocketContext'
const { Title } = Typography
const PrintJobInfo = () => {
const [printJobData, setPrintJobData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi] = message.useMessage()
const printJobId = new URLSearchParams(location.search).get('printJobId')
const { socket } = useContext(SocketContext)
useEffect(() => {
if (printJobId) {
fetchPrintJobDetails()
}
}, [printJobId])
useEffect(() => {
if (socket && printJobId) {
socket.on('notify_job_update', (updateData) => {
if (updateData.id === printJobId) {
setPrintJobData((prevData) => {
if (!prevData) return prevData
return {
...prevData,
state: updateData.state,
...updateData
}
})
}
})
}
return () => {
if (socket) {
socket.off('notify_job_update')
}
}
}, [socket, printJobId])
const fetchPrintJobDetails = async () => {
try {
setLoading(true)
const response = await axios.get(
`http://localhost:8080/printjobs/${printJobId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
setPrintJobData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch print job details')
messageApi.error('Failed to fetch print job details')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !printJobData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Print job not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchPrintJobDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Descriptions title='Print Job Information' bordered column={2}>
<Descriptions.Item label='ID'>
<IdText id={printJobData._id} type={'job'} />
</Descriptions.Item>
<Descriptions.Item label='Status'>
<JobState
job={printJobData}
showProgress={false}
showQuantity={false}
showId={false}
/>
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
{printJobData.gcodeFile?.name || 'Not specified'}
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
<IdText
id={printJobData.gcodeFile.id}
type={'gcodeFile'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Quantity'>
{printJobData.quantity || 1}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{(() => {
if (printJobData.createdat) {
return moment(printJobData.createdat.$date).format(
'YYYY-MM-DD HH:mm:ss'
)
}
return 'N/A'
})()}
</Descriptions.Item>
<Descriptions.Item label='Started At'>
{(() => {
if (printJobData.started_at) {
return moment(printJobData.started_at.$date).format(
'YYYY-MM-DD HH:mm:ss'
)
}
return 'N/A'
})()}
</Descriptions.Item>
{printJobData.state.type === 'printing' && (
<Descriptions.Item label='Progress'>
<Progress
percent={Math.round((printJobData.state.progress || 0) * 100)}
/>
</Descriptions.Item>
)}
<Descriptions.Item label='Assigned Printers'>
{printJobData.printers?.length > 0 ? (
<span>{printJobData.printers.length} printers assigned</span>
) : (
'Any available printer'
)}
</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginBottom: 20 }}>
Sub Job Information
</Title>
<SubJobsTree printJobData={printJobData} />
</div>
)
}
export default PrintJobInfo

View File

@ -0,0 +1,329 @@
// src/Printers.js
import React, { useEffect, useState, useContext, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Table,
Button,
message,
Dropdown,
Space,
Flex,
Input,
Tag,
Modal
} from 'antd'
import { createStyles } from 'antd-style'
import {
InfoCircleOutlined,
EditOutlined,
ControlOutlined,
LoadingOutlined,
ReloadOutlined,
FilterOutlined,
CloseOutlined,
PlusOutlined,
PrinterOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import PrinterState from '../common/PrinterState'
import NewPrinter from './Printers/NewPrinter'
import IdText from '../common/IdText'
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
customTable: css`
${antCls}-table {
${antCls}-table-container {
${antCls}-table-body,
${antCls}-table-content {
scrollbar-width: thin;
scrollbar-color: #eaeaea transparent;
scrollbar-gutter: stable;
}
}
}
`
}
})
const Printers = () => {
const { styles } = useStyle()
const [printerData, setPrinterData] = useState([])
const [messageApi] = message.useMessage()
const [showFilters, setShowFilters] = useState(false)
const { authenticated } = useContext(AuthContext)
const [loading, setLoading] = useState(false)
const [filters, setFilters] = useState({
printerName: '',
host: '',
tags: ''
})
const [newPrinterOpen, setNewPrinterOpen] = useState(false)
const navigate = useNavigate()
const fetchPrintersData = useCallback(async () => {
try {
const response = await axios.get('http://localhost:8080/printers', {
params: {
page: 1,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setLoading(false)
setPrinterData(response.data)
} catch (error) {
if (error.response) {
messageApi.error('Error fetching printer data:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}, [messageApi])
const handleFilterChange = (field, value) => {
setFilters((prev) => ({
...prev,
[field]: value
}))
}
const getPrinterActionItems = (printerId) => {
return {
items: [
{
label: 'Control',
key: 'control',
icon: <ControlOutlined />
},
{
type: 'divider'
},
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
},
{
label: 'Edit',
key: 'edit',
icon: <EditOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/production/printers/info?printerId=${printerId}`)
} else if (key === 'control') {
navigate(`/production/printers/control?printerId=${printerId}`)
}
}
}
}
useEffect(() => {
if (authenticated) {
// Fetch initial data
fetchPrintersData()
}
}, [fetchPrintersData, authenticated])
const filteredData = printerData.filter((printer) => {
const matchesName = printer.printerName
.toLowerCase()
.includes(filters.printerName.toLowerCase())
const matchesHost = printer.moonraker.host
.toLowerCase()
.includes(filters.host.toLowerCase())
const matchesTags =
!filters.tags ||
(printer.tags &&
printer.tags.some((tag) =>
tag.toLowerCase().includes(filters.tags.toLowerCase())
))
return matchesName && matchesHost && matchesTags
})
const actionItems = {
items: [
{
label: 'New Printer',
key: 'newPrinter',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchPrintersData()
} else if (key === 'newPrinter') {
setNewPrinterOpen(true)
}
}
}
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <PrinterOutlined></PrinterOutlined>
},
{
title: 'Name',
dataIndex: 'printerName',
key: 'printerName',
width: 200,
fixed: 'left'
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type='printer' longId={false} />
},
{
title: 'State',
key: 'state',
width: 240,
render: (record) => {
return (
<PrinterState
printer={record}
showPrinterName={false}
showControls={false}
/>
)
}
},
{
title: 'Tags',
dataIndex: 'tags',
key: 'tags',
width: 170,
render: (tags) => {
if (!tags || !Array.isArray(tags)) return null
return (
<Space size={[0, 8]} wrap>
{tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
)
}
},
{
title: 'Actions',
key: 'operation',
fixed: 'right',
width: 150,
render: (record) => {
return (
<Space gap='small'>
<Button
icon={<ControlOutlined />}
onClick={() =>
navigate(`/production/printers/control?printerId=${record.id}`)
}
/>
<Dropdown menu={getPrinterActionItems(record.id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
return (
<>
<Flex vertical={'true'} gap='large'>
<Space size='middle'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Button
icon={showFilters ? <CloseOutlined /> : <FilterOutlined />}
onClick={() => setShowFilters(!showFilters)}
/>
{showFilters && (
<>
<Input
placeholder='Filter by printer name'
value={filters.printerName}
onChange={(e) =>
handleFilterChange('printerName', e.target.value)
}
style={{ width: 200 }}
/>
<Input
placeholder='Filter by host'
value={filters.host}
onChange={(e) => handleFilterChange('host', e.target.value)}
style={{ width: 200 }}
/>
<Input
placeholder='Filter by tags'
value={filters.tags}
onChange={(e) => handleFilterChange('tags', e.target.value)}
style={{ width: 200 }}
/>
</>
)}
</Space>
<Table
className={styles.customTable}
dataSource={filteredData}
columns={columns}
pagination={false}
rowKey='id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
/>
<Modal
open={newPrinterOpen}
footer={null}
width={700}
onCancel={() => {
setNewPrinterOpen(false)
}}
>
<NewPrinter
onOk={() => {
setNewPrinterOpen(false)
fetchPrintersData()
}}
reset={newPrinterOpen}
/>
</Modal>
</Flex>
</>
)
}
export default Printers

View File

@ -0,0 +1,333 @@
import React, { useState, useContext, useRef } from 'react'
import axios from 'axios'
import {
Form,
Input,
Button,
message,
Typography,
Flex,
Steps,
Col,
Row,
Divider,
Upload,
Descriptions
} from 'antd'
import { AuthContext } from '../../Auth/AuthContext'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import FilamentSelect from '../common/FilamentSelect'
import PrinterSelect from '../common/PrinterSelect'
const { Dragger } = Upload
const { Title, Text } = Typography
const initialNewGCodeFileForm = {
name: '',
brand: '',
type: '',
price: 0,
color: '#FFFFFF',
diameter: '1.75',
image: null,
url: '',
barcode: ''
}
const chunkSize = 5000
const NewGCodeFile = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newGCodeFileForm] = Form.useForm()
const [newGCodeFileFormValues, setNewGCodeFileFormValues] = useState(
initialNewGCodeFileForm
)
const [imageList, setImageList] = useState([])
const [gcode, setGCode] = useState('')
const newGCodeFileFormUpdateValues = Form.useWatch([], newGCodeFileForm)
const { token } = useContext(AuthContext)
const gcodePreviewRef = useRef(null)
React.useEffect(() => {
newGCodeFileForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newGCodeFileForm, newGCodeFileFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newGCodeFileFormValues.name
},
{
key: 'brand',
label: 'Brand',
children: newGCodeFileFormValues.brand
},
{
key: 'type',
label: 'Material',
children: () => {
if (newGCodeFileFormValues.filament != null) {
return '1 selected.'
} else {
return '0 selected.'
}
}
},
{
key: 'price',
label: 'Price',
children: '£' + newGCodeFileFormValues.price + ' per kg'
}
]
React.useEffect(() => {
if (reset) {
newGCodeFileForm.resetFields()
}
}, [reset, newGCodeFileForm])
const handleNewGCodeFile = async () => {
setNewGCodeFileLoading(true)
try {
await axios.post(
`http://localhost:8080/gcodefiles`,
newGCodeFileFormValues,
{
headers: {
Authorization: `Bearer ${token}`
}
}
)
messageApi.success('New G Code file created successfully.')
onOk()
} catch (error) {
messageApi.error('Error creating new gcode file: ' + error.message)
} finally {
setNewGCodeFileLoading(false)
}
}
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
const handleGCodeUpload = (file) => {
const reader = new FileReader()
reader.onload = () => {
console.log(reader.result)
setGCode(reader.result)
}
reader.readAsText(file)
}
const steps = [
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item>
<Text>Please provide the following information:</Text>
</Form.Item>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Material'
name='filament'
rules={[
{
required: true,
message: 'Please provide a materal.'
}
]}
>
<FilamentSelect />
</Form.Item>
</>
)
},
{
title: 'Upload',
key: 'upload',
content: (
<>
<Form.Item
name='gcodefile'
rules={[
{
required: true,
message: 'Please upload a gcode file.'
}
]}
getValueFromEvent={(e) => (Array.isArray(e) ? e : e && e.fileList)}
>
<Dragger
name='G Code File'
maxCount={1}
showUploadList={false}
customRequest={({ file, onSuccess }) => {
handleGCodeUpload(file)
setTimeout(() => {
onSuccess('ok')
}, 0)
}}
>
<p className='ant-upload-drag-icon'>
<GCodeFileIcon />
</p>
<p className='ant-upload-text'>
Click or gcode instruction file here.
</p>
<p className='ant-upload-hint'>
Supported file extentions: .gcode, .gco, .g
</p>
</Dragger>
</Form.Item>
</>
)
},
{
title: 'Targets',
key: 'targets',
content: (
<>
<Form.Item>
<Text>
Please provide at least one target to deploy this G Code file:
</Text>
</Form.Item>
<Form.Item
label='Target(s)'
name='targets'
rules={[
{
required: true,
message: 'Please provide at least one target.'
}
]}
>
<PrinterSelect />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'done',
content: (
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
)
}
]
return (
<Row>
{contextHolder}
<Col flex={1}>
<Steps
current={currentStep}
items={steps}
direction='vertical'
style={{ width: 'fit-content' }}
/>
</Col>
<Col>
<Divider type='vertical' style={{ height: '100%' }} />
</Col>
<Col flex={3} style={{ paddingLeft: 15, maxWidth: 450 }}>
<Flex vertical='true'>
<Title level={2} style={{ marginTop: 0 }}>
New G Code File
</Title>
<Form
name='basic'
autoComplete='off'
form={newGCodeFileForm}
onFinish={handleNewGCodeFile}
onValuesChange={(changedValues) =>
setNewGCodeFileFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewGCodeFileForm}
>
{steps[currentStep].content}
<Flex justify='end'>
<Button
style={{
margin: '0 8px'
}}
onClick={() => setCurrentStep(currentStep - 1)}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newGCodeFileLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Col>
</Row>
)
}
export default NewGCodeFile

View File

@ -0,0 +1,357 @@
import React, { useState, useContext, useCallback, useEffect } from 'react'
import axios from 'axios'
import { useLocation } from 'react-router-dom'
import {
Button,
message,
Spin,
Flex,
Card,
Dropdown,
Space,
Descriptions,
Progress
} from 'antd'
import {
LoadingOutlined,
PlayCircleOutlined,
ExclamationCircleOutlined,
ReloadOutlined,
EditOutlined,
PauseCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons'
import { SocketContext } from '../../context/SocketContext'
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel'
import PrinterMovementPanel from '../../common/PrinterMovementPanel'
import PrinterState from '../../common/PrinterState'
import { AuthContext } from '../../../Auth/AuthContext'
import PrinterSubJobsTree from '../../common/PrinterJobsTree'
import IdText from '../../common/IdText'
// Helper function to parse query parameters
const useQuery = () => {
return new URLSearchParams(useLocation().search)
}
const ControlPrinter = () => {
const [messageApi] = message.useMessage()
const query = useQuery()
const printerId = query.get('printerId')
const [printerData, setPrinterData] = useState(null)
const [initialized, setInitialized] = useState(false)
const { socket } = useContext(SocketContext)
const { authenticated } = useContext(AuthContext)
// Fetch printer details when the component mounts
const fetchPrinterDetails = useCallback(async () => {
if (printerId) {
try {
const response = await axios.get(
`http://localhost:8080/printers/${printerId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
setPrinterData(response.data)
} catch (error) {
if (error.response) {
messageApi.error(
'Error fetching printer data:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
}, [printerId, messageApi])
// Add WebSocket event listener for real-time updates
useEffect(() => {
if (socket && !initialized && printerId) {
setInitialized(true)
socket.on('notify_printer_update', (statusUpdate) => {
setPrinterData((prevData) => {
if (statusUpdate?.id === printerId) {
return {
...prevData,
...statusUpdate
}
}
return prevData
})
})
}
return () => {
if (socket && initialized) {
socket.off('notify_printer_update')
}
}
}, [socket, initialized, printerId])
function handleEmergencyStop() {
console.log('Emergency stop button clicked')
socket.emit('printer.emergency_stop', { printerId })
}
useEffect(() => {
if (authenticated) {
fetchPrinterDetails()
}
}, [authenticated, fetchPrinterDetails])
const actionItems = {
items: [
{
label: 'Resume Print',
key: 'resumePrint',
icon: <PlayCircleOutlined />
},
{
label: 'Pause Print',
key: 'pausePrint',
icon: <PauseCircleOutlined />
},
{
label: 'Cancel Print',
key: 'cancelPrint',
icon: <CloseCircleOutlined />
},
{
type: 'divider'
},
{
label: 'Start Queue',
key: 'startQueue',
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'deploying' ||
printerData?.state?.type === 'paused' ||
printerData?.state?.type === 'error',
icon: <PlayCircleOutlined />
},
{
label: 'Pause Queue',
key: 'pauseQueue',
icon: <PauseCircleOutlined />
},
{
type: 'divider'
},
{
label: 'Restart Host',
key: 'restartHost',
icon: <ReloadOutlined />
},
{
label: 'Restart Firmware',
key: 'restartFirmware',
icon: <ReloadOutlined />
},
{
type: 'divider'
},
{
label: 'Edit Printer',
key: 'edit',
icon: <EditOutlined />
}
],
onClick: ({ key }) => {
if (key === 'restartHost') {
socket.emit('printer.restart', { printerId })
} else if (key === 'restartFirmware') {
socket.emit('printer.firmware_restart', { printerId })
} else if (key === 'resumePrint') {
socket.emit('printer.print.resume', { printerId })
} else if (key === 'pausePrint') {
socket.emit('printer.print.pause', { printerId })
} else if (key === 'cancelPrint') {
socket.emit('printer.print.cancel', { printerId })
} else if (key === 'startQueue') {
socket.emit('server.job_queue.start', { printerId })
} else if (key === 'pauseQueue') {
socket.emit('server.job_queue.pause', { printerId })
}
}
}
return (
<>
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
{printerData ? (
<PrinterState
printer={printerData}
showProgress={false}
showPrinterName={false}
showControls={false}
/>
) : (
<Spin indicator={<LoadingOutlined spin />} size='small' />
)}
</Space>
<Space size='small'>
<Button
icon={<ExclamationCircleOutlined />}
danger
onClick={handleEmergencyStop}
></Button>
<Button
icon={
printerData?.state?.type === 'paused' ? (
<PlayCircleOutlined />
) : (
<PauseCircleOutlined />
)
}
disabled={
!(
printerData?.state?.type == 'printing' ||
printerData?.state?.type == 'paused'
)
}
onClick={() => {
if (printerData?.state?.type === 'paused') {
socket.emit('printer.print.resume', { printerId })
} else {
socket.emit('printer.print.pause', { printerId })
}
}}
></Button>
<Button
icon={<PlayCircleOutlined />}
disabled={
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'deploying' ||
printerData?.state?.type === 'paused' ||
printerData?.state?.type === 'error'
}
onClick={() => {
socket.emit('server.job_queue.start', { printerId })
}}
></Button>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
{printerData ? (
<Flex gap={16}>
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
<Descriptions bordered column={2}>
<Descriptions.Item label='Printer Name'>
{printerData.printerName}
</Descriptions.Item>
<Descriptions.Item label='Print Job ID'>
{printerData.currentJob?.id ? (
<IdText
id={printerData.currentJob.id}
type='job'
longId={false}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
{printerData.currentJob?.gcodeFile?.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
{printerData.currentJob?.gcodeFile ? (
<IdText
id={printerData.currentJob.gcodeFile.id}
type='gcodeFile'
longId={false}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Est. Print Time'>
{(() => {
if (
printerData.currentJob?.gcodeFile?.gcodeFileInfo
.estimatedPrintingTimeNormalMode
) {
return `${
printerData.currentJob.gcodeFile.gcodeFileInfo
.estimatedPrintingTimeNormalMode
}`
}
return 'n/a'
})()}
</Descriptions.Item>
<Descriptions.Item label='Print Profile'>
{(() => {
if (
printerData?.currentJob?.gcodeFile.gcodeFileInfo
.printSettingsId
) {
return `${printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
{printerData.currentSubJob?.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 title='Temperature' bordered={true}>
<PrinterTemperaturePanel
printerId={printerId}
></PrinterTemperaturePanel>
</Card>
<Card title='Movement' bordered={true}>
<PrinterMovementPanel
printerId={printerId}
></PrinterMovementPanel>
</Card>
</Flex>
</Flex>
) : (
<Spin indicator={<LoadingOutlined spin />} size='large' />
)}
</div>
</Flex>
</>
)
}
export default ControlPrinter

View File

@ -0,0 +1,565 @@
import React, { useState, useContext, useEffect, useCallback } from 'react'
import axios from 'axios'
import {
Form,
Button,
message,
Typography,
Flex,
Steps,
Divider,
Input,
Select,
Space,
Descriptions,
List,
InputNumber,
notification,
Progress,
Modal,
Radio
} from 'antd'
import {
SearchOutlined,
SettingOutlined,
EditOutlined
} from '@ant-design/icons'
import PropTypes from 'prop-types'
import { SocketContext } from '../../context/SocketContext'
const { Title } = Typography
const initialNewPrinterForm = {
moonraker: {
protocol: 'ws',
host: '',
port: '',
apiKey: ''
}
}
const NewPrinter = ({ onOk, reset }) => {
NewPrinter.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired
}
const { socket } = useContext(SocketContext)
const [messageApi, contextHolder] = message.useMessage()
const [notificationApi, notificationContextHolder] =
notification.useNotification()
const [newPrinterLoading, setNewPrinterLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newPrinterForm] = Form.useForm()
const [newPrinterFormValues, setNewPrinterFormValues] = useState(
initialNewPrinterForm
)
const [discoveredPrinters, setDiscoveredPrinters] = useState([])
const [discovering, setDiscovering] = useState(false)
const [showManualSetup, setShowManualSetup] = useState(false)
const [scanPort, setScanPort] = useState(7125)
const [scanProtocol, setScanProtocol] = useState('ws')
const [editingHostname, setEditingHostname] = useState(null)
const [hostnameInput, setHostnameInput] = useState('')
const [initialized, setInitialized] = useState(false)
const newPrinterFormUpdateValues = Form.useWatch([], newPrinterForm)
useEffect(() => {
newPrinterForm
.validateFields({
validateOnly: true
})
.then(() => {
if (currentStep === 0) {
const moonraker = newPrinterForm.getFieldValue('moonraker')
setNextEnabled(
!!(moonraker?.protocol && moonraker?.host && moonraker?.port)
)
} else if (currentStep === 1) {
const printerName = newPrinterForm.getFieldValue('printerName')
setNextEnabled(!!printerName)
} else {
setNextEnabled(true)
}
})
.catch(() => setNextEnabled(false))
}, [newPrinterForm, newPrinterFormUpdateValues, currentStep])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newPrinterFormValues.printerName
},
{
key: 'protocol',
label: 'Protocol',
children: newPrinterFormValues.moonraker?.protocol
},
{
key: 'host',
label: 'Host',
children: newPrinterFormValues.moonraker?.host
},
{
key: 'port',
label: 'Port',
children: newPrinterFormValues.moonraker?.port
}
]
useEffect(() => {
if (reset) {
newPrinterForm.resetFields()
}
}, [reset, newPrinterForm])
const handlePrinterSelect = (printer) => {
newPrinterForm.setFieldsValue({
moonraker: {
protocol: printer.protocol,
host: printer.host,
port: printer.port
}
})
setNewPrinterFormValues({
...newPrinterFormValues,
moonraker: {
protocol: printer.protocol,
host: printer.host,
port: printer.port
}
})
}
const handleHostnameEdit = (printer, newHostname) => {
if (newHostname && newHostname.trim() !== '') {
const updatedPrinter = {
...printer,
host: newHostname.trim()
}
setDiscoveredPrinters((prev) =>
prev.map((p) => (p.host === printer.host ? updatedPrinter : p))
)
setEditingHostname(null)
setHostnameInput('')
}
}
const showEditHostnameDialog = (printer) => {
setEditingHostname(printer.host)
setHostnameInput(printer.host)
}
const handleNewPrinter = async () => {
setNewPrinterLoading(true)
try {
await axios.post(
'http://localhost:8080/printers',
{
...newPrinterFormValues
},
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
messageApi.success('New printer added successfully.')
onOk()
} catch (error) {
messageApi.error('Error adding new printer: ' + error.message)
} finally {
setNewPrinterLoading(false)
}
}
const notifyScanNetworkFound = useCallback(
(data) => {
const newPrinter = {
protocol: scanProtocol,
host: data.hostname || data.ip,
port: scanPort
}
notificationApi.info({
message: 'Printer Found',
description: `Printer found: ${data.hostname || data.ip}!`
})
setDiscoveredPrinters((prev) => [...prev, newPrinter])
},
[scanProtocol, scanPort, notificationApi]
)
const notifyScanNetworkComplete = useCallback(
(data) => {
setDiscovering(false)
notificationApi.destroy('network-scan')
if (data == false) {
messageApi.error('Error discovering printers!')
} else {
messageApi.success('Finished discovering printers!')
}
},
[messageApi, notificationApi]
)
const notifyScanNetworkProgress = useCallback(
(data) => {
notificationApi.info({
message: 'Scanning Network',
description: (
<div>
<div style={{ marginBottom: 12 }}>
Scanning IP: {data.currentIP}
</div>
<Progress
percent={data.progress}
size='small'
status='active'
showInfo={false}
/>
</div>
),
duration: 0,
key: 'network-scan',
icon: null,
placement: 'bottomRight',
style: {
width: 360
},
className: 'network-scan-notification',
closeIcon: null,
onClose: () => {},
btn: null
})
},
[notificationApi]
)
const discoverPrinters = useCallback(() => {
if (!discovering) {
setDiscovering(true)
setDiscoveredPrinters([])
messageApi.info('Discovering printers...')
socket.off('notify_scan_network_found')
socket.off('notify_scan_network_progress')
socket.off('notify_scan_network_complete')
socket.on('notify_scan_network_found', notifyScanNetworkFound)
socket.on('notify_scan_network_progress', notifyScanNetworkProgress)
socket.on('notify_scan_network_complete', notifyScanNetworkComplete)
socket.emit('bridge.scan_network.start', {
port: scanPort,
protocol: scanProtocol
})
}
}, [
discovering,
socket,
scanPort,
scanProtocol,
messageApi,
notifyScanNetworkFound,
notifyScanNetworkProgress,
notifyScanNetworkComplete
])
useEffect(() => {
setInitialized(true)
if (!initialized) {
discoverPrinters()
}
}, [initialized, discoverPrinters])
const stopDiscovery = () => {
if (discovering) {
setDiscovering(false)
notificationApi.destroy('network-scan')
messageApi.info('Stopping discovery...')
socket.off('notify_scan_network_found')
socket.off('notify_scan_network_progress')
socket.off('notify_scan_network_complete')
socket.emit('bridge.scan_network.stop', (response) => {
if (response == false) {
messageApi.error('Error stopping discovery!')
}
})
}
}
const handlePortChange = (value) => {
stopDiscovery()
setScanPort(value)
}
const handleProtocolChange = (value) => {
stopDiscovery()
setScanProtocol(value)
}
const steps = [
{
title: 'Discovery',
key: 'discovery',
content: (
<>
<Flex vertical style={{ width: '100%' }} gap='large'>
{!showManualSetup ? (
<>
<Flex
style={{ width: '100%' }}
justify='space-between'
align='center'
gap='middle'
>
<Space.Compact>
<InputNumber
min={1}
max={65535}
value={scanPort}
onChange={handlePortChange}
style={{ width: '80px' }}
placeholder='Port'
/>
<Select
value={scanProtocol}
onChange={handleProtocolChange}
options={[
{ value: 'ws', label: 'ws' },
{ value: 'wss', label: 'wss' }
]}
/>
<Button
icon={<SearchOutlined />}
onClick={discoverPrinters}
loading={discovering}
>
{discovering ? 'Discovering...' : 'Discover'}
</Button>
</Space.Compact>
<Button
icon={<SettingOutlined />}
onClick={() => setShowManualSetup(true)}
>
Manual Setup
</Button>
</Flex>
<List
dataSource={discoveredPrinters}
renderItem={(printer) => (
<List.Item
key={`${printer.host}:${printer.port}`}
actions={[
<Radio
key='select'
defaultChecked={
newPrinterFormValues.moonraker?.host ===
printer.host
}
onChange={() => handlePrinterSelect(printer)}
/>
]}
>
<List.Item.Meta
title={
<Space>
{printer.host}
{!printer.hostname && (
<Button
type='text'
icon={<EditOutlined />}
onClick={() => showEditHostnameDialog(printer)}
/>
)}
</Space>
}
description={`Protocol: ${printer.protocol}, Port: ${printer.port}`}
/>
</List.Item>
)}
/>
<Modal
title='Edit Host'
open={editingHostname !== null}
onOk={() => {
const printer = discoveredPrinters.find(
(p) => p.host === editingHostname
)
if (printer) {
handleHostnameEdit(printer, hostnameInput)
}
}}
onCancel={() => {
setEditingHostname(null)
setHostnameInput('')
}}
>
<Form.Item label='Host' required>
<Input
value={hostnameInput}
onChange={(e) => setHostnameInput(e.target.value)}
placeholder='Enter host'
autoFocus
/>
</Form.Item>
</Modal>
</>
) : (
<>
<Flex style={{ width: '100%' }} justify='end'>
<Button
icon={<SearchOutlined />}
onClick={() => setShowManualSetup(false)}
>
Back to Discovery
</Button>
</Flex>
<Flex vertical>
<Form.Item
label='Protocol'
name={['moonraker', 'protocol']}
rules={[
{ required: true, message: 'Protocol is required' }
]}
>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
]}
/>
</Form.Item>
<Form.Item
label='Host'
name={['moonraker', 'host']}
rules={[{ required: true, message: 'Host is required' }]}
>
<Input />
</Form.Item>
<Form.Item
label='Port'
name={['moonraker', 'port']}
rules={[{ required: true, message: 'Port is required' }]}
>
<InputNumber
min={1}
max={65535}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
label='API Key'
name={['moonraker', 'apiKey']}
rules={[{ required: false }]}
>
<Input.Password
placeholder='Optional API key'
style={{ width: '100%' }}
/>
</Form.Item>
</Flex>
</>
)}
</Flex>
</>
)
},
{
title: 'Required',
key: 'required',
content: (
<>
<Form.Item
label='Name'
name='printerName'
rules={[{ required: true, message: 'Name is required' }]}
>
<Input />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
)
}
]
return (
<Flex gap={'middle'}>
{contextHolder}
{notificationContextHolder}
<div style={{ minWidth: '160px' }}>
<Steps current={currentStep} items={steps} direction='vertical' />
</div>
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
New Printer
</Title>
<Form
name='basic'
autoComplete='off'
form={newPrinterForm}
onFinish={handleNewPrinter}
onValuesChange={(changedValues) =>
setNewPrinterFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewPrinterForm}
>
<div style={{ minHeight: '260px' }}>{steps[currentStep].content}</div>
<Flex justify={'end'}>
<Button
style={{
margin: '0 8px'
}}
onClick={() => setCurrentStep(currentStep - 1)}
disabled={!(currentStep > 0)}
>
Previous
</Button>
{currentStep < steps.length - 1 && (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
}}
>
Next
</Button>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newPrinterLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}
export default NewPrinter

View File

@ -0,0 +1,359 @@
import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Tag,
Typography,
Flex,
Form,
Input,
InputNumber,
Select
} from 'antd'
import {
LoadingOutlined,
ReloadOutlined,
EditOutlined,
CheckOutlined,
CloseOutlined,
PlusOutlined
} from '@ant-design/icons'
import PrinterState from '../../common/PrinterState'
import IdText from '../../common/IdText'
import PrinterSubJobsList from '../../common/PrinterJobsTree'
const { Title } = Typography
const PrinterInfo = () => {
const [printerData, setPrinterData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const printerId = new URLSearchParams(location.search).get('printerId')
const [messageApi, contextHolder] = message.useMessage()
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
useEffect(() => {
if (printerId) {
fetchPrinterDetails()
}
}, [printerId])
useEffect(() => {
if (printerData) {
form.setFieldsValue(printerData)
}
}, [printerData, form])
const fetchPrinterDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/printers/${printerId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setPrinterData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch printer details')
messageApi.error('Failed to fetch printer details')
} finally {
setFetchLoading(false)
}
}
const startEditing = () => {
setIsEditing(true)
}
const cancelEditing = () => {
setIsEditing(false)
fetchPrinterDetails()
}
const updatePrinterInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(`http://localhost:8080/printers/${printerId}`, values, {
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
})
setPrinterData((prev) => ({ ...prev, ...values }))
setIsEditing(false)
messageApi.success('Printer information updated successfully')
} catch (err) {
if (err.errorFields) {
// This is a form validation error
return
}
console.error('Failed to update printer information:', err)
messageApi.error('Failed to update printer information')
} finally {
setLoading(false)
}
}
const handleTagClose = (removedTag) => {
const newTags = printerData.tags.filter((tag) => tag !== removedTag)
setPrinterData((prev) => ({ ...prev, tags: newTags }))
}
const handleTagAdd = () => {
const input = form.getFieldValue('newTag')
if (input) {
const newTag = input.trim()
if (newTag && !printerData.tags.includes(newTag)) {
setPrinterData((prev) => ({ ...prev, tags: [...prev.tags, newTag] }))
form.setFieldValue('newTag', '')
}
}
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !printerData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Printer not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchPrinterDetails}>
Retry
</Button>
</Space>
)
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Printer Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updatePrinterInfo}
loading={loading}
></Button>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
></Button>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing}></Button>
)}
</Space>
</Flex>
<Form form={form} layout='vertical'>
<Descriptions bordered column={2}>
{/* Read-only fields */}
<Descriptions.Item label='ID'>
<IdText id={printerData.id} type='printer' />
</Descriptions.Item>
<Descriptions.Item label='Last Updated'>
{new Date(printerData.updatedAt).toLocaleString()}
</Descriptions.Item>
{/* Editable fields */}
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='printerName'
rules={[
{ required: true, message: 'Please enter a printer name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter printer name' />
</Form.Item>
) : (
printerData.printerName || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Host'>
{isEditing ? (
<Form.Item
name={['moonraker', 'host']}
rules={[
{ required: true, message: 'Please enter a host' },
{
pattern:
/^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$|\b(?:\d{1,3}\.){3}\d{1,3}\b/,
message: 'Please enter a valid hostname or IP address'
}
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter host (e.g., 192.168.1.100)' />
</Form.Item>
) : (
printerData.moonraker?.host || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Port'>
{isEditing ? (
<Form.Item
name={['moonraker', 'port']}
rules={[
{ required: true, message: 'Please enter a port number' },
{
type: 'number',
min: 1,
max: 65535,
message: 'Port must be between 1 and 65535'
}
]}
style={{ margin: 0 }}
>
<InputNumber
min={1}
max={65535}
placeholder='Enter port'
style={{ width: '100%' }}
/>
</Form.Item>
) : (
printerData.moonraker.port
)}
</Descriptions.Item>
<Descriptions.Item label='Protocol'>
{isEditing ? (
<Form.Item
name={['moonraker', 'protocol']}
rules={[{ required: true, message: 'Port is required' }]}
style={{ margin: 0 }}
>
<Select
defaultValue='ws'
options={[
{ value: 'ws', label: 'Websocket' },
{ value: 'wss', label: 'Websocket Secure' }
]}
/>
</Form.Item>
) : printerData.moonraker.protocol == 'ws' ? (
'Websocket'
) : (
'Websocket Secure'
)}
</Descriptions.Item>
<Descriptions.Item label='API Key'>
{isEditing ? (
<Form.Item name={['moonraker', 'apiKey']} style={{ margin: 0 }}>
<Input.Password placeholder='Enter API key' />
</Form.Item>
) : printerData.moonraker?.apiKey ? (
'Configured'
) : (
'Not configured'
)}
</Descriptions.Item>
<Descriptions.Item label='Status'>
<PrinterState
printer={printerData}
showPrinterName={false}
showControls={false}
showProgress={false}
/>
</Descriptions.Item>
<Descriptions.Item label='Tags'>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusOutlined />} />
</Space.Compact>
</Form.Item>
) : printerData.tags?.length > 0 ? (
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{printerData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
'No tags'
)}
</Descriptions.Item>
<Descriptions.Item label='Firmware Version'>
{printerData.firmware || 'Unknown'}
</Descriptions.Item>
</Descriptions>
</Form>
<Title level={5} style={{ margin: '24px 0 16px' }}>
Printer Jobs
</Title>
<PrinterSubJobsList subJobs={printerData.subJobs} />
</div>
)
}
export default PrinterInfo

View File

@ -0,0 +1,170 @@
// FilamentSelect.js
import { TreeSelect, Badge } from 'antd'
import React, { useEffect, useState, useContext, useRef } from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import { AuthContext } from '../../Auth/AuthContext'
const propertyOrder = ['diameter', 'type', 'brand']
const FilamentSelect = ({ onChange, filter, useFilter }) => {
const [filamentsTreeData, setFilamentsTreeData] = useState([])
const { token } = useContext(AuthContext)
const tokenRef = useRef(token)
const [loading, setLoading] = useState(true)
const fetchFilamentsData = async (property, filter) => {
setLoading(true)
try {
const response = await axios.get('http://localhost:8080/filaments', {
params: {
...filter,
property
},
headers: {
Authorization: `Bearer ${tokenRef.current}`
}
})
setLoading(false)
return response.data
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (err) {
console.error(err)
}
}
const getFilter = (node) => {
var filter = {}
var currentId = node.id
while (currentId != 0) {
const currentNode = filamentsTreeData.filter(
(treeData) => treeData['id'] === currentId
)[0]
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
}
return filter
}
const generateFilamentTreeNodes = async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
const filamentData = await fetchFilamentsData(null, filter)
let newNodeList = []
for (var i = 0; i < filamentData.length; i++) {
const filament = filamentData[i]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: filament._id,
key: filament._id,
title: <Badge color={filament.color} text={filament.name} />,
isLeaf: true
}
newNodeList.push(newNode)
}
setFilamentsTreeData(filamentsTreeData.concat(newNodeList))
}
const generateFilamentCategoryTreeNodes = async (node = null) => {
var filter = {}
var propertyId = 0
if (!node) {
node = {}
node.id = 0
} else {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchFilamentsData(propertyName, filter)
const newNodeList = []
for (var i = 0; i < propertyData.length; i++) {
const property = propertyData[i][propertyName]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: property + '-' + random,
key: property + '-' + random,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
newNodeList.push(newNode)
}
setFilamentsTreeData(filamentsTreeData.concat(newNodeList))
}
const handleFilamentsTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generateFilamentCategoryTreeNodes(node)
} else {
await generateFilamentTreeNodes(node) // End of properties
}
} else {
await generateFilamentCategoryTreeNodes(null) // First property
}
}
useEffect(() => {
setFilamentsTreeData([])
}, [token, filter, useFilter])
useEffect(() => {
if (filamentsTreeData.length === 0) {
if (useFilter === true) {
generateFilamentTreeNodes({ id: 0 }, filter)
} else {
handleFilamentsTreeLoad(null)
}
}
}, [filamentsTreeData])
return (
<TreeSelect
treeDataSimpleMode
loadData={handleFilamentsTreeLoad}
treeData={filamentsTreeData}
onChange={onChange}
loading={loading}
/>
)
}
FilamentSelect.propTypes = {
onChange: PropTypes.func.isRequired,
filter: PropTypes.object,
useFilter: PropTypes.bool
}
FilamentSelect.defaultProps = {
filter: {},
useFilter: false
}
export default FilamentSelect

View File

@ -0,0 +1,206 @@
// GCodeFileSelect.js
import PropTypes from 'prop-types'
import { TreeSelect, Badge, Space, message } from 'antd'
import React, { useEffect, useState, useContext } from 'react'
import axios from 'axios'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
import { AuthContext } from '../../Auth/AuthContext'
const propertyOrder = ['filament.diameter', 'filament.type', 'filament.brand']
const GCodeFileSelect = ({ onChange, filter, useFilter }) => {
const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null)
const [loading, setLoading] = useState(true)
const [searchValue, setSearchValue] = useState('')
const [messageApi] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const fetchGCodeFilesData = async (property, filter, search) => {
if (!authenticated) {
return
}
setLoading(true)
try {
const response = await axios.get('http://localhost:8080/gcodefiles', {
params: {
...filter,
search,
property
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setLoading(false)
return response.data
// setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (error) {
if (error.response) {
// For other errors, show a message
messageApi.error('Error fetching GCode files:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
const getFilter = (node) => {
const filter = {}
let currentId = node.id
while (currentId != 0) {
const currentNode = gcodeFilesTreeData.filter(
(treeData) => treeData['id'] === currentId
)[0]
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
}
return filter
}
const generateGCodeFileTreeNodes = async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
let search = null
if (searchValue != '') {
search = searchValue
}
const gcodeFileData = await fetchGCodeFilesData(null, filter, search)
let newNodeList = []
for (var i = 0; i < gcodeFileData.length; i++) {
const gcodeFile = gcodeFileData[i]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: gcodeFile._id,
key: gcodeFile._id,
title: (
<Space>
<GCodeFileIcon />
<Badge
color={gcodeFile.filament.color}
text={gcodeFile.name + ' (' + gcodeFile.filament.name + ')'}
/>
</Space>
),
isLeaf: true
}
newNodeList.push(newNode)
}
return newNodeList
}
const generateGCodeFileCategoryTreeNodes = async (node = null) => {
var filter = {}
var propertyId = 0
if (!node) {
node = {}
node.id = 0
} else {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchGCodeFilesData(propertyName, filter)
const newNodeList = []
for (var i = 0; i < propertyData.length; i++) {
const property =
propertyData[i][propertyName.split('.')[0]][propertyName.split('.')[1]]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: property + '-' + random,
key: property + '-' + random,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
newNodeList.push(newNode)
}
return newNodeList
}
const handleGCodeFilesTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
setGCodeFilesTreeData(
gcodeFilesTreeData.concat(
await generateGCodeFileCategoryTreeNodes(node)
)
)
} else {
setGCodeFilesTreeData(
gcodeFilesTreeData.concat(await generateGCodeFileTreeNodes(node))
) // End of properties
}
} else {
setGCodeFilesTreeData(await generateGCodeFileCategoryTreeNodes(null)) // First property
}
}
const handleGCodeFilesSearch = (value) => {
setSearchValue(value)
setGCodeFilesTreeData(null)
}
useEffect(() => {
setGCodeFilesTreeData([])
}, [filter, useFilter])
useEffect(() => {
if (gcodeFilesTreeData === null) {
if (useFilter === true || searchValue != '') {
setGCodeFilesTreeData(generateGCodeFileTreeNodes({ id: 0 }, filter))
} else {
handleGCodeFilesTreeLoad(null)
}
}
}, [gcodeFilesTreeData])
return (
<TreeSelect
showSearch
treeDataSimpleMode
loadData={handleGCodeFilesTreeLoad}
treeData={gcodeFilesTreeData}
onChange={onChange}
onSearch={handleGCodeFilesSearch}
loading={loading}
/>
)
}
GCodeFileSelect.propTypes = {
onChange: PropTypes.func.isRequired,
filter: PropTypes.string.isRequired,
useFilter: PropTypes.bool.isRequired
}
export default GCodeFileSelect

View File

@ -0,0 +1,127 @@
// PrinterSelect.js
import React from 'react'
import PropTypes from 'prop-types'
import { Flex, Typography, Button, Tooltip, message } from 'antd'
import { useNavigate } from 'react-router-dom'
import { CopyOutlined } from '@ant-design/icons'
const { Text, Link } = Typography
const IdText = ({
id,
type,
showCopy = true,
longId = true,
showHyperlink = false
}) => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
var prefix = 'UNK'
var hyperlink = '#'
switch (type) {
case 'printer':
prefix = 'PRN'
hyperlink = `/production/printers/info?printerId=${id}`
break
case 'filament':
prefix = 'FIL'
hyperlink = `/management/filaments/info?filamentId=${id}`
break
case 'spool':
prefix = 'SPL'
hyperlink = `/inventory/spool/info?spoolId=${id}`
break
case 'gcodeFile':
prefix = 'GCF'
hyperlink = `/production/gcodefiles/info?gcodeFileId=${id}`
break
case 'job':
prefix = 'JOB'
hyperlink = `/production/printjobs/info?printJobId=${id}`
break
case 'part':
prefix = 'PRT'
hyperlink = `/management/parts/info?partId=${id}`
break
case 'product':
prefix = 'PRD'
hyperlink = `/management/products/info?productId=${id}`
break
case 'vendor':
prefix = 'VEN'
hyperlink = `/management/vendors/info?vendorId=${id}`
break
case 'subjob':
prefix = 'SJB'
hyperlink = `#`
break
default:
hyperlink = `#`
prefix = 'UNK'
}
id = id.toString().toUpperCase()
var displayId = prefix + ':' + id
var copyId = prefix + ':' + id
if (longId == false) {
displayId = prefix + ':' + id.toString().slice(-6)
}
return (
<Flex align={'center'} gap={'small'}>
{contextHolder}
{showHyperlink && (
<Link
onClick={() => {
if (showHyperlink) {
navigate(hyperlink)
}
}}
>
<Text code ellipsis>
{displayId}
</Text>
</Link>
)}
{!showHyperlink && (
<Text code ellipsis>
{displayId}
</Text>
)}
{showCopy && (
<Tooltip title='Copy ID' arrow={false}>
<Button
icon={<CopyOutlined style={{ fontSize: '14px' }} />}
type='text'
style={{ height: '22px' }}
onClick={() => {
navigator.clipboard
.writeText(copyId)
.then(() => {
messageApi.success('ID copied to clipboard')
})
.catch(() => {
messageApi.error('Failed to copy ID')
})
}}
/>
</Tooltip>
)}
</Flex>
)
}
IdText.propTypes = {
id: PropTypes.string,
type: PropTypes.string,
showCopy: PropTypes.bool,
longId: PropTypes.bool,
showHyperlink: PropTypes.bool
}
export default IdText

View File

@ -0,0 +1,59 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu } from 'antd'
import {
DashboardOutlined,
InboxOutlined,
HistoryOutlined
} from '@ant-design/icons'
const { Sider } = Layout
const InventorySidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('inventory')
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 1) {
setSelectedKey(pathParts[1]) // Return the section (inventory/management)
}
}, [location.pathname])
const items = [
{
key: 'overview',
label: <Link to='/inventory/overview'>Overview</Link>,
icon: <DashboardOutlined />
},
{
key: 'spools',
label: <Link to='/inventory/spools'>Spools</Link>,
icon: <InboxOutlined />
},
{
key: 'stock',
label: <Link to='/inventory/stock'>Stock</Link>,
icon: <InboxOutlined />
},
{
key: 'history',
label: <Link to='/inventory/history'>History</Link>,
icon: <HistoryOutlined />
}
]
return (
<Sider width={250}>
<Menu
mode='inline'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
style={{ height: '100%' }}
items={items}
/>
</Sider>
)
}
export default InventorySidebar

View File

@ -0,0 +1,116 @@
import PropTypes from 'prop-types'
import { Badge, Progress, Flex, Typography, Tag, Space } from 'antd'
import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext'
import IdText from './IdText'
const JobState = ({
job,
showProgress = true,
showStatus = true,
showId = true,
showQuantity = true
}) => {
const { socket } = useContext(SocketContext)
const [badgeStatus, setBadgeStatus] = useState('default')
const [badgeText, setBadgeText] = useState('Unknown')
const [currentState, setCurrentState] = useState(
job?.state || { type: 'unknown', progress: 0 }
)
const [initialized, setInitialized] = useState(false)
const { Text } = Typography
useEffect(() => {
if (socket && !initialized && job?.id) {
setInitialized(true)
socket.on('notify_job_update', (statusUpdate) => {
if (statusUpdate?.id === job.id && statusUpdate?.state) {
setCurrentState(statusUpdate.state)
}
})
}
return () => {
if (socket && initialized) {
socket.off('notify_job_update')
}
}
}, [socket, initialized, job?.id])
useEffect(() => {
switch (currentState?.type) {
case 'draft':
setBadgeStatus('default')
setBadgeText('Draft')
break
case 'printing':
setBadgeStatus('processing')
setBadgeText('Printing')
break
case 'complete':
setBadgeStatus('success')
setBadgeText('Complete')
break
case 'failed':
setBadgeStatus('error')
setBadgeText('Failed')
break
case 'queued':
setBadgeStatus('warning')
setBadgeText('Queued')
break
case 'paused':
setBadgeStatus('warning')
setBadgeText('Paused')
break
default:
setBadgeStatus('default')
setBadgeText('Unknown')
}
}, [currentState])
return (
<Flex gap='small' align={'center'}>
{showId && (
<>
{'Sub Job '}
<IdText id={job.id} showCopy={false} type='job' longId={false} />
</>
)}
{showQuantity && <Text>(Quantity: {job.quantity})</Text>}
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
<Flex gap={6}>
<Badge status={badgeStatus} />
{badgeText}
</Flex>
</Tag>
</Space>
)}
{showProgress &&
(currentState.type === 'printing' ||
currentState.type === 'processing') ? (
<Progress
percent={Math.round(currentState.progress * 100)}
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}
</Flex>
)
}
JobState.propTypes = {
job: PropTypes.shape({
id: PropTypes.string,
quantity: PropTypes.number,
state: PropTypes.shape({
type: PropTypes.string,
progress: PropTypes.number
})
}),
showProgress: PropTypes.bool,
showQuantity: PropTypes.bool,
showId: PropTypes.bool,
showStatus: PropTypes.bool
}
export default JobState

View File

@ -0,0 +1,98 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd'
import {
SettingOutlined,
AuditOutlined,
ShopOutlined,
BlockOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons'
import FilamentIcon from '../../Icons/FilamentIcon'
import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon'
const { Sider } = Layout
const ManagementSidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 1) {
setSelectedKey(pathParts[1]) // Return the section (production/management)
}
}, [location.pathname])
const items = [
{
key: 'filaments',
label: <Link to='/management/filaments'>Filaments</Link>,
icon: <FilamentIcon />
},
{
key: 'parts',
label: <Link to='/management/parts'>Parts</Link>,
icon: <PartIcon />
},
{
key: 'products',
label: <Link to='/management/products'>Products</Link>,
icon: <ProductIcon />
},
{
key: 'vendors',
label: <Link to='/management/vendors'>Vendors</Link>,
icon: <ShopOutlined />
},
{
key: 'materials',
label: <Link to='/management/products'>Materials</Link>,
icon: <BlockOutlined />
},
{
key: 'settings',
label: <Link to='/management/settings'>Settings</Link>,
icon: <SettingOutlined />
},
{
key: 'audit',
label: <Link to='/management/audit'>Audit Log</Link>,
icon: <AuditOutlined />
}
]
return (
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex
style={{ height: '100%' }}
vertical
className='ant-menu-root ant-menu-inline ant-menu-light'
>
<Menu
mode='inline'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['filaments']}
items={items}
style={{ flexGrow: 1, border: 'none' }}
/>
<Flex style={{ padding: '4px', width: '100%' }}>
<Button
size='large'
type='text'
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
style={{ flexGrow: 1 }}
onClick={() => {
setCollapsed(!collapsed)
}}
/>
</Flex>
</Flex>
</Sider>
)
}
export default ManagementSidebar

View File

@ -0,0 +1,170 @@
// PartSelect.js
import { TreeSelect, Badge } from 'antd'
import React, { useEffect, useState, useContext, useRef } from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import { AuthContext } from '../../Auth/AuthContext'
const propertyOrder = ['diameter', 'type', 'brand']
const PartSelect = ({ onChange, filter, useFilter }) => {
const [partsTreeData, setPartsTreeData] = useState([])
const { token } = useContext(AuthContext)
const tokenRef = useRef(token)
const [loading, setLoading] = useState(true)
const fetchPartsData = async (property, filter) => {
setLoading(true)
try {
const response = await axios.get('http://localhost:8080/parts', {
params: {
...filter,
property
},
headers: {
Authorization: `Bearer ${tokenRef.current}`
}
})
setLoading(false)
return response.data
//setPagination({ ...pagination, total: response.data.totalItems }); // Update total count
} catch (err) {
console.error(err)
}
}
const getFilter = (node) => {
var filter = {}
var currentId = node.id
while (currentId != 0) {
const currentNode = partsTreeData.filter(
(treeData) => treeData['id'] === currentId
)[0]
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
}
return filter
}
const generatePartTreeNodes = async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
const partData = await fetchPartsData(null, filter)
let newNodeList = []
for (var i = 0; i < partData.length; i++) {
const part = partData[i]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: part._id,
key: part._id,
title: <Badge color={part.color} text={part.name} />,
isLeaf: true
}
newNodeList.push(newNode)
}
setPartsTreeData(partsTreeData.concat(newNodeList))
}
const generatePartCategoryTreeNodes = async (node = null) => {
var filter = {}
var propertyId = 0
if (!node) {
node = {}
node.id = 0
} else {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchPartsData(propertyName, filter)
const newNodeList = []
for (var i = 0; i < propertyData.length; i++) {
const property = propertyData[i][propertyName]
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: property + '-' + random,
key: property + '-' + random,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
newNodeList.push(newNode)
}
setPartsTreeData(partsTreeData.concat(newNodeList))
}
const handlePartsTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generatePartCategoryTreeNodes(node)
} else {
await generatePartTreeNodes(node) // End of properties
}
} else {
await generatePartCategoryTreeNodes(null) // First property
}
}
useEffect(() => {
setPartsTreeData([])
}, [token, filter, useFilter])
useEffect(() => {
if (partsTreeData.length === 0) {
if (useFilter === true) {
generatePartTreeNodes({ id: 0 }, filter)
} else {
handlePartsTreeLoad(null)
}
}
}, [partsTreeData])
return (
<TreeSelect
treeDataSimpleMode
loadData={handlePartsTreeLoad}
treeData={partsTreeData}
onChange={onChange}
loading={loading}
/>
)
}
PartSelect.propTypes = {
onChange: PropTypes.func.isRequired,
filter: PropTypes.object,
useFilter: PropTypes.bool
}
PartSelect.defaultProps = {
filter: {},
useFilter: false
}
export default PartSelect

View File

@ -0,0 +1,194 @@
import PropTypes from 'prop-types'
import { Card, Tree, Spin, Space, Button, message, Typography } from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import React, { useState, useEffect, useContext } from 'react'
import SubJobState from './SubJobState'
import { SocketContext } from '../context/SocketContext'
import axios from 'axios'
import JobState from './JobState'
const PrinterJobsTree = ({ subJobs: initialSubJobs }) => {
const [subJobs, setSubJobs] = useState(initialSubJobs || [])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const { socket } = useContext(SocketContext)
const [messageApi] = message.useMessage()
const [expandedKeys, setExpandedKeys] = useState([])
const [treeData, setTreeData] = useState([])
const { Text } = Typography
const buildTreeData = (subJobsData) => {
if (!subJobsData?.length) {
setTreeData([])
setExpandedKeys([])
return
}
// Group subjobs by printJob
const printJobGroups = subJobsData.reduce((acc, subJob) => {
const printJobId = subJob.printJob._id
if (!acc[printJobId]) {
acc[printJobId] = {
printJob: subJob.printJob,
subJobs: []
}
}
acc[printJobId].subJobs.push(subJob)
return acc
}, {})
// Create tree nodes for each printJob
const printJobNodes = Object.values(printJobGroups).map(
({ 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
</>
}
/>
),
key: `printjob-${printJob._id}`,
children: subJobs.map((subJob) => ({
title: (
<SubJobState
subJob={subJob}
text={`Sub Job #${subJob.number}
`}
showProgress={false}
showControls={false}
/>
),
key: `subjob-${subJob._id}`,
isLeaf: true
}))
}
}
)
setTreeData(printJobNodes)
}
useEffect(() => {
buildTreeData(subJobs)
}, [subJobs])
useEffect(() => {
const initializeData = async () => {
if (!initialSubJobs) {
try {
setLoading(true)
const response = await axios.get('http://localhost:8080/printjobs', {
headers: { Accept: 'application/json' },
withCredentials: true
})
if (response.data?.subJobs) {
setSubJobs(response.data.subJobs)
}
} catch (err) {
setError('Failed to fetch sub jobs')
messageApi.error('Failed to fetch sub jobs')
} finally {
setLoading(false)
}
} else {
setSubJobs(initialSubJobs)
}
}
initializeData()
// Add socket.io event listener for subjob updates
if (socket) {
socket.on('notify_subjob_update', (updateData) => {
if (updateData.subJobId) {
setSubJobs((prevSubJobs) =>
prevSubJobs.map((subJob) => {
if (subJob._id === updateData.id) {
return {
...subJob,
state: updateData.state,
subJobId: updateData.subJobId
}
}
return subJob
})
)
}
})
}
return () => {
if (socket) {
socket.off('notify_subjob_update')
}
}
}, [initialSubJobs, socket])
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error}</p>
<Button icon={<ReloadOutlined />} onClick={() => setError(null)}>
Retry
</Button>
</Space>
)
}
return (
<Card>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
/>
</Card>
)
}
PrinterJobsTree.propTypes = {
subJobs: PropTypes.arrayOf(
PropTypes.shape({
state: PropTypes.object.isRequired,
_id: PropTypes.string.isRequired,
printer: PropTypes.string.isRequired,
printJob: PropTypes.shape({
state: PropTypes.object.isRequired,
_id: PropTypes.string.isRequired,
printers: PropTypes.arrayOf(PropTypes.string).isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired,
startedAt: PropTypes.string.isRequired,
gcodeFile: PropTypes.string.isRequired,
quantity: PropTypes.number.isRequired,
subJobs: PropTypes.arrayOf(PropTypes.string).isRequired
}).isRequired,
subJobId: PropTypes.string.isRequired,
number: PropTypes.number.isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired
})
)
}
export default PrinterJobsTree

View File

@ -0,0 +1,256 @@
// PrinterMovementPanel.js
import React, { useContext, useState } from 'react'
import {
Flex,
Space,
InputNumber,
Button,
Radio,
Dropdown,
Card,
message // eslint-disable-line
} from 'antd'
import {
ArrowUpOutlined,
ArrowLeftOutlined,
HomeOutlined,
ArrowRightOutlined,
ArrowDownOutlined
} from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext'
import UnloadIcon from '../../Icons/UnloadIcon'
import PropTypes from 'prop-types'
import LevelBedIcon from '../../Icons/LevelBedIcon'
const PrinterMovementPanel = ({ printerId }) => {
const [posValue, setPosValue] = useState(10)
const [rateValue, setRateValue] = useState(1000)
const { socket } = useContext(SocketContext)
//const messageApi = message.useMessage()
const handlePosRadioChange = (e) => {
const value = e.target.value
setPosValue(value) // Update posValue state when radio button changes
}
const handlePosInputChange = (value) => {
setPosValue(value) // Update posValue state when input changes
}
const handleRateInputChange = (value) => {
setRateValue(value) // Update rateValue state when input changes
}
const handleHomeAxisClick = (axis) => {
if (socket) {
console.log('Homeing Axis:', axis)
socket.emit('printer.gcode.script', {
printerId,
script: `G28 ${axis}`
})
}
}
const handleMoveAxisClick = (axis, minus) => {
const distanceValue = !minus ? posValue * -1 : posValue
if (socket) {
console.log('Moving Axis:', axis, distanceValue)
socket.emit('printer.gcode.script', {
printerId,
script: `_CLIENT_LINEAR_MOVE ${axis}=${distanceValue} F=${rateValue}`
})
}
//sendCommand('moveAxis', { axis, pos, rate })
}
const handleLevelBedClick = () => {
//sendCommand('levelBed')
}
const handleUnloadFilamentClick = () => {
if (socket) {
socket.emit('printer.gcode.script', {
printerId,
script: `UNLOAD_FILAMENT TEMP=`
})
}
}
const homeAxisButtonItems = [
{
key: 'homeXYZ',
label: 'Home XYZ',
onClick: () => handleHomeAxisClick('ALL')
},
{
key: 'homeXY',
label: 'Home XY',
onClick: () => handleHomeAxisClick('X Y')
},
{
key: 'homeX',
label: 'Home X',
onClick: () => handleHomeAxisClick('X')
},
{
key: 'homeY',
label: 'Home Y',
onClick: () => handleHomeAxisClick('Y')
},
{
key: 'homeZ',
label: 'Home Z',
onClick: () => handleHomeAxisClick('Z')
}
]
return (
<div style={{ minWidth: 190 }}>
<Flex vertical gap={'large'}>
<Flex horizontal='true' gap='small'>
<Card size='small' title='XY'>
<Flex
vertical
align='center'
justify='center'
gap='small'
style={{ height: '100%' }}
>
<Button
icon={<ArrowUpOutlined />}
onClick={() => {
handleMoveAxisClick('Y', false)
}}
/>
<Space>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => {
handleMoveAxisClick('X', false)
}}
/>
<Dropdown
menu={{ items: homeAxisButtonItems }}
placement='bottom'
>
<Button icon={<HomeOutlined />}></Button>
</Dropdown>
<Button
icon={<ArrowRightOutlined />}
onClick={() => {
handleMoveAxisClick('X', true)
}}
/>
</Space>
<Button
icon={<ArrowDownOutlined />}
onClick={() => {
handleMoveAxisClick('Y', true)
}}
></Button>
</Flex>
</Card>
<Card size='small' title='Z'>
<Flex
vertical
align='center'
justify='center'
gap='small'
style={{ height: '100%' }}
>
<Button
icon={<ArrowUpOutlined />}
onClick={() => {
handleMoveAxisClick('Z', true)
}}
/>
<Button
icon={<LevelBedIcon />}
onClick={() => {
handleLevelBedClick()
}}
/>
<Button
icon={<ArrowDownOutlined />}
onClick={() => {
handleMoveAxisClick('Z', false)
}}
></Button>
</Flex>
</Card>
<Card size='small' title='E'>
<Flex vertical align='center' justify='center' gap='small'>
<Button
icon={<ArrowUpOutlined />}
onClick={() => {
handleMoveAxisClick('E', true)
}}
/>
<Button
icon={<UnloadIcon />}
onClick={() => {
handleUnloadFilamentClick()
}}
/>
<Button
icon={<ArrowDownOutlined />}
onClick={() => {
handleMoveAxisClick('E', false)
}}
></Button>
</Flex>
</Card>
</Flex>
<Flex vertical gap='small'>
<Radio.Group
onChange={handlePosRadioChange}
value={posValue}
name='posRadio'
style={{
display: 'flex',
justifyContent: 'center'
}}
block
>
<Radio.Button value={0.1}>0.1</Radio.Button>
<Radio.Button value={1}>1</Radio.Button>
<Radio.Button value={10}>10</Radio.Button>
<Radio.Button value={100}>100</Radio.Button>
</Radio.Group>
<Flex horizontal='true' gap='small'>
<InputNumber
min={0.1}
max={100}
value={posValue}
formatter={(value) => `${value} mm`}
parser={(value) => value?.replace(' mm', '')}
onChange={handlePosInputChange}
placeholder='10 mm'
name='posInput'
style={{ flexGrow: 1 }}
/>
<InputNumber
min={1}
max={5000}
value={rateValue}
formatter={(value) => `${value} mm/s`}
parser={(value) => value?.replace(' mm/s', '')}
onChange={handleRateInputChange}
placeholder='100 mm/s'
name='rateInput'
style={{ flexGrow: 1 }}
/>
</Flex>
</Flex>
</Flex>
</div>
)
}
PrinterMovementPanel.propTypes = {
printerId: PropTypes.string.isRequired
}
export default PrinterMovementPanel

View File

@ -0,0 +1,115 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import { TreeSelect, message, Tag } from 'antd'
import React, { useEffect, useState, useContext } from 'react'
import axios from 'axios'
import PrinterState from './PrinterState'
import { AuthContext } from '../../Auth/AuthContext'
const PrinterSelect = ({ onChange, disabled, checkable }) => {
const [printersData, setPrintersData] = useState([])
const [loading, setLoading] = useState(true)
const [messageApi] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const fetchPrintersData = async () => {
if (!authenticated) {
return
}
setLoading(true)
try {
const response = await axios.get('http://localhost:8080/printers', {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setLoading(false)
return response.data
} catch (error) {
if (error.response) {
// For other errors, show a message
messageApi.error('Error fetching printers data:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
const generatePrinterItems = async () => {
const printerData = await fetchPrintersData()
// Create a map to store tags and their printers
const tagMap = new Map()
// Add printers to their respective tag groups
printerData.forEach((printer) => {
if (printer.tags && printer.tags.length > 0) {
printer.tags.forEach((tag) => {
if (!tagMap.has(tag)) {
tagMap.set(tag, [])
}
tagMap.get(tag).push(printer)
})
} else {
// If no tags, add to "Untagged" group
if (!tagMap.has('Untagged')) {
tagMap.set('Untagged', [])
}
tagMap.get('Untagged').push(printer)
}
})
// Convert the map to tree data structure
const treeData = Array.from(tagMap.entries()).map(([tag, printers]) => ({
title: tag === 'Untagged' ? tag : <Tag color='blue'>{tag}</Tag>,
value: `tag-${tag}`,
key: `tag-${tag}`,
children: printers.map((printer) => ({
title: (
<PrinterState
printer={printer}
showProgress={false}
showControls={false}
/>
),
value: printer._id,
key: printer._id
}))
}))
setPrintersData(treeData)
}
useEffect(() => {
if (printersData.length === 0) {
generatePrinterItems()
}
}, [])
return (
<TreeSelect
treeData={printersData}
onChange={onChange}
loading={loading}
disabled={disabled}
treeDefaultExpandAll
treeCheckable={checkable}
treeNodeFilterProp='title'
placeholder='Select printer'
style={{ width: '100%' }}
/>
)
}
PrinterSelect.propTypes = {
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
checkable: PropTypes.bool
}
export default PrinterSelect

View File

@ -0,0 +1,189 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import { Badge, Progress, Flex, Space, Tag, Typography, Button } from 'antd'
import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext'
import {
CloseOutlined,
PauseOutlined,
CaretRightOutlined
} from '@ant-design/icons'
const PrinterState = ({
printer,
showProgress = true,
showStatus = true,
showPrinterName = true,
showControls = true
}) => {
const { socket } = useContext(SocketContext)
const [badgeStatus, setBadgeStatus] = useState('unknown')
const [badgeText, setBadgeText] = useState('Unknown')
const [currentState, setCurrentState] = useState(
printer?.state || {
type: 'unknown',
progress: 0
}
)
const [initialized, setInitialized] = useState(false)
const { Text } = Typography
useEffect(() => {
if (socket && !initialized && printer?.id) {
setInitialized(true)
socket.on('notify_printer_update', (statusUpdate) => {
if (statusUpdate?.id === printer.id && statusUpdate?.state) {
setCurrentState(statusUpdate.state)
}
})
}
return () => {
if (socket && initialized) {
socket.off('notify_printer_update')
}
}
}, [socket, initialized, printer?.id])
useEffect(() => {
switch (currentState.type) {
case 'online':
setBadgeStatus('success')
setBadgeText('Online')
break
case 'standby':
setBadgeStatus('success')
setBadgeText('Standby')
break
case 'complete':
setBadgeStatus('success')
setBadgeText('Complete')
break
case 'offline':
setBadgeStatus('default')
setBadgeText('Offline')
break
case 'shutdown':
setBadgeStatus('default')
setBadgeText('Shutdown')
break
case 'initializing':
setBadgeStatus('warning')
setBadgeText('Initializing')
break
case 'printing':
setBadgeStatus('processing')
setBadgeText('Printing')
break
case 'paused':
setBadgeStatus('warning')
setBadgeText('Paused')
break
case 'cancelled':
setBadgeStatus('warning')
setBadgeText('Cancelled')
break
case 'loading':
setBadgeStatus('processing')
setBadgeText('Uploading')
break
case 'processing':
setBadgeStatus('processing')
setBadgeText('Processing')
break
case 'ready':
setBadgeStatus('success')
setBadgeText('Ready')
break
case 'error':
setBadgeStatus('error')
setBadgeText('Error')
break
default:
setBadgeStatus('default')
setBadgeText(currentState.type)
}
}, [currentState])
return (
<Flex gap='small' align={'center'}>
{showPrinterName && <Text>{printer.printerName}</Text>}
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
<Flex gap={6}>
<Badge status={badgeStatus} />
{badgeText}
</Flex>
</Tag>
</Space>
)}
{showProgress &&
(currentState.type === 'printing' ||
currentState.type === 'deploying') ? (
<Progress
percent={Math.round(currentState.progress * 100)}
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}
{showControls && currentState.type === 'printing' ? (
<Space.Compact>
<Button
onClick={() => {
if (currentState.type === 'printing') {
socket.emit('printer.print.pause', {
printerId: printer.id
})
} else {
socket.emit('printer.print.resume', {
printerId: printer.id
})
}
}}
style={{ height: '22px' }}
icon={
currentState.type === 'printing' ? (
<PauseOutlined
style={{ fontSize: '10px', marginBottom: '3px' }}
/>
) : (
<CaretRightOutlined
style={{ fontSize: '10px', marginBottom: '3px' }}
/>
)
}
></Button>
<Button
onClick={() => {
socket.emit('printer.print.cancel', {
printerId: printer.id
})
}}
style={{ height: '22px' }}
icon={
<CloseOutlined
style={{ fontSize: '10px', marginBottom: '3px' }}
/>
}
/>
</Space.Compact>
) : null}
</Flex>
)
}
PrinterState.propTypes = {
printer: PropTypes.shape({
id: PropTypes.string,
printerName: PropTypes.string,
state: PropTypes.shape({
type: PropTypes.string,
progress: PropTypes.number
})
}),
showProgress: PropTypes.bool,
showStatus: PropTypes.bool,
showPrinterName: PropTypes.bool,
showControls: PropTypes.bool
}
export default PrinterState

View File

@ -0,0 +1,304 @@
// PrinterTemperaturePanel.js
import React, { useContext, useState, useEffect } from 'react'
import {
Progress,
Typography,
Spin,
Flex,
Space,
Collapse,
InputNumber,
Button
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { SocketContext } from '../context/SocketContext'
import styled from 'styled-components'
import PropTypes from 'prop-types'
const { Text } = Typography
const { Panel } = Collapse
const CustomCollapse = styled(Collapse)`
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content-box {
padding-left: 0 !important;
padding-right: 0 !important;
padding-bottom: 0 !important;
}
`
const PrinterTemperaturePanel = ({
printerId,
showControls = true,
showMoreInfo = true
}) => {
const [temperatureData, setTemperatureData] = useState({
hotEnd: {},
heatedBed: {}
})
// const [loading, setLoading] = React.useState(false)
const [hotEndTemperature, setHotEndTemperature] = useState(
temperatureData?.hotEnd?.target || 0
)
const [heatedBedTemperature, setHeatedBedTemperature] = useState(
temperatureData?.heatedBed?.target || 0
)
const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
const params = {
printerId,
objects: {
extruder: null,
heater_bed: null // eslint-disable-line
}
}
const notifyStatusUpdate = (statusUpdate) => {
var temperatureObject = {
...temperatureData
}
if (statusUpdate?.extruder?.temperature !== undefined) {
temperatureObject.hotEnd.current = statusUpdate?.extruder?.temperature
}
if (statusUpdate?.heater_bed?.temperature !== undefined) {
temperatureObject.heatedBed.current =
statusUpdate?.heater_bed?.temperature
}
if (statusUpdate?.extruder?.target !== undefined) {
temperatureObject.hotEnd.target = statusUpdate?.extruder?.target
setHotEndTemperature(statusUpdate?.extruder?.target)
}
if (statusUpdate?.heater_bed?.target !== undefined) {
temperatureObject.heatedBed.target = statusUpdate?.heater_bed?.target
setHeatedBedTemperature(statusUpdate?.heater_bed?.target)
}
if (statusUpdate?.extruder?.power !== undefined) {
temperatureObject.hotEnd.power = statusUpdate?.extruder?.power
}
if (statusUpdate?.heater_bed?.power !== undefined) {
temperatureObject.heatedBed.power = statusUpdate?.heater_bed?.power
}
setTemperatureData(temperatureObject)
}
if (!initialized && socket) {
setInitialized(true)
socket.on('connect', () => {
console.log('Connected to socket!')
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
})
console.log('Subscribing to temperature data')
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
socket.on('notify_status_update', notifyStatusUpdate)
}
return () => {
if (socket && initialized) {
console.log('Unsubscribing...')
socket.off('notify_status_update', notifyStatusUpdate)
socket.emit('printer.objects.unsubscribe', params)
}
// Cleanup code here, like:
// - Removing event listeners
// - Clearing timers
// - Closing sockets
}
}, [socket, initialized, printerId])
const handleSetTemperatureClick = (target, value) => {
if (socket) {
console.log('printer.gcode.script', target, value)
socket.emit('printer.gcode.script', {
printerId,
script: `SET_HEATER_TEMPERATURE HEATER=${target} TARGET=${value}`
})
}
}
const moreInfoItems = [
{
key: '1',
label: 'More Temperature Data',
children: (
<>
<Flex vertical gap={0}>
<Text>
Hot End Power:{' '}
{Math.round((temperatureData.hotEnd.power || 0) * 100)}%
</Text>
<Progress
percent={(temperatureData.hotEnd.power || 0) * 100}
showInfo={false}
/>
</Flex>
<Flex vertical gap={0}>
<Text>
Bed Power:{' '}
{Math.round((temperatureData.heatedBed.power || 0) * 100)}%
</Text>
<Progress
percent={(temperatureData.heatedBed.power || 0) * 100}
showInfo={false}
/>
</Flex>
<Panel>
{typeof temperatureData.pindaTemp !== 'undefined' && (
<Flex vertical gap={0}>
<Text>Pinda Temp: {temperatureData.pindaTemp}°C</Text>
</Flex>
)}
{typeof temperatureData.ambiantActual !== 'undefined' && (
<Flex vertical gap={0}>
<Text>Ambient Actual: {temperatureData.ambiantActual}°C</Text>
</Flex>
)}
</Panel>
</>
)
}
]
return (
<div style={{ minWidth: 190 }}>
{temperatureData ? (
<Flex vertical gap='middle'>
{temperatureData.hotEnd && (
<Flex vertical gap={0}>
<Text>
Hot End: {temperatureData.hotEnd.current}°C /{' '}
{temperatureData.hotEnd.target}°C
</Text>
<Progress
percent={(temperatureData.hotEnd.target / 300) * 100}
strokeColor='#FF392F1D'
success={{
percent: (temperatureData.hotEnd.current / 300) * 100,
strokeColor: '#FF3B2F'
}}
showInfo={false}
/>
{showControls === true && (
<Space direction='horizontal' style={{ marginTop: 5 }}>
<Space.Compact block size='small'>
<InputNumber
value={hotEndTemperature}
min={0}
max={300}
style={{ width: '120px' }}
addonAfter='°C'
onChange={(value) => setHotEndTemperature(value)}
onPressEnter={() =>
handleSetTemperatureClick('extruder', hotEndTemperature)
}
/>
<Button
type='default'
onClick={() =>
handleSetTemperatureClick('extruder', hotEndTemperature)
}
>
Set
</Button>
</Space.Compact>
<Button
type='default'
size='small'
onClick={() => handleSetTemperatureClick('extruder', 0)}
>
Off
</Button>
</Space>
)}
</Flex>
)}
{temperatureData.heatedBed && (
<Flex vertical gap={0}>
<Text>
Heated Bed: {temperatureData.heatedBed.current}°C /{' '}
{temperatureData.heatedBed.target}°C
</Text>
<Progress
percent={(temperatureData.heatedBed.target / 300) * 100}
strokeColor='#FF392F1D'
success={{
percent: (temperatureData.heatedBed.current / 300) * 100,
strokeColor: '#FF3B2F'
}}
showInfo={false}
/>
{showControls === true && (
<Space direction='horizontal' style={{ marginTop: 5 }}>
<Space.Compact block size='small'>
<InputNumber
value={heatedBedTemperature}
min={0}
max={300}
style={{ width: '120px' }}
addonAfter='°C'
onChange={(value) => setHeatedBedTemperature(value)}
onPressEnter={() =>
handleSetTemperatureClick(
'heater_bed',
heatedBedTemperature
)
}
/>
<Button
type='default'
onClick={() =>
handleSetTemperatureClick(
'heater_bed',
heatedBedTemperature
)
}
>
Set
</Button>
</Space.Compact>
<Button
type='default'
size='small'
onClick={() => handleSetTemperatureClick('heater_bed', 0)}
>
Off
</Button>
</Space>
)}
</Flex>
)}
{showMoreInfo === true && (
<CustomCollapse ghost size='small' items={moreInfoItems} />
)}
</Flex>
) : (
<Flex justify='centre'>
<Spin indicator={<LoadingOutlined spin />} size='large' />
</Flex>
)}
</div>
)
}
PrinterTemperaturePanel.propTypes = {
printerId: PropTypes.string.isRequired,
showControls: PropTypes.bool,
showMoreInfo: PropTypes.bool
}
export default PrinterTemperaturePanel

View File

@ -0,0 +1,78 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu, Flex, Button } from 'antd'
import {
DashboardOutlined,
PrinterOutlined,
PlayCircleOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons'
import GCodeFileIcon from '../../Icons/GCodeFileIcon'
const { Sider } = Layout
const ProductionSidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 1) {
setSelectedKey(pathParts[1]) // Return the section (production/management)
}
}, [location.pathname])
const items = [
{
key: 'overview',
label: <Link to='/production/overview'>Overview</Link>,
icon: <DashboardOutlined />
},
{
key: 'printers',
label: <Link to='/production/printers'>Printers</Link>,
icon: <PrinterOutlined />
},
{
key: 'printjobs',
label: <Link to='/production/printjobs'>Print Jobs</Link>,
icon: <PlayCircleOutlined />
},
{
key: 'gcodefiles',
label: <Link to='/production/gcodefiles'>G Code Files</Link>,
icon: <GCodeFileIcon />
}
]
return (
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex
style={{ height: '100%' }}
vertical
className='ant-menu-root ant-menu-inline ant-menu-light'
>
<Menu
mode='inline'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
items={items}
style={{ flexGrow: 1, border: 'none' }}
/>
<Flex style={{ padding: '4px', width: '100%' }}>
<Button
size='large'
type='text'
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
style={{ flexGrow: 1 }}
onClick={() => {
setCollapsed(!collapsed)
}}
/>
</Flex>
</Flex>
</Sider>
)
}
export default ProductionSidebar

View File

@ -0,0 +1,119 @@
import PropTypes from 'prop-types'
import { Typography, Tag } from 'antd' // eslint-disable-line
import {
CheckCircleOutlined,
PauseCircleOutlined,
QuestionCircleOutlined,
PlayCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons' // eslint-disable-line
import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext'
const SubJobCounter = ({
job,
showIcon = true,
state = { type: 'complete' }
}) => {
const { socket } = useContext(SocketContext)
const [initialized, setInitialized] = useState(false)
var badgeStatus = 'unknown'
var badgeIcon = <QuestionCircleOutlined />
const [subJobs, setSubJobs] = useState(job.subJobs)
const [count, setCount] = useState(0)
useEffect(() => {
if (socket && !initialized && job?.id) {
setInitialized(true)
console.log('on notify_subjob_update')
socket.on('notify_subjob_update', (statusUpdate) => {
for (const subJob of job.subJobs) {
if (statusUpdate?.id === subJob.id && statusUpdate?.state) {
console.log('statusUpdate', statusUpdate)
setSubJobs((prev) => [...prev, statusUpdate])
}
}
})
}
return () => {
if (socket && initialized) {
console.log('off notify_subjob_update')
socket.off('notify_subjob_update')
}
}
}, [socket, initialized, job?.subJobs, job?.id])
switch (state.type) {
case 'draft':
badgeStatus = 'default'
badgeIcon = <QuestionCircleOutlined />
break
case 'printing':
badgeStatus = 'processing'
badgeIcon = <PlayCircleOutlined />
break
case 'complete':
badgeStatus = 'success'
badgeIcon = <CheckCircleOutlined />
break
case 'failed':
badgeStatus = 'error'
badgeIcon = <CloseCircleOutlined />
break
case 'queued':
badgeStatus = 'warning'
badgeIcon = <PauseCircleOutlined />
break
case 'paused':
badgeStatus = 'warning'
badgeIcon = <PauseCircleOutlined />
break
case 'cancelled':
badgeIcon = <CloseCircleOutlined />
break
default:
badgeStatus = 'default'
}
useEffect(() => {
setCount(0)
for (let subJob of subJobs) {
if (subJob.state.type === state.type) {
setCount((prevCount) => prevCount + 1)
}
}
}, [subJobs, state.type])
return (
<Tag
count={badgeIcon}
color={badgeStatus}
icon={showIcon ? badgeIcon : null}
>
{count.toString()}
</Tag>
)
}
SubJobCounter.propTypes = {
state: PropTypes.shape({
type: PropTypes.string
}),
job: PropTypes.shape({
id: PropTypes.string,
subJobs: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string,
subJobId: PropTypes.string,
state: PropTypes.shape({
type: PropTypes.string,
progress: PropTypes.number
})
})
)
}),
showIcon: PropTypes.bool
}
export default SubJobCounter

View File

@ -0,0 +1,204 @@
import PropTypes from 'prop-types'
import { Badge, Progress, Flex, Button, Space, Tag } from 'antd' // eslint-disable-line
import {
CloseOutlined,
DeleteOutlined,
PauseOutlined,
CaretRightOutlined
} from '@ant-design/icons' // eslint-disable-line
import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext'
import IdText from './IdText'
const SubJobState = ({
subJob,
showStatus = true,
showId = true,
showProgress = true,
showControls = true //eslint-disable-line
}) => {
const { socket } = useContext(SocketContext)
const [badgeStatus, setBadgeStatus] = useState('unknown')
const [badgeText, setBadgeText] = useState('Unknown')
const [currentState, setCurrentState] = useState(
subJob?.state || {
type: 'unknown',
progress: 0
}
)
const [initialized, setInitialized] = useState(false)
useEffect(() => {
if (socket && !initialized && subJob?.id) {
setInitialized(true)
console.log('on notify_subjob_update')
socket.on('notify_subjob_update', (statusUpdate) => {
if (statusUpdate?.id === subJob.id && statusUpdate?.state) {
console.log('statusUpdate', statusUpdate)
setCurrentState(statusUpdate.state)
}
})
}
return () => {
if (socket && initialized) {
console.log('off notify_subjob_update')
socket.off('notify_subjob_update')
}
}
}, [socket, initialized, subJob?.id])
useEffect(() => {
switch (currentState.type) {
case 'draft':
setBadgeStatus('default')
setBadgeText('Draft')
break
case 'printing':
setBadgeStatus('processing')
setBadgeText('Printing')
break
case 'complete':
setBadgeStatus('success')
setBadgeText('Complete')
break
case 'failed':
setBadgeStatus('error')
setBadgeText('Failed')
break
case 'queued':
setBadgeStatus('warning')
setBadgeText('Queued')
break
case 'paused':
setBadgeStatus('warning')
setBadgeText('Paused')
break
case 'cancelled':
setBadgeStatus('error')
setBadgeText('Cancelled')
break
default:
setBadgeStatus('default')
setBadgeText('Unknown')
}
}, [currentState])
return (
<Flex gap='small' align={'center'}>
{showId && (
<>
{'Sub Job '}
<IdText
id={subJob.number.toString().padStart(6, '0')}
showCopy={false}
type='subjob'
/>
</>
)}
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
<Flex gap={6}>
<Badge status={badgeStatus} />
{badgeText}
</Flex>
</Tag>
</Space>
)}
{showProgress &&
(currentState.type === 'printing' ||
currentState.type === 'processing') ? (
<Progress
percent={Math.round(currentState.progress * 100)}
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}
{showControls &&
(currentState.type === 'printing' || currentState.type === 'paused') ? (
<Space.Compact>
<Button
onClick={() => {
if (currentState.type === 'printing') {
socket.emit('printer.print.pause', {
printerId: subJob.printer
})
} else {
socket.emit('printer.print.resume', {
printerId: subJob.printer
})
}
}}
style={{ height: '22px' }}
icon={
currentState.type === 'printing' ? (
<PauseOutlined
style={{ fontSize: '10px', marginBottom: '3px' }}
/>
) : (
<CaretRightOutlined
style={{ fontSize: '10px', marginBottom: '3px' }}
/>
)
}
></Button>
<Button
onClick={() => {
socket.emit('printer.print.cancel', {
printerId: subJob.printer
})
}}
style={{ height: '22px' }}
icon={
<CloseOutlined
style={{ fontSize: '10px', marginBottom: '3px' }}
/>
}
/>
</Space.Compact>
) : null}
{showControls && currentState.type === 'queued' ? (
<Button
onClick={() => {
socket.emit('server.job_queue.cancel', {
subJobId: subJob.id
})
}}
style={{ height: '22px' }}
icon={
<CloseOutlined style={{ fontSize: '10px', marginBottom: '3px' }} />
}
/>
) : null}
{showControls && currentState.type === 'draft' ? (
<Space>
<Button
onClick={() => {
console.log('Hello')
}}
style={{ height: 'unset' }}
icon={<DeleteOutlined style={{ fontSize: '12px' }} />}
/>
</Space>
) : null}
</Flex>
)
}
SubJobState.propTypes = {
subJob: PropTypes.shape({
id: PropTypes.string,
subJobId: PropTypes.string,
printer: PropTypes.string,
number: PropTypes.number,
state: PropTypes.shape({
type: PropTypes.string,
progress: PropTypes.number
})
}),
showProgress: PropTypes.bool,
showControls: PropTypes.bool,
showId: PropTypes.bool,
showStatus: PropTypes.bool
}
export default SubJobState

View File

@ -0,0 +1,204 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import { Tree, Card, Spin, Space, Button, message } from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import React, { useState, useEffect, useContext, useCallback } from 'react'
import PrinterState from './PrinterState'
import axios from 'axios'
import { SocketContext } from '../context/SocketContext'
import SubJobState from './SubJobState'
const SubJobsTree = ({ printJobData }) => {
const [treeData, setTreeData] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const { socket } = useContext(SocketContext)
const [messageApi] = message.useMessage()
const [expandedKeys, setExpandedKeys] = useState([])
const [currentPrintJobData, setCurrentPrintJobData] = useState(null)
const buildTreeData = useCallback(
(jobData) => {
if (!jobData?.subJobs?.length) {
setTreeData([])
setExpandedKeys([])
return
}
// Create tree nodes for each printer
const printerNodes = jobData.printers.map((printerData) => {
// Find subjobs for this printer
const printerSubJobs = jobData.subJobs.filter(
(subJob) => subJob.printer === printerData.id
)
setExpandedKeys((prev) => [...prev, `printer-${printerData.id}`])
return {
title: printerData.state ? (
<PrinterState
printer={printerData}
text={printerData.printerName}
showProgress={false}
/>
) : (
<Spin indicator={<LoadingOutlined />} />
),
key: `printer-${printerData.id}`,
children: printerSubJobs.map((subJob) => {
return {
title: (
<SubJobState
subJob={subJob}
text={`Subjob #${subJob.number}`}
showProgress={true}
/>
),
key: `subjob-${subJob._id}`,
isLeaf: true
}
})
}
})
setTreeData(printerNodes)
},
[expandedKeys]
)
useEffect(() => {
buildTreeData(currentPrintJobData)
}, [currentPrintJobData])
useEffect(() => {
const initializeData = async () => {
if (!printJobData) {
try {
setLoading(true)
const response = await axios.get('http://localhost:8080/printjobs', {
headers: { Accept: 'application/json' },
withCredentials: true
})
if (response.data) {
setCurrentPrintJobData(response.data)
}
} catch (err) {
setError('Failed to fetch print job details')
messageApi.error('Failed to fetch print job details')
} finally {
setLoading(false)
}
} else {
setCurrentPrintJobData(printJobData)
}
}
initializeData()
// Add socket.io event listener for deployment updates
if (socket) {
socket.on('notify_deployment_update', (updateData) => {
console.log('Received deployment update:', updateData)
setCurrentPrintJobData((prevData) => {
if (!prevData) return prevData
// Handle printer updates
if (updateData.printerId) {
return {
...prevData,
printers: prevData.printers.map((printer) => {
if (
printer.id === updateData.printerId &&
updateData.state == 'deploying'
) {
return {
...printer,
deploymentProgress: updateData.progress
}
} else if (
printer.id === updateData.printerId &&
updateData.state == 'complete'
) {
return {
...printer,
deploymentProgress: undefined
}
}
return printer
})
}
}
return prevData
})
})
socket.on('notify_subjob_update', (updateData) => {
// Handle sub-job updates
if (updateData.subJobId) {
console.log('Received subjob update:', updateData)
setCurrentPrintJobData((prevData) => {
if (!prevData) return prevData
return {
...prevData,
// eslint-disable-next-line camelcase
subJobs: prevData.subJobs.map((subJob) => {
if (subJob._id === updateData.id) {
return {
...subJob,
state: updateData.state,
subJobId: updateData.subJobId
}
}
return subJob
})
}
})
}
})
}
return () => {
if (socket) {
socket.off('notify_deployment_update')
}
}
}, [printJobData, socket])
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error}</p>
<Button
icon={<ReloadOutlined />}
onClick={() => buildTreeData(printJobData)}
>
Retry
</Button>
</Space>
)
}
return (
<Card>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
/>
</Card>
)
}
SubJobsTree.propTypes = {
printJobData: PropTypes.object.isRequired
}
export default SubJobsTree

View File

@ -0,0 +1,376 @@
import { Input, Flex, List, Typography, Modal, Spin, message, Form } from 'antd'
import React, { createContext, useEffect, useState, useRef } from 'react'
import axios from 'axios'
import {
LoadingOutlined,
PrinterOutlined,
PlayCircleOutlined
} from '@ant-design/icons'
import PropTypes from 'prop-types'
import PrinterState from '../common/PrinterState'
import JobState from '../common/JobState'
import IdText from '../common/IdText'
const SpotlightContext = createContext()
const SpotlightProvider = ({ children }) => {
const { Text } = Typography
const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('')
const [listData, setListData] = useState([])
const [messageApi, contextHolder] = message.useMessage()
const [inputPrefix, setInputPrefix] = useState('')
// Refs for throttling/debouncing
const lastFetchTime = useRef(0)
const pendingQuery = useRef(null)
const fetchTimeoutRef = useRef(null)
const inputRef = useRef(null)
const formRef = useRef(null)
const showSpotlight = (defaultQuery = '') => {
setQuery(defaultQuery)
setShowModal(true)
// Set prefix based on default query if provided
if (defaultQuery) {
detectAndSetPrefix(defaultQuery)
checkAndFetchData(defaultQuery)
} else {
setInputPrefix('')
}
// Focus will be handled in useEffect for proper timing after modal renders
}
const fetchData = async (searchQuery) => {
if (!searchQuery || !searchQuery.trim()) return
try {
// Update last fetch time
lastFetchTime.current = Date.now()
// Clear any pending queries
pendingQuery.current = null
setLoading(true)
setListData([])
const response = await axios.get(
`http://localhost:8080/spotlight/${encodeURIComponent(searchQuery.trim())}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setLoading(false)
setListData(response.data)
// Check if there's a pending query after this fetch completes
if (pendingQuery.current !== null) {
const timeToNextFetch = Math.max(
0,
1000 - (Date.now() - lastFetchTime.current)
)
scheduleNextFetch(timeToNextFetch)
}
} catch (error) {
setLoading(false)
messageApi.error('An error occurred while fetching data.')
console.error('Spotlight fetch error:', error)
}
}
const checkAndFetchData = (searchQuery) => {
// Store the latest query
pendingQuery.current = searchQuery
// Calculate time since last fetch
const now = Date.now()
const timeSinceLastFetch = now - lastFetchTime.current
// If we've waited at least 1 second since last fetch, fetch immediately
if (timeSinceLastFetch >= 1000) {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
fetchTimeoutRef.current = null
}
fetchData(searchQuery)
} else {
// Otherwise, schedule fetch for when 1 second has passed
if (!fetchTimeoutRef.current) {
const timeToWait = 1000 - timeSinceLastFetch
scheduleNextFetch(timeToWait)
}
// We don't need to do anything if a fetch is already scheduled
// as the latest query is already stored in pendingQuery
}
}
const scheduleNextFetch = (delay) => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
fetchTimeoutRef.current = setTimeout(() => {
fetchTimeoutRef.current = null
if (pendingQuery.current !== null) {
fetchData(pendingQuery.current)
}
}, delay)
}
// Detect and set the appropriate prefix based on input
const detectAndSetPrefix = (text) => {
if (!text || text.trim() === '') {
setInputPrefix('')
return
}
console.log('Detecting prefix')
const upperText = text.toUpperCase()
if (upperText.startsWith('JOB:')) {
setInputPrefix('JOB:')
return true
} else if (upperText.startsWith('PRN:')) {
setInputPrefix('PRN:')
return true
} else if (upperText.startsWith('FIL:')) {
setInputPrefix('FIL')
return true
} else if (upperText.startsWith('GCF:')) {
setInputPrefix('GCF:')
return true
}
// Default behavior if no match
setInputPrefix('')
return false
}
const handleSpotlightChange = (formData) => {
const newQuery = formData.query || ''
setQuery(newQuery)
// Detect and set the appropriate prefix
detectAndSetPrefix(inputPrefix + newQuery)
// Check if we need to fetch data
checkAndFetchData(inputPrefix + newQuery)
}
// Focus the input element
const focusInput = () => {
setTimeout(() => {
if (inputRef.current) {
const input = inputRef.current.input
if (input) {
input.focus()
}
}
}, 50)
}
// Custom handler for input changes to handle prefix logic
const handleInputChange = (e) => {
const value = e.target.value
// If the input is empty or being cleared
if (!value || value.trim() === '') {
// Only clear the prefix if the input is completely empty
if (value === '') {
console.log('Clearning prefix')
setInputPrefix('')
}
if (formRef.current) {
formRef.current.setFieldsValue({ query: value })
}
}
// If the user is typing and it doesn't have a prefix yet
else if (!inputPrefix) {
console.log('No prefix')
// Check for prefixes at the beginning of the input
const upperValue = value.toUpperCase()
if (upperValue.startsWith('JOB:') || upperValue.startsWith('PRN:')) {
const parts = upperValue.split(':')
const prefix = parts[0] + ':'
const restOfInput = value.substring(prefix.length)
// Set the prefix and update the input without the prefix
setInputPrefix(prefix)
if (formRef.current) {
formRef.current.setFieldsValue({ query: restOfInput })
// Ensure input gets focus after prefix is set
focusInput()
}
return
}
}
}
// Handle key down events for backspace behavior
const handleKeyDown = (e) => {
// If backspace is pressed and there's a prefix but the input is empty
if (e.key === 'Backspace' && inputPrefix && query == inputPrefix) {
console.log('Query', query)
// Clear the prefix
setInputPrefix('')
// Prevent the default backspace behavior in this case
e.preventDefault()
}
}
// Add keyboard shortcut listener
useEffect(() => {
const handleKeyPress = (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'p') {
e.preventDefault() // Prevent browser's default behavior
showSpotlight()
}
}
// Add event listener
window.addEventListener('keydown', handleKeyPress)
// Clean up
return () => {
window.removeEventListener('keydown', handleKeyPress)
}
}, [])
// Focus and select text in input when modal becomes visible
useEffect(() => {
if (showModal && inputRef.current) {
// Use a small timeout to ensure the modal is fully rendered and visible
setTimeout(() => {
const input = inputRef.current.input
if (input) {
input.focus()
input.select() // Select all text
}
}, 50)
}
}, [showModal])
// Focus input when inputPrefix changes
useEffect(() => {
if (showModal) {
focusInput()
}
}, [inputPrefix, showModal])
// Cleanup on unmount
useEffect(() => {
return () => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current)
}
}
}, [])
return (
<SpotlightContext.Provider value={{ showSpotlight }}>
{contextHolder}
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
closeIcon={null}
footer={null}
styles={{ content: { backgroundColor: 'transparent' } }}
>
<Flex vertical>
<Form ref={formRef} onValuesChange={handleSpotlightChange}>
<Form.Item name='query' initialValue={query}>
<Input
ref={inputRef}
placeholder='Enter a query or scan a barcode...'
size='large'
addonBefore={inputPrefix || undefined}
suffix={
<Spin
indicator={<LoadingOutlined />}
spinning={loading}
size='small'
/>
}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
</Form.Item>
</Form>
{listData.length > 0 && (
<List
bordered
dataSource={listData}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
description={
<Flex gap={'middle'} align='center'>
<Text>
{item.printer ? (
<PrinterOutlined style={{ fontSize: '20px' }} />
) : null}
{item.job ? (
<PlayCircleOutlined style={{ fontSize: '20px' }} />
) : null}
</Text>
<Flex
vertical
gap={'6px'}
style={{ marginBottom: '2px' }}
>
<Text>{item.name}</Text>
{item.printer ? (
<Flex gap={'small'}>
<PrinterState
printer={item.printer}
showPrinterName={false}
/>
<IdText
id={item.id}
longId={false}
type='printer'
/>
</Flex>
) : null}
{item.job ? (
<Flex gap={'small'}>
{item.job.state.type ? (
<JobState
job={item.job}
showQuantity={false}
showId={false}
/>
) : null}
<IdText id={item.id} longId={false} type='job' />
</Flex>
) : null}
</Flex>
</Flex>
}
/>
<Text keyboard>ENTER</Text>
</List.Item>
)}
></List>
)}
</Flex>
</Modal>
{children}
</SpotlightContext.Provider>
)
}
SpotlightProvider.propTypes = {
children: PropTypes.node.isRequired
}
export { SpotlightProvider, SpotlightContext }

View File

@ -0,0 +1,30 @@
export default class GCode {
constructor(configString) {
this.configString = configString
}
async parse(onProgress) {
return new Promise((resolve, reject) => {
const worker = new Worker('../gcode-worker.js')
worker.onmessage = (event) => {
const { type, progress, configObject } = event.data
if (type === 'progress') {
// Report progress to the caller
if (onProgress) onProgress(progress)
} else if (type === 'result') {
resolve(configObject)
worker.terminate()
}
}
worker.onerror = (error) => {
reject(error)
worker.terminate()
}
worker.postMessage({ configString: this.configString })
})
}
}

View File

@ -0,0 +1,31 @@
export function capitalizeFirstLetter(string) {
try {
return string[0].toUpperCase() + string.slice(1)
} catch {
return ''
}
}
export function timeStringToMinutes(timeString) {
// Extract hours, minutes, and seconds using a regular expression
const regex = /(\d+h)?\s*(\d+m)?\s*(\d+s)?/
const matches = timeString.match(regex)
// Initialize hours, minutes, and seconds to 0
let hours = 0
let minutes = 0
let seconds = 0
// If matches are found, extract the values
if (matches) {
if (matches[1]) hours = parseInt(matches[1])
if (matches[2]) minutes = parseInt(matches[2])
if (matches[3]) seconds = parseInt(matches[3])
}
// Convert everything to minutes
const totalMinutes = hours * 60 + minutes + seconds / 60
// Return the integer value of total minutes
return Math.floor(totalMinutes)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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