Added filament stock functionality and improves some performance

This commit is contained in:
Tom Butcher 2025-05-19 00:28:00 +01:00
parent 47ce2dfe8e
commit e56a563e98
89 changed files with 7937 additions and 4239 deletions

440
package-lock.json generated
View File

@ -11,15 +11,17 @@
"@simplewebauthn/browser": "^10.0.0",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.5.0",
"antd": "^5.19.2",
"antd": "^5.25.1",
"antd-style": "^3.7.1",
"axios": "*",
"country-list": "^2.3.0",
"dotenv": "^16.5.0",
"gcode-preview": "^2.17.0",
"keycloak-js": "^26.1.5",
"moment": "*",
"prop-types": "^15.8.1",
"react": "*",
"react-country-flag": "^3.1.0",
"react-dom": "*",
"react-router-dom": "*",
"react-scripts": "*",
@ -28,6 +30,7 @@
"styled-components": "*",
"three": "^0.166.1",
"tsparticles": "^3.5.0",
"virtualizedtableforantd4": "^1.3.1",
"web-vitals": "*"
},
"devDependencies": {
@ -67,12 +70,12 @@
}
},
"node_modules/@ant-design/colors": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.1.0.tgz",
"integrity": "sha512-MMoDGWn1y9LdQJQSHiCC20x3uZ3CwQnv9QMz6pCmJOrqdgM9YxsoVVY0wtrdXbmfSgnV0KNk6zi09NAhMR2jvg==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.0.tgz",
"integrity": "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.6.1"
"@ant-design/fast-color": "^2.0.6"
}
},
"node_modules/@ant-design/cssinjs": {
@ -94,16 +97,43 @@
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/cssinjs-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz",
"integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.0",
"@babel/runtime": "^7.23.2",
"rc-util": "^5.38.0"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@ant-design/cssinjs/node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/@ant-design/fast-color": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz",
"integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.7"
},
"engines": {
"node": ">=8.x"
}
},
"node_modules/@ant-design/icons": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.4.0.tgz",
"integrity": "sha512-QZbWC5xQYexCI5q4/fehSEkchJr5UGtvAJweT743qKUQQGs9IH2DehNLP49DJ3Ii9m9CijD2HN6fNy3WKhIFdA==",
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
@ -2199,13 +2229,10 @@
"license": "MIT"
},
"node_modules/@babel/runtime": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz",
"integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
@ -2584,15 +2611,6 @@
"postcss-selector-parser": "^6.0.10"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@ -3913,13 +3931,13 @@
}
},
"node_modules/@rc-component/color-picker": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-1.5.3.tgz",
"integrity": "sha512-+tGGH3nLmYXTalVe0L8hSZNs73VTP5ueSHwUlDC77KKRaN7G4DS4wcpG5DTDzdcV/Yas+rzA6UGgIyzd8fS4cw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz",
"integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==",
"license": "MIT",
"dependencies": {
"@ant-design/fast-color": "^2.0.6",
"@babel/runtime": "^7.23.6",
"@ctrl/tinycolor": "^3.6.1",
"classnames": "^2.2.6",
"rc-util": "^5.38.1"
},
@ -4009,9 +4027,9 @@
}
},
"node_modules/@rc-component/tour": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.0.tgz",
"integrity": "sha512-h6hyILDwL+In9GAgRobwRWihLqqsD7Uft3fZGrJ7L4EiyCoxbnNYwzPXDfz7vNDhWeVyvAWQJj9fJCzpI4+b4g==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz",
"integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.0",
@ -4029,9 +4047,9 @@
}
},
"node_modules/@rc-component/trigger": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.2.0.tgz",
"integrity": "sha512-QarBCji02YE9aRFhZgRZmOpXBj0IZutRippsVBv85sxvG4FGk/vRxwAlkn3MS9zK5mwbETd86mAVg2tKqTkdJA==",
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.2.6.tgz",
"integrity": "sha512-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2",
@ -4039,7 +4057,7 @@
"classnames": "^2.3.2",
"rc-motion": "^2.0.0",
"rc-resize-observer": "^1.3.1",
"rc-util": "^5.38.0"
"rc-util": "^5.44.0"
},
"engines": {
"node": ">=8.x"
@ -6038,57 +6056,58 @@
}
},
"node_modules/antd": {
"version": "5.19.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.19.3.tgz",
"integrity": "sha512-rhGI6yyZ4dA2MWl9bfO0MZjtNwWdzITpp3u7pKLiQpTjJYFlpF5wDFgGaG1or3sqyBihvqcO/OF1hSggmWczbQ==",
"version": "5.25.1",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.25.1.tgz",
"integrity": "sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.1.0",
"@ant-design/cssinjs": "^1.21.0",
"@ant-design/icons": "^5.3.7",
"@ant-design/colors": "^7.2.0",
"@ant-design/cssinjs": "^1.23.0",
"@ant-design/cssinjs-utils": "^1.1.3",
"@ant-design/fast-color": "^2.0.6",
"@ant-design/icons": "^5.6.1",
"@ant-design/react-slick": "~1.1.2",
"@babel/runtime": "^7.24.8",
"@ctrl/tinycolor": "^3.6.1",
"@rc-component/color-picker": "~1.5.3",
"@babel/runtime": "^7.26.0",
"@rc-component/color-picker": "~2.0.1",
"@rc-component/mutate-observer": "^1.1.0",
"@rc-component/qrcode": "~1.0.0",
"@rc-component/tour": "~1.15.0",
"@rc-component/trigger": "^2.2.0",
"@rc-component/tour": "~1.15.1",
"@rc-component/trigger": "^2.2.6",
"classnames": "^2.5.1",
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.11",
"rc-cascader": "~3.27.0",
"rc-checkbox": "~3.3.0",
"rc-collapse": "~3.7.3",
"rc-dialog": "~9.5.2",
"rc-cascader": "~3.34.0",
"rc-checkbox": "~3.5.0",
"rc-collapse": "~3.9.0",
"rc-dialog": "~9.6.0",
"rc-drawer": "~7.2.0",
"rc-dropdown": "~4.2.0",
"rc-field-form": "~2.2.1",
"rc-image": "~7.9.0",
"rc-input": "~1.5.1",
"rc-input-number": "~9.1.0",
"rc-mentions": "~2.14.0",
"rc-menu": "~9.14.1",
"rc-motion": "^2.9.2",
"rc-notification": "~5.6.0",
"rc-pagination": "~4.2.0",
"rc-picker": "~4.6.9",
"rc-dropdown": "~4.2.1",
"rc-field-form": "~2.7.0",
"rc-image": "~7.12.0",
"rc-input": "~1.8.0",
"rc-input-number": "~9.5.0",
"rc-mentions": "~2.20.0",
"rc-menu": "~9.16.1",
"rc-motion": "^2.9.5",
"rc-notification": "~5.6.4",
"rc-pagination": "~5.1.0",
"rc-picker": "~4.11.3",
"rc-progress": "~4.0.0",
"rc-rate": "~2.13.0",
"rc-resize-observer": "^1.4.0",
"rc-segmented": "~2.3.0",
"rc-select": "~14.15.1",
"rc-slider": "~10.6.2",
"rc-rate": "~2.13.1",
"rc-resize-observer": "^1.4.3",
"rc-segmented": "~2.7.0",
"rc-select": "~14.16.7",
"rc-slider": "~11.1.8",
"rc-steps": "~6.0.1",
"rc-switch": "~4.1.0",
"rc-table": "~7.45.7",
"rc-tabs": "~15.1.1",
"rc-textarea": "~1.7.0",
"rc-tooltip": "~6.2.0",
"rc-tree": "~5.8.8",
"rc-tree-select": "~5.22.1",
"rc-upload": "~4.6.0",
"rc-util": "^5.43.0",
"rc-table": "~7.50.4",
"rc-tabs": "~15.6.1",
"rc-textarea": "~1.10.0",
"rc-tooltip": "~6.4.0",
"rc-tree": "~5.13.1",
"rc-tree-select": "~5.27.0",
"rc-upload": "~4.9.0",
"rc-util": "^5.44.4",
"scroll-into-view-if-needed": "^3.1.0",
"throttle-debounce": "^5.0.2"
},
@ -6206,12 +6225,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
"integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
"license": "MIT"
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@ -7587,6 +7600,12 @@
"node": ">=10"
}
},
"node_modules/country-list": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/country-list/-/country-list-2.3.0.tgz",
"integrity": "sha512-qZk66RlmQm7fQjMYWku1AyjlKPogjPEorAZJG88owPExoPV8EsyCcuFLvO2afTXHEhi9liVOoyd+5A6ZS5QwaA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -8077,9 +8096,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.12",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz",
"integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==",
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": {
@ -17345,9 +17364,9 @@
}
},
"node_modules/prettier-eslint/node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -17629,17 +17648,16 @@
}
},
"node_modules/rc-cascader": {
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.27.0.tgz",
"integrity": "sha512-z5uq8VvQadFUBiuZJ7YF5UAUGNkZtdEtcEYiIA94N/Kc2MIKr6lEbN5HyVddvYSgwWlKqnL6pH5bFXFuIK3MNg==",
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz",
"integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"array-tree-filter": "^2.1.0",
"@babel/runtime": "^7.25.7",
"classnames": "^2.3.1",
"rc-select": "~14.15.0",
"rc-tree": "~5.8.1",
"rc-util": "^5.37.0"
"rc-select": "~14.16.2",
"rc-tree": "~5.13.0",
"rc-util": "^5.43.0"
},
"peerDependencies": {
"react": ">=16.9.0",
@ -17647,9 +17665,9 @@
}
},
"node_modules/rc-checkbox": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.3.0.tgz",
"integrity": "sha512-Ih3ZaAcoAiFKJjifzwsGiT/f/quIkxJoklW4yKGho14Olulwn8gN7hOBve0/WGDg5o/l/5mL0w7ff7/YGvefVw==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz",
"integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -17662,9 +17680,9 @@
}
},
"node_modules/rc-collapse": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.7.3.tgz",
"integrity": "sha512-60FJcdTRn0X5sELF18TANwtVi7FtModq649H11mYF1jh83DniMoM4MqY627sEKRCTm4+WXfGDcB7hY5oW6xhyw==",
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz",
"integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -17678,9 +17696,9 @@
}
},
"node_modules/rc-dialog": {
"version": "9.5.2",
"resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.5.2.tgz",
"integrity": "sha512-qVUjc8JukG+j/pNaHVSRa2GO2/KbV2thm7yO4hepQ902eGdYK913sGkwg/fh9yhKYV1ql3BKIN2xnud3rEXAPw==",
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz",
"integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -17712,15 +17730,15 @@
}
},
"node_modules/rc-dropdown": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.0.tgz",
"integrity": "sha512-odM8Ove+gSh0zU27DUj5cG1gNKg7mLWBYzB5E4nNLrLwBmYEgYP43vHKDGOVZcJSVElQBI0+jTQgjnq0NfLjng==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz",
"integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@rc-component/trigger": "^2.0.0",
"classnames": "^2.2.6",
"rc-util": "^5.17.0"
"rc-util": "^5.44.1"
},
"peerDependencies": {
"react": ">=16.11.0",
@ -17728,9 +17746,9 @@
}
},
"node_modules/rc-field-form": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.2.1.tgz",
"integrity": "sha512-uoNqDoR7A4tn4QTSqoWPAzrR7ZwOK5I+vuZ/qdcHtbKx+ZjEsTg7QXm2wk/jalDiSksAQmATxL0T5LJkRREdIA==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.0.tgz",
"integrity": "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.0",
@ -17746,15 +17764,15 @@
}
},
"node_modules/rc-image": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.9.0.tgz",
"integrity": "sha512-l4zqO5E0quuLMCtdKfBgj4Suv8tIS011F5k1zBBlK25iMjjiNHxA0VeTzGFtUZERSA45gvpXDg8/P6qNLjR25g==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz",
"integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.2",
"@rc-component/portal": "^1.0.2",
"classnames": "^2.2.6",
"rc-dialog": "~9.5.2",
"rc-dialog": "~9.6.0",
"rc-motion": "^2.6.2",
"rc-util": "^5.34.1"
},
@ -17764,9 +17782,9 @@
}
},
"node_modules/rc-input": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.5.1.tgz",
"integrity": "sha512-+nOzQJDeIfIpNP/SgY45LXSKbuMlp4Yap2y8c+ZpU7XbLmNzUd6+d5/S75sA/52jsVE6S/AkhkkDEAOjIu7i6g==",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz",
"integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.1",
@ -17779,15 +17797,15 @@
}
},
"node_modules/rc-input-number": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.1.0.tgz",
"integrity": "sha512-NqJ6i25Xn/AgYfVxynlevIhX3FuKlMwIFpucGG1h98SlK32wQwDK0zhN9VY32McOmuaqzftduNYWWooWz8pXQA==",
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz",
"integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"@rc-component/mini-decimal": "^1.0.1",
"classnames": "^2.2.5",
"rc-input": "~1.5.0",
"rc-input": "~1.8.0",
"rc-util": "^5.40.1"
},
"peerDependencies": {
@ -17796,17 +17814,17 @@
}
},
"node_modules/rc-mentions": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.14.0.tgz",
"integrity": "sha512-qKR59FMuF8PK4ZqsbWX3UuA5P1M/snzyqV6Yt3y1DCFbCEdqUGIBgQp6vEfLCO6Z0RoRFlzXtCeSlBTcDDpg1A==",
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz",
"integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.22.5",
"@rc-component/trigger": "^2.0.0",
"classnames": "^2.2.6",
"rc-input": "~1.5.0",
"rc-menu": "~9.14.0",
"rc-textarea": "~1.7.0",
"rc-input": "~1.8.0",
"rc-menu": "~9.16.0",
"rc-textarea": "~1.10.0",
"rc-util": "^5.34.1"
},
"peerDependencies": {
@ -17815,9 +17833,9 @@
}
},
"node_modules/rc-menu": {
"version": "9.14.1",
"resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.14.1.tgz",
"integrity": "sha512-5wlRb3M8S4yGlWhSoEYJ7ZVRElyScdcpUHxgiLxkeig1tEdyKrnED3B2fhpN0Rrpdp9jyhnmZR/Lwq2fH5VvDQ==",
"version": "9.16.1",
"resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz",
"integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -17833,14 +17851,14 @@
}
},
"node_modules/rc-motion": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.2.tgz",
"integrity": "sha512-fUAhHKLDdkAXIDLH0GYwof3raS58dtNUmzLF2MeiR8o6n4thNpSDQhOqQzWE4WfFZDCi9VEN8n7tiB7czREcyw==",
"version": "2.9.5",
"resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz",
"integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.1",
"classnames": "^2.2.1",
"rc-util": "^5.43.0"
"rc-util": "^5.44.0"
},
"peerDependencies": {
"react": ">=16.9.0",
@ -17848,9 +17866,9 @@
}
},
"node_modules/rc-notification": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.0.tgz",
"integrity": "sha512-TGQW5T7waOxLwgJG7fXcw8l7AQiFOjaZ7ISF5PrU526nunHRNcTMuzKihQHaF4E/h/KfOCDk3Mv8eqzbu2e28w==",
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz",
"integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -17867,9 +17885,9 @@
}
},
"node_modules/rc-overflow": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.3.2.tgz",
"integrity": "sha512-nsUm78jkYAoPygDAcGZeC2VwIg/IBGSodtOY3pMof4W3M9qRJgqaDYm03ZayHlde3I6ipliAxbN0RUcGf5KOzw==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.4.1.tgz",
"integrity": "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.1",
@ -17883,9 +17901,9 @@
}
},
"node_modules/rc-pagination": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-4.2.0.tgz",
"integrity": "sha512-V6qeANJsT6tmOcZ4XiUmj8JXjRLbkusuufpuoBw2GiAn94fIixYjFLmbruD1Sbhn8fPLDnWawPp4CN37zQorvw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz",
"integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -17898,9 +17916,9 @@
}
},
"node_modules/rc-picker": {
"version": "4.6.9",
"resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.6.9.tgz",
"integrity": "sha512-kwQq5xDNJ1VcX7pauLlVBiuQorpZGUwA/YczVJTO1e33YsTyDuVjaQkYAiAupXbEPUBCU3doGZo0J25HGq2ZOQ==",
"version": "4.11.3",
"resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz",
"integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.7",
@ -17952,9 +17970,9 @@
}
},
"node_modules/rc-rate": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.0.tgz",
"integrity": "sha512-oxvx1Q5k5wD30sjN5tqAyWTvJfLNNJn7Oq3IeS4HxWfAiC4BOXMITNAsw7u/fzdtO4MS8Ki8uRLOzcnEuoQiAw==",
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz",
"integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -17970,14 +17988,14 @@
}
},
"node_modules/rc-resize-observer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.0.tgz",
"integrity": "sha512-PnMVyRid9JLxFavTjeDXEXo65HCRqbmLBw9xX9gfC4BZiSzbLXKzW3jPz+J0P71pLbD5tBMTT+mkstV5gD0c9Q==",
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz",
"integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.7",
"classnames": "^2.2.1",
"rc-util": "^5.38.0",
"rc-util": "^5.44.1",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
@ -17986,9 +18004,9 @@
}
},
"node_modules/rc-segmented": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.3.0.tgz",
"integrity": "sha512-I3FtM5Smua/ESXutFfb8gJ8ZPcvFR+qUgeeGFQHBOvRiRKyAk4aBE5nfqrxXx+h8/vn60DQjOt6i4RNtrbOobg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.0.tgz",
"integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.1",
@ -18002,9 +18020,9 @@
}
},
"node_modules/rc-select": {
"version": "14.15.1",
"resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.15.1.tgz",
"integrity": "sha512-mGvuwW1RMm1NCSI8ZUoRoLRK51R2Nb+QJnmiAvbDRcjh2//ulCkxeV6ZRFTECPpE1t2DPfyqZMPw90SVJzQ7wQ==",
"version": "14.16.8",
"resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz",
"integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -18024,9 +18042,9 @@
}
},
"node_modules/rc-slider": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.6.2.tgz",
"integrity": "sha512-FjkoFjyvUQWcBo1F3RgSglky3ar0+qHLM41PlFVYB4Bj3RD8E/Mv7kqMouLFBU+3aFglMzzctAIWRwajEuueSw==",
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.8.tgz",
"integrity": "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -18075,16 +18093,16 @@
}
},
"node_modules/rc-table": {
"version": "7.45.7",
"resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.45.7.tgz",
"integrity": "sha512-wi9LetBL1t1csxyGkMB2p3mCiMt+NDexMlPbXHvQFmBBAsMxrgNSAPwUci2zDLUq9m8QdWc1Nh8suvrpy9mXrg==",
"version": "7.50.5",
"resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.50.5.tgz",
"integrity": "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"@rc-component/context": "^1.4.0",
"classnames": "^2.2.5",
"rc-resize-observer": "^1.1.0",
"rc-util": "^5.37.0",
"rc-util": "^5.44.3",
"rc-virtual-list": "^3.14.2"
},
"engines": {
@ -18096,15 +18114,15 @@
}
},
"node_modules/rc-tabs": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.1.1.tgz",
"integrity": "sha512-Tc7bJvpEdkWIVCUL7yQrMNBJY3j44NcyWS48jF/UKMXuUlzaXK+Z/pEL5LjGcTadtPvVmNqA40yv7hmr+tCOAw==",
"version": "15.6.1",
"resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.6.1.tgz",
"integrity": "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.2",
"classnames": "2.x",
"rc-dropdown": "~4.2.0",
"rc-menu": "~9.14.0",
"rc-menu": "~9.16.0",
"rc-motion": "^2.6.2",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.34.1"
@ -18118,14 +18136,14 @@
}
},
"node_modules/rc-textarea": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.7.0.tgz",
"integrity": "sha512-UxizYJkWkmxP3zofXgc487QiGyDmhhheDLLjIWbFtDmiru1ls30KpO8odDaPyqNUIy9ugj5djxTEuezIn6t3Jg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.0.tgz",
"integrity": "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.1",
"rc-input": "~1.5.0",
"rc-input": "~1.8.0",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.27.0"
},
@ -18135,14 +18153,15 @@
}
},
"node_modules/rc-tooltip": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.2.0.tgz",
"integrity": "sha512-iS/3iOAvtDh9GIx1ulY7EFUXUtktFccNLsARo3NPgLf0QW9oT0w3dA9cYWlhqAKmD+uriEwdWz1kH0Qs4zk2Aw==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz",
"integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.2",
"@rc-component/trigger": "^2.0.0",
"classnames": "^2.3.1"
"classnames": "^2.3.1",
"rc-util": "^5.44.3"
},
"peerDependencies": {
"react": ">=16.9.0",
@ -18150,9 +18169,9 @@
}
},
"node_modules/rc-tree": {
"version": "5.8.8",
"resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.8.8.tgz",
"integrity": "sha512-S+mCMWo91m5AJqjz3PdzKilGgbFm7fFJRFiTDOcoRbD7UfMOPnerXwMworiga0O2XIo383UoWuEfeHs1WOltag==",
"version": "5.13.1",
"resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz",
"integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
@ -18170,16 +18189,16 @@
}
},
"node_modules/rc-tree-select": {
"version": "5.22.1",
"resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.22.1.tgz",
"integrity": "sha512-b8mAK52xEpRgS+b2PTapCt29GoIrO5cO8jB7AfHttFsIJfcnynY9FCtnYzURsKXJkGHbFY6UzSEB2I3TETtdWg==",
"version": "5.27.0",
"resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz",
"integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"@babel/runtime": "^7.25.7",
"classnames": "2.x",
"rc-select": "~14.15.0",
"rc-tree": "~5.8.1",
"rc-util": "^5.16.1"
"rc-select": "~14.16.2",
"rc-tree": "~5.13.0",
"rc-util": "^5.43.0"
},
"peerDependencies": {
"react": "*",
@ -18187,9 +18206,9 @@
}
},
"node_modules/rc-upload": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.6.0.tgz",
"integrity": "sha512-Zr0DT1NHw/ApxrP7UAoxOtGaVYuzarrrCVr0ld7RiEFsKX07uFhE1EpCBxwL11ruFn89GMcshOKWp+s6FLyAlA==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.9.0.tgz",
"integrity": "sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
@ -18202,9 +18221,9 @@
}
},
"node_modules/rc-util": {
"version": "5.43.0",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.43.0.tgz",
"integrity": "sha512-AzC7KKOXFqAdIBqdGWepL9Xn7cm3vnAmjlHqUnoQaTMZYhM4VlXGLkkHHxj/BZ7Td0+SOPKB4RGPboBVKT9htw==",
"version": "5.44.4",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
"integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
@ -18216,9 +18235,9 @@
}
},
"node_modules/rc-virtual-list": {
"version": "3.14.5",
"resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.14.5.tgz",
"integrity": "sha512-ZMOnkCLv2wUN8Jz7yI4XiSLa9THlYvf00LuMhb1JlsQCewuU7ydPuHw1rGVPhe9VZYl/5UqODtNd7QKJ2DMGfg==",
"version": "3.18.6",
"resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.18.6.tgz",
"integrity": "sha512-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.0",
@ -18269,6 +18288,18 @@
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/react-country-flag": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-country-flag/-/react-country-flag-3.1.0.tgz",
"integrity": "sha512-JWQFw1efdv9sTC+TGQvTKXQg1NKbDU2mBiAiRWcKM9F1sK+/zjhP2yGmm8YDddWyZdXVkR8Md47rPMJmo4YO5g==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/react-dev-utils": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
@ -18754,12 +18785,6 @@
"node": ">=4"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regenerator-transform": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz",
@ -21494,6 +21519,17 @@
"node": ">=0.10.48"
}
},
"node_modules/virtualizedtableforantd4": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/virtualizedtableforantd4/-/virtualizedtableforantd4-1.3.1.tgz",
"integrity": "sha512-rW8KoToI2nt1jNtweXIUIiygi74XMzKLzUrrtZbGsQc7m3v68AaedPuf4CZcte+nosgYuPEWnAgjuI/KR8BVbg==",
"license": "MIT",
"peerDependencies": {
"antd": "^4.0.0 || ^5.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/vue-eslint-parser": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",

View File

@ -6,15 +6,17 @@
"@simplewebauthn/browser": "^10.0.0",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.5.0",
"antd": "^5.19.2",
"antd": "^5.25.1",
"antd-style": "^3.7.1",
"axios": "*",
"country-list": "^2.3.0",
"dotenv": "^16.5.0",
"gcode-preview": "^2.17.0",
"keycloak-js": "^26.1.5",
"moment": "*",
"prop-types": "^15.8.1",
"react": "*",
"react-country-flag": "^3.1.0",
"react-dom": "*",
"react-router-dom": "*",
"react-scripts": "*",
@ -23,6 +25,7 @@
"styled-components": "*",
"three": "^0.166.1",
"tsparticles": "^3.5.0",
"virtualizedtableforantd4": "^1.3.1",
"web-vitals": "*"
},
"scripts": {

View File

@ -1,3 +1,8 @@
body,
.ant-typography {
font-family: 'SF Pro';
}
.App {
text-align: center;
}

View File

@ -7,7 +7,7 @@ import {
} from 'react-router-dom'
import { App, ConfigProvider, theme } from 'antd'
import AuthLayout from './components/Auth/AuthLayout.jsx'
import ProductionOverview from './components/Dashboard/Production/Overview'
import ProductionOverview from './components/Dashboard/Production/ProductionOverview'
import Printers from './components/Dashboard/Production/Printers'
import ControlPrinter from './components/Dashboard/Production/Printers/ControlPrinter.jsx'
@ -16,8 +16,6 @@ import PrinterInfo from './components/Dashboard/Production/Printers/PrinterInfo.
import PrintJobs from './components/Dashboard/Production/PrintJobs.jsx'
import PrintJobInfo from './components/Dashboard/Production/PrintJobs/PrintJobInfo.jsx'
import Spools from './components/Dashboard/Inventory/Spools'
import Filaments from './components/Dashboard/Management/Filaments'
import FilamentInfo from './components/Dashboard/Management/Filaments/FilamentInfo.jsx'
@ -30,6 +28,14 @@ import PartInfo from './components/Dashboard/Management/Parts/PartInfo.jsx'
import Products from './components/Dashboard/Management/Products.jsx'
import ProductInfo from './components/Dashboard/Management/Products/ProductInfo.jsx'
import Vendors from './components/Dashboard/Management/Vendors'
import VendorInfo from './components/Dashboard/Management/Vendors/VendorInfo'
import Materials from './components/Dashboard/Management/Materials'
import FilamentStocks from './components/Dashboard/Inventory/FilamentStocks.jsx'
import FilamentStockInfo from './components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx'
import Dashboard from './components/Dashboard/common/Dashboard'
import PrivateRoute from './components/PrivateRoute'
import PublicRoute from './components/PublicRoute.jsx'
@ -37,8 +43,6 @@ import './App.css'
import { SocketProvider } from './components/Dashboard/context/SocketContext.js'
import { AuthProvider } from './components/Auth/AuthContext.js'
import { SpotlightProvider } from './components/Dashboard/context/SpotlightContext.js'
import Vendors from './components/Dashboard/Management/Vendors'
import VendorInfo from './components/Dashboard/Management/Vendors/VendorInfo'
const FarmControlApp = () => {
return (
@ -71,7 +75,10 @@ const FarmControlApp = () => {
element={
<PrivateRoute
component={() => (
<Navigate to='/production/overview' replace />
<Navigate
to='/dashboard/production/overview'
replace
/>
)}
/>
}
@ -82,41 +89,78 @@ const FarmControlApp = () => {
/>
<Route
path='/production'
path='/dashboard'
element={<PrivateRoute component={() => <Dashboard />} />}
>
<Route path='overview' element={<ProductionOverview />} />
<Route path='printers' element={<Printers />} />
{/* Production Routes */}
<Route
path='printers/control'
path='production/overview'
element={<ProductionOverview />}
/>
<Route path='production/printers' element={<Printers />} />
<Route
path='production/printers/control'
element={<ControlPrinter />}
/>
<Route path='printers/info' element={<PrinterInfo />} />
<Route path='printjobs' element={<PrintJobs />} />
<Route path='printjobs/info' element={<PrintJobInfo />} />
<Route path='gcodefiles' element={<GCodeFiles />} />
<Route path='gcodefiles/info' element={<GCodeFileInfo />} />
</Route>
<Route
path='production/printers/info'
element={<PrinterInfo />}
/>
<Route
path='production/printjobs'
element={<PrintJobs />}
/>
<Route
path='production/printjobs/info'
element={<PrintJobInfo />}
/>
<Route
path='production/gcodefiles'
element={<GCodeFiles />}
/>
<Route
path='production/gcodefiles/info'
element={<GCodeFileInfo />}
/>
<Route
path='/inventory'
element={<PrivateRoute component={() => <Dashboard />} />}
>
<Route path='spools' element={<Spools />} />
</Route>
{/* Inventory Routes */}
<Route
path='inventory/filamentstocks'
element={<FilamentStocks />}
/>
<Route
path='inventory/filamentstocks/info'
element={<FilamentStockInfo />}
/>
<Route
path='/management'
element={<PrivateRoute component={() => <Dashboard />} />}
>
<Route path='filaments' element={<Filaments />} />
<Route path='filaments/info' element={<FilamentInfo />} />
<Route path='parts' element={<Parts />} />
<Route path='parts/info' element={<PartInfo />} />
<Route path='products' element={<Products />} />
<Route path='products/info' element={<ProductInfo />} />
<Route path='vendors' element={<Vendors />} />
<Route path='vendors/info' element={<VendorInfo />} />
{/* Management Routes */}
<Route
path='management/filaments'
element={<Filaments />}
/>
<Route
path='management/filaments/info'
element={<FilamentInfo />}
/>
<Route path='management/parts' element={<Parts />} />
<Route
path='management/parts/info'
element={<PartInfo />}
/>
<Route path='management/products' element={<Products />} />
<Route
path='management/products/info'
element={<ProductInfo />}
/>
<Route path='management/vendors' element={<Vendors />} />
<Route
path='management/vendors/info'
element={<VendorInfo />}
/>
<Route
path='management/materials'
element={<Materials />}
/>
</Route>
</Routes>
</Router>

Binary file not shown.

View File

@ -0,0 +1,14 @@
<?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="m23.416 63.312-0.364-9e-3 -0.884-0.093c-1.461-0.148-4.02-0.63-5.722-1.059-7.544-1.903-12.971-5.507-14.887-9.902-0.174-0.376-0.416-1.193-0.536-1.823-0.643-3.189 0.589-6.485 3.43-9.112 0.616-0.576 0.898-0.965 1.072-1.474 0.121-0.375 0.348-0.884 0.482-1.112 0.255-0.415 0.255-0.429-0.134-1.273-1.058-2.291-1.072-4.623-0.013-6.82l0.402-0.831-0.322-0.63c-0.79-1.541-1.085-3.618-0.723-5.132l0.161-0.697-0.898-0.817c-2.908-2.707-4.167-6.124-3.417-9.367 0.804-3.578 3.524-6.552 8.241-9.045 4.181-2.197 9.098-3.484 15.584-4.06 1.434-0.134 7.611 0.04 9.286 0.255 8.375 1.072 15.129 3.966 18.76 8.066 3.953 4.462 3.604 9.983-0.884 14.151l-0.898 0.817 0.161 0.697c0.277 1.17 0.169 2.646-0.259 3.984-1.36-0.418-2.772-0.719-4.223-0.89 0.136-0.279 0.237-0.561 0.301-0.843 0.067-0.335 0.053-0.442-0.067-0.389-0.081 0.027-0.737 0.322-1.447 0.63-0.349 0.153-0.705 0.301-1.068 0.443-0.182-4e-3 -0.366-6e-3 -0.55-6e-3 -3.922 0-7.626 0.943-10.897 2.614-2.418 0.191-8.386 0.174-10.185-0.049-4.824-0.59-8.737-1.568-12.006-3.002-0.71-0.308-1.367-0.603-1.447-0.63-0.121-0.053-0.134 0.054-0.067 0.389 0.683 3.015 5.587 5.923 11.939 7.075 2.087 0.383 3.471 0.525 5.776 0.556-1.253 1.241-2.37 2.619-3.326 4.11-0.492-0.046-0.953-0.107-1.499-0.19-4.931-0.778-9.058-2.185-11.966-4.114-0.911-0.603-0.898-0.603-0.991-0.214-0.215 0.844 0.254 2.143 1.139 3.242 1.42 1.769 4.904 3.578 8.696 4.556 0.855 0.221 1.73 0.408 2.62 0.562-0.586 1.428-1.038 2.925-1.341 4.474-0.801-0.33-1.802-0.68-3.235-1.15-2.988-0.991-5.534-2.104-6.888-3.028-0.911-0.617-0.884-0.617-0.991-0.081-0.268 1.448 0.991 3.391 3.162 4.865 1.26 0.844 3.417 1.822 5.574 2.532 0.761 0.248 1.395 0.479 1.945 0.715-8e-3 0.255-0.012 0.511-0.012 0.768 0 1.565 0.15 3.096 0.437 4.579-0.126-0.155-0.26-0.295-0.4-0.42-0.723-0.63-1.675-1.072-4.006-1.863-1.126-0.389-2.587-0.951-3.23-1.26-1.675-0.777-3.712-2.184-4.851-3.336-0.536-0.536-1.206-1.34-1.514-1.796l-0.536-0.817-0.388 0.831c-0.778 1.675-0.402 3.725 0.978 5.373 2.278 2.707 7.129 5.065 12.676 6.151 1.131 0.219 1.781 0.351 2.148 0.404 0.547 1.58 1.254 3.085 2.102 4.495zm8.172-58.981c6.928 0.549 12.582 2.371 16.455 5.279 1.367 1.032 2.198 1.997 2.841 3.283 0.469 0.925 0.522 1.166 0.522 2.117 0 1.434-0.522 2.546-1.835 3.926-3.243 3.458-9.836 5.843-17.849 6.486-10.01 0.804-20.475-1.943-24.763-6.486-1.313-1.38-1.836-2.492-1.836-3.926 0-0.951 0.054-1.192 0.523-2.117 0.643-1.286 1.474-2.251 2.84-3.283 5.173-3.899 14.325-5.99 23.102-5.279z"/>
<g transform="matrix(.0134 -2.0521e-17 -2.0521e-17 -.0134 -3.5332 72)">
<path d="m2168 4709c-274-43-465-154-552-320-36-68-36-205 0-273 70-133 179-214 374-278 384-125 908-32 1085 192 64 82 80 126 80 223 0 96-16 140-80 222-145 184-554 290-907 234zm363-314c141-23 249-67 298-120 19-22 19-23 0-45-67-73-275-135-456-135s-389 62-456 135c-19 22-19 23 0 45 93 103 379 159 614 120z" fill-rule="nonzero"/>
</g>
<g transform="matrix(.65689 0 0 .65689 23 30.103)">
<path d="m31.875 63.75c17.594 0 31.875-14.281 31.875-31.875s-14.281-31.875-31.875-31.875-31.875 14.281-31.875 31.875 14.281 31.875 31.875 31.875zm0-5.312c-14.687 0-26.562-11.875-26.562-26.563 0-14.687 11.875-26.562 26.562-26.562 14.688 0 26.563 11.875 26.563 26.562 0 14.688-11.875 26.563-26.563 26.563z" fill-rule="nonzero"/>
</g>
<g transform="matrix(.65689 0 0 .65689 23 30.103)">
<path d="m33 49.625c0.125-0.031 0.219-0.094 0.313-0.156l12.812-7.281c1.469-0.875 2.344-1.75 2.344-4.094v-12.75c0-0.438-0.031-0.875-0.156-1.219l-15.313 8.75v16.75zm-2.25 0v-16.75l-15.312-8.75c-0.094 0.344-0.094 0.781-0.094 1.219v12.75c0 2.344 0.812 3.219 2.281 4.094l12.813 7.281c0.093 0.062 0.187 0.125 0.312 0.156zm1.125-18.687 6.969-3.969-15.438-8.813-6 3.438c-0.375 0.156-0.687 0.375-0.906 0.625l15.375 8.719zm9.25-5.25 6.125-3.469c-0.219-0.25-0.531-0.469-0.906-0.625l-11.5-6.594c-1-0.594-2-0.875-2.969-0.875-0.937 0-1.937 0.281-2.937 0.875l-3.344 1.875 15.531 8.813z" fill-rule="nonzero"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,20 +1,10 @@
<?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="M10.588,3.008L7.706,0.126C7.626,0.046 7.517,0 7.404,0L1.714,0C1.477,0 1.286,0.192 1.286,0.429L1.286,11.571C1.286,11.808 1.477,12 1.714,12L10.286,12C10.523,12 10.714,11.808 10.714,11.571L10.714,3.312C10.714,3.198 10.669,3.088 10.588,3.008ZM9.726,3.509L7.205,3.509L7.205,0.988L9.726,3.509ZM9.75,11.036L2.25,11.036L2.25,0.964L6.295,0.964L6.295,3.857C6.295,4.166 6.549,4.42 6.857,4.42L9.75,4.42L9.75,11.036Z" style="fill-rule:nonzero;"/>
<g transform="matrix(2.2954,0,0,2.35148,-5.18023,-13.3842)">
<path d="M4.727,6.742L4.34,6.742C4.338,6.723 4.333,6.705 4.324,6.687C4.316,6.669 4.304,6.653 4.288,6.639C4.273,6.624 4.254,6.613 4.232,6.605C4.21,6.596 4.184,6.592 4.156,6.592C4.089,6.592 4.038,6.616 4.004,6.664C3.97,6.712 3.953,6.782 3.953,6.872L3.953,7.01C3.953,7.058 3.959,7.104 3.97,7.148C3.981,7.192 4.002,7.228 4.032,7.256C4.063,7.283 4.108,7.297 4.166,7.297C4.208,7.297 4.243,7.289 4.27,7.273C4.298,7.257 4.318,7.237 4.331,7.213C4.345,7.189 4.351,7.164 4.351,7.14L4.351,7.124L4.189,7.124L4.189,6.859L4.727,6.859L4.727,7.103C4.727,7.167 4.716,7.229 4.696,7.291C4.675,7.352 4.642,7.408 4.597,7.457C4.552,7.506 4.493,7.545 4.42,7.574C4.347,7.603 4.258,7.618 4.155,7.618C4.045,7.618 3.952,7.602 3.874,7.569C3.797,7.536 3.734,7.491 3.686,7.435C3.638,7.378 3.603,7.313 3.581,7.24C3.559,7.167 3.547,7.091 3.547,7.012L3.547,6.867C3.547,6.753 3.57,6.652 3.617,6.562C3.663,6.472 3.731,6.401 3.822,6.349C3.913,6.297 4.025,6.271 4.159,6.271C4.256,6.271 4.34,6.285 4.412,6.312C4.484,6.339 4.543,6.375 4.589,6.421C4.636,6.466 4.67,6.517 4.693,6.573C4.715,6.628 4.727,6.685 4.727,6.742Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(2.72314,0,0,2.03633,-10.3459,-7.95312)">
<path d="M5.298,6.885L5.298,7.006C5.298,7.068 5.305,7.121 5.32,7.163C5.335,7.206 5.357,7.238 5.385,7.26C5.413,7.282 5.448,7.293 5.488,7.293C5.527,7.293 5.56,7.284 5.587,7.264C5.614,7.245 5.634,7.218 5.647,7.185C5.661,7.152 5.667,7.115 5.667,7.074L6.046,7.074L6.046,7.132C6.046,7.23 6.022,7.315 5.974,7.388C5.926,7.461 5.86,7.518 5.775,7.558C5.691,7.598 5.593,7.618 5.484,7.618C5.355,7.618 5.247,7.592 5.158,7.542C5.069,7.491 5.002,7.419 4.956,7.327C4.91,7.235 4.887,7.127 4.887,7.003L4.887,6.885C4.887,6.761 4.91,6.652 4.956,6.561C5.002,6.469 5.069,6.398 5.158,6.347C5.247,6.297 5.355,6.271 5.484,6.271C5.566,6.271 5.642,6.283 5.71,6.305C5.779,6.328 5.838,6.361 5.888,6.403C5.938,6.446 5.977,6.497 6.005,6.557C6.033,6.617 6.046,6.684 6.046,6.758L6.046,6.815L5.667,6.815C5.667,6.773 5.661,6.736 5.647,6.703C5.634,6.67 5.614,6.644 5.587,6.625C5.56,6.606 5.527,6.596 5.488,6.596C5.448,6.596 5.413,6.607 5.385,6.629C5.357,6.651 5.335,6.683 5.32,6.726C5.305,6.769 5.298,6.822 5.298,6.885Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(2.17112,0,0,2.03633,-6.93951,-7.85288)">
<path d="M7.023,6.877C7.023,6.815 7.015,6.762 6.998,6.719C6.982,6.677 6.959,6.644 6.928,6.622C6.898,6.6 6.862,6.589 6.819,6.589C6.776,6.589 6.74,6.6 6.709,6.622C6.679,6.644 6.655,6.677 6.638,6.719C6.622,6.762 6.614,6.815 6.614,6.877L6.614,7.013C6.614,7.075 6.622,7.128 6.638,7.171C6.655,7.214 6.679,7.246 6.709,7.267C6.74,7.289 6.776,7.3 6.819,7.3C6.862,7.3 6.898,7.289 6.928,7.267C6.959,7.246 6.982,7.214 6.998,7.171C7.015,7.128 7.023,7.075 7.023,7.013L7.023,6.877ZM7.434,7.011C7.434,7.132 7.41,7.239 7.363,7.33C7.316,7.421 7.247,7.492 7.156,7.542C7.064,7.593 6.952,7.618 6.819,7.618C6.687,7.618 6.574,7.593 6.483,7.542C6.391,7.492 6.322,7.421 6.274,7.33C6.227,7.239 6.203,7.132 6.203,7.011L6.203,6.883C6.203,6.759 6.227,6.652 6.274,6.56C6.322,6.468 6.391,6.397 6.483,6.347C6.574,6.297 6.687,6.271 6.819,6.271C6.952,6.271 7.064,6.297 7.156,6.348C7.247,6.399 7.316,6.47 7.363,6.561C7.41,6.653 7.434,6.76 7.434,6.883L7.434,7.011Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(2.53247,0,0,1.97582,-16.199,-4.47337)">
<path d="M7.648,6.292L8.214,6.292C8.355,6.292 8.47,6.319 8.558,6.373C8.646,6.426 8.71,6.501 8.751,6.598C8.792,6.694 8.813,6.807 8.813,6.936C8.813,7.067 8.792,7.182 8.75,7.282C8.708,7.381 8.643,7.458 8.555,7.513C8.466,7.568 8.353,7.596 8.214,7.596L7.648,7.596L7.648,6.292ZM8.049,6.601L8.049,7.288L8.141,7.288C8.188,7.288 8.229,7.281 8.262,7.267C8.296,7.253 8.323,7.231 8.343,7.203C8.363,7.175 8.378,7.139 8.388,7.097C8.397,7.054 8.402,7.004 8.402,6.948C8.402,6.872 8.394,6.808 8.376,6.757C8.359,6.705 8.332,6.666 8.293,6.64C8.255,6.614 8.204,6.601 8.141,6.601L8.049,6.601Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(2.80773,0,0,1.97582,-18.7952,-4.47337)">
<path d="M9.948,7.288L9.948,7.596L9.019,7.596L9.019,6.292L9.948,6.292L9.948,6.601L9.415,6.601L9.415,6.806L9.913,6.806L9.913,7.085L9.415,7.085L9.415,7.288L9.948,7.288Z" style="fill-rule:nonzero;"/>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 12 12" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.14501 0 0 .14501 .60055 -.62488)">
<path d="m50 13.094 17.219 17.531c2.5 2.563 2.969 4.313 2.969 8.344v28.375c0 6.5-3.219 9.781-9.688 9.781h-22.939c0.923-1.552 1.627-3.242 2.066-5.031h20.623c3.25 0 4.906-1.719 4.906-4.844v-28.062h-17.812c-3.906 0-5.875-1.938-5.875-5.875v-18.157h-13.906c-3.25 0-4.907 1.782-4.907 4.875v27.144c-0.816-0.122-1.652-0.175-2.5-0.175-0.858 0-1.705 0.055-2.531 0.179v-27.241c0-6.5 3.25-9.813 9.688-9.813h14.843c3.5 0 5.469 0.531 7.844 2.969zm-3.812 19.625c0 1.25 0.468 1.75 1.718 1.75h16.282l-18-18.344v16.594z" fill-rule="nonzero"/>
<g transform="matrix(6.896 0 0 6.896 .57772 4.3091)">
<path d="m5.141 9.114c0 1.255-1.056 2.302-2.302 2.302-1.264 0-2.302-1.038-2.302-2.302 0-1.265 1.038-2.302 2.302-2.302s2.302 1.037 2.302 2.302zm-3.473 0.174c0 0.782 0.448 1.252 1.209 1.252 0.677 0 1.133-0.41 1.133-1.015v-0.166c0-0.255-0.116-0.37-0.381-0.37h-0.493c-0.176 0-0.271 0.082-0.271 0.233 0 0.153 0.097 0.237 0.271 0.237h0.248v0.117c0 0.251-0.193 0.419-0.481 0.419-0.369 0-0.572-0.251-0.572-0.709v-0.331c0-0.464 0.199-0.707 0.578-0.707 0.26 0 0.41 0.156 0.573 0.319 0.058 0.058 0.118 0.085 0.197 0.085 0.159 0 0.269-0.109 0.269-0.268 0-0.158-0.119-0.331-0.299-0.462-0.201-0.15-0.476-0.235-0.788-0.235-0.739 0-1.193 0.476-1.193 1.236v0.365z" fill-rule="nonzero"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@ -0,0 +1,12 @@
<?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 67" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(.09375 1.5681)">
<g transform="matrix(.93038 0 0 .93038 2.2234 .097899)">
<path d="m23.324 37.057-16.574 9.474c-0.125 0.063-0.219 0.157-0.219 0.313s0.094 0.219 0.219 0.312l23.781 13.594c0.532 0.281 0.969 0.438 1.407 0.438 0.437 0 0.875-0.157 1.375-0.438l23.781-13.594c0.156-0.093 0.25-0.156 0.25-0.312s-0.094-0.25-0.25-0.313l-16.562-9.467 5.225-3.033 14.587 8.469c2.531 1.5 3.531 2.625 3.531 4.344s-1 2.844-3.531 4.312l-24.438 14.219c-1.5 0.875-2.718 1.281-3.968 1.281-1.282 0-2.469-0.406-3.969-1.281l-24.469-14.219c-2.531-1.468-3.5-2.593-3.5-4.312s0.969-2.844 3.5-4.344l14.606-8.469 5.218 3.026z" fill-rule="nonzero"/>
</g>
<g transform="matrix(.93038 0 0 .93038 2.2234 .097899)">
<path d="m31.938 41.031c1.25 0 2.468-0.406 3.968-1.281l24.438-14.187c2.531-1.5 3.531-2.625 3.531-4.344s-1-2.844-3.531-4.313l-24.438-14.218c-1.5-0.875-2.718-1.282-3.968-1.282-1.282 0-2.469 0.407-3.969 1.282l-24.469 14.218c-2.531 1.469-3.5 2.594-3.5 4.313s0.969 2.844 3.5 4.344l24.469 14.187c1.5 0.875 2.687 1.281 3.969 1.281zm0-5.468c-0.438 0-0.875-0.125-1.407-0.438l-23.781-13.594c-0.125-0.062-0.219-0.156-0.219-0.312s0.094-0.219 0.219-0.313l23.781-13.593c0.532-0.282 0.969-0.438 1.407-0.438 0.437 0 0.875 0.156 1.375 0.438l23.781 13.593c0.156 0.094 0.25 0.157 0.25 0.313s-0.094 0.25-0.25 0.312l-23.781 13.594c-0.5 0.313-0.938 0.438-1.375 0.438z" fill-rule="nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

View File

@ -0,0 +1,11 @@
<?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.23c-2.042-1.163-3.245-3.25-3.245-5.617v-23.756c0-2.367 1.203-4.454 3.245-5.639l20.576-11.861c2.051-1.221 4.428-1.221 6.501 0l20.554 11.861c2.042 1.185 3.246 3.272 3.246 5.639v8.434c-1.564-1.263-3.289-2.334-5.14-3.176v-2.05l-2.117 1.206c-2.447-0.843-5.073-1.3-7.806-1.3-0.15 0-0.299 1e-3 -0.449 4e-3l8.531-4.869c-0.118-0.114-0.161-0.179-0.369-0.306l-18.221-10.517c-0.942-0.564-2.039-0.564-2.981 0l-18.199 10.517c-0.229 0.127-0.282 0.197-0.42 0.325l17.376 9.904c-1.43 1.114-2.731 2.386-3.876 3.79l-15.316-8.726v19.575c0 1.075 0.564 2.017 1.506 2.577l8.639 4.994c0.349 2.554 1.1 4.982 2.188 7.217l-14.223-8.226z" fill-rule="nonzero"/>
<g transform="matrix(.65689 0 0 .65689 23 30.103)">
<path d="m31.875 63.75c17.594 0 31.875-14.281 31.875-31.875s-14.281-31.875-31.875-31.875-31.875 14.281-31.875 31.875 14.281 31.875 31.875 31.875zm0-5.312c-14.687 0-26.562-11.875-26.562-26.563 0-14.687 11.875-26.562 26.562-26.562 14.688 0 26.563 11.875 26.563 26.562 0 14.688-11.875 26.563-26.563 26.563z" fill-rule="nonzero"/>
</g>
<g transform="matrix(.65689 0 0 .65689 23 30.103)">
<path d="m33 49.625c0.125-0.031 0.219-0.094 0.313-0.156l12.812-7.281c1.469-0.875 2.344-1.75 2.344-4.094v-12.75c0-0.438-0.031-0.875-0.156-1.219l-15.313 8.75v16.75zm-2.25 0v-16.75l-15.312-8.75c-0.094 0.344-0.094 0.781-0.094 1.219v12.75c0 2.344 0.812 3.219 2.281 4.094l12.813 7.281c0.093 0.062 0.187 0.125 0.312 0.156zm1.125-18.687 6.969-3.969-15.438-8.813-6 3.438c-0.375 0.156-0.687 0.375-0.906 0.625l15.375 8.719zm9.25-5.25 6.125-3.469c-0.219-0.25-0.531-0.469-0.906-0.625l-11.5-6.594c-1-0.594-2-0.875-2.969-0.875-0.937 0-1.937 0.281-2.937 0.875l-3.344 1.875 15.531 8.813z" fill-rule="nonzero"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 65 72" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.847737,0,0,0.847737,4.941,5.44125)">
<path d="M28.878,12.775L34.938,12.775L34.938,3.306L28.878,3.306L28.878,12.775ZM28.878,23.873L34.938,23.873L34.938,15.867L28.878,15.867L28.878,23.873ZM4.914,55.663L12.763,51.146L9.617,45.862L2.235,50.08L4.914,55.663ZM15.414,49.628L22.38,45.664L19.286,40.349L12.346,44.313L15.414,49.628ZM50.985,51.146L58.834,55.663L61.513,50.08L54.131,45.862L50.985,51.146ZM41.368,45.664L48.359,49.628L51.403,44.313L44.463,40.349L41.368,45.664ZM25.077,44.141L31.887,40.24L38.697,44.141L41.791,38.857L34.938,34.933L34.938,26.939L28.878,26.939L28.878,34.894L21.957,38.857L25.077,44.141ZM3.827,56.348L28.088,70.379C30.507,71.793 33.309,71.793 35.753,70.379L59.989,56.348C62.396,54.977 63.816,52.516 63.816,49.725L63.816,21.714C63.816,18.923 62.396,16.462 59.989,15.065L35.753,1.08C33.309,-0.36 30.507,-0.36 28.088,1.08L3.827,15.065C1.419,16.462 0,18.923 0,21.714L0,49.725C0,52.516 1.419,54.977 3.827,56.348ZM28.878,63.808C28.642,63.697 28.605,63.666 28.349,63.51L7.831,51.649C6.72,50.99 6.055,49.878 6.055,48.611L6.055,25.53L28.878,38.533L28.878,63.808ZM31.887,33.175L8.197,19.672C8.359,19.521 8.421,19.439 8.691,19.288L30.15,6.888C31.261,6.223 32.554,6.223 33.666,6.888L55.15,19.288C55.394,19.439 55.446,19.516 55.585,19.65L31.887,33.175ZM34.938,63.808L34.938,38.493L57.755,25.496L57.755,48.611C57.755,49.878 57.121,50.99 56.01,51.649L35.727,63.371C35.361,63.575 35.279,63.635 34.938,63.808Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1,5 +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 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 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">
<g transform="matrix(1.0225 0 0 1.0225 .12558 -1.0545)">
<path d="m15.488 31.057c-0.04 0.022-0.08 0.044-0.12 0.068l-11.898 6.868c-1.317 0.758-2.106 2.121-2.106 3.66v13.737c0 1.539 0.789 2.902 2.106 3.652l11.898 6.876c1.333 0.781 2.887 0.781 4.227 0l11.891-6.876c0.06-0.034 0.118-0.069 0.176-0.105 0.057 0.036 0.116 0.071 0.175 0.105l11.899 6.876c1.332 0.781 2.887 0.781 4.227 0l11.891-6.876c1.317-0.75 2.106-2.113 2.106-3.652v-13.737c0-1.539-0.789-2.902-2.106-3.66l-11.891-6.868c-0.084-0.05-0.169-0.096-0.255-0.139 2e-3 -0.052 2e-3 -0.104 2e-3 -0.156v-13.736c0-1.54-0.788-2.902-2.105-3.661l-11.891-6.868c-1.34-0.788-2.895-0.788-4.227 0l-11.899 6.868c-1.317 0.759-2.106 2.121-2.106 3.661v13.736c0 0.076 2e-3 0.151 6e-3 0.227zm18.104 13.124 10.32 5.91v11.455c-0.13-0.069-0.153-0.084-0.283-0.161l-9.227-5.337c-0.486-0.287-0.788-0.766-0.81-1.32v-10.547zm14.187 17.365v-11.508l10.314-5.904v10.528c0 0.582-0.291 1.088-0.796 1.386l-9.043 5.23c-0.199 0.115-0.284 0.168-0.475 0.268zm-32.234 0c-0.131-0.069-0.154-0.084-0.284-0.161l-9.226-5.337c-0.506-0.298-0.812-0.804-0.812-1.386v-10.482l10.322 5.911v11.455zm3.866 0v-11.508l10.314-5.904v10.528c0 0.582-0.291 1.088-0.796 1.386l-9.043 5.23c-0.199 0.115-0.283 0.168-0.475 0.268zm26.392-14.854-10.658-6.103c0.084-0.069 0.115-0.1 0.237-0.169l9.663-5.581c0.506-0.307 1.095-0.307 1.601 0l9.67 5.581c0.115 0.069 0.131 0.092 0.184 0.138l-10.697 6.134zm-27.875-12.013 10.189 5.888-10.681 6.125-10.659-6.103c0.085-0.069 0.115-0.1 0.238-0.169l9.663-5.581c0.392-0.238 0.835-0.291 1.25-0.16zm15.602 2.307v-11.508l10.314-5.904v10.529c0 0.582-0.291 1.087-0.797 1.386l-9.042 5.229c-0.2 0.115-0.284 0.169-0.475 0.268zm-3.867-0.046-9.785-5.652c-0.339-0.301-0.537-0.718-0.537-1.185v-10.483l10.322 5.911v11.409zm1.891-14.808-10.658-6.103c0.084-0.069 0.115-0.099 0.237-0.168l9.663-5.582c0.506-0.306 1.095-0.306 1.601 0l9.67 5.582c0.115 0.069 0.13 0.092 0.184 0.138l-10.697 6.133z" fill-rule="nonzero"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

View File

@ -0,0 +1,11 @@
<?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="m23.949 64.157-3.787 2.19c-1.37 0.799-2.96 0.799-4.322 0l-12.167-7.03c-1.346-0.768-2.153-2.161-2.153-3.735v-14.046c0-1.573 0.807-2.967 2.153-3.742l12.167-7.023c0.041-0.024 0.081-0.047 0.122-0.07-4e-3 -0.077-6e-3 -0.154-6e-3 -0.231v-14.046c0-1.574 0.807-2.968 2.153-3.743l12.167-7.023c1.362-0.806 2.952-0.806 4.322 0l12.159 7.023c1.346 0.775 2.153 2.169 2.153 3.743v11.05c-1.284-0.267-2.605-0.432-3.954-0.484v-8.03l-10.546 6.037v3.969c-1.391 0.608-2.714 1.344-3.954 2.194v-6.108l-10.554-6.045v10.719c0 0.477 0.202 0.904 0.549 1.212l6.123 3.537c-0.927 0.979-1.773 2.037-2.526 3.161l-5.591-3.231c-0.424-0.134-0.877-0.08-1.278 0.163l-9.881 5.708c-0.125 0.07-0.156 0.102-0.242 0.172l10.898 6.24 2.771-1.589c-0.404 1.604-0.647 3.272-0.709 4.986l-0.042 0.024v11.768c0.196-0.102 0.282-0.157 0.485-0.274l1.591-0.92c0.538 1.214 1.174 2.375 1.899 3.474zm-7.929-2.28c-0.133-0.071-0.157-0.086-0.29-0.165l-9.434-5.457c-0.517-0.305-0.83-0.822-0.83-1.417v-10.718l10.554 6.044v11.713zm16.37-40.302-10.898-6.24c0.086-0.07 0.117-0.101 0.242-0.172l9.881-5.707c0.517-0.313 1.12-0.313 1.636 0l9.889 5.707c0.117 0.071 0.133 0.094 0.188 0.141l-10.938 6.271z"/>
<g transform="matrix(.65689 0 0 .65689 23 30.103)">
<path d="m31.875 63.75c17.594 0 31.875-14.281 31.875-31.875s-14.281-31.875-31.875-31.875-31.875 14.281-31.875 31.875 14.281 31.875 31.875 31.875zm0-5.312c-14.687 0-26.562-11.875-26.562-26.563 0-14.687 11.875-26.562 26.562-26.562 14.688 0 26.563 11.875 26.563 26.562 0 14.688-11.875 26.563-26.563 26.563z" fill-rule="nonzero"/>
</g>
<g transform="matrix(.65689 0 0 .65689 23 30.103)">
<path d="m33 49.625c0.125-0.031 0.219-0.094 0.313-0.156l12.812-7.281c1.469-0.875 2.344-1.75 2.344-4.094v-12.75c0-0.438-0.031-0.875-0.156-1.219l-15.313 8.75v16.75zm-2.25 0v-16.75l-15.312-8.75c-0.094 0.344-0.094 0.781-0.094 1.219v12.75c0 2.344 0.812 3.219 2.281 4.094l12.813 7.281c0.093 0.062 0.187 0.125 0.312 0.156zm1.125-18.687 6.969-3.969-15.438-8.813-6 3.438c-0.375 0.156-0.687 0.375-0.906 0.625l15.375 8.719zm9.25-5.25 6.125-3.469c-0.219-0.25-0.531-0.469-0.906-0.625l-11.5-6.594c-1-0.594-2-0.875-2.969-0.875-0.937 0-1.937 0.281-2.937 0.875l-3.344 1.875 15.531 8.813z" fill-rule="nonzero"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

View File

@ -0,0 +1,11 @@
<?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 65 72" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.852955,0,0,0.852955,1.89184,7.26595)">
<path d="M52.261,17.631C47.92,13.291 42.034,10.852 35.895,10.852C34.683,10.852 33.472,10.948 32.275,11.137L32.896,15.059C33.888,14.902 34.891,14.823 35.895,14.823C40.98,14.823 45.857,16.843 49.453,20.439L52.261,17.631Z"/>
</g>
<g transform="matrix(0.488258,0,0,0.488258,14.9739,19.4006)">
<path d="M35.895,10.852C48.669,10.852 59.04,21.223 59.04,33.997C59.04,46.771 48.669,57.142 35.895,57.142C23.121,57.142 12.751,46.771 12.751,33.997C12.751,21.223 23.121,10.852 35.895,10.852ZM35.895,19.205C44.059,19.205 50.687,25.833 50.687,33.997C50.687,42.161 44.059,48.789 35.895,48.789C27.731,48.789 21.103,42.161 21.103,33.997C21.103,25.833 27.731,19.205 35.895,19.205Z"/>
</g>
<path d="M54.909,51.146C50.043,58.327 41.819,63.049 32.5,63.049C17.571,63.049 5.451,50.929 5.451,36C5.451,21.071 17.571,8.951 32.5,8.951C47.429,8.951 59.549,21.071 59.549,36L59.549,63.049L54.909,63.049L54.909,51.146ZM54.909,36C54.909,48.368 44.868,58.409 32.5,58.409C20.132,58.409 10.091,48.368 10.091,36C10.091,23.632 20.132,13.591 32.5,13.591C44.868,13.591 54.909,23.632 54.909,36Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 72 69" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<path d="m20.601 66.402v-19.472h8.932v19.472h28.517c4.163 0 6.597-2.375 6.597-6.48v-23.76c3.033-1.711 5.043-4.945 5.043-8.699v-0.498c0-1.144-0.293-2.17-0.88-3.167l-7.77-13.283v-2.433c0-3.724-2.287-5.952-6.069-5.952h-37.912c-3.783 0-6.099 2.228-6.099 5.952v2.433l-7.77 13.283c-0.587 0.997-0.88 2.023-0.88 3.167v0.498c0 3.754 2.01 6.988 5.043 8.699v23.76c0 4.105 2.434 6.48 6.597 6.48h6.651zm39.325-28.945c-0.098 3e-3 -0.195 5e-3 -0.293 5e-3 -3.255 0-6.099-1.496-7.858-3.871-1.789 2.375-4.604 3.871-7.888 3.871s-6.128-1.496-7.887-3.871c-1.759 2.375-4.603 3.871-7.887 3.871-3.255 0-6.099-1.496-7.858-3.871-1.789 2.375-4.633 3.871-7.888 3.871-0.098 0-0.195-2e-3 -0.293-5e-3v21.262c0 1.906 1.026 2.962 2.903 2.962h1.759v-16.449c0-1.349 0.909-2.229 2.258-2.229h12.432c1.349 0 2.228 0.88 2.228 2.229v16.449h23.399c1.876 0 2.873-1.056 2.873-2.962v-21.262zm-37.443-8.088h11.289c-0.645 2.375-2.639 3.958-5.659 3.958-2.991 0-5.014-1.583-5.63-3.958zm31.52 0h11.289c-0.645 2.375-2.639 3.958-5.659 3.958-2.991 0-5.014-1.583-5.63-3.958zm-47.295 0h11.318c-0.645 2.375-2.639 3.958-5.659 3.958s-5.014-1.583-5.659-3.958zm31.52 0h11.318c-0.645 2.375-2.639 3.958-5.659 3.958s-5.013-1.583-5.659-3.958zm-30.846-4.134 6.363-11.201h44.275l6.539 11.201h-57.177zm8.005-15.629v-0.703c0-1.466 0.851-2.346 2.287-2.346h36.652c1.437 0 2.287 0.88 2.287 2.346v0.703h-41.226z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,33 @@
<?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 1280 113" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1.0156 0 0 1.0156 -2.2117e-16 0)">
<g transform="matrix(.67805 0 0 .67805 -10.706 -23.499)" fill-rule="nonzero">
<path d="m15.789 187.1v-142.1c0-1.997 0.817-3.766 2.45-5.309 1.634-1.542 3.449-2.314 5.445-2.314h77.857c1.997 0 3.812 0.772 5.445 2.314 1.633 1.543 2.45 3.312 2.45 5.309v18.239c0 1.996-0.817 3.766-2.45 5.309-1.633 1.542-3.448 2.314-5.445 2.314h-49.273v35.934h43.557c1.996 0 3.811 0.771 5.444 2.314 1.634 1.542 2.45 3.312 2.45 5.308v18.24c0 1.996-0.816 3.765-2.45 5.308-1.633 1.543-3.448 2.314-5.444 2.314h-43.557v46.823c0 1.997-0.771 3.812-2.314 5.445s-3.312 2.45-5.308 2.45h-21.234c-1.997 0-3.766-0.817-5.309-2.45-1.542-1.633-2.314-3.448-2.314-5.445z"/>
<path d="m163.34 37.379h30.49c3.63 0 6.352 1.725 8.167 5.173l44.646 144.28c0 2.178-0.772 4.084-2.314 5.717-1.543 1.633-3.403 2.45-5.581 2.45h-24.229c-3.629 0-6.079-1.815-7.35-5.445l-5.717-20.961h-45.734l-5.717 20.961c-1.27 3.63-3.72 5.445-7.35 5.445h-24.228c-2.178 0-4.038-0.817-5.581-2.45s-2.314-3.539-2.314-5.717l44.646-144.28c1.814-3.448 4.537-5.173 8.166-5.173zm15.245 46.552-13.883 51.179h27.767l-13.884-51.179z"/>
<path d="m315.51 117.69c6.715 0 12.16-1.906 16.334-5.717s6.261-9.8 6.261-17.967-2.087-14.201-6.261-18.103-9.619-5.853-16.334-5.853h-12.25v47.64h12.25zm29.129 74.591-22.051-42.468-6.261 0.544h-13.067v36.751c0 1.997-0.771 3.812-2.314 5.445-1.542 1.633-3.312 2.45-5.308 2.45h-21.234c-1.996 0-3.766-0.817-5.309-2.45-1.542-1.633-2.314-3.448-2.314-5.445v-142.1c0-1.997 0.817-3.766 2.451-5.309 1.633-1.542 3.448-2.314 5.444-2.314h41.651c0.908 0 2.087 0.046 3.539 0.137 1.452 0.09 4.31 0.499 8.575 1.225 4.265 0.725 8.303 1.724 12.115 2.994 3.811 1.27 8.076 3.358 12.794 6.261 4.719 2.904 8.757 6.262 12.114 10.073 3.358 3.811 6.216 8.847 8.576 15.109 2.359 6.261 3.539 13.203 3.539 20.825 0 19.056-6.715 33.484-20.145 43.284l25.861 50.091c0 2.177-0.68 3.992-2.041 5.444s-3.131 2.178-5.309 2.178h-25.317c-2.541 0-4.537-0.907-5.989-2.722z"/>
<path d="m480.48 174.04h-3.266c-1.997 0-3.358-0.726-4.084-2.177l-33.212-60.708v75.952c0 1.997-0.771 3.812-2.314 5.445-1.542 1.633-3.312 2.45-5.308 2.45h-21.234c-1.996 0-3.766-0.817-5.309-2.45-1.542-1.633-2.314-3.448-2.314-5.445v-141.83c0-1.996 0.772-3.811 2.314-5.445 1.543-1.633 3.313-2.45 5.309-2.45h24.5c2.904 0 4.901 1.089 5.99 3.267l37.023 66.424 37.023-66.424c1.089-2.178 3.085-3.267 5.989-3.267h24.5c1.997 0 3.766 0.817 5.309 2.45 1.543 1.634 2.314 3.449 2.314 5.445v141.83c0 1.997-0.771 3.812-2.314 5.445s-3.312 2.45-5.309 2.45h-21.233c-1.997 0-3.766-0.817-5.309-2.45s-2.314-3.448-2.314-5.445v-75.952l-33.212 60.708c-0.726 1.451-1.905 2.177-3.539 2.177z"/>
</g>
<g transform="matrix(.67805 0 0 .67805 15.294 -23.499)" fill="url(#a)" fill-rule="nonzero">
<path d="m656.07 198c-22.686 0-40.608-7.759-53.765-23.276-13.158-15.517-19.737-34.981-19.737-58.393s6.579-42.876 19.737-58.393c13.157-15.517 31.079-23.276 53.765-23.276 39.019 0 62.703 18.149 71.052 54.446-0.363 1.996-1.316 3.72-2.859 5.172-1.542 1.452-3.312 2.178-5.308 2.178h-23.412c-2.722 0-4.809-1.361-6.261-4.083-4.356-16.153-15.426-24.229-33.212-24.229-12.16 0-21.143 4.447-26.951 13.339-5.807 8.893-8.711 20.508-8.711 34.846 0 14.156 2.904 25.725 8.711 34.709 5.808 8.984 14.791 13.475 26.951 13.475 17.786 0 28.856-8.076 33.212-24.228 1.452-2.722 3.539-4.083 6.261-4.083h23.412c1.996 0 3.766 0.725 5.308 2.177 1.543 1.452 2.496 3.176 2.859 5.173-8.349 36.297-32.033 54.446-71.052 54.446z"/>
<path d="m767.14 57.797c12.704-15.427 30.399-23.14 53.085-23.14s40.335 7.713 52.949 23.14c12.613 15.426 18.92 34.936 18.92 58.529 0 23.412-6.352 42.876-19.056 58.393s-30.309 23.276-52.813 23.276-40.108-7.759-52.812-23.276-19.056-34.981-19.056-58.393c0-23.593 6.261-43.103 18.783-58.529zm19.056 58.529c0 32.123 11.343 48.184 34.029 48.184s34.029-16.061 34.029-48.184-11.343-48.185-34.029-48.185-34.029 16.062-34.029 48.185z"/>
<path d="m1005.3 191.73-47.91-78.129v73.501c0 1.997-0.772 3.812-2.314 5.445-1.543 1.633-3.313 2.45-5.309 2.45h-21.234c-1.996 0-3.766-0.817-5.308-2.45-1.543-1.633-2.314-3.448-2.314-5.445v-141.83c0-1.996 0.771-3.811 2.314-5.445 1.542-1.633 3.312-2.45 5.308-2.45h22.867c2.723 0 4.719 1.089 5.99 3.267l50.09 82.758v-78.13c0-1.996 0.77-3.811 2.31-5.445 1.55-1.633 3.32-2.45 5.31-2.45h21.24c1.99 0 3.76 0.817 5.3 2.45 1.55 1.634 2.32 3.449 2.32 5.445v141.83c0 1.997-0.77 3.812-2.32 5.445-1.54 1.633-3.31 2.45-5.3 2.45h-25.05c-2.72 0-4.72-1.089-5.99-3.267z"/>
<path d="m1076.7 37.379h99.9c2 0 3.81 0.772 5.45 2.314 1.63 1.543 2.45 3.312 2.45 5.309v18.239c0 1.996-0.82 3.766-2.45 5.309-1.64 1.542-3.45 2.314-5.45 2.314h-31.57v116.24c0 1.997-0.78 3.812-2.32 5.445s-3.31 2.45-5.31 2.45h-21.5c-2 0-3.77-0.817-5.31-2.45s-2.32-3.448-2.32-5.445v-116.24h-31.57c-2 0-3.81-0.772-5.45-2.314-1.63-1.543-2.45-3.313-2.45-5.309v-18.239c0-1.997 0.82-3.766 2.45-5.309 1.64-1.542 3.45-2.314 5.45-2.314z"/>
<path d="m1258 117.69c6.71 0 12.16-1.906 16.33-5.717 4.18-3.811 6.27-9.8 6.27-17.967s-2.09-14.201-6.27-18.103c-4.17-3.902-9.62-5.853-16.33-5.853h-12.25v47.64h12.25zm29.13 74.591-22.05-42.468-6.26 0.544h-13.07v36.751c0 1.997-0.77 3.812-2.31 5.445-1.55 1.633-3.32 2.45-5.31 2.45h-21.24c-1.99 0-3.76-0.817-5.3-2.45-1.55-1.633-2.32-3.448-2.32-5.445v-142.1c0-1.997 0.82-3.766 2.45-5.309 1.64-1.542 3.45-2.314 5.45-2.314h41.65c0.9 0 2.08 0.046 3.54 0.137 1.45 0.09 4.31 0.499 8.57 1.225 4.27 0.725 8.3 1.724 12.12 2.994 3.81 1.27 8.07 3.358 12.79 6.261 4.72 2.904 8.76 6.262 12.11 10.073 3.36 3.811 6.22 8.847 8.58 15.109 2.36 6.261 3.54 13.203 3.54 20.825 0 19.056-6.72 33.484-20.15 43.284l25.87 50.091c0 2.177-0.68 3.992-2.05 5.444-1.36 1.452-3.13 2.178-5.31 2.178h-25.31c-2.54 0-4.54-0.907-5.99-2.722z"/>
<path d="m1360.6 57.797c12.71-15.427 30.4-23.14 53.08-23.14 22.69 0 40.34 7.713 52.95 23.14 12.62 15.426 18.92 34.936 18.92 58.529 0 23.412-6.35 42.876-19.05 58.393-12.71 15.517-30.31 23.276-52.82 23.276-22.5 0-40.1-7.759-52.81-23.276-12.7-15.517-19.05-34.981-19.05-58.393 0-23.593 6.26-43.103 18.78-58.529zm19.06 58.529c0 32.123 11.34 48.184 34.02 48.184 22.69 0 34.03-16.061 34.03-48.184s-11.34-48.185-34.03-48.185c-22.68 0-34.02 16.062-34.02 48.185z"/>
<path d="m1514.4 187.38v-142.1c0-1.996 0.77-3.811 2.31-5.445 1.55-1.633 3.32-2.45 5.31-2.45h21.24c1.99 0 3.76 0.817 5.31 2.45 1.54 1.634 2.31 3.449 2.31 5.445v116.24h49.82c1.99 0 3.81 0.771 5.44 2.314 1.64 1.542 2.45 3.312 2.45 5.308v18.24c0 1.996-0.81 3.765-2.45 5.308-1.63 1.543-3.45 2.314-5.44 2.314h-78.4c-2 0-3.82-0.771-5.45-2.314s-2.45-3.312-2.45-5.308z"/>
</g>
<g transform="matrix(1.304 0 0 1.304 -18.701 -415.07)">
<path d="m947.98 337.61h-46.26c-1.056 0-2.016-0.408-2.88-1.224s-1.296-1.752-1.296-2.808v-11.232c0-1.056 0.432-1.992 1.296-2.808s1.824-1.224 2.88-1.224h66.099c3.382 0 6.386 1.364 9.01 4.091 2.727 2.624 4.091 5.628 4.091 9.01v66.099c0 1.056-0.408 2.016-1.224 2.88s-1.752 1.296-2.808 1.296h-11.232c-1.056 0-1.992-0.432-2.808-1.296s-1.224-1.824-1.224-2.88v-46.26l-49.262 49.263c-0.747 0.747-1.714 1.137-2.902 1.171s-2.156-0.323-2.902-1.069l-7.942-7.943c-0.747-0.746-1.104-1.714-1.07-2.902 0.034-1.187 0.425-2.155 1.171-2.902l49.263-49.262z" fill-rule="nonzero"/>
</g>
</g>
<defs>
<linearGradient id="a" x2="1" gradientTransform="matrix(9.8182e-15 -160.34 160.34 9.8182e-15 1115.9 195)" gradientUnits="userSpaceOnUse"><stop stop-color="#00a2ff" offset="0"/><stop stop-color="#008eff" offset="1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -14,7 +14,7 @@ const LoginUser = () => {
//const navigate = useNavigate()
const { loginWithSSO } = useContext(AuthContext)
const handleLogin = async () => {
loginWithSSO('/production/overview')
loginWithSSO('/dashboard/production/overview')
}
return (

View File

@ -0,0 +1,296 @@
// src/filamentStocks.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,
message,
Dropdown,
Typography
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
ReloadOutlined,
InfoCircleOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import { SocketContext } from '../context/SocketContext'
import NewFilamentStock from './FilamentStocks/NewFilamentStock'
import IdText from '../common/IdText'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import FilamentStockState from '../common/FilamentStockState'
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 FilamentStocks = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const { socket } = useContext(SocketContext)
const [filamentStocksData, setFilamentStocksData] = useState([])
const [newFilamentStockOpen, setNewFilamentStockOpen] = useState(false)
const [loading, setLoading] = useState(true)
const [initialized, setInitialized] = useState(false)
const { authenticated } = useContext(AuthContext)
const fetchFilamentStocksData = useCallback(async () => {
try {
const response = await axios.get('http://localhost:8080/filamentstocks', {
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
setFilamentStocksData(response.data)
setLoading(false)
} catch (err) {
messageApi.info(err)
}
}, [messageApi])
useEffect(() => {
// Fetch initial data
if (authenticated) {
fetchFilamentStocksData()
}
}, [authenticated, fetchFilamentStocksData])
useEffect(() => {
// Add WebSocket event listener for real-time updates
if (socket && !initialized) {
setInitialized(true)
socket.on('notify_filamentstock_update', (statusUpdate) => {
console.log('Received filament stock update:', statusUpdate)
setFilamentStocksData((prevData) => {
return prevData.map((stock) => {
if (stock._id === statusUpdate.id) {
return {
...stock,
...statusUpdate
}
}
return stock
})
})
})
}
return () => {
if (socket && initialized) {
console.log('Deregistering filament stock update listener')
socket.off('notify_filamentstock_update')
}
}
}, [socket, initialized])
const getFilamentStockActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(
`/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
)
}
}
}
}
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: 'icon',
width: 40,
fixed: 'left',
render: () => <FilamentStockIcon></FilamentStockIcon>
},
{
title: 'Filament Name',
dataIndex: 'filament',
key: 'name',
width: 200,
fixed: 'left',
render: (filament) => <Text ellipsis>{filament.name}</Text>
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => (
<IdText id={text} type={'filamentstock'} longId={false} />
)
},
{
title: 'Current (g)',
dataIndex: 'currentNetWeight',
key: 'currentNetWeight',
width: 120,
render: (currentNetWeight) => (
<Text ellipsis>{currentNetWeight.toFixed(2) + 'g'}</Text>
)
},
{
title: 'Starting (g)',
dataIndex: 'startingNetWeight',
key: 'startingNetWeight',
width: 120,
render: (startingNetWeight) => (
<Text ellipsis>{startingNetWeight.toFixed(2) + 'g'}</Text>
)
},
{
title: 'State',
key: 'state',
width: 350,
render: (record) => <FilamentStockState filamentStock={record} />
},
{
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(
`/dashboard/inventory/filamentstocks/info?filamentStockId=${record._id}`
)
}
/>
<Dropdown menu={getFilamentStockActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const actionItems = {
items: [
{
label: 'New Filament Stock',
key: 'newFilamentStock',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchFilamentStocksData()
} else if (key === 'newFilamentStock') {
setNewFilamentStockOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={filamentStocksData}
className={styles.customTable}
columns={columns}
pagination={false}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
/>
</Flex>
<Modal
open={newFilamentStockOpen}
styles={{ content: { paddingBottom: '24px' } }}
footer={null}
width={700}
onCancel={() => {
setNewFilamentStockOpen(false)
}}
destroyOnClose
>
<NewFilamentStock
onOk={() => {
setNewFilamentStockOpen(false)
messageApi.success('New filament stock created successfully.')
fetchFilamentStocksData()
}}
reset={newFilamentStockOpen}
/>
</Modal>
</>
)
}
export default FilamentStocks

View File

@ -0,0 +1,232 @@
import React, { useState, useEffect, useContext } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
Spin,
Space,
Button,
message,
Typography,
Flex,
Form,
Badge
} from 'antd'
import { LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import IdText from '../../common/IdText'
import moment from 'moment'
import { SocketContext } from '../../context/SocketContext'
import FilamentStockState from '../../common/FilamentStockState'
import StockEventTable from '../../common/StockEventTable'
const { Title, Text } = Typography
const FilamentStockInfo = () => {
const [filamentStockData, setFilamentStockData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [error, setError] = useState(null)
const [initialized, setInitialized] = useState(false)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const filamentStockId = new URLSearchParams(location.search).get(
'filamentStockId'
)
const [form] = Form.useForm()
const { socket } = useContext(SocketContext)
useEffect(() => {
if (filamentStockId) {
fetchFilamentStockDetails()
}
}, [filamentStockId])
useEffect(() => {
if (filamentStockData) {
form.setFieldsValue({
filament: filamentStockData.filament || ''
})
}
}, [filamentStockData, form])
// Add WebSocket event listener for real-time updates
useEffect(() => {
if (socket && !initialized && filamentStockId) {
setInitialized(true)
socket.on('notify_filamentstock_update', (statusUpdate) => {
console.log('GOT FILAMENT STOCK UPDATE', statusUpdate)
setFilamentStockData((prevData) => {
if (statusUpdate?.id === filamentStockId) {
return {
...prevData,
...statusUpdate
}
}
return prevData
})
})
}
return () => {
if (socket && initialized) {
console.log('Deregistering filament stock update listener')
socket.off('notify_filamentstock_update')
}
}
}, [socket, initialized, filamentStockId])
const fetchFilamentStockDetails = async () => {
try {
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/filamentStocks/${filamentStockId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
setFilamentStockData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch filament stock details')
messageApi.error('Failed to fetch filament stock details')
} finally {
setFetchLoading(false)
}
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !filamentStockData) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'FilamentStock not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchFilamentStockDetails}>
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 Stock Information
</Title>
</Flex>
<Form
form={form}
layout='vertical'
initialValues={{
filament: filamentStockData.filament || {}
}}
>
<Descriptions bordered column={2}>
{/* Read-only fields */}
<Descriptions.Item label='ID' span={1}>
{filamentStockData.id ? (
<IdText id={filamentStockData.id} type={'filamentstock'} />
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Created At'>
{moment(filamentStockData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='State'>
<FilamentStockState filamentStock={filamentStockData} />
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(filamentStockData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{filamentStockData.filament ? (
<Badge
color={filamentStockData.filament.color}
text={filamentStockData.filament.name}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID' span={1}>
{filamentStockData.filament ? (
<IdText
id={filamentStockData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Gross Weight'>
{filamentStockData.currentGrossWeight ? (
<Text>
{filamentStockData.currentGrossWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Gross Weight'>
{filamentStockData.startingGrossWeight ? (
<Text>
{filamentStockData.startingGrossWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Current Net Weight'>
{filamentStockData.currentNetWeight ? (
<Text>{filamentStockData.currentNetWeight.toFixed(2) + 'g'}</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Starting Net Weight'>
{filamentStockData.startingNetWeight ? (
<Text>
{filamentStockData.startingNetWeight.toFixed(2) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
<Flex
align={'center'}
style={{ marginTop: 32, marginBottom: 12, minHeight: '32px' }}
>
<Title level={5} style={{ margin: 0 }}>
Filament Stock Events
</Title>
</Flex>
<StockEventTable stockEvents={filamentStockData.stockEvents} />
</div>
)
}
export default FilamentStockInfo

View File

@ -0,0 +1,338 @@
import React, { useState, useContext, useEffect } from 'react'
import {
Form,
Button,
Typography,
Flex,
Steps,
Divider,
Descriptions,
Alert
} from 'antd'
import PropTypes from 'prop-types'
import { SocketContext } from '../../context/SocketContext'
import FilamentStockSelect from '../../common/FilamentStockSelect'
import PrinterSelect from '../../common/PrinterSelect'
import FilamentStockDisplay from '../../common/FilamentStockDisplay'
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel'
import { LoadingOutlined } from '@ant-design/icons'
import PrinterState from '../../common/PrinterState'
const { Title } = Typography
const LoadFilamentStock = ({
onOk,
reset,
printer = null,
filamentStockLoaded = false
}) => {
LoadFilamentStock.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired,
printer: PropTypes.object,
filamentStockLoaded: PropTypes.bool
}
const { socket } = useContext(SocketContext)
const initialLoadFilamentStockForm = {
printer: printer,
filamentStock: null
}
const [loadFilamentStockLoading, setLoadFilamentStockLoading] =
useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [currentTemperature, setCurrentTemperature] = useState(-1)
const [targetTemperature, setTargetTemperature] = useState(0)
const [filamentSensorDetected, setFilamentSensorDetected] =
useState(filamentStockLoaded)
const [loadFilamentStockForm] = Form.useForm()
const [loadFilamentStockFormValues, setLoadFilamentStockFormValues] =
useState(initialLoadFilamentStockForm)
const loadFilamentStockFormUpdateValues = Form.useWatch(
[],
loadFilamentStockForm
)
// Add websocket temperature monitoring
useEffect(() => {
if (loadFilamentStockFormValues.printer) {
const params = {
printerId: loadFilamentStockFormValues.printer._id,
objects: {
extruder: null,
'filament_switch_sensor fsensor': null
}
}
const notifyStatusUpdate = (statusUpdate) => {
if (statusUpdate?.extruder?.temperature !== undefined) {
setCurrentTemperature(statusUpdate.extruder.temperature)
}
if (statusUpdate?.extruder?.target !== undefined) {
setTargetTemperature(statusUpdate.extruder.target)
}
if (
statusUpdate?.['filament_switch_sensor fsensor']
?.filament_detected !== undefined
) {
setFilamentSensorDetected(
Boolean(
statusUpdate['filament_switch_sensor fsensor'].filament_detected
)
)
}
console.log(statusUpdate)
}
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
socket.on('notify_status_update', notifyStatusUpdate)
return () => {
socket.off('notify_status_update', notifyStatusUpdate)
socket.emit('printer.objects.unsubscribe', params)
}
}
}, [socket, loadFilamentStockFormValues.printer])
React.useEffect(() => {
loadFilamentStockForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(filamentSensorDetected))
.catch(() => setNextEnabled(false))
}, [
loadFilamentStockForm,
loadFilamentStockFormUpdateValues,
filamentSensorDetected
])
useEffect(() => {
if (
filamentSensorDetected == true &&
currentTemperature >= targetTemperature
) {
setNextEnabled(filamentSensorDetected)
if (currentStep == 0) {
setCurrentStep(1)
}
} else if (filamentSensorDetected == false) {
setCurrentStep(0)
}
}, [
filamentSensorDetected,
targetTemperature,
currentTemperature,
currentStep
])
const summaryItems = [
{
key: 'filamentStock',
label: 'Stock',
children: loadFilamentStockFormValues.filamentStock ? (
<FilamentStockDisplay
filamentStock={loadFilamentStockFormValues.filamentStock}
/>
) : (
'n/a'
)
},
{
key: 'printer',
label: 'Printer',
children: loadFilamentStockFormValues.printer ? (
<PrinterState printer={loadFilamentStockFormValues.printer} />
) : (
'n/a>'
)
}
]
React.useEffect(() => {
if (reset) {
loadFilamentStockForm.resetFields()
}
}, [reset, loadFilamentStockForm])
const handleLoadFilamentStock = async () => {
setLoadFilamentStockLoading(true)
try {
// Set the extruder temperature
await socket.emit('printer.filamentstock.load', {
printerId: loadFilamentStockFormValues.printer._id,
filamentStockId: loadFilamentStockFormValues.filamentStock._id
})
onOk()
} finally {
setLoadFilamentStockLoading(false)
}
}
const steps = [
{
title: 'Preheat',
key: 'preHeat',
content: (
<Flex vertical gap={'middle'} style={{ paddingBottom: 16 }}>
<Form.Item
label='Printer'
name='printer'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please select a printer'
}
]}
>
<PrinterSelect checkable={false} />
</Form.Item>
{targetTemperature == 0 ? (
<Alert
message={'Heat the extruder to begin loading filament.'}
type='info'
showIcon
/>
) : null}
{targetTemperature > 0 && currentTemperature < targetTemperature ? (
<Alert
message={'Heating extruder...'}
type='error'
showIcon
icon={<LoadingOutlined />}
/>
) : null}
{targetTemperature > 0 &&
currentTemperature >= targetTemperature &&
filamentSensorDetected == false ? (
<Alert
message={'Insert filament to continue'}
type='success'
showIcon
/>
) : null}
{loadFilamentStockFormValues.printer ? (
<PrinterTemperaturePanel
showHeatedBed={false}
showMoreInfo={false}
printerId={loadFilamentStockFormValues.printer._id}
shouldUnsubscribe={false}
/>
) : null}
</Flex>
)
},
{
title: 'Required',
key: 'required',
content: (
<>
<Form.Item
label='Stock:'
name='filamentStock'
rules={[
{
required: true,
message: 'Please enter a G Code File.'
}
]}
>
<FilamentStockSelect />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
</Form.Item>
)
}
]
return (
<Flex gap={'middle'}>
<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 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
Load Filament Stock
</Title>
<Form
name='basic'
autoComplete='off'
form={loadFilamentStockForm}
onFinish={handleLoadFilamentStock}
onValuesChange={(changedValues) =>
setLoadFilamentStockFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialLoadFilamentStockForm}
>
<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'
loading={loadFilamentStockLoading}
onClick={() => {
loadFilamentStockForm.submit()
}}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}
export default LoadFilamentStock

View File

@ -0,0 +1,236 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import axios from 'axios'
import {
Form,
InputNumber,
Button,
message,
Typography,
Flex,
Steps,
Divider,
Descriptions,
Badge
} from 'antd'
import FilamentSelect from '../../common/FilamentSelect'
const { Title } = Typography
const initialNewFilamentStockForm = {
filament: null,
startingGrossWeight: 0
}
const NewFilamentStock = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newFilamentStockLoading, setNewFilamentStockLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newFilamentStockForm] = Form.useForm()
const [newFilamentStockFormValues, setNewFilamentStockFormValues] = useState(
initialNewFilamentStockForm
)
const newFilamentStockFormUpdateValues = Form.useWatch(
[],
newFilamentStockForm
)
React.useEffect(() => {
newFilamentStockForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newFilamentStockForm, newFilamentStockFormUpdateValues])
const summaryItems = [
{
key: 'filament',
label: 'Filament',
children: (
<Badge
color={newFilamentStockFormValues?.filament?.color}
text={newFilamentStockFormValues?.filament?.name}
/>
)
},
{
key: 'emptySpoolWeight',
label: 'Empty Spool Weight',
children: newFilamentStockFormValues?.filament?.emptySpoolWeight + 'g'
},
{
key: 'startingGrossWeight',
label: 'Starting Gross Weight',
children: newFilamentStockFormValues.startingGrossWeight + 'g'
},
{
key: 'startingNetWeight',
label: 'Starting Net Weight',
children:
newFilamentStockFormValues.startingGrossWeight -
newFilamentStockFormValues?.filament?.emptySpoolWeight +
'g'
}
]
React.useEffect(() => {
if (reset) {
newFilamentStockForm.resetFields()
}
}, [reset, newFilamentStockForm])
const handleNewFilamentStock = async () => {
setNewFilamentStockLoading(true)
try {
await axios.post(
`http://localhost:8080/filamentstocks`,
newFilamentStockFormValues,
{
withCredentials: true // Important for including cookies
}
)
onOk()
} catch (error) {
messageApi.error('Error creating new filament stock: ' + error.message)
} finally {
setNewFilamentStockLoading(false)
}
}
const steps = [
{
title: 'Details',
key: 'details',
content: (
<>
<Form.Item
label='Filament'
name='filament'
rules={[
{
required: true,
message: 'Please enter a filament.'
}
]}
>
<FilamentSelect />
</Form.Item>
<Form.Item
label='Gross Weight'
name='startingGrossWeight'
rules={[
{
required: true,
message: 'Please enter an spool weight'
}
]}
>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return ''
return `${value}`
}}
step={0.01}
style={{ width: '100%' }}
addonAfter='g'
/>
</Form.Item>
</>
)
},
{
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 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New Filament Stock
</Title>
<Form
name='basic'
autoComplete='off'
form={newFilamentStockForm}
onFinish={handleNewFilamentStock}
onValuesChange={(changedValues) =>
setNewFilamentStockFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewFilamentStockForm}
>
<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={newFilamentStockLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}
NewFilamentStock.propTypes = {
reset: PropTypes.bool.isRequired,
onOk: PropTypes.func.isRequired
}
export default NewFilamentStock

View File

@ -0,0 +1,265 @@
import React, { useState, useContext, useEffect } from 'react'
import { Form, Button, Typography, Flex, Steps, Divider, Alert } from 'antd'
import PropTypes from 'prop-types'
import { SocketContext } from '../../context/SocketContext'
import PrinterSelect from '../../common/PrinterSelect'
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel'
import { LoadingOutlined } from '@ant-design/icons'
const { Title } = Typography
const UnloadFilamentStock = ({ onOk, reset, printer = null }) => {
UnloadFilamentStock.propTypes = {
onOk: PropTypes.func.isRequired,
reset: PropTypes.bool.isRequired,
printer: PropTypes.object
}
const { socket } = useContext(SocketContext)
const initialUnloadFilamentStockForm = {
printer: printer
}
const [unloadFilamentStockLoading, setUnloadFilamentStockLoading] =
useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [currentTemperature, setCurrentTemperature] = useState(-1)
const [targetTemperature, setTargetTemperature] = useState(0)
const [filamentSensorDetected, setFilamentSensorDetected] = useState(true)
const [unloadFilamentStockForm] = Form.useForm()
const [unloadFilamentStockFormValues, setUnloadFilamentStockFormValues] =
useState(initialUnloadFilamentStockForm)
// Add websocket temperature monitoring
useEffect(() => {
if (unloadFilamentStockFormValues.printer) {
const params = {
printerId: unloadFilamentStockFormValues.printer._id,
objects: {
extruder: null,
'filament_switch_sensor fsensor': null
}
}
const notifyStatusUpdate = (statusUpdate) => {
if (statusUpdate?.extruder?.temperature !== undefined) {
setCurrentTemperature(statusUpdate.extruder.temperature)
}
if (statusUpdate?.extruder?.target !== undefined) {
setTargetTemperature(statusUpdate.extruder.target)
}
if (
statusUpdate?.['filament_switch_sensor fsensor']
?.filament_detected !== undefined
) {
setFilamentSensorDetected(
Boolean(
statusUpdate['filament_switch_sensor fsensor'].filament_detected
)
)
}
}
socket.emit('printer.objects.subscribe', params)
socket.emit('printer.objects.query', params)
socket.on('notify_status_update', notifyStatusUpdate)
return () => {
socket.off('notify_status_update', notifyStatusUpdate)
socket.emit('printer.objects.unsubscribe', params)
}
}
}, [socket, unloadFilamentStockFormValues.printer])
React.useEffect(() => {
if (reset) {
unloadFilamentStockForm.resetFields()
}
}, [reset, unloadFilamentStockForm])
React.useEffect(() => {
unloadFilamentStockForm
.validateFields({
validateOnly: true
})
.then(() => {
// Only enable next if we have a printer selected, we're not loading, and we've reached target temperature
setNextEnabled(
Boolean(unloadFilamentStockFormValues.printer) &&
!unloadFilamentStockLoading &&
currentTemperature > targetTemperature
)
})
.catch(() => setNextEnabled(false))
}, [
unloadFilamentStockForm,
unloadFilamentStockFormValues,
unloadFilamentStockLoading,
currentTemperature,
targetTemperature
])
const handleUnloadFilamentStock = async () => {
setUnloadFilamentStockLoading(true)
// Send G-code to retract the filament
await socket.emit('printer.gcode.script', {
printerId: unloadFilamentStockFormValues.printer._id,
script: `_CLIENT_LINEAR_MOVE E=-200 F=1000`
})
//setUnloadFilamentStockLoading(false)
}
useEffect(() => {
if (unloadFilamentStockLoading == true && filamentSensorDetected == false) {
setUnloadFilamentStockLoading(false)
onOk()
}
}, [unloadFilamentStockLoading, filamentSensorDetected, onOk])
const steps = [
{
title: 'Preheat',
key: 'preHeat',
content: (
<Flex vertical gap={'middle'} style={{ paddingBottom: 16 }}>
<Form.Item
label='Printer'
name='printer'
style={{ margin: 0 }}
rules={[
{
required: true,
message: 'Please select a printer'
}
]}
>
<PrinterSelect checkable={false} />
</Form.Item>
{unloadFilamentStockLoading == false ? (
<>
{targetTemperature == 0 ? (
<Alert
message={'Heat the extruder to begin unloading filament.'}
type='info'
showIcon
/>
) : null}
{targetTemperature > 0 &&
currentTemperature < targetTemperature ? (
<Alert
message={'Heating extruder...'}
type='error'
showIcon
icon={<LoadingOutlined />}
/>
) : null}
{targetTemperature > 0 &&
currentTemperature >= targetTemperature &&
filamentSensorDetected ? (
<Alert
message={'Ready to unload filament stock.'}
type='success'
showIcon
/>
) : null}
</>
) : (
<Alert
message={'Unloading filament stock...'}
type='info'
showIcon
icon={<LoadingOutlined />}
/>
)}
{unloadFilamentStockFormValues.printer ? (
<PrinterTemperaturePanel
showHeatedBed={false}
showMoreInfo={false}
printerId={unloadFilamentStockFormValues.printer._id}
/>
) : null}
</Flex>
)
}
]
return (
<Flex gap={'middle'}>
<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 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
Unload Filament Stock
</Title>
<Form
name='unloadFilamentStock'
autoComplete='off'
form={unloadFilamentStockForm}
onFinish={handleUnloadFilamentStock}
onValuesChange={(changedValues) =>
setUnloadFilamentStockFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialUnloadFilamentStockForm}
>
<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'
loading={unloadFilamentStockLoading}
onClick={() => {
unloadFilamentStockForm.submit()
}}
>
{unloadFilamentStockLoading ? 'Unloading...' : 'Unload'}
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}
export default UnloadFilamentStock

View File

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

View File

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

View File

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

View File

@ -13,7 +13,8 @@ import {
Space,
Modal,
message,
Dropdown
Dropdown,
Typography
} from 'antd'
import { createStyles } from 'antd-style'
import {
@ -29,6 +30,8 @@ import NewFilament from './Filaments/NewFilament'
import IdText from '../common/IdText'
import FilamentIcon from '../../Icons/FilamentIcon'
const { Text } = Typography
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
@ -99,7 +102,7 @@ const Filaments = () => {
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/filaments/info?filamentId=${id}`)
navigate(`/dashboard/management/filaments/info?filamentId=${id}`)
}
}
}
@ -131,9 +134,12 @@ const Filaments = () => {
},
{
title: 'Vendor',
dataIndex: 'brand',
key: 'brand',
width: 200
dataIndex: 'vendor',
key: 'vendor',
width: 200,
render: (vendor) => {
return vendor.name
}
},
{
title: 'Material',
@ -142,12 +148,12 @@ const Filaments = () => {
key: 'material'
},
{
title: 'Price',
dataIndex: 'price',
title: 'Cost',
dataIndex: 'cost',
width: 120,
key: 'price',
render: (price) => {
return '£' + price + ' per kg'
key: 'cost',
render: (cost) => {
return <Text ellipsis>{'£' + cost + ' per kg'}</Text>
}
},
{
@ -173,6 +179,20 @@ const Filaments = () => {
}
}
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (updatedAt) => {
if (updatedAt) {
const formattedDate = moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
@ -184,7 +204,9 @@ const Filaments = () => {
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/management/filaments/info?filamentId=${record._id}`)
navigate(
`/dashboard/management/filaments/info?filamentId=${record._id}`
)
}
/>
<Dropdown menu={getFilamentActionItems(record._id)}>
@ -240,6 +262,7 @@ const Filaments = () => {
</Flex>
<Modal
open={newFilamentOpen}
styles={{ content: { paddingBottom: '24px' } }}
footer={null}
width={700}
onCancel={() => {
@ -250,6 +273,7 @@ const Filaments = () => {
<NewFilament
onOk={() => {
setNewFilamentOpen(false)
messageApi.success('New filament created successfully.')
fetchFilamentsData()
}}
reset={newFilamentOpen}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios'
import {
Descriptions,
@ -14,7 +14,8 @@ import {
Input,
InputNumber,
ColorPicker,
Select
Select,
Modal
} from 'antd'
import {
LoadingOutlined,
@ -22,10 +23,12 @@ import {
EditOutlined,
CheckOutlined,
CloseOutlined,
ExportOutlined
ExportOutlined,
DeleteOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import moment from 'moment'
import VendorSelect from '../../common/VendorSelect'
const { Title, Link } = Typography
@ -34,11 +37,13 @@ const FilamentInfo = () => {
const [fetchLoading, setFetchLoading] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const filamentId = new URLSearchParams(location.search).get('filamentId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const navigate = useNavigate()
useEffect(() => {
if (filamentId) {
@ -52,7 +57,7 @@ const FilamentInfo = () => {
name: filamentData.name || '',
brand: filamentData.brand || '',
type: filamentData.type || '',
price: filamentData.price || null,
cost: filamentData.cost || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
@ -96,7 +101,7 @@ const FilamentInfo = () => {
name: filamentData.name || '',
brand: filamentData.brand || '',
type: filamentData.type || '',
price: filamentData.price || null,
cost: filamentData.cost || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
@ -117,9 +122,9 @@ const FilamentInfo = () => {
`http://localhost:8080/filaments/${filamentId}`,
{
name: values.name,
brand: values.brand,
vendor: values.vendor,
type: values.type,
price: values.price,
cost: values.cost,
color: values.color,
diameter: values.diameter,
density: values.density,
@ -151,6 +156,23 @@ const FilamentInfo = () => {
}
}
const handleDelete = async () => {
try {
setLoading(true)
await axios.delete(`http://localhost:8080/filaments/${filamentId}`, {
withCredentials: true
})
messageApi.success('Filament deleted successfully')
navigate('/dashboard/filaments')
} catch (err) {
console.error('Failed to delete filament:', err)
messageApi.error('Failed to delete filament')
} finally {
setLoading(false)
setIsDeleteModalOpen(false)
}
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
@ -176,6 +198,21 @@ const FilamentInfo = () => {
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
{contextHolder}
<Modal
title='Delete Filament'
open={isDeleteModalOpen}
onOk={handleDelete}
onCancel={() => setIsDeleteModalOpen(false)}
okText='Yes, Delete'
cancelText='No, Cancel'
okType='danger'
confirmLoading={loading}
>
<p>
Are you sure you want to delete this filament? This action cannot be
undone.
</p>
</Modal>
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
@ -185,6 +222,12 @@ const FilamentInfo = () => {
Filament Information
</Title>
<Space>
<Button
icon={<DeleteOutlined />}
danger
onClick={() => setIsDeleteModalOpen(true)}
loading={loading}
/>
{isEditing ? (
<>
<Button
@ -200,7 +243,7 @@ const FilamentInfo = () => {
></Button>
</>
) : (
<Button icon={<EditOutlined />} onClick={startEditing}></Button>
<Button icon={<EditOutlined />} onClick={startEditing} />
)}
</Space>
</Flex>
@ -210,9 +253,9 @@ const FilamentInfo = () => {
layout='vertical'
initialValues={{
name: filamentData.name || '',
brand: filamentData.brand || '',
vendor: filamentData.vendor || { id: null, name: '' },
type: filamentData.type || '',
price: filamentData.price || null,
cost: filamentData.cost || null,
color: filamentData.color || '#000000',
diameter: filamentData.diameter || null,
density: filamentData.density || null,
@ -229,15 +272,8 @@ const FilamentInfo = () => {
'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 label='Created At'>
{moment(filamentData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
{/* Editable fields */}
@ -258,23 +294,32 @@ const FilamentInfo = () => {
)}
</Descriptions.Item>
<Descriptions.Item label='Brand'>
<Descriptions.Item label='Updated At'>
{moment(filamentData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='brand'
rules={[
{ required: true, message: 'Please enter a brand' },
{ max: 100, message: 'Brand cannot exceed 100 characters' }
]}
name='vendor'
rules={[{ required: true, message: 'Please enter a vendor' }]}
style={{ margin: 0 }}
>
<Input placeholder='Enter brand' />
<VendorSelect />
</Form.Item>
) : (
filamentData.brand || 'n/a'
filamentData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={filamentData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item label='Material'>
{isEditing ? (
<Form.Item
@ -298,15 +343,15 @@ const FilamentInfo = () => {
)}
</Descriptions.Item>
<Descriptions.Item label='Price'>
<Descriptions.Item label='Cost'>
{isEditing ? (
<Form.Item
name='price'
name='cost'
style={{ margin: 0 }}
rules={[{ required: true, message: 'Please enter a price' }]}
rules={[{ required: true, message: 'Please enter a cost' }]}
>
<InputNumber
placeholder='Enter price'
placeholder='Enter cost'
addonBefore='£'
addonAfter='per kg'
min={0}
@ -314,8 +359,8 @@ const FilamentInfo = () => {
style={{ width: '100%' }}
/>
</Form.Item>
) : filamentData.price ? (
`£${filamentData.price} per kg`
) : filamentData.cost ? (
`£${filamentData.cost} per kg`
) : (
'n/a'
)}
@ -323,8 +368,14 @@ const FilamentInfo = () => {
<Descriptions.Item label='Colour'>
{isEditing ? (
<Form.Item name='color' style={{ margin: 0 }}>
<ColorPicker format='hex' showText />
<Form.Item
name='color'
style={{ margin: 0 }}
getValueFromEvent={(color) => {
return '#' + color.toHex()
}}
>
<ColorPicker showText disabledAlpha />
</Form.Item>
) : filamentData.color ? (
<Badge color={filamentData.color} text={filamentData.color} />

View File

@ -11,8 +11,6 @@ import {
Select,
Flex,
Steps,
Col,
Row,
Divider,
ColorPicker,
Upload,
@ -20,14 +18,15 @@ import {
Badge
} from 'antd'
import { UploadOutlined, LinkOutlined } from '@ant-design/icons'
import VendorSelect from '../../common/VendorSelect'
const { Title, Text } = Typography
const { Title } = Typography
const initialNewFilamentForm = {
name: '',
brand: '',
vendor: { id: null, name: '' },
type: '',
price: 0,
cost: 0,
color: '#FFFFFF',
diameter: '1.75',
image: null,
@ -67,9 +66,9 @@ const NewFilament = ({ onOk, reset }) => {
children: newFilamentFormValues.name
},
{
key: 'brand',
label: 'Brand',
children: newFilamentFormValues.brand
key: 'vendor',
label: 'Vendor',
children: newFilamentFormValues.vendor.name
},
{
key: 'type',
@ -77,9 +76,9 @@ const NewFilament = ({ onOk, reset }) => {
children: newFilamentFormValues.type
},
{
key: 'price',
label: 'Price',
children: '£' + newFilamentFormValues.price + ' per kg'
key: 'cost',
label: 'Cost',
children: '£' + newFilamentFormValues.cost + ' per kg'
},
{
key: 'color',
@ -136,7 +135,6 @@ const NewFilament = ({ onOk, reset }) => {
withCredentials: true // Important for including cookies
}
)
messageApi.success('New filament created successfully.')
onOk()
} catch (error) {
messageApi.error('Error creating new filament: ' + error.message)
@ -190,16 +188,16 @@ const NewFilament = ({ onOk, reset }) => {
<Input />
</Form.Item>
<Form.Item
label='Brand'
name='brand'
label='Vendor'
name='vendor'
rules={[
{
required: true,
message: 'Please enter a brand.'
message: 'Please enter a vendor.'
}
]}
>
<Input />
<VendorSelect />
</Form.Item>
<Form.Item
label='Material'
@ -216,21 +214,18 @@ const NewFilament = ({ onOk, reset }) => {
</Select>
</Form.Item>
<Form.Item
label='Price'
name='price'
label='Cost'
name='cost'
rules={[
{
required: true,
message: 'Please enter a price.'
message: 'Please enter a cost.'
}
]}
>
<InputNumber
controls={false}
formatter={(value) => {
if (!value) return '£'
return `£${value}`
}}
addonBefore='£'
step={0.01}
style={{ width: '100%' }}
addonAfter='per kg'
@ -302,10 +297,6 @@ const NewFilament = ({ onOk, reset }) => {
key: 'optional',
content: (
<>
<Form.Item>
<Text>Optional information:</Text>
</Form.Item>
<Form.Item
label='Colour'
name='color'
@ -353,74 +344,74 @@ const NewFilament = ({ onOk, reset }) => {
]
return (
<Row>
<Flex gap='middle'>
{contextHolder}
<Col flex={1}>
<div style={{ minWidth: '160px' }}>
<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}
</div>
<Flex justify={'end'}>
<Divider type='vertical' style={{ height: 'unset' }} />
<Flex vertical style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New Filament
</Title>
<Form
name='basic'
autoComplete='off'
form={newFilamentForm}
onFinish={handleNewFilament}
onValuesChange={(changedValues) =>
setNewFilamentFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewFilamentForm}
>
<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
style={{
margin: '0 8px'
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
}}
onClick={() => setCurrentStep(currentStep - 1)}
disabled={!(currentStep > 0)}
>
Previous
Next
</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>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newFilamentLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}

View File

@ -0,0 +1,288 @@
// src/materials.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,
message,
Dropdown,
Spin
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
ReloadOutlined,
InfoCircleOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import NewMaterial from './Materials/NewMaterial'
import IdText from '../common/IdText'
import MaterialIcon from '../../Icons/MaterialIcon'
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 Materials = () => {
const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate()
const { styles } = useStyle()
const [materialsData, setMaterialsData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [newMaterialOpen, setNewMaterialOpen] = useState(false)
const { authenticated } = useContext(AuthContext)
const fetchMaterialsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get('http://localhost:8080/materials', {
params: {
page: pageNum,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
if (append) {
setMaterialsData((prev) => [...prev, ...newData])
} else {
setMaterialsData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
if (error.response) {
messageApi.error(
'Error updating material details:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi]
)
useEffect(() => {
if (authenticated) {
fetchMaterialsData()
}
}, [authenticated, fetchMaterialsData])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchMaterialsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchMaterialsData]
)
const getMaterialActionItems = (id) => {
return {
items: [
{
label: 'Info',
key: 'info',
icon: <InfoCircleOutlined />
}
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/dashboard/management/materials/info?materialId=${id}`)
}
}
}
}
const columns = [
{
title: '',
dataIndex: '',
key: 'icon',
width: 40,
fixed: 'left',
render: () => <MaterialIcon></MaterialIcon>
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left'
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'material'} longId={false} />
},
{
title: 'Category',
dataIndex: 'category',
key: 'category',
width: 150
},
{
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(
`/dashboard/management/materials/info?materialId=${record._id}`
)
}
/>
<Dropdown menu={getMaterialActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
]
const actionItems = {
items: [
{
label: 'New Material',
key: 'newMaterial',
icon: <PlusOutlined />
},
{ type: 'divider' },
{
label: 'Reload List',
key: 'reloadList',
icon: <ReloadOutlined />
}
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchMaterialsData()
} else if (key === 'newMaterial') {
setNewMaterialOpen(true)
}
}
}
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Table
dataSource={materialsData}
className={styles.customTable}
columns={columns}
pagination={false}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onScroll={handleScroll}
/>
{lazyLoading && (
<div style={{ textAlign: 'center', padding: '10px' }}>
<Spin indicator={<LoadingOutlined spin />} />
</div>
)}
</Flex>
<Modal
open={newMaterialOpen}
styles={{ content: { paddingBottom: '24px' } }}
footer={null}
width={700}
onCancel={() => {
setNewMaterialOpen(false)
}}
>
<NewMaterial
onSuccess={() => {
setNewMaterialOpen(false)
fetchMaterialsData()
}}
/>
</Modal>
</>
)
}
export default Materials

View File

@ -0,0 +1,277 @@
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import axios from 'axios'
import {
Form,
Input,
Button,
message,
Select,
Flex,
Steps,
Divider,
Upload,
Descriptions
} from 'antd'
import { UploadOutlined } from '@ant-design/icons'
import VendorSelect from '../../common/VendorSelect'
const initialNewMaterialForm = {
name: '',
vendor: { id: null, name: '' },
category: '',
image: null,
url: '',
barcode: ''
}
const NewMaterial = ({ onSuccess }) => {
const [messageApi, contextHolder] = message.useMessage()
const [newMaterialLoading, setNewMaterialLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [nextEnabled, setNextEnabled] = useState(false)
const [newMaterialForm] = Form.useForm()
const [newMaterialFormValues, setNewMaterialFormValues] = useState(
initialNewMaterialForm
)
const [imageList, setImageList] = useState([])
const newMaterialFormUpdateValues = Form.useWatch([], newMaterialForm)
React.useEffect(() => {
newMaterialForm
.validateFields({
validateOnly: true
})
.then(() => setNextEnabled(true))
.catch(() => setNextEnabled(false))
}, [newMaterialForm, newMaterialFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newMaterialFormValues.name
},
{
key: 'vendor',
label: 'Vendor',
children: newMaterialFormValues.vendor.name
},
{
key: 'category',
label: 'Category',
children: newMaterialFormValues.category
},
{
key: 'image',
label: 'Image',
children: newMaterialFormValues.image && (
<img
src={newMaterialFormValues.image}
style={{ width: 128 }}
alt='Material'
/>
)
},
{
key: 'url',
label: 'URL',
children: newMaterialFormValues.url
},
{
key: 'barcode',
label: 'Barcode',
children: newMaterialFormValues.barcode
}
]
const handleNewMaterial = async () => {
setNewMaterialLoading(true)
try {
await axios.post(
`http://localhost:8080/materials`,
newMaterialFormValues,
{
withCredentials: true
}
)
messageApi.success('New material created successfully.')
onSuccess()
} catch (error) {
messageApi.error('Error creating new material: ' + error.message)
} finally {
setNewMaterialLoading(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)
newMaterialForm.setFieldsValue({ image: '' })
return
}
const base64 = await getBase64(file)
setNewMaterialFormValues((prevValues) => ({
...prevValues,
image: base64
}))
fileList[0].name = 'Material Image'
setImageList(fileList)
newMaterialForm.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='Vendor'
name='vendor'
rules={[
{
required: true,
message: 'Please select a vendor.'
}
]}
>
<VendorSelect />
</Form.Item>
<Form.Item
label='Category'
name='category'
rules={[{ required: true, message: 'Please select a category' }]}
>
<Select>
<Select.Option value='Raw Material'>Raw Material</Select.Option>
<Select.Option value='Component'>Component</Select.Option>
<Select.Option value='Consumable'>Consumable</Select.Option>
<Select.Option value='Tool'>Tool</Select.Option>
<Select.Option value='Packaging'>Packaging</Select.Option>
</Select>
</Form.Item>
</>
)
},
{
title: 'Additional Info',
key: 'additional',
content: (
<>
<Form.Item label='Image' name='image'>
<Upload
listType='picture'
maxCount={1}
fileList={imageList}
onChange={handleImageUpload}
beforeUpload={() => false}
>
<Button icon={<UploadOutlined />}>Upload Image</Button>
</Upload>
</Form.Item>
<Form.Item label='URL' name='url'>
<Input />
</Form.Item>
<Form.Item label='Barcode' name='barcode'>
<Input />
</Form.Item>
</>
)
},
{
title: 'Summary',
key: 'summary',
content: (
<>
<Descriptions
title='Material Details'
bordered
column={1}
items={summaryItems}
/>
</>
)
}
]
return (
<>
{contextHolder}
<Form
form={newMaterialForm}
layout='vertical'
onValuesChange={(changedValues) => {
setNewMaterialFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}}
>
<Steps
current={currentStep}
items={steps.map((item) => ({ title: item.title }))}
style={{ marginBottom: 24 }}
/>
<div style={{ minHeight: 200 }}>{steps[currentStep].content}</div>
<Divider />
<Flex justify='space-between'>
<Button
disabled={currentStep === 0}
onClick={() => setCurrentStep((prev) => prev - 1)}
>
Previous
</Button>
{currentStep < steps.length - 1 ? (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => setCurrentStep((prev) => prev + 1)}
>
Next
</Button>
) : (
<Button
type='primary'
loading={newMaterialLoading}
onClick={handleNewMaterial}
>
Create Material
</Button>
)}
</Flex>
</Form>
</>
)
}
NewMaterial.propTypes = {
onSuccess: PropTypes.func.isRequired
}
export default NewMaterial

View File

@ -5,22 +5,39 @@ 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 {
Table,
Button,
Flex,
Space,
Modal,
Dropdown,
message,
Typography,
Spin,
Checkbox,
Popover,
Input
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
DownloadOutlined,
ReloadOutlined,
InfoCircleOutlined
InfoCircleOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewPart from './Parts/NewPart'
import NewProduct from './Products/NewProduct'
import PartIcon from '../../Icons/PartIcon'
const { Text } = Typography
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
return {
@ -45,41 +62,201 @@ const Parts = () => {
const { styles } = useStyle()
const [partsData, setPartsData] = useState([])
const [newPartOpen, setNewPartOpen] = useState(false)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [newProductOpen, setNewProductOpen] = useState(false)
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.'
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <PartIcon></PartIcon>
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left',
render: (text) => <Text ellipsis>{text}</Text>,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toLowerCase())
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'part'} longId={false} />
},
{
title: 'Product Name',
key: 'productName',
width: 200,
render: (record) => <Text>{record.product.name}</Text>,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'product name'
}),
onFilter: (value, record) =>
record.product.name.toLowerCase().includes(value.toLowerCase())
},
{
title: 'Product ID',
key: 'productId',
width: 165,
render: (record) => (
<IdText
id={record.product._id}
type={'product'}
longId={false}
showHyperlink={true}
/>
)
},
{
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'
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (updatedAt) => {
if (updatedAt) {
const formattedDate = moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
},
sorter: true,
defaultSortOrder: 'descend'
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(
`/dashboard/management/parts/info?partId=${record._id}`
)
}
/>
<Dropdown menu={getPartActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
}, [messageApi])
]
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [columnVisibility, setColumnVisibility] = useState(
columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
)
const { authenticated } = useContext(AuthContext)
const fetchPartsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get('http://localhost:8080/parts', {
params: {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setPartsData((prev) => [...prev, ...newData])
} else {
setPartsData(newData)
}
setLoading(false)
setLazyLoading(false)
} 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.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
useEffect(() => {
if (authenticated) {
@ -87,6 +264,28 @@ const Parts = () => {
}
}, [authenticated, fetchPartsData])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchPartsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchPartsData]
)
const getPartActionItems = (id) => {
return {
items: [
@ -103,78 +302,53 @@ const Parts = () => {
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/parts/info?partId=${id}`)
navigate(`/dashboard/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 getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<CloseOutlined />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckOutlined />}
/>
</Space.Compact>
</div>
)
}
const actionItems = {
items: [
{
label: 'New Part',
key: 'newPart',
label: 'New Product',
key: 'newProduct',
icon: <PlusOutlined />
},
{ type: 'divider' },
@ -186,46 +360,108 @@ const Parts = () => {
],
onClick: ({ key }) => {
if (key === 'reloadList') {
fetchPartsData()
} else if (key === 'newPart') {
setNewPartOpen(true)
setPage(1)
fetchPartsData(1)
} else if (key === 'newProduct') {
setNewProductOpen(true)
}
}
}
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setPage(1)
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '')
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
setColumnVisibility((prev) => ({
...prev,
[col.key]: e.target.checked
}))
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
return (
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Flex justify={'space-between'}>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Flex>
<Table
dataSource={partsData}
columns={columns}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
onChange={handleTableChange}
showSorterTooltip={false}
/>
</Flex>
<Modal
open={newPartOpen}
open={newProductOpen}
footer={null}
width={700}
onCancel={() => {
setNewPartOpen(false)
setNewProductOpen(false)
}}
destroyOnClose
>
<NewPart
<NewProduct
onOk={() => {
setNewPartOpen(false)
fetchPartsData()
setNewProductOpen(false)
setPage(1)
fetchPartsData(1)
}}
reset={newPartOpen}
reset={newProductOpen}
/>
</Modal>
</>

View File

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

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import {
@ -11,7 +11,11 @@ import {
Card,
Flex,
Form,
Input
Input,
Checkbox,
InputNumber,
Switch,
Tag
} from 'antd'
import {
LoadingOutlined,
@ -33,15 +37,29 @@ const PartInfo = () => {
const location = useLocation()
const [messageApi, contextHolder] = message.useMessage()
const partId = new URLSearchParams(location.search).get('partId')
const [partFileObjectId, setPartFileObjectId] = useState(null)
const [marginOrPrice, setMarginOrPrice] = useState(false)
const [useGlobalPricing, setUseGlobalPricing] = useState(true)
const [partForm] = Form.useForm()
const [partFormValues, setPartFormValues] = useState({})
// Add a ref to store the object URL
const objectUrlRef = useRef(null)
// Add a ref to store the array buffer
const arrayBufferRef = useRef(null)
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
const [partFileObjectId, setPartFileObjectId] = useState(null)
const [stlLoadError, setStlLoadError] = useState(null)
useEffect(() => {
async function fetchData() {
await fetchPartDetails()
await fetchPartContent()
setTimeout(async () => {
await fetchPartContent()
}, 1000)
}
if (partId) {
fetchData()
@ -50,11 +68,21 @@ const PartInfo = () => {
useEffect(() => {
if (partData) {
form.setFieldsValue({
name: partData.name || ''
partForm.setFieldsValue({
name: partData.name || '',
price: partData.price || null,
margin: partData.margin || null,
marginOrPrice: partData.marginOrPrice,
useGlobalPricing: partData.useGlobalPricing
})
setPartFormValues(partData)
}
}, [partData, form])
}, [partData, partForm])
useEffect(() => {
setMarginOrPrice(partFormValues.marginOrPrice)
setUseGlobalPricing(partFormValues.useGlobalPricing)
}, [partFormValues])
const fetchPartDetails = async () => {
try {
@ -71,34 +99,69 @@ const PartInfo = () => {
setPartData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch Part details')
setError('Failed to fetch part details')
console.log(err)
messageApi.error('Failed to fetch Part details')
messageApi.error('Failed to fetch part details')
} finally {
setFetchLoading(false)
}
}
const fetchPartContent = async () => {
if (fetchLoading == true) {
return
}
try {
setFetchLoading(true)
// Cleanup previous object URL if it exists
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
const response = await axios.get(
`http://localhost:8080/parts/${partId}/content`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true,
responseType: 'blob'
}
)
setPartFileObjectId(URL.createObjectURL(response.data))
setError(null)
// Check file size before processing
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
if (response.data.size > MAX_FILE_SIZE) {
throw new Error(
`File size exceeds ${MAX_FILE_SIZE / (1024 * 1024)}MB limit`
)
}
// Convert blob to array buffer for better memory management
const arrayBuffer = await response.data.arrayBuffer()
// Store array buffer in ref for later cleanup
arrayBufferRef.current = arrayBuffer
// Create a new blob from the array buffer
const blob = new Blob([arrayBuffer], { type: response.data.type })
try {
// Create and store object URL
const objectUrl = URL.createObjectURL(blob)
objectUrlRef.current = objectUrl
// Update state with the new object URL
setPartFileObjectId(objectUrl)
setStlLoadError(null)
setError(null)
} catch (allocErr) {
setStlLoadError(
'Failed to load STL file: Array buffer allocation failed'
)
console.error('STL allocation error:', allocErr)
}
} catch (err) {
setError('Failed to fetch Part content')
setError('Failed to fetch part content')
console.log(err)
messageApi.error('Failed to fetch Part content')
messageApi.error('Failed to fetch part content')
} finally {
setFetchLoading(false)
}
@ -109,7 +172,7 @@ const PartInfo = () => {
}
const cancelEditing = () => {
form.setFieldsValue({
partForm.setFieldsValue({
name: partData?.name || ''
})
setIsEditing(false)
@ -117,24 +180,18 @@ const PartInfo = () => {
const updateInfo = async () => {
try {
const values = await form.validateFields()
const values = await partForm.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/parts/${partId}`,
{
name: values.name
await axios.put(`http://localhost:8080/parts/${partId}`, values, {
headers: {
'Content-Type': 'application/json'
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
withCredentials: true
})
// Update the local state with the new name
setPartData({ ...partData, name: values.name })
// Update the local state with the new values
setPartData({ ...partData, ...values })
setIsEditing(false)
messageApi.success('Part information updated successfully')
} catch (err) {
@ -204,8 +261,14 @@ const PartInfo = () => {
</Flex>
<Form
form={form}
form={partForm}
layout='vertical'
onValuesChange={(changedValues) =>
setPartFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: partData.name || ''
}}
@ -218,32 +281,131 @@ const PartInfo = () => {
'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 label='Created At'>
{moment(partData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Name' span={2}>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
rules={[
{ required: true, message: 'Please enter a part name' },
{ required: true, message: 'Please enter a product name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
]}
style={{ margin: 0 }}
>
<Input placeholder='Enter part name' />
<Input placeholder='Enter product name' />
</Form.Item>
) : (
partData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(partData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Product Name' span={1}>
{partData.product.name || 'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Product ID' span={1}>
{(
<IdText
id={partData.product._id}
type={'product'}
showHyperlink={true}
/>
) || 'n/a'}
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing && useGlobalPricing == false ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : partData.margin &&
marginOrPrice == false &&
partData.useGlobalPricing == false ? (
partData.margin + '%'
) : partData.price &&
marginOrPrice == true &&
partData.useGlobalPricing == false ? (
'£' + partData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Global Pricing'>
{isEditing ? (
<Form.Item
name='useGlobalPricing'
rules={[
{
required: true,
message: 'Please enter a global price method'
}
]}
style={{ margin: 0 }}
valuePropName='checked'
>
<Switch />
</Form.Item>
) : partData.useGlobalPricing == true ? (
<Tag color='success' icon={<CheckOutlined />}>
Yes
</Tag>
) : partData.useGlobalPricing == false ? (
<Tag icon={<CloseOutlined />}>No</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
@ -256,15 +418,34 @@ const PartInfo = () => {
</Title>
</Flex>
<Card styles={{ body: { padding: '10px' } }}>
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
{stlLoadError ? (
<div
style={{
height: '40vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5'
}}
>
<Space direction='vertical' align='center'>
<CloseOutlined style={{ fontSize: '24px', color: '#ff4d4f' }} />
<Typography.Text type='danger'>{stlLoadError}</Typography.Text>
</Space>
</div>
) : (
partFileObjectId && (
<StlViewer
url={partFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
)
)}
</Card>
</div>
)

View File

@ -5,7 +5,17 @@ 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 {
Table,
Button,
Flex,
Space,
Modal,
Dropdown,
message,
Spin,
Tag
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
@ -45,6 +55,9 @@ const Products = () => {
const { styles } = useStyle()
const [productsData, setProductsData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [newProductOpen, setNewProductOpen] = useState(false)
@ -52,34 +65,48 @@ const Products = () => {
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.'
)
const fetchProductsData = useCallback(
async (pageNum = 1, append = false) => {
try {
const response = await axios.get('http://localhost:8080/products', {
params: {
page: pageNum,
limit: 25
},
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
})
const newData = response.data
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
if (append) {
setProductsData((prev) => [...prev, ...newData])
} else {
setProductsData(newData)
}
setLoading(false)
setLazyLoading(false)
} 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.'
)
}
setLoading(false)
setLazyLoading(false)
}
}
}, [messageApi])
},
[messageApi]
)
useEffect(() => {
if (authenticated) {
@ -87,6 +114,28 @@ const Products = () => {
}
}, [authenticated, fetchProductsData])
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchProductsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchProductsData]
)
const getProductActionItems = (id) => {
return {
items: [
@ -103,7 +152,7 @@ const Products = () => {
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/products/info?productId=${id}`)
navigate(`/dashboard/management/products/info?productId=${id}`)
}
}
}
@ -134,6 +183,32 @@ const Products = () => {
width: 165,
render: (text) => <IdText id={text} type={'product'} longId={false} />
},
{
title: 'Tags',
dataIndex: 'tags',
key: 'tags',
width: 170,
render: (tags) => {
if (!tags || !Array.isArray(tags)) return 'n/a'
if (tags.length == 0) return 'n/a'
return (
<Space size={[0, 8]} wrap>
{tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
)
}
},
{
title: 'Version',
dataIndex: 'version',
key: 'version',
width: 120,
render: (text) => (text ? <Tag>{text}</Tag> : 'n/a')
},
{
title: 'Created At',
dataIndex: 'createdAt',
@ -148,6 +223,20 @@ const Products = () => {
}
}
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (updatedAt) => {
if (updatedAt) {
const formattedDate = moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
@ -159,7 +248,9 @@ const Products = () => {
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/management/products/info?productId=${record._id}`)
navigate(
`/dashboard/management/products/info?productId=${record._id}`
)
}
/>
<Dropdown menu={getProductActionItems(record._id)}>
@ -198,11 +289,16 @@ const Products = () => {
<>
<Flex vertical={'true'} gap='large'>
{contextHolder}
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Flex justify={'space-between'}>
<Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
{lazyLoading == true ? (
<Spin indicator={<LoadingOutlined />}></Spin>
) : null}
</Flex>
<Table
dataSource={productsData}
columns={columns}
@ -211,6 +307,7 @@ const Products = () => {
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onScroll={handleScroll}
/>
</Flex>
<Modal
@ -225,6 +322,7 @@ const Products = () => {
<NewProduct
onOk={() => {
setNewProductOpen(false)
messageApi.success('Product created successfully!')
fetchProductsData()
}}
reset={newProductOpen}

View File

@ -1,8 +1,7 @@
import PropTypes from 'prop-types'
import React, { useState, useContext } from 'react'
import React, { useState, useContext, useEffect, useRef } from 'react'
import axios from 'axios'
import {
Form,
Input,
Button,
message,
@ -10,38 +9,61 @@ import {
Flex,
Steps,
Divider,
Descriptions
Upload,
Descriptions,
Modal,
Progress,
Form,
Checkbox,
InputNumber
} 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'
import VendorSelect from '../../common/VendorSelect'
const { Title } = Typography
const { Dragger } = Upload
const { Title, Text } = Typography
const initialNewProductForm = {
productInfo: {},
printTimeMins: 0,
name: '',
parts: [],
vendor: null,
marginOrPrice: false,
margin: 0,
price: 0
}
//const chunkSize = 5000
const NewProduct = ({ onOk, reset }) => {
// UI state
const [messageApi, contextHolder] = message.useMessage()
const [newProductLoading, setNewProductLoading] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [newProductLoading, setNewProductLoading] = useState(false)
const [nextEnabled, setNextEnabled] = useState(false)
const [newProductForm] = Form.useForm()
const [newProductFormValues, setNewProductFormValues] = useState(
initialNewProductForm
)
const newProductFormUpdateValues = Form.useWatch([], newProductForm)
// Combined parts and files state
const [parts, setParts] = useState([])
const [fileUrls, setFileUrls] = useState({})
const [uploadProgress, setUploadProgress] = useState({})
// Preview state
const [previewVisible, setPreviewVisible] = useState(false)
const [previewFile, setPreviewFile] = useState(null)
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
const previewTimerRef = useRef(null)
const [marginOrPrice, setMarginOrPrice] = useState(false)
const { token, authenticated } = useContext(AuthContext)
React.useEffect(() => {
useEffect(() => {
newProductForm
.validateFields({
validateOnly: true
@ -50,90 +72,325 @@ const NewProduct = ({ onOk, reset }) => {
.catch(() => setNextEnabled(false))
}, [newProductForm, newProductFormUpdateValues])
const summaryItems = [
{
key: 'name',
label: 'Name',
children: newProductFormValues.name
}
]
React.useEffect(() => {
useEffect(() => {
if (reset) {
newProductForm.resetFields()
}
}, [reset, newProductForm])
const handleNewProduct = async () => {
if (!authenticated) {
return
useEffect(() => {
setMarginOrPrice(newProductFormValues.marginOrPrice)
}, [newProductFormValues])
// Effect: Cleanup file URLs on unmount
useEffect(() => {
return () => {
Object.values(fileUrls).forEach(URL.revokeObjectURL)
if (previewTimerRef.current) {
clearTimeout(previewTimerRef.current)
}
}
}, [fileUrls])
useEffect(() => {
setNewProductFormValues((prev) => ({ ...prev, parts: parts }))
}, [parts, setNewProductFormValues])
// File handlers
const handleFileAdd = (file) => {
const objectUrl = URL.createObjectURL(file)
const defaultName = file.name.replace(/\.[^/.]+$/, '')
setParts((prev) => [
{
name: defaultName,
file,
uid: file.uid
},
...prev
])
setFileUrls((prev) => ({ ...prev, [file.uid]: objectUrl }))
setUploadProgress((prev) => ({ ...prev, [file.uid]: 0 }))
return false // Prevent default upload
}
const handleFileRemove = (index) => {
setParts((prev) => {
const newParts = [...prev]
const removedPart = newParts[index]
newParts.splice(index, 1)
// Cleanup URL and progress
if (removedPart && fileUrls[removedPart.uid]) {
URL.revokeObjectURL(fileUrls[removedPart.uid])
setFileUrls((urls) => {
const newUrls = { ...urls }
delete newUrls[removedPart.uid]
return newUrls
})
setUploadProgress((progress) => {
const newProgress = { ...progress }
delete newProgress[removedPart.uid]
return newProgress
})
}
return newParts
})
}
const handleNameChange = (index, newName) => {
setParts((prev) => {
const newParts = [...prev]
newParts[index] = { ...newParts[index], name: newName }
return newParts
})
}
const handlePreview = (file) => {
setPreviewFile(file)
setPreviewVisible(true)
setIsPreviewLoading(true)
if (previewTimerRef.current) {
clearTimeout(previewTimerRef.current)
}
previewTimerRef.current = setTimeout(() => {
setIsPreviewLoading(false)
}, 300)
}
const handleNewProduct = async () => {
setNewProductLoading(true)
try {
await axios.post(`http://localhost:8080/products`, newProductFormValues, {
headers: {
Authorization: `Bearer ${token}`
const result = await axios.post(
`http://localhost:8080/products`,
newProductFormValues,
{
withCredentials: true // Important for including cookies
}
})
messageApi.success(`Product created successfully.`)
)
await uploadParts(result.data.parts)
onOk()
} catch (error) {
messageApi.error('Error creating new product file: ' + error.message)
messageApi.error('Error creating new product: ' + error.message)
} finally {
setNewProductLoading(false)
}
}
const steps = [
{
title: 'Parts',
key: 'parts',
content: (
<>
// Submit handler
const uploadParts = async (partIds) => {
if (!authenticated) return
try {
// Upload files sequentially for each part
for (let i = 0; i < parts.length; i++) {
const formData = new FormData()
formData.append('partFile', parts[i].file)
await axios.post(
`http://localhost:8080/parts/${partIds[i]}/content`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`
},
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
setUploadProgress((prev) => ({
...prev,
[parts[i].uid]: percentCompleted
}))
}
}
)
}
} catch (error) {
messageApi.error('Error creating product: ' + error.message)
}
}
// Step Contents
const uploadStep = (
<Flex gap='middle' vertical>
{parts.length != 0 ? (
<div style={{ maxHeight: '200px', overflowY: 'scroll' }}>
<Flex vertical gap='small'>
{parts.map((part, index) => (
<Flex key={part.uid} gap='small' align='center'>
<Input
placeholder='Part name'
value={part.name}
onChange={(e) => handleNameChange(index, e.target.value)}
style={{ flex: 1 }}
/>
<Button
icon={<EyeOutlined />}
onClick={() => handlePreview(part.file)}
/>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleFileRemove(index)}
/>
</Flex>
))}
</Flex>
</div>
) : null}
<Dragger
name='parts'
multiple
fileList={[]}
showUploadList={false}
beforeUpload={handleFileAdd}
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>
</Flex>
)
const detailsStep = (
<>
<Form.Item
label='Name'
name='name'
rules={[
{
required: true,
message: 'Please enter a name.'
}
]}
>
<Input placeholder='Enter product name' />
</Form.Item>
<Form.Item
label='Vendor'
name='vendor'
rules={[
{
required: true,
message: 'Please enter a vendor.'
}
]}
>
<VendorSelect />
</Form.Item>
<Flex gap='middle'>
{marginOrPrice == false ? (
<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'
label={'Margin'}
name='margin'
style={{ flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a name.'
message: 'Please enter a margin.'
}
]}
>
<Input />
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
</Flex>
)
},
{
title: 'Summary',
key: 'done',
content: (
<>
<Form.Item>
<Descriptions column={1} items={summaryItems} size={'small'} />
) : (
<Form.Item
label={'Price'}
name='price'
style={{ flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
</>
)
}
)}
<Form.Item name='marginOrPrice' valuePropName='checked'>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
</>
)
const summaryStep = (
<Descriptions
column={1}
size='small'
items={[
{
key: 'name',
label: 'Name',
children: <Text>{newProductFormValues?.name}</Text>
},
{
key: 'vendor',
label: 'Vendor',
children: <Text>{newProductFormValues?.vendor?.name}</Text>
},
{
key: 'marginPrice',
label: !marginOrPrice ? 'Margin' : 'Price',
children: !marginOrPrice ? (
<Text>{newProductFormValues?.margin}%</Text>
) : (
<Text>£{newProductFormValues?.price}</Text>
)
},
...parts.map((part, index) => ({
key: part.uid,
label: `Part ${index + 1}`,
children: (
<Flex gap='middle' align='center'>
<span>{part.name}</span>
<Progress
percent={uploadProgress[part.uid] || 0}
size='small'
style={{ width: '120px', marginBottom: 0 }}
/>
</Flex>
)
}))
]}
/>
)
const steps = [
{ title: 'Upload Parts', content: uploadStep },
{ title: 'Details', content: detailsStep },
{ title: 'Summary', content: summaryStep }
]
return (
<Flex gap={'middle'}>
<Flex gap='middle'>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
@ -143,12 +400,13 @@ const NewProduct = ({ onOk, reset }) => {
/>
</div>
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Divider type='vertical' style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New Product
</Title>
<Form
name='basic'
autoComplete='off'
@ -163,44 +421,84 @@ const NewProduct = ({ onOk, reset }) => {
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 justify='end'>
<Button
style={{ margin: '0 8px' }}
onClick={() => {
setCurrentStep((prev) => prev - 1)
setNextEnabled(true)
}}
disabled={currentStep === 0}
>
Previous
</Button>
{currentStep < steps.length - 1 ? (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep((prev) => prev + 1)
setNextEnabled(false)
}}
>
Next
</Button>
) : (
<Button
type='primary'
loading={newProductLoading}
onClick={() => {
newProductForm.submit()
}}
>
Done
</Button>
)}
</Flex>
</Flex>
<Modal
open={previewVisible}
footer={null}
onCancel={() => {
setPreviewVisible(false)
setPreviewFile(null)
if (previewTimerRef.current) {
clearTimeout(previewTimerRef.current)
}
}}
style={{ top: 30 }}
width='90%'
>
<Flex style={{ minWidth: '100%', minHeight: '80vh' }}>
{previewFile && !isPreviewLoading ? (
<div style={{ flexGrow: 1 }}>
<StlViewer
url={fileUrls[previewFile.uid]}
orbitControls
shadows
style={{ height: '80vh', width: '100%' }}
modelProps={{ color: '#008675' }}
/>
</div>
) : (
<div
style={{
flexGrow: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '80vh'
}}
>
Loading 3D model...
</div>
)}
</Flex>
</Modal>
</Flex>
)
}

View File

@ -8,23 +8,27 @@ import {
Button,
message,
Typography,
Card,
Flex,
Form,
Input
Input,
Tag,
Checkbox,
InputNumber
} from 'antd'
import {
LoadingOutlined,
EditOutlined,
ReloadOutlined,
CheckOutlined,
CloseOutlined
CloseOutlined,
PlusOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import moment from 'moment'
import VendorSelect from '../../common/VendorSelect.jsx'
import PartsTable from '../../common/PartsTable.jsx'
const { Title } = Typography
import { StlViewer } from 'react-stl-viewer'
const ProductInfo = () => {
const [productData, setProductData] = useState(null)
@ -33,15 +37,36 @@ const ProductInfo = () => {
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)
const [marginOrPrice, setMarginOrPrice] = useState(false)
const [productForm] = Form.useForm()
const [productFormValues, setProductFormValues] = useState({})
const handleTagClose = (removedTag) => {
const newTags = productData.tags.filter((tag) => tag !== removedTag)
setProductData((prev) => ({ ...prev, tags: newTags }))
}
const handleTagAdd = () => {
const input = productForm.getFieldValue('newTag')
if (input) {
const newTag = input.trim()
if (newTag && !productData.tags.includes(newTag)) {
setProductData((prev) => ({ ...prev, tags: [...prev.tags, newTag] }))
productForm.setFieldValue('newTag', '')
}
}
}
useEffect(() => {
setMarginOrPrice(productFormValues.marginOrPrice)
}, [productFormValues])
useEffect(() => {
async function fetchData() {
await fetchProductDetails()
await fetchProductContent()
}
if (productId) {
fetchData()
@ -50,11 +75,19 @@ const ProductInfo = () => {
useEffect(() => {
if (productData) {
form.setFieldsValue({
name: productData.name || ''
productForm.setFieldsValue({
name: productData.name || '',
vendor: productData.vendor || null,
version: productData.version || '',
tags: productData.tags || [],
price: productData.price || null,
margin: productData.margin || null,
marginOrPrice: productData.marginOrPrice || false
})
setProductFormValues(productData)
setMarginOrPrice(productData.marginOrPrice)
}
}, [productData, form])
}, [productData, productForm])
const fetchProductDetails = async () => {
try {
@ -71,34 +104,9 @@ const ProductInfo = () => {
setProductData(response.data)
setError(null)
} catch (err) {
setError('Failed to fetch Product details')
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')
messageApi.error('Failed to fetch product details')
} finally {
setFetchLoading(false)
}
@ -109,37 +117,40 @@ const ProductInfo = () => {
}
const cancelEditing = () => {
form.setFieldsValue({
name: productData?.name || ''
productForm.setFieldsValue({
name: productData?.name || '',
vendor: productData?.vendor || { id: null, name: '' },
version: productData?.version || '',
tags: productData?.tags || [],
cost: productData?.cost || null,
price: productData?.price || null,
margin: productData?.margin || null,
marginOrPrice: productData?.marginOrPrice || null
})
setMarginOrPrice(productData?.marginOrPrice)
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
const values = await productForm.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/products/${productId}`,
{
name: values.name
await axios.put(`http://localhost:8080/products/${productId}`, values, {
headers: {
'Content-Type': 'application/json'
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
withCredentials: true
})
// Update the local state with the new name
setProductData({ ...productData, name: values.name })
setProductData({
...productData,
...values
})
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)
@ -172,7 +183,7 @@ const ProductInfo = () => {
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Flex style={{ height: '100%', minHeight: 0, overflowY: 'auto' }} vertical>
{contextHolder}
<Flex
align={'center'}
@ -204,10 +215,19 @@ const ProductInfo = () => {
</Flex>
<Form
form={form}
form={productForm}
layout='vertical'
onValuesChange={(changedValues) =>
setProductFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={{
name: productData.name || ''
name: productData.name || '',
vendor: productData.vendor || { id: null, name: '' },
version: productData.version || '',
tags: productData.tags || []
}}
>
<Descriptions bordered column={2}>
@ -218,17 +238,12 @@ const ProductInfo = () => {
'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 label='Created At'>
{moment(productData.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Name' span={2}>
<Descriptions.Item label='Name' span={1}>
{isEditing ? (
<Form.Item
name='name'
@ -244,29 +259,157 @@ const ProductInfo = () => {
productData.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(productData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[{ required: true, message: 'Please enter a vendor' }]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
productData.vendor.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
<IdText
id={productData.vendor.id}
type={'vendor'}
showHyperlink={true}
/>
</Descriptions.Item>
<Descriptions.Item
label={!marginOrPrice ? 'Margin' : 'Price'}
span={1}
>
{isEditing ? (
<Flex gap='middle'>
{marginOrPrice == false ? (
<Form.Item
name='margin'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a margin.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonAfter='%'
/>
</Form.Item>
) : (
<Form.Item
name='price'
style={{ margin: 0, flexGrow: 1 }}
rules={[
{
required: true,
message: 'Please enter a price.'
}
]}
>
<InputNumber
controls={false}
step={0.01}
style={{ width: '100%' }}
addonBefore='£'
/>
</Form.Item>
)}
<Form.Item
name='marginOrPrice'
valuePropName='checked'
style={{ margin: 0 }}
>
<Checkbox>Price</Checkbox>
</Form.Item>
</Flex>
) : productData.margin && marginOrPrice == false ? (
productData.margin + '%'
) : productData.price && marginOrPrice == true ? (
'£' + productData.price
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Version' span={1}>
{isEditing ? (
<Form.Item name='version' style={{ margin: 0 }}>
<Input placeholder='Enter version' />
</Form.Item>
) : productData.version ? (
<Tag>{productData.version}</Tag>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Tags' span={1}>
{isEditing ? (
<Form.Item name='tags' style={{ margin: 0 }}>
<Space
size={[0, 2]}
wrap
style={{ marginBottom: 4, maxWidth: '300px' }}
>
{productData.tags.map((tag) => (
<Tag
key={tag}
color='blue'
closable
onClose={() => handleTagClose(tag)}
style={{ marginBottom: 12 }}
>
{tag}
</Tag>
))}
</Space>
<Space.Compact block>
<Form.Item name='newTag' noStyle>
<Input placeholder='Add new tag' />
</Form.Item>
<Button onClick={handleTagAdd} icon={<PlusOutlined />} />
</Space.Compact>
</Form.Item>
) : productData.tags?.length > 0 ? (
<Space size={[0, 2]} wrap style={{ maxWidth: '300px' }}>
{productData.tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>
</Form>
<Flex
align={'center'}
style={{ marginTop: 32, marginBottom: 12, minHeight: '32px' }}
style={{ marginTop: 24, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Product Preview
Product Parts
</Title>
</Flex>
<Card styles={{ body: { padding: '10px' } }}>
<StlViewer
url={productFileObjectId}
orbitControls
shadows
style={{ height: '40vw' }}
modelProps={{
color: '#008675'
}}
></StlViewer>
</Card>
</div>
<PartsTable data={productData.parts}></PartsTable>
</Flex>
)
}

View File

@ -2,18 +2,31 @@ 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 {
Table,
Button,
Flex,
Space,
Modal,
Dropdown,
message,
Typography
} from 'antd'
import { createStyles } from 'antd-style'
import {
LoadingOutlined,
PlusOutlined,
ReloadOutlined,
InfoCircleOutlined,
ShopOutlined
ExportOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
import IdText from '../common/IdText'
import NewVendor from './Vendors/NewVendor'
import CountryDisplay from '../common/CountryDisplay'
import VendorIcon from '../../Icons/VendorIcon'
const { Link } = Typography
const useStyle = createStyles(({ css, token }) => {
const { antCls } = token
@ -84,7 +97,7 @@ const Vendors = () => {
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/management/vendors/info?vendorId=${id}`)
navigate(`/dashboard/management/vendors/info?vendorId=${id}`)
}
}
}
@ -97,7 +110,7 @@ const Vendors = () => {
key: '',
width: 40,
fixed: 'left',
render: () => <ShopOutlined />
render: () => <VendorIcon />
},
{
title: 'Name',
@ -117,13 +130,30 @@ const Vendors = () => {
title: 'Website',
dataIndex: 'website',
key: 'website',
width: 200
width: 200,
render: (text) =>
text ? (
<Link href={text} target='_blank' rel='noopener noreferrer'>
{new URL(text).hostname + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)
},
{
title: 'Country',
dataIndex: 'country',
key: 'country',
width: 200,
render: (text) => (text ? <CountryDisplay countryCode={text} /> : 'n/a')
},
{
title: 'Contact',
dataIndex: 'contact',
key: 'contact',
width: 200
width: 200,
render: (text) => (text ? text : 'n/a')
},
{
title: 'Created At',
@ -139,6 +169,20 @@ const Vendors = () => {
}
}
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (updatedAt) => {
if (updatedAt) {
const formattedDate = moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
},
{
title: 'Actions',
key: 'actions',
@ -150,7 +194,9 @@ const Vendors = () => {
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/management/vendors/info?vendorId=${record._id}`)
navigate(
`/dashboard/management/vendors/info?vendorId=${record._id}`
)
}
/>
<Dropdown menu={getVendorActionItems(record._id)}>
@ -214,6 +260,7 @@ const Vendors = () => {
<NewVendor
onOk={() => {
setNewVendorOpen(false)
messageApi.success('New vendor created successfully.')
fetchVendorsData()
}}
reset={!newVendorOpen}

View File

@ -13,12 +13,18 @@ import {
Divider
} from 'antd'
import CountrySelect from '../../common/CountrySelect'
import CountryDisplay from '../../common/CountryDisplay'
const { Title } = Typography
const initialNewVendorForm = {
name: '',
website: '',
contact: ''
contact: '',
country: '',
email: '',
phone: ''
}
const NewVendor = ({ onOk, reset }) => {
@ -50,12 +56,31 @@ const NewVendor = ({ onOk, reset }) => {
{
key: 'website',
label: 'Website',
children: newVendorFormValues.website
children: newVendorFormValues.website || 'n/a'
},
{
key: 'country',
label: 'Country',
children: newVendorFormValues.country ? (
<CountryDisplay countryCode={newVendorFormValues.country} />
) : (
'n/a'
)
},
{
key: 'contact',
label: 'Contact',
children: newVendorFormValues.contact
children: newVendorFormValues.contact || 'n/a'
},
{
key: 'email',
label: 'Email',
children: newVendorFormValues.email || 'n/a'
},
{
key: 'phone',
label: 'Phone',
children: newVendorFormValues.phone || 'n/a'
}
]
@ -71,7 +96,6 @@ const NewVendor = ({ onOk, reset }) => {
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)
@ -110,9 +134,37 @@ const NewVendor = ({ onOk, reset }) => {
>
<Input />
</Form.Item>
<Form.Item label='Country' name='country'>
<CountrySelect />
</Form.Item>
<Form.Item label='Contact' name='contact'>
<Input />
</Form.Item>
<Form.Item
label='Email'
name='email'
rules={[
{
type: 'email',
message: 'Please enter a valid email'
}
]}
>
<Input />
</Form.Item>
<Form.Item
label='Phone'
name='phone'
style={{ marginBottom: 8 }}
rules={[
{
type: 'phone',
message: 'Please enter a valid phone number'
}
]}
>
<Input />
</Form.Item>
</>
)
},
@ -124,8 +176,9 @@ const NewVendor = ({ onOk, reset }) => {
]
return (
<Flex gap={'middle'}>
<Flex gap='middle'>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
@ -135,10 +188,10 @@ const NewVendor = ({ onOk, reset }) => {
/>
</div>
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Divider type='vertical' style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
<Flex vertical gap='middle' style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New Vendor
</Title>
<Form
@ -155,43 +208,43 @@ const NewVendor = ({ onOk, reset }) => {
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 justify='end'>
<Button
style={{ margin: '0 8px' }}
onClick={() => {
setCurrentStep((prev) => prev - 1)
setNextEnabled(true)
}}
disabled={currentStep === 0}
>
Previous
</Button>
{currentStep < steps.length - 1 ? (
<Button
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep((prev) => prev + 1)
setNextEnabled(false)
}}
>
Next
</Button>
) : (
<Button
type='primary'
loading={newVendorLoading}
onClick={() => {
newVendorForm.submit()
}}
>
Done
</Button>
)}
</Flex>
</Flex>
</Flex>
)

View File

@ -17,12 +17,15 @@ import {
EditOutlined,
ReloadOutlined,
CheckOutlined,
CloseOutlined
CloseOutlined,
ExportOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText'
import moment from 'moment'
import CountrySelect from '../../common/CountrySelect'
import CountryDisplay from '../../common/CountryDisplay'
const { Title } = Typography
const { Title, Link } = Typography
const VendorInfo = () => {
const [vendorData, setVendorData] = useState(null)
@ -46,7 +49,10 @@ const VendorInfo = () => {
form.setFieldsValue({
name: vendorData.name || '',
website: vendorData.website || '',
contact: vendorData.contact || ''
contact: vendorData.contact || '',
country: vendorData.country || '',
phone: vendorData.phone || '',
email: vendorData.email || ''
})
}
}, [vendorData, form])
@ -81,7 +87,10 @@ const VendorInfo = () => {
form.setFieldsValue({
name: vendorData?.name || '',
website: vendorData?.website || '',
contact: vendorData?.contact || ''
contact: vendorData?.contact || '',
country: vendorData?.country || '',
phone: vendorData?.phone || '',
email: vendorData?.email || ''
})
setIsEditing(false)
}
@ -91,20 +100,12 @@ const VendorInfo = () => {
const values = await form.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/vendors/${vendorId}`,
{
name: values.name,
website: values.website,
contact: values.contact
await axios.put(`http://localhost:8080/vendors/${vendorId}`, values, {
headers: {
'Content-Type': 'application/json'
},
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
withCredentials: true
})
setVendorData({ ...vendorData, ...values })
setIsEditing(false)
@ -116,6 +117,7 @@ const VendorInfo = () => {
console.error('Failed to update vendor information:', err)
messageApi.error('Failed to update vendor information')
} finally {
fetchVendorDetails()
setLoading(false)
}
}
@ -200,6 +202,10 @@ const VendorInfo = () => {
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(vendorData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Website'>
{isEditing ? (
<Form.Item
@ -215,8 +221,29 @@ const VendorInfo = () => {
>
<Input />
</Form.Item>
) : vendorData.website ? (
<Link
href={vendorData.website}
target='_blank'
rel='noopener noreferrer'
>
{new URL(vendorData.website).hostname + ' '}
<ExportOutlined />
</Link>
) : (
vendorData.website
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Country'>
{isEditing ? (
<Form.Item name='country' style={{ margin: 0 }}>
<CountrySelect countryCode={vendorData.country} />
</Form.Item>
) : vendorData.country ? (
<CountryDisplay countryCode={vendorData.country} />
) : (
'n/a'
)}
</Descriptions.Item>
@ -234,8 +261,55 @@ const VendorInfo = () => {
>
<Input />
</Form.Item>
) : (
) : vendorData.contact ? (
vendorData.contact
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Phone'>
{isEditing ? (
<Form.Item
name='phone'
rules={[
{
type: 'phone',
message: 'Please enter a valid phone number'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.phone ? (
vendorData.phone
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Email'>
{isEditing ? (
<Form.Item
name='email'
rules={[
{
type: 'email',
message: 'Please enter a valid email'
}
]}
style={{ margin: 0 }}
>
<Input />
</Form.Item>
) : vendorData.email ? (
<Link href={`mailto:${vendorData.email}`}>
{vendorData.email + ' '}
<ExportOutlined />
</Link>
) : (
'n/a'
)}
</Descriptions.Item>
</Descriptions>

View File

@ -14,7 +14,12 @@ import {
Modal,
Dropdown,
Typography,
message
message,
Checkbox,
Divider,
Popover,
Input,
Spin
} from 'antd'
import { createStyles } from 'antd-style'
import {
@ -22,7 +27,9 @@ import {
PlusOutlined,
DownloadOutlined,
ReloadOutlined,
InfoCircleOutlined
InfoCircleOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
@ -56,42 +63,267 @@ const GCodeFiles = () => {
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
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<CloseOutlined />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckOutlined />}
/>
</Space.Compact>
</div>
)
}
// 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>,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toLowerCase())
},
{
title: 'ID',
dataIndex: '_id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'gcodeFile'} longId={false} />
},
{
title: 'Filament',
dataIndex: ['filament', 'name'],
key: 'filament',
width: 200,
render: (text, record) => {
return (
<Badge color={record.filament.color} text={record.filament.name} />
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
},
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'filament'
}),
onFilter: (value, record) =>
record.filament.name.toLowerCase().includes(value.toLowerCase())
},
{
title: 'Cost',
dataIndex: 'cost',
key: 'cost',
width: 120,
render: (cost) => {
return '£' + cost.toFixed(2)
},
sorter: true
},
{
title: 'Print Time',
key: 'estimatedPrintingTimeNormalMode',
dataIndex: ['gcodeFileInfo', 'estimatedPrintingTimeNormalMode'],
width: 140,
render: (text, record) => {
return `${record.gcodeFileInfo.estimatedPrintingTimeNormalMode}`
},
sorter: true
},
{
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'
}
},
sorter: true
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
key: 'updatedAt',
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'
}
},
sorter: true
},
{
title: 'Actions',
key: 'actions',
fixed: 'right',
width: 150,
render: (text, record) => {
return (
<Space gap='small'>
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(
`/dashboard/production/gcodefiles/info?gcodeFileId=${record._id}`
)
}
/>
<Dropdown menu={getGCodeFileActionItems(record._id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
}, [messageApi])
]
const [gcodeFilesData, setGCodeFilesData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [newGCodeFileOpen, setNewGCodeFileOpen] = useState(false)
const [showDeleted, setShowDeleted] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
const [columnVisibility, setColumnVisibility] = useState(
columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
)
const { authenticated } = useContext(AuthContext)
const fetchGCodeFilesData = useCallback(
async (pageNum = 1, append = false) => {
try {
const params = {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
}
const response = await axios.get('http://localhost:8080/gcodefiles', {
params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
if (append) {
setGCodeFilesData((prev) => [...prev, ...newData])
} else {
setGCodeFilesData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
if (error.response) {
messageApi.error('Error fetching gcode files:', error.response.status)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchGCodeFilesData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchGCodeFilesData]
)
useEffect(() => {
if (authenticated) {
@ -99,6 +331,21 @@ const GCodeFiles = () => {
}
}, [authenticated, fetchGCodeFilesData])
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0] // Take the first filter value
}
})
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getGCodeFileActionItems = (id) => {
return {
items: [
@ -115,7 +362,7 @@ const GCodeFiles = () => {
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/production/gcodefiles/info?gcodeFileId=${id}`)
navigate(`/dashboard/production/gcodefiles/info?gcodeFileId=${id}`)
} else if (key === 'download') {
handleDownloadGCode(
id,
@ -126,96 +373,6 @@ const GCodeFiles = () => {
}
}
// 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
@ -283,6 +440,46 @@ const GCodeFiles = () => {
}
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '') // Filter out empty title columns and ensure key exists
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
setColumnVisibility((prev) => ({
...prev,
[col.key]: e.target.checked
}))
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
<Divider style={{ margin: '8px 0' }} />
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
<Checkbox
checked={showDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
>
Show Deleted
</Checkbox>
</Flex>
</Flex>
)
}
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
return (
<>
<Flex vertical={'true'} gap='large'>
@ -291,15 +488,26 @@ const GCodeFiles = () => {
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space>
<Table
dataSource={gcodeFilesData}
columns={columns}
columns={visibleColumns}
className={styles.customTable}
pagination={false}
scroll={{ y: 'calc(100vh - 270px)' }}
rowKey='_id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
/>
</Flex>
<Modal
@ -309,10 +517,12 @@ const GCodeFiles = () => {
onCancel={() => {
setNewGCodeFileOpen(false)
}}
destroyOnClose
>
<NewGCodeFile
onOk={() => {
setNewGCodeFileOpen(false)
messageApi.success('Finished uploading GCode file!')
fetchGCodeFilesData()
}}
reset={newGCodeFileOpen}

View File

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

View File

@ -1,29 +1,61 @@
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 {
Descriptions,
Spin,
Space,
Button,
message,
Badge,
Form,
Typography,
Flex,
Input
} from 'antd'
import {
LoadingOutlined,
ReloadOutlined,
CheckOutlined,
CloseOutlined,
EditOutlined
} from '@ant-design/icons'
import IdText from '../../common/IdText.jsx'
import moment from 'moment'
import { capitalizeFirstLetter } from '../../utils/Utils.js'
import FilamentSelect from '../../common/FilamentSelect'
const { Title } = Typography
const GCodeFileInfo = () => {
const [gcodeFileData, setGCodeFileData] = useState(null)
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const location = useLocation()
const [messageApi] = message.useMessage()
const [messageApi, contextHolder] = message.useMessage()
const gcodeFileId = new URLSearchParams(location.search).get('gcodeFileId')
const [isEditing, setIsEditing] = useState(false)
const [form] = Form.useForm()
const [fetchLoading, setFetchLoading] = useState(true)
useEffect(() => {
if (gcodeFileId) {
fetchFilamentDetails()
fetchGCodeFileDetails()
}
}, [gcodeFileId])
const fetchFilamentDetails = async () => {
useEffect(() => {
if (gcodeFileData) {
form.setFieldsValue({
name: gcodeFileData.name || '',
filament: gcodeFileData.filament || { id: null, name: '' }
})
}
}, [gcodeFileData, form])
const fetchGCodeFileDetails = async () => {
try {
setLoading(true)
setFetchLoading(true)
const response = await axios.get(
`http://localhost:8080/gcodefiles/${gcodeFileId}`,
{
@ -39,16 +71,51 @@ const GCodeFileInfo = () => {
setError('Failed to fetch GCodeFile details')
messageApi.error('Failed to fetch GCodeFile details')
} finally {
setLoading(false)
setFetchLoading(false)
}
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
const startEditing = () => {
setIsEditing(true)
}
const cancelEditing = () => {
form.setFieldsValue({
name: gcodeFileData?.name || '',
filament: gcodeFileData?.filament || { id: null, name: '' }
})
setIsEditing(false)
}
const updateInfo = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await axios.put(
`http://localhost:8080/gcodefiles/${gcodeFileId}`,
values,
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
setGCodeFileData({ ...gcodeFileData, ...values })
setIsEditing(false)
messageApi.success('GCode File information updated successfully')
} catch (err) {
if (err.errorFields) {
return
}
console.error('Failed to update gcode file information:', err)
messageApi.error('Failed to update gcode file information')
} finally {
fetchGCodeFileDetails()
setLoading(false)
}
}
if (error || !gcodeFileData) {
@ -58,146 +125,209 @@ const GCodeFileInfo = () => {
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'GCodeFile not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchFilamentDetails}>
<Button icon={<ReloadOutlined />} onClick={fetchGCodeFileDetails}>
Retry
</Button>
</Space>
)
}
if (fetchLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
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>
{contextHolder}
<Flex
align={'center'}
style={{ marginTop: 0, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
GCode File Information
</Title>
<Space>
{isEditing ? (
<>
<Button
icon={<CheckOutlined />}
type='primary'
onClick={updateInfo}
loading={loading}
/>
<Button
icon={<CloseOutlined />}
onClick={cancelEditing}
disabled={loading}
/>
</>
) : (
'n/a'
<Button icon={<EditOutlined />} onClick={startEditing} />
)}
</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>
</Space>
</Flex>
<Form form={form} layout='vertical'>
<Descriptions 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'>
{moment(gcodeFileData.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>
) : (
gcodeFileData.name
)}
</Descriptions.Item>
<Descriptions.Item label='Updated At'>
{moment(gcodeFileData.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{isEditing ? (
<Form.Item
name='filament'
rules={[{ required: true, message: 'Please enter a filament' }]}
style={{ margin: 0 }}
>
<FilamentSelect />
</Form.Item>
) : gcodeFileData.filament ? (
<Badge
color={gcodeFileData.filament.color}
text={gcodeFileData.filament.name}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{gcodeFileData.filament ? (
<IdText
id={gcodeFileData.filament.id}
type={'filament'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Est Print Time'>
{gcodeFileData.gcodeFileInfo.estimatedPrintingTimeNormalMode ||
'n/a'}
</Descriptions.Item>
<Descriptions.Item label='Cost'>
{'£' + gcodeFileData.cost.toFixed(2) || '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>
</Form>
</div>
)
}

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types'
import React, { useState, useContext } from 'react'
import React, { useState, useContext, useEffect } from 'react'
import axios from 'axios'
import {
capitalizeFirstLetter,
@ -17,7 +17,9 @@ import {
Upload,
Descriptions,
Checkbox,
Spin
Spin,
InputNumber,
Badge
} from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
@ -35,15 +37,15 @@ const initialNewGCodeFileForm = {
gcodeFileInfo: {},
name: '',
printTimeMins: 0,
price: 0,
cost: 0,
file: null,
material: null
filament: null
}
//const chunkSize = 5000
const NewGCodeFile = ({ onOk, reset }) => {
const [messageApi, contextHolder] = message.useMessage()
const [messageApi] = message.useMessage()
const [newGCodeFileLoading, setNewGCodeFileLoading] = useState(false)
const [gcodeParsing, setGcodeParsing] = useState(false)
@ -128,61 +130,74 @@ const NewGCodeFile = ({ onOk, reset }) => {
{
key: 'name',
label: 'Name',
children: newGCodeFileFormValues.name
children: newGCodeFileFormValues?.name
},
{
key: 'price',
label: 'Price / Cost',
children: '£' + newGCodeFileFormValues.price.toFixed(2)
key: 'filament',
label: 'Filament',
children:
newGCodeFileFormValues?.filament != null ??
(<>
{newGCodeFileFormValues.filament}
<Badge
text={newGCodeFileFormValues.filament.name}
color={newGCodeFileFormValues.filament.color}
/>
</>)('n/a')
},
{
key: 'cost',
label: 'Cost',
children: '£' + newGCodeFileFormValues?.cost
},
{
key: 'sparse_infill_density',
label: 'Infill Density',
children: newGCodeFileFormValues.gcodeFileInfo.sparseInfillDensity
children: newGCodeFileFormValues?.gcodeFileInfo?.sparseInfillDensity
},
{
key: 'sparse_infill_pattern',
label: 'Infill Pattern',
children: capitalizeFirstLetter(
newGCodeFileFormValues.gcodeFileInfo.sparseInfillPattern
newGCodeFileFormValues?.gcodeFileInfo?.sparseInfillPattern
)
},
{
key: 'layer_height',
label: 'Layer Height',
children: newGCodeFileFormValues.gcodeFileInfo.layerHeight + 'mm'
children: newGCodeFileFormValues?.gcodeFileInfo?.layerHeight + 'mm'
},
{
key: 'filamentType',
label: 'Filament Material',
children: newGCodeFileFormValues.gcodeFileInfo.filamentType
children: newGCodeFileFormValues?.gcodeFileInfo?.filamentType
},
{
key: 'filamentUsedG',
label: 'Filament Used (g)',
children: newGCodeFileFormValues.gcodeFileInfo.filamentUsedG + 'g'
children: newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG + 'g'
},
{
key: 'filamentVendor',
label: 'Filament Brand',
children: newGCodeFileFormValues.gcodeFileInfo.filamentVendor
children: newGCodeFileFormValues?.gcodeFileInfo?.filamentVendor
},
{
key: 'hotendTemperature',
label: 'Hotend Temperature',
children: newGCodeFileFormValues.gcodeFileInfo.nozzleTemperature + '°'
children: newGCodeFileFormValues?.gcodeFileInfo?.nozzleTemperature + '°'
},
{
key: 'bedTemperature',
label: 'Bed Temperature',
children: newGCodeFileFormValues.gcodeFileInfo.hotPlateTemp + '°'
children: newGCodeFileFormValues?.gcodeFileInfo?.hotPlateTemp + '°'
},
{
key: 'estimated_printing_time_normal_mode',
label: 'Est. Print Time',
children:
newGCodeFileFormValues.gcodeFileInfo.estimatedPrintingTimeNormalMode
newGCodeFileFormValues?.gcodeFileInfo?.estimatedPrintingTimeNormalMode
}
]
@ -193,6 +208,22 @@ const NewGCodeFile = ({ onOk, reset }) => {
}
}, [reset, newGCodeFileForm])
useEffect(() => {
const filamentCost = newGCodeFileFormValues?.filament?.cost
const gcodeFilamentUsed =
newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG
if (filamentCost && gcodeFilamentUsed) {
const cost = (filamentCost / 1000) * gcodeFilamentUsed
console.log('Setting cost')
setNewGCodeFileFormValues((prev) => ({ ...prev, cost: cost.toFixed(2) }))
newGCodeFileForm.setFieldValue('cost', cost.toFixed(2))
}
}, [
newGCodeFileForm,
newGCodeFileFormValues?.filament?.cost,
newGCodeFileFormValues?.gcodeFileInfo?.filamentUsedG
])
const handleNewGCodeFileUpload = async (id) => {
setNewGCodeFileLoading(true)
const formData = new FormData()
@ -208,7 +239,7 @@ const NewGCodeFile = ({ onOk, reset }) => {
}
}
)
messageApi.success('Finished uploading!')
resetForm()
onOk()
} catch (error) {
@ -374,7 +405,7 @@ const NewGCodeFile = ({ onOk, reset }) => {
<Flex gap={'middle'}>
<Form.Item
label='Material'
label='Filament'
name='filament'
style={{ width: '100%' }}
rules={[
@ -401,6 +432,24 @@ const NewGCodeFile = ({ onOk, reset }) => {
</Checkbox>
</Form.Item>
</Flex>
<Form.Item
label='Cost'
name='cost'
rules={[
{
required: true,
message: 'Please enter a cost.'
}
]}
>
<InputNumber
controls={false}
addonBefore='£'
step={0.01}
style={{ width: '100%' }}
readOnly
/>
</Form.Item>
</>
)
},
@ -419,7 +468,6 @@ const NewGCodeFile = ({ onOk, reset }) => {
return (
<Flex gap={'middle'}>
{contextHolder}
<div style={{ minWidth: '160px' }}>
<Steps
current={currentStep}
@ -431,8 +479,8 @@ const NewGCodeFile = ({ onOk, reset }) => {
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }}>
<Title level={2} style={{ marginTop: 0 }}>
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New G Code File
</Title>
<Form

View File

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

View File

@ -14,7 +14,10 @@ import {
message,
notification,
Input,
Typography
Typography,
Checkbox,
Popover,
Spin
} from 'antd'
import { createStyles } from 'antd-style'
import {
@ -24,12 +27,12 @@ import {
InfoCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
FilterOutlined,
CloseOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
PauseCircleOutlined,
QuestionCircleOutlined
QuestionCircleOutlined,
CheckOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
@ -66,97 +69,52 @@ const PrintJobs = () => {
notification.useNotification()
const navigate = useNavigate()
const [printJobsData, setPrintJobsData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [showFilters, setShowFilters] = useState(false)
const [filters, setFilters] = useState({
id: '',
state: ''
})
const [filters, setFilters] = useState({})
const [sorter, setSorter] = useState({})
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 getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<CloseOutlined />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckOutlined />}
/>
</Space.Compact>
</div>
)
}
const 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 = [
{
@ -173,14 +131,44 @@ const PrintJobs = () => {
key: 'gcodeFileName',
width: 200,
fixed: 'left',
render: (gcodeFile) => <Text ellipsis>{gcodeFile.name}</Text>
render: (gcodeFile) => <Text ellipsis>{gcodeFile.name}</Text>,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'GCode file name'
}),
onFilter: (value, record) =>
record.gcodeFile.name.toLowerCase().includes(value.toLowerCase())
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 165,
render: (text) => <IdText id={text} type={'job'} longId={false} />
render: (text) => <IdText id={text} type={'job'} longId={false} />,
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'ID'
}),
onFilter: (value, record) =>
record.id.toLowerCase().includes(value.toLowerCase())
},
{
title: 'State',
@ -188,7 +176,22 @@ const PrintJobs = () => {
width: 240,
render: (record) => {
return <JobState job={record} showQuantity={false} showId={false} />
}
},
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'state'
}),
onFilter: (value, record) =>
record.state.type.toLowerCase().includes(value.toLowerCase())
},
{
title: <CheckCircleOutlined />,
@ -222,6 +225,21 @@ const PrintJobs = () => {
return <SubJobCounter job={record} state={{ type: 'draft' }} />
}
},
{
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'
}
},
sorter: true
},
{
title: 'Started At',
dataIndex: 'startedAt',
@ -234,7 +252,8 @@ const PrintJobs = () => {
} else {
return 'n/a'
}
}
},
sorter: true
},
{
title: 'Actions',
@ -253,7 +272,9 @@ const PrintJobs = () => {
<Button
icon={<InfoCircleOutlined />}
onClick={() =>
navigate(`/production/printjobs/info?printJobId=${record.id}`)
navigate(
`/dashboard/production/printjobs/info?printJobId=${record.id}`
)
}
/>
)}
@ -266,6 +287,137 @@ const PrintJobs = () => {
}
]
const [columnVisibility, setColumnVisibility] = useState(
columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
)
const { authenticated } = useContext(AuthContext)
const { socket } = useContext(SocketContext)
const 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(`/dashboard/production/printjobs/info?printJobId=${printJobId}`)
} else {
messageApi.error('Socket connection not available')
}
}
const fetchPrintJobsData = useCallback(
async (pageNum = 1, append = false) => {
if (!authenticated) {
return
}
try {
const params = {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
}
const response = await axios.get('http://localhost:8080/printjobs', {
params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25)
if (append) {
setPrintJobsData((prev) => [...prev, ...newData])
} else {
setPrintJobsData(newData)
}
setLoading(false)
setLazyLoading(false)
} catch (error) {
setLoading(false)
setLazyLoading(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, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchPrintJobsData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchPrintJobsData]
)
useEffect(() => {
// Fetch initial data
if (authenticated) {
fetchPrintJobsData()
}
}, [authenticated, fetchPrintJobsData])
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0]
}
})
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
setPage(1)
fetchPrintJobsData(1)
}
const getPrintJobActionItems = (printJobId) => {
return {
items: [
@ -284,7 +436,9 @@ const PrintJobs = () => {
if (key === 'edit') {
showNewPrintJobModal(printJobId)
} else if (key === 'info') {
navigate(`/production/printjobs/info?printJobId=${printJobId}`)
navigate(
`/dashboard/production/printjobs/info?printJobId=${printJobId}`
)
}
}
}
@ -317,45 +471,67 @@ const PrintJobs = () => {
setNewPrintJobOpen(true)
}
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '') // Filter out empty title columns and ensure key exists
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
setColumnVisibility((prev) => ({
...prev,
[col.key]: e.target.checked
}))
}}
>
{col.title}
</Checkbox>
))
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
return (
<>
{notificationContextHolder}
<Flex vertical={'true'} gap='large' style={{ height: '100%' }}>
{contextHolder}
<Space size='middle'>
<Space size='small'>
<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 }}
/>
</>
)}
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space>
<Table
className={styles.customTable}
dataSource={filteredData}
columns={columns}
dataSource={printJobsData}
columns={visibleColumns}
rowKey='id'
pagination={false}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
/>
</Flex>
<Modal
@ -369,6 +545,7 @@ const PrintJobs = () => {
<NewPrintJob
onOk={() => {
setNewPrintJobOpen(false)
messageApi.success('New print job created successfully.')
fetchPrintJobsData()
}}
reset={newPrintJobOpen}

View File

@ -7,10 +7,7 @@ import {
Typography,
Flex,
Steps,
Col,
Row,
Divider,
Checkbox,
Descriptions,
InputNumber
} from 'antd'
@ -19,9 +16,12 @@ import PropTypes from 'prop-types'
import GCodeFileSelect from '../../common/GCodeFileSelect'
import PrinterSelect from '../../common/PrinterSelect'
const { Title, Text } = Typography
const { Title } = Typography
const initialNewPrintJobForm = {}
const initialNewPrintJobForm = {
gcodeFile: null,
quantity: 1
}
const NewPrintJob = ({ onOk, reset }) => {
NewPrintJob.propTypes = {
@ -37,7 +37,6 @@ const NewPrintJob = ({ onOk, reset }) => {
const [newPrintJobFormValues, setNewPrintJobFormValues] = useState(
initialNewPrintJobForm
)
const [useAnyPrinter, setUseAnyPrinter] = useState(true)
const newPrintJobFormUpdateValues = Form.useWatch([], newPrintJobForm)
@ -58,31 +57,12 @@ const NewPrintJob = ({ onOk, reset }) => {
}
]
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 {
@ -96,7 +76,7 @@ const NewPrintJob = ({ onOk, reset }) => {
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)
@ -111,10 +91,6 @@ const NewPrintJob = ({ onOk, reset }) => {
key: 'required',
content: (
<>
<Form.Item>
<Text>Please select a G Code File:</Text>
</Form.Item>
<Form.Item
label='G Code File:'
name='gcodeFile'
@ -145,24 +121,17 @@ const NewPrintJob = ({ onOk, reset }) => {
>
<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
required: true,
message: 'Please select at least one printer'
}
]}
>
<PrinterSelect disabled={useAnyPrinter} checkable={true} />
<PrinterSelect checkable={true} />
</Form.Item>
</>
)
@ -179,74 +148,72 @@ const NewPrintJob = ({ onOk, reset }) => {
]
return (
<Row>
<Flex gap={'middle'}>
{contextHolder}
<Col flex={1}>
<div style={{ minWidth: '160px' }}>
<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}
</div>
<Flex justify={'end'}>
<Divider type={'vertical'} style={{ height: 'unset' }} />
<Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'>
<Title level={2} style={{ marginTop: 0, marginBottom: 4 }}>
New PrintJob
</Title>
<Form
name='basic'
autoComplete='off'
form={newPrintJobForm}
onFinish={handleNewPrintJob}
onValuesChange={(changedValues) =>
setNewPrintJobFormValues((prevValues) => ({
...prevValues,
...changedValues
}))
}
initialValues={initialNewPrintJobForm}
>
<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
style={{
margin: '0 8px'
type='primary'
disabled={!nextEnabled}
onClick={() => {
setCurrentStep(currentStep + 1)
}}
onClick={() => setCurrentStep(currentStep - 1)}
disabled={!(currentStep > 0)}
>
Previous
Next
</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>
)}
{currentStep === steps.length - 1 && (
<Button
type='primary'
htmlType='submit'
loading={newPrintJobLoading}
>
Done
</Button>
)}
</Flex>
</Form>
</Flex>
</Flex>
)
}

View File

@ -12,7 +12,10 @@ import {
Flex,
Input,
Tag,
Modal
Modal,
Popover,
Checkbox,
Spin
} from 'antd'
import { createStyles } from 'antd-style'
import {
@ -21,10 +24,10 @@ import {
ControlOutlined,
LoadingOutlined,
ReloadOutlined,
FilterOutlined,
CloseOutlined,
PlusOutlined,
PrinterOutlined
PrinterOutlined,
CheckOutlined
} from '@ant-design/icons'
import { AuthContext } from '../../Auth/AuthContext'
@ -53,52 +56,261 @@ const useStyle = createStyles(({ css, token }) => {
const Printers = () => {
const { styles } = useStyle()
const [printerData, setPrinterData] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [lazyLoading, setLazyLoading] = useState(false)
const [filters, setFilters] = useState({})
const [sorter, setSorter] = 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.'
// Column definitions
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <PrinterOutlined></PrinterOutlined>
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 200,
fixed: 'left',
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'name'
}),
onFilter: (value, record) =>
record.name.toLowerCase().includes(value.toLowerCase())
},
{
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 'n/a'
if (tags.length == 0) return 'n/a'
return (
<Space size={[0, 8]} wrap>
{tags.map((tag, index) => (
<Tag key={index} color='blue'>
{tag}
</Tag>
))}
</Space>
)
},
filterDropdown: ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters
}) =>
getFilterDropdown({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName: 'tags'
}),
onFilter: (value, record) =>
record.tags &&
record.tags.some((tag) =>
tag.toLowerCase().includes(value.toLowerCase())
)
},
{
title: 'Actions',
key: 'operation',
fixed: 'right',
width: 150,
render: (record) => {
return (
<Space gap='small'>
<Button
icon={<ControlOutlined />}
onClick={() =>
navigate(
`/dashboard/production/printers/control?printerId=${record.id}`
)
}
/>
<Dropdown menu={getPrinterActionItems(record.id)}>
<Button>Actions</Button>
</Dropdown>
</Space>
)
}
}
}, [messageApi])
]
const handleFilterChange = (field, value) => {
setFilters((prev) => ({
...prev,
[field]: value
}))
const [columnVisibility, setColumnVisibility] = useState(
columns.reduce((acc, col) => {
if (col.key) {
acc[col.key] = true
}
return acc
}, {})
)
const [messageApi] = message.useMessage()
const { authenticated } = useContext(AuthContext)
const [newPrinterOpen, setNewPrinterOpen] = useState(false)
const navigate = useNavigate()
const getFilterDropdown = ({
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
propertyName
}) => {
return (
<div style={{ padding: 8 }}>
<Space.Compact>
<Input
placeholder={'Search ' + propertyName}
value={selectedKeys[0]}
onChange={(e) =>
setSelectedKeys(e.target.value ? [e.target.value] : [])
}
onPressEnter={() => confirm()}
style={{ width: 200, display: 'block' }}
/>
<Button
onClick={() => {
clearFilters()
confirm()
}}
icon={<CloseOutlined />}
/>
<Button
type='primary'
onClick={() => confirm()}
icon={<CheckOutlined />}
/>
</Space.Compact>
</div>
)
}
const fetchPrintersData = useCallback(
async (pageNum = 1, append = false) => {
try {
const params = {
page: pageNum,
limit: 25,
...filters,
sort: sorter.field,
order: sorter.order
}
const response = await axios.get('http://localhost:8080/printers', {
params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const newData = response.data
setHasMore(newData.length === 25) // If we get less than 25 items, we've reached the end
if (append) {
setPrinterData((prev) => [...prev, ...newData])
} else {
setPrinterData(newData)
}
setLoading(false)
setLazyLoading(false)
} 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.'
)
}
setLoading(false)
setLazyLoading(false)
}
},
[messageApi, filters, sorter]
)
const handleScroll = useCallback(
(e) => {
const { target } = e
const scrollHeight = target.scrollHeight
const scrollTop = target.scrollTop
const clientHeight = target.clientHeight
// If we're near the bottom (within 100px) and not currently loading
if (
scrollHeight - scrollTop - clientHeight < 100 &&
!lazyLoading &&
hasMore
) {
setLazyLoading(true)
const nextPage = page + 1
setPage(nextPage)
fetchPrintersData(nextPage, true)
}
},
[page, lazyLoading, hasMore, fetchPrintersData]
)
const handleTableChange = (pagination, filters, sorter) => {
const newFilters = {}
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
newFilters[key] = value[0] // Take the first filter value
}
})
setFilters(newFilters)
setSorter({
field: sorter.field,
order: sorter.order
})
}
const getPrinterActionItems = (printerId) => {
@ -125,36 +337,46 @@ const Printers = () => {
],
onClick: ({ key }) => {
if (key === 'info') {
navigate(`/production/printers/info?printerId=${printerId}`)
navigate(`/dashboard/production/printers/info?printerId=${printerId}`)
} else if (key === 'control') {
navigate(`/production/printers/control?printerId=${printerId}`)
navigate(
`/dashboard/production/printers/control?printerId=${printerId}`
)
}
}
}
}
useEffect(() => {
if (authenticated) {
// Fetch initial data
fetchPrintersData()
}
}, [fetchPrintersData, authenticated])
const getViewDropdownItems = () => {
const columnItems = columns
.filter((col) => col.key && col.title !== '') // Filter out empty title columns and ensure key exists
.map((col) => (
<Checkbox
checked={columnVisibility[col.key]}
key={col.key}
onChange={(e) => {
setColumnVisibility((prev) => ({
...prev,
[col.key]: e.target.checked
}))
}}
>
{col.title}
</Checkbox>
))
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
})
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
{columnItems}
</Flex>
</Flex>
)
}
const visibleColumns = columns.filter(
(col) => !col.key || columnVisibility[col.key]
)
const actionItems = {
items: [
@ -179,131 +401,40 @@ const Printers = () => {
}
}
// 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>
)
}
useEffect(() => {
if (authenticated) {
fetchPrintersData()
}
]
}, [fetchPrintersData, authenticated])
return (
<>
<Flex vertical={'true'} gap='large'>
<Space size='middle'>
<Space>
<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 }}
/>
</>
)}
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
{lazyLoading && <Spin indicator={<LoadingOutlined />} />}
</Space>
<Table
className={styles.customTable}
dataSource={filteredData}
columns={columns}
dataSource={printerData}
columns={visibleColumns}
pagination={false}
rowKey='id'
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
scroll={{ y: 'calc(100vh - 270px)' }}
onChange={handleTableChange}
onScroll={handleScroll}
showSorterTooltip={false}
/>
<Modal
open={newPrinterOpen}
@ -316,6 +447,7 @@ const Printers = () => {
<NewPrinter
onOk={() => {
setNewPrinterOpen(false)
messageApi.success('New printer added successfully.')
fetchPrintersData()
}}
reset={newPrinterOpen}

View File

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

View File

@ -10,14 +10,16 @@ import {
Dropdown,
Space,
Descriptions,
Progress
Progress,
Modal,
Typography,
Badge
} from 'antd'
import {
LoadingOutlined,
PlayCircleOutlined,
ExclamationCircleOutlined,
ReloadOutlined,
EditOutlined,
PauseCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons'
@ -31,6 +33,14 @@ import { AuthContext } from '../../../Auth/AuthContext'
import PrinterSubJobsTree from '../../common/PrinterJobsTree'
import IdText from '../../common/IdText'
import FilamentIcon from '../../../Icons/FilamentIcon'
import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
import LoadFilamentStock from '../../Inventory/FilamentStocks/LoadFilamentStock'
import UnloadFilamentStock from '../../Inventory/FilamentStocks/UnloadFilamentStock'
const { Text } = Typography
// Helper function to parse query parameters
const useQuery = () => {
return new URLSearchParams(useLocation().search)
@ -43,6 +53,10 @@ const ControlPrinter = () => {
const [printerData, setPrinterData] = useState(null)
const [initialized, setInitialized] = useState(false)
const [loadFilamentStockModalOpen, setLoadFilamentStockModalOpen] =
useState(false)
const [unloadFilamentStockModalOpen, setUnloadFilamentStockModalOpen] =
useState(false)
const { socket } = useContext(SocketContext)
const { authenticated } = useContext(AuthContext)
@ -82,6 +96,7 @@ const ControlPrinter = () => {
if (socket && !initialized && printerId) {
setInitialized(true)
socket.on('notify_printer_update', (statusUpdate) => {
console.log('GOT STATUS', statusUpdate)
setPrinterData((prevData) => {
if (statusUpdate?.id === printerId) {
return {
@ -92,10 +107,29 @@ const ControlPrinter = () => {
return prevData
})
})
// Add WebSocket event listener for filament stock updates
socket.on('notify_filamentstock_update', (filamentStockUpdate) => {
console.log('GOT FILAMENT STOCK UPDATE', filamentStockUpdate)
setPrinterData((prevData) => {
if (prevData?.currentFilamentStock?.id === filamentStockUpdate?.id) {
return {
...prevData,
currentFilamentStock: {
...prevData.currentFilamentStock,
...filamentStockUpdate
}
}
}
return prevData
})
})
}
return () => {
if (socket && initialized) {
console.log('Deregistering')
socket.off('notify_printer_update')
socket.off('notify_filamentstock_update')
}
}
}, [socket, initialized, printerId])
@ -111,42 +145,99 @@ const ControlPrinter = () => {
}
}, [authenticated, fetchPrinterDetails])
useEffect(() => {
if (
printerData?.alerts?.some((alert) => alert.type === 'loadFilamentStock')
) {
setLoadFilamentStockModalOpen(true)
} else {
setLoadFilamentStockModalOpen(false)
}
}, [printerData?.alerts])
const actionItems = {
items: [
{
label: 'Resume Print',
key: 'resumePrint',
icon: <PlayCircleOutlined />
icon: <PlayCircleOutlined />,
disabled: printerData?.state?.type !== 'paused'
},
{
label: 'Pause Print',
key: 'pausePrint',
icon: <PauseCircleOutlined />
icon: <PauseCircleOutlined />,
disabled: printerData?.state?.type !== 'printing'
},
{
label: 'Cancel Print',
key: 'cancelPrint',
icon: <CloseCircleOutlined />
icon: <CloseCircleOutlined />,
disabled: !(
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'paused'
)
},
{
type: 'divider'
},
{
label: 'Start Queue',
key: 'startQueue',
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'deploying' ||
printerData?.state?.type === 'paused' ||
printerData?.state?.type === 'error',
label: 'Queue',
key: 'queue',
children: [
{
label: 'Start Queue',
key: 'startQueue',
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'deploying' ||
printerData?.state?.type === 'paused' ||
printerData?.state?.type === 'error',
icon: <PlayCircleOutlined />
icon: <PlayCircleOutlined />
},
{
label: 'Pause Queue',
key: 'pauseQueue',
icon: <PauseCircleOutlined />
}
]
},
{
label: 'Pause Queue',
key: 'pauseQueue',
icon: <PauseCircleOutlined />
label: 'Filament',
key: 'filament',
children: [
{
label: 'Load Filament Stock',
key: 'loadFilamentStock',
icon: <FilamentStockIcon />,
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'error' ||
printerData?.state?.type === 'offline' ||
printerData?.currentFilamentStock !== null
},
{
label: 'Unload Filament Stock',
key: 'unloadFilamentStock',
icon: <FilamentStockIcon />,
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'error' ||
printerData?.state?.type === 'offline' ||
printerData?.currentFilamentStock === null
},
{
type: 'divider'
},
{
label: 'Filament Info',
key: 'filamentInfo',
icon: <FilamentIcon />
}
]
},
{
type: 'divider'
},
@ -159,14 +250,6 @@ const ControlPrinter = () => {
label: 'Restart Firmware',
key: 'restartFirmware',
icon: <ReloadOutlined />
},
{
type: 'divider'
},
{
label: 'Edit Printer',
key: 'edit',
icon: <EditOutlined />
}
],
onClick: ({ key }) => {
@ -184,6 +267,10 @@ const ControlPrinter = () => {
socket.emit('server.job_queue.start', { printerId })
} else if (key === 'pauseQueue') {
socket.emit('server.job_queue.pause', { printerId })
} else if (key === 'loadFilamentStock') {
setLoadFilamentStockModalOpen(true)
} else if (key === 'unloadFilamentStock') {
setUnloadFilamentStockModalOpen(true)
}
}
}
@ -197,11 +284,9 @@ const ControlPrinter = () => {
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
</Space>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
{printerData ? (
<PrinterState
printer={printerData}
@ -261,7 +346,19 @@ const ControlPrinter = () => {
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
<Descriptions bordered column={2}>
<Descriptions.Item label='Printer Name'>
{printerData.printerName}
{printerData.name}
</Descriptions.Item>
<Descriptions.Item label='Printer ID'>
{printerData._id ? (
<IdText
id={printerData._id}
type='printer'
longId={false}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Print Job ID'>
{printerData.currentJob?.id ? (
@ -275,8 +372,26 @@ const ControlPrinter = () => {
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Sub Job ID'>
{printerData.currentSubJob?.id ? (
<IdText
id={printerData.currentSubJob.number
.toString()
.padStart(6, '0')}
type='subjob'
longId={false}
showHyperlink={false}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
{printerData.currentJob?.gcodeFile?.name || 'n/a'}
{
<Text ellipsis>
{printerData.currentJob?.gcodeFile?.name || 'n/a'}
</Text>
}
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
{printerData.currentJob?.gcodeFile ? (
@ -291,16 +406,69 @@ const ControlPrinter = () => {
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Stock Net Weight'>
{printerData.currentFilamentStock?.currentNetWeight ? (
<Text>
{printerData.currentFilamentStock.currentNetWeight.toFixed(
2
) + 'g'}
</Text>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Stock ID'>
{printerData.currentFilamentStock ? (
<IdText
id={printerData.currentFilamentStock._id}
type='filamentstock'
longId={false}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{printerData.currentFilamentStock?.filament?.name ? (
<Badge
text={printerData.currentFilamentStock.filament.name}
color={printerData.currentFilamentStock.filament.color}
></Badge>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{printerData?.currentFilamentStock?.filament ? (
<IdText
id={printerData.currentFilamentStock.filament._id}
type='filament'
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 (
<Text ellipsis>
{
printerData.currentJob.gcodeFile.gcodeFileInfo
.estimatedPrintingTimeNormalMode
}
</Text>
)
}
return 'n/a'
})()}
@ -312,14 +480,21 @@ const ControlPrinter = () => {
printerData?.currentJob?.gcodeFile.gcodeFileInfo
.printSettingsId
) {
return `${printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll('"', '')}`
return (
<Text ellipsis>
{printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll(
'"',
''
)}
</Text>
)
} else {
return 'n/a'
}
})()}
</Descriptions.Item>
{printerData.currentSubJob?.state.type === 'printing' && (
{printerData?.state.type === 'printing' && (
<Descriptions.Item label='Progress'>
<Progress
percent={Math.round(
@ -332,13 +507,13 @@ const ControlPrinter = () => {
<PrinterSubJobsTree subJobs={printerData.subJobs} />
</Flex>
<Flex gap={16} vertical>
<Card title='Temperature' bordered={true}>
<Card bordered={true}>
<PrinterTemperaturePanel
printerId={printerId}
></PrinterTemperaturePanel>
</Card>
<Card title='Movement' bordered={true}>
<Card bordered={true}>
<PrinterMovementPanel
printerId={printerId}
></PrinterMovementPanel>
@ -350,6 +525,41 @@ const ControlPrinter = () => {
)}
</div>
</Flex>
<Modal
open={loadFilamentStockModalOpen}
footer={null}
width={700}
onCancel={() => {
setLoadFilamentStockModalOpen(false)
}}
>
<LoadFilamentStock
onOk={() => {
setLoadFilamentStockModalOpen(false)
messageApi.success('New print job created successfully.')
}}
isFilamentLoaded={false}
printer={printerData}
reset={loadFilamentStockModalOpen}
/>
</Modal>
<Modal
open={unloadFilamentStockModalOpen}
footer={null}
width={700}
onCancel={() => {
setUnloadFilamentStockModalOpen(false)
}}
>
<UnloadFilamentStock
onOk={() => {
setUnloadFilamentStockModalOpen(false)
messageApi.success('Filament unloaded successfully.')
}}
printer={printerData}
reset={unloadFilamentStockModalOpen}
/>
</Modal>
</>
)
}

View File

@ -78,8 +78,8 @@ const NewPrinter = ({ onOk, reset }) => {
!!(moonraker?.protocol && moonraker?.host && moonraker?.port)
)
} else if (currentStep === 1) {
const printerName = newPrinterForm.getFieldValue('printerName')
setNextEnabled(!!printerName)
const name = newPrinterForm.getFieldValue('name')
setNextEnabled(!!name)
} else {
setNextEnabled(true)
}
@ -91,7 +91,7 @@ const NewPrinter = ({ onOk, reset }) => {
{
key: 'name',
label: 'Name',
children: newPrinterFormValues.printerName
children: newPrinterFormValues.name
},
{
key: 'protocol',
@ -168,7 +168,7 @@ const NewPrinter = ({ onOk, reset }) => {
withCredentials: true
}
)
messageApi.success('New printer added successfully.')
onOk()
} catch (error) {
messageApi.error('Error adding new printer: ' + error.message)
@ -476,7 +476,7 @@ const NewPrinter = ({ onOk, reset }) => {
<>
<Form.Item
label='Name'
name='printerName'
name='name'
rules={[{ required: true, message: 'Name is required' }]}
>
<Input />

View File

@ -26,6 +26,7 @@ import {
import PrinterState from '../../common/PrinterState'
import IdText from '../../common/IdText'
import PrinterSubJobsList from '../../common/PrinterJobsTree'
import VendorSelect from '../../common/VendorSelect'
const { Title } = Typography
@ -48,7 +49,17 @@ const PrinterInfo = () => {
useEffect(() => {
if (printerData) {
form.setFieldsValue(printerData)
form.setFieldsValue({
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
})
}
}, [printerData, form])
@ -79,8 +90,21 @@ const PrinterInfo = () => {
}
const cancelEditing = () => {
// Reset form values to original data
if (printerData) {
form.setFieldsValue({
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
})
}
setIsEditing(false)
fetchPrinterDetails()
}
const updatePrinterInfo = async () => {
@ -88,13 +112,29 @@ const PrinterInfo = () => {
const values = await form.validateFields()
setLoading(true)
await axios.put(`http://localhost:8080/printers/${printerId}`, values, {
headers: {
'Content-Type': 'application/json'
await axios.put(
`http://localhost:8080/printers/${printerId}`,
{
name: values.name,
vendor: values.vendor,
moonraker: {
host: values.moonraker.host,
port: values.moonraker.port,
protocol: values.moonraker.protocol,
apiKey: values.moonraker.apiKey
},
tags: values.tags
},
withCredentials: true
})
setPrinterData((prev) => ({ ...prev, ...values }))
{
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
}
)
// Update the local state with the new values
setPrinterData({ ...printerData, ...values })
setIsEditing(false)
messageApi.success('Printer information updated successfully')
} catch (err) {
@ -179,7 +219,21 @@ const PrinterInfo = () => {
</Space>
</Flex>
<Form form={form} layout='vertical'>
<Form
form={form}
layout='vertical'
initialValues={{
name: printerData.name || '',
vendor: printerData.vendor || { id: null, name: '' },
moonraker: {
host: printerData.moonraker?.host || '',
port: printerData.moonraker?.port || null,
protocol: printerData.moonraker?.protocol || 'ws',
apiKey: printerData.moonraker?.apiKey || ''
},
tags: printerData.tags || []
}}
>
<Descriptions bordered column={2}>
{/* Read-only fields */}
<Descriptions.Item label='ID'>
@ -193,7 +247,7 @@ const PrinterInfo = () => {
<Descriptions.Item label='Name'>
{isEditing ? (
<Form.Item
name='printerName'
name='name'
rules={[
{ required: true, message: 'Please enter a printer name' },
{ max: 100, message: 'Name cannot exceed 100 characters' }
@ -203,7 +257,7 @@ const PrinterInfo = () => {
<Input placeholder='Enter printer name' />
</Form.Item>
) : (
printerData.printerName || 'n/a'
printerData.name || 'n/a'
)}
</Descriptions.Item>
@ -228,6 +282,32 @@ const PrinterInfo = () => {
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor'>
{isEditing ? (
<Form.Item
name='vendor'
rules={[{ required: true, message: 'Please enter a vendor' }]}
style={{ margin: 0 }}
>
<VendorSelect />
</Form.Item>
) : (
printerData?.vendor?.name || 'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Vendor ID'>
{printerData?.vendor ? (
<IdText
id={printerData?.vendor?.id}
type={'vendor'}
showHyperlink={true}
/>
) : (
'n/a'
)}
</Descriptions.Item>
<Descriptions.Item label='Port'>
{isEditing ? (
<Form.Item

View File

@ -0,0 +1,367 @@
import React, { useEffect, useState, useCallback } from 'react'
import {
Descriptions,
Progress,
Space,
Flex,
Alert,
Typography,
Card,
Spin,
message,
Button
} from 'antd'
import {
PrinterOutlined,
LoadingOutlined,
CheckCircleOutlined,
PlayCircleOutlined,
ReloadOutlined
} from '@ant-design/icons'
import axios from 'axios'
const { Title, Text } = Typography
const ProductionOverview = () => {
const [messageApi, contextHolder] = message.useMessage()
const [error, setError] = useState(null)
const [fetchPrinterStatsLoading, setFetchPrinterStatsLoading] = useState(true)
const [stats, setStats] = useState({
totalPrinters: 0,
activePrinters: 0,
totalPrintJobs: 0,
activePrintJobs: 0,
completedPrintJobs: 0,
printerStatus: {
idle: 0,
printing: 0,
error: 0,
offline: 0
}
})
const fetchAllStats = useCallback(async () => {
await fetchPrinterStats()
await fetchPrintJobStats()
console.log(stats)
}, [])
const fetchPrinterStats = async () => {
try {
setFetchPrinterStatsLoading(true)
const response = await axios.get(`http://localhost:8080/printers/stats`, {
headers: {
Accept: 'application/json'
},
withCredentials: true
})
const printStats = response.data
console.log(printStats)
setStats((prev) => ({ ...prev, printers: printStats }))
setError(null)
} catch (err) {
setError('Failed to fetch printer details')
messageApi.error('Failed to fetch printer details')
} finally {
setFetchPrinterStatsLoading(false)
}
}
const fetchPrintJobStats = async () => {
try {
setFetchPrinterStatsLoading(true)
const response = await axios.get(
`http://localhost:8080/printjobs/stats`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true
}
)
const printJobStats = response.data
setStats((prev) => ({ ...prev, printJobs: printJobStats }))
setError(null)
} catch (err) {
setError('Failed to fetch printer details')
messageApi.error('Failed to fetch printer details')
} finally {
setFetchPrinterStatsLoading(false)
}
}
useEffect(() => {
fetchAllStats()
}, [fetchAllStats])
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
}
if (fetchPrinterStatsLoading || fetchPrinterStatsLoading) {
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
</div>
)
}
if (error || !stats) {
return (
<Space
direction='vertical'
style={{ width: '100%', textAlign: 'center' }}
>
<p>{error || 'Printer not found'}</p>
<Button icon={<ReloadOutlined />} onClick={fetchAllStats}>
Retry
</Button>
</Space>
)
}
return (
<Flex vertical>
{contextHolder}
<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={
<Flex vertical>
<Text type='secondary'>Ready</Text>
<Flex gap={'small'}>
<PrinterOutlined style={{ fontSize: 26 }} />
<Text style={{ fontSize: 26 }}>
{(stats.printers.standby || 0) +
(stats.printers.complete || 0)}
</Text>
</Flex>
</Flex>
}
/>
<Alert
type='info'
style={{ flexGrow: 1 }}
description={
<Flex vertical>
<Text type='secondary'>Printing</Text>
<Flex gap={'small'}>
<PrinterOutlined style={{ fontSize: 26 }} />
<Text style={{ fontSize: 26 }}>
{stats.printers.printing || 0}
</Text>
</Flex>
</Flex>
}
/>
<Alert
type='warning'
style={{ flexGrow: 1 }}
description={
<Flex vertical>
<Text type='secondary'>Queued</Text>
<Flex gap={'small'}>
<PlayCircleOutlined style={{ fontSize: 26 }} />
<Text style={{ fontSize: 26 }}>
{stats.printJobs.queued || 0}
</Text>
</Flex>
</Flex>
}
/>
<Alert
type='info'
style={{ flexGrow: 1 }}
description={
<Flex vertical>
<Text type='secondary'>Printing</Text>
<Flex gap={'small'}>
<PlayCircleOutlined style={{ fontSize: 26 }} />
<Text style={{ fontSize: 26 }}>
{stats.printJobs.printing || 0}
</Text>
</Flex>
</Flex>
}
/>
<Alert
type='error'
style={{ flexGrow: 1 }}
description={
<Flex vertical>
<Text type='secondary'>Failed</Text>
<Flex gap={'small'}>
<PlayCircleOutlined style={{ fontSize: 26 }} />
<Text style={{ fontSize: 26 }}>
{(stats.printJobs.failed || 0) +
(stats.printJobs.cancelled || 0)}
</Text>
</Flex>
</Flex>
}
/>
<Alert
type='success'
style={{ flexGrow: 1 }}
description={
<Flex vertical>
<Text type='secondary'>Complete</Text>
<Flex gap={'small'}>
<PlayCircleOutlined style={{ fontSize: 26 }} />
<Text style={{ fontSize: 26 }}>
{stats.printJobs.complete || 0}
</Text>
</Flex>
</Flex>
}
/>
</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>
<Card>
<Descriptions column={1}>
<Descriptions.Item label={`Printing`}>
<Flex style={{ width: '100%' }} gap={'small'}>
<Text style={{ minWidth: '28px' }}>
{stats.printerStatus.printing || 0}
</Text>
<Progress
percent={getPrinterStatusPercentage('printing')}
status='active'
showInfo={false}
/>
</Flex>
</Descriptions.Item>
<Descriptions.Item label={`Idle`}>
<Flex style={{ width: '100%' }} gap={'small'}>
<Text style={{ minWidth: '28px' }}>
{stats.printerStatus.idle || 0}
</Text>
<Progress
percent={getPrinterStatusPercentage('idle')}
status='active'
showInfo={false}
/>
</Flex>
</Descriptions.Item>
</Descriptions>
<Progress
percent={getPrinterStatusPercentage('idle')}
status='normal'
format={() => `${stats.printerStatus.idle || 0} Idle`}
/>
<Progress
percent={getPrinterStatusPercentage('error')}
status='exception'
format={() => `${stats.printerStatus.error || 0} Error`}
/>
</Card>
</Space>
</Flex>
<Flex flex={1} vertical>
<Flex
align={'center'}
style={{ marginTop: 24, marginBottom: 12, minHeight: '32px' }}
justify={'space-between'}
>
<Title level={5} style={{ margin: 0 }}>
Job Statistics
</Title>
</Flex>
<Space direction='vertical' style={{ width: '100%' }}>
<Descriptions column={1} bordered>
<Descriptions.Item
label={
<Space>
<PrinterOutlined /> Total Print Jobs
</Space>
}
>
{stats.totalPrintJobs}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<LoadingOutlined /> Active Print Jobs
</Space>
}
>
{stats.activePrintJobs}
</Descriptions.Item>
<Descriptions.Item
label={
<Space>
<CheckCircleOutlined /> Completed Print Jobs
</Space>
}
>
{stats.completedPrintJobs}
</Descriptions.Item>
<Descriptions.Item>
<Progress
percent={getCompletionRate()}
status='success'
format={() => 'Completion Rate'}
/>
</Descriptions.Item>
</Descriptions>
</Space>
</Flex>
</Flex>
</Flex>
)
}
export default ProductionOverview

View File

@ -0,0 +1,82 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Space } from 'antd'
const countries = [
{ code: 'US', name: 'United States', flag: '🇺🇸' },
{ code: 'GB', name: 'United Kingdom', flag: '🇬🇧' },
{ code: 'CA', name: 'Canada', flag: '🇨🇦' },
{ code: 'AU', name: 'Australia', flag: '🇦🇺' },
{ code: 'DE', name: 'Germany', flag: '🇩🇪' },
{ code: 'FR', name: 'France', flag: '🇫🇷' },
{ code: 'IT', name: 'Italy', flag: '🇮🇹' },
{ code: 'ES', name: 'Spain', flag: '🇪🇸' },
{ code: 'JP', name: 'Japan', flag: '🇯🇵' },
{ code: 'CN', name: 'China', flag: '🇨🇳' },
{ code: 'BR', name: 'Brazil', flag: '🇧🇷' },
{ code: 'IN', name: 'India', flag: '🇮🇳' },
{ code: 'RU', name: 'Russia', flag: '🇷🇺' },
{ code: 'ZA', name: 'South Africa', flag: '🇿🇦' },
{ code: 'MX', name: 'Mexico', flag: '🇲🇽' },
{ code: 'AR', name: 'Argentina', flag: '🇦🇷' },
{ code: 'AT', name: 'Austria', flag: '🇦🇹' },
{ code: 'BE', name: 'Belgium', flag: '🇧🇪' },
{ code: 'BG', name: 'Bulgaria', flag: '🇧🇬' },
{ code: 'CL', name: 'Chile', flag: '🇨🇱' },
{ code: 'CY', name: 'Cyprus', flag: '🇨🇾' },
{ code: 'CZ', name: 'Czech Republic', flag: '🇨🇿' },
{ code: 'CO', name: 'Colombia', flag: '🇨🇴' },
{ code: 'DK', name: 'Denmark', flag: '🇩🇰' },
{ code: 'EG', name: 'Egypt', flag: '🇪🇬' },
{ code: 'FI', name: 'Finland', flag: '🇫🇮' },
{ code: 'GR', name: 'Greece', flag: '🇬🇷' },
{ code: 'HR', name: 'Croatia', flag: '🇭🇷' },
{ code: 'HK', name: 'Hong Kong', flag: '🇭🇰' },
{ code: 'HU', name: 'Hungary', flag: '🇭🇺' },
{ code: 'ID', name: 'Indonesia', flag: '🇮🇩' },
{ code: 'IE', name: 'Ireland', flag: '🇮🇪' },
{ code: 'IS', name: 'Iceland', flag: '🇮🇸' },
{ code: 'IL', name: 'Israel', flag: '🇮🇱' },
{ code: 'KR', name: 'South Korea', flag: '🇰🇷' },
{ code: 'MY', name: 'Malaysia', flag: '🇲🇾' },
{ code: 'MT', name: 'Malta', flag: '🇲🇹' },
{ code: 'NL', name: 'Netherlands', flag: '🇳🇱' },
{ code: 'NZ', name: 'New Zealand', flag: '🇳🇿' },
{ code: 'NO', name: 'Norway', flag: '🇳🇴' },
{ code: 'PE', name: 'Peru', flag: '🇵🇪' },
{ code: 'PH', name: 'Philippines', flag: '🇵🇭' },
{ code: 'PL', name: 'Poland', flag: '🇵🇱' },
{ code: 'PT', name: 'Portugal', flag: '🇵🇹' },
{ code: 'RO', name: 'Romania', flag: '🇷🇴' },
{ code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦' },
{ code: 'SG', name: 'Singapore', flag: '🇸🇬' },
{ code: 'SI', name: 'Slovenia', flag: '🇸🇮' },
{ code: 'SK', name: 'Slovakia', flag: '🇸🇰' },
{ code: 'SE', name: 'Sweden', flag: '🇸🇪' },
{ code: 'CH', name: 'Switzerland', flag: '🇨🇭' },
{ code: 'TH', name: 'Thailand', flag: '🇹🇭' },
{ code: 'TR', name: 'Turkey', flag: '🇹🇷' },
{ code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪' },
{ code: 'VN', name: 'Vietnam', flag: '🇻🇳' }
]
const CountryDisplay = ({ countryCode }) => {
const country = countries.find((c) => c.code === countryCode)
if (!country) {
return null
}
return (
<Space>
{country.flag}
{country.name}
</Space>
)
}
CountryDisplay.propTypes = {
countryCode: PropTypes.string.isRequired
}
export default CountryDisplay

View File

@ -0,0 +1,100 @@
import React from 'react'
import { Select, Flex } from 'antd'
import PropTypes from 'prop-types'
const countries = [
{ code: 'US', name: 'United States', flag: '🇺🇸' },
{ code: 'GB', name: 'United Kingdom', flag: '🇬🇧' },
{ code: 'CA', name: 'Canada', flag: '🇨🇦' },
{ code: 'AU', name: 'Australia', flag: '🇦🇺' },
{ code: 'DE', name: 'Germany', flag: '🇩🇪' },
{ code: 'FR', name: 'France', flag: '🇫🇷' },
{ code: 'IT', name: 'Italy', flag: '🇮🇹' },
{ code: 'ES', name: 'Spain', flag: '🇪🇸' },
{ code: 'JP', name: 'Japan', flag: '🇯🇵' },
{ code: 'CN', name: 'China', flag: '🇨🇳' },
{ code: 'BR', name: 'Brazil', flag: '🇧🇷' },
{ code: 'IN', name: 'India', flag: '🇮🇳' },
{ code: 'RU', name: 'Russia', flag: '🇷🇺' },
{ code: 'ZA', name: 'South Africa', flag: '🇿🇦' },
{ code: 'MX', name: 'Mexico', flag: '🇲🇽' },
{ code: 'AR', name: 'Argentina', flag: '🇦🇷' },
{ code: 'AT', name: 'Austria', flag: '🇦🇹' },
{ code: 'BE', name: 'Belgium', flag: '🇧🇪' },
{ code: 'BG', name: 'Bulgaria', flag: '🇧🇬' },
{ code: 'CL', name: 'Chile', flag: '🇨🇱' },
{ code: 'CY', name: 'Cyprus', flag: '🇨🇾' },
{ code: 'CZ', name: 'Czech Republic', flag: '🇨🇿' },
{ code: 'CO', name: 'Colombia', flag: '🇨🇴' },
{ code: 'DK', name: 'Denmark', flag: '🇩🇰' },
{ code: 'EG', name: 'Egypt', flag: '🇪🇬' },
{ code: 'FI', name: 'Finland', flag: '🇫🇮' },
{ code: 'GR', name: 'Greece', flag: '🇬🇷' },
{ code: 'HR', name: 'Croatia', flag: '🇭🇷' },
{ code: 'HK', name: 'Hong Kong', flag: '🇭🇰' },
{ code: 'HU', name: 'Hungary', flag: '🇭🇺' },
{ code: 'ID', name: 'Indonesia', flag: '🇮🇩' },
{ code: 'IE', name: 'Ireland', flag: '🇮🇪' },
{ code: 'IS', name: 'Iceland', flag: '🇮🇸' },
{ code: 'IL', name: 'Israel', flag: '🇮🇱' },
{ code: 'KR', name: 'South Korea', flag: '🇰🇷' },
{ code: 'MY', name: 'Malaysia', flag: '🇲🇾' },
{ code: 'MT', name: 'Malta', flag: '🇲🇹' },
{ code: 'NL', name: 'Netherlands', flag: '🇳🇱' },
{ code: 'NZ', name: 'New Zealand', flag: '🇳🇿' },
{ code: 'NO', name: 'Norway', flag: '🇳🇴' },
{ code: 'PE', name: 'Peru', flag: '🇵🇪' },
{ code: 'PH', name: 'Philippines', flag: '🇵🇭' },
{ code: 'PL', name: 'Poland', flag: '🇵🇱' },
{ code: 'PT', name: 'Portugal', flag: '🇵🇹' },
{ code: 'RO', name: 'Romania', flag: '🇷🇴' },
{ code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦' },
{ code: 'SG', name: 'Singapore', flag: '🇸🇬' },
{ code: 'SI', name: 'Slovenia', flag: '🇸🇮' },
{ code: 'SK', name: 'Slovakia', flag: '🇸🇰' },
{ code: 'SE', name: 'Sweden', flag: '🇸🇪' },
{ code: 'CH', name: 'Switzerland', flag: '🇨🇭' },
{ code: 'TH', name: 'Thailand', flag: '🇹🇭' },
{ code: 'TR', name: 'Turkey', flag: '🇹🇷' },
{ code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪' },
{ code: 'VN', name: 'Vietnam', flag: '🇻🇳' }
]
const CountrySelect = ({
value,
onChange,
style,
placeholder = 'Select country'
}) => {
return (
<Select
showSearch
style={{ width: '100%', ...style }}
placeholder={placeholder}
optionFilterProp='children'
value={value}
onChange={onChange}
filterOption={(input, option) =>
option.name.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
options={countries.map((country) => ({
value: country.code,
name: country.name,
label: (
<Flex gap='middle'>
{country.flag} {country.name}
</Flex>
)
}))}
/>
)
}
CountrySelect.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func,
style: PropTypes.object,
placeholder: PropTypes.string
}
export default CountrySelect

View File

@ -5,24 +5,31 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'
const breadcrumbNameMap = {
'/production': 'Production',
'/management': 'Management',
'/production/overview': 'Overview',
'/production/printers': 'Printers',
'/production/printers/control': 'Control',
'/production/printers/info': 'Info',
'/production/printjobs': 'Print Jobs',
'/production/printjobs/info': 'Info',
'/production/gcodefiles': 'G Code Files',
'/production/gcodefiles/info': 'Info',
'/management/filaments': 'Filaments',
'/management/filaments/info': 'Info',
'/management/parts': 'Parts',
'/management/parts/info': 'Info',
'/management/products': 'Products',
'/management/products/info': 'Info',
'/management/vendors': 'Vendors',
'/management/vendors/info': 'Info'
'/dashboard/production': 'Production',
'/dashboard/inventory': 'Inventory',
'/dashboard/management': 'Management',
'/dashboard/production/overview': 'Overview',
'/dashboard/production/printers': 'Printers',
'/dashboard/production/printers/control': 'Control',
'/dashboard/production/printers/info': 'Info',
'/dashboard/production/printjobs': 'Print Jobs',
'/dashboard/production/printjobs/info': 'Info',
'/dashboard/production/gcodefiles': 'G Code Files',
'/dashboard/production/gcodefiles/info': 'Info',
'/dashboard/management/filaments': 'Filaments',
'/dashboard/management/filaments/info': 'Info',
'/dashboard/management/parts': 'Parts',
'/dashboard/management/parts/info': 'Info',
'/dashboard/management/products': 'Products',
'/dashboard/management/products/info': 'Info',
'/dashboard/management/vendors': 'Vendors',
'/dashboard/management/vendors/info': 'Info',
'/dashboard/management/materials': 'Materials',
'/dashboard/management/materials/info': 'Info',
'/dashboard/inventory/filamentstocks': 'Filaments',
'/dashboard/inventory/filamentstocks/info': 'Info',
'/dashboard/inventory/partstocks': 'Parts',
'/dashboard/inventory/partstocks/info': 'Info'
}
const DashboardBreadcrumb = () => {
@ -32,13 +39,25 @@ const DashboardBreadcrumb = () => {
const breadcrumbItems = pathSnippets.map((_, index) => {
const url = `/${pathSnippets.slice(0, index + 1).join('/')}`
return {
title: (
<Link to={url} style={{ padding: '0 12px' }}>
{breadcrumbNameMap[url]}
</Link>
),
key: url
if (url != '/dashboard') {
// Check if this is a main section (Production, Inventory, or Management)
const isMainSection =
url === '/dashboard/production' ||
url === '/dashboard/inventory' ||
url === '/dashboard/management'
return {
title: isMainSection ? (
<span style={{ padding: '0 12px' }}>{breadcrumbNameMap[url]}</span>
) : (
<Link to={url} style={{ padding: '0 12px' }}>
{breadcrumbNameMap[url]}
</Link>
),
key: url
}
} else {
return {}
}
})

View File

@ -16,9 +16,9 @@ const { Content } = Layout
const DashboardLayout = ({ children }) => {
const { connecting } = useContext(SocketContext)
const location = useLocation()
const isProduction = location.pathname.startsWith('/production')
const isInventory = location.pathname.startsWith('/inventory')
const isManagement = location.pathname.startsWith('/management')
const isProduction = location.pathname.startsWith('/dashboard/production')
const isInventory = location.pathname.startsWith('/dashboard/inventory')
const isManagement = location.pathname.startsWith('/dashboard/management')
return (
<Layout style={{ height: '100vh' }}>

View File

@ -30,6 +30,8 @@ import { SpotlightContext } from '../context/SpotlightContext'
import { useNavigate, useLocation } from 'react-router-dom'
import { Header } from 'antd/es/layout/layout'
import FarmControlLogo from '../../Logos/FarmControlLogo'
const { Text } = Typography
const DashboardNavigation = () => {
@ -43,8 +45,8 @@ const DashboardNavigation = () => {
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 1) {
setSelectedKey(pathParts[0]) // Return the section (production/management)
if (pathParts.length > 2) {
setSelectedKey(pathParts[1]) // Return the section (production/management)
}
}, [location.pathname])
@ -64,8 +66,8 @@ const DashboardNavigation = () => {
icon: <ProductOutlined />
},
{
key: 'shop',
label: 'Commerce',
key: 'sales',
label: 'Sales',
icon: <ShoppingCartOutlined />
},
{
@ -112,33 +114,39 @@ const DashboardNavigation = () => {
const handleMainMenuClick = ({ key }) => {
if (key === 'production') {
navigate('/production/overview')
navigate('/dashboard/production/overview')
} else if (key === 'inventory') {
navigate('/inventory/spools')
navigate('/dashboard/inventory/filamentstocks')
} else if (key === 'management') {
navigate('/management/filaments')
navigate('/dashboard/management/filaments')
}
}
return (
<Header
style={{ width: '100vw', padding: 0 }}
style={{ width: '100vw', padding: 0, background: 'unset' }}
theme='light'
className='ant-menu-horizontal'
>
<Flex
gap={'middle'}
align='center'
className='ant-menu-light ant-menu-horizontal'
style={{ padding: '0 24px', lineHeight: '64px', height: '100%' }}
className='ant-menu-light'
style={{
padding: '0 24px',
lineHeight: '64px',
height: '100%',
borderBottom: '1px solid rgba(5, 5, 5, 0.00)'
}}
>
<img src='/logo.svg' alt='Logo' width={180} />
<FarmControlLogo style={{ fontSize: '200px' }} />
<Menu
mode='horizontal'
items={mainMenuItems}
style={{
flexWrap: 'wrap',
flexGrow: 1
flexGrow: 1,
border: 0
}}
onClick={handleMainMenuClick}
selectedKeys={[selectedKey]}

View File

@ -1,17 +1,16 @@
// FilamentSelect.js
import { TreeSelect, Badge } from 'antd'
import React, { useEffect, useState, useContext, useRef } from 'react'
import React, { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import { AuthContext } from '../../Auth/AuthContext'
const propertyOrder = ['diameter', 'type', 'brand']
const propertyOrder = ['diameter', 'type', 'vendor.name']
const FilamentSelect = ({ onChange, filter, useFilter }) => {
const FilamentSelect = ({ onChange, filter, useFilter, value }) => {
const [filamentsTreeData, setFilamentsTreeData] = useState([])
const { token } = useContext(AuthContext)
const tokenRef = useRef(token)
const [loading, setLoading] = useState(true)
const [filamentsData, setFilamentsData] = useState([])
const [loading, setLoading] = useState(false)
const [defaultValue, setDefaultValue] = useState(value)
const fetchFilamentsData = async (property, filter) => {
setLoading(true)
@ -22,8 +21,9 @@ const FilamentSelect = ({ onChange, filter, useFilter }) => {
property
},
headers: {
Authorization: `Bearer ${tokenRef.current}`
}
Accept: 'application/json'
},
withCredentials: true
})
setLoading(false)
return response.data
@ -33,124 +33,164 @@ const FilamentSelect = ({ onChange, filter, useFilter }) => {
}
}
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
function getByPath(obj, path) {
return path.split('.').reduce((acc, part) => acc && acc[part], obj)
}
const generateFilamentTreeNodes = async (node = null, filter = null) => {
if (!node) {
return
const getFilter = useCallback(
(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
currentId = currentNode.pId
}
return filter
},
[filamentsTreeData]
)
const generateFilamentTreeNodes = useCallback(
async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
const filamentData = await fetchFilamentsData(null, filter)
setFilamentsData(filamentData)
for (var i = 0; i < filamentData.length; i++) {
const filament = filamentData[i]
const newNode = {
id: filament._id,
pId: node.id,
value: filament._id,
key: filament._id,
title: <Badge color={filament.color} text={filament.name} />,
isLeaf: true
}
setFilamentsTreeData((prev) => {
const filtered = prev.filter((node) => node.id !== newNode.id)
return [...filtered, newNode]
})
}
},
[filamentsTreeData, getFilter]
)
const generateFilamentCategoryTreeNodes = useCallback(
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)
for (var i = 0; i < propertyData.length; i++) {
const property = getByPath(propertyData[i], propertyName)
const newNode = {
id: property,
pId: node.id,
value: property,
key: property,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
setFilamentsTreeData((prev) => {
if (prev.some((node) => node.id === newNode.id)) {
return prev // already added
}
return [...prev, newNode]
})
}
},
[getFilter]
)
const handleFilamentsTreeLoad = useCallback(
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
}
},
[generateFilamentTreeNodes, generateFilamentCategoryTreeNodes]
)
const handleOnChange = (value, selectedOptions) => {
console.log('Handle onchange')
const filamentObject = filamentsData.filter(
(filament) => filament._id == value
)[0]
onChange(filamentObject, selectedOptions)
}
useEffect(() => {
if (value?._id != null) {
console.log('Setting default value...', value)
setDefaultValue(value)
}
}, [value])
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)
useEffect(() => {
console.log('Use Filter', useFilter)
if (defaultValue != undefined) {
const newNode = {
id: random,
pId: node.id,
value: filament._id,
key: filament._id,
title: <Badge color={filament.color} text={filament.name} />,
id: defaultValue._id,
pId: 0,
value: defaultValue._id,
key: defaultValue._id,
title: <Badge color={defaultValue.color} text={defaultValue.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
console.log('setting new node')
setFilamentsTreeData([newNode])
} else {
filter = getFilter(node)
propertyId = node.propertyId + 1
setFilamentsTreeData([])
}
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
}
if (useFilter === true) {
generateFilamentTreeNodes({ id: 0 }, filter)
} else {
await generateFilamentCategoryTreeNodes(null) // First property
handleFilamentsTreeLoad(null)
}
}
useEffect(() => {
setFilamentsTreeData([])
}, [token, filter, useFilter])
useEffect(() => {
if (filamentsTreeData.length === 0) {
if (useFilter === true) {
generateFilamentTreeNodes({ id: 0 }, filter)
} else {
handleFilamentsTreeLoad(null)
}
}
}, [filamentsTreeData])
}, [useFilter, defaultValue, filter])
return (
<TreeSelect
treeDataSimpleMode
value={defaultValue?._id}
loadData={handleFilamentsTreeLoad}
treeData={filamentsTreeData}
onChange={onChange}
onChange={handleOnChange}
loading={loading}
/>
)
@ -158,6 +198,7 @@ const FilamentSelect = ({ onChange, filter, useFilter }) => {
FilamentSelect.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.object,
filter: PropTypes.object,
useFilter: PropTypes.bool
}

View File

@ -0,0 +1,51 @@
import React from 'react'
import { Flex, Typography, Badge } from 'antd'
import PropTypes from 'prop-types'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import IdText from './IdText'
const { Text } = Typography
const FilamentStockDisplay = ({
filamentStock,
longId = false,
showIcon = true,
showColor = true,
showId = true,
showCopy = true
}) => {
FilamentStockDisplay.propTypes = {
filamentStock: PropTypes.shape({
_id: PropTypes.string.isRequired,
filament: PropTypes.shape({
name: PropTypes.string,
color: PropTypes.string
}),
currentNetWeight: PropTypes.number
}).isRequired,
longId: PropTypes.bool,
showIcon: PropTypes.bool,
showColor: PropTypes.bool,
showId: PropTypes.bool,
showCopy: PropTypes.bool
}
return (
<Flex gap={'small'} align='center'>
{showIcon && <FilamentStockIcon />}
{showColor && <Badge color={filamentStock.filament.color} />}
<Text>{`${filamentStock.filament?.name} (${filamentStock.currentNetWeight}g)`}</Text>
{showId && (
<IdText
id={filamentStock._id}
longId={longId}
type={'filamentstock'}
showCopy={showCopy}
/>
)}
</Flex>
)
}
export default FilamentStockDisplay

View File

@ -0,0 +1,139 @@
import { TreeSelect } from 'antd'
import React, { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import FilamentStockDisplay from './FilamentStockDisplay'
const FilamentStockSelect = ({ onChange, filter, useFilter, value }) => {
const [filamentStocksTreeData, setFilamentStocksTreeData] = useState([])
const [filamentStocksData, setFilamentStocksData] = useState([])
const [loading, setLoading] = useState(false)
const [defaultValue, setDefaultValue] = useState(value)
const getFilamentStockTitle = (filamentStock) => {
return (
<FilamentStockDisplay filamentStock={filamentStock} showCopy={false} />
)
}
const fetchFilamentStocksData = async (property, filter) => {
setLoading(true)
try {
const response = await axios.get('http://localhost:8080/filamentstocks', {
params: {
...filter,
property
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setLoading(false)
return response.data
} catch (err) {
console.error(err)
}
}
const generateFilamentStockTreeNodes = useCallback(
async (node = null, filter = null) => {
if (!node) {
return
}
const filamentStockData = await fetchFilamentStocksData(null, filter)
setFilamentStocksData(filamentStockData)
for (const filamentStock of filamentStockData) {
const newNode = {
id: filamentStock._id,
pId: node.id,
value: filamentStock._id,
key: filamentStock._id,
title: getFilamentStockTitle(filamentStock),
isLeaf: true
}
setFilamentStocksTreeData((prev) => {
const filtered = prev.filter((node) => node.id !== newNode.id)
return [...filtered, newNode]
})
}
},
[]
)
const handleFilamentStocksTreeLoad = useCallback(
async (node) => {
if (node) {
await generateFilamentStockTreeNodes(node)
} else {
await generateFilamentStockTreeNodes({ id: 0 })
}
},
[generateFilamentStockTreeNodes]
)
const handleOnChange = (value, selectedOptions) => {
const filamentStockObject = filamentStocksData.filter(
(filamentStock) => filamentStock._id === value
)[0]
onChange(filamentStockObject, selectedOptions)
}
useEffect(() => {
if (value?._id != null) {
setDefaultValue(value)
}
}, [value])
useEffect(() => {
if (defaultValue != undefined) {
const newNode = {
id: defaultValue._id,
pId: 0,
value: defaultValue._id,
key: defaultValue._id,
title: getFilamentStockTitle(defaultValue),
isLeaf: true
}
setFilamentStocksTreeData([newNode])
} else {
setFilamentStocksTreeData([])
}
if (useFilter === true) {
generateFilamentStockTreeNodes({ id: 0 }, filter)
} else {
handleFilamentStocksTreeLoad(null)
}
}, [useFilter, defaultValue, filter])
return (
<TreeSelect
treeDataSimpleMode
value={defaultValue?._id}
loadData={handleFilamentStocksTreeLoad}
treeData={filamentStocksTreeData}
onChange={handleOnChange}
loading={loading}
placeholder='Select a filament stock'
/>
)
}
FilamentStockSelect.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.object,
filter: PropTypes.object,
useFilter: PropTypes.bool
}
FilamentStockSelect.defaultProps = {
filter: {},
useFilter: false
}
export default FilamentStockSelect

View File

@ -0,0 +1,118 @@
import PropTypes from 'prop-types'
import { Badge, Progress, Flex, Space, Tag, Typography } from 'antd'
import { green, red } from '@ant-design/colors'
import React, { useState, useContext, useEffect } from 'react'
import { SocketContext } from '../context/SocketContext'
const FilamentStockState = ({
filamentStock,
showProgress = true,
showStatus = true,
showFilamentStockName = true
}) => {
const { socket } = useContext(SocketContext)
const [badgeStatus, setBadgeStatus] = useState('unknown')
const [badgeText, setBadgeText] = useState('Unknown')
const [currentState, setCurrentState] = useState(
filamentStock?.state || {
type: 'unknown',
progress: 0
}
)
const [initialized, setInitialized] = useState(false)
const { Text } = Typography
useEffect(() => {
if (socket && !initialized && filamentStock?._id) {
setInitialized(true)
socket.on('notify_filamentstock_update', (statusUpdate) => {
if (statusUpdate?.id === filamentStock._id && statusUpdate?.state) {
setCurrentState(statusUpdate.state)
}
})
}
return () => {
if (socket && initialized) {
socket.off('notify_filamentstock_update')
}
}
}, [socket, initialized, filamentStock?._id])
useEffect(() => {
switch (currentState.type) {
case 'unconsumed':
setBadgeStatus('success')
setBadgeText('Unconsumed')
break
case 'partiallyconsumed':
setBadgeStatus('warning')
setBadgeText('Partially Consumed')
break
case 'fullyconsumed':
setBadgeStatus('error')
setBadgeText('Fully Consumed')
break
case 'error':
setBadgeStatus('error')
setBadgeText('Error')
break
default:
setBadgeStatus('default')
setBadgeText(currentState.type)
}
}, [currentState])
return (
<Flex gap='middle' align={'center'}>
{showFilamentStockName && <Text>{filamentStock.name}</Text>}
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
<Flex gap={6}>
<Badge status={badgeStatus} />
{badgeText}
</Flex>
</Tag>
</Space>
)}
{showProgress && currentState.type === 'partiallyconsumed' ? (
<Progress
percent={Math.round(currentState.percent * 100)}
style={{ width: '150px', marginBottom: '2px' }}
steps={7}
strokeColor={[
green[5],
green[5],
green[5],
green[4],
green[3],
red[4],
red[5]
]}
/>
) : null}
</Flex>
)
}
FilamentStockState.propTypes = {
filamentStock: PropTypes.shape({
_id: PropTypes.string,
name: PropTypes.string,
state: PropTypes.shape({
type: PropTypes.oneOf([
'unconsumed',
'partiallyconsumed',
'fullyconsumed',
'error',
'unknown'
]),
progress: PropTypes.number
})
}),
showProgress: PropTypes.bool,
showStatus: PropTypes.bool,
showFilamentStockName: PropTypes.bool
}
export default FilamentStockState

View File

@ -1,12 +1,18 @@
// GCodeFileSelect.js
import PropTypes from 'prop-types'
import { TreeSelect, Badge, Space, message } from 'antd'
import { TreeSelect, Badge, Flex, message, Typography } 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 propertyOrder = [
'filament.diameter',
'filament.type',
'filament.vendor.name'
]
const { Text } = Typography
const GCodeFileSelect = ({ onChange, filter, useFilter }) => {
const [gcodeFilesTreeData, setGCodeFilesTreeData] = useState(null)
@ -90,13 +96,13 @@ const GCodeFileSelect = ({ onChange, filter, useFilter }) => {
value: gcodeFile._id,
key: gcodeFile._id,
title: (
<Space>
<Flex gap={'small'} align='center'>
<GCodeFileIcon />
<Badge
color={gcodeFile.filament.color}
text={gcodeFile.name + ' (' + gcodeFile.filament.name + ')'}
/>
</Space>
<Badge color={gcodeFile.filament.color} />
<Text ellipsis>
{gcodeFile.name + ' (' + gcodeFile.filament.name + ')'}
</Text>
</Flex>
),
isLeaf: true
}
@ -106,6 +112,10 @@ const GCodeFileSelect = ({ onChange, filter, useFilter }) => {
return newNodeList
}
function getByPath(obj, path) {
return path.split('.').reduce((acc, part) => acc && acc[part], obj)
}
const generateGCodeFileCategoryTreeNodes = async (node = null) => {
var filter = {}
@ -126,8 +136,7 @@ const GCodeFileSelect = ({ onChange, filter, useFilter }) => {
const newNodeList = []
for (var i = 0; i < propertyData.length; i++) {
const property =
propertyData[i][propertyName.split('.')[0]][propertyName.split('.')[1]]
const property = getByPath(propertyData[i], propertyName)
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
@ -193,6 +202,7 @@ const GCodeFileSelect = ({ onChange, filter, useFilter }) => {
onChange={onChange}
onSearch={handleGCodeFilesSearch}
loading={loading}
placeholder='Select GCode File'
/>
)
}

View File

@ -23,40 +23,52 @@ const IdText = ({
switch (type) {
case 'printer':
prefix = 'PRN'
hyperlink = `/production/printers/info?printerId=${id}`
hyperlink = `/dashboard/production/printers/info?printerId=${id}`
break
case 'filament':
prefix = 'FIL'
hyperlink = `/management/filaments/info?filamentId=${id}`
hyperlink = `/dashboard/management/filaments/info?filamentId=${id}`
break
case 'spool':
prefix = 'SPL'
hyperlink = `/inventory/spool/info?spoolId=${id}`
hyperlink = `/dashboard/inventory/spool/info?spoolId=${id}`
break
case 'gcodeFile':
prefix = 'GCF'
hyperlink = `/production/gcodefiles/info?gcodeFileId=${id}`
hyperlink = `/dashboard/production/gcodefiles/info?gcodeFileId=${id}`
break
case 'job':
prefix = 'JOB'
hyperlink = `/production/printjobs/info?printJobId=${id}`
hyperlink = `/dashboard/production/printjobs/info?printJobId=${id}`
break
case 'part':
prefix = 'PRT'
hyperlink = `/management/parts/info?partId=${id}`
hyperlink = `/dashboard/management/parts/info?partId=${id}`
break
case 'product':
prefix = 'PRD'
hyperlink = `/management/products/info?productId=${id}`
hyperlink = `/dashboard/management/products/info?productId=${id}`
break
case 'vendor':
prefix = 'VEN'
hyperlink = `/management/vendors/info?vendorId=${id}`
hyperlink = `/dashboard/management/vendors/info?vendorId=${id}`
break
case 'subjob':
prefix = 'SJB'
hyperlink = `#`
break
case 'filamentstock':
prefix = 'FLS'
hyperlink = `/dashboard/inventory/filamentstocks/info?filamentStockId=${id}`
break
case 'partstock':
prefix = 'PTS'
hyperlink = `/dashboard/management/partstocks/info?partStockId=${id}`
break
case 'productstock':
prefix = 'PDS'
hyperlink = `/dashboard/management/productstocks/info?productStockId=${id}`
break
default:
hyperlink = `#`
prefix = 'UNK'

View File

@ -1,57 +1,87 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { Layout, Menu } from 'antd'
import { Layout, Menu, Flex, Button } from 'antd'
import {
DashboardOutlined,
InboxOutlined,
HistoryOutlined
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons'
import FilamentStockIcon from '../../Icons/FilamentStockIcon'
import PartStockIcon from '../../Icons/PartStockIcon'
import ProductStockIcon from '../../Icons/ProductStockIcon'
const { Sider } = Layout
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed'
const InventorySidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('inventory')
const [collapsed, setCollapsed] = useState(() => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 1) {
setSelectedKey(pathParts[1]) // Return the section (inventory/management)
if (pathParts.length > 2) {
setSelectedKey(pathParts[2]) // Return the section (inventory/management)
}
}, [location.pathname])
const handleCollapse = (newCollapsed) => {
setCollapsed(newCollapsed)
sessionStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newCollapsed))
}
const items = [
{
key: 'overview',
label: <Link to='/inventory/overview'>Overview</Link>,
label: <Link to='/dashboard/inventory/overview'>Overview</Link>,
icon: <DashboardOutlined />
},
{
key: 'spools',
label: <Link to='/inventory/spools'>Spools</Link>,
icon: <InboxOutlined />
key: 'filamentstocks',
label: <Link to='/dashboard/inventory/filamentstocks'>Filaments</Link>,
icon: <FilamentStockIcon />
},
{
key: 'stock',
label: <Link to='/inventory/stock'>Stock</Link>,
icon: <InboxOutlined />
key: 'partstocks',
label: <Link to='/dashboard/inventory/partstocks'>Parts</Link>,
icon: <PartStockIcon />
},
{
key: 'history',
label: <Link to='/inventory/history'>History</Link>,
icon: <HistoryOutlined />
key: 'productstocks',
label: <Link to='/dashboard/inventory/productstocks'>Products</Link>,
icon: <ProductStockIcon />
}
]
return (
<Sider width={250}>
<Menu
mode='inline'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
<Sider width={250} theme='light' collapsed={collapsed}>
<Flex
style={{ height: '100%' }}
items={items}
/>
vertical
className='ant-menu-root ant-menu-inline ant-menu-light'
>
<Menu
mode='inline'
selectedKeys={[selectedKey]}
defaultSelectedKeys={['overview']}
style={{ height: '100%', flexGrow: 1, border: 'none' }}
items={items}
_internalDisableMenuItemTitleTooltip
/>
<Flex style={{ padding: '4px', width: '100%' }}>
<Button
size='large'
type='text'
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
style={{ flexGrow: 1 }}
onClick={() => handleCollapse(!collapsed)}
/>
</Flex>
</Flex>
</Sider>
)
}

View File

@ -4,8 +4,6 @@ import { Layout, Menu, Flex, Button } from 'antd'
import {
SettingOutlined,
AuditOutlined,
ShopOutlined,
BlockOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons'
@ -13,55 +11,67 @@ import {
import FilamentIcon from '../../Icons/FilamentIcon'
import PartIcon from '../../Icons/PartIcon'
import ProductIcon from '../../Icons/ProductIcon'
import VendorIcon from '../../Icons/VendorIcon'
import MaterialIcon from '../../Icons/MaterialIcon'
const { Sider } = Layout
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed'
const ManagementSidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(() => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 1) {
setSelectedKey(pathParts[1]) // Return the section (production/management)
if (pathParts.length > 2) {
setSelectedKey(pathParts[2]) // Return the section (production/management)
}
}, [location.pathname])
const handleCollapse = (newCollapsed) => {
setCollapsed(newCollapsed)
sessionStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newCollapsed))
}
const items = [
{
key: 'filaments',
label: <Link to='/management/filaments'>Filaments</Link>,
label: <Link to='/dashboard/management/filaments'>Filaments</Link>,
icon: <FilamentIcon />
},
{
key: 'parts',
label: <Link to='/management/parts'>Parts</Link>,
label: <Link to='/dashboard/management/parts'>Parts</Link>,
icon: <PartIcon />
},
{
key: 'products',
label: <Link to='/management/products'>Products</Link>,
label: <Link to='/dashboard/management/products'>Products</Link>,
icon: <ProductIcon />
},
{
key: 'vendors',
label: <Link to='/management/vendors'>Vendors</Link>,
icon: <ShopOutlined />
label: <Link to='/dashboard/management/vendors'>Vendors</Link>,
icon: <VendorIcon />
},
{
key: 'materials',
label: <Link to='/management/products'>Materials</Link>,
icon: <BlockOutlined />
label: <Link to='/dashboard/management/materials'>Materials</Link>,
icon: <MaterialIcon />
},
{
key: 'settings',
label: <Link to='/management/settings'>Settings</Link>,
label: <Link to='/dashboard/management/settings'>Settings</Link>,
icon: <SettingOutlined />
},
{
key: 'audit',
label: <Link to='/management/audit'>Audit Log</Link>,
label: <Link to='/dashboard/management/audit'>Audit Log</Link>,
icon: <AuditOutlined />
}
]
@ -77,6 +87,7 @@ const ManagementSidebar = () => {
selectedKeys={[selectedKey]}
defaultSelectedKeys={['filaments']}
items={items}
_internalDisableMenuItemTitleTooltip
style={{ flexGrow: 1, border: 'none' }}
/>
<Flex style={{ padding: '4px', width: '100%' }}>
@ -85,9 +96,7 @@ const ManagementSidebar = () => {
type='text'
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
style={{ flexGrow: 1 }}
onClick={() => {
setCollapsed(!collapsed)
}}
onClick={() => handleCollapse(!collapsed)}
/>
</Flex>
</Flex>

View File

@ -0,0 +1,207 @@
import { Transfer, Tree, Badge, Spin } 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 = ['products']
const PartTransfer = ({
onChange,
filter,
useFilter,
selectedKeys: initialSelectedKeys
}) => {
const [partsTreeData, setPartsTreeData] = useState([])
const [targetKeys, setTargetKeys] = useState(initialSelectedKeys || [])
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
} catch (err) {
console.error(err)
setLoading(false)
return []
}
}
const getFilter = (node) => {
let filter = {}
let currentId = node.id
while (currentId !== 0) {
const currentNode = partsTreeData.find(
(treeData) => treeData.id === currentId
)
if (currentNode) {
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
} else {
break
}
}
return filter
}
const generatePartTreeNodes = async (node = null, filter = null) => {
if (!node) return
const actualFilter = filter === null ? getFilter(node) : filter
const partData = await fetchPartsData(null, actualFilter)
const newNodeList = partData.map((part) => ({
id: part._id,
pId: node.id,
value: part._id,
key: part._id,
title: <Badge color={part.color} text={part.name} />,
isLeaf: true
}))
setPartsTreeData((prev) => [...prev, ...newNodeList])
}
const generatePartCategoryTreeNodes = async (node = null) => {
let filter = {}
let propertyId = 0
if (node) {
filter = getFilter(node)
propertyId = node.propertyId + 1
}
const propertyName = propertyOrder[propertyId]
const propertyData = await fetchPartsData(propertyName, filter)
const newNodeList = propertyData.map((data) => {
const property = data[propertyName]
const random = Math.random().toString(36).substring(2, 6)
return {
id: random,
pId: node ? node.id : '0',
value: `${property}-${random}`,
key: `${property}-${random}`,
propertyId: propertyId,
title: property,
isLeaf: false,
selectable: false
}
})
setPartsTreeData((prev) => [...prev, ...newNodeList])
}
const handleTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generatePartCategoryTreeNodes(node)
} else {
await generatePartTreeNodes(node)
}
} else {
await generatePartCategoryTreeNodes(null)
}
}
useEffect(() => {
setPartsTreeData([])
}, [token, filter, useFilter])
useEffect(() => {
if (partsTreeData.length === 0) {
if (useFilter) {
generatePartTreeNodes({ id: '0' }, filter)
} else {
handleTreeLoad(null)
}
}
}, [partsTreeData.length])
const transferDataSource = partsTreeData.filter((node) => node.isLeaf)
const renderTransferItem = (item) => item.title
const handleTransferChange = (newTargetKeys) => {
setTargetKeys(newTargetKeys)
onChange(newTargetKeys)
}
const renderSourceList = ({ onItemSelect }) => {
const treeData = partsTreeData
.map((node) => ({
...node,
children: partsTreeData
.filter((child) => child.pId === node.id)
.map((child) => ({
...child,
children: partsTreeData.filter(
(grandChild) => grandChild.pId === child.id
)
}))
}))
.filter((node) => !node.pId)
return (
<Tree
loadData={(node) => handleTreeLoad(node)}
treeData={treeData}
onSelect={(selectedKeys, { node }) => {
if (node.isLeaf) {
onItemSelect(node.key, !selectedKeys.includes(node.key))
}
}}
/>
)
}
if (loading && partsTreeData.length === 0) {
return <Spin />
}
return (
<Transfer
dataSource={transferDataSource}
targetKeys={targetKeys}
onChange={handleTransferChange}
render={renderTransferItem}
showSelectAll={true}
oneWay={false}
pagination
listStyle={{
width: 300,
height: 400
}}
>
{renderSourceList}
</Transfer>
)
}
PartTransfer.propTypes = {
onChange: PropTypes.func.isRequired,
filter: PropTypes.object,
useFilter: PropTypes.bool,
selectedKeys: PropTypes.arrayOf(PropTypes.string)
}
PartTransfer.defaultProps = {
filter: {},
useFilter: false,
selectedKeys: []
}
export default PartTransfer

View File

@ -0,0 +1,52 @@
import React from 'react'
import { Table } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import IdText from './IdText'
import PartIcon from '../../Icons/PartIcon'
import PropTypes from 'prop-types'
const PartsTable = ({ data = [], loading = false, showHeader = false }) => {
const columns = [
{
title: '',
dataIndex: '',
key: '',
width: 40,
fixed: 'left',
render: () => <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'} showHyperlink={true} />
}
]
return (
<Table
dataSource={data}
columns={columns}
pagination={false}
rowKey='_id'
showHeader={showHeader}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
/>
)
}
PartsTable.propTypes = {
data: PropTypes.array,
loading: PropTypes.bool,
showHeader: PropTypes.bool
}
export default PartsTable

View File

@ -6,14 +6,16 @@ import axios from 'axios'
import PrinterState from './PrinterState'
import { AuthContext } from '../../Auth/AuthContext'
const PrinterSelect = ({ onChange, disabled, checkable }) => {
const PrinterSelect = ({ onChange, disabled, checkable, value = [] }) => {
const [printersTreeData, setPrintersTreeData] = useState([])
const [printersData, setPrintersData] = useState([])
const [loading, setLoading] = useState(true)
const [messageApi] = message.useMessage()
const [defaultValue, setDefaultValue] = useState(value)
const { authenticated } = useContext(AuthContext)
const fetchPrintersData = async () => {
const fetchPrintersTreeData = async () => {
if (!authenticated) {
return
}
@ -41,7 +43,9 @@ const PrinterSelect = ({ onChange, disabled, checkable }) => {
}
const generatePrinterItems = async () => {
const printerData = await fetchPrintersData()
const printerData = await fetchPrintersTreeData()
setPrintersData(printerData)
// Create a map to store tags and their printers
const tagMap = new Map()
@ -65,36 +69,66 @@ const PrinterSelect = ({ onChange, disabled, checkable }) => {
})
// 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
}))
}))
Array.from(tagMap.entries()).map(([tag, printers]) => {
const newNode = {
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
}))
}
setPrintersTreeData((prev) => {
const filtered = prev.filter((node) => node.key !== newNode.key)
return [...filtered, newNode]
})
})
}
setPrintersData(treeData)
const handleOnChange = (value, selectedOptions) => {
if (checkable) {
// Multiple selection mode
const newValue = printersData.filter((printer) =>
value.includes(printer._id)
)
setDefaultValue(newValue)
onChange(newValue, selectedOptions)
} else {
// Single selection mode
const selectedPrinter = printersData.find(
(printer) => printer._id === value
)
setDefaultValue(selectedPrinter ? [selectedPrinter] : [])
onChange(selectedPrinter, selectedOptions)
}
}
useEffect(() => {
if (printersData.length === 0) {
generatePrinterItems()
if (value) {
if (Array.isArray(value)) {
setDefaultValue(value)
} else {
setDefaultValue([value])
}
}
}, [value])
useEffect(() => {
generatePrinterItems()
}, [])
return (
<TreeSelect
treeData={printersData}
onChange={onChange}
treeData={printersTreeData}
onChange={handleOnChange}
loading={loading}
disabled={disabled}
treeDefaultExpandAll
@ -102,6 +136,9 @@ const PrinterSelect = ({ onChange, disabled, checkable }) => {
treeNodeFilterProp='title'
placeholder='Select printer'
style={{ width: '100%' }}
value={
checkable ? defaultValue.map((item) => item._id) : defaultValue[0]?._id
}
/>
)
}
@ -109,7 +146,8 @@ const PrinterSelect = ({ onChange, disabled, checkable }) => {
PrinterSelect.propTypes = {
onChange: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
checkable: PropTypes.bool
checkable: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.object, PropTypes.array])
}
export default PrinterSelect

View File

@ -106,7 +106,7 @@ const PrinterState = ({
return (
<Flex gap='small' align={'center'}>
{showPrinterName && <Text>{printer.printerName}</Text>}
{showPrinterName && <Text>{printer.name}</Text>}
{showStatus && (
<Space>
<Tag color={badgeStatus} style={{ marginRight: 0 }}>
@ -174,7 +174,7 @@ const PrinterState = ({
PrinterState.propTypes = {
printer: PropTypes.shape({
id: PropTypes.string,
printerName: PropTypes.string,
name: PropTypes.string,
state: PropTypes.shape({
type: PropTypes.string,
progress: PropTypes.number

View File

@ -31,8 +31,12 @@ const CustomCollapse = styled(Collapse)`
const PrinterTemperaturePanel = ({
printerId,
showControls = true,
showMoreInfo = true
showHotEndControls = true,
showHeatedBedControls = true,
showHotEnd = true,
showHeatedBed = true,
showMoreInfo = true,
shouldUnsubscribe = true
}) => {
const [temperatureData, setTemperatureData] = useState({
hotEnd: {},
@ -106,7 +110,7 @@ const PrinterTemperaturePanel = ({
socket.on('notify_status_update', notifyStatusUpdate)
}
return () => {
if (socket && initialized) {
if (socket && initialized && shouldUnsubscribe == true) {
console.log('Unsubscribing...')
socket.off('notify_status_update', notifyStatusUpdate)
socket.emit('printer.objects.unsubscribe', params)
@ -177,7 +181,7 @@ const PrinterTemperaturePanel = ({
<div style={{ minWidth: 190 }}>
{temperatureData ? (
<Flex vertical gap='middle'>
{temperatureData.hotEnd && (
{temperatureData.hotEnd && showHotEnd && (
<Flex vertical gap={0}>
<Text>
Hot End: {temperatureData.hotEnd.current}°C /{' '}
@ -192,7 +196,7 @@ const PrinterTemperaturePanel = ({
}}
showInfo={false}
/>
{showControls === true && (
{showHotEndControls && (
<Space direction='horizontal' style={{ marginTop: 5 }}>
<Space.Compact block size='small'>
<InputNumber
@ -208,6 +212,7 @@ const PrinterTemperaturePanel = ({
/>
<Button
type='default'
style={{ width: 40 }}
onClick={() =>
handleSetTemperatureClick('extruder', hotEndTemperature)
}
@ -218,6 +223,7 @@ const PrinterTemperaturePanel = ({
<Button
type='default'
size='small'
style={{ width: 40 }}
onClick={() => handleSetTemperatureClick('extruder', 0)}
>
Off
@ -227,7 +233,7 @@ const PrinterTemperaturePanel = ({
</Flex>
)}
{temperatureData.heatedBed && (
{temperatureData.heatedBed && showHeatedBed && (
<Flex vertical gap={0}>
<Text>
Heated Bed: {temperatureData.heatedBed.current}°C /{' '}
@ -242,7 +248,7 @@ const PrinterTemperaturePanel = ({
}}
showInfo={false}
/>
{showControls === true && (
{showHeatedBedControls && (
<Space direction='horizontal' style={{ marginTop: 5 }}>
<Space.Compact block size='small'>
<InputNumber
@ -261,6 +267,7 @@ const PrinterTemperaturePanel = ({
/>
<Button
type='default'
style={{ width: 40 }}
onClick={() =>
handleSetTemperatureClick(
'heater_bed',
@ -274,6 +281,7 @@ const PrinterTemperaturePanel = ({
<Button
type='default'
size='small'
style={{ width: 40 }}
onClick={() => handleSetTemperatureClick('heater_bed', 0)}
>
Off
@ -297,8 +305,12 @@ const PrinterTemperaturePanel = ({
PrinterTemperaturePanel.propTypes = {
printerId: PropTypes.string.isRequired,
showControls: PropTypes.bool,
showMoreInfo: PropTypes.bool
showHotEndControls: PropTypes.bool,
showHeatedBedControls: PropTypes.bool,
showHotEnd: PropTypes.bool,
showHeatedBed: PropTypes.bool,
showMoreInfo: PropTypes.bool,
shouldUnsubscribe: PropTypes.bool
}
export default PrinterTemperaturePanel

View File

@ -12,36 +12,47 @@ import GCodeFileIcon from '../../Icons/GCodeFileIcon'
const { Sider } = Layout
const SIDEBAR_COLLAPSED_KEY = 'sidebar_collapsed'
const ProductionSidebar = () => {
const location = useLocation()
const [selectedKey, setSelectedKey] = useState('production')
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(() => {
const savedState = sessionStorage.getItem(SIDEBAR_COLLAPSED_KEY)
return savedState ? JSON.parse(savedState) : false
})
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
if (pathParts.length > 1) {
setSelectedKey(pathParts[1]) // Return the section (production/management)
if (pathParts.length > 2) {
setSelectedKey(pathParts[2]) // Return the section (production/management)
}
}, [location.pathname])
const handleCollapse = (newCollapsed) => {
setCollapsed(newCollapsed)
sessionStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(newCollapsed))
}
const items = [
{
key: 'overview',
label: <Link to='/production/overview'>Overview</Link>,
label: <Link to='/dashboard/production/overview'>Overview</Link>,
icon: <DashboardOutlined />
},
{
key: 'printers',
label: <Link to='/production/printers'>Printers</Link>,
label: <Link to='/dashboard/production/printers'>Printers</Link>,
icon: <PrinterOutlined />
},
{
key: 'printjobs',
label: <Link to='/production/printjobs'>Print Jobs</Link>,
label: <Link to='/dashboard/production/printjobs'>Print Jobs</Link>,
icon: <PlayCircleOutlined />
},
{
key: 'gcodefiles',
label: <Link to='/production/gcodefiles'>G Code Files</Link>,
label: <Link to='/dashboard/production/gcodefiles'>G Code Files</Link>,
icon: <GCodeFileIcon />
}
]
@ -58,6 +69,7 @@ const ProductionSidebar = () => {
defaultSelectedKeys={['overview']}
items={items}
style={{ flexGrow: 1, border: 'none' }}
_internalDisableMenuItemTitleTooltip
/>
<Flex style={{ padding: '4px', width: '100%' }}>
<Button
@ -65,9 +77,7 @@ const ProductionSidebar = () => {
type='text'
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
style={{ flexGrow: 1 }}
onClick={() => {
setCollapsed(!collapsed)
}}
onClick={() => handleCollapse(!collapsed)}
/>
</Flex>
</Flex>

View File

@ -0,0 +1,89 @@
import React from 'react'
import { Table } from 'antd'
import PropTypes from 'prop-types'
import moment from 'moment'
import IdText from './IdText'
const StockEventTable = ({ stockEvents }) => {
const columns = [
{
title: 'Type',
dataIndex: 'type',
key: 'type',
render: (type) => type.charAt(0).toUpperCase() + type.slice(1)
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
render: (value) => value.toFixed(2) + 'g'
},
{
title: 'Sub Job ID',
render: (record) =>
record.subJob ? (
<IdText
id={record.subJob.number.toString().padStart(6, '0')}
longId={false}
type={'subjob'}
/>
) : (
'n/a'
)
},
{
title: 'Job ID',
render: (record) =>
record.subJob ? (
<IdText id={record.job._id} longId={false} type={'job'} />
) : (
'n/a'
)
},
{
title: 'Timestamp',
dataIndex: ['timestamp', '$date'],
key: 'timestamp',
render: (timestamp) => {
if (timestamp) {
const formattedDate = moment(timestamp).format('YYYY-MM-DD HH:mm:ss')
return <span>{formattedDate}</span>
} else {
return 'n/a'
}
}
}
]
return (
<Table
dataSource={stockEvents}
columns={columns}
rowKey={(record) => record._id.$oid}
pagination={false}
/>
)
}
StockEventTable.propTypes = {
stockEvents: PropTypes.arrayOf(
PropTypes.shape({
type: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
subJobId: PropTypes.shape({
$oid: PropTypes.string.isRequired
}),
jobId: PropTypes.shape({
$oid: PropTypes.string.isRequired
}),
timestamp: PropTypes.shape({
$date: PropTypes.string.isRequired
}),
_id: PropTypes.shape({
$oid: PropTypes.string.isRequired
}).isRequired
})
).isRequired
}
export default StockEventTable

View File

@ -6,6 +6,7 @@ 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([])
@ -35,7 +36,7 @@ const SubJobsTree = ({ printJobData }) => {
title: printerData.state ? (
<PrinterState
printer={printerData}
text={printerData.printerName}
text={printerData.name}
showProgress={false}
/>
) : (

View File

@ -0,0 +1,186 @@
import { TreeSelect, Space } from 'antd'
import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import axios from 'axios'
import CountryDisplay from './CountryDisplay'
import VendorIcon from '../../Icons/VendorIcon'
const propertyOrder = ['country']
const VendorSelect = ({ onChange, filter = {}, useFilter = false, value }) => {
const [vendorsTreeData, setVendorsTreeData] = useState([])
const [loading, setLoading] = useState(true)
const [defaultValue, setDefaultValue] = useState(null)
const fetchVendorsData = async (property, filter) => {
setLoading(true)
try {
const response = await axios.get('http://localhost:8080/vendors', {
params: {
...filter,
property
},
headers: {
Accept: 'application/json'
},
withCredentials: true
})
setLoading(false)
return response.data
} catch (err) {
console.error(err)
}
}
const getFilter = (node) => {
var filter = {}
var currentId = node.id
while (currentId != 0) {
const currentNode = vendorsTreeData.filter(
(treeData) => treeData['id'] === currentId
)[0]
filter[propertyOrder[currentNode.propertyId]] =
currentNode.value.split('-')[0]
currentId = currentNode.pId
}
return filter
}
const generateVendorTreeNodes = async (node = null, filter = null) => {
if (!node) {
return
}
if (filter === null) {
filter = getFilter(node)
}
const vendorData = await fetchVendorsData(null, filter)
let newNodeList = []
for (const vendor of vendorData) {
const random = Math.random().toString(36).substring(2, 6)
const newNode = {
id: random,
pId: node.id,
value: vendor._id,
vendor: vendor,
key: vendor._id,
title: (
<Space>
<VendorIcon />
{vendor.name}
</Space>
),
isLeaf: true
}
newNodeList.push(newNode)
}
setVendorsTreeData(vendorsTreeData.concat(newNodeList))
}
const generateVendorCategoryTreeNodes = 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 fetchVendorsData(propertyName, filter)
const newNodeList = []
for (const item of propertyData) {
const property = item[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: <CountryDisplay countryCode={property} />,
isLeaf: false,
selectable: false
}
newNodeList.push(newNode)
}
setVendorsTreeData(vendorsTreeData.concat(newNodeList))
}
const handleVendorsTreeLoad = async (node) => {
if (node) {
if (node.propertyId !== propertyOrder.length - 1) {
await generateVendorCategoryTreeNodes(node)
} else {
await generateVendorTreeNodes(node) // End of properties
}
} else {
await generateVendorCategoryTreeNodes(null) // First property
}
}
const handleOnChange = (value, selectedOptions) => {
const vendorObject = vendorsTreeData.find(
(node) => node.value === value
)?.vendor
onChange(vendorObject, selectedOptions)
}
useEffect(() => {
setVendorsTreeData([])
}, [])
useEffect(() => {
if (vendorsTreeData.length === 0) {
if (useFilter === true) {
generateVendorTreeNodes({ id: 0 }, filter)
} else {
handleVendorsTreeLoad(null)
}
}
}, [vendorsTreeData])
useEffect(() => {
if (value?.name) {
setDefaultValue(value.name)
}
}, [value])
return (
<TreeSelect
treeDataSimpleMode
loadData={handleVendorsTreeLoad}
treeData={vendorsTreeData}
onChange={handleOnChange}
loading={loading}
placeholder='Select a vendor'
style={{ width: '100%' }}
value={defaultValue}
/>
)
}
VendorSelect.propTypes = {
onChange: PropTypes.func,
filter: PropTypes.object,
useFilter: PropTypes.bool,
value: PropTypes.object
}
export default VendorSelect

View File

@ -0,0 +1,203 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import PropTypes from 'prop-types'
const HistoryContext = createContext()
export const HistoryProvider = ({ children }) => {
const [navigationHistory, setNavigationHistory] = useState([])
const [currentPosition, setCurrentPosition] = useState(-1)
const navigate = useNavigate()
const location = useLocation()
// Base route names
const baseRouteNames = {
'/production': 'Production',
'/management': 'Management',
'/dashboard/production/gcodefiles': 'GCode Files',
'/dashboard/management/filaments': 'Filaments',
'/dashboard/management/parts': 'Parts',
'/dashboard/management/products': 'Products',
'/dashboard/management/vendors': 'Vendors'
}
const getEntityDetails = (pathname, search) => {
const searchParams = new URLSearchParams(search)
// Handle different entity types
if (pathname.includes('/gcodefiles/info')) {
const gcodeFileId = searchParams.get('gcodeFileId')
return {
type: 'gcodefile',
id: gcodeFileId,
displayName: `GCode File Info${gcodeFileId ? ` (${gcodeFileId})` : ''}`
}
}
if (pathname.includes('/filaments/info')) {
const filamentId = searchParams.get('filamentId')
return {
type: 'filament',
id: filamentId,
displayName: `Filament Info${filamentId ? ` (${filamentId})` : ''}`
}
}
if (pathname.includes('/parts/info')) {
const partId = searchParams.get('partId')
return {
type: 'part',
id: partId,
displayName: `Part Info${partId ? ` (${partId})` : ''}`
}
}
if (pathname.includes('/products/info')) {
const productId = searchParams.get('productId')
return {
type: 'product',
id: productId,
displayName: `Product Info${productId ? ` (${productId})` : ''}`
}
}
if (pathname.includes('/vendors/info')) {
const vendorId = searchParams.get('vendorId')
return {
type: 'vendor',
id: vendorId,
displayName: `Vendor Info${vendorId ? ` (${vendorId})` : ''}`
}
}
// For base routes, return the simple name
const baseName = baseRouteNames[pathname]
if (baseName) {
return {
type: 'base',
displayName: baseName
}
}
return null
}
// Track location changes
useEffect(() => {
const newPath = location.pathname
const details = getEntityDetails(location.pathname, location.search)
if (
newPath === '/dashboard/production/gcodefiles' ||
newPath === '/dashboard/management/filaments'
) {
setNavigationHistory([
{
path: newPath + location.search,
details,
timestamp: new Date().toISOString()
}
])
setCurrentPosition(0)
} else if (details) {
setNavigationHistory((prev) => {
const newHistory = prev.slice(0, currentPosition + 1)
return [
...newHistory,
{
path: newPath + location.search,
details,
timestamp: new Date().toISOString()
}
]
})
setCurrentPosition((prev) => prev + 1)
}
}, [location])
const goBack = () => {
if (currentPosition > 0) {
setCurrentPosition((prev) => prev - 1)
navigate(-1)
}
}
const goForward = () => {
if (currentPosition < navigationHistory.length - 1) {
setCurrentPosition((prev) => prev + 1)
navigate(1)
}
}
const getBreadcrumbItems = () => {
// If there's no history, return empty array
if (navigationHistory.length === 0) return []
// Get current location
const currentItem = navigationHistory[currentPosition]
if (!currentItem) return []
const pathParts = currentItem.path.split('/')
const breadcrumbs = []
// Build up breadcrumbs including parent routes
if (pathParts[1]) {
// First level (e.g. /production or /management)
const firstLevelPath = '/' + pathParts[1]
breadcrumbs.push({
path: firstLevelPath,
title: baseRouteNames[firstLevelPath]
})
}
if (pathParts[2]) {
// Second level (e.g. /dashboard/production/gcodefiles)
const secondLevelPath =
'/' + pathParts[1] + '/' + pathParts[2].split('?')[0]
breadcrumbs.push({
path: currentItem.path,
title:
baseRouteNames[secondLevelPath] || currentItem.details.displayName
})
}
// Add the entity detail level if it exists (e.g. specific gcodefile, filament, etc)
if (currentItem.details.type !== 'base') {
breadcrumbs.push({
path: currentItem.path,
title: currentItem.details.displayName
})
}
return breadcrumbs
}
const canGoBack = currentPosition > 0
const canGoForward = currentPosition < navigationHistory.length - 1
return (
<HistoryContext.Provider
value={{
navigationHistory,
currentPosition,
goBack,
goForward,
canGoBack,
canGoForward,
getBreadcrumbItems
}}
>
{children}
</HistoryContext.Provider>
)
}
export const useHistory = () => {
const context = useContext(HistoryContext)
if (!context) {
throw new Error('useHistory must be used within a HistoryProvider')
}
return context
}
HistoryProvider.propTypes = {
children: PropTypes.node.isRequired
}
export default HistoryContext

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,11 @@ const PublicRoute = ({ component: Component }) => {
}
// Redirect to login if not authenticated
return !authenticated ? <Component /> : <Navigate to='/production/overview' />
return !authenticated ? (
<Component />
) : (
<Navigate to='/dashboard/production/overview' />
)
}
PublicRoute.propTypes = {