From 02da984b6ef43b82d069d76e91d4c0c7a24b1b13 Mon Sep 17 00:00:00 2001 From: tonym Date: Wed, 21 Jan 2026 21:40:01 -0600 Subject: [PATCH] feat: Add batch operations, dashboard redesign, hierarchical locations ## Batch Operations (#13) - Add POST /spool/bulk/archive, /bulk/delete, /bulk/update endpoints - Add row selection with checkboxes to spool list - Add floating BatchActionBar with archive/unarchive/delete/move actions - Add confirmation modals for all batch operations ## Dashboard Redesign (#12) - Install recharts for pie charts - Add AlertCards (low stock, pending jobs, needs weighing) - Add MaterialDistributionChart (pie chart by material) - Add QuickStats row (total spools, filaments, vendors, weight) - Refactor home page layout with new components ## Hierarchical Locations (#9) - Add Location model with self-referential parent relationship - Add migration for location table and spool.location_id FK - Add location database operations (CRUD, tree, full path) - Add location API endpoints (list, tree, CRUD) - Add LocationTreeSelect component with nested tree display - Add LocationCreateModal for inline location creation - Keep legacy location string field for backward compatibility Co-Authored-By: Claude Opus 4.5 --- client/package-lock.json | 389 +++++++++++++++++- client/package.json | 1 + client/public/locales/en/common.json | 63 ++- client/src/components/LocationCreateModal.tsx | 143 +++++++ client/src/components/LocationTreeSelect.tsx | 110 +++++ .../src/pages/home/components/AlertCards.tsx | 115 ++++++ .../components/MaterialDistributionChart.tsx | 93 +++++ .../src/pages/home/components/QuickStats.tsx | 100 +++++ client/src/pages/home/index.tsx | 96 +---- .../spools/components/BatchActionBar.tsx | 91 ++++ client/src/pages/spools/functions.tsx | 30 ++ client/src/pages/spools/list.tsx | 71 +++- ...e5f6g7h8i9j0_add_hierarchical_locations.py | 61 +++ spoolman/api/v1/location.py | 164 ++++++++ spoolman/api/v1/router.py | 3 +- spoolman/api/v1/spool.py | 63 +++ spoolman/database/location.py | 148 +++++++ spoolman/database/models.py | 32 +- 18 files changed, 1683 insertions(+), 90 deletions(-) create mode 100644 client/src/components/LocationCreateModal.tsx create mode 100644 client/src/components/LocationTreeSelect.tsx create mode 100644 client/src/pages/home/components/AlertCards.tsx create mode 100644 client/src/pages/home/components/MaterialDistributionChart.tsx create mode 100644 client/src/pages/home/components/QuickStats.tsx create mode 100644 client/src/pages/spools/components/BatchActionBar.tsx create mode 100644 migrations/versions/2025_01_21_0100-e5f6g7h8i9j0_add_hierarchical_locations.py create mode 100644 spoolman/api/v1/location.py create mode 100644 spoolman/database/location.py 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} + > +
+ + + + + + ({ + 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 && ( + <> + + + + )} + + )} + 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 })} + + + + + + + + + + + { + setIsMoveModalOpen(false); + setSelectedLocation(undefined); + }} + okButtonProps={{ disabled: !selectedLocation }} + > +

{t("batch.move_description", { count: props.selectedIds.length })}

+