Added filament stock functionality and improves some performance
440
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
body,
|
||||
.ant-typography {
|
||||
font-family: 'SF Pro';
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
112
src/App.jsx
@ -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>
|
||||
|
||||
BIN
src/assets/icons/filamentstockicon.afdesign
Normal file
14
src/assets/icons/filamentstockicon.svg
Normal 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 |
@ -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 |
BIN
src/assets/icons/materialicon.afdesign
Normal file
12
src/assets/icons/materialicon.svg
Normal 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 |
BIN
src/assets/icons/partstockicon.afdesign
Normal file
11
src/assets/icons/partstockicon.svg
Normal 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 |
BIN
src/assets/icons/printedparticon.afdesign
Normal file
7
src/assets/icons/printedparticon.svg
Normal 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 |
@ -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 |
BIN
src/assets/icons/productstockicon.afdesign
Normal file
11
src/assets/icons/productstockicon.svg
Normal 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 |
BIN
src/assets/icons/spoolicon.afdesign
Normal file
11
src/assets/icons/spoolicon.svg
Normal 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 |
BIN
src/assets/icons/vendoricon.afdesign
Normal file
5
src/assets/icons/vendoricon.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 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 |
33
src/assets/logos/farmcontrollogo.svg
Normal 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 |
@ -14,7 +14,7 @@ const LoginUser = () => {
|
||||
//const navigate = useNavigate()
|
||||
const { loginWithSSO } = useContext(AuthContext)
|
||||
const handleLogin = async () => {
|
||||
loginWithSSO('/production/overview')
|
||||
loginWithSSO('/dashboard/production/overview')
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
296
src/components/Dashboard/Inventory/FilamentStocks.jsx
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
288
src/components/Dashboard/Management/Materials.jsx
Normal 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
|
||||
277
src/components/Dashboard/Management/Materials/NewMaterial.jsx
Normal 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
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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
|
||||
|
||||
367
src/components/Dashboard/Production/ProductionOverview.jsx
Normal 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
|
||||
82
src/components/Dashboard/common/CountryDisplay.jsx
Normal 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
|
||||
100
src/components/Dashboard/common/CountrySelect.jsx
Normal 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
|
||||
@ -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 {}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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' }}>
|
||||
|
||||
@ -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]}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
51
src/components/Dashboard/common/FilamentStockDisplay.jsx
Normal 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
|
||||
139
src/components/Dashboard/common/FilamentStockSelect.jsx
Normal 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
|
||||
118
src/components/Dashboard/common/FilamentStockState.jsx
Normal 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
|
||||
@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
207
src/components/Dashboard/common/PartTransfer.jsx
Normal 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
|
||||
52
src/components/Dashboard/common/PartsTable.jsx
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
89
src/components/Dashboard/common/StockEventTable.jsx
Normal 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
|
||||
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
186
src/components/Dashboard/common/VendorSelect.jsx
Normal 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
|
||||
203
src/components/Dashboard/context/HistoryContext.js
Normal 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
|
||||
9
src/components/Icons/FilamentStockIcon.jsx
Normal 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
|
||||
7
src/components/Icons/MaterialIcon.jsx
Normal 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
|
||||
7
src/components/Icons/PartStockIcon.jsx
Normal 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
|
||||
9
src/components/Icons/ProductStockIcon.jsx
Normal 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
|
||||
7
src/components/Icons/VendorIcon.jsx
Normal 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
|
||||
7
src/components/Logos/FarmControlLogo.jsx
Normal 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
|
||||
@ -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 = {
|
||||
|
||||