Compare commits

...

12 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 44s
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
25 changed files with 1677 additions and 164 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 | | 6 | Spool Adjustment History | ✅ DONE |
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED | | 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
| 8 | (duplicate of #7) | ✅ DONE | | 8 | (duplicate of #7) | ✅ DONE |
| 9 | Hierarchical Locations (Room/Bin) | **Open** | | 9 | Hierarchical Locations (Room/Bin) | ✅ DONE |
| 10 | Database Import/Export | ✅ DONE | | 10 | Database Import/Export | ✅ DONE |
| 11 | Slicer Integration / Print Jobs | ✅ DONE | | 11 | Slicer Integration / Print Jobs | ✅ DONE |
| 12 | Redesign Home Page Dashboard | **Open** | | 12 | Redesign Home Page Dashboard | ✅ DONE |
| 13 | Batch Operations (Multi-Select Edit) | **Open** | | 13 | Batch Operations (Multi-Select Edit) | ✅ DONE |
| 14 | Extra Weight Field (DryPods, etc.) | **Open** | | 14 | Extra Weight Field (DryPods, etc.) | ✅ DONE |
| 15 | Spool Count per Filament in List | **Open** | | 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) ### Other Features (not tracked in Gitea)
- ✅ Theme Colors (5 options) - ✅ 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 - ✅ QR Code Size Setting
- ✅ Parse Temps from Comments - ✅ Parse Temps from Comments
- ✅ Prev/Next Navigation on Edit Pages - ✅ 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) - ✅ Color Column in Spool List (with filter)
### Issue #1 Complete - Inline Creation Modals ### 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) - Multi-select filtering (can filter by multiple colors)
- Column hidden by default (enable via column selector) - 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 ## 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 ## 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 ### 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. 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.

View File

@@ -34,6 +34,8 @@
"notAccessTitle": "You don't have permission to access", "notAccessTitle": "You don't have permission to access",
"hideColumns": "Hide Columns", "hideColumns": "Hide Columns",
"clearFilters": "Clear Filters", "clearFilters": "Clear Filters",
"galleryView": "Gallery",
"tableView": "Table",
"viewAll": "View All" "viewAll": "View All"
}, },
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.", "warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
@@ -179,6 +181,7 @@
}, },
"spool": { "spool": {
"spool": "Spools", "spool": "Spools",
"search_placeholder": "Search by name, material, vendor, location...",
"fields": { "fields": {
"id": "ID", "id": "ID",
"filament_name": "Filament", "filament_name": "Filament",
@@ -191,11 +194,14 @@
"weight_to_use": "Weight", "weight_to_use": "Weight",
"used_weight": "Used Weight", "used_weight": "Used Weight",
"remaining_weight": "Remaining Weight", "remaining_weight": "Remaining Weight",
"remaining_value": "Remaining Value",
"measured_weight": "Measured Weight", "measured_weight": "Measured Weight",
"used_length": "Used Length", "used_length": "Used Length",
"remaining_length": "Remaining Length", "remaining_length": "Remaining Length",
"initial_weight": "Initial Weight", "initial_weight": "Initial Weight",
"spool_weight": "Empty Weight", "spool_weight": "Empty Weight",
"extra_weight": "Extra Weight",
"color_hex": "Color Override",
"location": "Location", "location": "Location",
"lot_nr": "Lot Nr", "lot_nr": "Lot Nr",
"first_used": "First Used", "first_used": "First Used",
@@ -203,8 +209,11 @@
"registered": "Registered", "registered": "Registered",
"comment": "Comment", "comment": "Comment",
"archived": "Archived", "archived": "Archived",
"filament_fields": "Filament Custom Fields",
"adjustment_history": "Adjustment History", "adjustment_history": "Adjustment History",
"show_history": "Show History", "show_history": "Show History",
"print_history": "Print History",
"show_print_history": "Show Print History",
"adjustment_timestamp": "Date/Time", "adjustment_timestamp": "Date/Time",
"adjustment_type": "Type", "adjustment_type": "Type",
"adjustment_type_weight": "Weight", "adjustment_type_weight": "Weight",
@@ -220,6 +229,8 @@
"measured_weight": "How much the filament and spool weigh.", "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.", "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.", "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.", "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.", "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." "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."
@@ -344,10 +355,21 @@
"created_success": "Created filament \"{{name}}\"", "created_success": "Created filament \"{{name}}\"",
"vendor_placeholder": "Select a manufacturer", "vendor_placeholder": "Select a manufacturer",
"name_placeholder": "E.g. Matte Black", "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": { "buttons": {
"add_spool": "Add Spool" "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": { "vendor": {
@@ -377,6 +399,20 @@
"name_required": "Name is required" "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": "Home", "home": "Home",
"welcome": "Welcome to your Spoolman instance!", "welcome": "Welcome to your Spoolman instance!",
@@ -396,7 +432,20 @@
"total_spools": "Total Spools", "total_spools": "Total Spools",
"total_filaments": "Filament Types", "total_filaments": "Filament Types",
"total_vendors": "Vendors", "total_vendors": "Vendors",
"total_weight": "Total Remaining" "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": {

View File

@@ -440,8 +440,9 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
const commonProps = { const commonProps = {
...props, ...props,
id: ["extra", field.key], id: ["extra", field.key],
dataId: `extra.${field.key}` as keyof Obj & string,
title: field.name, title: field.name,
sorter: false, sorter: true,
transform: (value: unknown) => { transform: (value: unknown) => {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return 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 { FilamentImportModal } from "../../components/filamentImportModal";
import { VendorCreateModal } from "../../components/vendorCreateModal"; import { VendorCreateModal } from "../../components/vendorCreateModal";
import { MultiColorPicker } from "../../components/multiColorPicker"; import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import { import {
DEFAULT_DENSITY, DEFAULT_DENSITY,
DEFAULT_DIAMETER, DEFAULT_DIAMETER,
@@ -248,6 +249,7 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
{colorType == "single" && ( {colorType == "single" && (
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item <Form.Item
name={"color_hex"} name={"color_hex"}
rules={[ rules={[
@@ -261,6 +263,8 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
> >
<ColorPicker format="hex" /> <ColorPicker format="hex" />
</Form.Item> </Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)} )}
{colorType == "multi" && ( {colorType == "multi" && (
<Form.Item <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 { Edit, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core"; import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd"; 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 { useNavigate } from "react-router";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { MultiColorPicker } from "../../components/multiColorPicker"; import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing"; import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields"; import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings"; import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { IVendor } from "../vendors/model"; import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./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 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. 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" }], 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", liveMode: "manual",
onLiveEvent() { onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened // Warn the user if the filament has been updated since the form was opened
@@ -200,6 +249,7 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
{colorType == "single" && ( {colorType == "single" && (
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item <Form.Item
name={"color_hex"} name={"color_hex"}
rules={[ rules={[
@@ -213,6 +263,8 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
> >
<ColorPicker /> <ColorPicker />
</Form.Item> </Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)} )}
{colorType == "multi" && ( {colorType == "multi" && (
<Form.Item <Form.Item
@@ -330,6 +382,29 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
> >
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </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")}> <Form.Item label={t("filament.fields.settings_extruder_temp")}>
<Space.Compact> <Space.Compact>
<Form.Item <Form.Item

View File

@@ -39,7 +39,11 @@ function AlertCard({ icon, label, count, to, color, loading }: AlertCardProps) {
); );
} }
export function AlertCards() { interface AlertCardsProps {
compact?: boolean;
}
export function AlertCards({ compact }: AlertCardsProps) {
const t = useTranslate(); const t = useTranslate();
const { token } = useToken(); const { token } = useToken();
@@ -106,7 +110,7 @@ export function AlertCards() {
if (visibleAlerts.length === 0) return null; if (visibleAlerts.length === 0) return null;
return ( return (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}> <Row gutter={[compact ? 8 : 12, compact ? 8 : 12]} style={{ marginTop: compact ? 8 : 16 }}>
{visibleAlerts.map((alert, i) => ( {visibleAlerts.map((alert, i) => (
<AlertCard key={i} {...alert} /> <AlertCard key={i} {...alert} />
))} ))}

View File

@@ -20,7 +20,11 @@ const COLORS = [
"#2f54eb", // geekblue "#2f54eb", // geekblue
]; ];
export function MaterialDistributionChart() { interface MaterialDistributionChartProps {
square?: boolean;
}
export function MaterialDistributionChart({ square }: MaterialDistributionChartProps) {
const t = useTranslate(); const t = useTranslate();
const { token } = useToken(); const { token } = useToken();
@@ -59,20 +63,21 @@ export function MaterialDistributionChart() {
<Card <Card
title={t("home.charts.material_distribution")} title={t("home.charts.material_distribution")}
size="small" size="small"
style={{ marginBottom: 16 }} 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={250}> <ResponsiveContainer width="100%" height={square ? "100%" : 250}>
<PieChart> <PieChart>
<Pie <Pie
data={chartData} data={chartData}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={50} innerRadius={square ? 40 : 50}
outerRadius={80} outerRadius={square ? 70 : 80}
paddingAngle={2} paddingAngle={2}
dataKey="value" dataKey="value"
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`} label={square ? undefined : ({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
labelLine={{ stroke: token.colorTextSecondary }} labelLine={square ? false : { stroke: token.colorTextSecondary }}
> >
{chartData.map((_, index) => ( {chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
@@ -86,6 +91,13 @@ export function MaterialDistributionChart() {
borderRadius: token.borderRadius, borderRadius: token.borderRadius,
}} }}
/> />
{square && (
<Legend
layout="horizontal"
verticalAlign="bottom"
wrapperStyle={{ fontSize: 11 }}
/>
)}
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</Card> </Card>

View File

@@ -1,17 +1,25 @@
import { HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined } from "@ant-design/icons"; import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined, RiseOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core"; import { useList, useTranslate } from "@refinedev/core";
import { useQuery } from "@tanstack/react-query";
import { Card, Col, Row, Statistic, theme } from "antd"; import { Card, Col, Row, Statistic, theme } from "antd";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { IFilament } from "../../filaments/model"; import { IFilament } from "../../filaments/model";
import { ISpool } from "../../spools/model"; import { ISpool } from "../../spools/model";
import { IVendor } from "../../vendors/model"; import { IVendor } from "../../vendors/model";
import { useCurrencyFormatter } from "../../../utils/settings";
import { getAPIURL } from "../../../utils/url";
const { useToken } = theme; const { useToken } = theme;
export function QuickStats() { interface QuickStatsProps {
compact?: boolean;
}
export function QuickStats({ compact }: QuickStatsProps) {
const t = useTranslate(); const t = useTranslate();
const { token } = useToken(); const { token } = useToken();
const currencyFormatter = useCurrencyFormatter();
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({ const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
resource: "filament", resource: "filament",
@@ -40,6 +48,25 @@ export function QuickStats() {
return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0); return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0);
}, [spoolsResult?.data]); }, [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) => { const formatWeight = (grams: number) => {
if (grams >= 1000) { if (grams >= 1000) {
return `${(grams / 1000).toFixed(1)} kg`; return `${(grams / 1000).toFixed(1)} kg`;
@@ -47,9 +74,70 @@ export function QuickStats() {
return `${Math.round(grams)} g`; 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 ( return (
<Row gutter={[12, 12]} style={{ marginTop: 16 }}> <Row gutter={[12, 12]} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} md={8} lg={4}>
<Link to="/spool"> <Link to="/spool">
<Card hoverable size="small"> <Card hoverable size="small">
<Statistic <Statistic
@@ -61,40 +149,21 @@ export function QuickStats() {
</Card> </Card>
</Link> </Link>
</Col> </Col>
<Col xs={12} sm={6}> {stats.map((stat, i) => (
<Link to="/filament"> <Col xs={12} md={8} lg={4} key={i}>
{stat.to ? (
<Link to={stat.to}>
<Card hoverable size="small"> <Card hoverable size="small">
<Statistic <Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
title={t("home.quick_stats.total_filaments")}
value={filamentsResult?.total ?? 0}
loading={filamentsQuery.isLoading}
prefix={<HighlightOutlined />}
/>
</Card> </Card>
</Link> </Link>
</Col> ) : (
<Col xs={12} sm={6}>
<Link to="/vendor">
<Card hoverable size="small">
<Statistic
title={t("home.quick_stats.total_vendors")}
value={vendorsResult?.total ?? 0}
loading={vendorsQuery.isLoading}
prefix={<UserOutlined />}
/>
</Card>
</Link>
</Col>
<Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
title={t("home.quick_stats.total_weight")}
value={formatWeight(totalWeight)}
loading={spoolsQuery.isLoading}
prefix={<DatabaseOutlined />}
/>
</Card> </Card>
)}
</Col> </Col>
))}
</Row> </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

@@ -14,6 +14,7 @@ import { ISpool } from "../spools/model";
import { AlertCards } from "./components/AlertCards"; import { AlertCards } from "./components/AlertCards";
import { MaterialDistributionChart } from "./components/MaterialDistributionChart"; import { MaterialDistributionChart } from "./components/MaterialDistributionChart";
import { QuickStats } from "./components/QuickStats"; import { QuickStats } from "./components/QuickStats";
import { UsageAnalytics } from "./components/UsageAnalytics";
dayjs.extend(utc); dayjs.extend(utc);
@@ -48,7 +49,9 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
key: "color", key: "color",
width: 50, width: 50,
render: (_: unknown, record: ISpool) => { render: (_: unknown, record: ISpool) => {
const colorObj = record.filament.multi_color_hexes const colorObj = record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? { ? {
colors: record.filament.multi_color_hexes.split(","), colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal", vertical: record.filament.multi_color_direction === "longitudinal",
@@ -109,8 +112,13 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
fontFamily: token.fontFamily, fontFamily: token.fontFamily,
}} }}
> >
{/* Header with spool count and add button */} {/* Top section: Pie chart + stats/alerts side by side */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={24} md={10}>
<MaterialDistributionChart square />
</Col>
<Col xs={24} md={14}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col> <Col>
<Statistic <Statistic
title={t("spool.spool")} title={t("spool.spool")}
@@ -125,18 +133,17 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
</Button> </Button>
</Col> </Col>
</Row> </Row>
<QuickStats compact />
{/* Alert badges */} <AlertCards compact />
<AlertCards /> </Col>
</Row>
{/* Material distribution chart */}
<MaterialDistributionChart />
{/* Spool list */} {/* Spool list */}
<Card <Card
title={t("home.all_spools")} title={t("home.all_spools")}
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>} extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
style={{ marginBottom: 16 }}
> >
<Table<ISpool> <Table<ISpool>
dataSource={spoolsResult?.data as ISpool[]} dataSource={spoolsResult?.data as ISpool[]}
@@ -162,8 +169,11 @@ export const Home: React.FC<IResourceComponentsProps> = () => {
</div> </div>
</Card> </Card>
{/* Quick stats */} {/* Usage analytics & insights */}
<QuickStats /> <UsageAnalytics />
{/* Alerts */}
<AlertCards />
</Content> </Content>
); );
}; };

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 { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd"; import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core"; 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 TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; 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 initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form); const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
if (props.mode === "clone") { if (props.mode === "clone") {
// Clear out the values that we don't want to 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, refetch: refetchFilaments,
} = useGetFilamentSelectOptions(); } = useGetFilamentSelectOptions();
// Track pending filament selection after inline creation
const [pendingFilamentId, setPendingFilamentId] = useState<number | null>(null);
const handleFilamentCreated = async (filament: IFilament) => { const handleFilamentCreated = async (filament: IFilament) => {
// Refetch filament list so the select options include the new filament setPendingFilamentId(filament.id);
await refetchFilaments(); await refetchFilaments();
// Select the newly created filament
form.setFieldValue("filament_id", filament.id);
setIsFilamentModalOpen(false); 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 selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = useMemo(() => { const selectedFilament = useMemo(() => {
// id is a number of it's an internal filament, and a string of it's an external filament. // 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; return initialWeightValue ?? selectedFilament?.weight ?? 0;
}; };
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => { const getGrossWeight = (): number => {
const net_weight = getFilamentWeight(); const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight(); const spool_weight = getSpoolWeight();
return net_weight + spool_weight; const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
}; };
const getMeasuredWeight = (): number => { const getMeasuredWeight = (): number => {
@@ -402,6 +417,30 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </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}> <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} /> <InputNumber value={usedWeight} />
</Form.Item> </Form.Item>

View File

@@ -1,7 +1,7 @@
import { LeftOutlined, RightOutlined } from "@ant-design/icons"; import { LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Edit, useForm } from "@refinedev/antd"; import { Edit, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core"; 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 TextArea from "antd/es/input/TextArea";
import { message } from "antd/lib"; import { message } from "antd/lib";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -67,6 +67,7 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const initialWeightValue = Form.useWatch("initial_weight", form); const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form); const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
// Calculate prev/next IDs for navigation // Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => { const { prevId, nextId } = useMemo(() => {
@@ -189,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
return initialWeightValue ?? selectedFilament?.weight ?? 0; return initialWeightValue ?? selectedFilament?.weight ?? 0;
}; };
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => { const getGrossWeight = (): number => {
const net_weight = getFilamentWeight(); const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight(); const spool_weight = getSpoolWeight();
return net_weight + spool_weight; const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
}; };
const getMeasuredWeight = (): number => { const getMeasuredWeight = (): number => {
@@ -394,6 +400,30 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} /> <InputNumber addonAfter="g" precision={1} />
</Form.Item> </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}> <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} /> <InputNumber value={usedWeight} />
</Form.Item> </Form.Item>

View File

@@ -1,16 +1,18 @@
import { import {
AppstoreOutlined,
EditOutlined, EditOutlined,
EyeOutlined, EyeOutlined,
FilterOutlined, FilterOutlined,
InboxOutlined, InboxOutlined,
PlusSquareOutlined, PlusSquareOutlined,
PrinterOutlined, PrinterOutlined,
TableOutlined,
ToolOutlined, ToolOutlined,
ToTopOutlined, ToTopOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd"; import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; 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 dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
@@ -42,6 +44,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model"; import { ISpool } from "./model";
import { BatchActionBar } from "./components/BatchActionBar"; import { BatchActionBar } from "./components/BatchActionBar";
import { SpoolGallery } from "./components/SpoolGallery";
dayjs.extend(utc); dayjs.extend(utc);
@@ -69,7 +72,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
"filament.combined_name": filament_name, "filament.combined_name": filament_name,
"filament.id": element.filament.id, "filament.id": element.filament.id,
"filament.material": element.filament.material, "filament.material": element.filament.material,
"filament.color_hex": element.filament.color_hex, "filament.color_hex": element.color_hex ?? element.filament.color_hex,
}; };
} }
@@ -89,6 +92,7 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"filament.color_hex", "filament.color_hex",
"filament.material", "filament.material",
"price", "price",
"remaining_value",
"used_weight", "used_weight",
"remaining_weight", "remaining_weight",
"used_length", "used_length",
@@ -101,7 +105,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"comment", "comment",
]; ];
const defaultColumns = allColumns.filter( 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> = () => { export const SpoolList: React.FC<IResourceComponentsProps> = () => {
@@ -120,6 +125,10 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
// State for the switch to show archived spools // State for the switch to show archived spools
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false); const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
// State for search and view mode
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"table" | "gallery">("table");
// State for batch selection // State for batch selection
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
@@ -132,6 +141,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
meta: { meta: {
queryParams: { queryParams: {
["allow_archived"]: showArchived, ["allow_archived"]: showArchived,
...(searchQuery ? { q: searchQuery } : {}),
}, },
}, },
syncWithLocation: false, syncWithLocation: false,
@@ -351,6 +361,12 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
> >
{t("buttons.clearFilters")} {t("buttons.clearFilters")}
</Button> </Button>
<Button
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
>
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
</Button>
<Dropdown <Dropdown
trigger={["click"]} trigger={["click"]}
menu={{ menu={{
@@ -388,6 +404,18 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
)} )}
> >
{spoolAdjustModal} {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 <Table
{...tableProps} {...tableProps}
sticky sticky
@@ -425,7 +453,9 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.combined_name", id: "filament.combined_name",
i18nkey: "spool.fields.filament_name", i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) => color: (record: ISpoolCollapsed) =>
record.filament.multi_color_hexes record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? { ? {
colors: record.filament.multi_color_hexes.split(","), colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal", vertical: record.filament.multi_color_direction === "longitudinal",
@@ -439,7 +469,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.color_hex", id: "filament.color_hex",
i18nkey: "spool.fields.color", i18nkey: "spool.fields.color",
filterValueQuery: useSpoolmanColors(), filterValueQuery: useSpoolmanColors(),
colorGetter: (record: ISpoolCollapsed) => record.filament.color_hex, colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex,
width: 60, width: 60,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
@@ -462,6 +492,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
return currencyFormatter.format(obj.price); 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({ NumberColumn({
...commonProps, ...commonProps,
id: "used_weight", id: "used_weight",
@@ -470,6 +513,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "g", unit: "g",
maxDecimals: 0, maxDecimals: 0,
width: 110, width: 110,
sorter: true,
}), }),
NumberColumn({ NumberColumn({
...commonProps, ...commonProps,
@@ -479,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0, maxDecimals: 0,
defaultText: t("unknown"), defaultText: t("unknown"),
width: 110, width: 110,
sorter: true,
}), }),
NumberColumn({ NumberColumn({
...commonProps, ...commonProps,
@@ -487,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "mm", unit: "mm",
maxDecimals: 0, maxDecimals: 0,
width: 120, width: 120,
sorter: true,
}), }),
NumberColumn({ NumberColumn({
...commonProps, ...commonProps,
@@ -496,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0, maxDecimals: 0,
defaultText: t("unknown"), defaultText: t("unknown"),
width: 120, width: 120,
sorter: true,
}), }),
FilteredQueryColumn({ FilteredQueryColumn({
...commonProps, ...commonProps,
@@ -541,6 +588,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
ActionsColumn(t("table.actions"), actions), ActionsColumn(t("table.actions"), actions),
])} ])}
/> />
)}
<BatchActionBar <BatchActionBar
selectedIds={selectedRowKeys as number[]} selectedIds={selectedRowKeys as number[]}
onArchive={handleBatchArchive} onArchive={handleBatchArchive}

View File

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

View File

@@ -15,7 +15,7 @@ import { useCurrencyFormatter } from "../../utils/settings";
import { getAPIURL, getBasePath } from "../../utils/url"; import { getAPIURL, getBasePath } from "../../utils/url";
import { IFilament } from "../filaments/model"; import { IFilament } from "../filaments/model";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool, ISpoolAdjustment } from "./model"; import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
dayjs.extend(utc); dayjs.extend(utc);
@@ -25,6 +25,7 @@ const { confirm } = Modal;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => { export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate(); const t = useTranslate();
const extraFields = useGetFields(EntityType.spool); const extraFields = useGetFields(EntityType.spool);
const filamentExtraFields = useGetFields(EntityType.filament);
const currencyFormatter = useCurrencyFormatter(); const currencyFormatter = useCurrencyFormatter();
const invalidate = useInvalidate(); const invalidate = useInvalidate();
@@ -45,6 +46,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
enabled: !!record?.id, 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 spoolPrice = (item?: ISpool) => {
const price = item?.price ?? item?.filament.price; const price = item?.price ?? item?.filament.price;
if (price === undefined) { if (price === undefined) {
@@ -116,7 +127,9 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
}); });
}; };
const colorObj = record?.filament.multi_color_hexes const colorObj = record?.color_hex
? record.color_hex
: record?.filament.multi_color_hexes
? { ? {
colors: record.filament.multi_color_hexes.split(","), colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal", vertical: record.filament.multi_color_direction === "longitudinal",
@@ -235,6 +248,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => ( {extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} /> <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 }}> <Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.adjustment_history")} {t("spool.fields.adjustment_history")}
</Title> </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> </Show>
); );
}; };

View File

@@ -35,26 +35,17 @@ def upgrade() -> None:
op.create_index(op.f("ix_location_id"), "location", ["id"], unique=False) 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) op.create_index(op.f("ix_location_parent_id"), "location", ["parent_id"], unique=False)
# Add location_id column to spool table # Add location_id column to spool table (SQLite compatible - no FK constraint)
op.add_column( op.add_column(
"spool", "spool",
sa.Column("location_id", sa.Integer(), nullable=True), sa.Column("location_id", sa.Integer(), nullable=True),
) )
op.create_foreign_key(
"fk_spool_location_id",
"spool",
"location",
["location_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_spool_location_id"), "spool", ["location_id"], unique=False) op.create_index(op.f("ix_spool_location_id"), "spool", ["location_id"], unique=False)
def downgrade() -> None: def downgrade() -> None:
"""Drop location table and remove location_id from spool.""" """Drop location table and remove location_id from spool."""
op.drop_index(op.f("ix_spool_location_id"), table_name="spool") op.drop_index(op.f("ix_spool_location_id"), table_name="spool")
op.drop_constraint("fk_spool_location_id", "spool", type_="foreignkey")
op.drop_column("spool", "location_id") op.drop_column("spool", "location_id")
op.drop_index(op.f("ix_location_parent_id"), table_name="location") 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_index(op.f("ix_location_id"), table_name="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")

View File

@@ -311,6 +311,18 @@ class Spool(BaseModel):
description=("Weight of an empty spool (tare weight)."), description=("Weight of an empty spool (tare weight)."),
examples=[246], 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( used_weight: float = Field(
ge=0, ge=0,
description="Consumed weight of filament from the spool in grams.", description="Consumed weight of filament from the spool in grams.",
@@ -325,6 +337,12 @@ class Spool(BaseModel):
), ),
examples=[5612.4], 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( used_length: float = Field(
ge=0, ge=0,
description="Consumed length of filament from the spool in millimeters.", description="Consumed length of filament from the spool in millimeters.",
@@ -369,14 +387,14 @@ class Spool(BaseModel):
remaining_length: Optional[float] = None remaining_length: Optional[float] = None
if item.initial_weight is not 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( remaining_length = length_from_weight(
weight=remaining_weight, weight=remaining_weight,
density=filament.density, density=filament.density,
diameter=filament.diameter, diameter=filament.diameter,
) )
elif filament.weight is not None: 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( remaining_length = length_from_weight(
weight=remaining_weight, weight=remaining_weight,
density=filament.density, density=filament.density,
@@ -389,6 +407,13 @@ class Spool(BaseModel):
diameter=filament.diameter, 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( return Spool(
id=item.id, id=item.id,
registered=item.registered, registered=item.registered,
@@ -398,10 +423,12 @@ class Spool(BaseModel):
price=item.price, price=item.price,
initial_weight=item.initial_weight, initial_weight=item.initial_weight,
spool_weight=item.spool_weight, spool_weight=item.spool_weight,
extra_weight=item.extra_weight,
used_weight=item.used_weight, used_weight=item.used_weight,
used_length=used_length, used_length=used_length,
remaining_weight=remaining_weight, remaining_weight=remaining_weight,
remaining_length=remaining_length, remaining_length=remaining_length,
remaining_value=remaining_value,
location=item.location, location=item.location,
lot_nr=item.lot_nr, lot_nr=item.lot_nr,
comment=item.comment, comment=item.comment,

View File

@@ -1,13 +1,17 @@
"""Filament related endpoints.""" """Filament related endpoints."""
import logging 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 pydantic import BaseModel, Field, RootModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession 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 from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -173,3 +177,299 @@ async def rename_location(
logger.info("Renaming location %s to %s", location, body.name) logger.info("Renaming location %s to %s", location, body.name)
await spool.rename_location(db=db, current_name=location, new_name=body.name) await spool.rename_location(db=db, current_name=location, new_name=body.name)
return 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

@@ -51,6 +51,18 @@ class SpoolParameters(BaseModel):
description="The weight of an empty spool, in grams. (tare weight)", description="The weight of an empty spool, in grams. (tare weight)",
examples=[200], 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( remaining_weight: Optional[float] = Field(
None, None,
ge=0, ge=0,
@@ -293,6 +305,15 @@ async def find(
description="Filter by spools with remaining weight (in grams) greater than this value.", description="Filter by spools with remaining weight (in grams) greater than this value.",
), ),
] = None, ] = None,
q: Annotated[
Optional[str],
Query(
title="Search",
description=(
"Global search term. Searches across filament name, vendor name, material, and location."
),
),
] = None,
sort: Annotated[ sort: Annotated[
Optional[str], Optional[str],
Query( Query(
@@ -329,6 +350,7 @@ async def find(
db_items, total_count = await spool.find( db_items, total_count = await spool.find(
db=db, db=db,
q=q,
filament_name=filament_name if filament_name is not None else filament_name_old, filament_name=filament_name if filament_name is not None else filament_name_old,
filament_id=filament_ids, filament_id=filament_ids,
filament_material=filament_material if filament_material is not None else filament_material_old, 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, price=body.price,
initial_weight=body.initial_weight, initial_weight=body.initial_weight,
spool_weight=body.spool_weight, spool_weight=body.spool_weight,
extra_weight=body.extra_weight,
color_hex=body.color_hex,
remaining_weight=body.remaining_weight, remaining_weight=body.remaining_weight,
used_weight=body.used_weight, used_weight=body.used_weight,
first_used=body.first_used, first_used=body.first_used,

View File

@@ -100,6 +100,15 @@ class Spool(Base):
initial_weight: Mapped[Optional[float]] = mapped_column() initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column() spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column() used_weight: Mapped[float] = mapped_column()
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: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column( location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"), ForeignKey("location.id", ondelete="SET NULL"),

View File

@@ -41,6 +41,8 @@ async def create(
remaining_weight: Optional[float] = None, remaining_weight: Optional[float] = None,
initial_weight: Optional[float] = None, initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None, spool_weight: Optional[float] = None,
extra_weight: Optional[float] = None,
color_hex: Optional[str] = None,
used_weight: Optional[float] = None, used_weight: Optional[float] = None,
first_used: Optional[datetime] = None, first_used: Optional[datetime] = None,
last_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 " "remaining_weight can only be used if the initial_weight is "
"defined or the filament has a weight set.", "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: else:
used_weight = 0 used_weight = 0
@@ -84,6 +89,8 @@ async def create(
registered=datetime.utcnow().replace(microsecond=0), registered=datetime.utcnow().replace(microsecond=0),
initial_weight=initial_weight, initial_weight=initial_weight,
spool_weight=spool_weight, spool_weight=spool_weight,
extra_weight=extra_weight,
color_hex=color_hex,
used_weight=used_weight, used_weight=used_weight,
price=price, price=price,
first_used=first_used, 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 async def find( # noqa: C901, PLR0912
*, *,
db: AsyncSession, db: AsyncSession,
q: Optional[str] = None,
filament_name: Optional[str] = None, filament_name: Optional[str] = None,
filament_id: Optional[Union[int, Sequence[int]]] = None, filament_id: Optional[Union[int, Sequence[int]]] = None,
filament_material: Optional[str] = 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.location, location)
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr) 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) # Filter by color_hex (exact match, supports multiple comma-separated values)
if filament_color_hex is not None: if filament_color_hex is not None:
if isinstance(filament_color_hex, str): if isinstance(filament_color_hex, str):
@@ -221,6 +243,18 @@ async def find( # noqa: C901, PLR0912
sorts.append(models.Filament.name) sorts.append(models.Filament.name)
elif fieldstr == "price": elif fieldstr == "price":
sorts.append(coalesce(models.Spool.price, models.Filament.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: else:
sorts.append(parse_nested_field(models.Spool, fieldstr)) sorts.append(parse_nested_field(models.Spool, fieldstr))
@@ -258,7 +292,10 @@ async def update(
elif k == "remaining_weight": elif k == "remaining_weight":
if spool.initial_weight is None: if spool.initial_weight is None:
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.") 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): elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v)) setattr(spool, k, utc_timezone_naive(v))
elif k == "extra": elif k == "extra":
@@ -440,7 +477,12 @@ async def measure(
""" """
spool_result = await db.execute( 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, models.Spool.id == spool_id,
), ),
) )
@@ -452,6 +494,7 @@ async def measure(
initial_weight = spool_info[0] initial_weight = spool_info[0]
spool_weight = spool_info[2] 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: if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# Get filament weight and spool_weight # Get filament weight and spool_weight
result = await db.execute( result = await db.execute(
@@ -473,7 +516,7 @@ async def measure(
if initial_weight is None or initial_weight == 0: if initial_weight is None or initial_weight == 0:
raise SpoolMeasureError("Initial weight is not set.") 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 the measurement is greater than the initial weight, set the initial weight to the measurement
if weight > initial_gross_weight: if weight > initial_gross_weight:
@@ -485,9 +528,10 @@ async def measure(
# Calculate the weight used since last measure # Calculate the weight used since last measure
weight_to_use = current_use - weight weight_to_use = current_use - weight
# If the measured weight is less than the empty weight, use the rest of the spool # If the measured weight is less than the empty weight (+ extra), use the rest of the spool
if (initial_gross_weight - weight_to_use) < spool_weight: empty_gross_weight = spool_weight + extra_weight
weight_to_use = current_use - spool_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) return await use_weight(db, spool_id, weight_to_use, comment=comment)