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",
|
"name": "spoolman-ui",
|
||||||
"version": "0.22.1",
|
"version": "0.23C.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "spoolman-ui",
|
"name": "spoolman-ui",
|
||||||
"version": "0.22.1",
|
"version": "0.23C.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@loadable/component": "^5.16.7",
|
"@loadable/component": "^5.16.7",
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"react-i18next": "^16.5.1",
|
"react-i18next": "^16.5.1",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^7.11.0",
|
||||||
"react-to-print": "^3.2.0",
|
"react-to-print": "^3.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -3359,6 +3360,48 @@
|
|||||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
|
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@refinedev/antd": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz",
|
||||||
@@ -4187,6 +4230,18 @@
|
|||||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
"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": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"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"
|
"@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": {
|
"node_modules/@types/emscripten": {
|
||||||
"version": "1.41.5",
|
"version": "1.41.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||||
@@ -4622,6 +4740,12 @@
|
|||||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -5886,6 +6010,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -6157,6 +6290,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"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"
|
"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": {
|
"node_modules/decode-uri-component": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
"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"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -8439,6 +8709,16 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -8577,6 +8857,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -11919,6 +12208,29 @@
|
|||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -12020,6 +12332,42 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/redeyed": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz",
|
||||||
@@ -12039,6 +12387,15 @@
|
|||||||
"@babel/runtime": "^7.9.2"
|
"@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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -12207,6 +12564,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
@@ -14129,6 +14492,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"react-i18next": "^16.5.1",
|
"react-i18next": "^16.5.1",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^7.11.0",
|
||||||
"react-to-print": "^3.2.0",
|
"react-to-print": "^3.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|||||||
@@ -37,6 +37,19 @@
|
|||||||
"viewAll": "View All"
|
"viewAll": "View All"
|
||||||
},
|
},
|
||||||
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
"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": {
|
"notifications": {
|
||||||
"success": "Successful",
|
"success": "Successful",
|
||||||
"error": "Error (status code: {{statusCode}})",
|
"error": "Error (status code: {{statusCode}})",
|
||||||
@@ -239,6 +252,40 @@
|
|||||||
"last_used": "Last used {{date}}"
|
"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": {
|
||||||
"filament": "Filaments",
|
"filament": "Filaments",
|
||||||
"fields": {
|
"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.",
|
"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",
|
"all_spools": "All Spools",
|
||||||
"low_stock_alert": "{{count}} spools below {{threshold}}g",
|
"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": "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 {
|
import {
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
HighlightOutlined,
|
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
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 { Content } from "antd/es/layout/layout";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import SpoolIcon from "../../components/spoolIcon";
|
import SpoolIcon from "../../components/spoolIcon";
|
||||||
import { IFilament } from "../filaments/model";
|
|
||||||
import { ISpool } from "../spools/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);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 15;
|
const pageSize = 10;
|
||||||
|
|
||||||
// Fetch all spools (not archived) for the main list
|
// Fetch all spools (not archived) for the main list
|
||||||
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
|
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 = [
|
const spoolColumns = [
|
||||||
{
|
{
|
||||||
title: "",
|
title: "",
|
||||||
@@ -160,24 +127,10 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Alert badges */}
|
{/* Alert badges */}
|
||||||
{totalLowStock > 0 && (
|
<AlertCards />
|
||||||
<Row style={{ marginBottom: 16 }}>
|
|
||||||
<Col>
|
{/* Material distribution chart */}
|
||||||
<Link to="/spool?remaining_weight_lt=100">
|
<MaterialDistributionChart />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spool list */}
|
{/* Spool list */}
|
||||||
<Card
|
<Card
|
||||||
@@ -209,33 +162,8 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Secondary stats */}
|
{/* Quick stats */}
|
||||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
<QuickStats />
|
||||||
<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>
|
|
||||||
</Content>
|
</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);
|
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.
|
* Use some spool filament from this spool. Either specify length or weight.
|
||||||
* @param spool The spool
|
* @param spool The spool
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ import { removeUndefined } from "../../utils/filtering";
|
|||||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||||
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
|
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
|
||||||
import { useCurrencyFormatter } from "../../utils/settings";
|
import { useCurrencyFormatter } from "../../utils/settings";
|
||||||
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
||||||
import { ISpool } from "./model";
|
import { ISpool } from "./model";
|
||||||
|
import { BatchActionBar } from "./components/BatchActionBar";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
@@ -119,6 +120,9 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
// State for the switch to show archived spools
|
// State for the switch to show archived spools
|
||||||
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
|
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
|
||||||
|
|
||||||
|
// State for batch selection
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
|
||||||
// Fetch data from the API
|
// Fetch data from the API
|
||||||
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
|
// 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
|
// 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) {
|
if (tableProps.pagination) {
|
||||||
tableProps.pagination.showSizeChanger = true;
|
tableProps.pagination.showSizeChanger = true;
|
||||||
}
|
}
|
||||||
@@ -339,6 +395,11 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
scroll={{ x: "max-content" }}
|
scroll={{ x: "max-content" }}
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (newSelectedRowKeys) => setSelectedRowKeys(newSelectedRowKeys),
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
}}
|
||||||
// Make archived rows greyed out
|
// Make archived rows greyed out
|
||||||
onRow={(record) => {
|
onRow={(record) => {
|
||||||
if (record.archived) {
|
if (record.archived) {
|
||||||
@@ -480,6 +541,14 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
ActionsColumn(t("table.actions"), actions),
|
ActionsColumn(t("table.actions"), actions),
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
|
<BatchActionBar
|
||||||
|
selectedIds={selectedRowKeys as number[]}
|
||||||
|
onArchive={handleBatchArchive}
|
||||||
|
onUnarchive={handleBatchUnarchive}
|
||||||
|
onDelete={handleBatchDelete}
|
||||||
|
onMove={handleBatchMove}
|
||||||
|
onClear={() => setSelectedRowKeys([])}
|
||||||
|
/>
|
||||||
</List>
|
</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.exceptions import ItemNotFoundError
|
||||||
from spoolman.ws import websocket_manager
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -116,3 +116,4 @@ app.include_router(export.router)
|
|||||||
app.include_router(backup_module.router)
|
app.include_router(backup_module.router)
|
||||||
app.include_router(print_job.router)
|
app.include_router(print_job.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(location_module.router)
|
||||||
|
|||||||
@@ -534,6 +534,69 @@ async def delete(
|
|||||||
return Message(message="Success!")
|
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(
|
@router.put(
|
||||||
"/{spool_id}/use",
|
"/{spool_id}/use",
|
||||||
name="Use spool filament",
|
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
|
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):
|
class Vendor(Base):
|
||||||
__tablename__ = "vendor"
|
__tablename__ = "vendor"
|
||||||
|
|
||||||
@@ -76,7 +100,13 @@ class Spool(Base):
|
|||||||
initial_weight: Mapped[Optional[float]] = mapped_column()
|
initial_weight: Mapped[Optional[float]] = mapped_column()
|
||||||
spool_weight: Mapped[Optional[float]] = mapped_column()
|
spool_weight: Mapped[Optional[float]] = mapped_column()
|
||||||
used_weight: Mapped[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))
|
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
|
||||||
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
||||||
archived: Mapped[Optional[bool]] = mapped_column()
|
archived: Mapped[Optional[bool]] = mapped_column()
|
||||||
|
|||||||
Reference in New Issue
Block a user