Added more functionality.
This commit is contained in:
parent
fa34bda959
commit
47ce2dfe8e
31
.eslintrc.json
Normal file
31
.eslintrc.json
Normal 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
7
.prettierrc.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
}
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"eslint.options": {
|
||||
"overrideConfigFile": "./.eslintrc.json"
|
||||
}
|
||||
}
|
||||
84
public/gcode-worker.js
Normal file
84
public/gcode-worker.js
Normal 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 })
|
||||
}
|
||||
13
public/silent-check-sso.html
Normal file
13
public/silent-check-sso.html
Normal 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>
|
||||
BIN
src/assets/icons/filamenticon.afdesign
Normal file
BIN
src/assets/icons/filamenticon.afdesign
Normal file
Binary file not shown.
8
src/assets/icons/filamenticon.svg
Normal file
8
src/assets/icons/filamenticon.svg
Normal 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 |
BIN
src/assets/icons/levelbedicon.afdesign
Normal file
BIN
src/assets/icons/levelbedicon.afdesign
Normal file
Binary file not shown.
5
src/assets/icons/levelbedicon.svg
Normal file
5
src/assets/icons/levelbedicon.svg
Normal 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 |
BIN
src/assets/icons/newwindowicon.afdesign
Normal file
BIN
src/assets/icons/newwindowicon.afdesign
Normal file
Binary file not shown.
9
src/assets/icons/newwindowicon.svg
Normal file
9
src/assets/icons/newwindowicon.svg
Normal 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 |
BIN
src/assets/icons/particon.afdesign
Normal file
BIN
src/assets/icons/particon.afdesign
Normal file
Binary file not shown.
7
src/assets/icons/particon.svg
Normal file
7
src/assets/icons/particon.svg
Normal 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 |
BIN
src/assets/icons/producticon.afdesign
Normal file
BIN
src/assets/icons/producticon.afdesign
Normal file
Binary file not shown.
5
src/assets/icons/producticon.svg
Normal file
5
src/assets/icons/producticon.svg
Normal 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 |
BIN
src/assets/icons/unloadicon.afdesign
Normal file
BIN
src/assets/icons/unloadicon.afdesign
Normal file
Binary file not shown.
8
src/assets/icons/unloadicon.svg
Normal file
8
src/assets/icons/unloadicon.svg
Normal 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 |
56
src/components/Auth/_RegisterPasskey.jsx.old
Normal file
56
src/components/Auth/_RegisterPasskey.jsx.old
Normal 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
|
||||
204
src/components/Dashboard/Inventory/Spools.jsx
Normal file
204
src/components/Dashboard/Inventory/Spools.jsx
Normal 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
|
||||
450
src/components/Dashboard/Inventory/Spools/EditSpool.jsx
Normal file
450
src/components/Dashboard/Inventory/Spools/EditSpool.jsx
Normal 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
|
||||
443
src/components/Dashboard/Inventory/Spools/NewSpool.jsx
Normal file
443
src/components/Dashboard/Inventory/Spools/NewSpool.jsx
Normal 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
|
||||
262
src/components/Dashboard/Management/Filaments.jsx
Normal file
262
src/components/Dashboard/Management/Filaments.jsx
Normal 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
|
||||
445
src/components/Dashboard/Management/Filaments/FilamentInfo.jsx
Normal file
445
src/components/Dashboard/Management/Filaments/FilamentInfo.jsx
Normal 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
|
||||
432
src/components/Dashboard/Management/Filaments/NewFilament.jsx
Normal file
432
src/components/Dashboard/Management/Filaments/NewFilament.jsx
Normal 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
|
||||
235
src/components/Dashboard/Management/Parts.jsx
Normal file
235
src/components/Dashboard/Management/Parts.jsx
Normal 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
|
||||
471
src/components/Dashboard/Management/Parts/NewPart.jsx
Normal file
471
src/components/Dashboard/Management/Parts/NewPart.jsx
Normal 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
|
||||
273
src/components/Dashboard/Management/Parts/PartInfo.jsx
Normal file
273
src/components/Dashboard/Management/Parts/PartInfo.jsx
Normal 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
|
||||
237
src/components/Dashboard/Management/Products.jsx
Normal file
237
src/components/Dashboard/Management/Products.jsx
Normal 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
|
||||
213
src/components/Dashboard/Management/Products/NewProduct.jsx
Normal file
213
src/components/Dashboard/Management/Products/NewProduct.jsx
Normal 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
|
||||
273
src/components/Dashboard/Management/Products/ProductInfo.jsx
Normal file
273
src/components/Dashboard/Management/Products/ProductInfo.jsx
Normal 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
|
||||
226
src/components/Dashboard/Management/Vendors.jsx
Normal file
226
src/components/Dashboard/Management/Vendors.jsx
Normal 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
|
||||
205
src/components/Dashboard/Management/Vendors/NewVendor.jsx
Normal file
205
src/components/Dashboard/Management/Vendors/NewVendor.jsx
Normal 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
|
||||
247
src/components/Dashboard/Management/Vendors/VendorInfo.jsx
Normal file
247
src/components/Dashboard/Management/Vendors/VendorInfo.jsx
Normal 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
|
||||
325
src/components/Dashboard/Production/GCodeFiles.jsx
Normal file
325
src/components/Dashboard/Production/GCodeFiles.jsx
Normal 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
|
||||
228
src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx
Normal file
228
src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx
Normal 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
|
||||
205
src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx
Normal file
205
src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx
Normal 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
|
||||
501
src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx
Normal file
501
src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx
Normal 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
|
||||
273
src/components/Dashboard/Production/Overview.jsx
Normal file
273
src/components/Dashboard/Production/Overview.jsx
Normal 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
|
||||
381
src/components/Dashboard/Production/PrintJobs.jsx
Normal file
381
src/components/Dashboard/Production/PrintJobs.jsx
Normal 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
|
||||
253
src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx
Normal file
253
src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx
Normal 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
|
||||
174
src/components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx
Normal file
174
src/components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx
Normal 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
|
||||
329
src/components/Dashboard/Production/Printers.jsx
Normal file
329
src/components/Dashboard/Production/Printers.jsx
Normal 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
|
||||
333
src/components/Dashboard/Production/Printers/ChangeFillament.jsx
Normal file
333
src/components/Dashboard/Production/Printers/ChangeFillament.jsx
Normal 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
|
||||
357
src/components/Dashboard/Production/Printers/ControlPrinter.jsx
Normal file
357
src/components/Dashboard/Production/Printers/ControlPrinter.jsx
Normal 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
|
||||
565
src/components/Dashboard/Production/Printers/NewPrinter.jsx
Normal file
565
src/components/Dashboard/Production/Printers/NewPrinter.jsx
Normal 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
|
||||
359
src/components/Dashboard/Production/Printers/PrinterInfo.jsx
Normal file
359
src/components/Dashboard/Production/Printers/PrinterInfo.jsx
Normal 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
|
||||
170
src/components/Dashboard/common/FilamentSelect.jsx
Normal file
170
src/components/Dashboard/common/FilamentSelect.jsx
Normal 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
|
||||
206
src/components/Dashboard/common/GCodeFileSelect.jsx
Normal file
206
src/components/Dashboard/common/GCodeFileSelect.jsx
Normal 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
|
||||
127
src/components/Dashboard/common/IdText.jsx
Normal file
127
src/components/Dashboard/common/IdText.jsx
Normal 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
|
||||
59
src/components/Dashboard/common/InventorySidebar.jsx
Normal file
59
src/components/Dashboard/common/InventorySidebar.jsx
Normal 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
|
||||
116
src/components/Dashboard/common/JobState.jsx
Normal file
116
src/components/Dashboard/common/JobState.jsx
Normal 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
|
||||
98
src/components/Dashboard/common/ManagementSidebar.jsx
Normal file
98
src/components/Dashboard/common/ManagementSidebar.jsx
Normal 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
|
||||
170
src/components/Dashboard/common/PartSelect.jsx
Normal file
170
src/components/Dashboard/common/PartSelect.jsx
Normal 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
|
||||
194
src/components/Dashboard/common/PrinterJobsTree.jsx
Normal file
194
src/components/Dashboard/common/PrinterJobsTree.jsx
Normal 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
|
||||
256
src/components/Dashboard/common/PrinterMovementPanel.jsx
Normal file
256
src/components/Dashboard/common/PrinterMovementPanel.jsx
Normal 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
|
||||
115
src/components/Dashboard/common/PrinterSelect.jsx
Normal file
115
src/components/Dashboard/common/PrinterSelect.jsx
Normal 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
|
||||
189
src/components/Dashboard/common/PrinterState.jsx
Normal file
189
src/components/Dashboard/common/PrinterState.jsx
Normal 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
|
||||
304
src/components/Dashboard/common/PrinterTemperaturePanel.jsx
Normal file
304
src/components/Dashboard/common/PrinterTemperaturePanel.jsx
Normal 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
|
||||
78
src/components/Dashboard/common/ProductionSidebar.jsx
Normal file
78
src/components/Dashboard/common/ProductionSidebar.jsx
Normal 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
|
||||
119
src/components/Dashboard/common/SubJobCounter.jsx
Normal file
119
src/components/Dashboard/common/SubJobCounter.jsx
Normal 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
|
||||
204
src/components/Dashboard/common/SubJobState.jsx
Normal file
204
src/components/Dashboard/common/SubJobState.jsx
Normal 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
|
||||
204
src/components/Dashboard/common/SubJobsTree.jsx
Normal file
204
src/components/Dashboard/common/SubJobsTree.jsx
Normal 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
|
||||
376
src/components/Dashboard/context/SpotlightContext.js
Normal file
376
src/components/Dashboard/context/SpotlightContext.js
Normal 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 }
|
||||
30
src/components/Dashboard/utils/GCode.js
Normal file
30
src/components/Dashboard/utils/GCode.js
Normal 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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
31
src/components/Dashboard/utils/Utils.js
Normal file
31
src/components/Dashboard/utils/Utils.js
Normal 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)
|
||||
}
|
||||
7
src/components/Icons/FilamentIcon.jsx
Normal file
7
src/components/Icons/FilamentIcon.jsx
Normal 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
|
||||
7
src/components/Icons/LevelBedIcon.jsx
Normal file
7
src/components/Icons/LevelBedIcon.jsx
Normal 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
|
||||
7
src/components/Icons/NewWindowIcon.jsx
Normal file
7
src/components/Icons/NewWindowIcon.jsx
Normal 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
|
||||
7
src/components/Icons/PartIcon.jsx
Normal file
7
src/components/Icons/PartIcon.jsx
Normal 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
|
||||
7
src/components/Icons/ProductIcon.jsx
Normal file
7
src/components/Icons/ProductIcon.jsx
Normal 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
|
||||
7
src/components/Icons/UnloadIcon.jsx
Normal file
7
src/components/Icons/UnloadIcon.jsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user