Compare commits
35 Commits
e264d7ed4e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| beb1ad2bfa | |||
| 23ef41270c | |||
| c321d916b5 | |||
| 67eaed32d5 | |||
| f38baa985e | |||
| ad1df035e9 | |||
| 801d3da02a | |||
| 5f66302e73 | |||
| 8681f7aee7 | |||
| 3aa633df0a | |||
| 18cafc4361 | |||
| 0556be9e3b | |||
| 02da984b6e | |||
| 27757bb949 | |||
| 9bfc322f32 | |||
| 8fc06cdb18 | |||
| f63f70ce86 | |||
| e782b89929 | |||
| a730dd1ec9 | |||
| 3613e7739a | |||
| 94612a6dd3 | |||
| 88747307e5 | |||
| 41d07f069b | |||
| f2fecede35 | |||
| 267fadfc54 | |||
| 48bd516c0f | |||
| 0a741c9712 | |||
| 2dd7c1198c | |||
| 0401dd680c | |||
| 9365e399be | |||
| 4cd58ebae9 | |||
| 695f1adeed | |||
| 75ddebb963 | |||
| db8bf59b15 | |||
| 5c29936231 |
351
CLAUDE.md
351
CLAUDE.md
@@ -122,7 +122,7 @@ Environment variables (see `.env.example`):
|
|||||||
|
|
||||||
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
|
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
|
||||||
|
|
||||||
## UX Improvement Issues
|
## UX Improvement Issues (Gitea)
|
||||||
|
|
||||||
| # | Title | Status |
|
| # | Title | Status |
|
||||||
|---|-------|--------|
|
|---|-------|--------|
|
||||||
@@ -132,14 +132,55 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
|
|||||||
| 4 | Layout Max-Width | ✅ DONE |
|
| 4 | Layout Max-Width | ✅ DONE |
|
||||||
| 5 | Temperature Ranges | ✅ DONE |
|
| 5 | Temperature Ranges | ✅ DONE |
|
||||||
| 6 | Spool Adjustment History | ✅ DONE |
|
| 6 | Spool Adjustment History | ✅ DONE |
|
||||||
| 7 | 3dfilamentprofiles Integration | ✅ DONE |
|
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
|
||||||
| 8 | (see Gitea #8) | ✅ 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 | Theme Colors | ✅ DONE |
|
| 11 | Slicer Integration / Print Jobs | ✅ DONE |
|
||||||
| 12 | Clickable Dashboard Cards | ✅ DONE |
|
| 12 | Redesign Home Page Dashboard | ✅ DONE |
|
||||||
| 13 | QR Code Size Setting | ✅ DONE |
|
| 13 | Batch Operations (Multi-Select Edit) | ✅ DONE |
|
||||||
| 14 | Parse Temps from Comments | Open |
|
| 14 | Extra Weight Field (DryPods, etc.) | ✅ DONE |
|
||||||
|
| 15 | Spool Count per Filament in List | ✅ DONE |
|
||||||
|
| 16 | Filter Spools by Color | ✅ DONE |
|
||||||
|
| 17 | Image Attachments for Filaments | **Open** |
|
||||||
|
| 18 | QR Code Labels for Filaments | **Open** |
|
||||||
|
| 19 | Integration with filamentcolors.xyz | ✅ DONE |
|
||||||
|
| 20 | Gallery View for Spools (Color Grid) | ✅ DONE |
|
||||||
|
| 21 | QR Code Version/Complexity Config | **Open** |
|
||||||
|
| 23 | Reporting & Data Export | **Open** |
|
||||||
|
| 24 | NFC/RFID Tag Support | **Open** |
|
||||||
|
| 25 | Sort Spools by Custom Fields | ✅ DONE |
|
||||||
|
| 26 | Label Template Enhancements | **Open** |
|
||||||
|
| 27 | QR Scanner Camera Device Selector | **Open** |
|
||||||
|
| 28 | Mobile-Friendly UI / Responsive | **Open** |
|
||||||
|
| 29 | Table Column Customization | **Open** |
|
||||||
|
| 30 | Printer Management & Parts Inventory | **Open** |
|
||||||
|
| 31 | Special Filament Properties | **Open** |
|
||||||
|
| 32 | SpoolType Model (Cardboard/Plastic/Metal) | **Open** |
|
||||||
|
| 34 | Color Swatch Border for Dark Mode | ✅ DONE |
|
||||||
|
| 35 | Global Search Box | ✅ DONE |
|
||||||
|
| 36 | Spool-Level Overrides (Color, Price) | ✅ DONE |
|
||||||
|
| 37 | Plugin System for Extensions | **Open** |
|
||||||
|
| 39 | Usage Statistics & Cost Tracking | ✅ DONE |
|
||||||
|
| 40 | Storage vs Active Spool Status | **Open** |
|
||||||
|
| 41 | Load Cell / Scale Integration | **Open** |
|
||||||
|
| 42 | Allow Spools Heavier Than Max | ✅ DONE |
|
||||||
|
| 43 | Show Filament Fields on Spool View | ✅ DONE |
|
||||||
|
| 44 | HomeAssistant Add-On Packaging | **Open** |
|
||||||
|
| 45 | Filament Swatch Card Generator | **Open** |
|
||||||
|
| 46 | Project-Based Spool Grouping | **Open** |
|
||||||
|
| 47 | G-code Filament Usage Estimation | **Open** |
|
||||||
|
|
||||||
|
### Other Features (not tracked in Gitea)
|
||||||
|
- ✅ Theme Colors (5 options)
|
||||||
|
- ✅ Clickable Dashboard Cards
|
||||||
|
- ✅ QR Code Size Setting
|
||||||
|
- ✅ Parse Temps from Comments
|
||||||
|
- ✅ Prev/Next Navigation on Edit Pages
|
||||||
|
- ✅ Price/Cost Tracking (remaining value, inventory value)
|
||||||
|
- ✅ Print History on Spool Detail
|
||||||
|
- ✅ Usage Analytics Dashboard
|
||||||
|
- ✅ Color Column in Spool List (with filter)
|
||||||
|
|
||||||
### Issue #1 Complete - Inline Creation Modals
|
### Issue #1 Complete - Inline Creation Modals
|
||||||
|
|
||||||
@@ -221,30 +262,10 @@ Frontend:
|
|||||||
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
|
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
|
||||||
- `client/public/locales/en/common.json` - Translation keys for history table
|
- `client/public/locales/en/common.json` - Translation keys for history table
|
||||||
|
|
||||||
### Issue #7 Complete - 3dfilamentprofiles Integration
|
### Issue #7 REMOVED - 3dfilamentprofiles Integration
|
||||||
|
|
||||||
Added 3dfilamentprofiles.com as a second external database source alongside SpoolmanDB.
|
**Removed** - 3dfilamentprofiles.com has Vercel bot protection blocking automated requests.
|
||||||
|
Their GitHub repo only has sample data (~3 filaments). SpoolmanDB has 6,957+ filaments, so this was unnecessary.
|
||||||
Backend (`spoolman/externaldb.py`):
|
|
||||||
- Added `ExternalSource` enum (SPOOLMANDB, FILAMENT_PROFILES_3D)
|
|
||||||
- Added `Filament3DFP` model for raw 3dfp data
|
|
||||||
- Added `_transform_3dfp_to_external()` to convert 3dfp format to ExternalFilament
|
|
||||||
- Updated `_sync()` to fetch from both sources and merge results
|
|
||||||
- Environment variables: `EXTERNAL_DB_3DFP_URL`, `EXTERNAL_DB_3DFP_ENABLED`
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- `client/src/utils/queryExternalDB.ts` - Added `ExternalSource` enum, `source` field, temp range fields
|
|
||||||
- `client/src/components/filamentImportModal.tsx` - Added tabs to filter by source (All/SpoolmanDB/3DFP)
|
|
||||||
- `client/src/pages/filaments/create.tsx` - Updated import handler for temp range fields
|
|
||||||
|
|
||||||
Data mapping (3dfp → Spoolman):
|
|
||||||
- `brand_name` → `manufacturer`
|
|
||||||
- `color` + `material_type` → `name`
|
|
||||||
- `rgb` → `color_hex` (strip # prefix)
|
|
||||||
- `properties.temp_min/max` → `extruder_temp/extruder_temp_max`
|
|
||||||
- `properties.bed_temp_min/max` → `bed_temp/bed_temp_max`
|
|
||||||
- Diameter defaults to 1.75mm (3dfp doesn't provide)
|
|
||||||
- Density from material lookup table
|
|
||||||
|
|
||||||
### Issue #10 Complete - Database Import/Export
|
### Issue #10 Complete - Database Import/Export
|
||||||
|
|
||||||
@@ -312,6 +333,177 @@ Added explicit QR code size control:
|
|||||||
- `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface
|
- `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface
|
||||||
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto)
|
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto)
|
||||||
|
|
||||||
|
### Issue #15 Complete - Print Job Tracking / Slicer Integration
|
||||||
|
|
||||||
|
Track pending print jobs from slicer and manage filament deduction after prints complete.
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
1. Slicer post-processing script parses G-code for filament usage
|
||||||
|
2. Script creates pending print job in Spoolman via API
|
||||||
|
3. After print completes, user visits Print Queue UI
|
||||||
|
4. Complete: Deducts filament from spool, records adjustment history
|
||||||
|
5. Cancel: Flags spool for manual weigh-in (no automatic deduction)
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/database/models.py` - `PrintJob` model, `needs_weighing` field on Spool
|
||||||
|
- `spoolman/database/print_job.py` - CRUD operations for print jobs
|
||||||
|
- `spoolman/api/v1/print_job.py` - REST API endpoints
|
||||||
|
- `POST /print-job` - Create pending job
|
||||||
|
- `POST /print-job/{id}/complete` - Complete job, deduct filament
|
||||||
|
- `POST /print-job/{id}/cancel` - Cancel job, flag spool for weighing
|
||||||
|
- `GET /print-job` - List jobs with status filter
|
||||||
|
- `migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py`
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/pages/print-queue/index.tsx` - Print Queue UI page
|
||||||
|
- `client/src/pages/spools/model.tsx` - `IPrintJob` interface, `needs_weighing` on ISpool
|
||||||
|
|
||||||
|
**Slicer Script:**
|
||||||
|
- `scripts/slicer_post_process.py` - Elegoo/Orca Slicer post-processing script
|
||||||
|
- Parses G-code comments for filament usage (weight in grams)
|
||||||
|
- Creates pending print job via API
|
||||||
|
- Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID`
|
||||||
|
- Or config file: `~/.config/spoolman/slicer.conf`
|
||||||
|
|
||||||
|
### Color Column in Spool List
|
||||||
|
|
||||||
|
Added optional color column to the spool list with filtering capability.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/database/filament.py` - `find_colors()` function to get distinct color hex codes
|
||||||
|
- `spoolman/api/v1/other.py` - `GET /api/v1/color` endpoint to list all colors
|
||||||
|
- `spoolman/api/v1/spool.py` - Added `filament.color_hex` query parameter for filtering
|
||||||
|
- `spoolman/database/spool.py` - Filter implementation (supports comma-separated values)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/components/column.tsx` - `ColorFilterColumn` component renders color swatches
|
||||||
|
- `client/src/components/otherModels.tsx` - `useSpoolmanColors()` hook to fetch colors
|
||||||
|
- `client/src/pages/spools/list.tsx` - Added color column (hidden by default)
|
||||||
|
- `client/public/locales/en/common.json` - Added "Color" translation key
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Color displayed as small swatch with hex tooltip
|
||||||
|
- Filter dropdown shows swatches with hex codes
|
||||||
|
- Multi-select filtering (can filter by multiple colors)
|
||||||
|
- Column hidden by default (enable via column selector)
|
||||||
|
|
||||||
|
### Issue #9 Complete - Hierarchical Locations
|
||||||
|
|
||||||
|
Storage locations can now be organized in a tree structure (Room > Shelf > Bin).
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/database/models.py` - Added `Location` model with self-referential parent
|
||||||
|
- `spoolman/database/location.py` - CRUD operations, tree building, full path resolution
|
||||||
|
- `spoolman/api/v1/location.py` - REST API for locations
|
||||||
|
- `migrations/versions/2025_01_21_0100-*.py` - Migration for location table
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/components/LocationTreeSelect.tsx` - Tree-based location picker
|
||||||
|
- `client/src/components/LocationCreateModal.tsx` - Inline location creation
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Hierarchical structure with parent/child relationships
|
||||||
|
- Location types: room, shelf, bin, drawer, box, other
|
||||||
|
- Legacy flat location string preserved for backward compatibility
|
||||||
|
- API endpoints: GET /location, GET /location/tree, POST/PATCH/DELETE
|
||||||
|
|
||||||
|
### Issue #12 Complete - Dashboard Redesign
|
||||||
|
|
||||||
|
Redesigned home page with better visualizations and quick stats.
|
||||||
|
|
||||||
|
**Backend:** No changes needed - uses existing endpoints.
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/pages/home/index.tsx` - Refactored layout
|
||||||
|
- `client/src/pages/home/components/AlertCards.tsx` - Low stock, pending jobs, needs weighing alerts
|
||||||
|
- `client/src/pages/home/components/MaterialDistributionChart.tsx` - Pie chart by material (recharts)
|
||||||
|
- `client/src/pages/home/components/QuickStats.tsx` - Total spools, filaments, vendors, weight
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- Added `recharts` package for charts
|
||||||
|
|
||||||
|
### Issue #13 Complete - Batch Operations
|
||||||
|
|
||||||
|
Multi-select spools with bulk archive/delete/move actions.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/api/v1/spool.py` - Added bulk endpoints:
|
||||||
|
- POST /spool/bulk/archive
|
||||||
|
- POST /spool/bulk/delete
|
||||||
|
- POST /spool/bulk/update
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/pages/spools/list.tsx` - Added row selection
|
||||||
|
- `client/src/pages/spools/components/BatchActionBar.tsx` - Floating action bar
|
||||||
|
- `client/src/pages/spools/functions.tsx` - Bulk helper functions
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Checkbox selection with select-all
|
||||||
|
- Floating action bar shows when items selected
|
||||||
|
- Archive, unarchive, delete, move to location
|
||||||
|
- Confirmation modals for all destructive actions
|
||||||
|
|
||||||
|
### Issue #14 Complete - Extra Weight Field
|
||||||
|
|
||||||
|
Track extra weight from DryPods, custom spool holders, or other accessories.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/database/models.py` - Added `extra_weight` column to Spool model
|
||||||
|
- `spoolman/database/spool.py` - Updated `measure()` to account for extra weight
|
||||||
|
- `spoolman/api/v1/spool.py` - Added `extra_weight` to create/update parameters
|
||||||
|
- `migrations/versions/2025_01_21_0200-*.py` - Migration for extra_weight column
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/pages/spools/create.tsx` - Added extra_weight form field
|
||||||
|
- `client/src/pages/spools/edit.tsx` - Added extra_weight form field
|
||||||
|
- Weight calculations updated to include extra_weight in gross weight
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Optional field for tracking additional weight on spool
|
||||||
|
- Automatically subtracted when measuring spool weight
|
||||||
|
- Useful for DryPods, custom spool holders, reel adapters
|
||||||
|
|
||||||
|
### Issue #15 Complete - Spool Count per Filament
|
||||||
|
|
||||||
|
Already implemented - filament list shows `spool_count` and `total_remaining_weight` columns.
|
||||||
|
|
||||||
|
### Price & Cost Tracking
|
||||||
|
|
||||||
|
Track spool purchase price and calculate remaining value.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/api/v1/models.py` - Added `remaining_value` computed field
|
||||||
|
- Formula: `remaining_value = (remaining_weight / initial_weight) * price`
|
||||||
|
- Price falls back to filament price if not set on spool
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/pages/spools/list.tsx` - Added remaining_value column (hidden by default)
|
||||||
|
- `client/src/pages/home/components/QuickStats.tsx` - Added "Inventory Value" stat
|
||||||
|
|
||||||
|
### Print History on Spool Detail
|
||||||
|
|
||||||
|
Show print job history on the spool detail page.
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/pages/spools/show.tsx` - Added collapsible print history table
|
||||||
|
- Fetches print jobs filtered by spool_id
|
||||||
|
- Shows: Created date, Filename, Filament Used, Status, Finished date
|
||||||
|
|
||||||
|
### Usage Analytics Dashboard
|
||||||
|
|
||||||
|
Time-series charts showing filament consumption trends.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `spoolman/api/v1/other.py` - New analytics endpoints:
|
||||||
|
- `GET /analytics/usage?days=N` - Daily usage from SpoolAdjustments
|
||||||
|
- `GET /analytics/by-material?days=N` - Usage breakdown by material
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `client/src/pages/home/components/UsageAnalytics.tsx` - New component
|
||||||
|
- Area chart showing daily consumption (recharts)
|
||||||
|
- Period selector: 7, 30, or 90 days
|
||||||
|
- Material breakdown cards showing usage by filament type
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -331,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
|
### Issue #7 - 3dfilamentprofiles Integration REMOVED
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### 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).
|
|
||||||
|
|
||||||
### 3dfilamentprofiles Sample Data
|
|
||||||
The 3dfp GitHub repo only contains sample data (3 filaments). The full dataset may require using their website API directly.
|
|
||||||
|
|
||||||
### Future: Issue #9 - Hierarchical Locations
|
|
||||||
Add room/bin/shelf support for locations. See Gitea issue #9 for details.
|
|
||||||
|
|||||||
389
client/package-lock.json
generated
389
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "spoolman-ui",
|
"name": "spoolman-ui",
|
||||||
"version": "0.22.1",
|
"version": "0.23C.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "spoolman-ui",
|
"name": "spoolman-ui",
|
||||||
"version": "0.22.1",
|
"version": "0.23C.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@loadable/component": "^5.16.7",
|
"@loadable/component": "^5.16.7",
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"react-i18next": "^16.5.1",
|
"react-i18next": "^16.5.1",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^7.11.0",
|
||||||
"react-to-print": "^3.2.0",
|
"react-to-print": "^3.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -3359,6 +3360,48 @@
|
|||||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
|
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||||
|
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@refinedev/antd": {
|
"node_modules/@refinedev/antd": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz",
|
||||||
@@ -4187,6 +4230,18 @@
|
|||||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
||||||
@@ -4511,6 +4566,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/emscripten": {
|
"node_modules/@types/emscripten": {
|
||||||
"version": "1.41.5",
|
"version": "1.41.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||||
@@ -4622,6 +4740,12 @@
|
|||||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -5886,6 +6010,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -6157,6 +6290,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
@@ -6263,6 +6517,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decode-uri-component": {
|
"node_modules/decode-uri-component": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
||||||
@@ -6772,6 +7032,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -8439,6 +8709,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -8577,6 +8857,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -11919,6 +12208,29 @@
|
|||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -12020,6 +12332,42 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/redeyed": {
|
"node_modules/redeyed": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz",
|
||||||
@@ -12039,6 +12387,15 @@
|
|||||||
"@babel/runtime": "^7.9.2"
|
"@babel/runtime": "^7.9.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -12207,6 +12564,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resize-observer-polyfill": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
@@ -14129,6 +14492,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "spoolman-ui",
|
"name": "spoolman-ui",
|
||||||
"version": "0.23C.1",
|
"version": "0.23C.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20.x"
|
"node": "20.x"
|
||||||
},
|
},
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"react-i18next": "^16.5.1",
|
"react-i18next": "^16.5.1",
|
||||||
"react-router": "^7.11.0",
|
"react-router": "^7.11.0",
|
||||||
"react-to-print": "^3.2.0",
|
"react-to-print": "^3.2.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|||||||
@@ -27,11 +27,31 @@
|
|||||||
"unArchive": "Unarchive",
|
"unArchive": "Unarchive",
|
||||||
"hideArchived": "Hide Archived",
|
"hideArchived": "Hide Archived",
|
||||||
"showArchived": "Show Archived",
|
"showArchived": "Show Archived",
|
||||||
|
"prev": "Prev",
|
||||||
|
"next": "Next",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
"warnWhenUnsavedChanges": "Are you sure you want to leave? You have unsaved changes.",
|
||||||
|
"batch": {
|
||||||
|
"selected": "{{count}} selected",
|
||||||
|
"clear_selection": "Clear",
|
||||||
|
"archive_confirm": "Archive Spools",
|
||||||
|
"archive_confirm_content": "Are you sure you want to archive {{count}} spools?",
|
||||||
|
"unarchive_confirm": "Unarchive Spools",
|
||||||
|
"unarchive_confirm_content": "Are you sure you want to unarchive {{count}} spools?",
|
||||||
|
"delete_confirm": "Delete Spools",
|
||||||
|
"delete_confirm_content": "Are you sure you want to delete {{count}} spools? This action cannot be undone.",
|
||||||
|
"move": "Move",
|
||||||
|
"move_title": "Move Spools to Location",
|
||||||
|
"move_description": "Select a location for the {{count}} selected spools."
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"success": "Successful",
|
"success": "Successful",
|
||||||
"error": "Error (status code: {{statusCode}})",
|
"error": "Error (status code: {{statusCode}})",
|
||||||
@@ -47,6 +67,11 @@
|
|||||||
"validationError": "Validation error: {{error}}"
|
"validationError": "Validation error: {{error}}"
|
||||||
},
|
},
|
||||||
"kofi": "Tip me on Ko-fi",
|
"kofi": "Tip me on Ko-fi",
|
||||||
|
"color": {
|
||||||
|
"click_to_copy": "Click to copy",
|
||||||
|
"copied": "Copied {{color}} to clipboard",
|
||||||
|
"copy_failed": "Failed to copy to clipboard"
|
||||||
|
},
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
@@ -156,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",
|
||||||
@@ -163,15 +189,19 @@
|
|||||||
"filament_internal": "Internal",
|
"filament_internal": "Internal",
|
||||||
"filament_external": "External",
|
"filament_external": "External",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
|
"color": "Color",
|
||||||
"material": "Material",
|
"material": "Material",
|
||||||
"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",
|
||||||
@@ -179,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",
|
||||||
@@ -196,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."
|
||||||
@@ -228,6 +263,40 @@
|
|||||||
"last_used": "Last used {{date}}"
|
"last_used": "Last used {{date}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"location": {
|
||||||
|
"location": "Location",
|
||||||
|
"locations": "Locations",
|
||||||
|
"create": "Create Location",
|
||||||
|
"titles": {
|
||||||
|
"create": "Create Location",
|
||||||
|
"edit": "Edit Location",
|
||||||
|
"list": "Locations"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"name_required": "Location name is required",
|
||||||
|
"name_placeholder": "e.g., Shelf A",
|
||||||
|
"parent": "Parent Location",
|
||||||
|
"parent_placeholder": "Select parent (optional)",
|
||||||
|
"location_type": "Type",
|
||||||
|
"location_type_placeholder": "Select type (optional)",
|
||||||
|
"description": "Description",
|
||||||
|
"spool_count": "Spools",
|
||||||
|
"full_path": "Full Path"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"room": "Room",
|
||||||
|
"shelf": "Shelf",
|
||||||
|
"bin": "Bin",
|
||||||
|
"drawer": "Drawer",
|
||||||
|
"box": "Box",
|
||||||
|
"other": "Other"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"delete_confirm": "Are you sure you want to delete this location?",
|
||||||
|
"has_children": "This location has child locations"
|
||||||
|
}
|
||||||
|
},
|
||||||
"filament": {
|
"filament": {
|
||||||
"filament": "Filaments",
|
"filament": "Filaments",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -255,7 +324,9 @@
|
|||||||
"longitudinal": "Longitudinal",
|
"longitudinal": "Longitudinal",
|
||||||
"external_id": "External ID",
|
"external_id": "External ID",
|
||||||
"spools": "Show Spools",
|
"spools": "Show Spools",
|
||||||
"not_set": "Not Set"
|
"not_set": "Not Set",
|
||||||
|
"spool_count": "Spools",
|
||||||
|
"total_remaining_weight": "Total Remaining"
|
||||||
},
|
},
|
||||||
"fields_help": {
|
"fields_help": {
|
||||||
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
|
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
|
||||||
@@ -284,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": {
|
||||||
@@ -317,10 +399,54 @@
|
|||||||
"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!",
|
||||||
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
|
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started.",
|
||||||
|
"all_spools": "All Spools",
|
||||||
|
"low_stock_alert": "{{count}} spools below {{threshold}}g",
|
||||||
|
"low_stock_warning": "Low stock",
|
||||||
|
"alerts": {
|
||||||
|
"low_stock": "Low Stock",
|
||||||
|
"pending_jobs": "Pending Jobs",
|
||||||
|
"needs_weighing": "Needs Weighing"
|
||||||
|
},
|
||||||
|
"charts": {
|
||||||
|
"material_distribution": "Material Distribution"
|
||||||
|
},
|
||||||
|
"quick_stats": {
|
||||||
|
"total_spools": "Total Spools",
|
||||||
|
"total_filaments": "Filament Types",
|
||||||
|
"total_vendors": "Vendors",
|
||||||
|
"total_weight": "Total Remaining",
|
||||||
|
"total_value": "Inventory Value",
|
||||||
|
"total_spent": "Total Spent",
|
||||||
|
"avg_cost_per_kg": "Avg Cost/kg"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Usage Analytics",
|
||||||
|
"7_days": "7 Days",
|
||||||
|
"30_days": "30 Days",
|
||||||
|
"90_days": "90 Days",
|
||||||
|
"total_used": "Total used: {{weight}} kg",
|
||||||
|
"used": "Used",
|
||||||
|
"spent": "Spent",
|
||||||
|
"by_material": "Usage by Material"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
@@ -375,6 +501,22 @@
|
|||||||
"replace_warning_title": "Replace all data?",
|
"replace_warning_title": "Replace all data?",
|
||||||
"replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!"
|
"replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!"
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Authentication",
|
||||||
|
"description": "Require users to log in to access Spoolman. Once enabled, you'll be redirected to the login page.",
|
||||||
|
"status_enabled": "Authentication is enabled",
|
||||||
|
"status_disabled": "Authentication is disabled",
|
||||||
|
"setup_title": "Enable Authentication",
|
||||||
|
"setup_description": "Create an admin account to enable authentication. This user will have full access to manage Spoolman.",
|
||||||
|
"enable_button": "Create Admin & Enable",
|
||||||
|
"disable_title": "Disable Authentication",
|
||||||
|
"disable_description": "Enter an admin password to confirm disabling authentication. Anyone will be able to access Spoolman without logging in.",
|
||||||
|
"disable_button": "Disable Authentication",
|
||||||
|
"admin_password": "Admin Password",
|
||||||
|
"password_required": "Password is required",
|
||||||
|
"enabled_success": "Authentication enabled",
|
||||||
|
"disabled_success": "Authentication disabled"
|
||||||
|
},
|
||||||
"extra_fields": {
|
"extra_fields": {
|
||||||
"tab": "Extra Fields",
|
"tab": "Extra Fields",
|
||||||
"description": "<p>Here you can add extra custom fields to your entities.</p><p>Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
|
"description": "<p>Here you can add extra custom fields to your entities.</p><p>Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
|
||||||
@@ -405,6 +547,39 @@
|
|||||||
"key_not_changed": "Please change the key to something else.",
|
"key_not_changed": "Please change the key to something else.",
|
||||||
"delete_confirm": "Delete field {{name}}?",
|
"delete_confirm": "Delete field {{name}}?",
|
||||||
"delete_confirm_description": "This will delete the field and all associated data for all entities."
|
"delete_confirm_description": "This will delete the field and all associated data for all entities."
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"tab": "Users",
|
||||||
|
"title": "User Management",
|
||||||
|
"add": "Add User",
|
||||||
|
"edit": "Edit User",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"role": "Role",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"you": "You",
|
||||||
|
"created": "User created successfully",
|
||||||
|
"updated": "User updated successfully",
|
||||||
|
"deleted": "User deleted successfully",
|
||||||
|
"delete_confirm": "Are you sure you want to delete this user?",
|
||||||
|
"username_required": "Username is required",
|
||||||
|
"username_min": "Username must be at least 3 characters",
|
||||||
|
"password_required": "Password is required",
|
||||||
|
"password_min": "Password must be at least 6 characters",
|
||||||
|
"password_change_hint": "Leave blank to keep current password",
|
||||||
|
"leave_blank": "Leave blank to keep current",
|
||||||
|
"role_viewer": "Viewer - Can only view data",
|
||||||
|
"role_operator": "Operator - Can view and record usage",
|
||||||
|
"role_editor": "Editor - Can view, use, create, and edit",
|
||||||
|
"role_admin": "Admin - Full access including user management",
|
||||||
|
"auth_disabled": "Authentication is not enabled",
|
||||||
|
"auth_disabled_hint": "Enable authentication in Settings > General to manage users",
|
||||||
|
"admin_required": "Admin Access Required",
|
||||||
|
"admin_required_hint": "Only administrators can manage users",
|
||||||
|
"logout": "Log Out"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"documentTitle": {
|
"documentTitle": {
|
||||||
@@ -446,5 +621,53 @@
|
|||||||
"new_location": "New Location",
|
"new_location": "New Location",
|
||||||
"no_location": "No Location",
|
"no_location": "No Location",
|
||||||
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
|
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
|
||||||
|
},
|
||||||
|
"print-queue": {
|
||||||
|
"print-queue": "Print Queue"
|
||||||
|
},
|
||||||
|
"print_queue": {
|
||||||
|
"title": "Print Queue",
|
||||||
|
"pending_jobs": "Pending Jobs",
|
||||||
|
"needs_weighing": "Spools Needing Weigh-In",
|
||||||
|
"recent_history": "Recent History",
|
||||||
|
"no_pending_jobs": "No pending print jobs",
|
||||||
|
"no_history": "No completed or cancelled jobs yet",
|
||||||
|
"fields": {
|
||||||
|
"filename": "Filename",
|
||||||
|
"spool": "Spool",
|
||||||
|
"filament_used": "Filament Used",
|
||||||
|
"created": "Created",
|
||||||
|
"finished": "Finished",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"completed": "Completed",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"needs_weighing": "Need Weigh-In",
|
||||||
|
"completed_today": "Completed Today",
|
||||||
|
"total_jobs": "Total Jobs"
|
||||||
|
},
|
||||||
|
"complete": "Complete",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"cancel_job": "Cancel Job",
|
||||||
|
"weigh_in": "Weigh In",
|
||||||
|
"clear_flag": "Clear Flag",
|
||||||
|
"clear_flag_tooltip": "Mark as weighed without updating weight",
|
||||||
|
"confirm_complete": "Complete Print Job?",
|
||||||
|
"confirm_complete_description": "This will deduct {{grams}}g from the spool.",
|
||||||
|
"confirm_cancel": "Cancel Print Job?",
|
||||||
|
"confirm_cancel_description": "The spool will be flagged for manual weigh-in since filament usage is unknown.",
|
||||||
|
"confirm_delete": "Delete Print Job?",
|
||||||
|
"confirm_delete_description": "This will permanently remove this job from history.",
|
||||||
|
"job_completed": "Print job completed",
|
||||||
|
"job_complete_error": "Failed to complete print job",
|
||||||
|
"job_cancelled": "Print job cancelled - spool flagged for weigh-in",
|
||||||
|
"job_cancel_error": "Failed to cancel print job",
|
||||||
|
"job_deleted": "Print job deleted",
|
||||||
|
"weighing_cleared": "Weigh-in flag cleared"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FileOutlined,
|
FileOutlined,
|
||||||
HighlightOutlined,
|
HighlightOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
|
PrinterOutlined,
|
||||||
QuestionOutlined,
|
QuestionOutlined,
|
||||||
TableOutlined,
|
TableOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
@@ -26,10 +27,16 @@ import { Favicon } from "./components/favicon";
|
|||||||
import { SpoolmanLayout } from "./components/layout";
|
import { SpoolmanLayout } from "./components/layout";
|
||||||
import liveProvider from "./components/liveProvider";
|
import liveProvider from "./components/liveProvider";
|
||||||
import SpoolmanNotificationProvider from "./components/notificationProvider";
|
import SpoolmanNotificationProvider from "./components/notificationProvider";
|
||||||
|
import { ProtectedRoute } from "./components/protectedRoute";
|
||||||
|
import { AuthProvider, useAuth } from "./contexts/auth";
|
||||||
import { ColorModeContextProvider } from "./contexts/color-mode";
|
import { ColorModeContextProvider } from "./contexts/color-mode";
|
||||||
import { languages } from "./i18n";
|
import { languages } from "./i18n";
|
||||||
|
import { setupAxiosAuth } from "./utils/axiosAuth";
|
||||||
import { getAPIURL, getBasePath } from "./utils/url";
|
import { getAPIURL, getBasePath } from "./utils/url";
|
||||||
|
|
||||||
|
// Setup axios auth interceptors once at module load
|
||||||
|
setupAxiosAuth();
|
||||||
|
|
||||||
interface ResourcePageProps {
|
interface ResourcePageProps {
|
||||||
resource: "spools" | "filaments" | "vendors";
|
resource: "spools" | "filaments" | "vendors";
|
||||||
page: "list" | "create" | "edit" | "show";
|
page: "list" | "create" | "edit" | "show";
|
||||||
@@ -53,6 +60,11 @@ const LoadablePage = loadable((props: LoadablePageProps) => import(`./pages/${pr
|
|||||||
cacheKey: (props: LoadablePageProps) => `page-${props.name}`,
|
cacheKey: (props: LoadablePageProps) => `page-${props.name}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lazy load login page
|
||||||
|
const LoginPage = loadable(() => import("./pages/login/index"), {
|
||||||
|
fallback: <div>Loading...</div>,
|
||||||
|
});
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
@@ -88,10 +100,11 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter basename={getBasePath() + "/"}>
|
<BrowserRouter basename={getBasePath() + "/"}>
|
||||||
<RefineKbarProvider>
|
<AuthProvider>
|
||||||
<ColorModeContextProvider>
|
<RefineKbarProvider>
|
||||||
<ConfigProvider locale={antdLocale}>
|
<ColorModeContextProvider>
|
||||||
<Refine
|
<ConfigProvider locale={antdLocale}>
|
||||||
|
<Refine
|
||||||
dataProvider={dataProvider(getAPIURL())}
|
dataProvider={dataProvider(getAPIURL())}
|
||||||
notificationProvider={SpoolmanNotificationProvider}
|
notificationProvider={SpoolmanNotificationProvider}
|
||||||
i18nProvider={i18nProvider}
|
i18nProvider={i18nProvider}
|
||||||
@@ -142,6 +155,14 @@ function App() {
|
|||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "print-queue",
|
||||||
|
list: "/print-queue",
|
||||||
|
meta: {
|
||||||
|
canDelete: false,
|
||||||
|
icon: <PrinterOutlined />,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "locations",
|
name: "locations",
|
||||||
list: "/locations",
|
list: "/locations",
|
||||||
@@ -174,11 +195,17 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Login route - outside protected area */}
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<SpoolmanLayout>
|
<ProtectedRoute>
|
||||||
<Outlet />
|
<SpoolmanLayout>
|
||||||
</SpoolmanLayout>
|
<Outlet />
|
||||||
|
</SpoolmanLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<LoadablePage name="home" />} />
|
<Route index element={<LoadablePage name="home" />} />
|
||||||
@@ -225,6 +252,7 @@ function App() {
|
|||||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||||
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
||||||
|
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
|
||||||
<Route path="*" element={<ErrorComponent />} />
|
<Route path="*" element={<ErrorComponent />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -235,9 +263,10 @@ function App() {
|
|||||||
<ReactQueryDevtools />
|
<ReactQueryDevtools />
|
||||||
<Favicon url={getBasePath() + "/favicon.svg"} />
|
<Favicon url={getBasePath() + "/favicon.svg"} />
|
||||||
</Refine>
|
</Refine>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</ColorModeContextProvider>
|
</ColorModeContextProvider>
|
||||||
</RefineKbarProvider>
|
</RefineKbarProvider>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
client/src/components/LocationCreateModal.tsx
Normal file
143
client/src/components/LocationCreateModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useTranslate } from "@refinedev/core";
|
||||||
|
import { Form, Input, Modal, Select, message } from "antd";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getAPIURL } from "../utils/url";
|
||||||
|
|
||||||
|
interface LocationTreeNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
location_type?: string;
|
||||||
|
children: LocationTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationCreateModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
parentId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCATION_TYPES = ["room", "shelf", "bin", "drawer", "box", "other"];
|
||||||
|
|
||||||
|
function flattenTree(nodes: LocationTreeNode[], prefix = ""): { value: number; label: string }[] {
|
||||||
|
const result: { value: number; label: string }[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const label = prefix ? `${prefix} > ${node.name}` : node.name;
|
||||||
|
result.push({ value: node.id, label });
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
result.push(...flattenTree(node.children, label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationCreateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
parentId,
|
||||||
|
}: LocationCreateModalProps) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
const { data: treeData } = useQuery<LocationTreeNode[]>({
|
||||||
|
queryKey: ["locations", "tree"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(getAPIURL() + "/location/tree");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch location tree");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentOptions = treeData ? flattenTree(treeData) : [];
|
||||||
|
|
||||||
|
const handleSubmit = async (values: { name: string; parent_id?: number; location_type?: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(getAPIURL() + "/location", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || "Failed to create location");
|
||||||
|
}
|
||||||
|
|
||||||
|
messageApi.success(t("notifications.createSuccess", { resource: t("location.location") }));
|
||||||
|
form.resetFields();
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
messageApi.error(err instanceof Error ? err.message : "Failed to create location");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{contextHolder}
|
||||||
|
<Modal
|
||||||
|
title={t("location.titles.create")}
|
||||||
|
open={isOpen}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
onCancel={() => {
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
confirmLoading={loading}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
initialValues={{ parent_id: parentId }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t("location.fields.name")}
|
||||||
|
rules={[{ required: true, message: t("location.fields.name_required") }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t("location.fields.name_placeholder")} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="parent_id"
|
||||||
|
label={t("location.fields.parent")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder={t("location.fields.parent_placeholder")}
|
||||||
|
options={parentOptions}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label as string).toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="location_type"
|
||||||
|
label={t("location.fields.location_type")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder={t("location.fields.location_type_placeholder")}
|
||||||
|
options={LOCATION_TYPES.map((type) => ({
|
||||||
|
value: type,
|
||||||
|
label: t(`location.types.${type}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
client/src/components/LocationTreeSelect.tsx
Normal file
110
client/src/components/LocationTreeSelect.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import { useTranslate } from "@refinedev/core";
|
||||||
|
import { TreeSelect, Divider, Button, Spin } from "antd";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getAPIURL } from "../utils/url";
|
||||||
|
import { LocationCreateModal } from "./LocationCreateModal";
|
||||||
|
|
||||||
|
interface LocationTreeNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
location_type?: string;
|
||||||
|
description?: string;
|
||||||
|
children: LocationTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeDataNode {
|
||||||
|
value: number;
|
||||||
|
title: string;
|
||||||
|
children?: TreeDataNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformTree(nodes: LocationTreeNode[]): TreeDataNode[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
value: node.id,
|
||||||
|
title: node.location_type ? `${node.name} (${node.location_type})` : node.name,
|
||||||
|
children: node.children.length > 0 ? transformTree(node.children) : undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationTreeSelectProps {
|
||||||
|
value?: number;
|
||||||
|
onChange?: (value: number | undefined) => void;
|
||||||
|
allowCreate?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationTreeSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
allowCreate = true,
|
||||||
|
placeholder,
|
||||||
|
style,
|
||||||
|
}: LocationTreeSelectProps) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery<LocationTreeNode[]>({
|
||||||
|
queryKey: ["locations", "tree"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(getAPIURL() + "/location/tree");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch location tree");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const treeData = data ? transformTree(data) : [];
|
||||||
|
|
||||||
|
const handleCreateSuccess = () => {
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TreeSelect
|
||||||
|
style={style}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
treeData={treeData}
|
||||||
|
placeholder={placeholder ?? t("spool.fields.location")}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
treeDefaultExpandAll
|
||||||
|
loading={isLoading}
|
||||||
|
notFoundContent={isLoading ? <Spin size="small" /> : undefined}
|
||||||
|
dropdownRender={(menu) => (
|
||||||
|
<>
|
||||||
|
{menu}
|
||||||
|
{allowCreate && (
|
||||||
|
<>
|
||||||
|
<Divider style={{ margin: "8px 0" }} />
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
{t("location.create")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
filterTreeNode={(input, treeNode) =>
|
||||||
|
(treeNode.title as string).toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LocationCreateModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -220,6 +220,52 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
|
|||||||
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
|
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ColorFilterColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
||||||
|
filterValueQuery: UseQueryResult<ColumnFilterItem[], unknown>;
|
||||||
|
colorGetter: (record: Obj) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorFilterColumn<Obj extends Entity>(props: ColorFilterColumnProps<Obj>) {
|
||||||
|
const query = props.filterValueQuery;
|
||||||
|
|
||||||
|
let filters: ColumnFilterItem[] = [];
|
||||||
|
if (query.data) {
|
||||||
|
filters = query.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedFilters = typeFilters<Obj>(props.tableState.filters);
|
||||||
|
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
|
||||||
|
|
||||||
|
const onFilterDropdownOpen = () => {
|
||||||
|
query.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return Column({
|
||||||
|
...props,
|
||||||
|
filters,
|
||||||
|
filteredValue,
|
||||||
|
onFilterDropdownOpen,
|
||||||
|
loadingFilters: query.isLoading,
|
||||||
|
allowMultipleFilters: true,
|
||||||
|
render: (_: unknown, record: Obj) => {
|
||||||
|
const colorHex = props.colorGetter(record);
|
||||||
|
if (!colorHex) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "1.5em",
|
||||||
|
height: "1.5em",
|
||||||
|
backgroundColor: "#" + colorHex,
|
||||||
|
borderRadius: "3px",
|
||||||
|
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
|
||||||
|
}}
|
||||||
|
title={"#" + colorHex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
||||||
unit: string;
|
unit: string;
|
||||||
maxDecimals?: number;
|
maxDecimals?: number;
|
||||||
@@ -394,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;
|
||||||
|
|||||||
166
client/src/components/filamentColorLookup.tsx
Normal file
166
client/src/components/filamentColorLookup.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useTranslate } from "@refinedev/core";
|
import { useTranslate } from "@refinedev/core";
|
||||||
import { Form, Modal, Select, Tabs } from "antd";
|
import { Form, Modal, Select } from "antd";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import { formatFilamentLabel } from "../pages/spools/functions";
|
import { formatFilamentLabel } from "../pages/spools/functions";
|
||||||
import { searchMatches } from "../utils/filtering";
|
import { searchMatches } from "../utils/filtering";
|
||||||
import { ExternalFilament, ExternalSource, useGetExternalDBFilaments } from "../utils/queryExternalDB";
|
import { ExternalFilament, useGetExternalDBFilaments } from "../utils/queryExternalDB";
|
||||||
|
|
||||||
export function FilamentImportModal(props: {
|
export function FilamentImportModal(props: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,25 +13,14 @@ export function FilamentImportModal(props: {
|
|||||||
}) {
|
}) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const [selectedSource, setSelectedSource] = useState<ExternalSource | "all">("all");
|
|
||||||
|
|
||||||
const externalFilaments = useGetExternalDBFilaments();
|
const externalFilaments = useGetExternalDBFilaments();
|
||||||
|
|
||||||
// Filter and format filaments based on selected source
|
// Format filaments for the select dropdown
|
||||||
const filamentOptions = useMemo(() => {
|
const filamentOptions = useMemo(() => {
|
||||||
const filtered = externalFilaments.data?.filter((item) => {
|
const options = (externalFilaments.data ?? []).map((item) => {
|
||||||
if (selectedSource === "all") return true;
|
|
||||||
return item.source === selectedSource;
|
|
||||||
}) ?? [];
|
|
||||||
|
|
||||||
const options = filtered.map((item) => {
|
|
||||||
// Add source indicator to label if showing all
|
|
||||||
const sourceIndicator = selectedSource === "all" && item.source === ExternalSource.FILAMENT_PROFILES_3D
|
|
||||||
? "[3DFP] "
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: sourceIndicator + formatFilamentLabel(
|
label: formatFilamentLabel(
|
||||||
item.name,
|
item.name,
|
||||||
item.diameter,
|
item.diameter,
|
||||||
item.manufacturer,
|
item.manufacturer,
|
||||||
@@ -46,20 +35,8 @@ export function FilamentImportModal(props: {
|
|||||||
|
|
||||||
options.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
options.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
|
||||||
return options;
|
return options;
|
||||||
}, [externalFilaments.data, selectedSource]);
|
|
||||||
|
|
||||||
// Count filaments by source
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const spoolmandb = externalFilaments.data?.filter(f => f.source === ExternalSource.SPOOLMANDB || !f.source).length ?? 0;
|
|
||||||
const threeDFP = externalFilaments.data?.filter(f => f.source === ExternalSource.FILAMENT_PROFILES_3D).length ?? 0;
|
|
||||||
return { spoolmandb, threeDFP, total: spoolmandb + threeDFP };
|
|
||||||
}, [externalFilaments.data]);
|
}, [externalFilaments.data]);
|
||||||
|
|
||||||
const handleSourceChange = (key: string) => {
|
|
||||||
setSelectedSource(key as ExternalSource | "all");
|
|
||||||
form.resetFields(["filament"]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("filament.form.import_external")}
|
title={t("filament.form.import_external")}
|
||||||
@@ -77,25 +54,6 @@ export function FilamentImportModal(props: {
|
|||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Tabs
|
|
||||||
activeKey={selectedSource}
|
|
||||||
onChange={handleSourceChange}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: "all",
|
|
||||||
label: `${t("filament.form.all_sources")} (${counts.total})`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: ExternalSource.SPOOLMANDB,
|
|
||||||
label: `SpoolmanDB (${counts.spoolmandb})`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: ExternalSource.FILAMENT_PROFILES_3D,
|
|
||||||
label: `3D Filament Profiles (${counts.threeDFP})`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
form={form}
|
form={form}
|
||||||
@@ -107,7 +65,6 @@ export function FilamentImportModal(props: {
|
|||||||
props.onImport(filament);
|
props.onImport(filament);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setSelectedSource("all");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="filament" rules={[{ required: true }]}>
|
<Form.Item name="filament" rules={[{ required: true }]}>
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
|
|||||||
tooltipParts.push(
|
tooltipParts.push(
|
||||||
<div
|
<div
|
||||||
key="color"
|
key="color"
|
||||||
|
className="color-swatch-mini"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: ".4em",
|
borderRadius: ".4em",
|
||||||
width: "1.4em",
|
width: "1.4em",
|
||||||
height: "1.4em",
|
height: "1.4em",
|
||||||
backgroundColor: "#" + filament.color_hex,
|
backgroundColor: "#" + filament.color_hex,
|
||||||
|
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
);
|
);
|
||||||
@@ -201,3 +203,36 @@ export function useSpoolmanLocations(enabled: boolean = false) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSpoolmanColors(enabled: boolean = false) {
|
||||||
|
return useQuery<string[], unknown, ColumnFilterItem[]>({
|
||||||
|
enabled: enabled,
|
||||||
|
queryKey: ["colors"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(getAPIURL() + "/color");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Network response was not ok");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
select: (data) => {
|
||||||
|
return data.map((colorHex) => ({
|
||||||
|
text: (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "1.2em",
|
||||||
|
height: "1.2em",
|
||||||
|
backgroundColor: "#" + colorHex,
|
||||||
|
borderRadius: "2px",
|
||||||
|
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}>#{colorHex}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: colorHex,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
28
client/src/components/protectedRoute.tsx
Normal file
28
client/src/components/protectedRoute.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Spin } from "antd";
|
||||||
|
import React from "react";
|
||||||
|
import { Navigate } from "react-router";
|
||||||
|
import { useAuth } from "../contexts/auth";
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
|
const { isAuthenticated, isLoading, authStatus } = useAuth();
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100vh" }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If auth is enabled but not authenticated, redirect to login
|
||||||
|
if (authStatus?.enabled && !isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -22,7 +22,12 @@
|
|||||||
.spool-icon * {
|
.spool-icon * {
|
||||||
flex: 1 1 0px;
|
flex: 1 1 0px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
border: #44444430 solid 2px;
|
border: rgba(68, 68, 68, 0.3) solid 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light border for dark mode to make dark colors visible */
|
||||||
|
body[data-theme="dark"] .spool-icon * {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spool-icon.vertical *:first-child {
|
.spool-icon.vertical *:first-child {
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
|
import { CopyOutlined } from "@ant-design/icons";
|
||||||
|
import { message, Tooltip } from "antd";
|
||||||
|
import { useTranslate } from "@refinedev/core";
|
||||||
import "./spoolIcon.css";
|
import "./spoolIcon.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
color: string | { colors: string[]; vertical: boolean };
|
color: string | { colors: string[]; vertical: boolean };
|
||||||
size?: "small" | "large";
|
size?: "small" | "large";
|
||||||
no_margin? : boolean
|
no_margin?: boolean;
|
||||||
|
showHex?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SpoolIcon(props: Readonly<Props>) {
|
export default function SpoolIcon(props: Readonly<Props>) {
|
||||||
|
const t = useTranslate();
|
||||||
let dirClass = "vertical";
|
let dirClass = "vertical";
|
||||||
let cols = [];
|
let cols: string[] = [];
|
||||||
let size = props.size ? props.size : "small";
|
const size = props.size ? props.size : "small";
|
||||||
let no_margin = props.no_margin ? "no-margin" : "";
|
const no_margin = props.no_margin ? "no-margin" : "";
|
||||||
|
|
||||||
if (typeof props.color === "string") {
|
if (typeof props.color === "string") {
|
||||||
cols = [props.color];
|
cols = [props.color];
|
||||||
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
|
|||||||
cols = props.color.colors;
|
cols = props.color.colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Normalize hex codes (remove # if present, uppercase)
|
||||||
|
const normalizedCols = cols.map((col) => col.replace("#", "").toUpperCase());
|
||||||
|
const hexDisplay = normalizedCols.length === 1 ? `#${normalizedCols[0]}` : normalizedCols.map((c) => `#${c}`).join(", ");
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(hexDisplay);
|
||||||
|
message.success(t("color.copied", { color: hexDisplay }));
|
||||||
|
} catch {
|
||||||
|
message.error(t("color.copy_failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const swatchContent = (
|
||||||
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
|
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
|
||||||
{cols.map((col) => (
|
{normalizedCols.map((col) => (
|
||||||
<div
|
<div
|
||||||
key={col}
|
key={col}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#" + col.replace("#", ""),
|
backgroundColor: "#" + col,
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (props.showHex) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={`${t("color.click_to_copy")}: ${hexDisplay}`}>
|
||||||
|
<div
|
||||||
|
className="spool-icon-with-hex"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
{swatchContent}
|
||||||
|
<span className="hex-code" style={{ fontFamily: "monospace", fontSize: "0.85em", opacity: 0.8 }}>
|
||||||
|
{hexDisplay}
|
||||||
|
<CopyOutlined style={{ marginLeft: 4, fontSize: "0.9em" }} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return swatchContent;
|
||||||
}
|
}
|
||||||
|
|||||||
174
client/src/contexts/auth/index.tsx
Normal file
174
client/src/contexts/auth/index.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { getAPIURL } from "../../utils/url";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "editor" | "operator" | "viewer";
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
has_users: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
authStatus: AuthStatus | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
setup: (username: string, password: string) => Promise<void>;
|
||||||
|
refreshAuthStatus: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const TOKEN_KEY = "spoolman_auth_token";
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [authStatus, setAuthStatus] = useState<AuthStatus | null>(null);
|
||||||
|
|
||||||
|
const apiUrl = getAPIURL();
|
||||||
|
|
||||||
|
// Fetch auth status
|
||||||
|
const refreshAuthStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}/auth/status`);
|
||||||
|
if (res.ok) {
|
||||||
|
const status = await res.json();
|
||||||
|
setAuthStatus(status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch auth status:", err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
// Fetch current user if we have a token
|
||||||
|
const fetchUser = useCallback(async (authToken: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const userData = await res.json();
|
||||||
|
setUser(userData);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Token invalid, clear it
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch user:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
// Initialize auth state
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const status = await refreshAuthStatus();
|
||||||
|
|
||||||
|
if (status?.enabled && token) {
|
||||||
|
await fetchUser(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, [refreshAuthStatus, fetchUser, token]);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append("username", username);
|
||||||
|
formData.append("password", password);
|
||||||
|
|
||||||
|
const res = await fetch(`${apiUrl}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token);
|
||||||
|
setToken(data.access_token);
|
||||||
|
await fetchUser(data.access_token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = async (username: string, password: string) => {
|
||||||
|
const res = await fetch(`${apiUrl}/auth/setup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Setup failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// After setup, log in
|
||||||
|
await login(username, password);
|
||||||
|
await refreshAuthStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAuthenticated = !!user || !authStatus?.enabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
authStatus,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setup,
|
||||||
|
refreshAuthStatus,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to check if user has minimum role
|
||||||
|
export const hasRole = (user: User | null, minRole: "viewer" | "operator" | "editor" | "admin"): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
const roleHierarchy = { viewer: 0, operator: 1, editor: 2, admin: 3 };
|
||||||
|
return roleHierarchy[user.role] >= roleHierarchy[minRole];
|
||||||
|
};
|
||||||
@@ -32,6 +32,8 @@ export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem("colorMode", mode);
|
window.localStorage.setItem("colorMode", mode);
|
||||||
|
// Set data attribute on body for CSS targeting
|
||||||
|
document.body.setAttribute("data-theme", mode);
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { PlusOutlined } from "@ant-design/icons";
|
|||||||
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";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||||
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,
|
||||||
@@ -20,6 +21,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ".
|
|||||||
import { ExternalFilament } from "../../utils/queryExternalDB";
|
import { ExternalFilament } from "../../utils/queryExternalDB";
|
||||||
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 { useFormShortcuts } from "../../utils/useFormShortcuts";
|
||||||
import { getOrCreateVendorFromExternal } from "../vendors/functions";
|
import { getOrCreateVendorFromExternal } from "../vendors/functions";
|
||||||
import { IVendor } from "../vendors/model";
|
import { IVendor } from "../vendors/model";
|
||||||
import { IFilament, IFilamentParsedExtras } from "./model";
|
import { IFilament, IFilamentParsedExtras } from "./model";
|
||||||
@@ -83,6 +85,12 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
redirect(redirectTo);
|
redirect(redirectTo);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+S / Cmd+S to save
|
||||||
|
const handleSaveShortcut = useCallback(() => {
|
||||||
|
handleSubmit("list");
|
||||||
|
}, []);
|
||||||
|
useFormShortcuts(handleSaveShortcut);
|
||||||
|
|
||||||
const { selectProps: vendorSelect } = useSelect<IVendor>({
|
const { selectProps: vendorSelect } = useSelect<IVendor>({
|
||||||
resource: "vendor",
|
resource: "vendor",
|
||||||
optionLabel: "name",
|
optionLabel: "name",
|
||||||
@@ -241,19 +249,22 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{colorType == "single" && (
|
{colorType == "single" && (
|
||||||
<Form.Item
|
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||||
name={"color_hex"}
|
<Form.Item
|
||||||
rules={[
|
name={"color_hex"}
|
||||||
{
|
rules={[
|
||||||
required: false,
|
{
|
||||||
},
|
required: false,
|
||||||
]}
|
},
|
||||||
getValueFromEvent={(e) => {
|
]}
|
||||||
return e?.toHex();
|
getValueFromEvent={(e) => {
|
||||||
}}
|
return e?.toHex();
|
||||||
>
|
}}
|
||||||
<ColorPicker format="hex" />
|
>
|
||||||
</Form.Item>
|
<ColorPicker format="hex" />
|
||||||
|
</Form.Item>
|
||||||
|
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{colorType == "multi" && (
|
{colorType == "multi" && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -1,17 +1,68 @@
|
|||||||
|
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, useTranslate } from "@refinedev/core";
|
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||||
import { Alert, 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";
|
||||||
import TextArea from "antd/es/input/TextArea";
|
import TextArea from "antd/es/input/TextArea";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
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.
|
||||||
@@ -21,13 +72,21 @@ the form's onFinish method. Form.Item's normalize should do this, but it doesn't
|
|||||||
|
|
||||||
export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [hasChanged, setHasChanged] = useState(false);
|
const [hasChanged, setHasChanged] = useState(false);
|
||||||
const extraFields = useGetFields(EntityType.filament);
|
const extraFields = useGetFields(EntityType.filament);
|
||||||
const currency = useCurrency();
|
const currency = useCurrency();
|
||||||
const [colorType, setColorType] = useState<"single" | "multi">("single");
|
const [colorType, setColorType] = useState<"single" | "multi">("single");
|
||||||
|
|
||||||
const { formProps, saveButtonProps } = useForm<IFilament, HttpError, IFilament, IFilament>({
|
// Get all filament IDs for prev/next navigation
|
||||||
|
const { result: allFilaments } = useList<IFilament>({
|
||||||
|
resource: "filament",
|
||||||
|
pagination: { mode: "off" },
|
||||||
|
sorters: [{ field: "id", order: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
@@ -36,6 +95,17 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate prev/next IDs for navigation
|
||||||
|
const { prevId, nextId } = useMemo(() => {
|
||||||
|
if (!allFilaments?.data || !id) return { prevId: null, nextId: null };
|
||||||
|
const ids = allFilaments.data.map((f: IFilament) => f.id);
|
||||||
|
const currentIndex = ids.indexOf(Number(id));
|
||||||
|
return {
|
||||||
|
prevId: currentIndex > 0 ? ids[currentIndex - 1] : null,
|
||||||
|
nextId: currentIndex < ids.length - 1 ? ids[currentIndex + 1] : null,
|
||||||
|
};
|
||||||
|
}, [allFilaments, id]);
|
||||||
|
|
||||||
// Get vendor selection options
|
// Get vendor selection options
|
||||||
const { selectProps } = useSelect<IVendor>({
|
const { selectProps } = useSelect<IVendor>({
|
||||||
resource: "vendor",
|
resource: "vendor",
|
||||||
@@ -76,7 +146,28 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit
|
||||||
|
saveButtonProps={saveButtonProps}
|
||||||
|
headerButtons={({ defaultButtons }) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
disabled={!prevId}
|
||||||
|
onClick={() => prevId && navigate(`/filament/edit/${prevId}`)}
|
||||||
|
>
|
||||||
|
{t("buttons.prev")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
disabled={!nextId}
|
||||||
|
onClick={() => nextId && navigate(`/filament/edit/${nextId}`)}
|
||||||
|
>
|
||||||
|
{t("buttons.next")}
|
||||||
|
</Button>
|
||||||
|
{defaultButtons}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Form {...formProps} layout="vertical">
|
<Form {...formProps} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -158,19 +249,22 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{colorType == "single" && (
|
{colorType == "single" && (
|
||||||
<Form.Item
|
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||||
name={"color_hex"}
|
<Form.Item
|
||||||
rules={[
|
name={"color_hex"}
|
||||||
{
|
rules={[
|
||||||
required: false,
|
{
|
||||||
},
|
required: false,
|
||||||
]}
|
},
|
||||||
getValueFromEvent={(e) => {
|
]}
|
||||||
return e?.toHex();
|
getValueFromEvent={(e) => {
|
||||||
}}
|
return e?.toHex();
|
||||||
>
|
}}
|
||||||
<ColorPicker />
|
>
|
||||||
</Form.Item>
|
<ColorPicker />
|
||||||
|
</Form.Item>
|
||||||
|
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{colorType == "multi" && (
|
{colorType == "multi" && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -288,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
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
|
|||||||
"article_number",
|
"article_number",
|
||||||
"settings_extruder_temp",
|
"settings_extruder_temp",
|
||||||
"settings_bed_temp",
|
"settings_bed_temp",
|
||||||
|
"spool_count",
|
||||||
|
"total_remaining_weight",
|
||||||
"registered",
|
"registered",
|
||||||
"comment",
|
"comment",
|
||||||
];
|
];
|
||||||
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
maxDecimals: 0,
|
maxDecimals: 0,
|
||||||
width: 100,
|
width: 100,
|
||||||
}),
|
}),
|
||||||
|
NumberColumn({
|
||||||
|
...commonProps,
|
||||||
|
id: "spool_count",
|
||||||
|
i18ncat: "filament",
|
||||||
|
unit: "",
|
||||||
|
maxDecimals: 0,
|
||||||
|
width: 80,
|
||||||
|
}),
|
||||||
|
NumberColumn({
|
||||||
|
...commonProps,
|
||||||
|
id: "total_remaining_weight",
|
||||||
|
i18ncat: "filament",
|
||||||
|
unit: "g",
|
||||||
|
maxDecimals: 0,
|
||||||
|
width: 120,
|
||||||
|
}),
|
||||||
DateColumn({
|
DateColumn({
|
||||||
...commonProps,
|
...commonProps,
|
||||||
id: "registered",
|
id: "registered",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface IFilament {
|
|||||||
multi_color_direction?: string;
|
multi_color_direction?: string;
|
||||||
external_id?: string;
|
external_id?: string;
|
||||||
extra: { [key: string]: string };
|
extra: { [key: string]: string };
|
||||||
|
spool_count?: number;
|
||||||
|
total_remaining_weight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
|
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
|
|||||||
<Title level={5}>{t("filament.fields.name")}</Title>
|
<Title level={5}>{t("filament.fields.name")}</Title>
|
||||||
<TextField value={record?.name} />
|
<TextField value={record?.name} />
|
||||||
<Title level={5}>{t("filament.fields.color_hex")}</Title>
|
<Title level={5}>{t("filament.fields.color_hex")}</Title>
|
||||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
|
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
|
||||||
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
|
|
||||||
<Title level={5}>{t("filament.fields.material")}</Title>
|
<Title level={5}>{t("filament.fields.material")}</Title>
|
||||||
<TextField value={record?.material} />
|
<TextField value={record?.material} />
|
||||||
<Title level={5}>{t("filament.fields.price")}</Title>
|
<Title level={5}>{t("filament.fields.price")}</Title>
|
||||||
|
|||||||
119
client/src/pages/home/components/AlertCards.tsx
Normal file
119
client/src/pages/home/components/AlertCards.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { ExclamationCircleOutlined, PrinterOutlined, DashboardOutlined } from "@ant-design/icons";
|
||||||
|
import { useList, useTranslate } from "@refinedev/core";
|
||||||
|
import { Badge, Card, Col, Row, theme } from "antd";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { ISpool } from "../../spools/model";
|
||||||
|
import { IPrintJob } from "../../spools/model";
|
||||||
|
|
||||||
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
const LOW_STOCK_THRESHOLD = 100;
|
||||||
|
|
||||||
|
interface AlertCardProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
to: string;
|
||||||
|
color: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertCard({ icon, label, count, to, color, loading }: AlertCardProps) {
|
||||||
|
const { token } = useToken();
|
||||||
|
|
||||||
|
if (count === 0 && !loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Link to={to}>
|
||||||
|
<Badge count={count} style={{ backgroundColor: color }} overflowCount={99}>
|
||||||
|
<Card size="small" hoverable loading={loading}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ color, fontSize: 18 }}>{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertCardsProps {
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertCards({ compact }: AlertCardsProps) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const { token } = useToken();
|
||||||
|
|
||||||
|
// Low stock spools
|
||||||
|
const { result: lowStockResult, query: lowStockQuery } = useList<ISpool>({
|
||||||
|
resource: "spool",
|
||||||
|
pagination: { pageSize: 1 },
|
||||||
|
meta: {
|
||||||
|
queryParams: {
|
||||||
|
allow_archived: false,
|
||||||
|
remaining_weight_lt: LOW_STOCK_THRESHOLD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pending print jobs
|
||||||
|
const { result: pendingJobsResult, query: pendingJobsQuery } = useList<IPrintJob>({
|
||||||
|
resource: "print-job",
|
||||||
|
pagination: { pageSize: 1 },
|
||||||
|
filters: [{ field: "status", operator: "eq", value: "pending" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spools needing weighing
|
||||||
|
const { result: needsWeighingResult, query: needsWeighingQuery } = useList<ISpool>({
|
||||||
|
resource: "spool",
|
||||||
|
pagination: { pageSize: 1 },
|
||||||
|
meta: {
|
||||||
|
queryParams: {
|
||||||
|
allow_archived: false,
|
||||||
|
needs_weighing: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alerts = [
|
||||||
|
{
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
label: t("home.alerts.low_stock"),
|
||||||
|
count: lowStockResult?.total ?? 0,
|
||||||
|
to: `/spool?remaining_weight_lt=${LOW_STOCK_THRESHOLD}`,
|
||||||
|
color: token.colorError,
|
||||||
|
loading: lowStockQuery.isLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PrinterOutlined />,
|
||||||
|
label: t("home.alerts.pending_jobs"),
|
||||||
|
count: pendingJobsResult?.total ?? 0,
|
||||||
|
to: "/print-queue",
|
||||||
|
color: token.colorWarning,
|
||||||
|
loading: pendingJobsQuery.isLoading,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
label: t("home.alerts.needs_weighing"),
|
||||||
|
count: needsWeighingResult?.total ?? 0,
|
||||||
|
to: "/spool?needs_weighing=true",
|
||||||
|
color: token.colorInfo,
|
||||||
|
loading: needsWeighingQuery.isLoading,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleAlerts = alerts.filter((a) => a.count > 0 || a.loading);
|
||||||
|
|
||||||
|
if (visibleAlerts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={[compact ? 8 : 12, compact ? 8 : 12]} style={{ marginTop: compact ? 8 : 16 }}>
|
||||||
|
{visibleAlerts.map((alert, i) => (
|
||||||
|
<AlertCard key={i} {...alert} />
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
client/src/pages/home/components/MaterialDistributionChart.tsx
Normal file
105
client/src/pages/home/components/MaterialDistributionChart.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useList, useTranslate } from "@refinedev/core";
|
||||||
|
import { Card, theme } from "antd";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
|
||||||
|
import { ISpool } from "../../spools/model";
|
||||||
|
|
||||||
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
// Color palette for the pie chart
|
||||||
|
const COLORS = [
|
||||||
|
"#1890ff", // blue
|
||||||
|
"#52c41a", // green
|
||||||
|
"#faad14", // gold
|
||||||
|
"#f5222d", // red
|
||||||
|
"#722ed1", // purple
|
||||||
|
"#13c2c2", // cyan
|
||||||
|
"#eb2f96", // magenta
|
||||||
|
"#fa8c16", // orange
|
||||||
|
"#a0d911", // lime
|
||||||
|
"#2f54eb", // geekblue
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MaterialDistributionChartProps {
|
||||||
|
square?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialDistributionChart({ square }: MaterialDistributionChartProps) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const { token } = useToken();
|
||||||
|
|
||||||
|
// Fetch all spools for aggregation
|
||||||
|
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
|
||||||
|
resource: "spool",
|
||||||
|
pagination: { pageSize: 1000 },
|
||||||
|
meta: {
|
||||||
|
queryParams: {
|
||||||
|
allow_archived: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate by material
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const data = spoolsResult?.data || [];
|
||||||
|
const materialCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
data.forEach((spool) => {
|
||||||
|
const material = spool.filament.material || t("unknown");
|
||||||
|
materialCounts[material] = (materialCounts[material] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(materialCounts)
|
||||||
|
.map(([name, value]) => ({ name, value }))
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 10); // Top 10 materials
|
||||||
|
}, [spoolsResult?.data, t]);
|
||||||
|
|
||||||
|
if (spoolsQuery.isLoading || chartData.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t("home.charts.material_distribution")}
|
||||||
|
size="small"
|
||||||
|
style={square ? { aspectRatio: "1", display: "flex", flexDirection: "column" } : { marginBottom: 16 }}
|
||||||
|
styles={square ? { body: { flex: 1, padding: "8px 12px", display: "flex", flexDirection: "column" } } : undefined}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={square ? "100%" : 250}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={square ? 40 : 50}
|
||||||
|
outerRadius={square ? 70 : 80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
label={square ? undefined : ({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||||
|
labelLine={square ? false : { stroke: token.colorTextSecondary }}
|
||||||
|
>
|
||||||
|
{chartData.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [`${value} spools`, t("spool.spool")]}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: token.colorBgElevated,
|
||||||
|
border: `1px solid ${token.colorBorder}`,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{square && (
|
||||||
|
<Legend
|
||||||
|
layout="horizontal"
|
||||||
|
verticalAlign="bottom"
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
client/src/pages/home/components/QuickStats.tsx
Normal file
169
client/src/pages/home/components/QuickStats.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { DollarOutlined, HighlightOutlined, InboxOutlined, DatabaseOutlined, UserOutlined, RiseOutlined } from "@ant-design/icons";
|
||||||
|
import { useList, useTranslate } from "@refinedev/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, Col, Row, Statistic, theme } from "antd";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { IFilament } from "../../filaments/model";
|
||||||
|
import { ISpool } from "../../spools/model";
|
||||||
|
import { IVendor } from "../../vendors/model";
|
||||||
|
import { useCurrencyFormatter } from "../../../utils/settings";
|
||||||
|
import { getAPIURL } from "../../../utils/url";
|
||||||
|
|
||||||
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
interface QuickStatsProps {
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickStats({ compact }: QuickStatsProps) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const { token } = useToken();
|
||||||
|
const currencyFormatter = useCurrencyFormatter();
|
||||||
|
|
||||||
|
const { result: filamentsResult, query: filamentsQuery } = useList<IFilament>({
|
||||||
|
resource: "filament",
|
||||||
|
pagination: { pageSize: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: vendorsResult, query: vendorsQuery } = useList<IVendor>({
|
||||||
|
resource: "vendor",
|
||||||
|
pagination: { pageSize: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all spools for total weight calculation
|
||||||
|
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
|
||||||
|
resource: "spool",
|
||||||
|
pagination: { pageSize: 1000 },
|
||||||
|
meta: {
|
||||||
|
queryParams: {
|
||||||
|
allow_archived: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total remaining weight
|
||||||
|
const totalWeight = useMemo(() => {
|
||||||
|
const data = spoolsResult?.data || [];
|
||||||
|
return data.reduce((sum, spool) => sum + (spool.remaining_weight ?? 0), 0);
|
||||||
|
}, [spoolsResult?.data]);
|
||||||
|
|
||||||
|
// Calculate total inventory value
|
||||||
|
const totalValue = useMemo(() => {
|
||||||
|
const data = spoolsResult?.data || [];
|
||||||
|
return data.reduce((sum, spool) => sum + (spool.remaining_value ?? 0), 0);
|
||||||
|
}, [spoolsResult?.data]);
|
||||||
|
|
||||||
|
// Fetch cost stats from backend
|
||||||
|
const { data: costStats, isLoading: costLoading } = useQuery<{
|
||||||
|
total_spent: number;
|
||||||
|
total_weight_purchased: number;
|
||||||
|
avg_cost_per_kg: number | null;
|
||||||
|
}>({
|
||||||
|
queryKey: ["analytics", "cost"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`${getAPIURL()}/analytics/cost`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatWeight = (grams: number) => {
|
||||||
|
if (grams >= 1000) {
|
||||||
|
return `${(grams / 1000).toFixed(1)} kg`;
|
||||||
|
}
|
||||||
|
return `${Math.round(grams)} g`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: t("home.quick_stats.total_filaments"),
|
||||||
|
value: filamentsResult?.total ?? 0,
|
||||||
|
loading: filamentsQuery.isLoading,
|
||||||
|
prefix: <HighlightOutlined />,
|
||||||
|
to: "/filament",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.quick_stats.total_vendors"),
|
||||||
|
value: vendorsResult?.total ?? 0,
|
||||||
|
loading: vendorsQuery.isLoading,
|
||||||
|
prefix: <UserOutlined />,
|
||||||
|
to: "/vendor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.quick_stats.total_weight"),
|
||||||
|
value: formatWeight(totalWeight),
|
||||||
|
loading: spoolsQuery.isLoading,
|
||||||
|
prefix: <DatabaseOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.quick_stats.total_value"),
|
||||||
|
value: currencyFormatter.format(totalValue),
|
||||||
|
loading: spoolsQuery.isLoading,
|
||||||
|
prefix: <DollarOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.quick_stats.total_spent"),
|
||||||
|
value: currencyFormatter.format(costStats?.total_spent ?? 0),
|
||||||
|
loading: costLoading,
|
||||||
|
prefix: <DollarOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.quick_stats.avg_cost_per_kg"),
|
||||||
|
value: costStats?.avg_cost_per_kg != null ? currencyFormatter.format(costStats.avg_cost_per_kg) : "-",
|
||||||
|
loading: costLoading,
|
||||||
|
prefix: <RiseOutlined />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
{stats.map((stat, i) => (
|
||||||
|
<Col xs={12} key={i}>
|
||||||
|
<Card size="small" styles={{ body: { padding: "8px 12px" } }}>
|
||||||
|
<Statistic
|
||||||
|
title={stat.title}
|
||||||
|
value={stat.value}
|
||||||
|
loading={stat.loading}
|
||||||
|
prefix={stat.prefix}
|
||||||
|
valueStyle={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={[12, 12]} style={{ marginTop: 16 }}>
|
||||||
|
<Col xs={12} md={8} lg={4}>
|
||||||
|
<Link to="/spool">
|
||||||
|
<Card hoverable size="small">
|
||||||
|
<Statistic
|
||||||
|
title={t("home.quick_stats.total_spools")}
|
||||||
|
value={spoolsResult?.total ?? 0}
|
||||||
|
loading={spoolsQuery.isLoading}
|
||||||
|
prefix={<InboxOutlined />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</Col>
|
||||||
|
{stats.map((stat, i) => (
|
||||||
|
<Col xs={12} md={8} lg={4} key={i}>
|
||||||
|
{stat.to ? (
|
||||||
|
<Link to={stat.to}>
|
||||||
|
<Card hoverable size="small">
|
||||||
|
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic title={stat.title} value={stat.value} loading={stat.loading} prefix={stat.prefix} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
client/src/pages/home/components/UsageAnalytics.tsx
Normal file
153
client/src/pages/home/components/UsageAnalytics.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,129 +1,179 @@
|
|||||||
import { FileOutlined, HighlightOutlined, PlusOutlined, UnorderedListOutlined, UserOutlined } from "@ant-design/icons";
|
import {
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
import { IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||||
import { Card, Col, Row, Statistic, theme } from "antd";
|
import { Button, Card, Col, Pagination, Row, Statistic, Table, theme, Tooltip } from "antd";
|
||||||
import { Content } from "antd/es/layout/layout";
|
import { Content } from "antd/es/layout/layout";
|
||||||
import Title from "antd/es/typography/Title";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import React, { ReactNode } from "react";
|
import React, { useState } from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { Link } from "react-router";
|
import SpoolIcon from "../../components/spoolIcon";
|
||||||
import Logo from "../../icon.svg?react";
|
|
||||||
import { ISpool } from "../spools/model";
|
import { ISpool } from "../spools/model";
|
||||||
|
import { AlertCards } from "./components/AlertCards";
|
||||||
|
import { MaterialDistributionChart } from "./components/MaterialDistributionChart";
|
||||||
|
import { QuickStats } from "./components/QuickStats";
|
||||||
|
import { UsageAnalytics } from "./components/UsageAnalytics";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
const { useToken } = theme;
|
const { useToken } = theme;
|
||||||
|
|
||||||
|
const LOW_STOCK_THRESHOLD = 100; // grams
|
||||||
|
|
||||||
export const Home: React.FC<IResourceComponentsProps> = () => {
|
export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||||
const { token } = useToken();
|
const { token } = useToken();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const spools = useList<ISpool>({
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
// Fetch all spools (not archived) for the main list
|
||||||
|
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
|
||||||
resource: "spool",
|
resource: "spool",
|
||||||
pagination: { pageSize: 1 },
|
pagination: { currentPage, pageSize },
|
||||||
});
|
sorters: [{ field: "last_used", order: "desc" }],
|
||||||
const filaments = useList<ISpool>({
|
meta: {
|
||||||
resource: "filament",
|
queryParams: {
|
||||||
pagination: { pageSize: 1 },
|
allow_archived: false,
|
||||||
});
|
},
|
||||||
const vendors = useList<ISpool>({
|
},
|
||||||
resource: "vendor",
|
|
||||||
pagination: { pageSize: 1 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSpools = !spools.result || spools.result.data.length > 0;
|
const spoolColumns = [
|
||||||
|
{
|
||||||
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => (
|
title: "",
|
||||||
<Col xs={12} md={6}>
|
dataIndex: "color",
|
||||||
<Card
|
key: "color",
|
||||||
loading={props.loading}
|
width: 50,
|
||||||
hoverable
|
render: (_: unknown, record: ISpool) => {
|
||||||
style={{ cursor: "pointer" }}
|
const colorObj = record.color_hex
|
||||||
styles={{ body: { padding: 0 } }}
|
? record.color_hex
|
||||||
actions={[
|
: record.filament.multi_color_hexes
|
||||||
<Link to={`/${props.resource}`} onClick={(e) => e.stopPropagation()}>
|
? {
|
||||||
<UnorderedListOutlined />
|
colors: record.filament.multi_color_hexes.split(","),
|
||||||
</Link>,
|
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||||
<Link to={`/${props.resource}/create`} onClick={(e) => e.stopPropagation()}>
|
}
|
||||||
<PlusOutlined />
|
: record.filament.color_hex;
|
||||||
</Link>,
|
return colorObj ? <SpoolIcon color={colorObj} /> : null;
|
||||||
]}
|
},
|
||||||
>
|
},
|
||||||
<Link to={`/${props.resource}`} style={{ display: "block", padding: "24px", color: "inherit" }}>
|
{
|
||||||
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
|
title: t("spool.fields.filament_name"),
|
||||||
</Link>
|
dataIndex: "filament",
|
||||||
</Card>
|
key: "filament",
|
||||||
</Col>
|
render: (_: unknown, record: ISpool) => {
|
||||||
);
|
const vendorName = record.filament.vendor?.name;
|
||||||
|
const filamentName = record.filament.name ?? `Filament #${record.filament.id}`;
|
||||||
|
return vendorName ? `${vendorName} - ${filamentName}` : filamentName;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.material"),
|
||||||
|
dataIndex: ["filament", "material"],
|
||||||
|
key: "material",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.remaining_weight"),
|
||||||
|
dataIndex: "remaining_weight",
|
||||||
|
key: "remaining_weight",
|
||||||
|
width: 120,
|
||||||
|
align: "right" as const,
|
||||||
|
render: (weight: number | undefined) => {
|
||||||
|
if (weight === undefined) return t("unknown");
|
||||||
|
const isLowStock = weight < LOW_STOCK_THRESHOLD;
|
||||||
|
return (
|
||||||
|
<span style={{ color: isLowStock ? token.colorError : undefined, fontWeight: isLowStock ? 600 : undefined }}>
|
||||||
|
{Math.round(weight)} g
|
||||||
|
{isLowStock && (
|
||||||
|
<Tooltip title={t("home.low_stock_warning")}>
|
||||||
|
<ExclamationCircleOutlined style={{ marginLeft: 6 }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Content
|
<Content
|
||||||
style={{
|
style={{
|
||||||
padding: "2em 20px",
|
padding: "1.5em 20px",
|
||||||
minHeight: 280,
|
minHeight: 280,
|
||||||
maxWidth: 800,
|
maxWidth: 900,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
backgroundColor: token.colorBgContainer,
|
backgroundColor: token.colorBgContainer,
|
||||||
borderRadius: token.borderRadiusLG,
|
borderRadius: token.borderRadiusLG,
|
||||||
color: token.colorText,
|
color: token.colorText,
|
||||||
fontFamily: token.fontFamily,
|
fontFamily: token.fontFamily,
|
||||||
fontSize: token.fontSizeLG,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title
|
{/* Top section: Pie chart + stats/alerts side by side */}
|
||||||
style={{
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
display: "flex",
|
<Col xs={24} md={10}>
|
||||||
alignItems: "center",
|
<MaterialDistributionChart square />
|
||||||
justifyContent: "center",
|
</Col>
|
||||||
fontSize: token.fontSizeHeading1,
|
<Col xs={24} md={14}>
|
||||||
}}
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||||
>
|
<Col>
|
||||||
<div
|
<Statistic
|
||||||
style={{
|
title={t("spool.spool")}
|
||||||
display: "inline-block",
|
value={spoolsResult?.total ?? 0}
|
||||||
height: "1.5em",
|
loading={spoolsQuery.isLoading}
|
||||||
marginRight: "0.5em",
|
valueStyle={{ fontSize: 36, fontWeight: 600 }}
|
||||||
}}
|
/>
|
||||||
>
|
</Col>
|
||||||
<Logo />
|
<Col>
|
||||||
</div>
|
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
|
||||||
Spoolman
|
{t("spool.titles.create")}
|
||||||
</Title>
|
</Button>
|
||||||
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
|
</Col>
|
||||||
<ResourceStatsCard
|
</Row>
|
||||||
resource="spool"
|
<QuickStats compact />
|
||||||
value={spools.result?.total || 0}
|
<AlertCards compact />
|
||||||
loading={spools.query.isLoading}
|
</Col>
|
||||||
icon={<FileOutlined />}
|
|
||||||
/>
|
|
||||||
<ResourceStatsCard
|
|
||||||
resource="filament"
|
|
||||||
value={filaments.result?.total || 0}
|
|
||||||
loading={filaments.query.isLoading}
|
|
||||||
icon={<HighlightOutlined />}
|
|
||||||
/>
|
|
||||||
<ResourceStatsCard
|
|
||||||
resource="vendor"
|
|
||||||
value={vendors.result?.total || 0}
|
|
||||||
loading={vendors.query.isLoading}
|
|
||||||
icon={<UserOutlined />}
|
|
||||||
/>
|
|
||||||
</Row>
|
</Row>
|
||||||
{!hasSpools && (
|
|
||||||
<>
|
{/* Spool list */}
|
||||||
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
|
<Card
|
||||||
<p>
|
title={t("home.all_spools")}
|
||||||
<Trans
|
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
|
||||||
i18nKey="home.description"
|
styles={{ body: { padding: 0 } }}
|
||||||
components={{
|
style={{ marginBottom: 16 }}
|
||||||
helpPageLink: <Link to="/help" />,
|
>
|
||||||
}}
|
<Table<ISpool>
|
||||||
/>
|
dataSource={spoolsResult?.data as ISpool[]}
|
||||||
</p>
|
columns={spoolColumns}
|
||||||
</>
|
rowKey="id"
|
||||||
)}
|
loading={spoolsQuery.isLoading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => navigate(`/spool/show/${record.id}`),
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "12px 16px", borderTop: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={spoolsResult?.total ?? 0}
|
||||||
|
onChange={setCurrentPage}
|
||||||
|
showSizeChanger={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Usage analytics & insights */}
|
||||||
|
<UsageAnalytics />
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
<AlertCards />
|
||||||
</Content>
|
</Content>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
98
client/src/pages/login/index.tsx
Normal file
98
client/src/pages/login/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { LockOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Card, Form, Input, message, Typography } from "antd";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useAuth } from "../../contexts/auth";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface LoginForm {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginPage: React.FC = () => {
|
||||||
|
const { login, setup, authStatus, refreshAuthStatus } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSetupMode, setIsSetupMode] = useState(false);
|
||||||
|
|
||||||
|
const needsSetup = authStatus?.enabled && !authStatus?.has_users;
|
||||||
|
|
||||||
|
const handleSubmit = async (values: LoginForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (needsSetup || isSetupMode) {
|
||||||
|
await setup(values.username, values.password);
|
||||||
|
message.success("Admin account created successfully!");
|
||||||
|
} else {
|
||||||
|
await login(values.username, values.password);
|
||||||
|
message.success("Logged in successfully!");
|
||||||
|
}
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "Authentication failed");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "#f0f2f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card style={{ width: 400, boxShadow: "0 4px 12px rgba(0,0,0,0.1)" }}>
|
||||||
|
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||||||
|
<Title level={2} style={{ marginBottom: 8 }}>
|
||||||
|
Spoolman
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
{needsSetup || isSetupMode ? "Create Admin Account" : "Sign in to continue"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form<LoginForm> name="login" onFinish={handleSubmit} layout="vertical" requiredMark={false}>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "Please enter your username" },
|
||||||
|
{ min: 3, message: "Username must be at least 3 characters" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input prefix={<UserOutlined />} placeholder="Username" size="large" autoFocus />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "Please enter your password" },
|
||||||
|
{ min: 6, message: "Password must be at least 6 characters" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password prefix={<LockOutlined />} placeholder="Password" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 12 }}>
|
||||||
|
<Button type="primary" htmlType="submit" size="large" block loading={isLoading}>
|
||||||
|
{needsSetup || isSetupMode ? "Create Account" : "Sign In"}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{needsSetup && (
|
||||||
|
<Text type="secondary" style={{ display: "block", textAlign: "center" }}>
|
||||||
|
This is the first time setup. Create an admin account to get started.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
456
client/src/pages/print-queue/index.tsx
Normal file
456
client/src/pages/print-queue/index.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { IResourceComponentsProps, useCustomMutation, useInvalidate, useList, useTranslate } from "@refinedev/core";
|
||||||
|
import { Badge, Button, Card, Col, Empty, Modal, Row, Space, Statistic, Table, Tag, theme, Tooltip } from "antd";
|
||||||
|
import { Content } from "antd/es/layout/layout";
|
||||||
|
import Title from "antd/es/typography/Title";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { IPrintJob, ISpool } from "../spools/model";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const { useToken } = theme;
|
||||||
|
const { confirm } = Modal;
|
||||||
|
|
||||||
|
export const PrintQueue: React.FC<IResourceComponentsProps> = () => {
|
||||||
|
const { token } = useToken();
|
||||||
|
const t = useTranslate();
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
const { mutate: customMutate } = useCustomMutation();
|
||||||
|
|
||||||
|
// Fetch print jobs
|
||||||
|
const { result: printJobs, query: printJobsQuery } = useList<IPrintJob>({
|
||||||
|
resource: "print-job",
|
||||||
|
pagination: { mode: "off" },
|
||||||
|
sorters: [{ field: "created", order: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch spools that need weighing
|
||||||
|
const { result: spoolsNeedingWeighing, query: spoolsQuery } = useList<ISpool>({
|
||||||
|
resource: "spool",
|
||||||
|
filters: [{ field: "needs_weighing", operator: "eq", value: true }],
|
||||||
|
pagination: { mode: "off" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Categorize jobs
|
||||||
|
const { pendingJobs, completedJobs, cancelledJobs } = useMemo(() => {
|
||||||
|
const jobs = printJobs?.data || [];
|
||||||
|
return {
|
||||||
|
pendingJobs: jobs.filter((j) => j.status === "pending"),
|
||||||
|
completedJobs: jobs.filter((j) => j.status === "completed").slice(0, 10),
|
||||||
|
cancelledJobs: jobs.filter((j) => j.status === "cancelled").slice(0, 10),
|
||||||
|
};
|
||||||
|
}, [printJobs]);
|
||||||
|
|
||||||
|
const handleComplete = (job: IPrintJob) => {
|
||||||
|
confirm({
|
||||||
|
title: t("print_queue.confirm_complete"),
|
||||||
|
content: t("print_queue.confirm_complete_description", { grams: job.filament_used_g.toFixed(1) }),
|
||||||
|
icon: <CheckCircleOutlined style={{ color: token.colorSuccess }} />,
|
||||||
|
okText: t("buttons.confirm"),
|
||||||
|
okType: "primary",
|
||||||
|
cancelText: t("buttons.cancel"),
|
||||||
|
onOk: () => {
|
||||||
|
customMutate({
|
||||||
|
url: `print-job/${job.id}/complete`,
|
||||||
|
method: "post",
|
||||||
|
values: {},
|
||||||
|
successNotification: {
|
||||||
|
message: t("print_queue.job_completed"),
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
errorNotification: {
|
||||||
|
message: t("print_queue.job_complete_error"),
|
||||||
|
type: "error",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (job: IPrintJob) => {
|
||||||
|
confirm({
|
||||||
|
title: t("print_queue.confirm_cancel"),
|
||||||
|
content: t("print_queue.confirm_cancel_description"),
|
||||||
|
icon: <ExclamationCircleOutlined style={{ color: token.colorWarning }} />,
|
||||||
|
okText: t("print_queue.cancel_job"),
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: t("buttons.cancel"),
|
||||||
|
onOk: () => {
|
||||||
|
customMutate({
|
||||||
|
url: `print-job/${job.id}/cancel`,
|
||||||
|
method: "post",
|
||||||
|
values: {},
|
||||||
|
successNotification: {
|
||||||
|
message: t("print_queue.job_cancelled"),
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
errorNotification: {
|
||||||
|
message: t("print_queue.job_cancel_error"),
|
||||||
|
type: "error",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (job: IPrintJob) => {
|
||||||
|
confirm({
|
||||||
|
title: t("print_queue.confirm_delete"),
|
||||||
|
content: t("print_queue.confirm_delete_description"),
|
||||||
|
icon: <DeleteOutlined style={{ color: token.colorError }} />,
|
||||||
|
okText: t("buttons.delete"),
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: t("buttons.cancel"),
|
||||||
|
onOk: () => {
|
||||||
|
customMutate({
|
||||||
|
url: `print-job/${job.id}`,
|
||||||
|
method: "delete",
|
||||||
|
values: {},
|
||||||
|
successNotification: {
|
||||||
|
message: t("print_queue.job_deleted"),
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearWeighingFlag = (spool: ISpool) => {
|
||||||
|
customMutate({
|
||||||
|
url: `spool/${spool.id}`,
|
||||||
|
method: "patch",
|
||||||
|
values: { needs_weighing: false },
|
||||||
|
successNotification: {
|
||||||
|
message: t("print_queue.weighing_cleared"),
|
||||||
|
type: "success",
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSpoolName = (job: IPrintJob) => {
|
||||||
|
if (job.spool?.filament) {
|
||||||
|
const fil = job.spool.filament;
|
||||||
|
if (fil.vendor?.name) {
|
||||||
|
return `${fil.vendor.name} - ${fil.name || fil.material}`;
|
||||||
|
}
|
||||||
|
return fil.name || fil.material || `Filament #${fil.id}`;
|
||||||
|
}
|
||||||
|
return `Spool #${job.spool_id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingColumns = [
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.filename"),
|
||||||
|
dataIndex: "filename",
|
||||||
|
key: "filename",
|
||||||
|
render: (filename: string) => (
|
||||||
|
<Space>
|
||||||
|
<FileOutlined />
|
||||||
|
{filename}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.spool"),
|
||||||
|
key: "spool",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.filament_used"),
|
||||||
|
key: "filament_used",
|
||||||
|
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.created"),
|
||||||
|
dataIndex: "created",
|
||||||
|
key: "created",
|
||||||
|
render: (created: string) => (
|
||||||
|
<Tooltip title={dayjs.utc(created).local().format("YYYY-MM-DD HH:mm:ss")}>
|
||||||
|
{dayjs.utc(created).fromNow()}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("table.actions"),
|
||||||
|
key: "actions",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
onClick={() => handleComplete(record)}
|
||||||
|
>
|
||||||
|
{t("print_queue.complete")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
onClick={() => handleCancel(record)}
|
||||||
|
>
|
||||||
|
{t("print_queue.cancel")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const historyColumns = [
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.filename"),
|
||||||
|
dataIndex: "filename",
|
||||||
|
key: "filename",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.spool"),
|
||||||
|
key: "spool",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.filament_used"),
|
||||||
|
key: "filament_used",
|
||||||
|
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.status"),
|
||||||
|
dataIndex: "status",
|
||||||
|
key: "status",
|
||||||
|
render: (status: string) => (
|
||||||
|
<Tag color={status === "completed" ? "success" : "error"}>
|
||||||
|
{status === "completed" ? t("print_queue.status.completed") : t("print_queue.status.cancelled")}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("print_queue.fields.finished"),
|
||||||
|
dataIndex: "finished",
|
||||||
|
key: "finished",
|
||||||
|
render: (finished: string) =>
|
||||||
|
finished ? (
|
||||||
|
<Tooltip title={dayjs.utc(finished).local().format("YYYY-MM-DD HH:mm:ss")}>
|
||||||
|
{dayjs.utc(finished).fromNow()}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("table.actions"),
|
||||||
|
key: "actions",
|
||||||
|
render: (_: unknown, record: IPrintJob) => (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const weighingColumns = [
|
||||||
|
{
|
||||||
|
title: t("spool.fields.id"),
|
||||||
|
dataIndex: "id",
|
||||||
|
key: "id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.filament_name"),
|
||||||
|
key: "filament",
|
||||||
|
render: (_: unknown, record: ISpool) => {
|
||||||
|
const fil = record.filament;
|
||||||
|
if (fil.vendor?.name) {
|
||||||
|
return `${fil.vendor.name} - ${fil.name || fil.material}`;
|
||||||
|
}
|
||||||
|
return fil.name || fil.material || `Filament #${fil.id}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("spool.fields.location"),
|
||||||
|
dataIndex: "location",
|
||||||
|
key: "location",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("table.actions"),
|
||||||
|
key: "actions",
|
||||||
|
render: (_: unknown, record: ISpool) => (
|
||||||
|
<Space>
|
||||||
|
<Link to={`/spool/edit/${record.id}`}>
|
||||||
|
<Button type="primary" icon={<ToolOutlined />}>
|
||||||
|
{t("print_queue.weigh_in")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Tooltip title={t("print_queue.clear_flag_tooltip")}>
|
||||||
|
<Button onClick={() => handleClearWeighingFlag(record)}>
|
||||||
|
{t("print_queue.clear_flag")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const isLoading = printJobsQuery.isLoading || spoolsQuery.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
padding: "2em 20px",
|
||||||
|
minHeight: 280,
|
||||||
|
maxWidth: 1200,
|
||||||
|
margin: "0 auto",
|
||||||
|
backgroundColor: token.colorBgContainer,
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
{t("print_queue.title")}
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
invalidate({ resource: "print-job", invalidates: ["list"] });
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.pending")}
|
||||||
|
value={pendingJobs.length}
|
||||||
|
valueStyle={{ color: token.colorWarning }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.needs_weighing")}
|
||||||
|
value={spoolsNeedingWeighing?.data?.length || 0}
|
||||||
|
valueStyle={{ color: (spoolsNeedingWeighing?.data?.length || 0) > 0 ? token.colorError : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.completed_today")}
|
||||||
|
value={completedJobs.filter((j) => dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length}
|
||||||
|
valueStyle={{ color: token.colorSuccess }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={t("print_queue.stats.total_jobs")}
|
||||||
|
value={printJobs?.data?.length || 0}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Pending Jobs */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Badge count={pendingJobs.length} offset={[15, 0]} color={token.colorWarning}>
|
||||||
|
<span>{t("print_queue.pending_jobs")}</span>
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
{pendingJobs.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={pendingJobs}
|
||||||
|
columns={pendingColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description={t("print_queue.no_pending_jobs")} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Spools Needing Weighing */}
|
||||||
|
{(spoolsNeedingWeighing?.data?.length || 0) > 0 && (
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Badge count={spoolsNeedingWeighing?.data?.length || 0} offset={[15, 0]} color={token.colorError}>
|
||||||
|
<span>{t("print_queue.needs_weighing")}</span>
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={spoolsNeedingWeighing?.data || []}
|
||||||
|
columns={weighingColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent History */}
|
||||||
|
<Card title={t("print_queue.recent_history")}>
|
||||||
|
{completedJobs.length > 0 || cancelledJobs.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={[...completedJobs, ...cancelledJobs].sort(
|
||||||
|
(a, b) => dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf()
|
||||||
|
).slice(0, 10)}
|
||||||
|
columns={historyColumns}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description={t("print_queue.no_history")} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Content>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrintQueue;
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { DownloadOutlined, UploadOutlined } from "@ant-design/icons";
|
import { DownloadOutlined, LockOutlined, UploadOutlined } from "@ant-design/icons";
|
||||||
import { useTranslate } from "@refinedev/core";
|
import { useTranslate } from "@refinedev/core";
|
||||||
import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd";
|
import { Button, Checkbox, Divider, Form, Input, message, Modal, Popconfirm, Space, Switch, Upload } from "antd";
|
||||||
import type { UploadFile } from "antd";
|
import type { UploadFile } from "antd";
|
||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { getAPIURL } from "../../utils/url";
|
import { getAPIURL } from "../../utils/url";
|
||||||
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
|
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
|
||||||
import { ColorModeContext, THEME_COLORS, ThemeColorKey } from "../../contexts/color-mode";
|
import { ColorModeContext, THEME_COLORS, ThemeColorKey } from "../../contexts/color-mode";
|
||||||
|
import { useAuth } from "../../contexts/auth";
|
||||||
|
|
||||||
export function GeneralSettings() {
|
export function GeneralSettings() {
|
||||||
const settings = useGetSettings();
|
const settings = useGetSettings();
|
||||||
@@ -137,13 +138,191 @@ export function GeneralSettings() {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
<AuthSettings messageApi={messageApi} t={t} />
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<BackupRestore messageApi={messageApi} t={t} />
|
<BackupRestore messageApi={messageApi} t={t} />
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<AboutSection />
|
||||||
|
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuthSettingsProps {
|
||||||
|
messageApi: ReturnType<typeof message.useMessage>[0];
|
||||||
|
t: ReturnType<typeof useTranslate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthSettings({ messageApi, t }: AuthSettingsProps) {
|
||||||
|
const { authStatus, refreshAuthStatus, setup } = useAuth();
|
||||||
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||||
|
const [disableModalOpen, setDisableModalOpen] = useState(false);
|
||||||
|
const [setupForm] = Form.useForm();
|
||||||
|
const [disableForm] = Form.useForm();
|
||||||
|
|
||||||
|
const handleToggle = async (enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
// If enabling and no users exist, show setup modal
|
||||||
|
if (!authStatus?.has_users) {
|
||||||
|
setSetupModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If users exist, just enable
|
||||||
|
await toggleAuth(true);
|
||||||
|
} else {
|
||||||
|
// Disabling - show password confirmation modal
|
||||||
|
setDisableModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAuth = async (enabled: boolean, password?: string) => {
|
||||||
|
setToggling(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getAPIURL()}/auth/toggle`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to toggle authentication");
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshAuthStatus();
|
||||||
|
messageApi.success(
|
||||||
|
enabled
|
||||||
|
? t("settings.auth.enabled_success")
|
||||||
|
: t("settings.auth.disabled_success")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// Redirect to login after enabling
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
messageApi.error(err instanceof Error ? err.message : "Failed to toggle authentication");
|
||||||
|
} finally {
|
||||||
|
setToggling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetup = async (values: { username: string; password: string }) => {
|
||||||
|
setToggling(true);
|
||||||
|
try {
|
||||||
|
// Create the first admin user
|
||||||
|
await setup(values.username, values.password);
|
||||||
|
// Enable auth
|
||||||
|
await toggleAuth(true);
|
||||||
|
setSetupModalOpen(false);
|
||||||
|
setupForm.resetFields();
|
||||||
|
} catch (err) {
|
||||||
|
messageApi.error(err instanceof Error ? err.message : "Setup failed");
|
||||||
|
} finally {
|
||||||
|
setToggling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisable = async (values: { password: string }) => {
|
||||||
|
await toggleAuth(false, values.password);
|
||||||
|
setDisableModalOpen(false);
|
||||||
|
disableForm.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
|
||||||
|
<h3>
|
||||||
|
<LockOutlined style={{ marginRight: 8 }} />
|
||||||
|
{t("settings.auth.title")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: "1em", color: "#888" }}>
|
||||||
|
{t("settings.auth.description")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
|
<Switch
|
||||||
|
checked={authStatus?.enabled ?? false}
|
||||||
|
onChange={handleToggle}
|
||||||
|
loading={toggling}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{authStatus?.enabled
|
||||||
|
? t("settings.auth.status_enabled")
|
||||||
|
: t("settings.auth.status_disabled")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup Modal - Create first admin user */}
|
||||||
|
<Modal
|
||||||
|
title={t("settings.auth.setup_title")}
|
||||||
|
open={setupModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setSetupModalOpen(false);
|
||||||
|
setupForm.resetFields();
|
||||||
|
}}
|
||||||
|
onOk={() => setupForm.submit()}
|
||||||
|
okText={t("settings.auth.enable_button")}
|
||||||
|
confirmLoading={toggling}
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: 16 }}>{t("settings.auth.setup_description")}</p>
|
||||||
|
<Form form={setupForm} layout="vertical" onFinish={handleSetup}>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label={t("settings.users.username")}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t("settings.users.username_required") },
|
||||||
|
{ min: 3, message: t("settings.users.username_min") },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={t("settings.users.password")}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t("settings.users.password_required") },
|
||||||
|
{ min: 6, message: t("settings.users.password_min") },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Disable Modal - Require password confirmation */}
|
||||||
|
<Modal
|
||||||
|
title={t("settings.auth.disable_title")}
|
||||||
|
open={disableModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setDisableModalOpen(false);
|
||||||
|
disableForm.resetFields();
|
||||||
|
}}
|
||||||
|
onOk={() => disableForm.submit()}
|
||||||
|
okText={t("settings.auth.disable_button")}
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
confirmLoading={toggling}
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: 16 }}>{t("settings.auth.disable_description")}</p>
|
||||||
|
<Form form={disableForm} layout="vertical" onFinish={handleDisable}>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={t("settings.auth.admin_password")}
|
||||||
|
rules={[{ required: true, message: t("settings.auth.password_required") }]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BackupRestoreProps {
|
interface BackupRestoreProps {
|
||||||
messageApi: ReturnType<typeof message.useMessage>[0];
|
messageApi: ReturnType<typeof message.useMessage>[0];
|
||||||
t: ReturnType<typeof useTranslate>;
|
t: ReturnType<typeof useTranslate>;
|
||||||
@@ -272,3 +451,27 @@ function BackupRestore({ messageApi, t }: BackupRestoreProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AboutSection() {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
|
||||||
|
<h3>About</h3>
|
||||||
|
<p style={{ color: "#888", lineHeight: 1.8 }}>
|
||||||
|
Based on{" "}
|
||||||
|
<a href="https://github.com/Donkie/Spoolman" target="_blank" rel="noopener noreferrer">
|
||||||
|
Spoolman
|
||||||
|
</a>{" "}
|
||||||
|
by{" "}
|
||||||
|
<a href="https://github.com/Donkie" target="_blank" rel="noopener noreferrer">
|
||||||
|
Donkie
|
||||||
|
</a>
|
||||||
|
<br />
|
||||||
|
UX enhancements developed with{" "}
|
||||||
|
<a href="https://claude.ai" target="_blank" rel="noopener noreferrer">
|
||||||
|
Claude
|
||||||
|
</a>{" "}
|
||||||
|
(Anthropic)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileOutlined, HighlightOutlined, SolutionOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
|
import { FileOutlined, HighlightOutlined, SolutionOutlined, TeamOutlined, ToolOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
import { IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||||
import { Menu, theme } from "antd";
|
import { Menu, theme } from "antd";
|
||||||
import { Content } from "antd/es/layout/layout";
|
import { Content } from "antd/es/layout/layout";
|
||||||
@@ -8,6 +8,7 @@ import React from "react";
|
|||||||
import { Route, Routes, useNavigate } from "react-router";
|
import { Route, Routes, useNavigate } from "react-router";
|
||||||
import { ExtraFieldsSettings } from "./extraFieldsSettings";
|
import { ExtraFieldsSettings } from "./extraFieldsSettings";
|
||||||
import { GeneralSettings } from "./generalSettings";
|
import { GeneralSettings } from "./generalSettings";
|
||||||
|
import { UserSettings } from "./userSettings";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
|
|||||||
}}
|
}}
|
||||||
items={[
|
items={[
|
||||||
{ key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> },
|
{ key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> },
|
||||||
|
{ key: "users", label: t("settings.users.tab"), icon: <TeamOutlined /> },
|
||||||
{
|
{
|
||||||
key: "extra",
|
key: "extra",
|
||||||
label: t("settings.extra_fields.tab"),
|
label: t("settings.extra_fields.tab"),
|
||||||
@@ -88,6 +90,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
|
|||||||
<main>
|
<main>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<GeneralSettings />} />
|
<Route index element={<GeneralSettings />} />
|
||||||
|
<Route path="/users" element={<UserSettings />} />
|
||||||
<Route path="/extra/:entityType" element={<ExtraFieldsSettings />} />
|
<Route path="/extra/:entityType" element={<ExtraFieldsSettings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
285
client/src/pages/settings/userSettings.tsx
Normal file
285
client/src/pages/settings/userSettings.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
|
import { useTranslate } from "@refinedev/core";
|
||||||
|
import { Button, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from "antd";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { hasRole, useAuth, User } from "../../contexts/auth";
|
||||||
|
import { getAPIURL } from "../../utils/url";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface UserFormValues {
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
role: "admin" | "editor" | "operator" | "viewer";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserSettings: React.FC = () => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const { user: currentUser, token, authStatus, logout } = useAuth();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [form] = Form.useForm<UserFormValues>();
|
||||||
|
|
||||||
|
const apiUrl = getAPIURL();
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}/auth/users`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setUsers(await res.json());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch users:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiUrl, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authStatus?.enabled && hasRole(currentUser, "admin")) {
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
}, [authStatus, currentUser, fetchUsers]);
|
||||||
|
|
||||||
|
const handleCreate = async (values: UserFormValues) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}/auth/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to create user");
|
||||||
|
}
|
||||||
|
message.success(t("settings.users.created"));
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "Failed to create user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (values: UserFormValues) => {
|
||||||
|
if (!editingUser) return;
|
||||||
|
try {
|
||||||
|
const body: Partial<UserFormValues> = { role: values.role };
|
||||||
|
if (values.password) {
|
||||||
|
body.password = values.password;
|
||||||
|
}
|
||||||
|
const res = await fetch(`${apiUrl}/auth/users/${editingUser.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to update user");
|
||||||
|
}
|
||||||
|
message.success(t("settings.users.updated"));
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
form.resetFields();
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "Failed to update user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (userId: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}/auth/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to delete user");
|
||||||
|
}
|
||||||
|
message.success(t("settings.users.deleted"));
|
||||||
|
fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : "Failed to delete user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ role: "viewer" });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
form.setFieldsValue({ username: user.username, role: user.role, password: undefined });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
admin: "red",
|
||||||
|
editor: "blue",
|
||||||
|
operator: "green",
|
||||||
|
viewer: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
// If auth is not enabled, show message
|
||||||
|
if (!authStatus?.enabled) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: 40 }}>
|
||||||
|
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
|
||||||
|
<Title level={4}>{t("settings.users.auth_disabled")}</Title>
|
||||||
|
<Text type="secondary">{t("settings.users.auth_disabled_hint")}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is not admin, show access denied
|
||||||
|
if (!hasRole(currentUser, "admin")) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: 40 }}>
|
||||||
|
<UserOutlined style={{ fontSize: 48, color: "#999", marginBottom: 16 }} />
|
||||||
|
<Title level={4}>{t("settings.users.admin_required")}</Title>
|
||||||
|
<Text type="secondary">{t("settings.users.admin_required_hint")}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
{t("settings.users.title")}
|
||||||
|
</Title>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||||
|
{t("settings.users.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<User>
|
||||||
|
dataSource={users}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: t("settings.users.username"),
|
||||||
|
dataIndex: "username",
|
||||||
|
key: "username",
|
||||||
|
render: (username: string, record: User) => (
|
||||||
|
<Space>
|
||||||
|
<UserOutlined />
|
||||||
|
{username}
|
||||||
|
{record.id === currentUser?.id && <Tag>{t("settings.users.you")}</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("settings.users.role"),
|
||||||
|
dataIndex: "role",
|
||||||
|
key: "role",
|
||||||
|
render: (role: string) => <Tag color={roleColors[role]}>{role.toUpperCase()}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("settings.users.status"),
|
||||||
|
dataIndex: "is_active",
|
||||||
|
key: "is_active",
|
||||||
|
render: (isActive: boolean) => (
|
||||||
|
<Tag color={isActive ? "green" : "red"}>{isActive ? t("settings.users.active") : t("settings.users.inactive")}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("settings.users.actions"),
|
||||||
|
key: "actions",
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, record: User) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
|
||||||
|
<Popconfirm
|
||||||
|
title={t("settings.users.delete_confirm")}
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
disabled={record.id === currentUser?.id}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} disabled={record.id === currentUser?.id} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingUser ? t("settings.users.edit") : t("settings.users.add")}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
okText={editingUser ? t("buttons.save") : t("buttons.create")}
|
||||||
|
>
|
||||||
|
<Form<UserFormValues>
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={editingUser ? handleUpdate : handleCreate}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label={t("settings.users.username")}
|
||||||
|
rules={[
|
||||||
|
{ required: !editingUser, message: t("settings.users.username_required") },
|
||||||
|
{ min: 3, message: t("settings.users.username_min") },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input disabled={!!editingUser} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={t("settings.users.password")}
|
||||||
|
rules={[
|
||||||
|
{ required: !editingUser, message: t("settings.users.password_required") },
|
||||||
|
{ min: 6, message: t("settings.users.password_min") },
|
||||||
|
]}
|
||||||
|
help={editingUser ? t("settings.users.password_change_hint") : undefined}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={editingUser ? t("settings.users.leave_blank") : undefined} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="role"
|
||||||
|
label={t("settings.users.role")}
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="viewer">{t("settings.users.role_viewer")}</Select.Option>
|
||||||
|
<Select.Option value="operator">{t("settings.users.role_operator")}</Select.Option>
|
||||||
|
<Select.Option value="editor">{t("settings.users.role_editor")}</Select.Option>
|
||||||
|
<Select.Option value="admin">{t("settings.users.role_admin")}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<div style={{ marginTop: 24, textAlign: "center" }}>
|
||||||
|
<Button onClick={logout}>{t("settings.users.logout")}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserSettings;
|
||||||
91
client/src/pages/spools/components/BatchActionBar.tsx
Normal file
91
client/src/pages/spools/components/BatchActionBar.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { DeleteOutlined, InboxOutlined, UndoOutlined, EnvironmentOutlined, CloseOutlined } from "@ant-design/icons";
|
||||||
|
import { useTranslate } from "@refinedev/core";
|
||||||
|
import { Affix, Button, Card, Space, Modal, Select } from "antd";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSpoolmanLocations } from "../../../components/otherModels";
|
||||||
|
|
||||||
|
interface BatchActionBarProps {
|
||||||
|
selectedIds: number[];
|
||||||
|
onArchive: () => void;
|
||||||
|
onUnarchive: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onMove: (location: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchActionBar(props: BatchActionBarProps) {
|
||||||
|
const t = useTranslate();
|
||||||
|
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<string>();
|
||||||
|
const locations = useSpoolmanLocations(isMoveModalOpen);
|
||||||
|
|
||||||
|
if (props.selectedIds.length === 0) return null;
|
||||||
|
|
||||||
|
const handleMoveConfirm = () => {
|
||||||
|
if (selectedLocation) {
|
||||||
|
props.onMove(selectedLocation);
|
||||||
|
setIsMoveModalOpen(false);
|
||||||
|
setSelectedLocation(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Affix offsetBottom={20}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
boxShadow: "0 -2px 8px rgba(0,0,0,0.15)",
|
||||||
|
borderRadius: 8,
|
||||||
|
maxWidth: 600,
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space wrap>
|
||||||
|
<span style={{ fontWeight: 500 }}>
|
||||||
|
{t("batch.selected", { count: props.selectedIds.length })}
|
||||||
|
</span>
|
||||||
|
<Button icon={<InboxOutlined />} onClick={props.onArchive}>
|
||||||
|
{t("buttons.archive")}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<UndoOutlined />} onClick={props.onUnarchive}>
|
||||||
|
{t("buttons.unArchive")}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<EnvironmentOutlined />} onClick={() => setIsMoveModalOpen(true)}>
|
||||||
|
{t("batch.move")}
|
||||||
|
</Button>
|
||||||
|
<Button danger icon={<DeleteOutlined />} onClick={props.onDelete}>
|
||||||
|
{t("buttons.delete")}
|
||||||
|
</Button>
|
||||||
|
<Button type="text" icon={<CloseOutlined />} onClick={props.onClear}>
|
||||||
|
{t("batch.clear_selection")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Affix>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t("batch.move_title")}
|
||||||
|
open={isMoveModalOpen}
|
||||||
|
onOk={handleMoveConfirm}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsMoveModalOpen(false);
|
||||||
|
setSelectedLocation(undefined);
|
||||||
|
}}
|
||||||
|
okButtonProps={{ disabled: !selectedLocation }}
|
||||||
|
>
|
||||||
|
<p>{t("batch.move_description", { count: props.selectedIds.length })}</p>
|
||||||
|
<Select
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
placeholder={t("spool.fields.location")}
|
||||||
|
value={selectedLocation}
|
||||||
|
onChange={setSelectedLocation}
|
||||||
|
loading={locations.isLoading}
|
||||||
|
options={locations.data?.map((loc) => ({ label: loc, value: loc })) || []}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
client/src/pages/spools/components/SpoolGallery.tsx
Normal file
91
client/src/pages/spools/components/SpoolGallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
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";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||||
import { FilamentCreateModal } from "../../components/filamentCreateModal";
|
import { FilamentCreateModal } from "../../components/filamentCreateModal";
|
||||||
import { useSpoolmanLocations } from "../../components/otherModels";
|
import { useSpoolmanLocations } from "../../components/otherModels";
|
||||||
@@ -14,6 +14,7 @@ import "../../utils/overrides.css";
|
|||||||
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 { useFormShortcuts } from "../../utils/useFormShortcuts";
|
||||||
import { createFilamentFromExternal } from "../filaments/functions";
|
import { createFilamentFromExternal } from "../filaments/functions";
|
||||||
import { IFilament } from "../filaments/model";
|
import { IFilament } from "../filaments/model";
|
||||||
import { useGetFilamentSelectOptions } from "./functions";
|
import { useGetFilamentSelectOptions } from "./functions";
|
||||||
@@ -50,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
|
||||||
@@ -84,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.
|
||||||
@@ -140,6 +151,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
|||||||
redirect(redirectTo);
|
redirect(redirectTo);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+S / Cmd+S to save
|
||||||
|
const handleSaveShortcut = useCallback(() => {
|
||||||
|
handleSubmit("list");
|
||||||
|
}, []);
|
||||||
|
useFormShortcuts(handleSaveShortcut);
|
||||||
|
|
||||||
// Use useEffect to update the form's initialValues when the extra fields are loaded
|
// Use useEffect to update the form's initialValues when the extra fields are loaded
|
||||||
// This is necessary because the form is rendered before the extra fields are loaded
|
// This is necessary because the form is rendered before the extra fields are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -201,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 => {
|
||||||
@@ -395,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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||||
import { Edit, useForm } from "@refinedev/antd";
|
import { Edit, useForm } from "@refinedev/antd";
|
||||||
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||||
import { Alert, 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";
|
||||||
@@ -37,7 +38,14 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
const [searchParams, _] = useSearchParams();
|
const [searchParams, _] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
|
// Get all spool IDs for prev/next navigation
|
||||||
|
const { result: allSpools } = useList<ISpool>({
|
||||||
|
resource: "spool",
|
||||||
|
pagination: { mode: "off" },
|
||||||
|
sorters: [{ field: "id", order: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { form, formProps, saveButtonProps, id } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
|
||||||
liveMode: "manual",
|
liveMode: "manual",
|
||||||
onLiveEvent() {
|
onLiveEvent() {
|
||||||
// Warn the user if the spool has been updated since the form was opened
|
// Warn the user if the spool has been updated since the form was opened
|
||||||
@@ -59,6 +67,18 @@ 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
|
||||||
|
const { prevId, nextId } = useMemo(() => {
|
||||||
|
if (!allSpools?.data || !id) return { prevId: null, nextId: null };
|
||||||
|
const ids = allSpools.data.map((s: ISpool) => s.id);
|
||||||
|
const currentIndex = ids.indexOf(Number(id));
|
||||||
|
return {
|
||||||
|
prevId: currentIndex > 0 ? ids[currentIndex - 1] : null,
|
||||||
|
nextId: currentIndex < ids.length - 1 ? ids[currentIndex + 1] : null,
|
||||||
|
};
|
||||||
|
}, [allSpools, id]);
|
||||||
|
|
||||||
// Add the filament_id field to the form
|
// Add the filament_id field to the form
|
||||||
if (formProps.initialValues) {
|
if (formProps.initialValues) {
|
||||||
@@ -170,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 => {
|
||||||
@@ -231,7 +256,28 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
}, [initialUsedWeight]);
|
}, [initialUsedWeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit
|
||||||
|
saveButtonProps={saveButtonProps}
|
||||||
|
headerButtons={({ defaultButtons }) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
disabled={!prevId}
|
||||||
|
onClick={() => prevId && navigate(`/spool/edit/${prevId}`)}
|
||||||
|
>
|
||||||
|
{t("buttons.prev")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
disabled={!nextId}
|
||||||
|
onClick={() => nextId && navigate(`/spool/edit/${nextId}`)}
|
||||||
|
>
|
||||||
|
{t("buttons.next")}
|
||||||
|
</Button>
|
||||||
|
{defaultButtons}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Form {...formProps} layout="vertical">
|
<Form {...formProps} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -354,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>
|
||||||
|
|||||||
@@ -24,6 +24,36 @@ export async function setSpoolArchived(spool: ISpool, archived: boolean) {
|
|||||||
await fetch(request, init);
|
await fetch(request, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkArchiveSpools(spoolIds: number[], archived: boolean) {
|
||||||
|
const response = await fetch(getAPIURL() + "/spool/bulk/archive", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ spool_ids: spoolIds, archived }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to archive spools");
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteSpools(spoolIds: number[]) {
|
||||||
|
const response = await fetch(getAPIURL() + "/spool/bulk/delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ spool_ids: spoolIds }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to delete spools");
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkUpdateSpools(spoolIds: number[], data: { location?: string }) {
|
||||||
|
const response = await fetch(getAPIURL() + "/spool/bulk/update", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ spool_ids: spoolIds, ...data }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to update spools");
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use some spool filament from this spool. Either specify length or weight.
|
* Use some spool filament from this spool. Either specify length or weight.
|
||||||
* @param spool The spool
|
* @param spool The spool
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -18,6 +20,7 @@ import { useNavigate } from "react-router";
|
|||||||
import {
|
import {
|
||||||
Action,
|
Action,
|
||||||
ActionsColumn,
|
ActionsColumn,
|
||||||
|
ColorFilterColumn,
|
||||||
CustomFieldColumn,
|
CustomFieldColumn,
|
||||||
DateColumn,
|
DateColumn,
|
||||||
FilteredQueryColumn,
|
FilteredQueryColumn,
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
} from "../../components/column";
|
} from "../../components/column";
|
||||||
import { useLiveify } from "../../components/liveify";
|
import { useLiveify } from "../../components/liveify";
|
||||||
import {
|
import {
|
||||||
|
useSpoolmanColors,
|
||||||
useSpoolmanFilamentFilter,
|
useSpoolmanFilamentFilter,
|
||||||
useSpoolmanLocations,
|
useSpoolmanLocations,
|
||||||
useSpoolmanLotNumbers,
|
useSpoolmanLotNumbers,
|
||||||
@@ -37,8 +41,10 @@ import { removeUndefined } from "../../utils/filtering";
|
|||||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||||
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
|
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
|
||||||
import { useCurrencyFormatter } from "../../utils/settings";
|
import { useCurrencyFormatter } from "../../utils/settings";
|
||||||
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
||||||
import { ISpool } from "./model";
|
import { ISpool } from "./model";
|
||||||
|
import { BatchActionBar } from "./components/BatchActionBar";
|
||||||
|
import { SpoolGallery } from "./components/SpoolGallery";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
@@ -48,6 +54,7 @@ interface ISpoolCollapsed extends ISpool {
|
|||||||
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
|
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
|
||||||
"filament.id": number;
|
"filament.id": number;
|
||||||
"filament.material"?: string;
|
"filament.material"?: string;
|
||||||
|
"filament.color_hex"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseSpool(element: ISpool): ISpoolCollapsed {
|
function collapseSpool(element: ISpool): ISpoolCollapsed {
|
||||||
@@ -65,6 +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.color_hex ?? element.filament.color_hex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +80,7 @@ function translateColumnI18nKey(columnName: string): string {
|
|||||||
columnName = columnName.replace(".", "_");
|
columnName = columnName.replace(".", "_");
|
||||||
if (columnName === "filament_combined_name") columnName = "filament_name";
|
if (columnName === "filament_combined_name") columnName = "filament_name";
|
||||||
else if (columnName === "filament_material") columnName = "material";
|
else if (columnName === "filament_material") columnName = "material";
|
||||||
|
else if (columnName === "filament_color_hex") columnName = "color";
|
||||||
return `spool.fields.${columnName}`;
|
return `spool.fields.${columnName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +89,10 @@ const namespace = "spoolList-v2";
|
|||||||
const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
||||||
"id",
|
"id",
|
||||||
"filament.combined_name",
|
"filament.combined_name",
|
||||||
|
"filament.color_hex",
|
||||||
"filament.material",
|
"filament.material",
|
||||||
"price",
|
"price",
|
||||||
|
"remaining_value",
|
||||||
"used_weight",
|
"used_weight",
|
||||||
"remaining_weight",
|
"remaining_weight",
|
||||||
"used_length",
|
"used_length",
|
||||||
@@ -94,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"].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> = () => {
|
||||||
@@ -113,6 +125,13 @@ 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
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
|
||||||
// Fetch data from the API
|
// Fetch data from the API
|
||||||
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
|
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
|
||||||
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
|
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
|
||||||
@@ -122,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,
|
||||||
@@ -205,6 +225,58 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Batch action handlers
|
||||||
|
const handleBatchArchive = () => {
|
||||||
|
confirm({
|
||||||
|
title: t("batch.archive_confirm"),
|
||||||
|
content: t("batch.archive_confirm_content", { count: selectedRowKeys.length }),
|
||||||
|
okText: t("buttons.archive"),
|
||||||
|
okType: "primary",
|
||||||
|
cancelText: t("buttons.cancel"),
|
||||||
|
async onOk() {
|
||||||
|
await bulkArchiveSpools(selectedRowKeys as number[], true);
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list"] });
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchUnarchive = async () => {
|
||||||
|
confirm({
|
||||||
|
title: t("batch.unarchive_confirm"),
|
||||||
|
content: t("batch.unarchive_confirm_content", { count: selectedRowKeys.length }),
|
||||||
|
okText: t("buttons.unArchive"),
|
||||||
|
okType: "primary",
|
||||||
|
cancelText: t("buttons.cancel"),
|
||||||
|
async onOk() {
|
||||||
|
await bulkArchiveSpools(selectedRowKeys as number[], false);
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list"] });
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
confirm({
|
||||||
|
title: t("batch.delete_confirm"),
|
||||||
|
content: t("batch.delete_confirm_content", { count: selectedRowKeys.length }),
|
||||||
|
okText: t("buttons.delete"),
|
||||||
|
okType: "danger",
|
||||||
|
cancelText: t("buttons.cancel"),
|
||||||
|
async onOk() {
|
||||||
|
await bulkDeleteSpools(selectedRowKeys as number[]);
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list"] });
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchMove = async (location: string) => {
|
||||||
|
await bulkUpdateSpools(selectedRowKeys as number[], { location });
|
||||||
|
invalidate({ resource: "spool", invalidates: ["list"] });
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
};
|
||||||
|
|
||||||
if (tableProps.pagination) {
|
if (tableProps.pagination) {
|
||||||
tableProps.pagination.showSizeChanger = true;
|
tableProps.pagination.showSizeChanger = true;
|
||||||
}
|
}
|
||||||
@@ -289,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={{
|
||||||
@@ -326,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
|
||||||
@@ -333,6 +423,11 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
scroll={{ x: "max-content" }}
|
scroll={{ x: "max-content" }}
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (newSelectedRowKeys) => setSelectedRowKeys(newSelectedRowKeys),
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
|
}}
|
||||||
// Make archived rows greyed out
|
// Make archived rows greyed out
|
||||||
onRow={(record) => {
|
onRow={(record) => {
|
||||||
if (record.archived) {
|
if (record.archived) {
|
||||||
@@ -358,15 +453,25 @@ 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
|
||||||
colors: record.filament.multi_color_hexes.split(","),
|
: record.filament.multi_color_hexes
|
||||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
? {
|
||||||
}
|
colors: record.filament.multi_color_hexes.split(","),
|
||||||
: record.filament.color_hex,
|
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||||
|
}
|
||||||
|
: record.filament.color_hex,
|
||||||
dataId: "filament.combined_name",
|
dataId: "filament.combined_name",
|
||||||
filterValueQuery: useSpoolmanFilamentFilter(),
|
filterValueQuery: useSpoolmanFilamentFilter(),
|
||||||
}),
|
}),
|
||||||
|
ColorFilterColumn({
|
||||||
|
...commonProps,
|
||||||
|
id: "filament.color_hex",
|
||||||
|
i18nkey: "spool.fields.color",
|
||||||
|
filterValueQuery: useSpoolmanColors(),
|
||||||
|
colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex,
|
||||||
|
width: 60,
|
||||||
|
}),
|
||||||
FilteredQueryColumn({
|
FilteredQueryColumn({
|
||||||
...commonProps,
|
...commonProps,
|
||||||
id: "filament.material",
|
id: "filament.material",
|
||||||
@@ -387,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",
|
||||||
@@ -395,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,
|
||||||
@@ -404,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,
|
||||||
@@ -412,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,
|
||||||
@@ -421,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,
|
||||||
@@ -466,6 +588,15 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
|||||||
ActionsColumn(t("table.actions"), actions),
|
ActionsColumn(t("table.actions"), actions),
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<BatchActionBar
|
||||||
|
selectedIds={selectedRowKeys as number[]}
|
||||||
|
onArchive={handleBatchArchive}
|
||||||
|
onUnarchive={handleBatchUnarchive}
|
||||||
|
onDelete={handleBatchDelete}
|
||||||
|
onMove={handleBatchMove}
|
||||||
|
onClear={() => setSelectedRowKeys([])}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,17 +15,33 @@ 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;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
needs_weighing: boolean;
|
||||||
extra: { [key: string]: string };
|
extra: { [key: string]: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPrintJob {
|
||||||
|
id: number;
|
||||||
|
spool_id: number;
|
||||||
|
spool?: ISpool;
|
||||||
|
created: string;
|
||||||
|
finished?: string;
|
||||||
|
filename: string;
|
||||||
|
filament_used_g: number;
|
||||||
|
filament_used_mm?: number;
|
||||||
|
status: "pending" | "completed" | "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
|
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
|
||||||
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
|
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +127,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorObj = record?.filament.multi_color_hexes
|
const colorObj = record?.color_hex
|
||||||
? {
|
? record.color_hex
|
||||||
colors: record.filament.multi_color_hexes.split(","),
|
: record?.filament.multi_color_hexes
|
||||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
? {
|
||||||
}
|
colors: record.filament.multi_color_hexes.split(","),
|
||||||
: record?.filament.color_hex;
|
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||||
|
}
|
||||||
|
: record?.filament.color_hex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
@@ -163,7 +176,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
|||||||
<Title level={5}>{t("spool.fields.id")}</Title>
|
<Title level={5}>{t("spool.fields.id")}</Title>
|
||||||
<NumberField value={record?.id ?? ""} />
|
<NumberField value={record?.id ?? ""} />
|
||||||
<Title level={5}>{t("spool.fields.filament")}</Title>
|
<Title level={5}>{t("spool.fields.filament")}</Title>
|
||||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
|
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
|
||||||
<TextField value={record ? filamentURL(record?.filament) : ""} />
|
<TextField value={record ? filamentURL(record?.filament) : ""} />
|
||||||
<Title level={5}>{t("spool.fields.price")}</Title>
|
<Title level={5}>{t("spool.fields.price")}</Title>
|
||||||
<TextField value={spoolPrice(record)} />
|
<TextField value={spoolPrice(record)} />
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
9
client/src/pages/vendors/create.tsx
vendored
9
client/src/pages/vendors/create.tsx
vendored
@@ -4,9 +4,10 @@ import { Button, Form, Input, InputNumber, 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";
|
||||||
import { useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||||
|
import { useFormShortcuts } from "../../utils/useFormShortcuts";
|
||||||
import { IVendor, IVendorParsedExtras } from "./model";
|
import { IVendor, IVendorParsedExtras } from "./model";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
@@ -41,6 +42,12 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
|
|||||||
redirect(redirectTo, (values as IVendor).id);
|
redirect(redirectTo, (values as IVendor).id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+S / Cmd+S to save
|
||||||
|
const handleSaveShortcut = useCallback(() => {
|
||||||
|
handleSubmit("list");
|
||||||
|
}, []);
|
||||||
|
useFormShortcuts(handleSaveShortcut);
|
||||||
|
|
||||||
// Use useEffect to update the form's initialValues when the extra fields are loaded
|
// Use useEffect to update the form's initialValues when the extra fields are loaded
|
||||||
// This is necessary because the form is rendered before the extra fields are loaded
|
// This is necessary because the form is rendered before the extra fields are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
52
client/src/pages/vendors/edit.tsx
vendored
52
client/src/pages/vendors/edit.tsx
vendored
@@ -1,9 +1,11 @@
|
|||||||
|
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||||
import { Edit, useForm } from "@refinedev/antd";
|
import { Edit, useForm } from "@refinedev/antd";
|
||||||
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||||
import { Alert, DatePicker, Form, Input, InputNumber, message, Typography } from "antd";
|
import { Alert, Button, DatePicker, Form, Input, InputNumber, message, 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 React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||||
import { IVendor, IVendorParsedExtras } from "./model";
|
import { IVendor, IVendorParsedExtras } from "./model";
|
||||||
@@ -17,11 +19,19 @@ the form's onFinish method. Form.Item's normalize should do this, but it doesn't
|
|||||||
|
|
||||||
export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
|
export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [hasChanged, setHasChanged] = useState(false);
|
const [hasChanged, setHasChanged] = useState(false);
|
||||||
const extraFields = useGetFields(EntityType.vendor);
|
const extraFields = useGetFields(EntityType.vendor);
|
||||||
|
|
||||||
const { formProps, saveButtonProps } = useForm<IVendor, HttpError, IVendor, IVendor>({
|
// Get all vendor IDs for prev/next navigation
|
||||||
|
const { result: allVendors } = useList<IVendor>({
|
||||||
|
resource: "vendor",
|
||||||
|
pagination: { mode: "off" },
|
||||||
|
sorters: [{ field: "id", order: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formProps, saveButtonProps, id } = useForm<IVendor, HttpError, IVendor, IVendor>({
|
||||||
liveMode: "manual",
|
liveMode: "manual",
|
||||||
onLiveEvent() {
|
onLiveEvent() {
|
||||||
// Warn the user if the vendor has been updated since the form was opened
|
// Warn the user if the vendor has been updated since the form was opened
|
||||||
@@ -30,6 +40,17 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate prev/next IDs for navigation
|
||||||
|
const { prevId, nextId } = useMemo(() => {
|
||||||
|
if (!allVendors?.data || !id) return { prevId: null, nextId: null };
|
||||||
|
const ids = allVendors.data.map((v: IVendor) => v.id);
|
||||||
|
const currentIndex = ids.indexOf(Number(id));
|
||||||
|
return {
|
||||||
|
prevId: currentIndex > 0 ? ids[currentIndex - 1] : null,
|
||||||
|
nextId: currentIndex < ids.length - 1 ? ids[currentIndex + 1] : null,
|
||||||
|
};
|
||||||
|
}, [allVendors, id]);
|
||||||
|
|
||||||
// Parse the extra fields from string values into real types
|
// Parse the extra fields from string values into real types
|
||||||
if (formProps.initialValues) {
|
if (formProps.initialValues) {
|
||||||
formProps.initialValues = ParsedExtras(formProps.initialValues);
|
formProps.initialValues = ParsedExtras(formProps.initialValues);
|
||||||
@@ -49,7 +70,28 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit
|
||||||
|
saveButtonProps={saveButtonProps}
|
||||||
|
headerButtons={({ defaultButtons }) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
disabled={!prevId}
|
||||||
|
onClick={() => prevId && navigate(`/vendor/edit/${prevId}`)}
|
||||||
|
>
|
||||||
|
{t("buttons.prev")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
disabled={!nextId}
|
||||||
|
onClick={() => nextId && navigate(`/vendor/edit/${nextId}`)}
|
||||||
|
>
|
||||||
|
{t("buttons.next")}
|
||||||
|
</Button>
|
||||||
|
{defaultButtons}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Form {...formProps} layout="vertical">
|
<Form {...formProps} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
34
client/src/utils/axiosAuth.ts
Normal file
34
client/src/utils/axiosAuth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { axiosInstance } from "@refinedev/simple-rest";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "spoolman_auth_token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup axios interceptor to add auth token to requests.
|
||||||
|
* Should be called once at app initialization.
|
||||||
|
*/
|
||||||
|
export function setupAxiosAuth() {
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle 401 responses by clearing token
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Clear invalid token
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
// Optionally redirect to login
|
||||||
|
// window.location.href = "/login";
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
/* Color swatch border for dark mode visibility (#34) */
|
||||||
|
:root {
|
||||||
|
--swatch-border-color: rgba(68, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="dark"] {
|
||||||
|
--swatch-border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
#qty-input {
|
#qty-input {
|
||||||
text-align: center !important;
|
text-align: center !important;
|
||||||
}
|
}
|
||||||
@@ -6,3 +15,15 @@
|
|||||||
.ant-form {
|
.ant-form {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sticky Save/Cancel buttons on forms (#38) */
|
||||||
|
.ant-card-actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--ant-color-bg-container, #fff);
|
||||||
|
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
||||||
|
margin: 0 -24px -24px -24px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ export enum Pattern {
|
|||||||
SPARKLE = "sparkle",
|
SPARKLE = "sparkle",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ExternalSource {
|
|
||||||
SPOOLMANDB = "spoolmandb",
|
|
||||||
FILAMENT_PROFILES_3D = "3dfilamentprofiles",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalFilament {
|
export interface ExternalFilament {
|
||||||
id: string;
|
id: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
@@ -48,7 +43,6 @@ export interface ExternalFilament {
|
|||||||
pattern?: Pattern;
|
pattern?: Pattern;
|
||||||
translucent: boolean;
|
translucent: boolean;
|
||||||
glow: boolean;
|
glow: boolean;
|
||||||
source?: ExternalSource;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalMaterial {
|
export interface ExternalMaterial {
|
||||||
|
|||||||
26
client/src/utils/useFormShortcuts.ts
Normal file
26
client/src/utils/useFormShortcuts.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to add keyboard shortcuts to forms.
|
||||||
|
* - Ctrl+S / Cmd+S: Save (calls onSave callback)
|
||||||
|
* - Escape: Cancel (calls onCancel callback if provided)
|
||||||
|
*/
|
||||||
|
export function useFormShortcuts(onSave: () => void, onCancel?: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+S or Cmd+S to save
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
|
// Escape to cancel (if handler provided)
|
||||||
|
if (e.key === "Escape" && onCancel) {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onSave, onCancel]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""add_print_jobs.
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6g7h8
|
||||||
|
Revises: b2c3d4e5f6g7
|
||||||
|
Create Date: 2025-01-15 04:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "c3d4e5f6g7h8"
|
||||||
|
down_revision = "b2c3d4e5f6g7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create print_job table and add needs_weighing to spool."""
|
||||||
|
# Create print_job table
|
||||||
|
op.create_table(
|
||||||
|
"print_job",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("spool_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("finished", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("filename", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column("filament_used_g", sa.Float(), nullable=False),
|
||||||
|
sa.Column("filament_used_mm", sa.Float(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["spool_id"],
|
||||||
|
["spool.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_print_job_id"), "print_job", ["id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_print_job_spool_id"), "print_job", ["spool_id"], unique=False)
|
||||||
|
|
||||||
|
# Add needs_weighing column to spool table
|
||||||
|
op.add_column(
|
||||||
|
"spool",
|
||||||
|
sa.Column("needs_weighing", sa.Boolean(), nullable=True, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop print_job table and remove needs_weighing from spool."""
|
||||||
|
op.drop_column("spool", "needs_weighing")
|
||||||
|
op.drop_index(op.f("ix_print_job_spool_id"), table_name="print_job")
|
||||||
|
op.drop_index(op.f("ix_print_job_id"), table_name="print_job")
|
||||||
|
op.drop_table("print_job")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Add user authentication table.
|
||||||
|
|
||||||
|
Revision ID: d4e5f6g7h8i9
|
||||||
|
Revises: c3d4e5f6g7h8
|
||||||
|
Create Date: 2025-01-16 01:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "d4e5f6g7h8i9"
|
||||||
|
down_revision = "c3d4e5f6g7h8"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("hashed_password", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
sa.String(length=16),
|
||||||
|
nullable=False,
|
||||||
|
server_default="viewer",
|
||||||
|
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
|
||||||
|
),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_user_username"), table_name="user")
|
||||||
|
op.drop_index(op.f("ix_user_id"), table_name="user")
|
||||||
|
op.drop_table("user")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Add hierarchical locations.
|
||||||
|
|
||||||
|
Revision ID: e5f6g7h8i9j0
|
||||||
|
Revises: d4e5f6g7h8i9
|
||||||
|
Create Date: 2025-01-21 01:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "e5f6g7h8i9j0"
|
||||||
|
down_revision = "d4e5f6g7h8i9"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create location table and add location_id to spool."""
|
||||||
|
# Create location table
|
||||||
|
op.create_table(
|
||||||
|
"location",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("location_type", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("description", sa.String(length=256), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["parent_id"],
|
||||||
|
["location.id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_location_id"), "location", ["id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_location_parent_id"), "location", ["parent_id"], unique=False)
|
||||||
|
|
||||||
|
# Add location_id column to spool table (SQLite compatible - no FK constraint)
|
||||||
|
op.add_column(
|
||||||
|
"spool",
|
||||||
|
sa.Column("location_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_spool_location_id"), "spool", ["location_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop location table and remove location_id from spool."""
|
||||||
|
op.drop_index(op.f("ix_spool_location_id"), table_name="spool")
|
||||||
|
op.drop_column("spool", "location_id")
|
||||||
|
op.drop_index(op.f("ix_location_parent_id"), table_name="location")
|
||||||
|
op.drop_index(op.f("ix_location_id"), table_name="location")
|
||||||
|
op.drop_table("location")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
261
pdm.lock
generated
261
pdm.lock
generated
@@ -5,7 +5,7 @@
|
|||||||
groups = ["default", "dev"]
|
groups = ["default", "dev"]
|
||||||
strategy = ["inherit_metadata"]
|
strategy = ["inherit_metadata"]
|
||||||
lock_version = "4.5.0"
|
lock_version = "4.5.0"
|
||||||
content_hash = "sha256:e641a2fe4fc6e91347172c0b5f04e36d3ab15e85c888b0b476899cd75613c887"
|
content_hash = "sha256:0c5ca0d0ddb245de3c1fad7fc4ae1fd3397f9aa6a7b233793b8d12d8a64a79e8"
|
||||||
|
|
||||||
[[metadata.targets]]
|
[[metadata.targets]]
|
||||||
requires_python = ">=3.9,<=3.12"
|
requires_python = ">=3.9,<=3.12"
|
||||||
@@ -172,6 +172,51 @@ files = [
|
|||||||
{file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"},
|
{file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "5.0.0"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "Modern password hashing for your software and your servers"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
files = [
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"},
|
||||||
|
{file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"},
|
||||||
|
{file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"},
|
||||||
|
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"},
|
||||||
|
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"},
|
||||||
|
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"},
|
||||||
|
{file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"},
|
||||||
|
{file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "25.1.0"
|
version = "25.1.0"
|
||||||
@@ -221,6 +266,69 @@ files = [
|
|||||||
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
|
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.0.0"
|
||||||
|
requires_python = ">=3.9"
|
||||||
|
summary = "Foreign Function Interface for Python calling C code."
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\" and platform_python_implementation != \"PyPy\""
|
||||||
|
dependencies = [
|
||||||
|
"pycparser; implementation_name != \"PyPy\"",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
|
||||||
|
{file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
|
||||||
|
{file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
|
||||||
|
{file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
|
||||||
|
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
|
||||||
|
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfgv"
|
name = "cfgv"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -261,6 +369,46 @@ files = [
|
|||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "43.0.3"
|
||||||
|
requires_python = ">=3.7"
|
||||||
|
summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
dependencies = [
|
||||||
|
"cffi>=1.12; platform_python_implementation != \"PyPy\"",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"},
|
||||||
|
{file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"},
|
||||||
|
{file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"},
|
||||||
|
{file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"},
|
||||||
|
{file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"},
|
||||||
|
{file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"},
|
||||||
|
{file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"},
|
||||||
|
{file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"},
|
||||||
|
{file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"},
|
||||||
|
{file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"},
|
||||||
|
{file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"},
|
||||||
|
{file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distlib"
|
name = "distlib"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -272,6 +420,21 @@ files = [
|
|||||||
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecdsa"
|
||||||
|
version = "0.19.1"
|
||||||
|
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6"
|
||||||
|
summary = "ECDSA cryptographic signature library (pure python)"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
dependencies = [
|
||||||
|
"six>=1.9.0",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3"},
|
||||||
|
{file = "ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -679,6 +842,17 @@ files = [
|
|||||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "passlib"
|
||||||
|
version = "1.7.4"
|
||||||
|
summary = "comprehensive password hashing framework supporting over 30 schemes"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
files = [
|
||||||
|
{file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
|
||||||
|
{file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -801,6 +975,30 @@ files = [
|
|||||||
{file = "psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02"},
|
{file = "psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1"
|
||||||
|
version = "0.6.1"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
files = [
|
||||||
|
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
|
||||||
|
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "2.23"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "C parser in Python"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\" and python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
files = [
|
||||||
|
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
|
||||||
|
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.12.5"
|
||||||
@@ -950,6 +1148,40 @@ files = [
|
|||||||
{file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"},
|
{file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-jose"
|
||||||
|
version = "3.5.0"
|
||||||
|
requires_python = ">=3.9"
|
||||||
|
summary = "JOSE implementation in Python"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
dependencies = [
|
||||||
|
"ecdsa!=0.15",
|
||||||
|
"pyasn1>=0.5.0",
|
||||||
|
"rsa!=4.1.1,!=4.4,<5.0,>=4.0",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"},
|
||||||
|
{file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-jose"
|
||||||
|
version = "3.5.0"
|
||||||
|
extras = ["cryptography"]
|
||||||
|
requires_python = ">=3.9"
|
||||||
|
summary = "JOSE implementation in Python"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
dependencies = [
|
||||||
|
"cryptography>=3.4.0",
|
||||||
|
"python-jose==3.5.0",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"},
|
||||||
|
{file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-multipart"
|
name = "python-multipart"
|
||||||
version = "0.0.20"
|
version = "0.0.20"
|
||||||
@@ -1010,6 +1242,21 @@ files = [
|
|||||||
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "4.9.1"
|
||||||
|
requires_python = "<4,>=3.6"
|
||||||
|
summary = "Pure-Python RSA implementation"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
dependencies = [
|
||||||
|
"pyasn1>=0.1.3",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
|
||||||
|
{file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -1065,6 +1312,18 @@ files = [
|
|||||||
{file = "setuptools-76.1.0.tar.gz", hash = "sha256:4959b9ad482ada2ba2320c8f1a8d8481d4d8d668908a7a1b84d987375cd7f5bd"},
|
{file = "setuptools-76.1.0.tar.gz", hash = "sha256:4959b9ad482ada2ba2320c8f1a8d8481d4d8d668908a7a1b84d987375cd7f5bd"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
||||||
|
summary = "Python 2 and 3 compatibility utilities"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_version <= \"3.12\" and python_version >= \"3.9\""
|
||||||
|
files = [
|
||||||
|
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
|
||||||
|
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.45"
|
version = "2.0.45"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "spoolman"
|
name = "spoolman"
|
||||||
version = "0.23C.1"
|
version = "0.23C.5"
|
||||||
description = "A web service that keeps track of 3D printing spools."
|
description = "A web service that keeps track of 3D printing spools."
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Tony", email = "tony@nastynas.xyz" },
|
{ name = "Tony", email = "tony@nastynas.xyz" },
|
||||||
@@ -26,6 +26,9 @@ dependencies = [
|
|||||||
"httpx~=0.28",
|
"httpx~=0.28",
|
||||||
"hishel~=0.1",
|
"hishel~=0.1",
|
||||||
"python-multipart~=0.0",
|
"python-multipart~=0.0",
|
||||||
|
"passlib>=1.7",
|
||||||
|
"bcrypt>=4.0",
|
||||||
|
"python-jose[cryptography]>=3.3",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.9,<3.13"
|
requires-python = ">=3.9,<3.13"
|
||||||
|
|
||||||
|
|||||||
214
scripts/slicer_post_process.py
Executable file
214
scripts/slicer_post_process.py
Executable file
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Elegoo Slicer / Orca Slicer Post-Processing Script for Spoolman Integration.
|
||||||
|
|
||||||
|
This script parses G-code files to extract filament usage information and creates
|
||||||
|
a pending print job in Spoolman. After the print completes, use the Spoolman
|
||||||
|
Print Queue UI to confirm the print (deducts filament) or cancel it (flags for weighing).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
1. In Elegoo Slicer, go to Printer Settings > Post-processing Scripts
|
||||||
|
2. Add the path to this script: /path/to/slicer_post_process.py
|
||||||
|
3. Configure the environment variables below or create a config file
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
Set these environment variables:
|
||||||
|
- SPOOLMAN_URL: Your Spoolman instance URL (e.g., http://192.168.0.11:8000)
|
||||||
|
- SPOOLMAN_SPOOL_ID: Default spool ID to use (or set per-filament in slicer)
|
||||||
|
|
||||||
|
Or create a file at ~/.config/spoolman/slicer.conf:
|
||||||
|
[spoolman]
|
||||||
|
url = http://192.168.0.11:8000
|
||||||
|
spool_id = 1
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Read the G-code file passed as argument
|
||||||
|
2. Parse Elegoo/Orca slicer comments for filament usage
|
||||||
|
3. Create a pending print job in Spoolman via API
|
||||||
|
4. The job appears in the Print Queue for confirmation after printing
|
||||||
|
"""
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> tuple[str, int]:
|
||||||
|
"""Get Spoolman URL and default spool ID from environment or config file."""
|
||||||
|
url = os.environ.get("SPOOLMAN_URL")
|
||||||
|
spool_id = os.environ.get("SPOOLMAN_SPOOL_ID")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
config_path = Path.home() / ".config" / "spoolman" / "slicer.conf"
|
||||||
|
if config_path.exists():
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(config_path)
|
||||||
|
url = config.get("spoolman", "url", fallback=None)
|
||||||
|
spool_id = config.get("spoolman", "spool_id", fallback=None)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
print("ERROR: SPOOLMAN_URL not set. Set environment variable or create config file.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not spool_id:
|
||||||
|
print("ERROR: SPOOLMAN_SPOOL_ID not set. Set environment variable or create config file.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return url.rstrip("/"), int(spool_id)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gcode(filepath: str) -> dict:
|
||||||
|
"""
|
||||||
|
Parse G-code file for filament usage information.
|
||||||
|
|
||||||
|
Looks for Elegoo/Orca Slicer comments like:
|
||||||
|
; total filament used [g] = 12.34
|
||||||
|
; filament used [mm] = 4567.89
|
||||||
|
; filament used [g] = 12.34
|
||||||
|
|
||||||
|
Also checks for PrusaSlicer format:
|
||||||
|
; filament used [mm] = 4567.89
|
||||||
|
; filament used [g] = 12.34
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"filename": Path(filepath).name,
|
||||||
|
"filament_used_g": None,
|
||||||
|
"filament_used_mm": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Patterns for different slicers
|
||||||
|
patterns = {
|
||||||
|
# Elegoo/Orca format
|
||||||
|
"total_filament_g": re.compile(r";\s*total filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE),
|
||||||
|
"filament_g": re.compile(r";\s*filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE),
|
||||||
|
"filament_mm": re.compile(r";\s*filament used \[mm\]\s*=\s*([\d.]+)", re.IGNORECASE),
|
||||||
|
# Alternative formats
|
||||||
|
"filament_weight": re.compile(r";\s*(?:total )?filament (?:weight|cost)\s*=\s*([\d.]+)\s*g", re.IGNORECASE),
|
||||||
|
"filament_length": re.compile(r";\s*(?:total )?filament\s*=\s*([\d.]+)\s*mm", re.IGNORECASE),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
# Only read first 500 lines and last 500 lines for efficiency
|
||||||
|
lines = f.readlines()
|
||||||
|
header = lines[:500]
|
||||||
|
footer = lines[-500:] if len(lines) > 500 else []
|
||||||
|
check_lines = header + footer
|
||||||
|
|
||||||
|
for line in check_lines:
|
||||||
|
if not line.startswith(";"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check each pattern
|
||||||
|
for key, pattern in patterns.items():
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
value = float(match.group(1))
|
||||||
|
if "g" in key or "weight" in key:
|
||||||
|
if result["filament_used_g"] is None:
|
||||||
|
result["filament_used_g"] = value
|
||||||
|
elif "mm" in key or "length" in key:
|
||||||
|
if result["filament_used_mm"] is None:
|
||||||
|
result["filament_used_mm"] = value
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Error parsing G-code: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_print_job(url: str, spool_id: int, gcode_info: dict) -> bool:
|
||||||
|
"""Create a pending print job in Spoolman."""
|
||||||
|
if gcode_info["filament_used_g"] is None:
|
||||||
|
print("Warning: Could not find filament weight in G-code. Skipping job creation.", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
api_url = f"{url}/api/v1/print-job"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"spool_id": spool_id,
|
||||||
|
"filename": gcode_info["filename"],
|
||||||
|
"filament_used_g": gcode_info["filament_used_g"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if gcode_info["filament_used_mm"] is not None:
|
||||||
|
payload["filament_used_mm"] = gcode_info["filament_used_mm"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
api_url,
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||||||
|
if response.status in (200, 201):
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
print(f"Created print job #{result['id']}: {gcode_info['filename']} ({gcode_info['filament_used_g']:.1f}g)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"Error: API returned status {response.status}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8") if e.fp else ""
|
||||||
|
print(f"Error: API request failed: {e.code} {e.reason} - {error_body}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"Error: Could not connect to Spoolman: {e.reason}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: slicer_post_process.py <gcode_file>", file=sys.stderr)
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
gcode_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not os.path.exists(gcode_path):
|
||||||
|
print(f"Error: G-code file not found: {gcode_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Get configuration
|
||||||
|
url, spool_id = get_config()
|
||||||
|
|
||||||
|
# Parse G-code
|
||||||
|
print(f"Parsing G-code: {gcode_path}")
|
||||||
|
gcode_info = parse_gcode(gcode_path)
|
||||||
|
|
||||||
|
if gcode_info["filament_used_g"]:
|
||||||
|
print(f" Filament: {gcode_info['filament_used_g']:.2f}g", end="")
|
||||||
|
if gcode_info["filament_used_mm"]:
|
||||||
|
print(f" ({gcode_info['filament_used_mm']:.1f}mm)")
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
else:
|
||||||
|
print(" Warning: Could not parse filament usage from G-code")
|
||||||
|
|
||||||
|
# Create print job
|
||||||
|
success = create_print_job(url, spool_id, gcode_info)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"Print job created in Spoolman ({url})")
|
||||||
|
print("After printing, use the Print Queue to confirm or cancel the job.")
|
||||||
|
else:
|
||||||
|
print("Failed to create print job. The print will proceed without tracking.", file=sys.stderr)
|
||||||
|
|
||||||
|
# Always exit 0 so the slicer continues with the print
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
374
spoolman/api/v1/auth.py
Normal file
374
spoolman/api/v1/auth.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
"""Authentication API endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from spoolman import auth
|
||||||
|
from spoolman.auth import (
|
||||||
|
RequireAdmin,
|
||||||
|
Role,
|
||||||
|
Token,
|
||||||
|
UserResponse,
|
||||||
|
authenticate_user,
|
||||||
|
create_access_token,
|
||||||
|
get_current_user,
|
||||||
|
get_password_hash,
|
||||||
|
get_user_by_username,
|
||||||
|
is_auth_enabled,
|
||||||
|
set_auth_enabled_cache,
|
||||||
|
)
|
||||||
|
from spoolman.database import models
|
||||||
|
from spoolman.database.database import get_db_session
|
||||||
|
from spoolman.settings import SETTINGS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/auth",
|
||||||
|
tags=["auth"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatusResponse(BaseModel):
|
||||||
|
"""Authentication status response."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
has_users: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateRequest(BaseModel):
|
||||||
|
"""Request to create a new user."""
|
||||||
|
|
||||||
|
username: str = Field(..., min_length=3, max_length=64)
|
||||||
|
password: str = Field(..., min_length=6)
|
||||||
|
role: Role = Role.VIEWER
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdateRequest(BaseModel):
|
||||||
|
"""Request to update a user."""
|
||||||
|
|
||||||
|
password: Optional[str] = Field(None, min_length=6)
|
||||||
|
role: Optional[Role] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SetupRequest(BaseModel):
|
||||||
|
"""Initial setup request to create admin user."""
|
||||||
|
|
||||||
|
username: str = Field(..., min_length=3, max_length=64)
|
||||||
|
password: str = Field(..., min_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/status",
|
||||||
|
name="Get auth status",
|
||||||
|
description="Check if authentication is enabled and if any users exist.",
|
||||||
|
response_model=AuthStatusResponse,
|
||||||
|
)
|
||||||
|
async def get_auth_status(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> AuthStatusResponse:
|
||||||
|
"""Get authentication status."""
|
||||||
|
result = await db.execute(select(func.count()).select_from(models.User))
|
||||||
|
user_count = result.scalar() or 0
|
||||||
|
return AuthStatusResponse(
|
||||||
|
enabled=is_auth_enabled(),
|
||||||
|
has_users=user_count > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/setup",
|
||||||
|
name="Initial setup",
|
||||||
|
description="Create the first admin user. Only works if no users exist.",
|
||||||
|
response_model=UserResponse,
|
||||||
|
)
|
||||||
|
async def setup(
|
||||||
|
body: SetupRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Create the first admin user during initial setup."""
|
||||||
|
# Check if any users exist
|
||||||
|
result = await db.execute(select(func.count()).select_from(models.User))
|
||||||
|
user_count = result.scalar() or 0
|
||||||
|
|
||||||
|
if user_count > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Setup already completed. Users already exist.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
user = models.User(
|
||||||
|
username=body.username,
|
||||||
|
hashed_password=get_password_hash(body.password),
|
||||||
|
role=Role.ADMIN.value,
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
logger.info("Initial admin user created: %s", body.username)
|
||||||
|
return UserResponse.from_db(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/login",
|
||||||
|
name="Login",
|
||||||
|
description="Authenticate and get an access token.",
|
||||||
|
response_model=Token,
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> Token:
|
||||||
|
"""Authenticate user and return JWT token."""
|
||||||
|
if not is_auth_enabled():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Authentication is not enabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await authenticate_user(db, form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.username, "role": user.role}
|
||||||
|
)
|
||||||
|
return Token(access_token=access_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/me",
|
||||||
|
name="Get current user",
|
||||||
|
description="Get the currently authenticated user's info.",
|
||||||
|
response_model=UserResponse,
|
||||||
|
)
|
||||||
|
async def get_me(
|
||||||
|
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Get current user info."""
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
)
|
||||||
|
return UserResponse.from_db(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
# User management endpoints (admin only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users",
|
||||||
|
name="List users",
|
||||||
|
description="Get all users (admin only).",
|
||||||
|
response_model=list[UserResponse],
|
||||||
|
dependencies=[RequireAdmin],
|
||||||
|
)
|
||||||
|
async def list_users(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> list[UserResponse]:
|
||||||
|
"""List all users."""
|
||||||
|
result = await db.execute(select(models.User).order_by(models.User.username))
|
||||||
|
users = result.scalars().all()
|
||||||
|
return [UserResponse.from_db(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users",
|
||||||
|
name="Create user",
|
||||||
|
description="Create a new user (admin only).",
|
||||||
|
response_model=UserResponse,
|
||||||
|
dependencies=[RequireAdmin],
|
||||||
|
)
|
||||||
|
async def create_user(
|
||||||
|
body: UserCreateRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Create a new user."""
|
||||||
|
# Check if username exists
|
||||||
|
existing = await get_user_by_username(db, body.username)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.User(
|
||||||
|
username=body.username,
|
||||||
|
hashed_password=get_password_hash(body.password),
|
||||||
|
role=body.role.value,
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
logger.info("User created: %s (role: %s)", body.username, body.role.value)
|
||||||
|
return UserResponse.from_db(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/users/{user_id}",
|
||||||
|
name="Update user",
|
||||||
|
description="Update a user (admin only).",
|
||||||
|
response_model=UserResponse,
|
||||||
|
dependencies=[RequireAdmin],
|
||||||
|
)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
body: UserUpdateRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Update a user."""
|
||||||
|
user = await db.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.password is not None:
|
||||||
|
user.hashed_password = get_password_hash(body.password)
|
||||||
|
if body.role is not None:
|
||||||
|
user.role = body.role.value
|
||||||
|
if body.is_active is not None:
|
||||||
|
user.is_active = body.is_active
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
logger.info("User updated: %s", user.username)
|
||||||
|
return UserResponse.from_db(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/users/{user_id}",
|
||||||
|
name="Delete user",
|
||||||
|
description="Delete a user (admin only).",
|
||||||
|
dependencies=[RequireAdmin],
|
||||||
|
)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a user."""
|
||||||
|
user = await db.get(models.User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prevent self-deletion
|
||||||
|
if current_user and user.id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot delete your own account",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(user)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info("User deleted: %s", user.username)
|
||||||
|
return {"message": "User deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
class AuthToggleRequest(BaseModel):
|
||||||
|
"""Request to enable or disable authentication."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
password: Optional[str] = Field(None, description="Admin password required when disabling auth")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/toggle",
|
||||||
|
name="Toggle authentication",
|
||||||
|
description="Enable or disable authentication. Requires admin password to disable.",
|
||||||
|
response_model=AuthStatusResponse,
|
||||||
|
)
|
||||||
|
async def toggle_auth(
|
||||||
|
body: AuthToggleRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> AuthStatusResponse:
|
||||||
|
"""Enable or disable authentication."""
|
||||||
|
# Get current user count
|
||||||
|
result = await db.execute(select(func.count()).select_from(models.User))
|
||||||
|
user_count = result.scalar() or 0
|
||||||
|
|
||||||
|
if body.enabled:
|
||||||
|
# Enabling auth
|
||||||
|
if user_count == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot enable auth without users. Use /auth/setup to create the first admin user.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Disabling auth - require admin password for security
|
||||||
|
if not body.password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Admin password required to disable authentication",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find any admin user and verify password
|
||||||
|
result = await db.execute(
|
||||||
|
select(models.User).where(models.User.role == Role.ADMIN.value, models.User.is_active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
admin_users = result.scalars().all()
|
||||||
|
|
||||||
|
if not admin_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No active admin user found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if password matches any admin
|
||||||
|
password_valid = False
|
||||||
|
for admin in admin_users:
|
||||||
|
if auth.verify_password(body.password, admin.hashed_password):
|
||||||
|
password_valid = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not password_valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid admin password",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the setting in database
|
||||||
|
setting = models.Setting(
|
||||||
|
key="auth_enabled",
|
||||||
|
value=json.dumps(body.enabled),
|
||||||
|
last_updated=datetime.utcnow().replace(microsecond=0),
|
||||||
|
)
|
||||||
|
await db.merge(setting)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update the cache
|
||||||
|
set_auth_enabled_cache(body.enabled)
|
||||||
|
|
||||||
|
logger.info("Authentication %s", "enabled" if body.enabled else "disabled")
|
||||||
|
|
||||||
|
return AuthStatusResponse(
|
||||||
|
enabled=body.enabled,
|
||||||
|
has_users=user_count > 0,
|
||||||
|
)
|
||||||
@@ -366,7 +366,7 @@ async def find(
|
|||||||
else:
|
else:
|
||||||
filter_by_ids = None
|
filter_by_ids = None
|
||||||
|
|
||||||
db_items, total_count = await filament.find(
|
db_items, total_count, spool_stats = await filament.find(
|
||||||
db=db,
|
db=db,
|
||||||
ids=filter_by_ids,
|
ids=filter_by_ids,
|
||||||
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
|
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
|
||||||
@@ -383,7 +383,14 @@ async def find(
|
|||||||
# Set x-total-count header for pagination
|
# Set x-total-count header for pagination
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=jsonable_encoder(
|
content=jsonable_encoder(
|
||||||
(Filament.from_db(db_item) for db_item in db_items),
|
(
|
||||||
|
Filament.from_db(
|
||||||
|
db_item,
|
||||||
|
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
|
||||||
|
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
|
||||||
|
)
|
||||||
|
for db_item in db_items
|
||||||
|
),
|
||||||
exclude_none=True,
|
exclude_none=True,
|
||||||
),
|
),
|
||||||
headers={"x-total-count": str(total_count)},
|
headers={"x-total-count": str(total_count)},
|
||||||
|
|||||||
164
spoolman/api/v1/location.py
Normal file
164
spoolman/api/v1/location.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""Location related endpoints."""
|
||||||
|
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from spoolman.api.v1.models import Message
|
||||||
|
from spoolman.database import location
|
||||||
|
from spoolman.database.database import get_db_session
|
||||||
|
from spoolman.database import models
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/location",
|
||||||
|
tags=["location"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationResponse(BaseModel):
|
||||||
|
"""Location response model."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
location_type: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
full_path: Optional[str] = None # Only populated for single-item requests
|
||||||
|
spool_count: Optional[int] = None # Only populated when requested
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_db(item: models.Location, full_path: Optional[str] = None) -> "LocationResponse":
|
||||||
|
return LocationResponse(
|
||||||
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
parent_id=item.parent_id,
|
||||||
|
location_type=item.location_type,
|
||||||
|
description=item.description,
|
||||||
|
full_path=full_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationTreeNode(BaseModel):
|
||||||
|
"""Location tree node for nested structure."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
location_type: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
children: list["LocationTreeNode"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class LocationParameters(BaseModel):
|
||||||
|
"""Parameters for creating a location."""
|
||||||
|
|
||||||
|
name: str = Field(max_length=64, description="Location name.")
|
||||||
|
parent_id: Optional[int] = Field(None, description="Parent location ID.")
|
||||||
|
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
|
||||||
|
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
|
||||||
|
|
||||||
|
|
||||||
|
class LocationUpdateParameters(BaseModel):
|
||||||
|
"""Parameters for updating a location."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, max_length=64, description="Location name.")
|
||||||
|
parent_id: Optional[int] = Field(None, description="Parent location ID.")
|
||||||
|
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
|
||||||
|
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
name="Find locations",
|
||||||
|
description="Get all locations as a flat list.",
|
||||||
|
response_model=list[LocationResponse],
|
||||||
|
)
|
||||||
|
async def find_locations(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> list[LocationResponse]:
|
||||||
|
db_items = await location.find_all(db=db)
|
||||||
|
return [LocationResponse.from_db(item) for item in db_items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tree",
|
||||||
|
name="Get location tree",
|
||||||
|
description="Get all locations as a nested tree structure.",
|
||||||
|
response_model=list[LocationTreeNode],
|
||||||
|
)
|
||||||
|
async def get_location_tree(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> list[dict]:
|
||||||
|
return await location.get_tree(db=db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{location_id}",
|
||||||
|
name="Get location",
|
||||||
|
description="Get a specific location by ID.",
|
||||||
|
response_model=LocationResponse,
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def get_location(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
location_id: int,
|
||||||
|
) -> LocationResponse:
|
||||||
|
db_item = await location.get_by_id(db=db, location_id=location_id)
|
||||||
|
full_path = await location.get_full_path(db=db, location_id=location_id)
|
||||||
|
spool_count = await location.get_spool_count(db=db, location_id=location_id)
|
||||||
|
response = LocationResponse.from_db(db_item, full_path=full_path)
|
||||||
|
response.spool_count = spool_count
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
name="Create location",
|
||||||
|
description="Create a new location.",
|
||||||
|
response_model=LocationResponse,
|
||||||
|
responses={400: {"model": Message}, 404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def create_location(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
body: LocationParameters,
|
||||||
|
) -> LocationResponse:
|
||||||
|
db_item = await location.create(
|
||||||
|
db=db,
|
||||||
|
name=body.name,
|
||||||
|
parent_id=body.parent_id,
|
||||||
|
location_type=body.location_type,
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
return LocationResponse.from_db(db_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{location_id}",
|
||||||
|
name="Update location",
|
||||||
|
description="Update a location.",
|
||||||
|
response_model=LocationResponse,
|
||||||
|
responses={400: {"model": Message}, 404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def update_location(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
location_id: int,
|
||||||
|
body: LocationUpdateParameters,
|
||||||
|
) -> LocationResponse:
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
db_item = await location.update(db=db, location_id=location_id, data=data)
|
||||||
|
return LocationResponse.from_db(db_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{location_id}",
|
||||||
|
name="Delete location",
|
||||||
|
description="Delete a location. Spools using this location will have their location_id set to null.",
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def delete_location(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
location_id: int,
|
||||||
|
) -> Message:
|
||||||
|
await location.delete(db=db, location_id=location_id)
|
||||||
|
return Message(message="Success!")
|
||||||
@@ -221,9 +221,25 @@ class Filament(BaseModel):
|
|||||||
"Query the /fields endpoint for more details about the fields."
|
"Query the /fields endpoint for more details about the fields."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
spool_count: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Number of spools of this filament (excluding archived). Computed field.",
|
||||||
|
examples=[5],
|
||||||
|
)
|
||||||
|
total_remaining_weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
|
ge=0,
|
||||||
|
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
|
||||||
|
examples=[2500.0],
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_db(item: models.Filament) -> "Filament":
|
def from_db(
|
||||||
|
item: models.Filament,
|
||||||
|
spool_count: Optional[int] = None,
|
||||||
|
total_remaining_weight: Optional[float] = None,
|
||||||
|
) -> "Filament":
|
||||||
"""Create a new Pydantic filament object from a database filament object."""
|
"""Create a new Pydantic filament object from a database filament object."""
|
||||||
return Filament(
|
return Filament(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -251,6 +267,8 @@ class Filament(BaseModel):
|
|||||||
),
|
),
|
||||||
external_id=item.external_id,
|
external_id=item.external_id,
|
||||||
extra={field.key: field.value for field in item.extra},
|
extra={field.key: field.value for field in item.extra},
|
||||||
|
spool_count=spool_count,
|
||||||
|
total_remaining_weight=total_remaining_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -293,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.",
|
||||||
@@ -307,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.",
|
||||||
@@ -331,6 +367,10 @@ class Spool(BaseModel):
|
|||||||
examples=[""],
|
examples=[""],
|
||||||
)
|
)
|
||||||
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
|
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
|
||||||
|
needs_weighing: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether this spool needs manual weigh-in (e.g., after a cancelled print).",
|
||||||
|
)
|
||||||
extra: dict[str, str] = Field(
|
extra: dict[str, str] = Field(
|
||||||
description=(
|
description=(
|
||||||
"Extra fields for this spool. All values are JSON-encoded data. "
|
"Extra fields for this spool. All values are JSON-encoded data. "
|
||||||
@@ -347,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,
|
||||||
@@ -367,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,
|
||||||
@@ -376,14 +423,17 @@ 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,
|
||||||
archived=item.archived if item.archived is not None else False,
|
archived=item.archived if item.archived is not None else False,
|
||||||
|
needs_weighing=item.needs_weighing if item.needs_weighing is not None else False,
|
||||||
extra={field.key: field.value for field in item.extra},
|
extra={field.key: field.value for field in item.extra},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -421,6 +471,7 @@ class Info(BaseModel):
|
|||||||
db_type: str = Field(examples=["sqlite"])
|
db_type: str = Field(examples=["sqlite"])
|
||||||
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
|
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
|
||||||
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
|
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
|
||||||
|
auth_enabled: bool = Field(default=False, examples=[False])
|
||||||
|
|
||||||
|
|
||||||
class HealthCheck(BaseModel):
|
class HealthCheck(BaseModel):
|
||||||
|
|||||||
@@ -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__)
|
||||||
@@ -47,6 +51,33 @@ async def find_materials(
|
|||||||
return await filament.find_materials(db=db)
|
return await filament.find_materials(db=db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/color",
|
||||||
|
name="Find colors",
|
||||||
|
description="Get a list of all filament colors (hex codes).",
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "A list of all filament color hex codes.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"example": [
|
||||||
|
"FF0000",
|
||||||
|
"00FF00",
|
||||||
|
"0000FF",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def find_colors(
|
||||||
|
*,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> list[str]:
|
||||||
|
return await filament.find_colors(db=db)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/article-number",
|
"/article-number",
|
||||||
name="Find article numbers",
|
name="Find article numbers",
|
||||||
@@ -146,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)
|
||||||
|
|||||||
199
spoolman/api/v1/print_job.py
Normal file
199
spoolman/api/v1/print_job.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""Print job API endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from spoolman.api.v1.models import Message, Spool
|
||||||
|
from spoolman.database import print_job
|
||||||
|
from spoolman.database.database import get_db_session
|
||||||
|
from spoolman.database.models import PrintJob as DBPrintJob
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/print-job",
|
||||||
|
tags=["print-job"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrintJobResponse(BaseModel):
|
||||||
|
"""Print job response model."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
spool_id: int
|
||||||
|
spool: Optional[Spool] = None
|
||||||
|
created: datetime
|
||||||
|
finished: Optional[datetime] = None
|
||||||
|
filename: str
|
||||||
|
filament_used_g: float
|
||||||
|
filament_used_mm: Optional[float] = None
|
||||||
|
status: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db(cls, db_item: DBPrintJob) -> "PrintJobResponse":
|
||||||
|
"""Create from database model."""
|
||||||
|
return cls(
|
||||||
|
id=db_item.id,
|
||||||
|
spool_id=db_item.spool_id,
|
||||||
|
spool=Spool.from_db(db_item.spool) if db_item.spool else None,
|
||||||
|
created=db_item.created,
|
||||||
|
finished=db_item.finished,
|
||||||
|
filename=db_item.filename,
|
||||||
|
filament_used_g=db_item.filament_used_g,
|
||||||
|
filament_used_mm=db_item.filament_used_mm,
|
||||||
|
status=db_item.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePrintJobRequest(BaseModel):
|
||||||
|
"""Request to create a print job."""
|
||||||
|
|
||||||
|
spool_id: int = Field(..., description="ID of the spool to use.")
|
||||||
|
filename: str = Field(..., max_length=256, description="G-code filename.")
|
||||||
|
filament_used_g: float = Field(..., ge=0, description="Estimated filament usage in grams.")
|
||||||
|
filament_used_mm: Optional[float] = Field(None, ge=0, description="Estimated filament usage in mm.")
|
||||||
|
|
||||||
|
|
||||||
|
class CompletePrintJobRequest(BaseModel):
|
||||||
|
"""Request to complete a print job."""
|
||||||
|
|
||||||
|
comment: Optional[str] = Field(None, max_length=256, description="Optional comment for adjustment history.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
name="List print jobs",
|
||||||
|
description="Get a list of print jobs.",
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
responses={200: {"model": list[PrintJobResponse]}},
|
||||||
|
)
|
||||||
|
async def find(
|
||||||
|
*,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
status: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
Query(description="Filter by status: 'pending', 'completed', 'cancelled'."),
|
||||||
|
] = None,
|
||||||
|
spool_id: Annotated[
|
||||||
|
Optional[int],
|
||||||
|
Query(description="Filter by spool ID."),
|
||||||
|
] = None,
|
||||||
|
limit: Annotated[
|
||||||
|
Optional[int],
|
||||||
|
Query(description="Maximum number of items to return."),
|
||||||
|
] = None,
|
||||||
|
offset: Annotated[
|
||||||
|
int,
|
||||||
|
Query(description="Offset for pagination."),
|
||||||
|
] = 0,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Find print jobs."""
|
||||||
|
db_items, total_count = await print_job.find(
|
||||||
|
db=db,
|
||||||
|
status=status,
|
||||||
|
spool_id=spool_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content=jsonable_encoder(
|
||||||
|
[PrintJobResponse.from_db(item) for item in db_items],
|
||||||
|
exclude_none=True,
|
||||||
|
),
|
||||||
|
headers={"x-total-count": str(total_count)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{job_id}",
|
||||||
|
name="Get print job",
|
||||||
|
description="Get a specific print job.",
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def get(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
job_id: int,
|
||||||
|
) -> PrintJobResponse:
|
||||||
|
"""Get a print job by ID."""
|
||||||
|
db_item = await print_job.get_by_id(db, job_id)
|
||||||
|
return PrintJobResponse.from_db(db_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
name="Create print job",
|
||||||
|
description="Create a new pending print job (called by slicer post-processing script).",
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
responses={400: {"model": Message}, 404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def create(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
body: CreatePrintJobRequest,
|
||||||
|
) -> PrintJobResponse:
|
||||||
|
"""Create a new print job."""
|
||||||
|
db_item = await print_job.create(
|
||||||
|
db=db,
|
||||||
|
spool_id=body.spool_id,
|
||||||
|
filename=body.filename,
|
||||||
|
filament_used_g=body.filament_used_g,
|
||||||
|
filament_used_mm=body.filament_used_mm,
|
||||||
|
)
|
||||||
|
return PrintJobResponse.from_db(db_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{job_id}/complete",
|
||||||
|
name="Complete print job",
|
||||||
|
description="Mark a print job as completed - deducts filament from spool.",
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
responses={400: {"model": Message}, 404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def complete(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
job_id: int,
|
||||||
|
body: Optional[CompletePrintJobRequest] = None,
|
||||||
|
) -> PrintJobResponse:
|
||||||
|
"""Complete a print job."""
|
||||||
|
db_item = await print_job.complete(
|
||||||
|
db=db,
|
||||||
|
job_id=job_id,
|
||||||
|
comment=body.comment if body else None,
|
||||||
|
)
|
||||||
|
return PrintJobResponse.from_db(db_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{job_id}/cancel",
|
||||||
|
name="Cancel print job",
|
||||||
|
description="Mark a print job as cancelled - flags spool for manual weigh-in.",
|
||||||
|
response_model_exclude_none=True,
|
||||||
|
responses={400: {"model": Message}, 404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def cancel(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
job_id: int,
|
||||||
|
) -> PrintJobResponse:
|
||||||
|
"""Cancel a print job."""
|
||||||
|
db_item = await print_job.cancel(db=db, job_id=job_id)
|
||||||
|
return PrintJobResponse.from_db(db_item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{job_id}",
|
||||||
|
name="Delete print job",
|
||||||
|
description="Delete a print job.",
|
||||||
|
response_model=Message,
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def delete(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
job_id: int,
|
||||||
|
) -> Message:
|
||||||
|
"""Delete a print job."""
|
||||||
|
await print_job.delete(db=db, job_id=job_id)
|
||||||
|
return Message(message="Success!")
|
||||||
@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
|
|||||||
from spoolman.exceptions import ItemNotFoundError
|
from spoolman.exceptions import ItemNotFoundError
|
||||||
from spoolman.ws import websocket_manager
|
from spoolman.ws import websocket_manager
|
||||||
|
|
||||||
from . import backup as backup_module, export, externaldb, field, filament, models, other, setting, spool, vendor
|
from . import auth, backup as backup_module, export, externaldb, field, filament, location as location_module, models, other, print_job, setting, spool, vendor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ async def info() -> models.Info:
|
|||||||
db_type=str(env.get_database_type() or "sqlite"),
|
db_type=str(env.get_database_type() or "sqlite"),
|
||||||
git_commit=env.get_commit_hash(),
|
git_commit=env.get_commit_hash(),
|
||||||
build_date=env.get_build_date(),
|
build_date=env.get_build_date(),
|
||||||
|
auth_enabled=auth.is_auth_enabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -113,3 +114,6 @@ app.include_router(other.router)
|
|||||||
app.include_router(externaldb.router)
|
app.include_router(externaldb.router)
|
||||||
app.include_router(export.router)
|
app.include_router(export.router)
|
||||||
app.include_router(backup_module.router)
|
app.include_router(backup_module.router)
|
||||||
|
app.include_router(print_job.router)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(location_module.router)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -84,6 +96,7 @@ class SpoolParameters(BaseModel):
|
|||||||
examples=[""],
|
examples=[""],
|
||||||
)
|
)
|
||||||
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
|
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
|
||||||
|
needs_weighing: bool = Field(default=False, description="Whether this spool needs manual weigh-in.")
|
||||||
extra: Optional[dict[str, str]] = Field(
|
extra: Optional[dict[str, str]] = Field(
|
||||||
None,
|
None,
|
||||||
description="Extra fields for this spool.",
|
description="Extra fields for this spool.",
|
||||||
@@ -211,6 +224,17 @@ async def find(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
|
filament_color_hex: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
Query(
|
||||||
|
alias="filament.color_hex",
|
||||||
|
title="Filament Color",
|
||||||
|
description=(
|
||||||
|
"Filter by exact filament color hex code (without #). Separate multiple colors with a comma."
|
||||||
|
),
|
||||||
|
examples=["FF0000", "FF0000,00FF00,0000FF"],
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
filament_vendor_name: Annotated[
|
filament_vendor_name: Annotated[
|
||||||
Optional[str],
|
Optional[str],
|
||||||
Query(
|
Query(
|
||||||
@@ -263,6 +287,33 @@ async def find(
|
|||||||
bool,
|
bool,
|
||||||
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
|
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
|
||||||
] = False,
|
] = False,
|
||||||
|
needs_weighing: Annotated[
|
||||||
|
Optional[bool],
|
||||||
|
Query(title="Needs Weighing", description="Filter by spools that need manual weigh-in."),
|
||||||
|
] = None,
|
||||||
|
remaining_weight_lt: Annotated[
|
||||||
|
Optional[float],
|
||||||
|
Query(
|
||||||
|
title="Remaining Weight Less Than",
|
||||||
|
description="Filter by spools with remaining weight (in grams) less than this value.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
remaining_weight_gt: Annotated[
|
||||||
|
Optional[float],
|
||||||
|
Query(
|
||||||
|
title="Remaining Weight Greater Than",
|
||||||
|
description="Filter by spools with remaining weight (in grams) greater than this value.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
q: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
Query(
|
||||||
|
title="Search",
|
||||||
|
description=(
|
||||||
|
"Global search term. Searches across filament name, vendor name, material, and location."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
sort: Annotated[
|
sort: Annotated[
|
||||||
Optional[str],
|
Optional[str],
|
||||||
Query(
|
Query(
|
||||||
@@ -299,14 +350,19 @@ 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,
|
||||||
|
filament_color_hex=filament_color_hex,
|
||||||
vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
|
vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
|
||||||
vendor_id=filament_vendor_ids,
|
vendor_id=filament_vendor_ids,
|
||||||
location=location,
|
location=location,
|
||||||
lot_nr=lot_nr,
|
lot_nr=lot_nr,
|
||||||
allow_archived=allow_archived,
|
allow_archived=allow_archived,
|
||||||
|
needs_weighing=needs_weighing,
|
||||||
|
remaining_weight_lt=remaining_weight_lt,
|
||||||
|
remaining_weight_gt=remaining_weight_gt,
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
@@ -415,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,
|
||||||
@@ -500,6 +558,69 @@ async def delete(
|
|||||||
return Message(message="Success!")
|
return Message(message="Success!")
|
||||||
|
|
||||||
|
|
||||||
|
# Bulk operations
|
||||||
|
|
||||||
|
|
||||||
|
class BulkArchiveParameters(BaseModel):
|
||||||
|
spool_ids: list[int] = Field(description="List of spool IDs to archive/unarchive.")
|
||||||
|
archived: bool = Field(description="Whether to archive (true) or unarchive (false).")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteParameters(BaseModel):
|
||||||
|
spool_ids: list[int] = Field(description="List of spool IDs to delete.")
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateParameters(BaseModel):
|
||||||
|
spool_ids: list[int] = Field(description="List of spool IDs to update.")
|
||||||
|
location: Optional[str] = Field(None, max_length=64, description="New location for all spools.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/bulk/archive",
|
||||||
|
name="Bulk archive spools",
|
||||||
|
description="Archive or unarchive multiple spools at once.",
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def bulk_archive(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
body: BulkArchiveParameters,
|
||||||
|
) -> Message:
|
||||||
|
for spool_id in body.spool_ids:
|
||||||
|
await spool.update(db=db, spool_id=spool_id, data={"archived": body.archived})
|
||||||
|
return Message(message=f"Updated {len(body.spool_ids)} spools.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/bulk/delete",
|
||||||
|
name="Bulk delete spools",
|
||||||
|
description="Delete multiple spools at once.",
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def bulk_delete(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
body: BulkDeleteParameters,
|
||||||
|
) -> Message:
|
||||||
|
for spool_id in body.spool_ids:
|
||||||
|
await spool.delete(db=db, spool_id=spool_id)
|
||||||
|
return Message(message=f"Deleted {len(body.spool_ids)} spools.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/bulk/update",
|
||||||
|
name="Bulk update spools",
|
||||||
|
description="Update the same fields on multiple spools at once.",
|
||||||
|
responses={404: {"model": Message}},
|
||||||
|
)
|
||||||
|
async def bulk_update(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
body: BulkUpdateParameters,
|
||||||
|
) -> Message:
|
||||||
|
data = body.model_dump(exclude={"spool_ids"}, exclude_unset=True)
|
||||||
|
for spool_id in body.spool_ids:
|
||||||
|
await spool.update(db=db, spool_id=spool_id, data=data)
|
||||||
|
return Message(message=f"Updated {len(body.spool_ids)} spools.")
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{spool_id}/use",
|
"/{spool_id}/use",
|
||||||
name="Use spool filament",
|
name="Use spool filament",
|
||||||
|
|||||||
237
spoolman/auth.py
Normal file
237
spoolman/auth.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""Authentication utilities and dependencies."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from spoolman.database import models
|
||||||
|
from spoolman.database.database import get_db_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
SECRET_KEY = os.environ.get("SPOOLMAN_AUTH_SECRET_KEY", secrets.token_urlsafe(32))
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("SPOOLMAN_AUTH_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours default
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# OAuth2 scheme - auto_error=False allows unauthenticated access when auth is disabled
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login", auto_error=False)
|
||||||
|
|
||||||
|
# Cached auth enabled state (loaded from database on startup)
|
||||||
|
_auth_enabled_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def is_auth_enabled() -> bool:
|
||||||
|
"""Check if authentication is enabled (from cached database setting)."""
|
||||||
|
return _auth_enabled_cache
|
||||||
|
|
||||||
|
|
||||||
|
def set_auth_enabled_cache(enabled: bool) -> None:
|
||||||
|
"""Update the cached auth enabled state."""
|
||||||
|
global _auth_enabled_cache
|
||||||
|
_auth_enabled_cache = enabled
|
||||||
|
logger.info("Auth enabled cache updated: %s", enabled)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_auth_enabled_from_db(db: AsyncSession) -> bool:
|
||||||
|
"""Load auth_enabled setting from database and update cache."""
|
||||||
|
from spoolman.settings import SETTINGS
|
||||||
|
|
||||||
|
setting = await db.get(models.Setting, "auth_enabled")
|
||||||
|
if setting is not None:
|
||||||
|
enabled = json.loads(setting.value)
|
||||||
|
else:
|
||||||
|
# Use default from settings definition
|
||||||
|
enabled = json.loads(SETTINGS["auth_enabled"].default)
|
||||||
|
|
||||||
|
set_auth_enabled_cache(enabled)
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
"""User roles with increasing permissions."""
|
||||||
|
|
||||||
|
VIEWER = "viewer" # Can only view
|
||||||
|
OPERATOR = "operator" # Can view + record usage
|
||||||
|
EDITOR = "editor" # Can view + use + create + edit
|
||||||
|
ADMIN = "admin" # Full access
|
||||||
|
|
||||||
|
|
||||||
|
# Role hierarchy - each role includes permissions of roles below it
|
||||||
|
ROLE_HIERARCHY = {
|
||||||
|
Role.VIEWER: 0,
|
||||||
|
Role.OPERATOR: 1,
|
||||||
|
Role.EDITOR: 2,
|
||||||
|
Role.ADMIN: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""JWT token response."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
"""Data extracted from JWT token."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
role: Role
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""User data returned by API (no password)."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: Role
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_db(user: models.User) -> "UserResponse":
|
||||||
|
"""Convert database model to response model."""
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
role=Role(user.role),
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a plain password against a hashed password."""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Hash a password."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""Create a JWT access token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[models.User]:
|
||||||
|
"""Get a user by username."""
|
||||||
|
result = await db.execute(select(models.User).where(models.User.username == username))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(db: AsyncSession, username: str, password: str) -> Optional[models.User]:
|
||||||
|
"""Authenticate a user by username and password."""
|
||||||
|
user = await get_user_by_username(db, username)
|
||||||
|
if not user or not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
if not user.is_active:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||||
|
) -> Optional[models.User]:
|
||||||
|
"""Get the current authenticated user from JWT token.
|
||||||
|
|
||||||
|
Returns None if auth is disabled or no token provided.
|
||||||
|
Raises HTTPException if token is invalid.
|
||||||
|
"""
|
||||||
|
if not is_auth_enabled():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if token is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await get_user_by_username(db, username)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found or inactive",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(minimum_role: Role):
|
||||||
|
"""Dependency factory that requires a minimum role level.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.post("/spool", dependencies=[Depends(require_role(Role.EDITOR))])
|
||||||
|
async def create_spool(...):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def role_checker(
|
||||||
|
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
|
||||||
|
) -> Optional[models.User]:
|
||||||
|
# If auth is disabled, allow all operations
|
||||||
|
if not is_auth_enabled():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_role = Role(current_user.role)
|
||||||
|
if ROLE_HIERARCHY[user_role] < ROLE_HIERARCHY[minimum_role]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Requires {minimum_role.value} role or higher",
|
||||||
|
)
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
|
# Common dependencies for different permission levels
|
||||||
|
RequireViewer = Depends(require_role(Role.VIEWER))
|
||||||
|
RequireOperator = Depends(require_role(Role.OPERATOR))
|
||||||
|
RequireEditor = Depends(require_role(Role.EDITOR))
|
||||||
|
RequireAdmin = Depends(require_role(Role.ADMIN))
|
||||||
@@ -114,13 +114,16 @@ async def find(
|
|||||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> tuple[list[models.Filament], int]:
|
) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
|
||||||
"""Find a list of filament objects by search criteria.
|
"""Find a list of filament objects by search criteria.
|
||||||
|
|
||||||
Sort by a field by passing a dict with the field name as key and the sort order as value.
|
Sort by a field by passing a dict with the field name as key and the sort order as value.
|
||||||
The field name can contain nested fields, e.g. vendor.name.
|
The field name can contain nested fields, e.g. vendor.name.
|
||||||
|
|
||||||
Returns a tuple containing the list of items and the total count of matching items.
|
Returns a tuple containing:
|
||||||
|
- list of filaments
|
||||||
|
- total count of matching items
|
||||||
|
- dict mapping filament_id to (spool_count, total_remaining_weight)
|
||||||
"""
|
"""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(models.Filament)
|
select(models.Filament)
|
||||||
@@ -160,7 +163,25 @@ async def find(
|
|||||||
if total_count is None:
|
if total_count is None:
|
||||||
total_count = len(result)
|
total_count = len(result)
|
||||||
|
|
||||||
return result, total_count
|
# Fetch spool statistics for all filaments in the result
|
||||||
|
spool_stats: dict[int, tuple[int, float]] = {}
|
||||||
|
if result:
|
||||||
|
filament_ids = [f.id for f in result]
|
||||||
|
stats_stmt = (
|
||||||
|
select(
|
||||||
|
models.Spool.filament_id,
|
||||||
|
func.count(models.Spool.id).label("spool_count"),
|
||||||
|
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
|
||||||
|
)
|
||||||
|
.where(models.Spool.filament_id.in_(filament_ids))
|
||||||
|
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
|
||||||
|
.group_by(models.Spool.filament_id)
|
||||||
|
)
|
||||||
|
stats_rows = await db.execute(stats_stmt)
|
||||||
|
for row in stats_rows.all():
|
||||||
|
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
|
||||||
|
|
||||||
|
return result, total_count, spool_stats
|
||||||
|
|
||||||
|
|
||||||
async def update(
|
async def update(
|
||||||
@@ -220,6 +241,16 @@ async def find_materials(
|
|||||||
return [row[0] for row in rows.all() if row[0] is not None]
|
return [row[0] for row in rows.all() if row[0] is not None]
|
||||||
|
|
||||||
|
|
||||||
|
async def find_colors(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Find a list of unique filament colors (hex codes) from the filament table."""
|
||||||
|
stmt = select(models.Filament.color_hex).distinct()
|
||||||
|
rows = await db.execute(stmt)
|
||||||
|
return sorted([row[0] for row in rows.all() if row[0] is not None])
|
||||||
|
|
||||||
|
|
||||||
async def find_article_numbers(
|
async def find_article_numbers(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
148
spoolman/database/location.py
Normal file
148
spoolman/database/location.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Database operations for Location model."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from spoolman.database import models
|
||||||
|
from spoolman.exceptions import ItemNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
parent_id: Optional[int] = None,
|
||||||
|
location_type: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> models.Location:
|
||||||
|
"""Create a new location."""
|
||||||
|
# Verify parent exists if specified
|
||||||
|
if parent_id is not None:
|
||||||
|
parent = await db.get(models.Location, parent_id)
|
||||||
|
if parent is None:
|
||||||
|
raise ItemNotFoundError(f"Parent location with ID {parent_id} not found.")
|
||||||
|
|
||||||
|
db_item = models.Location(
|
||||||
|
name=name,
|
||||||
|
parent_id=parent_id,
|
||||||
|
location_type=location_type,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.add(db_item)
|
||||||
|
await db.commit()
|
||||||
|
return db_item
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_id(db: AsyncSession, location_id: int) -> models.Location:
|
||||||
|
"""Get a location by ID."""
|
||||||
|
stmt = select(models.Location).where(models.Location.id == location_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
db_item = result.scalar_one_or_none()
|
||||||
|
if db_item is None:
|
||||||
|
raise ItemNotFoundError(f"Location with ID {location_id} not found.")
|
||||||
|
return db_item
|
||||||
|
|
||||||
|
|
||||||
|
async def find(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
parent_id: Optional[int] = None,
|
||||||
|
) -> list[models.Location]:
|
||||||
|
"""Find locations, optionally filtered by parent."""
|
||||||
|
stmt = select(models.Location).order_by(models.Location.name)
|
||||||
|
if parent_id is not None:
|
||||||
|
stmt = stmt.where(models.Location.parent_id == parent_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def find_all(db: AsyncSession) -> list[models.Location]:
|
||||||
|
"""Get all locations."""
|
||||||
|
stmt = select(models.Location).order_by(models.Location.name)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
location_id: int,
|
||||||
|
data: dict,
|
||||||
|
) -> models.Location:
|
||||||
|
"""Update a location."""
|
||||||
|
db_item = await get_by_id(db, location_id)
|
||||||
|
|
||||||
|
# Verify parent exists if changing parent
|
||||||
|
if "parent_id" in data and data["parent_id"] is not None:
|
||||||
|
# Prevent setting parent to self
|
||||||
|
if data["parent_id"] == location_id:
|
||||||
|
raise ValueError("Location cannot be its own parent.")
|
||||||
|
parent = await db.get(models.Location, data["parent_id"])
|
||||||
|
if parent is None:
|
||||||
|
raise ItemNotFoundError(f"Parent location with ID {data['parent_id']} not found.")
|
||||||
|
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(db_item, key, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return db_item
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, location_id: int) -> None:
|
||||||
|
"""Delete a location."""
|
||||||
|
db_item = await get_by_id(db, location_id)
|
||||||
|
await db.delete(db_item)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_full_path(db: AsyncSession, location_id: int) -> str:
|
||||||
|
"""Get the full path of a location as a string (e.g., 'Room > Shelf > Bin')."""
|
||||||
|
parts = []
|
||||||
|
current_id: Optional[int] = location_id
|
||||||
|
|
||||||
|
while current_id is not None:
|
||||||
|
location = await get_by_id(db, current_id)
|
||||||
|
parts.insert(0, location.name)
|
||||||
|
current_id = location.parent_id
|
||||||
|
|
||||||
|
return " > ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tree(db: AsyncSession) -> list[dict]:
|
||||||
|
"""Get all locations as a nested tree structure."""
|
||||||
|
all_locations = await find_all(db)
|
||||||
|
|
||||||
|
# Build lookup dict
|
||||||
|
by_id = {loc.id: loc for loc in all_locations}
|
||||||
|
|
||||||
|
# Build tree
|
||||||
|
def build_node(loc: models.Location) -> dict:
|
||||||
|
return {
|
||||||
|
"id": loc.id,
|
||||||
|
"name": loc.name,
|
||||||
|
"location_type": loc.location_type,
|
||||||
|
"description": loc.description,
|
||||||
|
"children": [
|
||||||
|
build_node(by_id[child.id])
|
||||||
|
for child in all_locations
|
||||||
|
if child.parent_id == loc.id
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get root nodes (no parent)
|
||||||
|
roots = [loc for loc in all_locations if loc.parent_id is None]
|
||||||
|
return [build_node(loc) for loc in roots]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_spool_count(db: AsyncSession, location_id: int) -> int:
|
||||||
|
"""Get the number of spools at this location (not including children)."""
|
||||||
|
stmt = (
|
||||||
|
select(func.count(models.Spool.id))
|
||||||
|
.where(models.Spool.location_id == location_id)
|
||||||
|
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar() or 0
|
||||||
@@ -12,6 +12,30 @@ class Base(AsyncAttrs, DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Location(Base):
|
||||||
|
"""Hierarchical storage location (room, shelf, bin, etc.)."""
|
||||||
|
|
||||||
|
__tablename__ = "location"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
|
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("location.id", ondelete="SET NULL"), index=True)
|
||||||
|
location_type: Mapped[Optional[str]] = mapped_column(String(32), comment="Type: room, shelf, bin, etc.")
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(String(256))
|
||||||
|
|
||||||
|
# Self-referential relationships
|
||||||
|
parent: Mapped[Optional["Location"]] = relationship(
|
||||||
|
"Location",
|
||||||
|
remote_side="Location.id",
|
||||||
|
back_populates="children",
|
||||||
|
)
|
||||||
|
children: Mapped[list["Location"]] = relationship(
|
||||||
|
"Location",
|
||||||
|
back_populates="parent",
|
||||||
|
)
|
||||||
|
spools: Mapped[list["Spool"]] = relationship(back_populates="location_obj")
|
||||||
|
|
||||||
|
|
||||||
class Vendor(Base):
|
class Vendor(Base):
|
||||||
__tablename__ = "vendor"
|
__tablename__ = "vendor"
|
||||||
|
|
||||||
@@ -76,10 +100,29 @@ class Spool(Base):
|
|||||||
initial_weight: Mapped[Optional[float]] = mapped_column()
|
initial_weight: Mapped[Optional[float]] = mapped_column()
|
||||||
spool_weight: Mapped[Optional[float]] = mapped_column()
|
spool_weight: Mapped[Optional[float]] = mapped_column()
|
||||||
used_weight: Mapped[float] = mapped_column()
|
used_weight: Mapped[float] = mapped_column()
|
||||||
location: Mapped[Optional[str]] = mapped_column(String(64))
|
extra_weight: Mapped[Optional[float]] = mapped_column(
|
||||||
|
default=None,
|
||||||
|
comment="Extra weight to account for (DryPods, custom holders, etc.).",
|
||||||
|
)
|
||||||
|
color_hex: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(8),
|
||||||
|
default=None,
|
||||||
|
comment="Spool-level color override (hex without #).",
|
||||||
|
)
|
||||||
|
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
|
||||||
|
location_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
ForeignKey("location.id", ondelete="SET NULL"),
|
||||||
|
index=True,
|
||||||
|
comment="FK to hierarchical location.",
|
||||||
|
)
|
||||||
|
location_obj: Mapped[Optional["Location"]] = relationship(back_populates="spools")
|
||||||
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
|
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
|
||||||
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
||||||
archived: Mapped[Optional[bool]] = mapped_column()
|
archived: Mapped[Optional[bool]] = mapped_column()
|
||||||
|
needs_weighing: Mapped[Optional[bool]] = mapped_column(
|
||||||
|
default=False,
|
||||||
|
comment="Flag indicating spool needs manual weigh-in (e.g., after cancelled print).",
|
||||||
|
)
|
||||||
extra: Mapped[list["SpoolField"]] = relationship(
|
extra: Mapped[list["SpoolField"]] = relationship(
|
||||||
back_populates="spool",
|
back_populates="spool",
|
||||||
cascade="save-update, merge, delete, delete-orphan",
|
cascade="save-update, merge, delete, delete-orphan",
|
||||||
@@ -106,6 +149,26 @@ class SpoolAdjustment(Base):
|
|||||||
comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.")
|
comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.")
|
||||||
|
|
||||||
|
|
||||||
|
class PrintJob(Base):
|
||||||
|
"""Track pending/completed print jobs from slicer."""
|
||||||
|
|
||||||
|
__tablename__ = "print_job"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
|
||||||
|
spool: Mapped["Spool"] = relationship()
|
||||||
|
created: Mapped[datetime] = mapped_column(comment="When the job was created (slice time).")
|
||||||
|
finished: Mapped[Optional[datetime]] = mapped_column(comment="When the job was completed/cancelled.")
|
||||||
|
filename: Mapped[str] = mapped_column(String(256), comment="G-code filename.")
|
||||||
|
filament_used_g: Mapped[float] = mapped_column(comment="Estimated filament usage in grams.")
|
||||||
|
filament_used_mm: Mapped[Optional[float]] = mapped_column(comment="Estimated filament usage in mm.")
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16),
|
||||||
|
default="pending",
|
||||||
|
comment="Status: 'pending', 'completed', 'cancelled'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Setting(Base):
|
class Setting(Base):
|
||||||
__tablename__ = "setting"
|
__tablename__ = "setting"
|
||||||
|
|
||||||
@@ -139,3 +202,20 @@ class SpoolField(Base):
|
|||||||
spool: Mapped["Spool"] = relationship(back_populates="extra")
|
spool: Mapped["Spool"] = relationship(back_populates="extra")
|
||||||
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
|
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
|
||||||
value: Mapped[str] = mapped_column(Text())
|
value: Mapped[str] = mapped_column(Text())
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User account for authentication."""
|
||||||
|
|
||||||
|
__tablename__ = "user"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(128))
|
||||||
|
role: Mapped[str] = mapped_column(
|
||||||
|
String(16),
|
||||||
|
default="viewer",
|
||||||
|
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column()
|
||||||
|
|||||||
157
spoolman/database/print_job.py
Normal file
157
spoolman/database/print_job.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""Database operations for print jobs."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from spoolman.database.models import PrintJob, Spool, SpoolAdjustment
|
||||||
|
from spoolman.exceptions import ItemNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
spool_id: int,
|
||||||
|
filename: str,
|
||||||
|
filament_used_g: float,
|
||||||
|
filament_used_mm: Optional[float] = None,
|
||||||
|
) -> PrintJob:
|
||||||
|
"""Create a new pending print job."""
|
||||||
|
# Verify spool exists
|
||||||
|
spool = await db.get(Spool, spool_id)
|
||||||
|
if spool is None:
|
||||||
|
raise ItemNotFoundError(f"Spool with ID {spool_id} not found.")
|
||||||
|
|
||||||
|
job = PrintJob(
|
||||||
|
spool_id=spool_id,
|
||||||
|
created=datetime.utcnow(),
|
||||||
|
filename=filename,
|
||||||
|
filament_used_g=filament_used_g,
|
||||||
|
filament_used_mm=filament_used_mm,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(job)
|
||||||
|
|
||||||
|
# Load spool relationship
|
||||||
|
result = await db.execute(
|
||||||
|
select(PrintJob).where(PrintJob.id == job.id).options(joinedload(PrintJob.spool))
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_id(db: AsyncSession, job_id: int) -> PrintJob:
|
||||||
|
"""Get a print job by ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PrintJob).where(PrintJob.id == job_id).options(joinedload(PrintJob.spool))
|
||||||
|
)
|
||||||
|
job = result.scalar_one_or_none()
|
||||||
|
if job is None:
|
||||||
|
raise ItemNotFoundError(f"Print job with ID {job_id} not found.")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
async def find(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
spool_id: Optional[int] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[PrintJob], int]:
|
||||||
|
"""Find print jobs with optional filters."""
|
||||||
|
query = select(PrintJob).options(joinedload(PrintJob.spool))
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.where(PrintJob.status == status)
|
||||||
|
if spool_id:
|
||||||
|
query = query.where(PrintJob.spool_id == spool_id)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(PrintJob)
|
||||||
|
if status:
|
||||||
|
count_query = count_query.where(PrintJob.status == status)
|
||||||
|
if spool_id:
|
||||||
|
count_query = count_query.where(PrintJob.spool_id == spool_id)
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
total = len(count_result.all())
|
||||||
|
|
||||||
|
# Apply ordering and pagination
|
||||||
|
query = query.order_by(PrintJob.created.desc())
|
||||||
|
if offset:
|
||||||
|
query = query.offset(offset)
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all()), total
|
||||||
|
|
||||||
|
|
||||||
|
async def complete(db: AsyncSession, job_id: int, comment: Optional[str] = None) -> PrintJob:
|
||||||
|
"""Complete a print job - deduct filament from spool."""
|
||||||
|
job = await get_by_id(db, job_id)
|
||||||
|
if job.status != "pending":
|
||||||
|
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
|
||||||
|
|
||||||
|
# Deduct filament from spool
|
||||||
|
spool = await db.get(Spool, job.spool_id)
|
||||||
|
if spool is None:
|
||||||
|
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
|
||||||
|
|
||||||
|
spool.used_weight += job.filament_used_g
|
||||||
|
if spool.first_used is None:
|
||||||
|
spool.first_used = datetime.utcnow()
|
||||||
|
spool.last_used = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record adjustment
|
||||||
|
adjustment = SpoolAdjustment(
|
||||||
|
spool_id=spool.id,
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
adjustment_type="weight",
|
||||||
|
value=-job.filament_used_g,
|
||||||
|
comment=comment or f"Print job: {job.filename}",
|
||||||
|
)
|
||||||
|
db.add(adjustment)
|
||||||
|
|
||||||
|
# Update job status
|
||||||
|
job.status = "completed"
|
||||||
|
job.finished = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Reload with relationships
|
||||||
|
return await get_by_id(db, job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel(db: AsyncSession, job_id: int) -> PrintJob:
|
||||||
|
"""Cancel a print job - flag spool for weighing."""
|
||||||
|
job = await get_by_id(db, job_id)
|
||||||
|
if job.status != "pending":
|
||||||
|
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
|
||||||
|
|
||||||
|
# Flag spool for weighing
|
||||||
|
spool = await db.get(Spool, job.spool_id)
|
||||||
|
if spool is None:
|
||||||
|
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
|
||||||
|
|
||||||
|
spool.needs_weighing = True
|
||||||
|
|
||||||
|
# Update job status
|
||||||
|
job.status = "cancelled"
|
||||||
|
job.finished = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Reload with relationships
|
||||||
|
return await get_by_id(db, job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete(db: AsyncSession, job_id: int) -> None:
|
||||||
|
"""Delete a print job."""
|
||||||
|
job = await get_by_id(db, job_id)
|
||||||
|
await db.delete(job)
|
||||||
|
await db.commit()
|
||||||
@@ -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,14 +122,19 @@ 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,
|
||||||
|
filament_color_hex: Optional[Union[str, Sequence[str]]] = None,
|
||||||
vendor_name: Optional[str] = None,
|
vendor_name: Optional[str] = None,
|
||||||
vendor_id: Optional[Union[int, Sequence[int]]] = None,
|
vendor_id: Optional[Union[int, Sequence[int]]] = None,
|
||||||
location: Optional[str] = None,
|
location: Optional[str] = None,
|
||||||
lot_nr: Optional[str] = None,
|
lot_nr: Optional[str] = None,
|
||||||
allow_archived: bool = False,
|
allow_archived: bool = False,
|
||||||
|
needs_weighing: Optional[bool] = None,
|
||||||
|
remaining_weight_lt: Optional[float] = None,
|
||||||
|
remaining_weight_gt: Optional[float] = None,
|
||||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -149,6 +161,29 @@ 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)
|
||||||
|
if filament_color_hex is not None:
|
||||||
|
if isinstance(filament_color_hex, str):
|
||||||
|
colors = [c.strip() for c in filament_color_hex.split(",") if c.strip()]
|
||||||
|
else:
|
||||||
|
colors = list(filament_color_hex)
|
||||||
|
if colors:
|
||||||
|
stmt = stmt.where(models.Filament.color_hex.in_(colors))
|
||||||
|
|
||||||
if not allow_archived:
|
if not allow_archived:
|
||||||
# Since the archived field is nullable, and default is false, we need to check for both false or null
|
# Since the archived field is nullable, and default is false, we need to check for both false or null
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
@@ -158,6 +193,24 @@ async def find( # noqa: C901, PLR0912
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if needs_weighing is not None:
|
||||||
|
if needs_weighing:
|
||||||
|
stmt = stmt.where(models.Spool.needs_weighing.is_(True))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(
|
||||||
|
sqlalchemy.or_(
|
||||||
|
models.Spool.needs_weighing.is_(False),
|
||||||
|
models.Spool.needs_weighing.is_(None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by remaining_weight (computed as initial_weight - used_weight, falling back to filament.weight)
|
||||||
|
remaining_weight_expr = coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight
|
||||||
|
if remaining_weight_lt is not None:
|
||||||
|
stmt = stmt.where(remaining_weight_expr < remaining_weight_lt)
|
||||||
|
if remaining_weight_gt is not None:
|
||||||
|
stmt = stmt.where(remaining_weight_expr > remaining_weight_gt)
|
||||||
|
|
||||||
total_count = None
|
total_count = None
|
||||||
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
@@ -190,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))
|
||||||
|
|
||||||
@@ -227,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":
|
||||||
@@ -409,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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -421,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(
|
||||||
@@ -442,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:
|
||||||
@@ -454,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)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Optional
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import hishel
|
import hishel
|
||||||
@@ -20,16 +20,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
|
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
|
||||||
DEFAULT_3DFP_URL = "https://raw.githubusercontent.com/jklewa/filament-profiles-data/main/"
|
|
||||||
DEFAULT_SYNC_INTERVAL = 3600
|
DEFAULT_SYNC_INTERVAL = 3600
|
||||||
|
|
||||||
|
|
||||||
class ExternalSource(Enum):
|
|
||||||
"""External database sources."""
|
|
||||||
|
|
||||||
SPOOLMANDB = "spoolmandb"
|
|
||||||
FILAMENT_PROFILES_3D = "3dfilamentprofiles"
|
|
||||||
|
|
||||||
controller = hishel.Controller(allow_stale=True)
|
controller = hishel.Controller(allow_stale=True)
|
||||||
try:
|
try:
|
||||||
cache_path = get_cache_dir() / "hishel"
|
cache_path = get_cache_dir() / "hishel"
|
||||||
@@ -97,10 +89,6 @@ class ExternalFilament(BaseModel):
|
|||||||
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
|
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
|
||||||
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
|
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
|
||||||
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
|
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
|
||||||
source: ExternalSource = Field(
|
|
||||||
default=ExternalSource.SPOOLMANDB,
|
|
||||||
description="External database source this filament came from.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalFilamentsFile(RootModel):
|
class ExternalFilamentsFile(RootModel):
|
||||||
@@ -134,146 +122,11 @@ class ExternalMaterialsFile(RootModel):
|
|||||||
return self.root[index]
|
return self.root[index]
|
||||||
|
|
||||||
|
|
||||||
# 3dfilamentprofiles.com data models
|
|
||||||
class FilamentProperties3DFP(BaseModel):
|
|
||||||
"""Properties object from 3dfilamentprofiles."""
|
|
||||||
|
|
||||||
temp_min: Optional[int] = None
|
|
||||||
temp_max: Optional[int] = None
|
|
||||||
bed_temp_min: Optional[int] = None
|
|
||||||
bed_temp_max: Optional[int] = None
|
|
||||||
spool_weight: Optional[float] = None
|
|
||||||
flow_ratio: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Filament3DFP(BaseModel):
|
|
||||||
"""Raw filament data from 3dfilamentprofiles.com."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
brand_name: str
|
|
||||||
material: str
|
|
||||||
material_type: Optional[str] = None
|
|
||||||
color: str
|
|
||||||
rgb: Optional[str] = None
|
|
||||||
properties: Optional[dict[str, Any]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FilamentsFile3DFP(BaseModel):
|
|
||||||
"""The filaments.json file from 3dfilamentprofiles."""
|
|
||||||
|
|
||||||
filaments: list[Filament3DFP]
|
|
||||||
|
|
||||||
|
|
||||||
# Material density defaults (used when 3dfilamentprofiles doesn't provide density)
|
|
||||||
MATERIAL_DENSITIES: dict[str, float] = {
|
|
||||||
"PLA": 1.24,
|
|
||||||
"ABS": 1.04,
|
|
||||||
"PETG": 1.27,
|
|
||||||
"NYLON": 1.52,
|
|
||||||
"TPU": 1.21,
|
|
||||||
"PC": 1.3,
|
|
||||||
"WOOD": 1.28,
|
|
||||||
"CF": 1.3,
|
|
||||||
"PC/ABS": 1.19,
|
|
||||||
"HIPS": 1.03,
|
|
||||||
"PVA": 1.23,
|
|
||||||
"ASA": 1.05,
|
|
||||||
"PP": 0.9,
|
|
||||||
"POM": 1.4,
|
|
||||||
"PMMA": 1.18,
|
|
||||||
"FPE": 2.16,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_density_for_material(material: str) -> float:
|
|
||||||
"""Get density for a material, defaulting to PLA's density if unknown."""
|
|
||||||
material_upper = material.upper()
|
|
||||||
for key, density in MATERIAL_DENSITIES.items():
|
|
||||||
if key in material_upper:
|
|
||||||
return density
|
|
||||||
return 1.24 # Default to PLA density
|
|
||||||
|
|
||||||
|
|
||||||
def _transform_3dfp_to_external(filament: Filament3DFP) -> ExternalFilament:
|
|
||||||
"""Transform a 3dfilamentprofiles filament to ExternalFilament format."""
|
|
||||||
# Build name from material_type and color
|
|
||||||
name_parts = []
|
|
||||||
if filament.material_type:
|
|
||||||
name_parts.append(filament.material_type)
|
|
||||||
name_parts.append(filament.color)
|
|
||||||
name = " ".join(name_parts)
|
|
||||||
|
|
||||||
# Handle color hex (3dfp includes # prefix, SpoolmanDB doesn't)
|
|
||||||
color_hex = None
|
|
||||||
if filament.rgb:
|
|
||||||
color_hex = filament.rgb.lstrip("#").lower()
|
|
||||||
|
|
||||||
# Extract properties
|
|
||||||
props = filament.properties or {}
|
|
||||||
extruder_temp = props.get("temp_min")
|
|
||||||
extruder_temp_max = props.get("temp_max")
|
|
||||||
bed_temp = props.get("bed_temp_min")
|
|
||||||
bed_temp_max = props.get("bed_temp_max")
|
|
||||||
spool_weight = props.get("spool_weight")
|
|
||||||
|
|
||||||
# Determine finish from material_type
|
|
||||||
finish = None
|
|
||||||
if filament.material_type:
|
|
||||||
material_type_lower = filament.material_type.lower()
|
|
||||||
if "matte" in material_type_lower:
|
|
||||||
finish = Finish.MATTE
|
|
||||||
elif "glossy" in material_type_lower or "silk" in material_type_lower:
|
|
||||||
finish = Finish.GLOSSY
|
|
||||||
|
|
||||||
# Determine pattern from material_type
|
|
||||||
pattern = None
|
|
||||||
if filament.material_type:
|
|
||||||
material_type_lower = filament.material_type.lower()
|
|
||||||
if "marble" in material_type_lower:
|
|
||||||
pattern = Pattern.MARBLE
|
|
||||||
elif "sparkle" in material_type_lower or "glitter" in material_type_lower:
|
|
||||||
pattern = Pattern.SPARKLE
|
|
||||||
|
|
||||||
return ExternalFilament(
|
|
||||||
id=f"3dfp_{filament.id}",
|
|
||||||
manufacturer=filament.brand_name,
|
|
||||||
name=name,
|
|
||||||
material=filament.material,
|
|
||||||
density=_get_density_for_material(filament.material),
|
|
||||||
weight=None, # 3dfp doesn't provide filament weight
|
|
||||||
spool_weight=spool_weight,
|
|
||||||
spool_type=None,
|
|
||||||
diameter=1.75, # Default, 3dfp doesn't provide diameter
|
|
||||||
color_hex=color_hex,
|
|
||||||
color_hexes=None,
|
|
||||||
extruder_temp=extruder_temp,
|
|
||||||
extruder_temp_max=extruder_temp_max,
|
|
||||||
bed_temp=bed_temp,
|
|
||||||
bed_temp_max=bed_temp_max,
|
|
||||||
finish=finish,
|
|
||||||
multi_color_direction=None,
|
|
||||||
pattern=pattern,
|
|
||||||
translucent=False,
|
|
||||||
glow=False,
|
|
||||||
source=ExternalSource.FILAMENT_PROFILES_3D,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_external_db_url() -> str:
|
def get_external_db_url() -> str:
|
||||||
"""Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
|
"""Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
|
||||||
return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL)
|
return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL)
|
||||||
|
|
||||||
|
|
||||||
def get_3dfp_url() -> str:
|
|
||||||
"""Get the 3dfilamentprofiles URL from environment variables."""
|
|
||||||
return os.getenv("EXTERNAL_DB_3DFP_URL", DEFAULT_3DFP_URL)
|
|
||||||
|
|
||||||
|
|
||||||
def is_3dfp_enabled() -> bool:
|
|
||||||
"""Check if 3dfilamentprofiles sync is enabled."""
|
|
||||||
return os.getenv("EXTERNAL_DB_3DFP_ENABLED", "true").lower() in ("true", "1", "yes")
|
|
||||||
|
|
||||||
|
|
||||||
def get_external_db_sync_interval() -> int:
|
def get_external_db_sync_interval() -> int:
|
||||||
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
|
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
|
||||||
return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
|
return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
|
||||||
@@ -315,70 +168,36 @@ def get_materials_file() -> Path:
|
|||||||
return filecache.get_file("materials.json")
|
return filecache.get_file("materials.json")
|
||||||
|
|
||||||
|
|
||||||
def _parse_3dfp_filaments(data: bytes) -> list[Filament3DFP]:
|
|
||||||
"""Parse 3dfilamentprofiles JSON data."""
|
|
||||||
try:
|
|
||||||
parsed = FilamentsFile3DFP.model_validate_json(data)
|
|
||||||
return parsed.filaments
|
|
||||||
except Exception:
|
|
||||||
# Fallback: try parsing as raw list
|
|
||||||
import json
|
|
||||||
|
|
||||||
raw = json.loads(data)
|
|
||||||
if isinstance(raw, dict) and "filaments" in raw:
|
|
||||||
return [Filament3DFP.model_validate(f) for f in raw["filaments"]]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def _sync() -> None:
|
async def _sync() -> None:
|
||||||
logger.info("Syncing external DB.")
|
"""Sync filaments and materials from SpoolmanDB."""
|
||||||
|
logger.info("Syncing external DB (SpoolmanDB).")
|
||||||
|
|
||||||
all_filaments: list[ExternalFilament] = []
|
|
||||||
|
|
||||||
# Sync SpoolmanDB
|
|
||||||
spoolmandb_url = get_external_db_url()
|
spoolmandb_url = get_external_db_url()
|
||||||
if spoolmandb_url.strip():
|
if not spoolmandb_url.strip():
|
||||||
try:
|
logger.warning("External DB URL is empty. Skipping sync.")
|
||||||
spoolmandb_filaments = _parse_filaments_from_bytes(
|
return
|
||||||
await _download_file(urljoin(spoolmandb_url, "filaments.json"))
|
|
||||||
)
|
|
||||||
# Mark all SpoolmanDB filaments with source
|
|
||||||
for filament in spoolmandb_filaments.root:
|
|
||||||
filament.source = ExternalSource.SPOOLMANDB
|
|
||||||
all_filaments.extend(spoolmandb_filaments.root)
|
|
||||||
logger.info("SpoolmanDB synced: %d filaments", len(spoolmandb_filaments.root))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to sync SpoolmanDB: %s", e)
|
|
||||||
|
|
||||||
# Sync 3dfilamentprofiles
|
# Sync filaments
|
||||||
if is_3dfp_enabled():
|
try:
|
||||||
url_3dfp = get_3dfp_url()
|
filaments = _parse_filaments_from_bytes(
|
||||||
if url_3dfp.strip():
|
await _download_file(urljoin(spoolmandb_url, "filaments.json"))
|
||||||
try:
|
)
|
||||||
raw_data = await _download_file(urljoin(url_3dfp, "sample-filaments.json"))
|
_write_to_local_cache("filaments.json", filaments.model_dump_json().encode())
|
||||||
filaments_3dfp = _parse_3dfp_filaments(raw_data)
|
logger.info("SpoolmanDB filaments synced: %d filaments", len(filaments.root))
|
||||||
transformed = [_transform_3dfp_to_external(f) for f in filaments_3dfp]
|
except Exception as e:
|
||||||
all_filaments.extend(transformed)
|
logger.warning("Failed to sync SpoolmanDB filaments: %s", e)
|
||||||
logger.info("3dfilamentprofiles synced: %d filaments", len(transformed))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to sync 3dfilamentprofiles: %s", e)
|
|
||||||
|
|
||||||
# Write combined filaments
|
# Sync materials
|
||||||
combined = ExternalFilamentsFile(root=all_filaments)
|
try:
|
||||||
_write_to_local_cache("filaments.json", combined.model_dump_json().encode())
|
materials = _parse_materials_from_bytes(
|
||||||
|
await _download_file(urljoin(spoolmandb_url, "materials.json"))
|
||||||
|
)
|
||||||
|
_write_to_local_cache("materials.json", materials.model_dump_json().encode())
|
||||||
|
logger.info("SpoolmanDB materials synced: %d materials", len(materials.root))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to sync SpoolmanDB materials: %s", e)
|
||||||
|
|
||||||
# Materials only from SpoolmanDB
|
logger.info("External DB sync complete.")
|
||||||
if spoolmandb_url.strip():
|
|
||||||
try:
|
|
||||||
materials = _parse_materials_from_bytes(
|
|
||||||
await _download_file(urljoin(spoolmandb_url, "materials.json"))
|
|
||||||
)
|
|
||||||
_write_to_local_cache("materials.json", materials.model_dump_json().encode())
|
|
||||||
logger.info("Materials synced: %d", len(materials.root))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to sync materials: %s", e)
|
|
||||||
|
|
||||||
logger.info("External DB sync complete. Total filaments: %d", len(all_filaments))
|
|
||||||
|
|
||||||
|
|
||||||
def schedule_tasks(scheduler: Scheduler) -> None:
|
def schedule_tasks(scheduler: Scheduler) -> None:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
|
|||||||
from prometheus_client import generate_latest
|
from prometheus_client import generate_latest
|
||||||
from scheduler.asyncio.scheduler import Scheduler
|
from scheduler.asyncio.scheduler import Scheduler
|
||||||
|
|
||||||
from spoolman import env, externaldb
|
from spoolman import auth, env, externaldb
|
||||||
from spoolman.api.v1.router import app as v1_app
|
from spoolman.api.v1.router import app as v1_app
|
||||||
from spoolman.client import SinglePageApplication
|
from spoolman.client import SinglePageApplication
|
||||||
from spoolman.database import database
|
from spoolman.database import database
|
||||||
@@ -173,6 +173,12 @@ async def startup() -> None:
|
|||||||
project_root = Path(__file__).parent.parent
|
project_root = Path(__file__).parent.parent
|
||||||
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC221
|
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC221
|
||||||
|
|
||||||
|
# Load auth enabled setting from database
|
||||||
|
logger.info("Loading auth settings...")
|
||||||
|
async for db in database.get_db_session():
|
||||||
|
auth_enabled = await auth.load_auth_enabled_from_db(db)
|
||||||
|
logger.info("Authentication enabled: %s", auth_enabled)
|
||||||
|
|
||||||
# Setup scheduler
|
# Setup scheduler
|
||||||
schedule = Scheduler()
|
schedule = Scheduler()
|
||||||
database.schedule_tasks(schedule)
|
database.schedule_tasks(schedule)
|
||||||
|
|||||||
@@ -72,3 +72,4 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
|
|||||||
|
|
||||||
register_setting("locations", SettingType.ARRAY, json.dumps([]))
|
register_setting("locations", SettingType.ARRAY, json.dumps([]))
|
||||||
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
|
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
|
||||||
|
register_setting("auth_enabled", SettingType.BOOLEAN, json.dumps(False))
|
||||||
|
|||||||
Reference in New Issue
Block a user