Compare commits

...

14 Commits

Author SHA1 Message Date
beb1ad2bfa docs: Update CLAUDE.md with completed issue #19
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / build-amd64 (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
Issue Manager / issue-manager (push) Failing after 9s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:40:18 -06:00
23ef41270c feat(#19): Add filamentcolors.xyz color lookup integration
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
- Backend proxy endpoints: GET /filamentcolors/manufacturers, GET /filamentcolors/search
- Frontend: FilamentColorLookup modal with manufacturer filter + color search
- Color grid shows swatches with hex codes, click to apply
- Added to both filament create and edit forms next to ColorPicker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:21:13 -06:00
c321d916b5 fix: Show alerts below stats normally, fix usage cost calculation
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
- Alerts shown below QuickStats without hover reveal
- Alerts also listed after usage analytics section
- Usage cost now shows cost of material consumed (unit cost * usage)
  instead of all-time purchase total which was misleading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:05:07 -06:00
67eaed32d5 feat: Move alerts below stats, reveal on hover
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
Alerts now appear below the quick stats badges and are collapsed by
default, showing just a subtle "N alerts" indicator that expands
on mouseover.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:37:04 -06:00
f38baa985e feat: Rearrange dashboard - pie chart + stats on top, spools middle, analytics bottom
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
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:32:03 -06:00
ad1df035e9 docs: Update CLAUDE.md with completed issues #20, #25, #35, #36, #39, #42, #43
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
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:17:43 -06:00
801d3da02a feat: Add quick wins batch (#42, #43, #25, #35, #20, #36, #39)
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
- #42: Allow spools heavier than theoretical max (remove weight clamps)
- #43: Show filament custom fields on spool detail page
- #25: Sort spools by custom fields and remaining weight
- #35: Global search box for spool list
- #20: Gallery view for spools (color grid with progress bars)
- #36: Spool-level color override with ColorPicker
- #39: Cost analytics endpoint, total spent & avg cost/kg stats
- Fix: Filament select refresh after inline creation
- Fix: Parse temperatures from filament comments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:09:42 -06:00
5f66302e73 docs: Update CLAUDE.md with full Gitea issue list (#1-#47)
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
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:04:17 -06:00
8681f7aee7 fix: Remove FK constraint from location migration for SQLite compatibility
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
Issue Manager / issue-manager (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:47:58 -06:00
3aa633df0a docs: Update CLAUDE.md with completed features #14, #15, and new features
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
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:41:17 -06:00
18cafc4361 feat: Add extra weight, price tracking, print history, usage analytics
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
- Extra Weight Field (#14): Track DryPods, custom holders in spool weight
  calculations. New extra_weight field on spool with DB migration.

- Price/Cost Tracking: Compute remaining_value based on remaining weight
  and price. Added column to spool list, inventory value on dashboard.

- Print History: Show print job history on spool detail page with
  collapsible table showing filename, filament used, status, dates.

- Usage Analytics: New dashboard component with time-series chart
  showing daily consumption, period selector (7/30/90 days), and
  material breakdown. New API endpoints for analytics data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:38:14 -06:00
0556be9e3b docs: Update CLAUDE.md with completed features #9, #12, #13
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
Mark issues 9, 12, 13 as done and add implementation details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:40:58 -06:00
02da984b6e 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>
2026-01-21 21:40:01 -06:00
27757bb949 Add About section with attribution to Settings page
Credits Donkie for original Spoolman and Claude for UX enhancements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:17:25 -06:00
35 changed files with 3325 additions and 191 deletions

252
CLAUDE.md
View File

@@ -134,13 +134,42 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
| 6 | Spool Adjustment History | ✅ DONE |
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
| 8 | (duplicate of #7) | ✅ DONE |
| 9 | Hierarchical Locations (Room/Bin) | **Open** |
| 9 | Hierarchical Locations (Room/Bin) | ✅ DONE |
| 10 | Database Import/Export | ✅ DONE |
| 11 | Slicer Integration / Print Jobs | ✅ DONE |
| 12 | Redesign Home Page Dashboard | **Open** |
| 13 | Batch Operations (Multi-Select Edit) | **Open** |
| 14 | Extra Weight Field (DryPods, etc.) | **Open** |
| 15 | Spool Count per Filament in List | **Open** |
| 12 | Redesign Home Page Dashboard | ✅ DONE |
| 13 | Batch Operations (Multi-Select Edit) | ✅ DONE |
| 14 | Extra Weight Field (DryPods, etc.) | ✅ DONE |
| 15 | Spool Count per Filament in List | ✅ DONE |
| 16 | Filter Spools by Color | ✅ DONE |
| 17 | Image Attachments for Filaments | **Open** |
| 18 | QR Code Labels for Filaments | **Open** |
| 19 | Integration with filamentcolors.xyz | ✅ DONE |
| 20 | Gallery View for Spools (Color Grid) | ✅ DONE |
| 21 | QR Code Version/Complexity Config | **Open** |
| 23 | Reporting & Data Export | **Open** |
| 24 | NFC/RFID Tag Support | **Open** |
| 25 | Sort Spools by Custom Fields | ✅ DONE |
| 26 | Label Template Enhancements | **Open** |
| 27 | QR Scanner Camera Device Selector | **Open** |
| 28 | Mobile-Friendly UI / Responsive | **Open** |
| 29 | Table Column Customization | **Open** |
| 30 | Printer Management & Parts Inventory | **Open** |
| 31 | Special Filament Properties | **Open** |
| 32 | SpoolType Model (Cardboard/Plastic/Metal) | **Open** |
| 34 | Color Swatch Border for Dark Mode | ✅ DONE |
| 35 | Global Search Box | ✅ DONE |
| 36 | Spool-Level Overrides (Color, Price) | ✅ DONE |
| 37 | Plugin System for Extensions | **Open** |
| 39 | Usage Statistics & Cost Tracking | ✅ DONE |
| 40 | Storage vs Active Spool Status | **Open** |
| 41 | Load Cell / Scale Integration | **Open** |
| 42 | Allow Spools Heavier Than Max | ✅ DONE |
| 43 | Show Filament Fields on Spool View | ✅ DONE |
| 44 | HomeAssistant Add-On Packaging | **Open** |
| 45 | Filament Swatch Card Generator | **Open** |
| 46 | Project-Based Spool Grouping | **Open** |
| 47 | G-code Filament Usage Estimation | **Open** |
### Other Features (not tracked in Gitea)
- ✅ Theme Colors (5 options)
@@ -148,6 +177,9 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
- ✅ QR Code Size Setting
- ✅ Parse Temps from Comments
- ✅ Prev/Next Navigation on Edit Pages
- ✅ Price/Cost Tracking (remaining value, inventory value)
- ✅ Print History on Spool Detail
- ✅ Usage Analytics Dashboard
- ✅ Color Column in Spool List (with filter)
### Issue #1 Complete - Inline Creation Modals
@@ -355,6 +387,123 @@ Added optional color column to the spool list with filtering capability.
- Multi-select filtering (can filter by multiple colors)
- Column hidden by default (enable via column selector)
### Issue #9 Complete - Hierarchical Locations
Storage locations can now be organized in a tree structure (Room > Shelf > Bin).
**Backend:**
- `spoolman/database/models.py` - Added `Location` model with self-referential parent
- `spoolman/database/location.py` - CRUD operations, tree building, full path resolution
- `spoolman/api/v1/location.py` - REST API for locations
- `migrations/versions/2025_01_21_0100-*.py` - Migration for location table
**Frontend:**
- `client/src/components/LocationTreeSelect.tsx` - Tree-based location picker
- `client/src/components/LocationCreateModal.tsx` - Inline location creation
**Features:**
- Hierarchical structure with parent/child relationships
- Location types: room, shelf, bin, drawer, box, other
- Legacy flat location string preserved for backward compatibility
- API endpoints: GET /location, GET /location/tree, POST/PATCH/DELETE
### Issue #12 Complete - Dashboard Redesign
Redesigned home page with better visualizations and quick stats.
**Backend:** No changes needed - uses existing endpoints.
**Frontend:**
- `client/src/pages/home/index.tsx` - Refactored layout
- `client/src/pages/home/components/AlertCards.tsx` - Low stock, pending jobs, needs weighing alerts
- `client/src/pages/home/components/MaterialDistributionChart.tsx` - Pie chart by material (recharts)
- `client/src/pages/home/components/QuickStats.tsx` - Total spools, filaments, vendors, weight
**Dependencies:**
- Added `recharts` package for charts
### Issue #13 Complete - Batch Operations
Multi-select spools with bulk archive/delete/move actions.
**Backend:**
- `spoolman/api/v1/spool.py` - Added bulk endpoints:
- POST /spool/bulk/archive
- POST /spool/bulk/delete
- POST /spool/bulk/update
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added row selection
- `client/src/pages/spools/components/BatchActionBar.tsx` - Floating action bar
- `client/src/pages/spools/functions.tsx` - Bulk helper functions
**Features:**
- Checkbox selection with select-all
- Floating action bar shows when items selected
- Archive, unarchive, delete, move to location
- Confirmation modals for all destructive actions
### Issue #14 Complete - Extra Weight Field
Track extra weight from DryPods, custom spool holders, or other accessories.
**Backend:**
- `spoolman/database/models.py` - Added `extra_weight` column to Spool model
- `spoolman/database/spool.py` - Updated `measure()` to account for extra weight
- `spoolman/api/v1/spool.py` - Added `extra_weight` to create/update parameters
- `migrations/versions/2025_01_21_0200-*.py` - Migration for extra_weight column
**Frontend:**
- `client/src/pages/spools/create.tsx` - Added extra_weight form field
- `client/src/pages/spools/edit.tsx` - Added extra_weight form field
- Weight calculations updated to include extra_weight in gross weight
**Features:**
- Optional field for tracking additional weight on spool
- Automatically subtracted when measuring spool weight
- Useful for DryPods, custom spool holders, reel adapters
### Issue #15 Complete - Spool Count per Filament
Already implemented - filament list shows `spool_count` and `total_remaining_weight` columns.
### Price & Cost Tracking
Track spool purchase price and calculate remaining value.
**Backend:**
- `spoolman/api/v1/models.py` - Added `remaining_value` computed field
- Formula: `remaining_value = (remaining_weight / initial_weight) * price`
- Price falls back to filament price if not set on spool
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added remaining_value column (hidden by default)
- `client/src/pages/home/components/QuickStats.tsx` - Added "Inventory Value" stat
### Print History on Spool Detail
Show print job history on the spool detail page.
**Frontend:**
- `client/src/pages/spools/show.tsx` - Added collapsible print history table
- Fetches print jobs filtered by spool_id
- Shows: Created date, Filename, Filament Used, Status, Finished date
### Usage Analytics Dashboard
Time-series charts showing filament consumption trends.
**Backend:**
- `spoolman/api/v1/other.py` - New analytics endpoints:
- `GET /analytics/usage?days=N` - Daily usage from SpoolAdjustments
- `GET /analytics/by-material?days=N` - Usage breakdown by material
**Frontend:**
- `client/src/pages/home/components/UsageAnalytics.tsx` - New component
- Area chart showing daily consumption (recharts)
- Period selector: 7, 30, or 90 days
- Material breakdown cards showing usage by filament type
---
## Deployment
@@ -374,16 +523,91 @@ ssh root@192.168.0.5 "/mnt/user/appdata/spoolman2-build/update.sh"
---
### Issue #42 Complete - Allow Spools Heavier Than Max
Removed weight validation cap that prevented spools from exceeding their theoretical initial weight.
**Backend:**
- `spoolman/database/spool.py` - Removed `max(..., 0)` clamps from create/update; when remaining > initial, adjusts initial upward
- `spoolman/api/v1/models.py` - Removed `max(..., 0)` from remaining_weight computation
### Issue #43 Complete - Show Filament Fields on Spool View
Display filament custom fields on the spool detail page.
**Frontend:**
- `client/src/pages/spools/show.tsx` - Added `filamentExtraFields` section after spool extra fields
### Issue #25 Complete - Sort by Custom Fields & Remaining Weight
**Backend:**
- `spoolman/database/spool.py` - Added `extra.{key}` sort support via outerjoin on SpoolField table
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added `sorter: true` to weight/length NumberColumns
- `client/src/components/column.tsx` - Changed CustomFieldColumn to sortable with `dataId: extra.{key}`
### Issue #35 Complete - Global Search Box
**Backend:**
- `spoolman/api/v1/spool.py` - Added `q` parameter to find endpoint
- `spoolman/database/spool.py` - OR-based search across filament name, material, vendor name, location, lot_nr, comment
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added `Input.Search` component, passes `q` via meta.queryParams
### Issue #20 Complete - Gallery View for Spools
Color grid view for spools with progress bars and material labels.
**Frontend:**
- `client/src/pages/spools/components/SpoolGallery.tsx` - Grid of color cards with progress bars
- `client/src/pages/spools/list.tsx` - View mode toggle (table/gallery)
### Issue #36 Complete - Spool-Level Overrides
Per-spool color override with ColorPicker (price and initial_weight already existed).
**Backend:**
- `spoolman/database/models.py` - Added `color_hex` field to Spool model
- `spoolman/api/v1/spool.py` - Added `color_hex` to create/update params
- `migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py`
**Frontend:**
- `client/src/pages/spools/create.tsx`, `edit.tsx` - ColorPicker form field
- All display locations updated to prefer `spool.color_hex` over `filament.color_hex`
### Issue #39 Complete - Usage Statistics & Cost Tracking
Added cost analytics endpoint and total spent / avg cost per kg stats.
**Backend:**
- `spoolman/api/v1/other.py` - Added `GET /analytics/cost` endpoint returning total_spent, avg_cost_per_kg, cost_by_material
**Frontend:**
- `client/src/pages/home/components/QuickStats.tsx` - Added "Total Spent" and "Avg Cost/kg" stat cards
- `client/src/pages/home/components/UsageAnalytics.tsx` - Added cost per material in breakdown cards
### Issue #19 Complete - filamentcolors.xyz Integration
Color lookup from filamentcolors.xyz database (2134 swatches, 317 manufacturers).
**Backend:**
- `spoolman/api/v1/other.py` - Proxy endpoints:
- `GET /filamentcolors/manufacturers` - List all manufacturers
- `GET /filamentcolors/search` - Search swatches by manufacturer, color, filament type
**Frontend:**
- `client/src/components/filamentColorLookup.tsx` - Modal with manufacturer filter + color grid
- Added search button next to ColorPicker on filament create and edit forms
- Click a swatch to apply its measured hex color
### Bug Fixes
- **Filament Select Refresh**: Fixed race condition where form value was set before options re-rendered. Uses `pendingFilamentId` state + useEffect.
- **Parse Temps from Comments**: Added regex-based temperature parser button on filament edit page.
## Known Issues / TODO
### Parse Temperature from Comments
Some filaments have temp info in comment field (e.g., "extrude 190-230 / bed 50-70") but not in the min/max fields. Could add a migration or button to parse and populate.
### Filament Select Not Refreshing After Inline Creation
When creating a filament via the inline modal on spool create page, the select dropdown doesn't immediately show the new filament with its name (shows ID instead).
### Issue #7 - 3dfilamentprofiles Integration REMOVED
The 3dfilamentprofiles.com website has Vercel bot protection that blocks automated requests. Their GitHub repo (jklewa/filament-profiles-data) only provides sample data (~3 filaments). Since SpoolmanDB already has 6,957+ filaments from major brands, the 3dfp integration was removed rather than maintaining dead code.
### Future: Issue #9 - Hierarchical Locations
Add room/bin/shelf support for locations. See Gitea issue #9 for details.

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

@@ -34,9 +34,24 @@
"notAccessTitle": "You don't have permission to access",
"hideColumns": "Hide Columns",
"clearFilters": "Clear Filters",
"galleryView": "Gallery",
"tableView": "Table",
"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}})",
@@ -166,6 +181,7 @@
},
"spool": {
"spool": "Spools",
"search_placeholder": "Search by name, material, vendor, location...",
"fields": {
"id": "ID",
"filament_name": "Filament",
@@ -178,11 +194,14 @@
"weight_to_use": "Weight",
"used_weight": "Used Weight",
"remaining_weight": "Remaining Weight",
"remaining_value": "Remaining Value",
"measured_weight": "Measured Weight",
"used_length": "Used Length",
"remaining_length": "Remaining Length",
"initial_weight": "Initial Weight",
"spool_weight": "Empty Weight",
"extra_weight": "Extra Weight",
"color_hex": "Color Override",
"location": "Location",
"lot_nr": "Lot Nr",
"first_used": "First Used",
@@ -190,8 +209,11 @@
"registered": "Registered",
"comment": "Comment",
"archived": "Archived",
"filament_fields": "Filament Custom Fields",
"adjustment_history": "Adjustment History",
"show_history": "Show History",
"print_history": "Print History",
"show_print_history": "Show Print History",
"adjustment_timestamp": "Date/Time",
"adjustment_type": "Type",
"adjustment_type_weight": "Weight",
@@ -207,6 +229,8 @@
"measured_weight": "How much the filament and spool weigh.",
"initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.",
"spool_weight": "The weight of the spool when it is empty. Leave empty to use the value from the filament or the manufacturer.",
"extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.",
"color_hex": "Override the filament color for this specific spool.",
"location": "Where the spool is located if you have multiple locations where you store your spools.",
"lot_nr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used.",
"external_filament": "You have selected a filament from the external database. A filament object (and possibly a manufacturer object) will be created automatically when you create this spool. This can create duplicate filament objects if you have already created a filament object for this filament."
@@ -239,6 +263,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": {
@@ -297,10 +355,21 @@
"created_success": "Created filament \"{{name}}\"",
"vendor_placeholder": "Select a manufacturer",
"name_placeholder": "E.g. Matte Black",
"material_placeholder": "PLA, PETG, ABS, etc."
"material_placeholder": "PLA, PETG, ABS, etc.",
"parse_temps": "Parse temps from comment",
"temps_parsed": "Temperature fields updated from comment",
"no_temps_found": "No temperature patterns found in comment"
},
"buttons": {
"add_spool": "Add Spool"
},
"color_lookup": {
"button": "Find color from filamentcolors.xyz",
"title": "Find Color - filamentcolors.xyz",
"manufacturer": "Manufacturer",
"color_search": "Search color name...",
"results": "results",
"showing_page": "page {{page}}"
}
},
"vendor": {
@@ -330,13 +399,54 @@
"name_required": "Name is required"
}
},
"print_job": {
"fields": {
"created": "Created",
"filename": "Filename",
"filament_used": "Filament Used",
"status": "Status",
"finished": "Finished"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
}
},
"home": {
"home": "Home",
"welcome": "Welcome to your Spoolman instance!",
"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",
"total_value": "Inventory Value",
"total_spent": "Total Spent",
"avg_cost_per_kg": "Avg Cost/kg"
},
"analytics": {
"title": "Usage Analytics",
"7_days": "7 Days",
"30_days": "30 Days",
"90_days": "90 Days",
"total_used": "Total used: {{weight}} kg",
"used": "Used",
"spent": "Spent",
"by_material": "Usage by Material"
}
},
"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

@@ -440,8 +440,9 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
const commonProps = {
...props,
id: ["extra", field.key],
dataId: `extra.${field.key}` as keyof Obj & string,
title: field.name,
sorter: false,
sorter: true,
transform: (value: unknown) => {
if (value === null || value === undefined) {
return undefined;

View File

@@ -0,0 +1,166 @@
import { SearchOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Button, Input, Modal, Select, Space, Spin, Tooltip } from "antd";
import { useState } from "react";
import { getAPIURL } from "../utils/url";
interface Swatch {
id: number;
color_name: string;
hex_color: string | null;
manufacturer_name: string;
filament_type: string;
image_front: string | null;
}
interface Manufacturer {
id: number;
name: string;
}
interface FilamentColorLookupProps {
onSelect: (hexColor: string) => void;
}
export function FilamentColorLookup({ onSelect }: FilamentColorLookupProps) {
const t = useTranslate();
const [open, setOpen] = useState(false);
const [manufacturer, setManufacturer] = useState<string | undefined>();
const [colorSearch, setColorSearch] = useState("");
const [page, setPage] = useState(1);
const { data: manufacturers, isLoading: mfrsLoading } = useQuery<Manufacturer[]>({
queryKey: ["filamentcolors", "manufacturers"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/filamentcolors/manufacturers`);
return res.json();
},
enabled: open,
});
const { data: searchResult, isLoading: searchLoading } = useQuery<{ count: number; swatches: Swatch[] }>({
queryKey: ["filamentcolors", "search", manufacturer, colorSearch, page],
queryFn: async () => {
const params = new URLSearchParams();
if (manufacturer) params.set("manufacturer", manufacturer);
if (colorSearch) params.set("color", colorSearch);
params.set("limit", "50");
params.set("page", String(page));
const res = await fetch(`${getAPIURL()}/filamentcolors/search?${params}`);
return res.json();
},
enabled: open,
});
const handleSelect = (hex: string) => {
onSelect(hex);
setOpen(false);
};
return (
<>
<Tooltip title={t("filament.color_lookup.button")}>
<Button icon={<SearchOutlined />} onClick={() => setOpen(true)} size="small" />
</Tooltip>
<Modal
title={t("filament.color_lookup.title")}
open={open}
onCancel={() => setOpen(false)}
footer={null}
width={700}
>
<Space.Compact style={{ width: "100%", marginBottom: 12 }}>
<Select
showSearch
allowClear
placeholder={t("filament.color_lookup.manufacturer")}
style={{ width: "50%" }}
loading={mfrsLoading}
value={manufacturer}
onChange={(v) => { setManufacturer(v); setPage(1); }}
options={manufacturers?.map((m) => ({ label: m.name, value: m.name }))}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
<Input
placeholder={t("filament.color_lookup.color_search")}
value={colorSearch}
onChange={(e) => { setColorSearch(e.target.value); setPage(1); }}
allowClear
style={{ width: "50%" }}
/>
</Space.Compact>
{searchLoading ? (
<div style={{ textAlign: "center", padding: 40 }}><Spin /></div>
) : (
<>
<div style={{ marginBottom: 8, fontSize: 12, color: "#888" }}>
{searchResult?.count ?? 0} {t("filament.color_lookup.results")}
{searchResult && searchResult.count > 50 && ` (${t("filament.color_lookup.showing_page", { page })})`}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(100px, 1fr))",
gap: 8,
maxHeight: 400,
overflowY: "auto",
}}
>
{searchResult?.swatches.map((s) => (
<Tooltip
key={s.id}
title={`${s.manufacturer_name} - ${s.color_name} (${s.filament_type}) #${s.hex_color ?? "?"}`}
>
<div
onClick={() => s.hex_color && handleSelect(s.hex_color)}
style={{
cursor: s.hex_color ? "pointer" : "default",
border: "1px solid #d9d9d9",
borderRadius: 6,
padding: 6,
textAlign: "center",
opacity: s.hex_color ? 1 : 0.4,
}}
>
<div
style={{
width: "100%",
aspectRatio: "1",
borderRadius: 4,
backgroundColor: s.hex_color ? `#${s.hex_color}` : "#ccc",
marginBottom: 4,
}}
/>
<div style={{ fontSize: 10, lineHeight: 1.2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.color_name}
</div>
<div style={{ fontSize: 9, color: "#888", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.filament_type}
</div>
</div>
</Tooltip>
))}
</div>
{searchResult && searchResult.count > 50 && (
<div style={{ marginTop: 12, textAlign: "center" }}>
<Button size="small" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("buttons.prev")}
</Button>
<span style={{ margin: "0 12px", fontSize: 12 }}>
{page} / {Math.ceil(searchResult.count / 50)}
</span>
<Button size="small" disabled={page * 50 >= searchResult.count} onClick={() => setPage((p) => p + 1)}>
{t("buttons.next")}
</Button>
</div>
)}
</>
)}
</Modal>
</>
);
}

View File

@@ -10,6 +10,7 @@ import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../compo
import { FilamentImportModal } from "../../components/filamentImportModal";
import { VendorCreateModal } from "../../components/vendorCreateModal";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import {
DEFAULT_DENSITY,
DEFAULT_DIAMETER,
@@ -248,19 +249,22 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item

View File

@@ -1,4 +1,4 @@
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
import { LeftOutlined, RightOutlined, ThunderboltOutlined } from "@ant-design/icons";
import { Edit, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd";
@@ -8,12 +8,61 @@ import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
/**
* Parse temperature ranges from a comment string.
* Handles patterns like:
* "extrude 190-230 / bed 50-70"
* "nozzle: 200-220°C, bed: 60-70°C"
* "hotend 200 bed 60"
*/
function parseTempsFromComment(comment: string): {
extruderMin?: number;
extruderMax?: number;
bedMin?: number;
bedMax?: number;
} {
const result: { extruderMin?: number; extruderMax?: number; bedMin?: number; bedMax?: number } = {};
// Patterns for extruder temperature
const extruderPatterns = [
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*°?C?/i,
];
// Patterns for bed temperature
const bedPatterns = [
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*°?C?/i,
];
for (const pattern of extruderPatterns) {
const match = comment.match(pattern);
if (match) {
result.extruderMin = parseInt(match[1]);
result.extruderMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
for (const pattern of bedPatterns) {
const match = comment.match(pattern);
if (match) {
result.bedMin = parseInt(match[1]);
result.bedMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
return result;
}
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
in order for Ant design's form to work properly. ParsedExtras does this for us.
@@ -37,7 +86,7 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
sorters: [{ field: "id", order: "asc" }],
});
const { formProps, saveButtonProps, id } = useForm<IFilament, HttpError, IFilament, IFilament>({
const { formProps, saveButtonProps, id, form } = useForm<IFilament, HttpError, IFilament, IFilament>({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened
@@ -200,19 +249,22 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker />
</Form.Item>
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item
@@ -330,6 +382,29 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<div style={{ marginBottom: 16 }}>
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => {
const comment = form.getFieldValue("comment") || "";
const temps = parseTempsFromComment(comment);
if (!temps.extruderMin && !temps.bedMin) {
messageApi.warning(t("filament.form.no_temps_found"));
return;
}
const fields: Record<string, number | undefined> = {};
if (temps.extruderMin) fields.settings_extruder_temp_min = temps.extruderMin;
if (temps.extruderMax) fields.settings_extruder_temp_max = temps.extruderMax;
if (temps.bedMin) fields.settings_bed_temp_min = temps.bedMin;
if (temps.bedMax) fields.settings_bed_temp_max = temps.bedMax;
form.setFieldsValue(fields);
messageApi.success(t("filament.form.temps_parsed"));
}}
>
{t("filament.form.parse_temps")}
</Button>
</div>
<Form.Item label={t("filament.fields.settings_extruder_temp")}>
<Space.Compact>
<Form.Item

View File

@@ -0,0 +1,119 @@
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>
);
}
interface AlertCardsProps {
compact?: boolean;
}
export function AlertCards({ compact }: AlertCardsProps) {
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={[compact ? 8 : 12, compact ? 8 : 12]} style={{ marginTop: compact ? 8 : 16 }}>
{visibleAlerts.map((alert, i) => (
<AlertCard key={i} {...alert} />
))}
</Row>
);
}

View File

@@ -0,0 +1,105 @@
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
];
interface MaterialDistributionChartProps {
square?: boolean;
}
export function MaterialDistributionChart({ square }: MaterialDistributionChartProps) {
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={square ? { aspectRatio: "1", display: "flex", flexDirection: "column" } : { marginBottom: 16 }}
styles={square ? { body: { flex: 1, padding: "8px 12px", display: "flex", flexDirection: "column" } } : undefined}
>
<ResponsiveContainer width="100%" height={square ? "100%" : 250}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={square ? 40 : 50}
outerRadius={square ? 70 : 80}
paddingAngle={2}
dataKey="value"
label={square ? undefined : ({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
labelLine={square ? false : { 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,
}}
/>
{square && (
<Legend
layout="horizontal"
verticalAlign="bottom"
wrapperStyle={{ fontSize: 11 }}
/>
)}
</PieChart>
</ResponsiveContainer>
</Card>
);
}

View File

@@ -0,0 +1,169 @@
import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined, RiseOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
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";
import { useCurrencyFormatter } from "../../../utils/settings";
import { getAPIURL } from "../../../utils/url";
const { useToken } = theme;
interface QuickStatsProps {
compact?: boolean;
}
export function QuickStats({ compact }: QuickStatsProps) {
const t = useTranslate();
const { token } = useToken();
const currencyFormatter = useCurrencyFormatter();
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]);
// Calculate total inventory value
const totalValue = useMemo(() => {
const data = spoolsResult?.data || [];
return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0);
}, [spoolsResult?.data]);
// Fetch cost stats from backend
const { data: costStats, isLoading: costLoading } = useQuery<{
total_spent: number;
total_weight_purchased: number;
avg_cost_per_kg: number | null;
}>({
queryKey: ["analytics", "cost"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/cost`);
return res.json();
},
});
const formatWeight = (grams: number) => {
if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)} kg`;
}
return `${Math.round(grams)} g`;
};
const stats = [
{
title: t("home.quick_stats.total_filaments"),
value: filamentsResult?.total ?? 0,
loading: filamentsQuery.isLoading,
prefix: <HighlightOutlined />,
to: "/filament",
},
{
title: t("home.quick_stats.total_vendors"),
value: vendorsResult?.total ?? 0,
loading: vendorsQuery.isLoading,
prefix: <UserOutlined />,
to: "/vendor",
},
{
title: t("home.quick_stats.total_weight"),
value: formatWeight(totalWeight),
loading: spoolsQuery.isLoading,
prefix: <DatabaseOutlined />,
},
{
title: t("home.quick_stats.total_value"),
value: currencyFormatter.format(totalValue),
loading: spoolsQuery.isLoading,
prefix: <DollarOutlined />,
},
{
title: t("home.quick_stats.total_spent"),
value: currencyFormatter.format(costStats?.total_spent ?? 0),
loading: costLoading,
prefix: <DollarOutlined />,
},
{
title: t("home.quick_stats.avg_cost_per_kg"),
value: costStats?.avg_cost_per_kg != null ? currencyFormatter.format(costStats.avg_cost_per_kg) : "-",
loading: costLoading,
prefix: <RiseOutlined />,
},
];
if (compact) {
return (
<Row gutter={[8, 8]}>
{stats.map((stat, i) => (
<Col xs={12} key={i}>
<Card size="small" styles={{ body: { padding: "8px 12px" } }}>
<Statistic
title={stat.title}
value={stat.value}
loading={stat.loading}
prefix={stat.prefix}
valueStyle={{ fontSize: 16 }}
/>
</Card>
</Col>
))}
</Row>
);
}
return (
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
<Col xs={12} md={8} lg={4}>
<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>
{stats.map((stat, i) => (
<Col xs={12} md={8} lg={4} key={i}>
{stat.to ? (
<Link to={stat.to}>
<Card hoverable size="small">
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
</Card>
</Link>
) : (
<Card size="small">
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
</Card>
)}
</Col>
))}
</Row>
);
}

View File

@@ -0,0 +1,153 @@
import { useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Card, Segmented, Spin, Typography } from "antd";
import { useState } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { getAPIURL } from "../../../utils/url";
import { useCurrencyFormatter } from "../../../utils/settings";
interface UsageDataPoint {
date: string;
weight_used: number;
}
interface MaterialUsage {
material: string;
weight_used: number;
}
interface CostByMaterial {
material: string;
total_cost: number;
total_weight: number;
}
export function UsageAnalytics() {
const t = useTranslate();
const currencyFormatter = useCurrencyFormatter();
const [days, setDays] = useState<number>(30);
const { data: usageData, isLoading: usageLoading } = useQuery<UsageDataPoint[]>({
queryKey: ["analytics", "usage", days],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/usage?days=${days}`);
return res.json();
},
});
const { data: materialData, isLoading: materialLoading } = useQuery<MaterialUsage[]>({
queryKey: ["analytics", "by-material", days],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/by-material?days=${days}`);
return res.json();
},
});
const { data: costData } = useQuery<{ cost_by_material: CostByMaterial[] }>({
queryKey: ["analytics", "cost"],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/analytics/cost`);
return res.json();
},
});
// Format date for display
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
// Calculate total usage
const totalUsage = usageData?.reduce((sum, d) => sum + d.weight_used, 0) ?? 0;
return (
<Card
title={t("home.analytics.title")}
extra={
<Segmented
value={days}
onChange={(v) => setDays(v as number)}
options={[
{ label: t("home.analytics.7_days"), value: 7 },
{ label: t("home.analytics.30_days"), value: 30 },
{ label: t("home.analytics.90_days"), value: 90 },
]}
/>
}
style={{ marginTop: 16 }}
>
{usageLoading ? (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin />
</div>
) : (
<>
<Typography.Text type="secondary">
{t("home.analytics.total_used", { weight: (totalUsage / 1000).toFixed(2) })}
</Typography.Text>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={usageData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorUsage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.8} />
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.1} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 11 }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}g`} width={50} />
<Tooltip
formatter={(value) => [`${(value as number).toFixed(1)} g`, t("home.analytics.used")]}
labelFormatter={(label) => new Date(label).toLocaleDateString()}
/>
<Area
type="monotone"
dataKey="weight_used"
stroke="#1890ff"
fillOpacity={1}
fill="url(#colorUsage)"
/>
</AreaChart>
</ResponsiveContainer>
{materialData && materialData.length > 0 && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
{t("home.analytics.by_material")}
</Typography.Title>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{materialData.map((m) => {
const costInfo = costData?.cost_by_material?.find((c) => c.material === m.material);
// Calculate cost of material used based on unit cost (cost per gram)
const costPerGram = costInfo && costInfo.total_weight > 0 ? costInfo.total_cost / costInfo.total_weight : null;
const usageCost = costPerGram != null ? costPerGram * m.weight_used : null;
return (
<Card size="small" key={m.material} style={{ minWidth: 120 }}>
<Typography.Text strong>{m.material}</Typography.Text>
<br />
<Typography.Text type="secondary">
{m.weight_used >= 1000 ? `${(m.weight_used / 1000).toFixed(2)} kg` : `${m.weight_used.toFixed(0)} g`}
</Typography.Text>
{usageCost != null && (
<>
<br />
<Typography.Text type="secondary">
{currencyFormatter.format(usageCost)}
</Typography.Text>
</>
)}
</Card>
);
})}
</div>
</>
)}
</>
)}
</Card>
);
}

View File

@@ -1,20 +1,20 @@
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";
import { UsageAnalytics } from "./components/UsageAnalytics";
dayjs.extend(utc);
@@ -28,7 +28,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 +42,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: "",
@@ -81,12 +49,14 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
key: "color",
width: 50,
render: (_: unknown, record: ISpool) => {
const colorObj = record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex;
const colorObj = record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex;
return colorObj ? <SpoolIcon color={colorObj} /> : null;
},
},
@@ -142,48 +112,38 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
fontFamily: token.fontFamily,
}}
>
{/* Header with spool count and add button */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Statistic
title={t("spool.spool")}
value={spoolsResult?.total ?? 0}
loading={spoolsQuery.isLoading}
valueStyle={{ fontSize: 36, fontWeight: 600 }}
/>
{/* Top section: Pie chart + stats/alerts side by side */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={24} md={10}>
<MaterialDistributionChart square />
</Col>
<Col>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
{t("spool.titles.create")}
</Button>
<Col xs={24} md={14}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Statistic
title={t("spool.spool")}
value={spoolsResult?.total ?? 0}
loading={spoolsQuery.isLoading}
valueStyle={{ fontSize: 36, fontWeight: 600 }}
/>
</Col>
<Col>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
{t("spool.titles.create")}
</Button>
</Col>
</Row>
<QuickStats compact />
<AlertCards compact />
</Col>
</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>
)}
{/* Spool list */}
<Card
title={t("home.all_spools")}
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
styles={{ body: { padding: 0 } }}
style={{ marginBottom: 16 }}
>
<Table<ISpool>
dataSource={spoolsResult?.data as ISpool[]}
@@ -209,33 +169,11 @@ 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>
{/* Usage analytics & insights */}
<UsageAnalytics />
{/* Alerts */}
<AlertCards />
</Content>
);
};

View File

@@ -144,6 +144,10 @@ export function GeneralSettings() {
<BackupRestore messageApi={messageApi} t={t} />
<Divider />
<AboutSection />
{contextHolder}
</>
);
@@ -447,3 +451,27 @@ function BackupRestore({ messageApi, t }: BackupRestoreProps) {
</div>
);
}
function AboutSection() {
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>About</h3>
<p style={{ color: "#888", lineHeight: 1.8 }}>
Based on{" "}
<a href="https://github.com/Donkie/Spoolman" target="_blank" rel="noopener noreferrer">
Spoolman
</a>{" "}
by{" "}
<a href="https://github.com/Donkie" target="_blank" rel="noopener noreferrer">
Donkie
</a>
<br />
UX enhancements developed with{" "}
<a href="https://claude.ai" target="_blank" rel="noopener noreferrer">
Claude
</a>{" "}
(Anthropic)
</p>
</div>
);
}

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

@@ -0,0 +1,91 @@
import { useTranslate } from "@refinedev/core";
import { Card, Progress, Typography } from "antd";
import { useNavigate } from "react-router";
import SpoolIcon from "../../../components/spoolIcon";
import { ISpool } from "../model";
interface SpoolGalleryProps {
dataSource: ISpool[];
loading?: boolean;
}
export function SpoolGallery({ dataSource, loading }: SpoolGalleryProps) {
const t = useTranslate();
const navigate = useNavigate();
if (loading) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{Array.from({ length: 12 }).map((_, i) => (
<Card key={i} loading size="small" style={{ height: 160 }} />
))}
</div>
);
}
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 12 }}>
{dataSource.map((spool) => {
const colorObj = spool.color_hex
? spool.color_hex
: spool.filament.multi_color_hexes
? {
colors: spool.filament.multi_color_hexes.split(","),
vertical: spool.filament.multi_color_direction === "longitudinal",
}
: spool.filament.color_hex;
const remaining = spool.remaining_weight;
const initial = spool.initial_weight ?? spool.filament.weight;
const percent = remaining != null && initial ? Math.round((remaining / initial) * 100) : undefined;
const vendorName = spool.filament.vendor?.name;
const filamentName = spool.filament.name ?? `#${spool.filament.id}`;
const label = vendorName ? `${vendorName} - ${filamentName}` : filamentName;
return (
<Card
key={spool.id}
size="small"
hoverable
onClick={() => navigate(`/spool/show/${spool.id}`)}
style={{
opacity: spool.archived ? 0.5 : 1,
textAlign: "center",
}}
styles={{ body: { padding: "12px 8px" } }}
>
<div style={{ marginBottom: 8 }}>
{colorObj ? <SpoolIcon color={colorObj} size="large" /> : <SpoolIcon color="#cccccc" size="large" />}
</div>
<Typography.Text
ellipsis={{ tooltip: label }}
style={{ fontSize: 12, display: "block", marginBottom: 4 }}
>
{label}
</Typography.Text>
{spool.filament.material && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{spool.filament.material}
</Typography.Text>
)}
{percent !== undefined && (
<Progress
percent={percent}
size="small"
showInfo={false}
strokeColor={percent < 10 ? "#ff4d4f" : percent < 25 ? "#faad14" : "#52c41a"}
style={{ marginTop: 6 }}
/>
)}
{remaining != null && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{Math.round(remaining)}g
</Typography.Text>
)}
</Card>
);
})}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
@@ -51,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
if (props.mode === "clone") {
// Clear out the values that we don't want to clone
@@ -85,14 +86,23 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
refetch: refetchFilaments,
} = useGetFilamentSelectOptions();
// Track pending filament selection after inline creation
const [pendingFilamentId, setPendingFilamentId] = useState<number | null>(null);
const handleFilamentCreated = async (filament: IFilament) => {
// Refetch filament list so the select options include the new filament
setPendingFilamentId(filament.id);
await refetchFilaments();
// Select the newly created filament
form.setFieldValue("filament_id", filament.id);
setIsFilamentModalOpen(false);
};
// Set the filament_id once the new filament appears in the options
useEffect(() => {
if (pendingFilamentId !== null && internalSelectOptions?.some((opt) => opt.value === pendingFilamentId)) {
form.setFieldValue("filament_id", pendingFilamentId);
setPendingFilamentId(null);
}
}, [pendingFilamentId, internalSelectOptions]);
const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = useMemo(() => {
// id is a number of it's an internal filament, and a string of it's an external filament.
@@ -208,10 +218,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -402,6 +417,30 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -1,7 +1,7 @@
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Edit, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import { message } from "antd/lib";
import dayjs from "dayjs";
@@ -67,6 +67,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
// Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => {
@@ -189,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -394,6 +400,30 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

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

@@ -1,16 +1,18 @@
import {
AppstoreOutlined,
EditOutlined,
EyeOutlined,
FilterOutlined,
InboxOutlined,
PlusSquareOutlined,
PrinterOutlined,
TableOutlined,
ToolOutlined,
ToTopOutlined,
} from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Modal, Table } from "antd";
import { Button, Dropdown, Input, Modal, Table } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react";
@@ -39,8 +41,10 @@ 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";
import { SpoolGallery } from "./components/SpoolGallery";
dayjs.extend(utc);
@@ -68,7 +72,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
"filament.combined_name": filament_name,
"filament.id": element.filament.id,
"filament.material": element.filament.material,
"filament.color_hex": element.filament.color_hex,
"filament.color_hex": element.color_hex ?? element.filament.color_hex,
};
}
@@ -88,6 +92,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"filament.color_hex",
"filament.material",
"price",
"remaining_value",
"used_weight",
"remaining_weight",
"used_length",
@@ -100,7 +105,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"comment",
];
const defaultColumns = allColumns.filter(
(column_id) => ["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex"].indexOf(column_id) === -1
(column_id) =>
["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex", "remaining_value"].indexOf(column_id) === -1
);
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
@@ -119,6 +125,13 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
// State for the switch to show archived spools
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
// State for search and view mode
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"table" | "gallery">("table");
// 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
@@ -128,6 +141,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
meta: {
queryParams: {
["allow_archived"]: showArchived,
...(searchQuery ? { q: searchQuery } : {}),
},
},
syncWithLocation: false,
@@ -211,6 +225,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;
}
@@ -295,6 +361,12 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
>
{t("buttons.clearFilters")}
</Button>
<Button
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
>
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
</Button>
<Dropdown
trigger={["click"]}
menu={{
@@ -332,6 +404,18 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
)}
>
{spoolAdjustModal}
<Input.Search
placeholder={t("spool.search_placeholder")}
allowClear
onSearch={(value) => {
setSearchQuery(value);
setCurrentPage(1);
}}
style={{ marginBottom: 16, maxWidth: 400 }}
/>
{viewMode === "gallery" ? (
<SpoolGallery dataSource={dataSource} loading={tableProps.loading as boolean} />
) : (
<Table
{...tableProps}
sticky
@@ -339,6 +423,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) {
@@ -364,12 +453,14 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.combined_name",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) =>
record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex,
record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex,
dataId: "filament.combined_name",
filterValueQuery: useSpoolmanFilamentFilter(),
}),
@@ -378,7 +469,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.color_hex",
i18nkey: "spool.fields.color",
filterValueQuery: useSpoolmanColors(),
colorGetter: (record: ISpoolCollapsed) => record.filament.color_hex,
colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex,
width: 60,
}),
FilteredQueryColumn({
@@ -401,6 +492,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
return currencyFormatter.format(obj.price);
},
}),
SortedColumn({
...commonProps,
id: "remaining_value",
i18ncat: "spool",
align: "right",
width: 100,
render: (_, obj: ISpoolCollapsed) => {
if (obj.remaining_value === undefined) {
return "";
}
return currencyFormatter.format(obj.remaining_value);
},
}),
NumberColumn({
...commonProps,
id: "used_weight",
@@ -409,6 +513,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "g",
maxDecimals: 0,
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -418,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -426,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "mm",
maxDecimals: 0,
width: 120,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -435,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 120,
sorter: true,
}),
FilteredQueryColumn({
...commonProps,
@@ -480,6 +588,15 @@ 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>
);
};

View File

@@ -15,9 +15,12 @@ export interface ISpool {
price?: number;
initial_weight?: number;
spool_weight?: number;
extra_weight?: number;
color_hex?: string;
remaining_weight?: number;
used_weight: number;
remaining_length?: number;
remaining_value?: number;
used_length: number;
location?: string;
lot_nr?: string;

View File

@@ -15,7 +15,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
import { getAPIURL, getBasePath } from "../../utils/url";
import { IFilament } from "../filaments/model";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool, ISpoolAdjustment } from "./model";
import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
dayjs.extend(utc);
@@ -25,6 +25,7 @@ const { confirm } = Modal;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
const filamentExtraFields = useGetFields(EntityType.filament);
const currencyFormatter = useCurrencyFormatter();
const invalidate = useInvalidate();
@@ -45,6 +46,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
enabled: !!record?.id,
});
// Fetch print job history
const { data: printJobsData, isLoading: printJobsLoading } = useQuery({
queryKey: ["spool-print-jobs", record?.id],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/print-job?spool_id=${record?.id}`);
return (await res.json()) as IPrintJob[];
},
enabled: !!record?.id,
});
const spoolPrice = (item?: ISpool) => {
const price = item?.price ?? item?.filament.price;
if (price === undefined) {
@@ -116,12 +127,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
});
};
const colorObj = record?.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record?.filament.color_hex;
const colorObj = record?.color_hex
? record.color_hex
: record?.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record?.filament.color_hex;
return (
<Show
@@ -235,6 +248,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))}
{filamentExtraFields?.data && filamentExtraFields.data.length > 0 && (
<>
<Title level={4}>{t("spool.fields.filament_fields")}</Title>
{filamentExtraFields.data.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.filament.extra[field.key]} />
))}
</>
)}
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.adjustment_history")}
</Title>
@@ -288,6 +309,69 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
},
]}
/>
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.print_history")}
</Title>
<Collapse
items={[
{
key: "1",
label: t("spool.fields.show_print_history"),
children: (
<Table<IPrintJob>
dataSource={printJobsData}
loading={printJobsLoading}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{
title: t("print_job.fields.created"),
dataIndex: "created",
key: "created",
render: (value: string) => (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
),
},
{
title: t("print_job.fields.filename"),
dataIndex: "filename",
key: "filename",
ellipsis: true,
},
{
title: t("print_job.fields.filament_used"),
dataIndex: "filament_used_g",
key: "filament_used_g",
render: (value: number) => `${value.toFixed(1)} g`,
},
{
title: t("print_job.fields.status"),
dataIndex: "status",
key: "status",
render: (value: string) => t(`print_job.status.${value}`),
},
{
title: t("print_job.fields.finished"),
dataIndex: "finished",
key: "finished",
render: (value: string | null) =>
value ? (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
) : (
"-"
),
},
]}
/>
),
},
]}
/>
</Show>
);
};

View File

@@ -0,0 +1,52 @@
"""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 (SQLite compatible - no FK constraint)
op.add_column(
"spool",
sa.Column("location_id", sa.Integer(), nullable=True),
)
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_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")

View File

@@ -0,0 +1,31 @@
"""Add extra_weight field to spool for DryPods, custom holders, etc.
Revision ID: f6g7h8i9j0k1
Revises: e5f6g7h8i9j0
Create Date: 2025-01-21 22:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6g7h8i9j0k1"
down_revision = "e5f6g7h8i9j0"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"spool",
sa.Column(
"extra_weight",
sa.Float(),
nullable=True,
comment="Extra weight to account for (DryPods, custom holders, etc.).",
),
)
def downgrade() -> None:
op.drop_column("spool", "extra_weight")

View File

@@ -0,0 +1,25 @@
"""Add spool color_hex override.
Revision ID: g7h8i9j0k1l2
Revises: f6g7h8i9j0k1
Create Date: 2025-01-22 01:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "g7h8i9j0k1l2"
down_revision = "f6g7h8i9j0k1"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add color_hex column to spool table."""
op.add_column("spool", sa.Column("color_hex", sa.String(length=8), nullable=True))
def downgrade() -> None:
"""Remove color_hex column from spool table."""
op.drop_column("spool", "color_hex")

164
spoolman/api/v1/location.py Normal file
View 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!")

View File

@@ -311,6 +311,18 @@ class Spool(BaseModel):
description=("Weight of an empty spool (tare weight)."),
examples=[246],
)
extra_weight: Optional[float] = Field(
default=None,
ge=0,
description=("Extra weight to account for, such as DryPods, custom spool holders, etc."),
examples=[50],
)
color_hex: Optional[str] = Field(
default=None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
used_weight: float = Field(
ge=0,
description="Consumed weight of filament from the spool in grams.",
@@ -325,6 +337,12 @@ class Spool(BaseModel):
),
examples=[5612.4],
)
remaining_value: Optional[float] = Field(
default=None,
ge=0,
description="Estimated remaining value of filament on the spool based on remaining weight and price.",
examples=[12.50],
)
used_length: float = Field(
ge=0,
description="Consumed length of filament from the spool in millimeters.",
@@ -369,14 +387,14 @@ class Spool(BaseModel):
remaining_length: Optional[float] = None
if item.initial_weight is not None:
remaining_weight = max(item.initial_weight - item.used_weight, 0)
remaining_weight = item.initial_weight - item.used_weight
remaining_length = length_from_weight(
weight=remaining_weight,
density=filament.density,
diameter=filament.diameter,
)
elif filament.weight is not None:
remaining_weight = max(filament.weight - item.used_weight, 0)
remaining_weight = filament.weight - item.used_weight
remaining_length = length_from_weight(
weight=remaining_weight,
density=filament.density,
@@ -389,6 +407,13 @@ class Spool(BaseModel):
diameter=filament.diameter,
)
# Calculate remaining value based on remaining weight and price
remaining_value: Optional[float] = None
price = item.price if item.price is not None else filament.price
initial_weight = item.initial_weight if item.initial_weight is not None else filament.weight
if remaining_weight is not None and price is not None and initial_weight is not None and initial_weight > 0:
remaining_value = round((remaining_weight / initial_weight) * price, 2)
return Spool(
id=item.id,
registered=item.registered,
@@ -398,10 +423,12 @@ class Spool(BaseModel):
price=item.price,
initial_weight=item.initial_weight,
spool_weight=item.spool_weight,
extra_weight=item.extra_weight,
used_weight=item.used_weight,
used_length=used_length,
remaining_weight=remaining_weight,
remaining_length=remaining_length,
remaining_value=remaining_value,
location=item.location,
lot_nr=item.lot_nr,
comment=item.comment,

View File

@@ -1,13 +1,17 @@
"""Filament related endpoints."""
import logging
from typing import Annotated
from datetime import datetime, timedelta
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
import httpx
import sqlalchemy
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field, RootModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool
from spoolman.database import filament, models, spool
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
@@ -173,3 +177,299 @@ async def rename_location(
logger.info("Renaming location %s to %s", location, body.name)
await spool.rename_location(db=db, current_name=location, new_name=body.name)
return body.name
class UsageDataPoint(BaseModel):
"""A single data point for usage analytics."""
date: str = Field(description="Date in YYYY-MM-DD format.")
weight_used: float = Field(description="Total weight used in grams (positive value).")
@router.get(
"/analytics/usage",
name="Get usage analytics",
description="Get filament usage data aggregated by day for the specified period.",
response_model=list[UsageDataPoint],
)
async def get_usage_analytics(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
days: Annotated[
int,
Query(description="Number of days to look back.", ge=1, le=365),
] = 30,
) -> list[UsageDataPoint]:
"""Get usage analytics aggregated by day."""
# Calculate the start date
start_date = datetime.utcnow() - timedelta(days=days)
# Query adjustments grouped by date
# Only include negative values (filament used, not added)
stmt = (
sqlalchemy.select(
func.date(models.SpoolAdjustment.timestamp).label("date"),
func.sum(
sqlalchemy.case(
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
else_=0,
)
).label("weight_used"),
)
.where(models.SpoolAdjustment.timestamp >= start_date)
.where(models.SpoolAdjustment.adjustment_type == "weight")
.group_by(func.date(models.SpoolAdjustment.timestamp))
.order_by(func.date(models.SpoolAdjustment.timestamp))
)
result = await db.execute(stmt)
rows = result.all()
# Fill in missing dates with 0
data_by_date = {str(row.date): row.weight_used for row in rows}
all_dates = []
current_date = start_date.date()
end_date = datetime.utcnow().date()
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
all_dates.append(
UsageDataPoint(
date=date_str,
weight_used=data_by_date.get(date_str, 0),
)
)
current_date += timedelta(days=1)
return all_dates
class MaterialUsage(BaseModel):
"""Usage breakdown by material."""
material: str = Field(description="Material name.")
weight_used: float = Field(description="Total weight used in grams.")
@router.get(
"/analytics/by-material",
name="Get usage by material",
description="Get filament usage breakdown by material type.",
response_model=list[MaterialUsage],
)
async def get_usage_by_material(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
days: Annotated[
Optional[int],
Query(description="Number of days to look back. If not set, returns all-time usage.", ge=1, le=365),
] = None,
) -> list[MaterialUsage]:
"""Get usage breakdown by material."""
# Join adjustments with spools and filaments to get material
stmt = (
sqlalchemy.select(
func.coalesce(models.Filament.material, "Unknown").label("material"),
func.sum(
sqlalchemy.case(
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
else_=0,
)
).label("weight_used"),
)
.join(models.Spool, models.SpoolAdjustment.spool_id == models.Spool.id)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(models.SpoolAdjustment.adjustment_type == "weight")
)
if days is not None:
start_date = datetime.utcnow() - timedelta(days=days)
stmt = stmt.where(models.SpoolAdjustment.timestamp >= start_date)
stmt = stmt.group_by(models.Filament.material).order_by(func.sum(-models.SpoolAdjustment.value).desc())
result = await db.execute(stmt)
rows = result.all()
return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows]
class CostByMaterial(BaseModel):
"""Cost breakdown by material."""
material: str = Field(description="Material name.")
total_cost: float = Field(description="Total cost spent on this material.")
total_weight: float = Field(description="Total initial weight purchased in grams.")
class CostStats(BaseModel):
"""Overall cost statistics."""
total_spent: float = Field(description="Sum of all spool purchase prices.")
total_weight_purchased: float = Field(description="Total initial weight of all spools in grams.")
avg_cost_per_kg: Optional[float] = Field(description="Average cost per kilogram of filament.")
cost_by_material: list[CostByMaterial] = Field(description="Cost breakdown by material type.")
@router.get(
"/analytics/cost",
name="Get cost statistics",
description="Get overall cost tracking statistics across all spools.",
response_model=CostStats,
)
async def get_cost_stats(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> CostStats:
"""Get cost statistics including total spent and cost per kg."""
# Use COALESCE(spool.price, filament.price) for effective price
effective_price = func.coalesce(models.Spool.price, models.Filament.price)
effective_weight = func.coalesce(models.Spool.initial_weight, models.Filament.weight)
# Total spent and total weight across all spools (including archived)
totals_stmt = (
sqlalchemy.select(
func.sum(effective_price).label("total_spent"),
func.sum(effective_weight).label("total_weight"),
)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(effective_price.isnot(None))
)
totals_result = await db.execute(totals_stmt)
totals_row = totals_result.one()
total_spent = totals_row.total_spent or 0.0
total_weight_purchased = totals_row.total_weight or 0.0
avg_cost_per_kg = (total_spent / total_weight_purchased * 1000) if total_weight_purchased > 0 else None
# Cost by material
material_stmt = (
sqlalchemy.select(
func.coalesce(models.Filament.material, "Unknown").label("material"),
func.sum(effective_price).label("total_cost"),
func.sum(effective_weight).label("total_weight"),
)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(effective_price.isnot(None))
.group_by(models.Filament.material)
.order_by(func.sum(effective_price).desc())
)
material_result = await db.execute(material_stmt)
material_rows = material_result.all()
cost_by_material = [
CostByMaterial(
material=row.material or "Unknown",
total_cost=row.total_cost or 0.0,
total_weight=row.total_weight or 0.0,
)
for row in material_rows
]
return CostStats(
total_spent=total_spent,
total_weight_purchased=total_weight_purchased,
avg_cost_per_kg=avg_cost_per_kg,
cost_by_material=cost_by_material,
)
# --- filamentcolors.xyz integration ---
FILAMENTCOLORS_API = "https://filamentcolors.xyz/api"
class FilamentColorSwatch(BaseModel):
"""A color swatch from filamentcolors.xyz."""
id: int
color_name: str
hex_color: Optional[str] = None
manufacturer_name: str
filament_type: str
image_front: Optional[str] = None
class FilamentColorManufacturer(BaseModel):
"""A manufacturer from filamentcolors.xyz."""
id: int
name: str
class FilamentColorSearchResult(BaseModel):
"""Search results from filamentcolors.xyz."""
count: int
swatches: list[FilamentColorSwatch]
@router.get(
"/filamentcolors/manufacturers",
name="Get filamentcolors.xyz manufacturers",
description="List all manufacturers from filamentcolors.xyz.",
response_model=list[FilamentColorManufacturer],
)
async def get_filamentcolors_manufacturers() -> list[FilamentColorManufacturer]:
"""Proxy manufacturer list from filamentcolors.xyz."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{FILAMENTCOLORS_API}/manufacturer/", params={"format": "json", "limit": 500})
resp.raise_for_status()
data = resp.json()
return [
FilamentColorManufacturer(id=m["id"], name=m["name"])
for m in data.get("results", [])
]
@router.get(
"/filamentcolors/search",
name="Search filamentcolors.xyz swatches",
description="Search color swatches from filamentcolors.xyz by manufacturer and/or color name.",
response_model=FilamentColorSearchResult,
)
async def search_filamentcolors(
*,
manufacturer: Annotated[
Optional[str],
Query(description="Manufacturer name to filter by."),
] = None,
color: Annotated[
Optional[str],
Query(description="Color name to search for."),
] = None,
filament_type: Annotated[
Optional[str],
Query(description="Filament type to filter by (e.g., PLA, PETG)."),
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
page: Annotated[int, Query(ge=1)] = 1,
) -> FilamentColorSearchResult:
"""Proxy swatch search from filamentcolors.xyz."""
params: dict = {"format": "json", "limit": limit, "page": page}
if manufacturer:
params["manufacturer__name"] = manufacturer
if color:
params["color_name__icontains"] = color
if filament_type:
params["filament_type__name__icontains"] = filament_type
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{FILAMENTCOLORS_API}/swatch/", params=params)
resp.raise_for_status()
data = resp.json()
swatches = [
FilamentColorSwatch(
id=s["id"],
color_name=s.get("color_name", ""),
hex_color=s.get("hex_color"),
manufacturer_name=s.get("manufacturer", {}).get("name", ""),
filament_type=s.get("filament_type", {}).get("name", ""),
image_front=s.get("image_front"),
)
for s in data.get("results", [])
]
return FilamentColorSearchResult(count=data.get("count", 0), swatches=swatches)

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
from . import auth, backup as backup_module, export, externaldb, field, filament, models, other, print_job, setting, spool, vendor
from . import auth, backup as backup_module, export, externaldb, field, filament, location as location_module, models, other, print_job, setting, spool, vendor
logger = logging.getLogger(__name__)
@@ -116,3 +116,4 @@ app.include_router(export.router)
app.include_router(backup_module.router)
app.include_router(print_job.router)
app.include_router(auth.router)
app.include_router(location_module.router)

View File

@@ -51,6 +51,18 @@ class SpoolParameters(BaseModel):
description="The weight of an empty spool, in grams. (tare weight)",
examples=[200],
)
extra_weight: Optional[float] = Field(
None,
ge=0,
description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.",
examples=[50],
)
color_hex: Optional[str] = Field(
None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
remaining_weight: Optional[float] = Field(
None,
ge=0,
@@ -293,6 +305,15 @@ async def find(
description="Filter by spools with remaining weight (in grams) greater than this value.",
),
] = None,
q: Annotated[
Optional[str],
Query(
title="Search",
description=(
"Global search term. Searches across filament name, vendor name, material, and location."
),
),
] = None,
sort: Annotated[
Optional[str],
Query(
@@ -329,6 +350,7 @@ async def find(
db_items, total_count = await spool.find(
db=db,
q=q,
filament_name=filament_name if filament_name is not None else filament_name_old,
filament_id=filament_ids,
filament_material=filament_material if filament_material is not None else filament_material_old,
@@ -449,6 +471,8 @@ async def create( # noqa: ANN201
price=body.price,
initial_weight=body.initial_weight,
spool_weight=body.spool_weight,
extra_weight=body.extra_weight,
color_hex=body.color_hex,
remaining_weight=body.remaining_weight,
used_weight=body.used_weight,
first_used=body.first_used,
@@ -534,6 +558,69 @@ async def delete(
return Message(message="Success!")
# Bulk operations
class BulkArchiveParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to archive/unarchive.")
archived: bool = Field(description="Whether to archive (true) or unarchive (false).")
class BulkDeleteParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to delete.")
class BulkUpdateParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to update.")
location: Optional[str] = Field(None, max_length=64, description="New location for all spools.")
@router.post(
"/bulk/archive",
name="Bulk archive spools",
description="Archive or unarchive multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_archive(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkArchiveParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data={"archived": body.archived})
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.post(
"/bulk/delete",
name="Bulk delete spools",
description="Delete multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkDeleteParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.delete(db=db, spool_id=spool_id)
return Message(message=f"Deleted {len(body.spool_ids)} spools.")
@router.post(
"/bulk/update",
name="Bulk update spools",
description="Update the same fields on multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_update(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkUpdateParameters,
) -> Message:
data = body.model_dump(exclude={"spool_ids"}, exclude_unset=True)
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data=data)
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.put(
"/{spool_id}/use",
name="Use spool filament",

View 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

View File

@@ -12,6 +12,30 @@ class Base(AsyncAttrs, DeclarativeBase):
pass
class Location(Base):
"""Hierarchical storage location (room, shelf, bin, etc.)."""
__tablename__ = "location"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(64))
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("location.id", ondelete="SET NULL"), index=True)
location_type: Mapped[Optional[str]] = mapped_column(String(32), comment="Type: room, shelf, bin, etc.")
description: Mapped[Optional[str]] = mapped_column(String(256))
# Self-referential relationships
parent: Mapped[Optional["Location"]] = relationship(
"Location",
remote_side="Location.id",
back_populates="children",
)
children: Mapped[list["Location"]] = relationship(
"Location",
back_populates="parent",
)
spools: Mapped[list["Spool"]] = relationship(back_populates="location_obj")
class Vendor(Base):
__tablename__ = "vendor"
@@ -76,7 +100,22 @@ class Spool(Base):
initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column()
location: Mapped[Optional[str]] = mapped_column(String(64))
extra_weight: Mapped[Optional[float]] = mapped_column(
default=None,
comment="Extra weight to account for (DryPods, custom holders, etc.).",
)
color_hex: Mapped[Optional[str]] = mapped_column(
String(8),
default=None,
comment="Spool-level color override (hex without #).",
)
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"),
index=True,
comment="FK to hierarchical location.",
)
location_obj: Mapped[Optional["Location"]] = relationship(back_populates="spools")
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column()

View File

@@ -41,6 +41,8 @@ async def create(
remaining_weight: Optional[float] = None,
initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None,
extra_weight: Optional[float] = None,
color_hex: Optional[str] = None,
used_weight: Optional[float] = None,
first_used: Optional[datetime] = None,
last_used: Optional[datetime] = None,
@@ -69,7 +71,10 @@ async def create(
"remaining_weight can only be used if the initial_weight is "
"defined or the filament has a weight set.",
)
used_weight = max(initial_weight - remaining_weight, 0)
if remaining_weight > initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
initial_weight = remaining_weight
used_weight = initial_weight - remaining_weight
else:
used_weight = 0
@@ -84,6 +89,8 @@ async def create(
registered=datetime.utcnow().replace(microsecond=0),
initial_weight=initial_weight,
spool_weight=spool_weight,
extra_weight=extra_weight,
color_hex=color_hex,
used_weight=used_weight,
price=price,
first_used=first_used,
@@ -115,6 +122,7 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool:
async def find( # noqa: C901, PLR0912
*,
db: AsyncSession,
q: Optional[str] = None,
filament_name: Optional[str] = None,
filament_id: Optional[Union[int, Sequence[int]]] = None,
filament_material: Optional[str] = None,
@@ -153,6 +161,20 @@ async def find( # noqa: C901, PLR0912
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
# Global search: OR across multiple text fields
if q is not None and q.strip():
search_term = f"%{q.strip()}%"
stmt = stmt.where(
sqlalchemy.or_(
models.Filament.name.ilike(search_term),
models.Filament.material.ilike(search_term),
models.Vendor.name.ilike(search_term),
models.Spool.location.ilike(search_term),
models.Spool.lot_nr.ilike(search_term),
models.Spool.comment.ilike(search_term),
)
)
# Filter by color_hex (exact match, supports multiple comma-separated values)
if filament_color_hex is not None:
if isinstance(filament_color_hex, str):
@@ -221,6 +243,18 @@ async def find( # noqa: C901, PLR0912
sorts.append(models.Filament.name)
elif fieldstr == "price":
sorts.append(coalesce(models.Spool.price, models.Filament.price))
elif fieldstr.startswith("extra."):
# Sort by custom field value
extra_key = fieldstr[len("extra."):]
extra_alias = sqlalchemy.orm.aliased(models.SpoolField)
stmt = stmt.outerjoin(
extra_alias,
sqlalchemy.and_(
extra_alias.spool_id == models.Spool.id,
extra_alias.key == extra_key,
),
)
sorts.append(extra_alias.value)
else:
sorts.append(parse_nested_field(models.Spool, fieldstr))
@@ -258,7 +292,10 @@ async def update(
elif k == "remaining_weight":
if spool.initial_weight is None:
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.")
spool.used_weight = max(spool.initial_weight - v, 0)
if v > spool.initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
spool.initial_weight = v
spool.used_weight = spool.initial_weight - v
elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v))
elif k == "extra":
@@ -440,7 +477,12 @@ async def measure(
"""
spool_result = await db.execute(
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
sqlalchemy.select(
models.Spool.initial_weight,
models.Spool.used_weight,
models.Spool.spool_weight,
models.Spool.extra_weight,
).where(
models.Spool.id == spool_id,
),
)
@@ -452,6 +494,7 @@ async def measure(
initial_weight = spool_info[0]
spool_weight = spool_info[2]
extra_weight = spool_info[3] or 0 # Default to 0 if not set
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# Get filament weight and spool_weight
result = await db.execute(
@@ -473,7 +516,7 @@ async def measure(
if initial_weight is None or initial_weight == 0:
raise SpoolMeasureError("Initial weight is not set.")
initial_gross_weight = initial_weight + spool_weight
initial_gross_weight = initial_weight + spool_weight + extra_weight
# if the measurement is greater than the initial weight, set the initial weight to the measurement
if weight > initial_gross_weight:
@@ -485,9 +528,10 @@ async def measure(
# Calculate the weight used since last measure
weight_to_use = current_use - weight
# If the measured weight is less than the empty weight, use the rest of the spool
if (initial_gross_weight - weight_to_use) < spool_weight:
weight_to_use = current_use - spool_weight
# If the measured weight is less than the empty weight (+ extra), use the rest of the spool
empty_gross_weight = spool_weight + extra_weight
if (initial_gross_weight - weight_to_use) < empty_gross_weight:
weight_to_use = current_use - empty_gross_weight
return await use_weight(db, spool_id, weight_to_use, comment=comment)