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

## 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:
2026-01-21 21:40:01 -06:00
parent 27757bb949
commit 02da984b6e
18 changed files with 1683 additions and 90 deletions

389
client/package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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",

View 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>
</>
);
}

View 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}
/>
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
};

View 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>
</>
);
}

View File

@@ -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

View File

@@ -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>
);
};