diff --git a/client/package-lock.json b/client/package-lock.json index aba7078..2d76a56 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "spoolman-ui", - "version": "0.22.1", + "version": "0.23C.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spoolman-ui", - "version": "0.22.1", + "version": "0.23C.2", "dependencies": { "@ant-design/v5-patch-for-react-19": "^1.0.3", "@loadable/component": "^5.16.7", @@ -34,6 +34,7 @@ "react-i18next": "^16.5.1", "react-router": "^7.11.0", "react-to-print": "^3.2.0", + "recharts": "^3.7.0", "uuid": "^13.0.0", "vite-plugin-svgr": "^4.5.0", "zustand": "^5.0.9" @@ -3359,6 +3360,48 @@ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@reduxjs/toolkit/node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/@refinedev/antd": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz", @@ -4187,6 +4230,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -4511,6 +4566,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -4622,6 +4740,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -5886,6 +6010,15 @@ "node": ">=0.10.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6157,6 +6290,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6263,6 +6517,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -6772,6 +7032,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -8439,6 +8709,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8577,6 +8857,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -11919,6 +12208,29 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -12020,6 +12332,42 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/redeyed": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", @@ -12039,6 +12387,15 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12207,6 +12564,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -14129,6 +14492,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", diff --git a/client/package.json b/client/package.json index 21c9642..6bce4d4 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,7 @@ "react-i18next": "^16.5.1", "react-router": "^7.11.0", "react-to-print": "^3.2.0", + "recharts": "^3.7.0", "uuid": "^13.0.0", "vite-plugin-svgr": "^4.5.0", "zustand": "^5.0.9" diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 9021828..789f871 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -37,6 +37,19 @@ "viewAll": "View All" }, "warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.", + "batch": { + "selected": "{{count}} selected", + "clear_selection": "Clear", + "archive_confirm": "Archive Spools", + "archive_confirm_content": "Are you sure you want to archive {{count}} spools?", + "unarchive_confirm": "Unarchive Spools", + "unarchive_confirm_content": "Are you sure you want to unarchive {{count}} spools?", + "delete_confirm": "Delete Spools", + "delete_confirm_content": "Are you sure you want to delete {{count}} spools? This action cannot be undone.", + "move": "Move", + "move_title": "Move Spools to Location", + "move_description": "Select a location for the {{count}} selected spools." + }, "notifications": { "success": "Successful", "error": "Error (status code: {{statusCode}})", @@ -239,6 +252,40 @@ "last_used": "Last used {{date}}" } }, + "location": { + "location": "Location", + "locations": "Locations", + "create": "Create Location", + "titles": { + "create": "Create Location", + "edit": "Edit Location", + "list": "Locations" + }, + "fields": { + "name": "Name", + "name_required": "Location name is required", + "name_placeholder": "e.g., Shelf A", + "parent": "Parent Location", + "parent_placeholder": "Select parent (optional)", + "location_type": "Type", + "location_type_placeholder": "Select type (optional)", + "description": "Description", + "spool_count": "Spools", + "full_path": "Full Path" + }, + "types": { + "room": "Room", + "shelf": "Shelf", + "bin": "Bin", + "drawer": "Drawer", + "box": "Box", + "other": "Other" + }, + "messages": { + "delete_confirm": "Are you sure you want to delete this location?", + "has_children": "This location has child locations" + } + }, "filament": { "filament": "Filaments", "fields": { @@ -336,7 +383,21 @@ "description": "It looks like you haven't added any spools yet. See the Help page for help getting started.", "all_spools": "All Spools", "low_stock_alert": "{{count}} spools below {{threshold}}g", - "low_stock_warning": "Low stock" + "low_stock_warning": "Low stock", + "alerts": { + "low_stock": "Low Stock", + "pending_jobs": "Pending Jobs", + "needs_weighing": "Needs Weighing" + }, + "charts": { + "material_distribution": "Material Distribution" + }, + "quick_stats": { + "total_spools": "Total Spools", + "total_filaments": "Filament Types", + "total_vendors": "Vendors", + "total_weight": "Total Remaining" + } }, "help": { "help": "Help", diff --git a/client/src/components/LocationCreateModal.tsx b/client/src/components/LocationCreateModal.tsx new file mode 100644 index 0000000..33663dc --- /dev/null +++ b/client/src/components/LocationCreateModal.tsx @@ -0,0 +1,143 @@ +import { useTranslate } from "@refinedev/core"; +import { Form, Input, Modal, Select, message } from "antd"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { getAPIURL } from "../utils/url"; + +interface LocationTreeNode { + id: number; + name: string; + location_type?: string; + children: LocationTreeNode[]; +} + +interface LocationCreateModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + parentId?: number; +} + +const LOCATION_TYPES = ["room", "shelf", "bin", "drawer", "box", "other"]; + +function flattenTree(nodes: LocationTreeNode[], prefix = ""): { value: number; label: string }[] { + const result: { value: number; label: string }[] = []; + for (const node of nodes) { + const label = prefix ? `${prefix} > ${node.name}` : node.name; + result.push({ value: node.id, label }); + if (node.children.length > 0) { + result.push(...flattenTree(node.children, label)); + } + } + return result; +} + +export function LocationCreateModal({ + isOpen, + onClose, + onSuccess, + parentId, +}: LocationCreateModalProps) { + const t = useTranslate(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const { data: treeData } = useQuery({ + queryKey: ["locations", "tree"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/location/tree"); + if (!response.ok) { + throw new Error("Failed to fetch location tree"); + } + return response.json(); + }, + enabled: isOpen, + }); + + const parentOptions = treeData ? flattenTree(treeData) : []; + + const handleSubmit = async (values: { name: string; parent_id?: number; location_type?: string }) => { + setLoading(true); + try { + const response = await fetch(getAPIURL() + "/location", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "Failed to create location"); + } + + messageApi.success(t("notifications.createSuccess", { resource: t("location.location") })); + form.resetFields(); + onSuccess(); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : "Failed to create location"); + } finally { + setLoading(false); + } + }; + + return ( + <> + {contextHolder} + form.submit()} + onCancel={() => { + form.resetFields(); + onClose(); + }} + confirmLoading={loading} + > + + + + + + + + (option?.label as string).toLowerCase().includes(input.toLowerCase()) + } + /> + + + + ({ + value: type, + label: t(`location.types.${type}`), + }))} + /> + + + + > + ); +} diff --git a/client/src/components/LocationTreeSelect.tsx b/client/src/components/LocationTreeSelect.tsx new file mode 100644 index 0000000..791226b --- /dev/null +++ b/client/src/components/LocationTreeSelect.tsx @@ -0,0 +1,110 @@ +import { PlusOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { TreeSelect, Divider, Button, Spin } from "antd"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { getAPIURL } from "../utils/url"; +import { LocationCreateModal } from "./LocationCreateModal"; + +interface LocationTreeNode { + id: number; + name: string; + location_type?: string; + description?: string; + children: LocationTreeNode[]; +} + +interface TreeDataNode { + value: number; + title: string; + children?: TreeDataNode[]; +} + +function transformTree(nodes: LocationTreeNode[]): TreeDataNode[] { + return nodes.map((node) => ({ + value: node.id, + title: node.location_type ? `${node.name} (${node.location_type})` : node.name, + children: node.children.length > 0 ? transformTree(node.children) : undefined, + })); +} + +interface LocationTreeSelectProps { + value?: number; + onChange?: (value: number | undefined) => void; + allowCreate?: boolean; + placeholder?: string; + style?: React.CSSProperties; +} + +export function LocationTreeSelect({ + value, + onChange, + allowCreate = true, + placeholder, + style, +}: LocationTreeSelectProps) { + const t = useTranslate(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ["locations", "tree"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/location/tree"); + if (!response.ok) { + throw new Error("Failed to fetch location tree"); + } + return response.json(); + }, + }); + + const treeData = data ? transformTree(data) : []; + + const handleCreateSuccess = () => { + setIsCreateModalOpen(false); + refetch(); + }; + + return ( + <> + : undefined} + dropdownRender={(menu) => ( + <> + {menu} + {allowCreate && ( + <> + + } + onClick={() => setIsCreateModalOpen(true)} + style={{ width: "100%" }} + > + {t("location.create")} + + > + )} + > + )} + filterTreeNode={(input, treeNode) => + (treeNode.title as string).toLowerCase().includes(input.toLowerCase()) + } + /> + + setIsCreateModalOpen(false)} + onSuccess={handleCreateSuccess} + /> + > + ); +} diff --git a/client/src/pages/home/components/AlertCards.tsx b/client/src/pages/home/components/AlertCards.tsx new file mode 100644 index 0000000..1663e8b --- /dev/null +++ b/client/src/pages/home/components/AlertCards.tsx @@ -0,0 +1,115 @@ +import { ExclamationCircleOutlined, PrinterOutlined, DashboardOutlined } from "@ant-design/icons"; +import { useList, useTranslate } from "@refinedev/core"; +import { Badge, Card, Col, Row, theme } from "antd"; +import { Link } from "react-router"; +import { ISpool } from "../../spools/model"; +import { IPrintJob } from "../../spools/model"; + +const { useToken } = theme; + +const LOW_STOCK_THRESHOLD = 100; + +interface AlertCardProps { + icon: React.ReactNode; + label: string; + count: number; + to: string; + color: string; + loading?: boolean; +} + +function AlertCard({ icon, label, count, to, color, loading }: AlertCardProps) { + const { token } = useToken(); + + if (count === 0 && !loading) return null; + + return ( + + + + + + {icon} + {label} + + + + + + ); +} + +export function AlertCards() { + const t = useTranslate(); + const { token } = useToken(); + + // Low stock spools + const { result: lowStockResult, query: lowStockQuery } = useList({ + resource: "spool", + pagination: { pageSize: 1 }, + meta: { + queryParams: { + allow_archived: false, + remaining_weight_lt: LOW_STOCK_THRESHOLD, + }, + }, + }); + + // Pending print jobs + const { result: pendingJobsResult, query: pendingJobsQuery } = useList({ + resource: "print-job", + pagination: { pageSize: 1 }, + filters: [{ field: "status", operator: "eq", value: "pending" }], + }); + + // Spools needing weighing + const { result: needsWeighingResult, query: needsWeighingQuery } = useList({ + resource: "spool", + pagination: { pageSize: 1 }, + meta: { + queryParams: { + allow_archived: false, + needs_weighing: true, + }, + }, + }); + + const alerts = [ + { + icon: , + label: t("home.alerts.low_stock"), + count: lowStockResult?.total ?? 0, + to: `/spool?remaining_weight_lt=${LOW_STOCK_THRESHOLD}`, + color: token.colorError, + loading: lowStockQuery.isLoading, + }, + { + icon: , + label: t("home.alerts.pending_jobs"), + count: pendingJobsResult?.total ?? 0, + to: "/print-queue", + color: token.colorWarning, + loading: pendingJobsQuery.isLoading, + }, + { + icon: , + label: t("home.alerts.needs_weighing"), + count: needsWeighingResult?.total ?? 0, + to: "/spool?needs_weighing=true", + color: token.colorInfo, + loading: needsWeighingQuery.isLoading, + }, + ]; + + const visibleAlerts = alerts.filter((a) => a.count > 0 || a.loading); + + if (visibleAlerts.length === 0) return null; + + return ( + + {visibleAlerts.map((alert, i) => ( + + ))} + + ); +} diff --git a/client/src/pages/home/components/MaterialDistributionChart.tsx b/client/src/pages/home/components/MaterialDistributionChart.tsx new file mode 100644 index 0000000..76b67b0 --- /dev/null +++ b/client/src/pages/home/components/MaterialDistributionChart.tsx @@ -0,0 +1,93 @@ +import { useList, useTranslate } from "@refinedev/core"; +import { Card, theme } from "antd"; +import { useMemo } from "react"; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"; +import { ISpool } from "../../spools/model"; + +const { useToken } = theme; + +// Color palette for the pie chart +const COLORS = [ + "#1890ff", // blue + "#52c41a", // green + "#faad14", // gold + "#f5222d", // red + "#722ed1", // purple + "#13c2c2", // cyan + "#eb2f96", // magenta + "#fa8c16", // orange + "#a0d911", // lime + "#2f54eb", // geekblue +]; + +export function MaterialDistributionChart() { + const t = useTranslate(); + const { token } = useToken(); + + // Fetch all spools for aggregation + const { result: spoolsResult, query: spoolsQuery } = useList({ + resource: "spool", + pagination: { pageSize: 1000 }, + meta: { + queryParams: { + allow_archived: false, + }, + }, + }); + + // Aggregate by material + const chartData = useMemo(() => { + const data = spoolsResult?.data || []; + const materialCounts: Record = {}; + + data.forEach((spool) => { + const material = spool.filament.material || t("unknown"); + materialCounts[material] = (materialCounts[material] || 0) + 1; + }); + + return Object.entries(materialCounts) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value) + .slice(0, 10); // Top 10 materials + }, [spoolsResult?.data, t]); + + if (spoolsQuery.isLoading || chartData.length === 0) { + return null; + } + + return ( + + + + `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`} + labelLine={{ stroke: token.colorTextSecondary }} + > + {chartData.map((_, index) => ( + + ))} + + [`${value} spools`, t("spool.spool")]} + contentStyle={{ + backgroundColor: token.colorBgElevated, + border: `1px solid ${token.colorBorder}`, + borderRadius: token.borderRadius, + }} + /> + + + + ); +} diff --git a/client/src/pages/home/components/QuickStats.tsx b/client/src/pages/home/components/QuickStats.tsx new file mode 100644 index 0000000..5220f46 --- /dev/null +++ b/client/src/pages/home/components/QuickStats.tsx @@ -0,0 +1,100 @@ +import { HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined } from "@ant-design/icons"; +import { useList, useTranslate } from "@refinedev/core"; +import { Card, Col, Row, Statistic, theme } from "antd"; +import { useMemo } from "react"; +import { Link } from "react-router"; +import { IFilament } from "../../filaments/model"; +import { ISpool } from "../../spools/model"; +import { IVendor } from "../../vendors/model"; + +const { useToken } = theme; + +export function QuickStats() { + const t = useTranslate(); + const { token } = useToken(); + + const { result: filamentsResult, query: filamentsQuery } = useList({ + resource: "filament", + pagination: { pageSize: 1 }, + }); + + const { result: vendorsResult, query: vendorsQuery } = useList({ + resource: "vendor", + pagination: { pageSize: 1 }, + }); + + // Fetch all spools for total weight calculation + const { result: spoolsResult, query: spoolsQuery } = useList({ + resource: "spool", + pagination: { pageSize: 1000 }, + meta: { + queryParams: { + allow_archived: false, + }, + }, + }); + + // Calculate total remaining weight + const totalWeight = useMemo(() => { + const data = spoolsResult?.data || []; + return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0); + }, [spoolsResult?.data]); + + const formatWeight = (grams: number) => { + if (grams >= 1000) { + return `${(grams / 1000).toFixed(1)} kg`; + } + return `${Math.round(grams)} g`; + }; + + return ( + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + } + /> + + + + ); +} diff --git a/client/src/pages/home/index.tsx b/client/src/pages/home/index.tsx index e0d2acd..a2cb0d9 100644 --- a/client/src/pages/home/index.tsx +++ b/client/src/pages/home/index.tsx @@ -1,20 +1,19 @@ import { ExclamationCircleOutlined, - HighlightOutlined, PlusOutlined, - UserOutlined, } from "@ant-design/icons"; import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core"; -import { Badge, Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd"; +import { Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd"; import { Content } from "antd/es/layout/layout"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import React, { useMemo, useState } from "react"; +import React, { useState } from "react"; import { Link, useNavigate } from "react-router"; import SpoolIcon from "../../components/spoolIcon"; -import { IFilament } from "../filaments/model"; import { ISpool } from "../spools/model"; -import { IVendor } from "../vendors/model"; +import { AlertCards } from "./components/AlertCards"; +import { MaterialDistributionChart } from "./components/MaterialDistributionChart"; +import { QuickStats } from "./components/QuickStats"; dayjs.extend(utc); @@ -28,7 +27,7 @@ export const Home: React.FC = () => { const navigate = useNavigate(); const [currentPage, setCurrentPage] = useState(1); - const pageSize = 15; + const pageSize = 10; // Fetch all spools (not archived) for the main list const { result: spoolsResult, query: spoolsQuery } = useList({ @@ -42,38 +41,6 @@ export const Home: React.FC = () => { }, }); - // Fetch counts for stats - const { result: filamentsResult, query: filamentsQuery } = useList({ - resource: "filament", - pagination: { pageSize: 1 }, - }); - const { result: vendorsResult, query: vendorsQuery } = useList({ - resource: "vendor", - pagination: { pageSize: 1 }, - }); - - // Calculate low stock spools - const lowStockCount = useMemo(() => { - const data = spoolsResult?.data || []; - return data.filter( - (spool) => spool.remaining_weight !== undefined && spool.remaining_weight < LOW_STOCK_THRESHOLD - ).length; - }, [spoolsResult?.data]); - - // For accurate low stock count, we need all spools with remaining_weight < threshold - const { result: lowStockResult } = useList({ - resource: "spool", - pagination: { pageSize: 1000 }, - meta: { - queryParams: { - allow_archived: false, - remaining_weight_lt: LOW_STOCK_THRESHOLD, - }, - }, - }); - - const totalLowStock = lowStockResult?.total ?? lowStockCount; - const spoolColumns = [ { title: "", @@ -160,24 +127,10 @@ export const Home: React.FC = () => { {/* Alert badges */} - {totalLowStock > 0 && ( - - - - - - - {t("home.low_stock_alert", { count: totalLowStock, threshold: LOW_STOCK_THRESHOLD })} - - - - - - )} + + + {/* Material distribution chart */} + {/* Spool list */} = () => { - {/* Secondary stats */} - - - - - } - /> - - - - - - - } - /> - - - - + {/* Quick stats */} + ); }; diff --git a/client/src/pages/spools/components/BatchActionBar.tsx b/client/src/pages/spools/components/BatchActionBar.tsx new file mode 100644 index 0000000..272ebde --- /dev/null +++ b/client/src/pages/spools/components/BatchActionBar.tsx @@ -0,0 +1,91 @@ +import { DeleteOutlined, InboxOutlined, UndoOutlined, EnvironmentOutlined, CloseOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; +import { Affix, Button, Card, Space, Modal, Select } from "antd"; +import { useState } from "react"; +import { useSpoolmanLocations } from "../../../components/otherModels"; + +interface BatchActionBarProps { + selectedIds: number[]; + onArchive: () => void; + onUnarchive: () => void; + onDelete: () => void; + onMove: (location: string) => void; + onClear: () => void; +} + +export function BatchActionBar(props: BatchActionBarProps) { + const t = useTranslate(); + const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); + const [selectedLocation, setSelectedLocation] = useState(); + const locations = useSpoolmanLocations(isMoveModalOpen); + + if (props.selectedIds.length === 0) return null; + + const handleMoveConfirm = () => { + if (selectedLocation) { + props.onMove(selectedLocation); + setIsMoveModalOpen(false); + setSelectedLocation(undefined); + } + }; + + return ( + <> + + + + + {t("batch.selected", { count: props.selectedIds.length })} + + } onClick={props.onArchive}> + {t("buttons.archive")} + + } onClick={props.onUnarchive}> + {t("buttons.unArchive")} + + } onClick={() => setIsMoveModalOpen(true)}> + {t("batch.move")} + + } onClick={props.onDelete}> + {t("buttons.delete")} + + } onClick={props.onClear}> + {t("batch.clear_selection")} + + + + + + { + setIsMoveModalOpen(false); + setSelectedLocation(undefined); + }} + okButtonProps={{ disabled: !selectedLocation }} + > + {t("batch.move_description", { count: props.selectedIds.length })} + ({ label: loc, value: loc })) || []} + showSearch + allowClear + /> + + > + ); +} diff --git a/client/src/pages/spools/functions.tsx b/client/src/pages/spools/functions.tsx index 24e1e3a..dd1bb82 100644 --- a/client/src/pages/spools/functions.tsx +++ b/client/src/pages/spools/functions.tsx @@ -24,6 +24,36 @@ export async function setSpoolArchived(spool: ISpool, archived: boolean) { await fetch(request, init); } +export async function bulkArchiveSpools(spoolIds: number[], archived: boolean) { + const response = await fetch(getAPIURL() + "/spool/bulk/archive", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ spool_ids: spoolIds, archived }), + }); + if (!response.ok) throw new Error("Failed to archive spools"); + return response.json(); +} + +export async function bulkDeleteSpools(spoolIds: number[]) { + const response = await fetch(getAPIURL() + "/spool/bulk/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ spool_ids: spoolIds }), + }); + if (!response.ok) throw new Error("Failed to delete spools"); + return response.json(); +} + +export async function bulkUpdateSpools(spoolIds: number[], data: { location?: string }) { + const response = await fetch(getAPIURL() + "/spool/bulk/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ spool_ids: spoolIds, ...data }), + }); + if (!response.ok) throw new Error("Failed to update spools"); + return response.json(); +} + /** * Use some spool filament from this spool. Either specify length or weight. * @param spool The spool diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index c0b8dbc..e819c3d 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -39,8 +39,9 @@ import { removeUndefined } from "../../utils/filtering"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { useCurrencyFormatter } from "../../utils/settings"; -import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; +import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { ISpool } from "./model"; +import { BatchActionBar } from "./components/BatchActionBar"; dayjs.extend(utc); @@ -119,6 +120,9 @@ export const SpoolList: React.FC = () => { // State for the switch to show archived spools const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false); + // State for batch selection + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + // Fetch data from the API // To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature. // This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead @@ -211,6 +215,58 @@ export const SpoolList: React.FC = () => { } }; + // Batch action handlers + const handleBatchArchive = () => { + confirm({ + title: t("batch.archive_confirm"), + content: t("batch.archive_confirm_content", { count: selectedRowKeys.length }), + okText: t("buttons.archive"), + okType: "primary", + cancelText: t("buttons.cancel"), + async onOk() { + await bulkArchiveSpools(selectedRowKeys as number[], true); + invalidate({ resource: "spool", invalidates: ["list"] }); + setSelectedRowKeys([]); + }, + }); + }; + + const handleBatchUnarchive = async () => { + confirm({ + title: t("batch.unarchive_confirm"), + content: t("batch.unarchive_confirm_content", { count: selectedRowKeys.length }), + okText: t("buttons.unArchive"), + okType: "primary", + cancelText: t("buttons.cancel"), + async onOk() { + await bulkArchiveSpools(selectedRowKeys as number[], false); + invalidate({ resource: "spool", invalidates: ["list"] }); + setSelectedRowKeys([]); + }, + }); + }; + + const handleBatchDelete = () => { + confirm({ + title: t("batch.delete_confirm"), + content: t("batch.delete_confirm_content", { count: selectedRowKeys.length }), + okText: t("buttons.delete"), + okType: "danger", + cancelText: t("buttons.cancel"), + async onOk() { + await bulkDeleteSpools(selectedRowKeys as number[]); + invalidate({ resource: "spool", invalidates: ["list"] }); + setSelectedRowKeys([]); + }, + }); + }; + + const handleBatchMove = async (location: string) => { + await bulkUpdateSpools(selectedRowKeys as number[], { location }); + invalidate({ resource: "spool", invalidates: ["list"] }); + setSelectedRowKeys([]); + }; + if (tableProps.pagination) { tableProps.pagination.showSizeChanger = true; } @@ -339,6 +395,11 @@ export const SpoolList: React.FC = () => { scroll={{ x: "max-content" }} dataSource={dataSource} rowKey="id" + rowSelection={{ + selectedRowKeys, + onChange: (newSelectedRowKeys) => setSelectedRowKeys(newSelectedRowKeys), + preserveSelectedRowKeys: true, + }} // Make archived rows greyed out onRow={(record) => { if (record.archived) { @@ -480,6 +541,14 @@ export const SpoolList: React.FC = () => { ActionsColumn(t("table.actions"), actions), ])} /> + setSelectedRowKeys([])} + /> ); }; diff --git a/migrations/versions/2025_01_21_0100-e5f6g7h8i9j0_add_hierarchical_locations.py b/migrations/versions/2025_01_21_0100-e5f6g7h8i9j0_add_hierarchical_locations.py new file mode 100644 index 0000000..40bca33 --- /dev/null +++ b/migrations/versions/2025_01_21_0100-e5f6g7h8i9j0_add_hierarchical_locations.py @@ -0,0 +1,61 @@ +"""Add hierarchical locations. + +Revision ID: e5f6g7h8i9j0 +Revises: d4e5f6g7h8i9 +Create Date: 2025-01-21 01:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e5f6g7h8i9j0" +down_revision = "d4e5f6g7h8i9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create location table and add location_id to spool.""" + # Create location table + op.create_table( + "location", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("parent_id", sa.Integer(), nullable=True), + sa.Column("location_type", sa.String(length=32), nullable=True), + sa.Column("description", sa.String(length=256), nullable=True), + sa.ForeignKeyConstraint( + ["parent_id"], + ["location.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_location_id"), "location", ["id"], unique=False) + op.create_index(op.f("ix_location_parent_id"), "location", ["parent_id"], unique=False) + + # Add location_id column to spool table + op.add_column( + "spool", + sa.Column("location_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_spool_location_id", + "spool", + "location", + ["location_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index(op.f("ix_spool_location_id"), "spool", ["location_id"], unique=False) + + +def downgrade() -> None: + """Drop location table and remove location_id from spool.""" + op.drop_index(op.f("ix_spool_location_id"), table_name="spool") + op.drop_constraint("fk_spool_location_id", "spool", type_="foreignkey") + op.drop_column("spool", "location_id") + op.drop_index(op.f("ix_location_parent_id"), table_name="location") + op.drop_index(op.f("ix_location_id"), table_name="location") + op.drop_table("location") diff --git a/spoolman/api/v1/location.py b/spoolman/api/v1/location.py new file mode 100644 index 0000000..da1af7b --- /dev/null +++ b/spoolman/api/v1/location.py @@ -0,0 +1,164 @@ +"""Location related endpoints.""" + +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.api.v1.models import Message +from spoolman.database import location +from spoolman.database.database import get_db_session +from spoolman.database import models + +router = APIRouter( + prefix="/location", + tags=["location"], +) + + +class LocationResponse(BaseModel): + """Location response model.""" + + id: int + name: str + parent_id: Optional[int] = None + location_type: Optional[str] = None + description: Optional[str] = None + full_path: Optional[str] = None # Only populated for single-item requests + spool_count: Optional[int] = None # Only populated when requested + + @staticmethod + def from_db(item: models.Location, full_path: Optional[str] = None) -> "LocationResponse": + return LocationResponse( + id=item.id, + name=item.name, + parent_id=item.parent_id, + location_type=item.location_type, + description=item.description, + full_path=full_path, + ) + + +class LocationTreeNode(BaseModel): + """Location tree node for nested structure.""" + + id: int + name: str + location_type: Optional[str] = None + description: Optional[str] = None + children: list["LocationTreeNode"] = [] + + +class LocationParameters(BaseModel): + """Parameters for creating a location.""" + + name: str = Field(max_length=64, description="Location name.") + parent_id: Optional[int] = Field(None, description="Parent location ID.") + location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.") + description: Optional[str] = Field(None, max_length=256, description="Optional description.") + + +class LocationUpdateParameters(BaseModel): + """Parameters for updating a location.""" + + name: Optional[str] = Field(None, max_length=64, description="Location name.") + parent_id: Optional[int] = Field(None, description="Parent location ID.") + location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.") + description: Optional[str] = Field(None, max_length=256, description="Optional description.") + + +@router.get( + "", + name="Find locations", + description="Get all locations as a flat list.", + response_model=list[LocationResponse], +) +async def find_locations( + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[LocationResponse]: + db_items = await location.find_all(db=db) + return [LocationResponse.from_db(item) for item in db_items] + + +@router.get( + "/tree", + name="Get location tree", + description="Get all locations as a nested tree structure.", + response_model=list[LocationTreeNode], +) +async def get_location_tree( + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[dict]: + return await location.get_tree(db=db) + + +@router.get( + "/{location_id}", + name="Get location", + description="Get a specific location by ID.", + response_model=LocationResponse, + responses={404: {"model": Message}}, +) +async def get_location( + db: Annotated[AsyncSession, Depends(get_db_session)], + location_id: int, +) -> LocationResponse: + db_item = await location.get_by_id(db=db, location_id=location_id) + full_path = await location.get_full_path(db=db, location_id=location_id) + spool_count = await location.get_spool_count(db=db, location_id=location_id) + response = LocationResponse.from_db(db_item, full_path=full_path) + response.spool_count = spool_count + return response + + +@router.post( + "", + name="Create location", + description="Create a new location.", + response_model=LocationResponse, + responses={400: {"model": Message}, 404: {"model": Message}}, +) +async def create_location( + db: Annotated[AsyncSession, Depends(get_db_session)], + body: LocationParameters, +) -> LocationResponse: + db_item = await location.create( + db=db, + name=body.name, + parent_id=body.parent_id, + location_type=body.location_type, + description=body.description, + ) + return LocationResponse.from_db(db_item) + + +@router.patch( + "/{location_id}", + name="Update location", + description="Update a location.", + response_model=LocationResponse, + responses={400: {"model": Message}, 404: {"model": Message}}, +) +async def update_location( + db: Annotated[AsyncSession, Depends(get_db_session)], + location_id: int, + body: LocationUpdateParameters, +) -> LocationResponse: + data = body.model_dump(exclude_unset=True) + db_item = await location.update(db=db, location_id=location_id, data=data) + return LocationResponse.from_db(db_item) + + +@router.delete( + "/{location_id}", + name="Delete location", + description="Delete a location. Spools using this location will have their location_id set to null.", + responses={404: {"model": Message}}, +) +async def delete_location( + db: Annotated[AsyncSession, Depends(get_db_session)], + location_id: int, +) -> Message: + await location.delete(db=db, location_id=location_id) + return Message(message="Success!") diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index 0d634c8..d6e68ac 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db from spoolman.exceptions import ItemNotFoundError from spoolman.ws import websocket_manager -from . import auth, backup as backup_module, export, externaldb, field, filament, models, other, print_job, setting, spool, vendor +from . import auth, backup as backup_module, export, externaldb, field, filament, location as location_module, models, other, print_job, setting, spool, vendor logger = logging.getLogger(__name__) @@ -116,3 +116,4 @@ app.include_router(export.router) app.include_router(backup_module.router) app.include_router(print_job.router) app.include_router(auth.router) +app.include_router(location_module.router) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 7b335e4..48d0170 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -534,6 +534,69 @@ async def delete( return Message(message="Success!") +# Bulk operations + + +class BulkArchiveParameters(BaseModel): + spool_ids: list[int] = Field(description="List of spool IDs to archive/unarchive.") + archived: bool = Field(description="Whether to archive (true) or unarchive (false).") + + +class BulkDeleteParameters(BaseModel): + spool_ids: list[int] = Field(description="List of spool IDs to delete.") + + +class BulkUpdateParameters(BaseModel): + spool_ids: list[int] = Field(description="List of spool IDs to update.") + location: Optional[str] = Field(None, max_length=64, description="New location for all spools.") + + +@router.post( + "/bulk/archive", + name="Bulk archive spools", + description="Archive or unarchive multiple spools at once.", + responses={404: {"model": Message}}, +) +async def bulk_archive( + db: Annotated[AsyncSession, Depends(get_db_session)], + body: BulkArchiveParameters, +) -> Message: + for spool_id in body.spool_ids: + await spool.update(db=db, spool_id=spool_id, data={"archived": body.archived}) + return Message(message=f"Updated {len(body.spool_ids)} spools.") + + +@router.post( + "/bulk/delete", + name="Bulk delete spools", + description="Delete multiple spools at once.", + responses={404: {"model": Message}}, +) +async def bulk_delete( + db: Annotated[AsyncSession, Depends(get_db_session)], + body: BulkDeleteParameters, +) -> Message: + for spool_id in body.spool_ids: + await spool.delete(db=db, spool_id=spool_id) + return Message(message=f"Deleted {len(body.spool_ids)} spools.") + + +@router.post( + "/bulk/update", + name="Bulk update spools", + description="Update the same fields on multiple spools at once.", + responses={404: {"model": Message}}, +) +async def bulk_update( + db: Annotated[AsyncSession, Depends(get_db_session)], + body: BulkUpdateParameters, +) -> Message: + data = body.model_dump(exclude={"spool_ids"}, exclude_unset=True) + for spool_id in body.spool_ids: + await spool.update(db=db, spool_id=spool_id, data=data) + return Message(message=f"Updated {len(body.spool_ids)} spools.") + + @router.put( "/{spool_id}/use", name="Use spool filament", diff --git a/spoolman/database/location.py b/spoolman/database/location.py new file mode 100644 index 0000000..80a60e0 --- /dev/null +++ b/spoolman/database/location.py @@ -0,0 +1,148 @@ +"""Database operations for Location model.""" + +from typing import Optional + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from spoolman.database import models +from spoolman.exceptions import ItemNotFoundError + + +async def create( + *, + db: AsyncSession, + name: str, + parent_id: Optional[int] = None, + location_type: Optional[str] = None, + description: Optional[str] = None, +) -> models.Location: + """Create a new location.""" + # Verify parent exists if specified + if parent_id is not None: + parent = await db.get(models.Location, parent_id) + if parent is None: + raise ItemNotFoundError(f"Parent location with ID {parent_id} not found.") + + db_item = models.Location( + name=name, + parent_id=parent_id, + location_type=location_type, + description=description, + ) + db.add(db_item) + await db.commit() + return db_item + + +async def get_by_id(db: AsyncSession, location_id: int) -> models.Location: + """Get a location by ID.""" + stmt = select(models.Location).where(models.Location.id == location_id) + result = await db.execute(stmt) + db_item = result.scalar_one_or_none() + if db_item is None: + raise ItemNotFoundError(f"Location with ID {location_id} not found.") + return db_item + + +async def find( + *, + db: AsyncSession, + parent_id: Optional[int] = None, +) -> list[models.Location]: + """Find locations, optionally filtered by parent.""" + stmt = select(models.Location).order_by(models.Location.name) + if parent_id is not None: + stmt = stmt.where(models.Location.parent_id == parent_id) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def find_all(db: AsyncSession) -> list[models.Location]: + """Get all locations.""" + stmt = select(models.Location).order_by(models.Location.name) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def update( + *, + db: AsyncSession, + location_id: int, + data: dict, +) -> models.Location: + """Update a location.""" + db_item = await get_by_id(db, location_id) + + # Verify parent exists if changing parent + if "parent_id" in data and data["parent_id"] is not None: + # Prevent setting parent to self + if data["parent_id"] == location_id: + raise ValueError("Location cannot be its own parent.") + parent = await db.get(models.Location, data["parent_id"]) + if parent is None: + raise ItemNotFoundError(f"Parent location with ID {data['parent_id']} not found.") + + for key, value in data.items(): + setattr(db_item, key, value) + + await db.commit() + return db_item + + +async def delete(db: AsyncSession, location_id: int) -> None: + """Delete a location.""" + db_item = await get_by_id(db, location_id) + await db.delete(db_item) + await db.commit() + + +async def get_full_path(db: AsyncSession, location_id: int) -> str: + """Get the full path of a location as a string (e.g., 'Room > Shelf > Bin').""" + parts = [] + current_id: Optional[int] = location_id + + while current_id is not None: + location = await get_by_id(db, current_id) + parts.insert(0, location.name) + current_id = location.parent_id + + return " > ".join(parts) + + +async def get_tree(db: AsyncSession) -> list[dict]: + """Get all locations as a nested tree structure.""" + all_locations = await find_all(db) + + # Build lookup dict + by_id = {loc.id: loc for loc in all_locations} + + # Build tree + def build_node(loc: models.Location) -> dict: + return { + "id": loc.id, + "name": loc.name, + "location_type": loc.location_type, + "description": loc.description, + "children": [ + build_node(by_id[child.id]) + for child in all_locations + if child.parent_id == loc.id + ], + } + + # Get root nodes (no parent) + roots = [loc for loc in all_locations if loc.parent_id is None] + return [build_node(loc) for loc in roots] + + +async def get_spool_count(db: AsyncSession, location_id: int) -> int: + """Get the number of spools at this location (not including children).""" + stmt = ( + select(func.count(models.Spool.id)) + .where(models.Spool.location_id == location_id) + .where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712 + ) + result = await db.execute(stmt) + return result.scalar() or 0 diff --git a/spoolman/database/models.py b/spoolman/database/models.py index 9574e09..ba5530a 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -12,6 +12,30 @@ class Base(AsyncAttrs, DeclarativeBase): pass +class Location(Base): + """Hierarchical storage location (room, shelf, bin, etc.).""" + + __tablename__ = "location" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(64)) + parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("location.id", ondelete="SET NULL"), index=True) + location_type: Mapped[Optional[str]] = mapped_column(String(32), comment="Type: room, shelf, bin, etc.") + description: Mapped[Optional[str]] = mapped_column(String(256)) + + # Self-referential relationships + parent: Mapped[Optional["Location"]] = relationship( + "Location", + remote_side="Location.id", + back_populates="children", + ) + children: Mapped[list["Location"]] = relationship( + "Location", + back_populates="parent", + ) + spools: Mapped[list["Spool"]] = relationship(back_populates="location_obj") + + class Vendor(Base): __tablename__ = "vendor" @@ -76,7 +100,13 @@ class Spool(Base): initial_weight: Mapped[Optional[float]] = mapped_column() spool_weight: Mapped[Optional[float]] = mapped_column() used_weight: Mapped[float] = mapped_column() - location: Mapped[Optional[str]] = mapped_column(String(64)) + location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.") + location_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("location.id", ondelete="SET NULL"), + index=True, + comment="FK to hierarchical location.", + ) + location_obj: Mapped[Optional["Location"]] = relationship(back_populates="spools") lot_nr: Mapped[Optional[str]] = mapped_column(String(64)) comment: Mapped[Optional[str]] = mapped_column(String(1024)) archived: Mapped[Optional[bool]] = mapped_column()
{t("batch.move_description", { count: props.selectedIds.length })}