feat: Add batch operations, dashboard redesign, hierarchical locations
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
## 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 <noreply@anthropic.com>
This commit is contained in:
389
client/package-lock.json
generated
389
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <helpPageLink>Help page</helpPageLink> 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",
|
||||
|
||||
143
client/src/components/LocationCreateModal.tsx
Normal file
143
client/src/components/LocationCreateModal.tsx
Normal file
@@ -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<LocationTreeNode[]>({
|
||||
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}
|
||||
<Modal
|
||||
title={t("location.titles.create")}
|
||||
open={isOpen}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{ parent_id: parentId }}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t("location.fields.name")}
|
||||
rules={[{ required: true, message: t("location.fields.name_required") }]}
|
||||
>
|
||||
<Input placeholder={t("location.fields.name_placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="parent_id"
|
||||
label={t("location.fields.parent")}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t("location.fields.parent_placeholder")}
|
||||
options={parentOptions}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string).toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="location_type"
|
||||
label={t("location.fields.location_type")}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t("location.fields.location_type_placeholder")}
|
||||
options={LOCATION_TYPES.map((type) => ({
|
||||
value: type,
|
||||
label: t(`location.types.${type}`),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
client/src/components/LocationTreeSelect.tsx
Normal file
110
client/src/components/LocationTreeSelect.tsx
Normal file
@@ -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<LocationTreeNode[]>({
|
||||
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 (
|
||||
<>
|
||||
<TreeSelect
|
||||
style={style}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
treeData={treeData}
|
||||
placeholder={placeholder ?? t("spool.fields.location")}
|
||||
allowClear
|
||||
showSearch
|
||||
treeDefaultExpandAll
|
||||
loading={isLoading}
|
||||
notFoundContent={isLoading ? <Spin size="small" /> : undefined}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
{allowCreate && (
|
||||
<>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{t("location.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
filterTreeNode={(input, treeNode) =>
|
||||
(treeNode.title as string).toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
|
||||
<LocationCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
115
client/src/pages/home/components/AlertCards.tsx
Normal file
115
client/src/pages/home/components/AlertCards.tsx
Normal file
@@ -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 (
|
||||
<Col xs={24} sm={8}>
|
||||
<Link to={to}>
|
||||
<Badge count={count} style={{ backgroundColor: color }} overflowCount={99}>
|
||||
<Card size="small" hoverable loading={loading}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ color, fontSize: 18 }}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</Card>
|
||||
</Badge>
|
||||
</Link>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCards() {
|
||||
const t = useTranslate();
|
||||
const { token } = useToken();
|
||||
|
||||
// Low stock spools
|
||||
const { result: lowStockResult, query: lowStockQuery } = useList<ISpool>({
|
||||
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<IPrintJob>({
|
||||
resource: "print-job",
|
||||
pagination: { pageSize: 1 },
|
||||
filters: [{ field: "status", operator: "eq", value: "pending" }],
|
||||
});
|
||||
|
||||
// Spools needing weighing
|
||||
const { result: needsWeighingResult, query: needsWeighingQuery } = useList<ISpool>({
|
||||
resource: "spool",
|
||||
pagination: { pageSize: 1 },
|
||||
meta: {
|
||||
queryParams: {
|
||||
allow_archived: false,
|
||||
needs_weighing: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const alerts = [
|
||||
{
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
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: <PrinterOutlined />,
|
||||
label: t("home.alerts.pending_jobs"),
|
||||
count: pendingJobsResult?.total ?? 0,
|
||||
to: "/print-queue",
|
||||
color: token.colorWarning,
|
||||
loading: pendingJobsQuery.isLoading,
|
||||
},
|
||||
{
|
||||
icon: <DashboardOutlined />,
|
||||
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 (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
{visibleAlerts.map((alert, i) => (
|
||||
<AlertCard key={i} {...alert} />
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -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<ISpool>({
|
||||
resource: "spool",
|
||||
pagination: { pageSize: 1000 },
|
||||
meta: {
|
||||
queryParams: {
|
||||
allow_archived: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Aggregate by material
|
||||
const chartData = useMemo(() => {
|
||||
const data = spoolsResult?.data || [];
|
||||
const materialCounts: Record<string, number> = {};
|
||||
|
||||
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 (
|
||||
<Card
|
||||
title={t("home.charts.material_distribution")}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
labelLine={{ stroke: token.colorTextSecondary }}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => [`${value} spools`, t("spool.spool")]}
|
||||
contentStyle={{
|
||||
backgroundColor: token.colorBgElevated,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
borderRadius: token.borderRadius,
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
client/src/pages/home/components/QuickStats.tsx
Normal file
100
client/src/pages/home/components/QuickStats.tsx
Normal file
@@ -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<IFilament>({
|
||||
resource: "filament",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
|
||||
const { result: vendorsResult, query: vendorsQuery } = useList<IVendor>({
|
||||
resource: "vendor",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
|
||||
// Fetch all spools for total weight calculation
|
||||
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
|
||||
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 (
|
||||
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Link to="/spool">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("home.quick_stats.total_spools")}
|
||||
value={spoolsResult?.total ?? 0}
|
||||
loading={spoolsQuery.isLoading}
|
||||
prefix={<InboxOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Link to="/filament">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("home.quick_stats.total_filaments")}
|
||||
value={filamentsResult?.total ?? 0}
|
||||
loading={filamentsQuery.isLoading}
|
||||
prefix={<HighlightOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Link to="/vendor">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("home.quick_stats.total_vendors")}
|
||||
value={vendorsResult?.total ?? 0}
|
||||
loading={vendorsQuery.isLoading}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t("home.quick_stats.total_weight")}
|
||||
value={formatWeight(totalWeight)}
|
||||
loading={spoolsQuery.isLoading}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -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<IResourceComponentsProps> = () => {
|
||||
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<ISpool>({
|
||||
@@ -42,38 +41,6 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch counts for stats
|
||||
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
|
||||
resource: "filament",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
const { result: vendorsResult, query: vendorsQuery } = useList<IVendor>({
|
||||
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<ISpool>({
|
||||
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<IResourceComponentsProps> = () => {
|
||||
</Row>
|
||||
|
||||
{/* Alert badges */}
|
||||
{totalLowStock > 0 && (
|
||||
<Row style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Link to="/spool?remaining_weight_lt=100">
|
||||
<Badge
|
||||
count={totalLowStock}
|
||||
style={{ backgroundColor: token.colorError }}
|
||||
overflowCount={99}
|
||||
>
|
||||
<Card size="small" hoverable style={{ cursor: "pointer" }}>
|
||||
<ExclamationCircleOutlined style={{ color: token.colorError, marginRight: 8 }} />
|
||||
{t("home.low_stock_alert", { count: totalLowStock, threshold: LOW_STOCK_THRESHOLD })}
|
||||
</Card>
|
||||
</Badge>
|
||||
</Link>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<AlertCards />
|
||||
|
||||
{/* Material distribution chart */}
|
||||
<MaterialDistributionChart />
|
||||
|
||||
{/* Spool list */}
|
||||
<Card
|
||||
@@ -209,33 +162,8 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Secondary stats */}
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12}>
|
||||
<Link to="/filament">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("filament.filament")}
|
||||
value={filamentsResult?.total ?? 0}
|
||||
loading={filamentsQuery.isLoading}
|
||||
prefix={<HighlightOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Link to="/vendor">
|
||||
<Card hoverable size="small">
|
||||
<Statistic
|
||||
title={t("vendor.vendor")}
|
||||
value={vendorsResult?.total ?? 0}
|
||||
loading={vendorsQuery.isLoading}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* Quick stats */}
|
||||
<QuickStats />
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
91
client/src/pages/spools/components/BatchActionBar.tsx
Normal file
91
client/src/pages/spools/components/BatchActionBar.tsx
Normal file
@@ -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<string>();
|
||||
const locations = useSpoolmanLocations(isMoveModalOpen);
|
||||
|
||||
if (props.selectedIds.length === 0) return null;
|
||||
|
||||
const handleMoveConfirm = () => {
|
||||
if (selectedLocation) {
|
||||
props.onMove(selectedLocation);
|
||||
setIsMoveModalOpen(false);
|
||||
setSelectedLocation(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Affix offsetBottom={20}>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
boxShadow: "0 -2px 8px rgba(0,0,0,0.15)",
|
||||
borderRadius: 8,
|
||||
maxWidth: 600,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{t("batch.selected", { count: props.selectedIds.length })}
|
||||
</span>
|
||||
<Button icon={<InboxOutlined />} onClick={props.onArchive}>
|
||||
{t("buttons.archive")}
|
||||
</Button>
|
||||
<Button icon={<UndoOutlined />} onClick={props.onUnarchive}>
|
||||
{t("buttons.unArchive")}
|
||||
</Button>
|
||||
<Button icon={<EnvironmentOutlined />} onClick={() => setIsMoveModalOpen(true)}>
|
||||
{t("batch.move")}
|
||||
</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={props.onDelete}>
|
||||
{t("buttons.delete")}
|
||||
</Button>
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={props.onClear}>
|
||||
{t("batch.clear_selection")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Affix>
|
||||
|
||||
<Modal
|
||||
title={t("batch.move_title")}
|
||||
open={isMoveModalOpen}
|
||||
onOk={handleMoveConfirm}
|
||||
onCancel={() => {
|
||||
setIsMoveModalOpen(false);
|
||||
setSelectedLocation(undefined);
|
||||
}}
|
||||
okButtonProps={{ disabled: !selectedLocation }}
|
||||
>
|
||||
<p>{t("batch.move_description", { count: props.selectedIds.length })}</p>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
placeholder={t("spool.fields.location")}
|
||||
value={selectedLocation}
|
||||
onChange={setSelectedLocation}
|
||||
loading={locations.isLoading}
|
||||
options={locations.data?.map((loc) => ({ label: loc, value: loc })) || []}
|
||||
showSearch
|
||||
allowClear
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<IResourceComponentsProps> = () => {
|
||||
// State for the switch to show archived spools
|
||||
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
|
||||
|
||||
// State for batch selection
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
// 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<IResourceComponentsProps> = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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<IResourceComponentsProps> = () => {
|
||||
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<IResourceComponentsProps> = () => {
|
||||
ActionsColumn(t("table.actions"), actions),
|
||||
])}
|
||||
/>
|
||||
<BatchActionBar
|
||||
selectedIds={selectedRowKeys as number[]}
|
||||
onArchive={handleBatchArchive}
|
||||
onUnarchive={handleBatchUnarchive}
|
||||
onDelete={handleBatchDelete}
|
||||
onMove={handleBatchMove}
|
||||
onClear={() => setSelectedRowKeys([])}
|
||||
/>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
164
spoolman/api/v1/location.py
Normal file
164
spoolman/api/v1/location.py
Normal file
@@ -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!")
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
148
spoolman/database/location.py
Normal file
148
spoolman/database/location.py
Normal file
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user