diff --git a/package-lock.json b/package-lock.json index 4ff677d..b388afa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f4c34b5..92f17a4 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.css b/src/App.css index f37773d..ca5c511 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,8 @@ +body, +.ant-typography { + font-family: 'SF Pro'; +} + .App { text-align: center; } diff --git a/src/App.jsx b/src/App.jsx index db04ebf..b0c00fb 100644 --- a/src/App.jsx +++ b/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={ ( - + )} /> } @@ -82,41 +89,78 @@ const FarmControlApp = () => { /> } />} > - } /> - } /> + {/* Production Routes */} } + /> + } /> + } /> - } /> - } /> - } /> - } /> - } /> - + } + /> + } + /> + } + /> + } + /> + } + /> - } />} - > - } /> - + {/* Inventory Routes */} + } + /> + } + /> - } />} - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Management Routes */} + } + /> + } + /> + } /> + } + /> + } /> + } + /> + } /> + } + /> + } + /> diff --git a/src/assets/icons/filamentstockicon.afdesign b/src/assets/icons/filamentstockicon.afdesign new file mode 100644 index 0000000..deff271 Binary files /dev/null and b/src/assets/icons/filamentstockicon.afdesign differ diff --git a/src/assets/icons/filamentstockicon.svg b/src/assets/icons/filamentstockicon.svg new file mode 100644 index 0000000..cea5dbd --- /dev/null +++ b/src/assets/icons/filamentstockicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/gcodefileicon.afdesign b/src/assets/icons/gcodefileicon.afdesign index 8878e0d..6b9a90f 100644 Binary files a/src/assets/icons/gcodefileicon.afdesign and b/src/assets/icons/gcodefileicon.afdesign differ diff --git a/src/assets/icons/gcodefileicon.svg b/src/assets/icons/gcodefileicon.svg index 5824f5b..32f5792 100644 --- a/src/assets/icons/gcodefileicon.svg +++ b/src/assets/icons/gcodefileicon.svg @@ -1,20 +1,10 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/src/assets/icons/materialicon.afdesign b/src/assets/icons/materialicon.afdesign new file mode 100644 index 0000000..533c24f Binary files /dev/null and b/src/assets/icons/materialicon.afdesign differ diff --git a/src/assets/icons/materialicon.svg b/src/assets/icons/materialicon.svg new file mode 100644 index 0000000..b875109 --- /dev/null +++ b/src/assets/icons/materialicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/partstockicon.afdesign b/src/assets/icons/partstockicon.afdesign new file mode 100644 index 0000000..9b55a37 Binary files /dev/null and b/src/assets/icons/partstockicon.afdesign differ diff --git a/src/assets/icons/partstockicon.svg b/src/assets/icons/partstockicon.svg new file mode 100644 index 0000000..65d6086 --- /dev/null +++ b/src/assets/icons/partstockicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/printedparticon.afdesign b/src/assets/icons/printedparticon.afdesign new file mode 100644 index 0000000..7059aae Binary files /dev/null and b/src/assets/icons/printedparticon.afdesign differ diff --git a/src/assets/icons/printedparticon.svg b/src/assets/icons/printedparticon.svg new file mode 100644 index 0000000..0aeb5f9 --- /dev/null +++ b/src/assets/icons/printedparticon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/producticon.afdesign b/src/assets/icons/producticon.afdesign index dfea98b..4dd71d4 100644 Binary files a/src/assets/icons/producticon.afdesign and b/src/assets/icons/producticon.afdesign differ diff --git a/src/assets/icons/producticon.svg b/src/assets/icons/producticon.svg index 60e0d17..57ad022 100644 --- a/src/assets/icons/producticon.svg +++ b/src/assets/icons/producticon.svg @@ -1,5 +1,7 @@ - - + + + + diff --git a/src/assets/icons/productstockicon.afdesign b/src/assets/icons/productstockicon.afdesign new file mode 100644 index 0000000..a6929ae Binary files /dev/null and b/src/assets/icons/productstockicon.afdesign differ diff --git a/src/assets/icons/productstockicon.svg b/src/assets/icons/productstockicon.svg new file mode 100644 index 0000000..7cfc9b8 --- /dev/null +++ b/src/assets/icons/productstockicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/spoolicon.afdesign b/src/assets/icons/spoolicon.afdesign new file mode 100644 index 0000000..662ee42 Binary files /dev/null and b/src/assets/icons/spoolicon.afdesign differ diff --git a/src/assets/icons/spoolicon.svg b/src/assets/icons/spoolicon.svg new file mode 100644 index 0000000..9024ca8 --- /dev/null +++ b/src/assets/icons/spoolicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icons/vendoricon.afdesign b/src/assets/icons/vendoricon.afdesign new file mode 100644 index 0000000..5cb67da Binary files /dev/null and b/src/assets/icons/vendoricon.afdesign differ diff --git a/src/assets/icons/vendoricon.svg b/src/assets/icons/vendoricon.svg new file mode 100644 index 0000000..58f212e --- /dev/null +++ b/src/assets/icons/vendoricon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/logos/farmcontrollogo.svg b/src/assets/logos/farmcontrollogo.svg new file mode 100644 index 0000000..58a2340 --- /dev/null +++ b/src/assets/logos/farmcontrollogo.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Auth/LoginUser.jsx b/src/components/Auth/LoginUser.jsx index ac0a382..f1b98cb 100644 --- a/src/components/Auth/LoginUser.jsx +++ b/src/components/Auth/LoginUser.jsx @@ -14,7 +14,7 @@ const LoginUser = () => { //const navigate = useNavigate() const { loginWithSSO } = useContext(AuthContext) const handleLogin = async () => { - loginWithSSO('/production/overview') + loginWithSSO('/dashboard/production/overview') } return ( diff --git a/src/components/Dashboard/Inventory/FilamentStocks.jsx b/src/components/Dashboard/Inventory/FilamentStocks.jsx new file mode 100644 index 0000000..9d0de56 --- /dev/null +++ b/src/components/Dashboard/Inventory/FilamentStocks.jsx @@ -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: + } + ], + 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: () => + }, + { + title: 'Filament Name', + dataIndex: 'filament', + key: 'name', + width: 200, + fixed: 'left', + render: (filament) => {filament.name} + }, + { + title: 'ID', + dataIndex: '_id', + key: 'id', + width: 165, + render: (text) => ( + + ) + }, + + { + title: 'Current (g)', + dataIndex: 'currentNetWeight', + key: 'currentNetWeight', + width: 120, + render: (currentNetWeight) => ( + {currentNetWeight.toFixed(2) + 'g'} + ) + }, + { + title: 'Starting (g)', + dataIndex: 'startingNetWeight', + key: 'startingNetWeight', + width: 120, + render: (startingNetWeight) => ( + {startingNetWeight.toFixed(2) + 'g'} + ) + }, + { + title: 'State', + key: 'state', + width: 350, + render: (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 {formattedDate} + } else { + return 'n/a' + } + } + }, + { + title: 'Actions', + key: 'actions', + fixed: 'right', + width: 150, + render: (text, record) => { + return ( + + + + + ) + } + } + ] + + const actionItems = { + items: [ + { + label: 'New Filament Stock', + key: 'newFilamentStock', + icon: + }, + { type: 'divider' }, + { + label: 'Reload List', + key: 'reloadList', + icon: + } + ], + onClick: ({ key }) => { + if (key === 'reloadList') { + fetchFilamentStocksData() + } else if (key === 'newFilamentStock') { + setNewFilamentStockOpen(true) + } + } + } + + return ( + <> + + {contextHolder} + + + + + + }} + scroll={{ y: 'calc(100vh - 270px)' }} + /> + + { + setNewFilamentStockOpen(false) + }} + destroyOnClose + > + { + setNewFilamentStockOpen(false) + messageApi.success('New filament stock created successfully.') + fetchFilamentStocksData() + }} + reset={newFilamentStockOpen} + /> + + + ) +} + +export default FilamentStocks diff --git a/src/components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx b/src/components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx new file mode 100644 index 0000000..d94ca72 --- /dev/null +++ b/src/components/Dashboard/Inventory/FilamentStocks/FilamentStockInfo.jsx @@ -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 ( +
+ } /> +
+ ) + } + + if (error || !filamentStockData) { + return ( + +

{error || 'FilamentStock not found'}

+ +
+ ) + } + + return ( +
+ {contextHolder} + + + Filament Stock Information + + + +
+ + {/* Read-only fields */} + + {filamentStockData.id ? ( + + ) : ( + 'n/a' + )} + + + {moment(filamentStockData.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + + + + + + {moment(filamentStockData.updatedAt).format('YYYY-MM-DD HH:mm:ss')} + + + + {filamentStockData.filament ? ( + + ) : ( + 'n/a' + )} + + + + {filamentStockData.filament ? ( + + ) : ( + 'n/a' + )} + + + {filamentStockData.currentGrossWeight ? ( + + {filamentStockData.currentGrossWeight.toFixed(2) + 'g'} + + ) : ( + 'n/a' + )} + + + {filamentStockData.startingGrossWeight ? ( + + {filamentStockData.startingGrossWeight.toFixed(2) + 'g'} + + ) : ( + 'n/a' + )} + + + {filamentStockData.currentNetWeight ? ( + {filamentStockData.currentNetWeight.toFixed(2) + 'g'} + ) : ( + 'n/a' + )} + + + {filamentStockData.startingNetWeight ? ( + + {filamentStockData.startingNetWeight.toFixed(2) + 'g'} + + ) : ( + 'n/a' + )} + + + + + + Filament Stock Events + + + +
+ ) +} + +export default FilamentStockInfo diff --git a/src/components/Dashboard/Inventory/FilamentStocks/LoadFilamentStock.jsx b/src/components/Dashboard/Inventory/FilamentStocks/LoadFilamentStock.jsx new file mode 100644 index 0000000..6c878a7 --- /dev/null +++ b/src/components/Dashboard/Inventory/FilamentStocks/LoadFilamentStock.jsx @@ -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 ? ( + + ) : ( + 'n/a' + ) + }, + { + key: 'printer', + label: 'Printer', + children: 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: ( + + + + + {targetTemperature == 0 ? ( + + ) : null} + {targetTemperature > 0 && currentTemperature < targetTemperature ? ( + } + /> + ) : null} + + {targetTemperature > 0 && + currentTemperature >= targetTemperature && + filamentSensorDetected == false ? ( + + ) : null} + + {loadFilamentStockFormValues.printer ? ( + + ) : null} + + ) + }, + { + title: 'Required', + key: 'required', + content: ( + <> + + + + + ) + }, + { + title: 'Summary', + key: 'summary', + content: ( + + + + ) + } + ] + + return ( + +
+ +
+ + + + + + Load Filament Stock + +
+ setLoadFilamentStockFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialLoadFilamentStockForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+
+ ) +} + +export default LoadFilamentStock diff --git a/src/components/Dashboard/Inventory/FilamentStocks/NewFilamentStock.jsx b/src/components/Dashboard/Inventory/FilamentStocks/NewFilamentStock.jsx new file mode 100644 index 0000000..f61db24 --- /dev/null +++ b/src/components/Dashboard/Inventory/FilamentStocks/NewFilamentStock.jsx @@ -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: ( + + ) + }, + { + 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: ( + <> + + + + + + { + if (!value) return '' + return `${value}` + }} + step={0.01} + style={{ width: '100%' }} + addonAfter='g' + /> + + + ) + }, + { + title: 'Summary', + key: 'done', + content: ( + + + + ) + } + ] + + return ( + + {contextHolder} + +
+ +
+ + + + + + New Filament Stock + +
+ setNewFilamentStockFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewFilamentStockForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+
+ ) +} + +NewFilamentStock.propTypes = { + reset: PropTypes.bool.isRequired, + onOk: PropTypes.func.isRequired +} + +export default NewFilamentStock diff --git a/src/components/Dashboard/Inventory/FilamentStocks/UnloadFilamentStock.jsx b/src/components/Dashboard/Inventory/FilamentStocks/UnloadFilamentStock.jsx new file mode 100644 index 0000000..06911eb --- /dev/null +++ b/src/components/Dashboard/Inventory/FilamentStocks/UnloadFilamentStock.jsx @@ -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: ( + + + + + + {unloadFilamentStockLoading == false ? ( + <> + {targetTemperature == 0 ? ( + + ) : null} + + {targetTemperature > 0 && + currentTemperature < targetTemperature ? ( + } + /> + ) : null} + + {targetTemperature > 0 && + currentTemperature >= targetTemperature && + filamentSensorDetected ? ( + + ) : null} + + ) : ( + } + /> + )} + + {unloadFilamentStockFormValues.printer ? ( + + ) : null} + + ) + } + ] + + return ( + +
+ +
+ + + + + + Unload Filament Stock + +
+ setUnloadFilamentStockFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialUnloadFilamentStockForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+
+ ) +} + +export default UnloadFilamentStock diff --git a/src/components/Dashboard/Inventory/Spools.jsx b/src/components/Dashboard/Inventory/Spools.jsx deleted file mode 100644 index a982896..0000000 --- a/src/components/Dashboard/Inventory/Spools.jsx +++ /dev/null @@ -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 {formattedDate} - } else { - return 'n/a' - } - } - }, - { - title: 'Actions', - key: 'operation', - fixed: 'right', - width: 100, - render: (text, record) => { - return ( - - - - -
}} - /> - - { - setNewSpoolOpen(false) - }} - > - { - setNewSpoolOpen(false) - fetchSpoolsData() - }} - reset={newSpoolOpen} - /> - - { - setEditSpoolOpen(false) - }} - > - {editSpool} - - - ) -} - -export default Spools diff --git a/src/components/Dashboard/Inventory/Spools/EditSpool.jsx b/src/components/Dashboard/Inventory/Spools/EditSpool.jsx deleted file mode 100644 index cf3d5fd..0000000 --- a/src/components/Dashboard/Inventory/Spools/EditSpool.jsx +++ /dev/null @@ -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: ( - - ) - }, - { - key: 'diameter', - label: 'Diameter', - children: editSpoolFormValues?.diameter + 'mm' - }, - { - key: 'density', - label: 'Density', - children: editSpoolFormValues?.diameter + 'g/cm³' - }, - { - key: 'image', - label: 'Image', - children: editSpoolFormValues?.image ? ( - - ) : 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: ( - <> - - Required information: - - - - - - - - - - - - - { - if (!value) return '£' - return `£${value}` - }} - step={0.01} - style={{ width: '100%' }} - addonAfter='per kg' - /> - - - - - - - - - - - - - - - ) - }, - { - title: 'Optional', - key: 'optional', - content: ( - <> - - - - - - - - - - } - placeholder='https://example.com' - /> - - - ) - }, - { - title: 'Summary', - key: 'summary', - content: ( - <> - - Please review the information: - - - - ) - } - ] - - return ( - <> - {contextHolder} -
{ - setEditSpoolFormValues(allValues) - }} - > - { - setCurrentStep(current) - }} - /> - - {steps[currentStep].content} - - - {currentStep > 0 && ( - - )} - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - - - - ) -} - -EditSpool.propTypes = { - id: PropTypes.string.isRequired, - onOk: PropTypes.func.isRequired -} - -export default EditSpool diff --git a/src/components/Dashboard/Inventory/Spools/NewSpool.jsx b/src/components/Dashboard/Inventory/Spools/NewSpool.jsx deleted file mode 100644 index 1456d91..0000000 --- a/src/components/Dashboard/Inventory/Spools/NewSpool.jsx +++ /dev/null @@ -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: ( - - ) - }, - { - key: 'diameter', - label: 'Diameter', - children: newSpoolFormValues.diameter + 'mm' - }, - { - key: 'density', - label: 'Density', - children: newSpoolFormValues.diameter + 'g/cm³' - }, - { - key: 'image', - label: 'Image', - children: ( - - ) - }, - { - 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: ( - <> - - Required information: - - - - - - - - - - - - - { - if (!value) return '£' - return `£${value}` - }} - step={0.01} - style={{ width: '100%' }} - addonAfter='per kg' - /> - - - - - - - - - - - - - - - ) - }, - { - title: 'Optional', - key: 'optional', - content: ( - <> - - Optional information: - - - - - - - - - - - } - placeholder='https://example.com' - /> - - - ) - }, - { - title: 'Summary', - key: 'summary', - content: ( - <> - - Please review the information: - - - - ) - } - ] - - return ( - <> - {contextHolder} -
{ - setNewSpoolFormValues(allValues) - }} - > - { - setCurrentStep(current) - }} - /> - - {steps[currentStep].content} - - - {currentStep > 0 && ( - - )} - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - - - - ) -} - -NewSpool.propTypes = { - onOk: PropTypes.func.isRequired, - reset: PropTypes.bool.isRequired -} - -export default NewSpool diff --git a/src/components/Dashboard/Management/Filaments.jsx b/src/components/Dashboard/Management/Filaments.jsx index 6d53194..a516020 100644 --- a/src/components/Dashboard/Management/Filaments.jsx +++ b/src/components/Dashboard/Management/Filaments.jsx @@ -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 {'£' + cost + ' per kg'} } }, { @@ -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 {formattedDate} + } else { + return 'n/a' + } + } + }, { title: 'Actions', key: 'actions', @@ -184,7 +204,9 @@ const Filaments = () => { ) : ( - +
+ +
- -
- - - - - - New Filament - -
- setNewFilamentFormValues((prevValues) => ({ - ...prevValues, - ...changedValues - })) - } - initialValues={initialNewFilamentForm} - > - {steps[currentStep].content} + - + + + + + New Filament + + + + setNewFilamentFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewFilamentForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - - -
- - + )} + {currentStep === steps.length - 1 && ( + + )} +
+ +
+ ) } diff --git a/src/components/Dashboard/Management/Materials.jsx b/src/components/Dashboard/Management/Materials.jsx new file mode 100644 index 0000000..e238a93 --- /dev/null +++ b/src/components/Dashboard/Management/Materials.jsx @@ -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: + } + ], + onClick: ({ key }) => { + if (key === 'info') { + navigate(`/dashboard/management/materials/info?materialId=${id}`) + } + } + } + } + + const columns = [ + { + title: '', + dataIndex: '', + key: 'icon', + width: 40, + fixed: 'left', + render: () => + }, + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 200, + fixed: 'left' + }, + { + title: 'ID', + dataIndex: '_id', + key: 'id', + width: 165, + render: (text) => + }, + { + 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 {formattedDate} + } else { + return 'n/a' + } + } + }, + { + title: 'Actions', + key: 'actions', + fixed: 'right', + width: 150, + render: (text, record) => { + return ( + + + + + ) + } + } + ] + + const actionItems = { + items: [ + { + label: 'New Material', + key: 'newMaterial', + icon: + }, + { type: 'divider' }, + { + label: 'Reload List', + key: 'reloadList', + icon: + } + ], + onClick: ({ key }) => { + if (key === 'reloadList') { + fetchMaterialsData() + } else if (key === 'newMaterial') { + setNewMaterialOpen(true) + } + } + } + + return ( + <> + + {contextHolder} + + + + + +
}} + scroll={{ y: 'calc(100vh - 270px)' }} + onScroll={handleScroll} + /> + {lazyLoading && ( +
+ } /> +
+ )} + + { + setNewMaterialOpen(false) + }} + > + { + setNewMaterialOpen(false) + fetchMaterialsData() + }} + /> + + + ) +} + +export default Materials diff --git a/src/components/Dashboard/Management/Materials/NewMaterial.jsx b/src/components/Dashboard/Management/Materials/NewMaterial.jsx new file mode 100644 index 0000000..1d9c773 --- /dev/null +++ b/src/components/Dashboard/Management/Materials/NewMaterial.jsx @@ -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 && ( + 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: ( + <> + + + + + + + + + + + ) + }, + { + title: 'Additional Info', + key: 'additional', + content: ( + <> + + false} + > + + + + + + + + + + + ) + }, + { + title: 'Summary', + key: 'summary', + content: ( + <> + + + ) + } + ] + + return ( + <> + {contextHolder} +
{ + setNewMaterialFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + }} + > + ({ title: item.title }))} + style={{ marginBottom: 24 }} + /> +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 ? ( + + ) : ( + + )} + + + + ) +} + +NewMaterial.propTypes = { + onSuccess: PropTypes.func.isRequired +} + +export default NewMaterial diff --git a/src/components/Dashboard/Management/Parts.jsx b/src/components/Dashboard/Management/Parts.jsx index 9542b14..7aff893 100644 --- a/src/components/Dashboard/Management/Parts.jsx +++ b/src/components/Dashboard/Management/Parts.jsx @@ -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: () => + }, + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 200, + fixed: 'left', + render: (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) => + }, + { + title: 'Product Name', + key: 'productName', + width: 200, + render: (record) => {record.product.name}, + 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) => ( + + ) + }, + { + 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 {formattedDate} + } 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 {formattedDate} + } else { + return 'n/a' + } + }, + sorter: true, + defaultSortOrder: 'descend' + }, + { + title: 'Actions', + key: 'actions', + fixed: 'right', + width: 150, + render: (text, record) => { + return ( + + + + ) } } - }, [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: () => - }, - { - title: 'Name', - dataIndex: 'name', - key: 'name', - width: 200, - fixed: 'left' - }, - { - title: 'ID', - dataIndex: '_id', - key: 'id', - width: 165, - render: (text) => - }, - { - 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 {formattedDate} - } else { - return 'n/a' - } - } - }, - { - title: 'Actions', - key: 'actions', - fixed: 'right', - width: 150, - render: (text, record) => { - return ( - - - - - ) - } - } - ] + const getFilterDropdown = ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + propertyName + }) => { + return ( +
+ + + setSelectedKeys(e.target.value ? [e.target.value] : []) + } + onPressEnter={() => confirm()} + style={{ width: 200, display: 'block' }} + /> +
+ ) + } const actionItems = { items: [ { - label: 'New Part', - key: 'newPart', + label: 'New Product', + key: 'newProduct', icon: }, { 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) => ( + { + setColumnVisibility((prev) => ({ + ...prev, + [col.key]: e.target.checked + })) + }} + > + {col.title} + + )) + + return ( + + + {columnItems} + + + ) + } + + const visibleColumns = columns.filter( + (col) => !col.key || columnVisibility[col.key] + ) + return ( <> {contextHolder} - - - - - + + + + + + + + + + {lazyLoading && } />} +
}} + onScroll={handleScroll} + onChange={handleTableChange} + showSorterTooltip={false} /> { - setNewPartOpen(false) + setNewProductOpen(false) }} + destroyOnClose > - { - setNewPartOpen(false) - fetchPartsData() + setNewProductOpen(false) + setPage(1) + fetchPartsData(1) }} - reset={newPartOpen} + reset={newProductOpen} /> diff --git a/src/components/Dashboard/Management/Parts/NewPart.jsx b/src/components/Dashboard/Management/Parts/NewPart.jsx deleted file mode 100644 index 5235180..0000000 --- a/src/components/Dashboard/Management/Parts/NewPart.jsx +++ /dev/null @@ -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 && ( - - - {(parts, { remove }) => ( - <> - {parts.map((part) => ( - - - - handleFileNameChange('file.uid', e.target.value) - } - /> - - - - , - - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - - - - - {/* STL Preview Modal */} - { - setPreviewVisible(false) - setPreviewFile(null) - if (stlTimerRef.current) { - clearTimeout(stlTimerRef.current) - } - }} - style={{ top: 30 }} - width={'90%'} - > - - {previewFile && !stlLoading && ( -
- -
- )} - {stlLoading && ( -
- Loading 3D model... -
- )} -
-
- - ) -} - -NewPart.propTypes = { - reset: PropTypes.bool.isRequired, - onOk: PropTypes.func.isRequired -} - -export default NewPart diff --git a/src/components/Dashboard/Management/Parts/PartInfo.jsx b/src/components/Dashboard/Management/Parts/PartInfo.jsx index de8a4b9..7221258 100644 --- a/src/components/Dashboard/Management/Parts/PartInfo.jsx +++ b/src/components/Dashboard/Management/Parts/PartInfo.jsx @@ -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 = () => {
+ setPartFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } initialValues={{ name: partData.name || '' }} @@ -218,32 +281,131 @@ const PartInfo = () => { 'n/a' )} - - {(() => { - if (partData.createdAt) { - return moment(partData.createdAt.$date).format( - 'YYYY-MM-DD HH:mm:ss' - ) - } - return 'N/A' - })()} + + {moment(partData.createdAt).format('YYYY-MM-DD HH:mm:ss')} - + + {isEditing ? ( - + ) : ( partData.name || 'n/a' )} + + + {moment(partData.updatedAt).format('YYYY-MM-DD HH:mm:ss')} + + + + {partData.product.name || 'n/a'} + + + {( + + ) || 'n/a'} + + + {isEditing && useGlobalPricing == false ? ( + + {marginOrPrice == false ? ( + + + + ) : ( + + + + )} + + Price + + + ) : partData.margin && + marginOrPrice == false && + partData.useGlobalPricing == false ? ( + partData.margin + '%' + ) : partData.price && + marginOrPrice == true && + partData.useGlobalPricing == false ? ( + '£' + partData.price + ) : ( + 'n/a' + )} + + + {isEditing ? ( + + + + ) : partData.useGlobalPricing == true ? ( + }> + Yes + + ) : partData.useGlobalPricing == false ? ( + }>No + ) : ( + 'n/a' + )} + @@ -256,15 +418,34 @@ const PartInfo = () => { - + {stlLoadError ? ( +
+ + + {stlLoadError} + +
+ ) : ( + partFileObjectId && ( + + ) + )}
) diff --git a/src/components/Dashboard/Management/Products.jsx b/src/components/Dashboard/Management/Products.jsx index 1d4c4ac..49cea1c 100644 --- a/src/components/Dashboard/Management/Products.jsx +++ b/src/components/Dashboard/Management/Products.jsx @@ -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) => }, + { + 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 ( + + {tags.map((tag, index) => ( + + {tag} + + ))} + + ) + } + }, + { + title: 'Version', + dataIndex: 'version', + key: 'version', + width: 120, + render: (text) => (text ? {text} : '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 {formattedDate} + } else { + return 'n/a' + } + } + }, { title: 'Actions', key: 'actions', @@ -159,7 +248,9 @@ const Products = () => { - - + + + + + + + {lazyLoading == true ? ( + }> + ) : null} +
{ scroll={{ y: 'calc(100vh - 270px)' }} rowKey='_id' loading={{ spinning: loading, indicator: }} + onScroll={handleScroll} /> { { setNewProductOpen(false) + messageApi.success('Product created successfully!') fetchProductsData() }} reset={newProductOpen} diff --git a/src/components/Dashboard/Management/Products/NewProduct.jsx b/src/components/Dashboard/Management/Products/NewProduct.jsx index c6d02f7..079460f 100644 --- a/src/components/Dashboard/Management/Products/NewProduct.jsx +++ b/src/components/Dashboard/Management/Products/NewProduct.jsx @@ -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 = ( + + {parts.length != 0 ? ( +
+ + {parts.map((part, index) => ( + + handleNameChange(index, e.target.value)} + style={{ flex: 1 }} + /> +
+ ) : null} + + setTimeout(() => onSuccess('ok'), 0)} + > + +

+ +

+

Click or drag 3D Model files here

+

+ Supported file extensions: .stl, .3mf +

+
+
+
+ ) + + const detailsStep = ( + <> + + + + + + + + {marginOrPrice == false ? ( (Array.isArray(e) ? e : e && e.fileList)} - > - - ) - }, - { - title: 'Details', - key: 'details', - content: ( - - - + - - ) - }, - { - title: 'Summary', - key: 'done', - content: ( - <> - - + ) : ( + + - - ) - } + )} + + Price + + + + ) + + const summaryStep = ( + {newProductFormValues?.name} + }, + { + key: 'vendor', + label: 'Vendor', + children: {newProductFormValues?.vendor?.name} + }, + { + key: 'marginPrice', + label: !marginOrPrice ? 'Margin' : 'Price', + children: !marginOrPrice ? ( + {newProductFormValues?.margin}% + ) : ( + £{newProductFormValues?.price} + ) + }, + ...parts.map((part, index) => ({ + key: part.uid, + label: `Part ${index + 1}`, + children: ( + + {part.name} + + + ) + })) + ]} + /> + ) + + const steps = [ + { title: 'Upload Parts', content: uploadStep }, + { title: 'Details', content: detailsStep }, + { title: 'Summary', content: summaryStep } ] return ( - + {contextHolder} +
{ />
- + - - + <Flex vertical gap='middle' style={{ flexGrow: 1 }}> + <Title level={2} style={{ marginTop: 0, marginBottom: 4 }}> New Product +
{ initialValues={initialNewProductForm} >
{steps[currentStep].content}
- - - - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - + + + + + {currentStep < steps.length - 1 ? ( + + ) : ( + + )} +
+ + { + setPreviewVisible(false) + setPreviewFile(null) + if (previewTimerRef.current) { + clearTimeout(previewTimerRef.current) + } + }} + style={{ top: 30 }} + width='90%' + > + + {previewFile && !isPreviewLoading ? ( +
+ +
+ ) : ( +
+ Loading 3D model... +
+ )} +
+
) } diff --git a/src/components/Dashboard/Management/Products/ProductInfo.jsx b/src/components/Dashboard/Management/Products/ProductInfo.jsx index c77a14f..02dfb49 100644 --- a/src/components/Dashboard/Management/Products/ProductInfo.jsx +++ b/src/components/Dashboard/Management/Products/ProductInfo.jsx @@ -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 ( -
+ {contextHolder} {
+ setProductFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } initialValues={{ - name: productData.name || '' + name: productData.name || '', + vendor: productData.vendor || { id: null, name: '' }, + version: productData.version || '', + tags: productData.tags || [] }} > @@ -218,17 +238,12 @@ const ProductInfo = () => { 'n/a' )} - - {(() => { - if (productData.createdAt) { - return moment(productData.createdAt.$date).format( - 'YYYY-MM-DD HH:mm:ss' - ) - } - return 'N/A' - })()} + + + {moment(productData.createdAt).format('YYYY-MM-DD HH:mm:ss')} - + + {isEditing ? ( { productData.name || 'n/a' )} + + + {moment(productData.updatedAt).format('YYYY-MM-DD HH:mm:ss')} + + + + {isEditing ? ( + + + + ) : ( + productData.vendor.name || 'n/a' + )} + + + + + + + + {isEditing ? ( + + {marginOrPrice == false ? ( + + + + ) : ( + + + + )} + + Price + + + ) : productData.margin && marginOrPrice == false ? ( + productData.margin + '%' + ) : productData.price && marginOrPrice == true ? ( + '£' + productData.price + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : productData.version ? ( + {productData.version} + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + {productData.tags.map((tag) => ( + handleTagClose(tag)} + style={{ marginBottom: 12 }} + > + {tag} + + ))} + + + + + +
+ +
) } diff --git a/src/components/Dashboard/Management/Vendors.jsx b/src/components/Dashboard/Management/Vendors.jsx index e391208..afe1c26 100644 --- a/src/components/Dashboard/Management/Vendors.jsx +++ b/src/components/Dashboard/Management/Vendors.jsx @@ -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: () => + render: () => }, { title: 'Name', @@ -117,13 +130,30 @@ const Vendors = () => { title: 'Website', dataIndex: 'website', key: 'website', - width: 200 + width: 200, + render: (text) => + text ? ( + + {new URL(text).hostname + ' '} + + + ) : ( + 'n/a' + ) + }, + { + title: 'Country', + dataIndex: 'country', + key: 'country', + width: 200, + render: (text) => (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 {formattedDate} + } else { + return 'n/a' + } + } + }, { title: 'Actions', key: 'actions', @@ -150,7 +194,9 @@ const Vendors = () => { - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - + + + + + {currentStep < steps.length - 1 ? ( + + ) : ( + + )} + ) diff --git a/src/components/Dashboard/Management/Vendors/VendorInfo.jsx b/src/components/Dashboard/Management/Vendors/VendorInfo.jsx index e692fdd..ad38300 100644 --- a/src/components/Dashboard/Management/Vendors/VendorInfo.jsx +++ b/src/components/Dashboard/Management/Vendors/VendorInfo.jsx @@ -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 = () => { )} + + {moment(vendorData.updatedAt).format('YYYY-MM-DD HH:mm:ss')} + + {isEditing ? ( { > + ) : vendorData.website ? ( + + {new URL(vendorData.website).hostname + ' '} + + ) : ( - vendorData.website + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : vendorData.country ? ( + + ) : ( + 'n/a' )} @@ -234,8 +261,55 @@ const VendorInfo = () => { > - ) : ( + ) : vendorData.contact ? ( vendorData.contact + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : vendorData.phone ? ( + vendorData.phone + ) : ( + 'n/a' + )} + + + + {isEditing ? ( + + + + ) : vendorData.email ? ( + + {vendorData.email + ' '} + + + ) : ( + 'n/a' )}
diff --git a/src/components/Dashboard/Production/GCodeFiles.jsx b/src/components/Dashboard/Production/GCodeFiles.jsx index b5e2b07..d4762a9 100644 --- a/src/components/Dashboard/Production/GCodeFiles.jsx +++ b/src/components/Dashboard/Production/GCodeFiles.jsx @@ -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 ( +
+ + + setSelectedKeys(e.target.value ? [e.target.value] : []) + } + onPressEnter={() => confirm()} + style={{ width: 200, display: 'block' }} + /> +
+ ) + } + // Column definitions + const columns = [ + { + title: '', + dataIndex: '', + key: '', + width: 40, + fixed: 'left', + render: () => + }, + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 200, + fixed: 'left', + render: (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) => + }, + { + title: 'Filament', + dataIndex: ['filament', 'name'], + key: 'filament', + width: 200, + render: (text, record) => { + return ( + ) - } 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 {formattedDate} + } 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 {formattedDate} + } else { + return 'n/a' + } + }, + sorter: true + }, + { + title: 'Actions', + key: 'actions', + fixed: 'right', + width: 150, + render: (text, record) => { + return ( + + + + ) } } - }, [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: () => - }, - { - title: 'Name', - dataIndex: 'name', - key: 'name', - width: 200, - fixed: 'left', - render: (text) => {text} - }, - { - title: 'ID', - dataIndex: '_id', - key: 'id', - width: 165, - render: (text) => - }, - { - title: 'Filament', - dataIndex: 'filament', - key: 'filament', - width: 200, - render: (filament) => { - return - } - }, - { - 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 {formattedDate} - } else { - return 'n/a' - } - } - }, - { - title: 'Actions', - key: 'actions', - fixed: 'right', - width: 150, - render: (text, record) => { - return ( - - - - - ) - } - } - ] - 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) => ( + { + setColumnVisibility((prev) => ({ + ...prev, + [col.key]: e.target.checked + })) + }} + > + {col.title} + + )) + + return ( + + + {columnItems} + + + + setShowDeleted(e.target.checked)} + > + Show Deleted + + + + ) + } + + const visibleColumns = columns.filter( + (col) => !col.key || columnVisibility[col.key] + ) + return ( <> @@ -291,15 +488,26 @@ const GCodeFiles = () => { + + + + {lazyLoading && } />}
}} + onChange={handleTableChange} + onScroll={handleScroll} + showSorterTooltip={false} /> { onCancel={() => { setNewGCodeFileOpen(false) }} + destroyOnClose > { setNewGCodeFileOpen(false) + messageApi.success('Finished uploading GCode file!') fetchGCodeFilesData() }} reset={newGCodeFileOpen} diff --git a/src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx b/src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx deleted file mode 100644 index 4803317..0000000 --- a/src/components/Dashboard/Production/GCodeFiles/EditGCodeFile.jsx +++ /dev/null @@ -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} - } - size='large' - > -
- setEditFilamentFormValues((prevValues) => ({ - ...prevValues, - ...changedValues - })) - } - > - - - - - - - - - - - { - if (!value) return '£' - return `£${value}` - }} - step={0.01} - style={{ width: '100%' }} - addonAfter='per kg' - /> - - - { - return '#' + color.toHex() - }} - > - - - - - - - - { - setImageList(fileList) - }} - > - - - - - } /> - - - } /> - - - - - - - - - - -
- - ) -} - -EditFilament.propTypes = { - id: PropTypes.string.isRequired, - onOk: PropTypes.func.isRequired -} - -export default EditFilament diff --git a/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx b/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx index 0c33d83..7427b89 100644 --- a/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx +++ b/src/components/Dashboard/Production/GCodeFiles/GCodeFileInfo.jsx @@ -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 ( -
- } /> -
- ) + 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' }} >

{error || 'GCodeFile not found'}

- ) } + if (fetchLoading) { + return ( +
+ } /> +
+ ) + } + return (
- - - {gcodeFileData.id ? ( - + {contextHolder} + + + GCode File Information + + + {isEditing ? ( + <> +
) } diff --git a/src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx b/src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx index 06596ef..59ee28c 100644 --- a/src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx +++ b/src/components/Dashboard/Production/GCodeFiles/NewGCodeFile.jsx @@ -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} + + )('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 }) => { { + + + ) }, @@ -419,7 +468,6 @@ const NewGCodeFile = ({ onOk, reset }) => { return ( - {contextHolder}
{ - - + <Flex vertical={'true'} style={{ flexGrow: 1 }} gap='middle'> + <Title level={2} style={{ marginTop: 0, marginBottom: 4 }}> New G Code File
{ - 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 ( - - - - Overview - - - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - - - Printer Statistics - - - - - - Total Printers - - } - > - {stats.totalPrinters} - - - Active Printers - - } - > - {stats.activePrinters} - - - `${stats.printerStatus.printing || 0} Printing`} - /> - `${stats.printerStatus.idle || 0} Idle`} - /> - `${stats.printerStatus.error || 0} Error`} - /> - - - - - - Job Statistics - - - - - - Total Print Jobs - - } - > - {stats.totalPrintJobs} - - - Active Print Jobs - - } - > - {stats.activePrintJobs} - - - Completed Print Jobs - - } - > - {stats.completedPrintJobs} - - - 'Completion Rate'} - /> - - - - - - - ) -} - -export default ProductionOverview diff --git a/src/components/Dashboard/Production/PrintJobs.jsx b/src/components/Dashboard/Production/PrintJobs.jsx index df502fa..4415a21 100644 --- a/src/components/Dashboard/Production/PrintJobs.jsx +++ b/src/components/Dashboard/Production/PrintJobs.jsx @@ -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 ( +
+ + + setSelectedKeys(e.target.value ? [e.target.value] : []) + } + onPressEnter={() => confirm()} + style={{ width: 200, display: 'block' }} + /> +
+ ) } - 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) => {gcodeFile.name} + render: (gcodeFile) => {gcodeFile.name}, + 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) => + render: (text) => , + 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 - } + }, + filterDropdown: ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters + }) => + getFilterDropdown({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + propertyName: 'state' + }), + onFilter: (value, record) => + record.state.type.toLowerCase().includes(value.toLowerCase()) }, { title: , @@ -222,6 +225,21 @@ const PrintJobs = () => { return } }, + { + 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 {formattedDate} + } 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 = () => { - + + {lazyLoading && } />}
}} scroll={{ y: 'calc(100vh - 270px)' }} + onChange={handleTableChange} + onScroll={handleScroll} + showSorterTooltip={false} /> { { setNewPrintJobOpen(false) + messageApi.success('New print job created successfully.') fetchPrintJobsData() }} reset={newPrintJobOpen} diff --git a/src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx b/src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx index bfd2651..4dc29f0 100644 --- a/src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx +++ b/src/components/Dashboard/Production/PrintJobs/NewPrintJob.jsx @@ -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: ( <> - - Please select a G Code File: - - { > - - - Use any printer configured. - - - + ) @@ -179,74 +148,72 @@ const NewPrintJob = ({ onOk, reset }) => { ] return ( - + {contextHolder} - +
- -
- - - - - - New PrintJob - - - setNewPrintJobFormValues((prevValues) => ({ - ...prevValues, - ...changedValues - })) - } - initialValues={initialNewPrintJobForm} - > - {steps[currentStep].content} + - + + + + + New PrintJob + + + setNewPrintJobFormValues((prevValues) => ({ + ...prevValues, + ...changedValues + })) + } + initialValues={initialNewPrintJobForm} + > +
{steps[currentStep].content}
+ + + + {currentStep < steps.length - 1 && ( - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - - -
- - + )} + {currentStep === steps.length - 1 && ( + + )} +
+ +
+ ) } diff --git a/src/components/Dashboard/Production/Printers.jsx b/src/components/Dashboard/Production/Printers.jsx index fe7b75d..59ee0f0 100644 --- a/src/components/Dashboard/Production/Printers.jsx +++ b/src/components/Dashboard/Production/Printers.jsx @@ -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: () => + }, + { + 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) => + }, + { + title: 'State', + key: 'state', + width: 240, + render: (record) => { + return ( + + ) + } + }, + { + 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 ( + + {tags.map((tag, index) => ( + + {tag} + + ))} + + ) + }, + 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 ( + + + + ) } } - }, [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 ( +
+ + + setSelectedKeys(e.target.value ? [e.target.value] : []) + } + onPressEnter={() => confirm()} + style={{ width: 200, display: 'block' }} + /> +
+ ) + } + + 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) => ( + { + setColumnVisibility((prev) => ({ + ...prev, + [col.key]: e.target.checked + })) + }} + > + {col.title} + + )) - 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 ( + + + {columnItems} + + + ) + } + + 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: () => - }, - { - title: 'Name', - dataIndex: 'printerName', - key: 'printerName', - width: 200, - fixed: 'left' - }, - { - title: 'ID', - dataIndex: 'id', - key: 'id', - width: 165, - render: (text) => - }, - - { - title: 'State', - key: 'state', - width: 240, - render: (record) => { - return ( - - ) - } - }, - { - title: 'Tags', - dataIndex: 'tags', - key: 'tags', - width: 170, - render: (tags) => { - if (!tags || !Array.isArray(tags)) return null - return ( - - {tags.map((tag, index) => ( - - {tag} - - ))} - - ) - } - }, - { - title: 'Actions', - key: 'operation', - fixed: 'right', - width: 150, - render: (record) => { - return ( - - - - - ) - } + useEffect(() => { + if (authenticated) { + fetchPrintersData() } - ] + }, [fetchPrintersData, authenticated]) return ( <> - + - + + {lazyLoading && } />}
}} scroll={{ y: 'calc(100vh - 270px)' }} + onChange={handleTableChange} + onScroll={handleScroll} + showSorterTooltip={false} /> { { setNewPrinterOpen(false) + messageApi.success('New printer added successfully.') fetchPrintersData() }} reset={newPrinterOpen} diff --git a/src/components/Dashboard/Production/Printers/ChangeFillament.jsx b/src/components/Dashboard/Production/Printers/ChangeFillament.jsx deleted file mode 100644 index ca6363d..0000000 --- a/src/components/Dashboard/Production/Printers/ChangeFillament.jsx +++ /dev/null @@ -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: ( - <> - - Please provide the following information: - - - - - - - - - - ) - }, - { - title: 'Upload', - key: 'upload', - content: ( - <> - (Array.isArray(e) ? e : e && e.fileList)} - > - { - handleGCodeUpload(file) - setTimeout(() => { - onSuccess('ok') - }, 0) - }} - > -

- -

-

- Click or gcode instruction file here. -

-

- Supported file extentions: .gcode, .gco, .g -

-
-
- - ) - }, - { - title: 'Targets', - key: 'targets', - content: ( - <> - - - Please provide at least one target to deploy this G Code file: - - - - - - - ) - }, - { - title: 'Summary', - key: 'done', - content: ( - - - - ) - } - ] - - return ( - - {contextHolder} -
- - - - - - - - - New G Code File - -
- setNewGCodeFileFormValues((prevValues) => ({ - ...prevValues, - ...changedValues - })) - } - initialValues={initialNewGCodeFileForm} - > - {steps[currentStep].content} - - - - {currentStep < steps.length - 1 && ( - - )} - {currentStep === steps.length - 1 && ( - - )} - - -
- - - ) -} - -export default NewGCodeFile diff --git a/src/components/Dashboard/Production/Printers/ControlPrinter.jsx b/src/components/Dashboard/Production/Printers/ControlPrinter.jsx index 453c4b1..4514338 100644 --- a/src/components/Dashboard/Production/Printers/ControlPrinter.jsx +++ b/src/components/Dashboard/Production/Printers/ControlPrinter.jsx @@ -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: + icon: , + disabled: printerData?.state?.type !== 'paused' }, { label: 'Pause Print', key: 'pausePrint', - icon: + icon: , + disabled: printerData?.state?.type !== 'printing' }, { label: 'Cancel Print', key: 'cancelPrint', - icon: + icon: , + 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: + icon: + }, + { + label: 'Pause Queue', + key: 'pauseQueue', + icon: + } + ] }, { - label: 'Pause Queue', - key: 'pauseQueue', - icon: + label: 'Filament', + key: 'filament', + children: [ + { + label: 'Load Filament Stock', + key: 'loadFilamentStock', + icon: , + disabled: + printerData?.state?.type === 'printing' || + printerData?.state?.type === 'error' || + printerData?.state?.type === 'offline' || + printerData?.currentFilamentStock !== null + }, + { + label: 'Unload Filament Stock', + key: 'unloadFilamentStock', + icon: , + disabled: + printerData?.state?.type === 'printing' || + printerData?.state?.type === 'error' || + printerData?.state?.type === 'offline' || + printerData?.currentFilamentStock === null + }, + { + type: 'divider' + }, + { + label: 'Filament Info', + key: 'filamentInfo', + icon: + } + ] }, + { type: 'divider' }, @@ -159,14 +250,6 @@ const ControlPrinter = () => { label: 'Restart Firmware', key: 'restartFirmware', icon: - }, - { - type: 'divider' - }, - { - label: 'Edit Printer', - key: 'edit', - icon: } ], 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 = () => { > - - - - - + + + {printerData ? ( { - {printerData.printerName} + {printerData.name} + + + {printerData._id ? ( + + ) : ( + 'n/a' + )} {printerData.currentJob?.id ? ( @@ -275,8 +372,26 @@ const ControlPrinter = () => { 'n/a' )} + + {printerData.currentSubJob?.id ? ( + + ) : ( + 'n/a' + )} + - {printerData.currentJob?.gcodeFile?.name || 'n/a'} + { + + {printerData.currentJob?.gcodeFile?.name || 'n/a'} + + } {printerData.currentJob?.gcodeFile ? ( @@ -291,16 +406,69 @@ const ControlPrinter = () => { )} + + {printerData.currentFilamentStock?.currentNetWeight ? ( + + {printerData.currentFilamentStock.currentNetWeight.toFixed( + 2 + ) + 'g'} + + ) : ( + 'n/a' + )} + + + + {printerData.currentFilamentStock ? ( + + ) : ( + 'n/a' + )} + + + + {printerData.currentFilamentStock?.filament?.name ? ( + + ) : ( + 'n/a' + )} + + + + {printerData?.currentFilamentStock?.filament ? ( + + ) : ( + 'n/a' + )} + + {(() => { if ( printerData.currentJob?.gcodeFile?.gcodeFileInfo .estimatedPrintingTimeNormalMode ) { - return `${ - printerData.currentJob.gcodeFile.gcodeFileInfo - .estimatedPrintingTimeNormalMode - }` + return ( + + { + printerData.currentJob.gcodeFile.gcodeFileInfo + .estimatedPrintingTimeNormalMode + } + + ) } return 'n/a' })()} @@ -312,14 +480,21 @@ const ControlPrinter = () => { printerData?.currentJob?.gcodeFile.gcodeFileInfo .printSettingsId ) { - return `${printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll('"', '')}` + return ( + + {printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll( + '"', + '' + )} + + ) } else { return 'n/a' } })()} - {printerData.currentSubJob?.state.type === 'printing' && ( + {printerData?.state.type === 'printing' && ( { - + - + @@ -350,6 +525,41 @@ const ControlPrinter = () => { )} + { + setLoadFilamentStockModalOpen(false) + }} + > + { + setLoadFilamentStockModalOpen(false) + messageApi.success('New print job created successfully.') + }} + isFilamentLoaded={false} + printer={printerData} + reset={loadFilamentStockModalOpen} + /> + + { + setUnloadFilamentStockModalOpen(false) + }} + > + { + setUnloadFilamentStockModalOpen(false) + messageApi.success('Filament unloaded successfully.') + }} + printer={printerData} + reset={unloadFilamentStockModalOpen} + /> + ) } diff --git a/src/components/Dashboard/Production/Printers/NewPrinter.jsx b/src/components/Dashboard/Production/Printers/NewPrinter.jsx index e10097b..9606833 100644 --- a/src/components/Dashboard/Production/Printers/NewPrinter.jsx +++ b/src/components/Dashboard/Production/Printers/NewPrinter.jsx @@ -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 }) => { <> diff --git a/src/components/Dashboard/Production/Printers/PrinterInfo.jsx b/src/components/Dashboard/Production/Printers/PrinterInfo.jsx index 6f0060b..1779e80 100644 --- a/src/components/Dashboard/Production/Printers/PrinterInfo.jsx +++ b/src/components/Dashboard/Production/Printers/PrinterInfo.jsx @@ -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 = () => { -
+ {/* Read-only fields */} @@ -193,7 +247,7 @@ const PrinterInfo = () => { {isEditing ? ( { ) : ( - printerData.printerName || 'n/a' + printerData.name || 'n/a' )} @@ -228,6 +282,32 @@ const PrinterInfo = () => { )} + + {isEditing ? ( + + + + ) : ( + printerData?.vendor?.name || 'n/a' + )} + + + + {printerData?.vendor ? ( + + ) : ( + 'n/a' + )} + + {isEditing ? ( { + 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 ( +
+ } /> +
+ ) + } + + if (error || !stats) { + return ( + +

{error || 'Printer not found'}

+ +
+ ) + } + + return ( + + {contextHolder} + + + Overview + + + + + Ready + + + + {(stats.printers.standby || 0) + + (stats.printers.complete || 0)} + + + + } + /> + + Printing + + + + {stats.printers.printing || 0} + + + + } + /> + + Queued + + + + {stats.printJobs.queued || 0} + + + + } + /> + + Printing + + + + {stats.printJobs.printing || 0} + + + + } + /> + + Failed + + + + {(stats.printJobs.failed || 0) + + (stats.printJobs.cancelled || 0)} + + + + } + /> + + Complete + + + + {stats.printJobs.complete || 0} + + + + } + /> + + + + + + Printer Statistics + + + + + + Total Printers + + } + > + {stats.totalPrinters} +
+ + Active Printers + + } + > + {stats.activePrinters} + +
+ + + + + + {stats.printerStatus.printing || 0} + + + + + + + + {stats.printerStatus.idle || 0} + + + + + + `${stats.printerStatus.idle || 0} Idle`} + /> + `${stats.printerStatus.error || 0} Error`} + /> + + + + + + + Job Statistics + + + + + + Total Print Jobs + + } + > + {stats.totalPrintJobs} + + + Active Print Jobs + + } + > + {stats.activePrintJobs} + + + Completed Print Jobs + + } + > + {stats.completedPrintJobs} + + + 'Completion Rate'} + /> + + + + + + + ) +} + +export default ProductionOverview diff --git a/src/components/Dashboard/common/CountryDisplay.jsx b/src/components/Dashboard/common/CountryDisplay.jsx new file mode 100644 index 0000000..24cfeaa --- /dev/null +++ b/src/components/Dashboard/common/CountryDisplay.jsx @@ -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 ( + + {country.flag} + {country.name} + + ) +} + +CountryDisplay.propTypes = { + countryCode: PropTypes.string.isRequired +} + +export default CountryDisplay diff --git a/src/components/Dashboard/common/CountrySelect.jsx b/src/components/Dashboard/common/CountrySelect.jsx new file mode 100644 index 0000000..49598c7 --- /dev/null +++ b/src/components/Dashboard/common/CountrySelect.jsx @@ -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 ( +
}} + /> + ) +} + +PartsTable.propTypes = { + data: PropTypes.array, + loading: PropTypes.bool, + showHeader: PropTypes.bool +} +export default PartsTable diff --git a/src/components/Dashboard/common/PrinterSelect.jsx b/src/components/Dashboard/common/PrinterSelect.jsx index 030a730..75790ce 100644 --- a/src/components/Dashboard/common/PrinterSelect.jsx +++ b/src/components/Dashboard/common/PrinterSelect.jsx @@ -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}, - value: `tag-${tag}`, - key: `tag-${tag}`, - children: printers.map((printer) => ({ - title: ( - - ), - value: printer._id, - key: printer._id - })) - })) + Array.from(tagMap.entries()).map(([tag, printers]) => { + const newNode = { + title: tag === 'Untagged' ? tag : {tag}, + value: `tag-${tag}`, + key: `tag-${tag}`, + children: printers.map((printer) => ({ + title: ( + + ), + 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 ( { 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 diff --git a/src/components/Dashboard/common/PrinterState.jsx b/src/components/Dashboard/common/PrinterState.jsx index e0dc1e4..d1ea988 100644 --- a/src/components/Dashboard/common/PrinterState.jsx +++ b/src/components/Dashboard/common/PrinterState.jsx @@ -106,7 +106,7 @@ const PrinterState = ({ return ( - {showPrinterName && {printer.printerName}} + {showPrinterName && {printer.name}} {showStatus && ( @@ -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 diff --git a/src/components/Dashboard/common/PrinterTemperaturePanel.jsx b/src/components/Dashboard/common/PrinterTemperaturePanel.jsx index 16766d6..1029472 100644 --- a/src/components/Dashboard/common/PrinterTemperaturePanel.jsx +++ b/src/components/Dashboard/common/PrinterTemperaturePanel.jsx @@ -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 = ({
{temperatureData ? ( - {temperatureData.hotEnd && ( + {temperatureData.hotEnd && showHotEnd && ( Hot End: {temperatureData.hotEnd.current}°C /{' '} @@ -192,7 +196,7 @@ const PrinterTemperaturePanel = ({ }} showInfo={false} /> - {showControls === true && ( + {showHotEndControls && (
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 diff --git a/src/components/Dashboard/common/SubJobsTree.jsx b/src/components/Dashboard/common/SubJobsTree.jsx index e588c15..a457c6d 100644 --- a/src/components/Dashboard/common/SubJobsTree.jsx +++ b/src/components/Dashboard/common/SubJobsTree.jsx @@ -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 ? ( ) : ( diff --git a/src/components/Dashboard/common/VendorSelect.jsx b/src/components/Dashboard/common/VendorSelect.jsx new file mode 100644 index 0000000..8183a45 --- /dev/null +++ b/src/components/Dashboard/common/VendorSelect.jsx @@ -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: ( + + + {vendor.name} + + ), + 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: , + 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 ( + + ) +} + +VendorSelect.propTypes = { + onChange: PropTypes.func, + filter: PropTypes.object, + useFilter: PropTypes.bool, + value: PropTypes.object +} + +export default VendorSelect diff --git a/src/components/Dashboard/context/HistoryContext.js b/src/components/Dashboard/context/HistoryContext.js new file mode 100644 index 0000000..b3e3c62 --- /dev/null +++ b/src/components/Dashboard/context/HistoryContext.js @@ -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 ( + + {children} + + ) +} + +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 diff --git a/src/components/Icons/FilamentStockIcon.jsx b/src/components/Icons/FilamentStockIcon.jsx new file mode 100644 index 0000000..2b0ad66 --- /dev/null +++ b/src/components/Icons/FilamentStockIcon.jsx @@ -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) => ( + +) + +export default FilamentStockIcon diff --git a/src/components/Icons/MaterialIcon.jsx b/src/components/Icons/MaterialIcon.jsx new file mode 100644 index 0000000..324157d --- /dev/null +++ b/src/components/Icons/MaterialIcon.jsx @@ -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) => + +export default MaterialIcon diff --git a/src/components/Icons/PartStockIcon.jsx b/src/components/Icons/PartStockIcon.jsx new file mode 100644 index 0000000..225d448 --- /dev/null +++ b/src/components/Icons/PartStockIcon.jsx @@ -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) => + +export default PartStockIcon diff --git a/src/components/Icons/ProductStockIcon.jsx b/src/components/Icons/ProductStockIcon.jsx new file mode 100644 index 0000000..c6ff187 --- /dev/null +++ b/src/components/Icons/ProductStockIcon.jsx @@ -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) => ( + +) + +export default ProductStockIcon diff --git a/src/components/Icons/VendorIcon.jsx b/src/components/Icons/VendorIcon.jsx new file mode 100644 index 0000000..24a7a09 --- /dev/null +++ b/src/components/Icons/VendorIcon.jsx @@ -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) => + +export default VendorIcon diff --git a/src/components/Logos/FarmControlLogo.jsx b/src/components/Logos/FarmControlLogo.jsx new file mode 100644 index 0000000..cb2f9f4 --- /dev/null +++ b/src/components/Logos/FarmControlLogo.jsx @@ -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) => + +export default FarmControlLogo diff --git a/src/components/PublicRoute.jsx b/src/components/PublicRoute.jsx index ef019ff..45013b6 100644 --- a/src/components/PublicRoute.jsx +++ b/src/components/PublicRoute.jsx @@ -13,7 +13,11 @@ const PublicRoute = ({ component: Component }) => { } // Redirect to login if not authenticated - return !authenticated ? : + return !authenticated ? ( + + ) : ( + + ) } PublicRoute.propTypes = {