Compare commits
76 Commits
e1349cc760
...
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 | |||
| e264d7ed4e | |||
| 46cc0656d6 | |||
| 10b98957b8 | |||
| 767e0aea95 | |||
| 021d287865 | |||
| 49d4bf85fd | |||
| a18daa8134 | |||
| bf775f3745 | |||
| a97914f6c8 | |||
| 59f8f5d9f7 | |||
| 1e3e956dd1 | |||
| bf458b6077 | |||
| a1572694e3 | |||
| 613176dd42 | |||
| 34eaa6a9b1 | |||
| 671d3bd3d1 | |||
| 6d0ecec068 | |||
| d804329b77 | |||
| 388669d0c6 | |||
| 3451996a8f | |||
| dc166450ce | |||
| 66e956f021 | |||
| e14efa2b89 | |||
| d17a66b0ec | |||
| cbb2940193 | |||
| 478806dcc8 | |||
| 27d85eb479 | |||
| ea5b06493c | |||
| a55194ba16 | |||
|
|
cda756557d | ||
|
|
b627e151a8 | ||
|
|
848f5dcc94 | ||
|
|
6adb6bedc2 | ||
|
|
edda1e965a | ||
|
|
7bb0c6b1ec | ||
|
|
9124ce0b86 | ||
|
|
753140ecc3 | ||
|
|
9baf91380a | ||
|
|
9df3f0c91b | ||
|
|
e49976bf8a | ||
|
|
25f78e90e6 |
613
CLAUDE.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Spoolman is a self-hosted web service for managing 3D printer filament spools. It tracks inventory, monitors usage, and integrates with OctoPrint and Klipper/Moonraker.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: Python 3.9-3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic
|
||||
- Frontend: React 19, TypeScript, Vite, Ant Design, Refine framework
|
||||
- Databases: SQLite (default), PostgreSQL, MySQL, CockroachDB
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Python (PDM)
|
||||
|
||||
```bash
|
||||
pdm install # Install all dependencies including dev
|
||||
pdm sync --prod --no-editable # Install production dependencies only
|
||||
pdm run app # Run backend server (uvicorn)
|
||||
pdm run itest # Run integration tests (all databases)
|
||||
pdm run docs # Generate API documentation
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm ci # Install dependencies
|
||||
npm run dev # Development server with hot reload
|
||||
npm run build # Production build
|
||||
npm run tsc # Type check only
|
||||
```
|
||||
|
||||
### Linting/Formatting
|
||||
|
||||
```bash
|
||||
black . # Format Python code
|
||||
ruff check . --fix # Lint and auto-fix Python
|
||||
pre-commit run --all-files # Run all pre-commit hooks
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Tests run in Docker containers against multiple database types:
|
||||
|
||||
```bash
|
||||
python tests_integration/run.py sqlite # Single database
|
||||
python tests_integration/run.py sqlite postgres # Multiple databases
|
||||
pdm run itest # All databases
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
```bash
|
||||
pytest tests_integration/tests/spool/test_add.py::test_add_spool_remaining_weight -v
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
1. Edit models in `spoolman/database/models.py`
|
||||
2. Run the server to update local SQLite
|
||||
3. `pdm run alembic revision -m "description" --autogenerate`
|
||||
4. Review generated migration, format with Black/Ruff
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (`/spoolman/`)
|
||||
|
||||
- `main.py` - FastAPI app entry, startup/shutdown, SPA mounting
|
||||
- `env.py` - Environment variable parsing and configuration
|
||||
- `ws.py` - WebSocket manager for real-time updates
|
||||
- `api/v1/` - REST endpoints and WebSocket handlers
|
||||
- `router.py` - Main router with WebSocket root endpoint
|
||||
- `spool.py`, `filament.py`, `vendor.py` - CRUD endpoints per resource
|
||||
- `models.py` - Pydantic response models
|
||||
- `database/` - SQLAlchemy models and async database operations
|
||||
- `models.py` - ORM definitions (Vendor, Filament, Spool, Setting, *Field)
|
||||
- `database.py` - Connection setup, backup logic, migrations
|
||||
|
||||
### Frontend (`/client/src/`)
|
||||
|
||||
- `App.tsx` - Routing and Refine framework setup
|
||||
- `pages/` - Page components (spools/, filaments/, vendors/, settings/)
|
||||
- `components/dataProvider.ts` - REST API integration
|
||||
- `components/liveProvider.ts` - WebSocket live updates
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
Vendor (1) ──→ (many) Filament (1) ──→ (many) Spool
|
||||
```
|
||||
|
||||
All entities support custom fields via *Field tables (VendorField, FilamentField, SpoolField).
|
||||
|
||||
### API Patterns
|
||||
|
||||
- REST: Standard CRUD at `/api/v1/{resource}`
|
||||
- WebSocket: Real-time events at `WS /api/v1/` or `WS /api/v1/{resource}`
|
||||
- Query params: `?skip=0&limit=50&sort_by=name&sort_order=asc`
|
||||
|
||||
### Key Configuration
|
||||
|
||||
Environment variables (see `.env.example`):
|
||||
- `SPOOLMAN_DB_TYPE` - Database type (sqlite/mysql/postgres/cockroachdb)
|
||||
- `SPOOLMAN_PORT` - Server port (default: 7912)
|
||||
- `SPOOLMAN_DEBUG_MODE` - Enables permissive CORS
|
||||
- `VITE_APIURL` - Frontend API base URL (e.g., `/api/v1`)
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Line length: 120 characters
|
||||
- Python target: 3.9
|
||||
- Pre-commit hooks enforce Black formatting and Ruff linting
|
||||
- Backend is fully async using SQLAlchemy AsyncSession
|
||||
|
||||
## Gitea Repository
|
||||
|
||||
- **URL:** http://192.168.0.5:3022/tonym/spoolman2
|
||||
- **API Token:** 8a04b3cb5dbb54e2d895b707305523c3ad83a945
|
||||
- **Upstream:** https://github.com/Donkie/Spoolman (remote: `upstream`)
|
||||
|
||||
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
|
||||
|
||||
## UX Improvement Issues (Gitea)
|
||||
|
||||
| # | Title | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | Inline Creation Modals | ✅ DONE |
|
||||
| 2 | Smart Density Defaults | ✅ DONE |
|
||||
| 3 | QR Code Sizing Fixes | ✅ DONE |
|
||||
| 4 | Layout Max-Width | ✅ DONE |
|
||||
| 5 | Temperature Ranges | ✅ DONE |
|
||||
| 6 | Spool Adjustment History | ✅ DONE |
|
||||
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
|
||||
| 8 | (duplicate of #7) | ✅ DONE |
|
||||
| 9 | Hierarchical Locations (Room/Bin) | ✅ DONE |
|
||||
| 10 | Database Import/Export | ✅ DONE |
|
||||
| 11 | Slicer Integration / Print Jobs | ✅ DONE |
|
||||
| 12 | Redesign Home Page Dashboard | ✅ DONE |
|
||||
| 13 | Batch Operations (Multi-Select Edit) | ✅ DONE |
|
||||
| 14 | Extra Weight Field (DryPods, etc.) | ✅ DONE |
|
||||
| 15 | Spool Count per Filament in List | ✅ DONE |
|
||||
| 16 | Filter Spools by Color | ✅ DONE |
|
||||
| 17 | Image Attachments for Filaments | **Open** |
|
||||
| 18 | QR Code Labels for Filaments | **Open** |
|
||||
| 19 | Integration with filamentcolors.xyz | ✅ DONE |
|
||||
| 20 | Gallery View for Spools (Color Grid) | ✅ DONE |
|
||||
| 21 | QR Code Version/Complexity Config | **Open** |
|
||||
| 23 | Reporting & Data Export | **Open** |
|
||||
| 24 | NFC/RFID Tag Support | **Open** |
|
||||
| 25 | Sort Spools by Custom Fields | ✅ DONE |
|
||||
| 26 | Label Template Enhancements | **Open** |
|
||||
| 27 | QR Scanner Camera Device Selector | **Open** |
|
||||
| 28 | Mobile-Friendly UI / Responsive | **Open** |
|
||||
| 29 | Table Column Customization | **Open** |
|
||||
| 30 | Printer Management & Parts Inventory | **Open** |
|
||||
| 31 | Special Filament Properties | **Open** |
|
||||
| 32 | SpoolType Model (Cardboard/Plastic/Metal) | **Open** |
|
||||
| 34 | Color Swatch Border for Dark Mode | ✅ DONE |
|
||||
| 35 | Global Search Box | ✅ DONE |
|
||||
| 36 | Spool-Level Overrides (Color, Price) | ✅ DONE |
|
||||
| 37 | Plugin System for Extensions | **Open** |
|
||||
| 39 | Usage Statistics & Cost Tracking | ✅ DONE |
|
||||
| 40 | Storage vs Active Spool Status | **Open** |
|
||||
| 41 | Load Cell / Scale Integration | **Open** |
|
||||
| 42 | Allow Spools Heavier Than Max | ✅ DONE |
|
||||
| 43 | Show Filament Fields on Spool View | ✅ DONE |
|
||||
| 44 | HomeAssistant Add-On Packaging | **Open** |
|
||||
| 45 | Filament Swatch Card Generator | **Open** |
|
||||
| 46 | Project-Based Spool Grouping | **Open** |
|
||||
| 47 | G-code Filament Usage Estimation | **Open** |
|
||||
|
||||
### Other Features (not tracked in Gitea)
|
||||
- ✅ Theme Colors (5 options)
|
||||
- ✅ 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
|
||||
|
||||
Added ability to create filament/vendor inline without leaving the page:
|
||||
- `client/src/components/vendorCreateModal.tsx` - Quick vendor creation
|
||||
- `client/src/components/filamentCreateModal.tsx` - Quick filament creation with nested vendor modal
|
||||
- Modified spool create to add "Create Filament" in dropdown
|
||||
- Modified filament create to add "Create Manufacturer" in dropdown
|
||||
- Uses `dropdownRender` pattern (same as location select)
|
||||
- Uses `useInvalidate` to refresh lists after creation
|
||||
|
||||
### Issue #2 Complete - Smart Density Defaults
|
||||
|
||||
Auto-fill density based on material type:
|
||||
- `client/src/utils/materialDefaults.ts` - Density lookup table with fuzzy matching
|
||||
- Material field converted to AutoComplete (dropdown suggestions + free text)
|
||||
- Fuzzy matching: "PLA Silk" → PLA (1.24 g/cm³)
|
||||
- New filaments default to 1.75mm diameter and 1.24 g/cm³ (PLA)
|
||||
- Applied to both full create page and FilamentCreateModal
|
||||
- Edit page not affected (preserves user customizations)
|
||||
|
||||
Material densities in lookup:
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Issue #3 Complete - QR Code Sizing Fixes
|
||||
|
||||
See upstream issue: https://github.com/Donkie/Spoolman/issues/671
|
||||
|
||||
Changes:
|
||||
- Removed fixed 50% max-width constraint (now uses fit-content by default)
|
||||
- Added `qrCodePadding` setting (0-10mm, default 2mm)
|
||||
- Added `qrCodeMaxWidth` setting (0-100%, 0 = auto)
|
||||
- Added UI controls with slider + input number for both settings
|
||||
|
||||
Files modified:
|
||||
- `client/src/pages/printing/printing.tsx` - Added settings to interface
|
||||
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - CSS and UI controls
|
||||
|
||||
### Issue #4 Complete - Layout Max-Width
|
||||
|
||||
Changes:
|
||||
- Added 1400px max-width container in `SpoolmanLayout`
|
||||
- Added 800px max-width for forms via CSS override in `overrides.css`
|
||||
- Content is centered on wide screens
|
||||
|
||||
### Issue #5 Complete - Temperature Ranges
|
||||
|
||||
Store extruder and bed temperatures as min/max ranges:
|
||||
- Added 4 new DB columns: `settings_extruder_temp_min/max`, `settings_bed_temp_min/max`
|
||||
- Old single-value columns kept for backward compatibility (deprecated)
|
||||
- Alembic migration copies existing values to both min and max
|
||||
- Frontend uses Space.Compact with min/max input pairs
|
||||
- Label template shows ranges: "ET: 190-220 °C"
|
||||
|
||||
Files:
|
||||
- `spoolman/database/models.py` - New columns
|
||||
- `migrations/versions/2025_01_15_0200-*.py` - Migration
|
||||
- `spoolman/api/v1/models.py`, `filament.py` - API changes
|
||||
- `client/src/pages/filaments/create.tsx` - Form updates
|
||||
- `client/src/pages/printing/spoolQrCodePrintingDialog.tsx` - Template tags
|
||||
|
||||
### Issue #6 Complete - Spool Adjustment History
|
||||
|
||||
Track history of all spool weight/length adjustments with timestamps and comments.
|
||||
|
||||
Backend:
|
||||
- `spoolman/database/models.py` - Added `SpoolAdjustment` model with relationship to Spool
|
||||
- `migrations/versions/2025_01_15_0300-*.py` - Migration for spool_adjustment table
|
||||
- `spoolman/api/v1/models.py` - Added `SpoolAdjustment` Pydantic model
|
||||
- `spoolman/api/v1/spool.py` - Added comment field to use/measure endpoints, GET /spool/{id}/adjustments endpoint
|
||||
- `spoolman/database/spool.py` - Updated use_weight, use_length, measure functions to record adjustments
|
||||
|
||||
Frontend:
|
||||
- `client/src/pages/spools/model.tsx` - Added `ISpoolAdjustment` interface
|
||||
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
|
||||
- `client/public/locales/en/common.json` - Translation keys for history table
|
||||
|
||||
### Issue #7 REMOVED - 3dfilamentprofiles Integration
|
||||
|
||||
**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.
|
||||
|
||||
### Issue #10 Complete - Database Import/Export
|
||||
|
||||
Full backup/restore functionality for migrating between databases (PostgreSQL ↔ SQLite).
|
||||
|
||||
Backend (`spoolman/api/v1/backup.py`):
|
||||
- `GET /api/v1/backup/export` - Downloads all data as single JSON file
|
||||
- `POST /api/v1/backup/import` - Imports JSON with `replace` option
|
||||
- Exports: vendors, filaments, spools, spool_adjustments, settings
|
||||
- Handles ID remapping to maintain relationships
|
||||
- Requires `python-multipart` package for file uploads
|
||||
|
||||
Frontend (`client/src/pages/settings/generalSettings.tsx`):
|
||||
- "Backup & Restore" section in Settings page
|
||||
- "Download Backup" button for export
|
||||
- File upload with "Import (Merge)" or "Import (Replace All)" options
|
||||
- Confirmation dialog for destructive replace operation
|
||||
|
||||
Migration workflow:
|
||||
1. Export from PostgreSQL instance via Settings → Download Backup
|
||||
2. Reconfigure container for SQLite
|
||||
3. Import via Settings → Import (Replace All)
|
||||
|
||||
---
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
To run locally against the Unraid PostgreSQL database:
|
||||
|
||||
```bash
|
||||
# Create virtualenv and install deps
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[postgres]"
|
||||
|
||||
# Start backend (reads .env file)
|
||||
source .venv/bin/activate
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
uvicorn spoolman.main:app --host 0.0.0.0 --port 7912
|
||||
|
||||
# Start frontend (separate terminal)
|
||||
cd client
|
||||
VITE_APIURL=http://localhost:7912/api/v1 npm run dev
|
||||
```
|
||||
|
||||
Access at http://localhost:5173
|
||||
|
||||
### Issue #11 Complete - Theme Colors
|
||||
|
||||
Added theme color selector with 5 color options:
|
||||
- `client/src/contexts/color-mode/index.tsx` - Theme color system with THEME_COLORS constant
|
||||
- Colors: orange, teal, red, green, blue
|
||||
- Persists in localStorage
|
||||
- Settings page has color selector buttons
|
||||
|
||||
### Issue #12 Complete - Clickable Dashboard Cards
|
||||
|
||||
Dashboard cards now link to their respective list pages:
|
||||
- `client/src/pages/home/index.tsx` - Card body is clickable, links to list
|
||||
- Only the + button stays independent (for create)
|
||||
|
||||
### Issue #13 Complete - QR Code Size Setting
|
||||
|
||||
Added explicit QR code size control:
|
||||
- `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface
|
||||
- `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
|
||||
|
||||
### Production (Unraid)
|
||||
|
||||
Deployed on Unraid with br0 network:
|
||||
- **URL:** http://192.168.0.11:8000 or https://spoolman.nastynas.xyz
|
||||
- **Database:** SQLite
|
||||
- **Network:** br0 (own IP: 192.168.0.11)
|
||||
|
||||
Update script: `/mnt/user/appdata/spoolman2-build/update.sh`
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
### Issue #7 - 3dfilamentprofiles Integration REMOVED
|
||||
The 3dfilamentprofiles.com website has Vercel bot protection that blocks automated requests. Their GitHub repo (jklewa/filament-profiles-data) only provides sample data (~3 filaments). Since SpoolmanDB already has 6,957+ filaments from major brands, the 3dfp integration was removed rather than maintaining dead code.
|
||||
25
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-bookworm AS python-builder
|
||||
FROM python:3.12-slim-bookworm AS python-builder
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
@@ -32,28 +32,17 @@ COPY --chown=app:app spoolman /home/app/spoolman/spoolman
|
||||
COPY --chown=app:app alembic.ini /home/app/spoolman/
|
||||
COPY --chown=app:app README.md /home/app/spoolman/
|
||||
|
||||
FROM python:3.12-bookworm AS python-runner
|
||||
FROM python:3.12-slim-bookworm AS python-runner
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/Donkie/Spoolman
|
||||
LABEL org.opencontainers.image.description="Keep track of your inventory of 3D-printer filament spools."
|
||||
LABEL org.opencontainers.image.licenses=MIT
|
||||
|
||||
# Install latest su-exec
|
||||
RUN set -ex; \
|
||||
\
|
||||
curl -o /usr/local/bin/su-exec.c https://raw.githubusercontent.com/ncopa/su-exec/master/su-exec.c; \
|
||||
\
|
||||
fetch_deps='gcc libc-dev'; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends $fetch_deps; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
gcc -Wall \
|
||||
/usr/local/bin/su-exec.c -o/usr/local/bin/su-exec; \
|
||||
chown root:root /usr/local/bin/su-exec; \
|
||||
chmod 0755 /usr/local/bin/su-exec; \
|
||||
rm /usr/local/bin/su-exec.c; \
|
||||
\
|
||||
apt-get purge -y --auto-remove $fetch_deps
|
||||
# Install gosu for privilege dropping
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gosu \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add local user so we don't run as root
|
||||
RUN groupmod -g 1000 users \
|
||||
|
||||
1
client/.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/dev-dist
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
8442
client/package-lock.json
generated
@@ -1,59 +1,59 @@
|
||||
{
|
||||
"name": "spoolman-ui",
|
||||
"version": "0.22.1",
|
||||
"version": "0.23C.2",
|
||||
"engines": {
|
||||
"node": "20.x"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@loadable/component": "^5.16.7",
|
||||
"@refinedev/antd": "^5.46.1",
|
||||
"@refinedev/core": "^4.57.9",
|
||||
"@refinedev/kbar": "^1.3.16",
|
||||
"@refinedev/react-router": "^1.0.1",
|
||||
"@refinedev/simple-rest": "^5.0.10",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-query-devtools": "^4.36.1",
|
||||
"@types/loadable__component": "^5.13.9",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@refinedev/antd": "^6.0.3",
|
||||
"@refinedev/core": "^5.0.7",
|
||||
"@refinedev/kbar": "^2.0.1",
|
||||
"@refinedev/react-router": "^2.0.3",
|
||||
"@refinedev/simple-rest": "^6.0.1",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@types/loadable__component": "^5.13.10",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@yudiel/react-qr-scanner": "^1.2.10",
|
||||
"antd": "^5.25.4",
|
||||
"axios": "^1.12.2",
|
||||
"@yudiel/react-qr-scanner": "^2.5.0",
|
||||
"axios": "^1.13.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next": "^25.7.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^19.1.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-router": "^7.6.2",
|
||||
"react-to-print": "^3.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"zustand": "^5.0.5"
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-router": "^7.11.0",
|
||||
"react-to-print": "^3.2.0",
|
||||
"recharts": "^3.7.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@refinedev/cli": "^2.16.46",
|
||||
"@refinedev/cli": "^2.16.50",
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||
"@typescript-eslint/parser": "^8.33.1",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-mkcert": "^1.17.8"
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.52.0",
|
||||
"@typescript-eslint/parser": "^8.52.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-mkcert": "^1.17.9",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "refine dev",
|
||||
|
||||
BIN
client/public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 768 B |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@@ -27,11 +27,31 @@
|
||||
"unArchive": "Unarchive",
|
||||
"hideArchived": "Hide Archived",
|
||||
"showArchived": "Show Archived",
|
||||
"prev": "Prev",
|
||||
"next": "Next",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"notAccessTitle": "You don't have permission to access",
|
||||
"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.",
|
||||
"batch": {
|
||||
"selected": "{{count}} selected",
|
||||
"clear_selection": "Clear",
|
||||
"archive_confirm": "Archive Spools",
|
||||
"archive_confirm_content": "Are you sure you want to archive {{count}} spools?",
|
||||
"unarchive_confirm": "Unarchive Spools",
|
||||
"unarchive_confirm_content": "Are you sure you want to unarchive {{count}} spools?",
|
||||
"delete_confirm": "Delete Spools",
|
||||
"delete_confirm_content": "Are you sure you want to delete {{count}} spools? This action cannot be undone.",
|
||||
"move": "Move",
|
||||
"move_title": "Move Spools to Location",
|
||||
"move_description": "Select a location for the {{count}} selected spools."
|
||||
},
|
||||
"notifications": {
|
||||
"success": "Successful",
|
||||
"error": "Error (status code: {{statusCode}})",
|
||||
@@ -47,6 +67,11 @@
|
||||
"validationError": "Validation error: {{error}}"
|
||||
},
|
||||
"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",
|
||||
"version": "Version",
|
||||
"unknown": "Unknown",
|
||||
@@ -123,7 +148,14 @@
|
||||
"no": "No",
|
||||
"simple": "Simple",
|
||||
"withIcon": "With Icon"
|
||||
}
|
||||
},
|
||||
"qrCodePadding": "QR Code Padding",
|
||||
"qrCodePaddingTooltip": "White space around the QR code for better scanning reliability",
|
||||
"qrCodeMaxWidth": "QR Code Max Width",
|
||||
"qrCodeMaxWidthTooltip": "Maximum width of QR code as percentage of label. Set to 0 for automatic sizing.",
|
||||
"qrCodeSize": "QR Code Size",
|
||||
"qrCodeSizeTooltip": "Fixed size of QR code in millimeters. Set to 0 for automatic sizing based on label.",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"spoolSelect": {
|
||||
"title": "Select Spools",
|
||||
@@ -149,6 +181,7 @@
|
||||
},
|
||||
"spool": {
|
||||
"spool": "Spools",
|
||||
"search_placeholder": "Search by name, material, vendor, location...",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"filament_name": "Filament",
|
||||
@@ -156,22 +189,37 @@
|
||||
"filament_internal": "Internal",
|
||||
"filament_external": "External",
|
||||
"price": "Price",
|
||||
"color": "Color",
|
||||
"material": "Material",
|
||||
"weight_to_use": "Weight",
|
||||
"used_weight": "Used Weight",
|
||||
"remaining_weight": "Remaining Weight",
|
||||
"remaining_value": "Remaining Value",
|
||||
"measured_weight": "Measured Weight",
|
||||
"used_length": "Used Length",
|
||||
"remaining_length": "Remaining Length",
|
||||
"initial_weight": "Initial Weight",
|
||||
"spool_weight": "Empty Weight",
|
||||
"extra_weight": "Extra Weight",
|
||||
"color_hex": "Color Override",
|
||||
"location": "Location",
|
||||
"lot_nr": "Lot Nr",
|
||||
"first_used": "First Used",
|
||||
"last_used": "Last Used",
|
||||
"registered": "Registered",
|
||||
"comment": "Comment",
|
||||
"archived": "Archived"
|
||||
"archived": "Archived",
|
||||
"filament_fields": "Filament Custom Fields",
|
||||
"adjustment_history": "Adjustment History",
|
||||
"show_history": "Show History",
|
||||
"print_history": "Print History",
|
||||
"show_print_history": "Show Print History",
|
||||
"adjustment_timestamp": "Date/Time",
|
||||
"adjustment_type": "Type",
|
||||
"adjustment_type_weight": "Weight",
|
||||
"adjustment_type_length": "Length",
|
||||
"adjustment_value": "Amount",
|
||||
"adjustment_comment": "Comment"
|
||||
},
|
||||
"fields_help": {
|
||||
"price": "Price of a full spool. If not set, the price of the filament will be assumed instead.",
|
||||
@@ -181,6 +229,8 @@
|
||||
"measured_weight": "How much the filament and spool weigh.",
|
||||
"initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.",
|
||||
"spool_weight": "The weight of the spool when it is empty. Leave empty to use the value from the filament or the manufacturer.",
|
||||
"extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.",
|
||||
"color_hex": "Override the filament color for this specific spool.",
|
||||
"location": "Where the spool is located if you have multiple locations where you store your spools.",
|
||||
"lot_nr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used.",
|
||||
"external_filament": "You have selected a filament from the external database. A filament object (and possibly a manufacturer object) will be created automatically when you create this spool. This can create duplicate filament objects if you have already created a filament object for this filament."
|
||||
@@ -213,6 +263,40 @@
|
||||
"last_used": "Last used {{date}}"
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
"location": "Location",
|
||||
"locations": "Locations",
|
||||
"create": "Create Location",
|
||||
"titles": {
|
||||
"create": "Create Location",
|
||||
"edit": "Edit Location",
|
||||
"list": "Locations"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"name_required": "Location name is required",
|
||||
"name_placeholder": "e.g., Shelf A",
|
||||
"parent": "Parent Location",
|
||||
"parent_placeholder": "Select parent (optional)",
|
||||
"location_type": "Type",
|
||||
"location_type_placeholder": "Select type (optional)",
|
||||
"description": "Description",
|
||||
"spool_count": "Spools",
|
||||
"full_path": "Full Path"
|
||||
},
|
||||
"types": {
|
||||
"room": "Room",
|
||||
"shelf": "Shelf",
|
||||
"bin": "Bin",
|
||||
"drawer": "Drawer",
|
||||
"box": "Box",
|
||||
"other": "Other"
|
||||
},
|
||||
"messages": {
|
||||
"delete_confirm": "Are you sure you want to delete this location?",
|
||||
"has_children": "This location has child locations"
|
||||
}
|
||||
},
|
||||
"filament": {
|
||||
"filament": "Filaments",
|
||||
"fields": {
|
||||
@@ -231,18 +315,24 @@
|
||||
"comment": "Comment",
|
||||
"settings_extruder_temp": "Extruder Temp",
|
||||
"settings_bed_temp": "Bed Temp",
|
||||
"temp_min": "Min",
|
||||
"temp_max": "Max",
|
||||
"color_hex": "Color",
|
||||
"single_color": "Single",
|
||||
"multi_color": "Multi",
|
||||
"coaxial": "Coextruded",
|
||||
"longitudinal": "Longitudinal",
|
||||
"external_id": "External ID",
|
||||
"spools": "Show Spools"
|
||||
"spools": "Show Spools",
|
||||
"not_set": "Not Set",
|
||||
"spool_count": "Spools",
|
||||
"total_remaining_weight": "Total Remaining"
|
||||
},
|
||||
"fields_help": {
|
||||
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
|
||||
"material": "E.g. PLA, ABS, PETG, etc.",
|
||||
"price": "Price of a full spool.",
|
||||
"density": "Auto-filled based on material. Standard: PLA 1.24, ABS 1.04, PETG 1.27 g/cm³.",
|
||||
"weight": "The filament weight of a full spool (net weight). This should not include the weight of the spool itself, only the filament. It is what is usually written on the packaging.",
|
||||
"spool_weight": "The weight of an empty spool. Used to determine measured weight of a spool.",
|
||||
"article_number": "E.g. EAN, UPC, etc.",
|
||||
@@ -259,10 +349,27 @@
|
||||
"form": {
|
||||
"filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
|
||||
"import_external": "Import from External",
|
||||
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary."
|
||||
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.",
|
||||
"all_sources": "All Sources",
|
||||
"select_filament": "Select a filament...",
|
||||
"created_success": "Created filament \"{{name}}\"",
|
||||
"vendor_placeholder": "Select a manufacturer",
|
||||
"name_placeholder": "E.g. Matte Black",
|
||||
"material_placeholder": "PLA, PETG, ABS, etc.",
|
||||
"parse_temps": "Parse temps from comment",
|
||||
"temps_parsed": "Temperature fields updated from comment",
|
||||
"no_temps_found": "No temperature patterns found in comment"
|
||||
},
|
||||
"buttons": {
|
||||
"add_spool": "Add Spool"
|
||||
},
|
||||
"color_lookup": {
|
||||
"button": "Find color from filamentcolors.xyz",
|
||||
"title": "Find Color - filamentcolors.xyz",
|
||||
"manufacturer": "Manufacturer",
|
||||
"color_search": "Search color name...",
|
||||
"results": "results",
|
||||
"showing_page": "page {{page}}"
|
||||
}
|
||||
},
|
||||
"vendor": {
|
||||
@@ -287,13 +394,59 @@
|
||||
"show_title": "[Manufacturer #{{id}}] {{name}}"
|
||||
},
|
||||
"form": {
|
||||
"vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!"
|
||||
"vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
|
||||
"created_success": "Created manufacturer \"{{name}}\"",
|
||||
"name_required": "Name is required"
|
||||
}
|
||||
},
|
||||
"print_job": {
|
||||
"fields": {
|
||||
"created": "Created",
|
||||
"filename": "Filename",
|
||||
"filament_used": "Filament Used",
|
||||
"status": "Status",
|
||||
"finished": "Finished"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"home": "Home",
|
||||
"welcome": "Welcome to your Spoolman instance!",
|
||||
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
|
||||
"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",
|
||||
@@ -322,8 +475,48 @@
|
||||
"round_prices": {
|
||||
"label": "Round prices",
|
||||
"tooltip": "Round prices to the nearest whole number."
|
||||
},
|
||||
"theme_color": {
|
||||
"label": "Theme Color",
|
||||
"orange": "Orange",
|
||||
"teal": "Teal",
|
||||
"red": "Red",
|
||||
"green": "Green",
|
||||
"blue": "Blue"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup & Restore",
|
||||
"description": "Export all your data for backup or migrate to a different database.",
|
||||
"export_title": "Export Data",
|
||||
"export_button": "Download Backup",
|
||||
"export_success": "Backup downloaded successfully",
|
||||
"export_error": "Failed to export data",
|
||||
"import_title": "Import Data",
|
||||
"select_file": "Select backup file",
|
||||
"import_merge": "Import (Merge)",
|
||||
"import_replace": "Import (Replace All)",
|
||||
"import_success": "Data imported successfully",
|
||||
"import_error": "Failed to import 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!"
|
||||
},
|
||||
"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": {
|
||||
"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>",
|
||||
@@ -354,6 +547,39 @@
|
||||
"key_not_changed": "Please change the key to something else.",
|
||||
"delete_confirm": "Delete field {{name}}?",
|
||||
"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": {
|
||||
@@ -395,5 +621,53 @@
|
||||
"new_location": "New Location",
|
||||
"no_location": "No Location",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "Spoolman",
|
||||
"short_name": "Spoolman",
|
||||
"description": "Keep track of your inventory of 3D-printer filament spools.",
|
||||
"icons": [
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"sizes": "512x512",
|
||||
"src": "icon512_maskable.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "any",
|
||||
"sizes": "512x512",
|
||||
"src": "icon512_rounded.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"background_color": "#1F1F1F",
|
||||
"theme_color": "#DC7734",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
BIN
client/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
client/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
client/public/pwa-64x64.png
Normal file
|
After Width: | Height: | Size: 710 B |
@@ -9,6 +9,7 @@ import {
|
||||
FileOutlined,
|
||||
HighlightOutlined,
|
||||
HomeOutlined,
|
||||
PrinterOutlined,
|
||||
QuestionOutlined,
|
||||
TableOutlined,
|
||||
ToolOutlined,
|
||||
@@ -26,10 +27,16 @@ import { Favicon } from "./components/favicon";
|
||||
import { SpoolmanLayout } from "./components/layout";
|
||||
import liveProvider from "./components/liveProvider";
|
||||
import SpoolmanNotificationProvider from "./components/notificationProvider";
|
||||
import { ProtectedRoute } from "./components/protectedRoute";
|
||||
import { AuthProvider, useAuth } from "./contexts/auth";
|
||||
import { ColorModeContextProvider } from "./contexts/color-mode";
|
||||
import { languages } from "./i18n";
|
||||
import { setupAxiosAuth } from "./utils/axiosAuth";
|
||||
import { getAPIURL, getBasePath } from "./utils/url";
|
||||
|
||||
// Setup axios auth interceptors once at module load
|
||||
setupAxiosAuth();
|
||||
|
||||
interface ResourcePageProps {
|
||||
resource: "spools" | "filaments" | "vendors";
|
||||
page: "list" | "create" | "edit" | "show";
|
||||
@@ -53,6 +60,11 @@ const LoadablePage = loadable((props: LoadablePageProps) => import(`./pages/${pr
|
||||
cacheKey: (props: LoadablePageProps) => `page-${props.name}`,
|
||||
});
|
||||
|
||||
// Lazy load login page
|
||||
const LoginPage = loadable(() => import("./pages/login/index"), {
|
||||
fallback: <div>Loading...</div>,
|
||||
});
|
||||
|
||||
function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
@@ -88,17 +100,11 @@ function App() {
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={getBasePath() + "/"}>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider>
|
||||
<ConfigProvider
|
||||
locale={antdLocale}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: "#dc7734",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Refine
|
||||
<AuthProvider>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider>
|
||||
<ConfigProvider locale={antdLocale}>
|
||||
<Refine
|
||||
dataProvider={dataProvider(getAPIURL())}
|
||||
notificationProvider={SpoolmanNotificationProvider}
|
||||
i18nProvider={i18nProvider}
|
||||
@@ -149,6 +155,14 @@ function App() {
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "print-queue",
|
||||
list: "/print-queue",
|
||||
meta: {
|
||||
canDelete: false,
|
||||
icon: <PrinterOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locations",
|
||||
list: "/locations",
|
||||
@@ -181,11 +195,17 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
{/* Login route - outside protected area */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
element={
|
||||
<SpoolmanLayout>
|
||||
<Outlet />
|
||||
</SpoolmanLayout>
|
||||
<ProtectedRoute>
|
||||
<SpoolmanLayout>
|
||||
<Outlet />
|
||||
</SpoolmanLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<LoadablePage name="home" />} />
|
||||
@@ -232,6 +252,7 @@ function App() {
|
||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
||||
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
|
||||
<Route path="*" element={<ErrorComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -242,9 +263,10 @@ function App() {
|
||||
<ReactQueryDevtools />
|
||||
<Favicon url={getBasePath() + "/favicon.svg"} />
|
||||
</Refine>
|
||||
</ConfigProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</ConfigProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -116,8 +116,8 @@ function Column<Obj extends Entity>(
|
||||
if (open && props.onFilterDropdownOpen) {
|
||||
props.onFilterDropdownOpen();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if (props.dataId) {
|
||||
columnProps.key = props.dataId;
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export function RichColumn<Obj extends Entity>(
|
||||
}
|
||||
|
||||
interface FilteredQueryColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
|
||||
filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[]>;
|
||||
filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[], unknown>;
|
||||
allowMultipleFilters?: boolean;
|
||||
}
|
||||
|
||||
@@ -220,6 +220,52 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
|
||||
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> {
|
||||
unit: string;
|
||||
maxDecimals?: number;
|
||||
@@ -394,8 +440,9 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
|
||||
const commonProps = {
|
||||
...props,
|
||||
id: ["extra", field.key],
|
||||
dataId: `extra.${field.key}` as keyof Obj & string,
|
||||
title: field.name,
|
||||
sorter: false,
|
||||
sorter: true,
|
||||
transform: (value: unknown) => {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
|
||||
@@ -19,7 +19,7 @@ const dataProvider = (
|
||||
|
||||
if (pagination && pagination.mode == "server") {
|
||||
const pageSize = pagination.pageSize ?? 10;
|
||||
const offset = ((pagination.current ?? 1) - 1) * pageSize;
|
||||
const offset = ((pagination.currentPage ?? 1) - 1) * pageSize;
|
||||
queryParams["limit"] = pageSize;
|
||||
queryParams["offset"] = offset;
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
238
client/src/components/filamentCreateModal.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useSelect } from "@refinedev/antd";
|
||||
import { useInvalidate, useTranslate } from "@refinedev/core";
|
||||
import { AutoComplete, Button, ColorPicker, Divider, Form, Input, InputNumber, Modal, Select, message } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Color } from "antd/es/color-picker";
|
||||
import { IFilament } from "../pages/filaments/model";
|
||||
import { IVendor } from "../pages/vendors/model";
|
||||
import {
|
||||
DEFAULT_DENSITY,
|
||||
DEFAULT_DIAMETER,
|
||||
getDensityForMaterial,
|
||||
KNOWN_MATERIALS,
|
||||
} from "../utils/materialDefaults";
|
||||
import { getAPIURL } from "../utils/url";
|
||||
import { VendorCreateModal } from "./vendorCreateModal";
|
||||
|
||||
export interface FilamentCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onSuccess: (filament: IFilament) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for quickly creating a filament inline without navigating away from the current page.
|
||||
* Includes essential fields and the ability to create a vendor inline.
|
||||
*/
|
||||
export function FilamentCreateModal(props: FilamentCreateModalProps) {
|
||||
const [form] = Form.useForm();
|
||||
const t = useTranslate();
|
||||
const invalidate = useInvalidate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
|
||||
const [materialSearch, setMaterialSearch] = useState("");
|
||||
|
||||
// Material options for AutoComplete
|
||||
const materialOptions = useMemo(() => {
|
||||
const filtered = materialSearch
|
||||
? KNOWN_MATERIALS.filter((m) => m.toLowerCase().includes(materialSearch.toLowerCase()))
|
||||
: KNOWN_MATERIALS;
|
||||
return filtered.map((material) => ({ value: material, label: material }));
|
||||
}, [materialSearch]);
|
||||
|
||||
// Auto-fill density when material is selected or entered
|
||||
const handleMaterialChange = (value: string) => {
|
||||
const density = getDensityForMaterial(value);
|
||||
if (density !== undefined) {
|
||||
form.setFieldValue("density", density);
|
||||
}
|
||||
};
|
||||
|
||||
// Vendor select
|
||||
const { selectProps: vendorSelectProps } = useSelect<IVendor>({
|
||||
resource: "vendor",
|
||||
optionLabel: "name",
|
||||
});
|
||||
|
||||
const handleVendorCreated = async (vendor: IVendor) => {
|
||||
// Invalidate vendor cache so select options refresh
|
||||
await invalidate({
|
||||
resource: "vendor",
|
||||
invalidates: ["list"],
|
||||
});
|
||||
form.setFieldValue("vendor_id", vendor.id);
|
||||
setIsVendorModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Convert ColorPicker value to hex string if present
|
||||
let colorHex: string | undefined = undefined;
|
||||
if (values.color_hex) {
|
||||
const color = values.color_hex as Color;
|
||||
colorHex = typeof color === "string" ? color : color.toHexString();
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: values.name || undefined,
|
||||
vendor_id: values.vendor_id || undefined,
|
||||
material: values.material || undefined,
|
||||
density: values.density,
|
||||
diameter: values.diameter,
|
||||
weight: values.weight || undefined,
|
||||
color_hex: colorHex,
|
||||
};
|
||||
|
||||
const response = await fetch(getAPIURL() + "/filament", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || "Failed to create filament");
|
||||
}
|
||||
|
||||
const filament: IFilament = await response.json();
|
||||
|
||||
// Invalidate filament cache so lists refresh
|
||||
await invalidate({
|
||||
resource: "filament",
|
||||
invalidates: ["list"],
|
||||
});
|
||||
|
||||
message.success(t("filament.form.created_success", { name: filament.name || `#${filament.id}` }));
|
||||
|
||||
props.onSuccess(filament);
|
||||
form.resetFields();
|
||||
props.onClose();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "Failed to create filament");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
// Set default values when modal opens
|
||||
const handleAfterOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
form.setFieldsValue({
|
||||
diameter: DEFAULT_DIAMETER,
|
||||
density: DEFAULT_DENSITY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VendorCreateModal
|
||||
isOpen={isVendorModalOpen}
|
||||
onSuccess={handleVendorCreated}
|
||||
onClose={() => setIsVendorModalOpen(false)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("filament.titles.create")}
|
||||
open={props.isOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={isSubmitting}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
destroyOnClose
|
||||
width={500}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* Vendor with inline create option */}
|
||||
<Form.Item
|
||||
label={t("filament.fields.vendor")}
|
||||
name="vendor_id"
|
||||
>
|
||||
<Select
|
||||
{...vendorSelectProps}
|
||||
allowClear
|
||||
placeholder={t("filament.form.vendor_placeholder")}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsVendorModalOpen(true)}
|
||||
style={{ width: "100%", textAlign: "left" }}
|
||||
>
|
||||
{t("vendor.titles.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("filament.fields.name")}
|
||||
name="name"
|
||||
>
|
||||
<Input maxLength={64} placeholder={t("filament.form.name_placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("filament.fields.material")}
|
||||
name="material"
|
||||
>
|
||||
<AutoComplete
|
||||
options={materialOptions}
|
||||
onSearch={setMaterialSearch}
|
||||
onSelect={handleMaterialChange}
|
||||
onBlur={(e) => handleMaterialChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={t("filament.form.material_placeholder")}
|
||||
maxLength={64}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("filament.fields.color_hex")}
|
||||
name="color_hex"
|
||||
>
|
||||
<ColorPicker format="hex" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("filament.fields.density")}
|
||||
name="density"
|
||||
rules={[{ required: true, type: "number", min: 0.01, max: 100 }]}
|
||||
help={t("filament.fields_help.density")}
|
||||
>
|
||||
<InputNumber addonAfter="g/cm³" precision={2} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("filament.fields.diameter")}
|
||||
name="diameter"
|
||||
rules={[{ required: true, type: "number", min: 0.01, max: 10 }]}
|
||||
>
|
||||
<InputNumber addonAfter="mm" precision={2} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("filament.fields.weight")}
|
||||
name="weight"
|
||||
help={t("filament.fields_help.weight")}
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={0} min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { Form, Modal, Select } from "antd";
|
||||
import { useMemo } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { formatFilamentLabel } from "../pages/spools/functions";
|
||||
import { searchMatches } from "../utils/filtering";
|
||||
@@ -14,8 +15,10 @@ export function FilamentImportModal(props: {
|
||||
const t = useTranslate();
|
||||
|
||||
const externalFilaments = useGetExternalDBFilaments();
|
||||
const filamentOptions =
|
||||
externalFilaments.data?.map((item) => {
|
||||
|
||||
// Format filaments for the select dropdown
|
||||
const filamentOptions = useMemo(() => {
|
||||
const options = (externalFilaments.data ?? []).map((item) => {
|
||||
return {
|
||||
label: formatFilamentLabel(
|
||||
item.name,
|
||||
@@ -28,8 +31,11 @@ export function FilamentImportModal(props: {
|
||||
value: item.id,
|
||||
item: item,
|
||||
};
|
||||
}) ?? [];
|
||||
filamentOptions.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;
|
||||
}, [externalFilaments.data]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -37,7 +43,17 @@ export function FilamentImportModal(props: {
|
||||
open={props.isOpen}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => props.onClose()}
|
||||
width={600}
|
||||
>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={"filament.form.import_external_description"}
|
||||
components={{
|
||||
br: <br />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
@@ -51,19 +67,12 @@ export function FilamentImportModal(props: {
|
||||
form.resetFields();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={"filament.form.import_external_description"}
|
||||
components={{
|
||||
br: <br />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<Form.Item name="filament" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={filamentOptions}
|
||||
showSearch
|
||||
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
|
||||
placeholder={t("filament.form.select_filament")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DownOutlined } from "@ant-design/icons";
|
||||
import type { RefineThemedLayoutV2HeaderProps } from "@refinedev/antd";
|
||||
import type { RefineThemedLayoutHeaderProps } from "@refinedev/antd";
|
||||
import { useGetLocale, useSetLocale } from "@refinedev/core";
|
||||
import { Layout as AntdLayout, Button, Dropdown, MenuProps, Space, Switch, theme } from "antd";
|
||||
import React, { useContext } from "react";
|
||||
@@ -10,7 +10,7 @@ import QRCodeScannerModal from "../qrCodeScanner";
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({ sticky }) => {
|
||||
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ sticky }) => {
|
||||
const { token } = useToken();
|
||||
const locale = useGetLocale();
|
||||
const changeLanguage = useSetLocale();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2 } from "@refinedev/antd";
|
||||
import { ThemedLayout, ThemedSider, ThemedTitle } from "@refinedev/antd";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { Button } from "antd";
|
||||
import { Badge, Button } from "antd";
|
||||
import { Footer } from "antd/es/layout/layout";
|
||||
import Logo from "../icon.svg?react";
|
||||
import { getBasePath } from "../utils/url";
|
||||
import { Header } from "./header";
|
||||
import { Version } from "./version";
|
||||
|
||||
// DEV VERSION indicator
|
||||
const IS_DEV = false;
|
||||
|
||||
const SpoolmanFooter: React.FC = () => {
|
||||
const t = useTranslate();
|
||||
|
||||
@@ -50,17 +53,57 @@ interface SpoolmanLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DevTitle: React.FC<{ collapsed: boolean }> = ({ collapsed }) => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<ThemedTitle collapsed={collapsed} text="Spoolman" icon={<Logo />} />
|
||||
{IS_DEV && !collapsed && (
|
||||
<Badge
|
||||
count="DEV"
|
||||
style={{
|
||||
backgroundColor: "#0891b2",
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
marginLeft: -8,
|
||||
marginTop: -20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SpoolmanLayout: React.FC<SpoolmanLayoutProps> = ({ children }) => (
|
||||
<ThemedLayoutV2
|
||||
<ThemedLayout
|
||||
Header={() => <Header sticky />}
|
||||
Sider={() => (
|
||||
<ThemedSiderV2
|
||||
<ThemedSider
|
||||
fixed
|
||||
Title={({ collapsed }) => <ThemedTitleV2 collapsed={collapsed} text="Spoolman" icon={<Logo />} />}
|
||||
Title={({ collapsed }) => <DevTitle collapsed={collapsed} />}
|
||||
/>
|
||||
)}
|
||||
Footer={() => <SpoolmanFooter />}
|
||||
>
|
||||
{children}
|
||||
</ThemedLayoutV2>
|
||||
{IS_DEV && (
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #0891b2 0%, #06b6d4 50%, #0891b2 100%)",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
padding: "4px 0",
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
🚀 DEV BUILD - Connected to Unraid PostgreSQL
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 1400,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
|
||||
@@ -17,12 +17,12 @@ type Props = NumberFieldProps & {
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/ui-frameworks/antd/components/fields/number} for more details.
|
||||
*/
|
||||
export const NumberFieldUnit: React.FC<Props> = ({ value, locale, options, ...rest }) => {
|
||||
export const NumberFieldUnit: React.FC<Props> = ({ value, locale, options, unit }) => {
|
||||
const number = Number(value);
|
||||
|
||||
return (
|
||||
<Text {...rest}>
|
||||
{toLocaleStringSupportsOptions() ? number.toLocaleString(locale, options) : number} {rest.unit}
|
||||
<Text>
|
||||
{toLocaleStringSupportsOptions() ? number.toLocaleString(locale, options) : number} {unit}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,11 +37,13 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
|
||||
tooltipParts.push(
|
||||
<div
|
||||
key="color"
|
||||
className="color-swatch-mini"
|
||||
style={{
|
||||
borderRadius: ".4em",
|
||||
width: "1.4em",
|
||||
height: "1.4em",
|
||||
backgroundColor: "#" + filament.color_hex,
|
||||
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
|
||||
}}
|
||||
></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
@@ -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}</>;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CameraOutlined } from "@ant-design/icons";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { QrScanner } from "@yudiel/react-qr-scanner";
|
||||
import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner";
|
||||
import { FloatButton, Modal, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
@@ -11,7 +11,12 @@ const QRCodeScannerModal: React.FC = () => {
|
||||
const t = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onScan = (result: string) => {
|
||||
const onScan = (detectedCodes: IDetectedBarcode[]) => {
|
||||
if (detectedCodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const result = detectedCodes[0].rawValue;
|
||||
|
||||
// Check for the spoolman ID format
|
||||
const match = result.match(/^web\+spoolman:s-(?<id>[0-9]+)$/);
|
||||
if (match && match.groups) {
|
||||
@@ -28,31 +33,17 @@ const QRCodeScannerModal: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<FloatButton type="primary" onClick={() => setVisible(true)} icon={<CameraOutlined />} shape="circle" />
|
||||
<Modal open={visible} destroyOnClose onCancel={() => setVisible(false)} footer={null} title={t("scanner.title")}>
|
||||
<Modal open={visible} destroyOnHidden onCancel={() => setVisible(false)} footer={null} title={t("scanner.title")}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<p>{t("scanner.description")}</p>
|
||||
<QrScanner
|
||||
<Scanner
|
||||
constraints={{
|
||||
facingMode: "environment",
|
||||
}}
|
||||
viewFinder={
|
||||
lastError
|
||||
? () => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
top: "50%",
|
||||
}}
|
||||
>
|
||||
<p>{lastError}</p>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onDecode={onScan}
|
||||
onError={(error: Error) => {
|
||||
onScan={onScan}
|
||||
formats={["qr_code"]}
|
||||
onError={(err: unknown) => {
|
||||
const error = err as Error;
|
||||
console.error(error);
|
||||
if (error.name === "NotAllowedError") {
|
||||
setLastError(t("scanner.error.notAllowed"));
|
||||
@@ -71,7 +62,20 @@ const QRCodeScannerModal: React.FC = () => {
|
||||
setLastError(t("scanner.error.unknown", { error: error.name }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{lastError && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
top: "50%",
|
||||
}}
|
||||
>
|
||||
<p>{lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
</Scanner>
|
||||
</Space>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
.spool-icon * {
|
||||
flex: 1 1 0px;
|
||||
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 {
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { message, Tooltip } from "antd";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import "./spoolIcon.css";
|
||||
|
||||
interface Props {
|
||||
color: string | { colors: string[]; vertical: boolean };
|
||||
size?: "small" | "large";
|
||||
no_margin? : boolean
|
||||
no_margin?: boolean;
|
||||
showHex?: boolean;
|
||||
}
|
||||
|
||||
export default function SpoolIcon(props: Readonly<Props>) {
|
||||
const t = useTranslate();
|
||||
let dirClass = "vertical";
|
||||
let cols = [];
|
||||
let size = props.size ? props.size : "small";
|
||||
let no_margin = props.no_margin ? "no-margin" : "";
|
||||
let cols: string[] = [];
|
||||
const size = props.size ? props.size : "small";
|
||||
const no_margin = props.no_margin ? "no-margin" : "";
|
||||
|
||||
if (typeof props.color === "string") {
|
||||
cols = [props.color];
|
||||
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
|
||||
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}>
|
||||
{cols.map((col) => (
|
||||
{normalizedCols.map((col) => (
|
||||
<div
|
||||
key={col}
|
||||
style={{
|
||||
backgroundColor: "#" + col.replace("#", ""),
|
||||
backgroundColor: "#" + col,
|
||||
}}
|
||||
></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;
|
||||
}
|
||||
|
||||
98
client/src/components/vendorCreateModal.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { Form, Input, InputNumber, Modal, message } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { useState } from "react";
|
||||
import { IVendor } from "../pages/vendors/model";
|
||||
import { getAPIURL } from "../utils/url";
|
||||
|
||||
export interface VendorCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onSuccess: (vendor: IVendor) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for quickly creating a vendor inline without navigating away from the current page.
|
||||
* Only requires the vendor name - all other fields are optional.
|
||||
*/
|
||||
export function VendorCreateModal(props: VendorCreateModalProps) {
|
||||
const [form] = Form.useForm();
|
||||
const t = useTranslate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const body: Omit<IVendor, "id" | "registered" | "extra"> = {
|
||||
name: values.name,
|
||||
comment: values.comment || undefined,
|
||||
empty_spool_weight: values.empty_spool_weight || undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(getAPIURL() + "/vendor", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || "Failed to create vendor");
|
||||
}
|
||||
|
||||
const vendor: IVendor = await response.json();
|
||||
message.success(t("vendor.form.created_success", { name: vendor.name }));
|
||||
|
||||
props.onSuccess(vendor);
|
||||
form.resetFields();
|
||||
props.onClose();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "Failed to create vendor");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("vendor.titles.create")}
|
||||
open={props.isOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={isSubmitting}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label={t("vendor.fields.name")}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t("vendor.form.name_required") }]}
|
||||
>
|
||||
<Input maxLength={64} autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("vendor.fields.comment")}
|
||||
name="comment"
|
||||
>
|
||||
<TextArea maxLength={1024} rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("vendor.fields.empty_spool_weight")}
|
||||
name="empty_spool_weight"
|
||||
help={t("vendor.fields_help.empty_spool_weight")}
|
||||
>
|
||||
<InputNumber min={0} addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export const Version: React.FC = () => {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
if (infoResult.isError) {
|
||||
if (infoResult.isError || !infoResult.data) {
|
||||
return <span>Unknown</span>;
|
||||
}
|
||||
|
||||
|
||||
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];
|
||||
};
|
||||
@@ -1,25 +1,45 @@
|
||||
import { RefineThemes } from "@refinedev/antd";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
import { createContext, PropsWithChildren, useEffect, useState } from "react";
|
||||
|
||||
// Theme color options
|
||||
export const THEME_COLORS = {
|
||||
orange: "#f97316",
|
||||
teal: "#0891b2",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
blue: "#2563eb",
|
||||
} as const;
|
||||
|
||||
export type ThemeColorKey = keyof typeof THEME_COLORS;
|
||||
|
||||
type ColorModeContextType = {
|
||||
mode: string;
|
||||
setMode: (mode: string) => void;
|
||||
themeColor: ThemeColorKey;
|
||||
setThemeColor: (color: ThemeColorKey) => void;
|
||||
};
|
||||
|
||||
export const ColorModeContext = createContext<ColorModeContextType>({} as ColorModeContextType);
|
||||
|
||||
export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const colorModeFromLocalStorage = localStorage.getItem("colorMode");
|
||||
const themeColorFromLocalStorage = localStorage.getItem("themeColor") as ThemeColorKey | null;
|
||||
const isSystemPreferenceDark = window?.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
const systemPreference = isSystemPreferenceDark ? "dark" : "light";
|
||||
const [mode, setMode] = useState(colorModeFromLocalStorage || systemPreference);
|
||||
const [themeColor, setThemeColorState] = useState<ThemeColorKey>(themeColorFromLocalStorage || "teal");
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("colorMode", mode);
|
||||
// Set data attribute on body for CSS targeting
|
||||
document.body.setAttribute("data-theme", mode);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("themeColor", themeColor);
|
||||
}, [themeColor]);
|
||||
|
||||
const setColorMode = () => {
|
||||
if (mode === "light") {
|
||||
setMode("dark");
|
||||
@@ -28,20 +48,29 @@ export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children
|
||||
}
|
||||
};
|
||||
|
||||
const setThemeColor = (color: ThemeColorKey) => {
|
||||
setThemeColorState(color);
|
||||
};
|
||||
|
||||
const { darkAlgorithm, defaultAlgorithm } = theme;
|
||||
const primaryColor = THEME_COLORS[themeColor];
|
||||
|
||||
return (
|
||||
<ColorModeContext.Provider
|
||||
value={{
|
||||
setMode: setColorMode,
|
||||
mode,
|
||||
themeColor,
|
||||
setThemeColor,
|
||||
}}
|
||||
>
|
||||
<ConfigProvider
|
||||
// you can change the theme colors here. example: ...RefineThemes.Magenta,
|
||||
theme={{
|
||||
...RefineThemes.Yellow,
|
||||
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: primaryColor,
|
||||
colorInfo: primaryColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import "@ant-design/v5-patch-for-react-19";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { Create, useForm, useSelect } from "@refinedev/antd";
|
||||
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
|
||||
import { Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
||||
import { AutoComplete, Button, ColorPicker, Divider, Form, Input, InputNumber, Radio, Select, Space, Typography } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { FilamentImportModal } from "../../components/filamentImportModal";
|
||||
import { VendorCreateModal } from "../../components/vendorCreateModal";
|
||||
import { MultiColorPicker } from "../../components/multiColorPicker";
|
||||
import { FilamentColorLookup } from "../../components/filamentColorLookup";
|
||||
import {
|
||||
DEFAULT_DENSITY,
|
||||
DEFAULT_DIAMETER,
|
||||
getDensityForMaterial,
|
||||
KNOWN_MATERIALS,
|
||||
} from "../../utils/materialDefaults";
|
||||
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { ExternalFilament } from "../../utils/queryExternalDB";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
import { useFormShortcuts } from "../../utils/useFormShortcuts";
|
||||
import { getOrCreateVendorFromExternal } from "../vendors/functions";
|
||||
import { IVendor } from "../vendors/model";
|
||||
import { IFilament, IFilamentParsedExtras } from "./model";
|
||||
@@ -31,8 +41,18 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
const extraFields = useGetFields(EntityType.filament);
|
||||
const currency = useCurrency();
|
||||
const [isImportExtOpen, setIsImportExtOpen] = useState(false);
|
||||
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
|
||||
const invalidate = useInvalidate();
|
||||
const [colorType, setColorType] = useState<"single" | "multi">("single");
|
||||
const [materialSearch, setMaterialSearch] = useState("");
|
||||
|
||||
// Material options for AutoComplete
|
||||
const materialOptions = useMemo(() => {
|
||||
const filtered = materialSearch
|
||||
? KNOWN_MATERIALS.filter((m) => m.toLowerCase().includes(materialSearch.toLowerCase()))
|
||||
: KNOWN_MATERIALS;
|
||||
return filtered.map((material) => ({ value: material, label: material }));
|
||||
}, [materialSearch]);
|
||||
|
||||
const { form, formProps, formLoading, onFinish, redirect } = useForm<
|
||||
IFilament,
|
||||
@@ -53,6 +73,10 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
|
||||
// Parse the extra fields from string values into real types
|
||||
formProps.initialValues = ParsedExtras(formProps.initialValues);
|
||||
} else {
|
||||
// Set defaults for new filaments
|
||||
formProps.initialValues.density = formProps.initialValues.density ?? DEFAULT_DENSITY;
|
||||
formProps.initialValues.diameter = formProps.initialValues.diameter ?? DEFAULT_DIAMETER;
|
||||
}
|
||||
|
||||
const handleSubmit = async (redirectTo: "list" | "create") => {
|
||||
@@ -61,11 +85,35 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
redirect(redirectTo);
|
||||
};
|
||||
|
||||
// Keyboard shortcut: Ctrl+S / Cmd+S to save
|
||||
const handleSaveShortcut = useCallback(() => {
|
||||
handleSubmit("list");
|
||||
}, []);
|
||||
useFormShortcuts(handleSaveShortcut);
|
||||
|
||||
const { selectProps: vendorSelect } = useSelect<IVendor>({
|
||||
resource: "vendor",
|
||||
optionLabel: "name",
|
||||
});
|
||||
|
||||
const handleVendorCreated = async (vendor: IVendor) => {
|
||||
// Invalidate vendor cache so select options refresh
|
||||
await invalidate({
|
||||
resource: "vendor",
|
||||
invalidates: ["list"],
|
||||
});
|
||||
form.setFieldValue("vendor_id", vendor.id);
|
||||
setIsVendorModalOpen(false);
|
||||
};
|
||||
|
||||
// Auto-fill density when material is selected or entered
|
||||
const handleMaterialChange = (value: string) => {
|
||||
const density = getDensityForMaterial(value);
|
||||
if (density !== undefined) {
|
||||
form.setFieldValue("density", density);
|
||||
}
|
||||
};
|
||||
|
||||
const importFilament = async (filament: ExternalFilament) => {
|
||||
const vendor = await getOrCreateVendorFromExternal(filament.manufacturer);
|
||||
await invalidate({
|
||||
@@ -86,8 +134,10 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
color_hex: filament.color_hex,
|
||||
multi_color_hexes: filament.color_hexes?.join(",") || undefined,
|
||||
multi_color_direction: filament.multi_color_direction,
|
||||
settings_extruder_temp: filament.extruder_temp || undefined,
|
||||
settings_bed_temp: filament.bed_temp || undefined,
|
||||
settings_extruder_temp_min: filament.extruder_temp || undefined,
|
||||
settings_extruder_temp_max: filament.extruder_temp_max || filament.extruder_temp || undefined,
|
||||
settings_bed_temp_min: filament.bed_temp || undefined,
|
||||
settings_bed_temp_max: filament.bed_temp_max || filament.bed_temp || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -132,6 +182,11 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
}}
|
||||
onClose={() => setIsImportExtOpen(false)}
|
||||
/>
|
||||
<VendorCreateModal
|
||||
isOpen={isVendorModalOpen}
|
||||
onSuccess={handleVendorCreated}
|
||||
onClose={() => setIsVendorModalOpen(false)}
|
||||
/>
|
||||
<Form {...formProps} layout="vertical">
|
||||
<Form.Item
|
||||
label={t("filament.fields.name")}
|
||||
@@ -165,6 +220,20 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
filterOption={(input, option) =>
|
||||
typeof option?.label === "string" && option?.label.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsVendorModalOpen(true)}
|
||||
style={{ width: "100%", textAlign: "left" }}
|
||||
>
|
||||
{t("vendor.titles.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("filament.fields.color_hex")}>
|
||||
@@ -180,19 +249,22 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{colorType == "single" && (
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker format="hex" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker format="hex" />
|
||||
</Form.Item>
|
||||
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||
</div>
|
||||
)}
|
||||
{colorType == "multi" && (
|
||||
<Form.Item
|
||||
@@ -233,7 +305,14 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input maxLength={64} />
|
||||
<AutoComplete
|
||||
options={materialOptions}
|
||||
onSearch={setMaterialSearch}
|
||||
onSelect={handleMaterialChange}
|
||||
onBlur={(e) => handleMaterialChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={t("filament.form.material_placeholder")}
|
||||
maxLength={64}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.price")}
|
||||
@@ -310,31 +389,43 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.settings_extruder_temp")}
|
||||
name={["settings_extruder_temp"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="°C" precision={0} />
|
||||
<Form.Item label={t("filament.fields.settings_extruder_temp")}>
|
||||
<Space.Compact>
|
||||
<Form.Item
|
||||
name={["settings_extruder_temp_min"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
|
||||
<Form.Item
|
||||
name={["settings_extruder_temp_max"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.settings_bed_temp")}
|
||||
name={["settings_bed_temp"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="°C" precision={0} />
|
||||
<Form.Item label={t("filament.fields.settings_bed_temp")}>
|
||||
<Space.Compact>
|
||||
<Form.Item
|
||||
name={["settings_bed_temp_min"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
|
||||
<Form.Item
|
||||
name={["settings_bed_temp_max"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.article_number")}
|
||||
|
||||
@@ -1,17 +1,68 @@
|
||||
import { LeftOutlined, RightOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||||
import { Edit, useForm, useSelect } from "@refinedev/antd";
|
||||
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||
import { Alert, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd";
|
||||
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||
import { Alert, Button, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
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 { MultiColorPicker } from "../../components/multiColorPicker";
|
||||
import { FilamentColorLookup } from "../../components/filamentColorLookup";
|
||||
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
import { IVendor } from "../vendors/model";
|
||||
import { IFilament, IFilamentParsedExtras } from "./model";
|
||||
|
||||
/**
|
||||
* Parse temperature ranges from a comment string.
|
||||
* Handles patterns like:
|
||||
* "extrude 190-230 / bed 50-70"
|
||||
* "nozzle: 200-220°C, bed: 60-70°C"
|
||||
* "hotend 200 bed 60"
|
||||
*/
|
||||
function parseTempsFromComment(comment: string): {
|
||||
extruderMin?: number;
|
||||
extruderMax?: number;
|
||||
bedMin?: number;
|
||||
bedMax?: number;
|
||||
} {
|
||||
const result: { extruderMin?: number; extruderMax?: number; bedMin?: number; bedMax?: number } = {};
|
||||
|
||||
// Patterns for extruder temperature
|
||||
const extruderPatterns = [
|
||||
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*[-–~to]+\s*(\d{2,3})/i,
|
||||
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*°?C?/i,
|
||||
];
|
||||
|
||||
// Patterns for bed temperature
|
||||
const bedPatterns = [
|
||||
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*[-–~to]+\s*(\d{2,3})/i,
|
||||
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*°?C?/i,
|
||||
];
|
||||
|
||||
for (const pattern of extruderPatterns) {
|
||||
const match = comment.match(pattern);
|
||||
if (match) {
|
||||
result.extruderMin = parseInt(match[1]);
|
||||
result.extruderMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of bedPatterns) {
|
||||
const match = comment.match(pattern);
|
||||
if (match) {
|
||||
result.bedMin = parseInt(match[1]);
|
||||
result.bedMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
The API returns the extra fields as JSON values, but we need to parse them into their real types
|
||||
in order for Ant design's form to work properly. ParsedExtras does this for us.
|
||||
@@ -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> = () => {
|
||||
const t = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
const extraFields = useGetFields(EntityType.filament);
|
||||
const currency = useCurrency();
|
||||
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",
|
||||
onLiveEvent() {
|
||||
// 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
|
||||
const { selectProps } = useSelect<IVendor>({
|
||||
resource: "vendor",
|
||||
@@ -76,7 +146,28 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
};
|
||||
|
||||
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}
|
||||
<Form {...formProps} layout="vertical">
|
||||
<Form.Item
|
||||
@@ -158,19 +249,22 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{colorType == "single" && (
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
|
||||
<Form.Item
|
||||
name={"color_hex"}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
},
|
||||
]}
|
||||
getValueFromEvent={(e) => {
|
||||
return e?.toHex();
|
||||
}}
|
||||
>
|
||||
<ColorPicker />
|
||||
</Form.Item>
|
||||
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
|
||||
</div>
|
||||
)}
|
||||
{colorType == "multi" && (
|
||||
<Form.Item
|
||||
@@ -288,31 +382,66 @@ export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.settings_extruder_temp")}
|
||||
name={["settings_extruder_temp"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="°C" precision={0} />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => {
|
||||
const comment = form.getFieldValue("comment") || "";
|
||||
const temps = parseTempsFromComment(comment);
|
||||
if (!temps.extruderMin && !temps.bedMin) {
|
||||
messageApi.warning(t("filament.form.no_temps_found"));
|
||||
return;
|
||||
}
|
||||
const fields: Record<string, number | undefined> = {};
|
||||
if (temps.extruderMin) fields.settings_extruder_temp_min = temps.extruderMin;
|
||||
if (temps.extruderMax) fields.settings_extruder_temp_max = temps.extruderMax;
|
||||
if (temps.bedMin) fields.settings_bed_temp_min = temps.bedMin;
|
||||
if (temps.bedMax) fields.settings_bed_temp_max = temps.bedMax;
|
||||
form.setFieldsValue(fields);
|
||||
messageApi.success(t("filament.form.temps_parsed"));
|
||||
}}
|
||||
>
|
||||
{t("filament.form.parse_temps")}
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Item label={t("filament.fields.settings_extruder_temp")}>
|
||||
<Space.Compact>
|
||||
<Form.Item
|
||||
name={["settings_extruder_temp_min"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
|
||||
<Form.Item
|
||||
name={["settings_extruder_temp_max"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.settings_bed_temp")}
|
||||
name={["settings_bed_temp"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="°C" precision={0} />
|
||||
<Form.Item label={t("filament.fields.settings_bed_temp")}>
|
||||
<Space.Compact>
|
||||
<Form.Item
|
||||
name={["settings_bed_temp_min"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
|
||||
<Form.Item
|
||||
name={["settings_bed_temp_max"]}
|
||||
noStyle
|
||||
rules={[{ type: "number", min: 0 }]}
|
||||
>
|
||||
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("filament.fields.article_number")}
|
||||
|
||||
@@ -7,21 +7,21 @@ import utc from "dayjs/plugin/utc";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
ActionsColumn,
|
||||
CustomFieldColumn,
|
||||
DateColumn,
|
||||
FilteredQueryColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
SpoolIconColumn,
|
||||
ActionsColumn,
|
||||
CustomFieldColumn,
|
||||
DateColumn,
|
||||
FilteredQueryColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
SpoolIconColumn,
|
||||
} from "../../components/column";
|
||||
import { useLiveify } from "../../components/liveify";
|
||||
import {
|
||||
useSpoolmanArticleNumbers,
|
||||
useSpoolmanFilamentNames,
|
||||
useSpoolmanMaterials,
|
||||
useSpoolmanVendors,
|
||||
useSpoolmanArticleNumbers,
|
||||
useSpoolmanFilamentNames,
|
||||
useSpoolmanMaterials,
|
||||
useSpoolmanVendors,
|
||||
} from "../../components/otherModels";
|
||||
import { removeUndefined } from "../../utils/filtering";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
|
||||
"article_number",
|
||||
"settings_extruder_temp",
|
||||
"settings_bed_temp",
|
||||
"spool_count",
|
||||
"total_remaining_weight",
|
||||
"registered",
|
||||
"comment",
|
||||
];
|
||||
@@ -88,12 +90,12 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
// 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
|
||||
// calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard.
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } =
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } =
|
||||
useTable<IFilamentCollapsed>({
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "server",
|
||||
current: initialState.pagination.current,
|
||||
currentPage: initialState.pagination.currentPage,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
@@ -131,7 +133,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
const tableState: TableState = {
|
||||
sorters,
|
||||
filters,
|
||||
pagination: { current, pageSize },
|
||||
pagination: { currentPage: currentPage, pageSize },
|
||||
showColumns,
|
||||
};
|
||||
useStoreInitialState(namespace, tableState);
|
||||
@@ -175,7 +177,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
onClick={() => {
|
||||
setFilters([], "replace");
|
||||
setSorters([{ field: "id", order: "asc" }]);
|
||||
setCurrent(1);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{t("buttons.clearFilters")}
|
||||
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
|
||||
maxDecimals: 0,
|
||||
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({
|
||||
...commonProps,
|
||||
id: "registered",
|
||||
|
||||
@@ -15,11 +15,17 @@ export interface IFilament {
|
||||
comment?: string;
|
||||
settings_extruder_temp?: number;
|
||||
settings_bed_temp?: number;
|
||||
settings_extruder_temp_min?: number;
|
||||
settings_extruder_temp_max?: number;
|
||||
settings_bed_temp_min?: number;
|
||||
settings_bed_temp_max?: number;
|
||||
color_hex?: string;
|
||||
multi_color_hexes?: string;
|
||||
multi_color_direction?: string;
|
||||
external_id?: 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
|
||||
|
||||
@@ -21,10 +21,10 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
|
||||
const navigate = useNavigate();
|
||||
const extraFields = useGetFields(EntityType.filament);
|
||||
const currencyFormatter = useCurrencyFormatter();
|
||||
const { queryResult } = useShow<IFilament>({
|
||||
const { query } = useShow<IFilament>({
|
||||
liveMode: "auto",
|
||||
});
|
||||
const { data, isLoading } = queryResult;
|
||||
const { data, isLoading } = query;
|
||||
|
||||
const record = data?.data;
|
||||
|
||||
@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
|
||||
<Title level={5}>{t("filament.fields.name")}</Title>
|
||||
<TextField value={record?.name} />
|
||||
<Title level={5}>{t("filament.fields.color_hex")}</Title>
|
||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
|
||||
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
|
||||
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
|
||||
<Title level={5}>{t("filament.fields.material")}</Title>
|
||||
<TextField value={record?.material} />
|
||||
<Title level={5}>{t("filament.fields.price")}</Title>
|
||||
@@ -131,16 +130,20 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
|
||||
}}
|
||||
/>
|
||||
<Title level={5}>{t("filament.fields.settings_extruder_temp")}</Title>
|
||||
{!record?.settings_extruder_temp ? (
|
||||
<TextField value="Not Set" />
|
||||
{!record?.settings_extruder_temp_min && !record?.settings_extruder_temp_max ? (
|
||||
<TextField value={t("filament.fields.not_set")} />
|
||||
) : record?.settings_extruder_temp_min === record?.settings_extruder_temp_max || !record?.settings_extruder_temp_max ? (
|
||||
<NumberFieldUnit value={record?.settings_extruder_temp_min ?? ""} unit="°C" />
|
||||
) : (
|
||||
<NumberFieldUnit value={record?.settings_extruder_temp ?? ""} unit="°C" />
|
||||
<TextField value={`${record?.settings_extruder_temp_min ?? "?"} - ${record?.settings_extruder_temp_max ?? "?"} °C`} />
|
||||
)}
|
||||
<Title level={5}>{t("filament.fields.settings_bed_temp")}</Title>
|
||||
{!record?.settings_bed_temp ? (
|
||||
<TextField value="Not Set" />
|
||||
{!record?.settings_bed_temp_min && !record?.settings_bed_temp_max ? (
|
||||
<TextField value={t("filament.fields.not_set")} />
|
||||
) : record?.settings_bed_temp_min === record?.settings_bed_temp_max || !record?.settings_bed_temp_max ? (
|
||||
<NumberFieldUnit value={record?.settings_bed_temp_min ?? ""} unit="°C" />
|
||||
) : (
|
||||
<NumberFieldUnit value={record?.settings_bed_temp ?? ""} unit="°C" />
|
||||
<TextField value={`${record?.settings_bed_temp_min ?? "?"} - ${record?.settings_bed_temp_max ?? "?"} °C`} />
|
||||
)}
|
||||
<Title level={5}>{t("filament.fields.article_number")}</Title>
|
||||
<TextField value={record?.article_number} />
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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,124 +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 { 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 Title from "antd/es/typography/Title";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Trans } from "react-i18next";
|
||||
import Logo from "../../icon.svg?react";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import SpoolIcon from "../../components/spoolIcon";
|
||||
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);
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
const LOW_STOCK_THRESHOLD = 100; // grams
|
||||
|
||||
export const Home: React.FC<IResourceComponentsProps> = () => {
|
||||
const { token } = useToken();
|
||||
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",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
const filaments = useList<ISpool>({
|
||||
resource: "filament",
|
||||
pagination: { pageSize: 1 },
|
||||
});
|
||||
const vendors = useList<ISpool>({
|
||||
resource: "vendor",
|
||||
pagination: { pageSize: 1 },
|
||||
pagination: { currentPage, pageSize },
|
||||
sorters: [{ field: "last_used", order: "desc" }],
|
||||
meta: {
|
||||
queryParams: {
|
||||
allow_archived: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasSpools = !spools.data || spools.data.data.length > 0;
|
||||
|
||||
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => (
|
||||
<Col xs={12} md={6}>
|
||||
<Card
|
||||
loading={props.loading}
|
||||
actions={[
|
||||
<Link to={`/${props.resource}`}>
|
||||
<UnorderedListOutlined />
|
||||
</Link>,
|
||||
<Link to={`/${props.resource}/create`}>
|
||||
<PlusOutlined />
|
||||
</Link>,
|
||||
]}
|
||||
>
|
||||
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
const spoolColumns = [
|
||||
{
|
||||
title: "",
|
||||
dataIndex: "color",
|
||||
key: "color",
|
||||
width: 50,
|
||||
render: (_: unknown, record: ISpool) => {
|
||||
const colorObj = record.color_hex
|
||||
? record.color_hex
|
||||
: record.filament.multi_color_hexes
|
||||
? {
|
||||
colors: record.filament.multi_color_hexes.split(","),
|
||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: record.filament.color_hex;
|
||||
return colorObj ? <SpoolIcon color={colorObj} /> : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.filament_name"),
|
||||
dataIndex: "filament",
|
||||
key: "filament",
|
||||
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 (
|
||||
<Content
|
||||
style={{
|
||||
padding: "2em 20px",
|
||||
padding: "1.5em 20px",
|
||||
minHeight: 280,
|
||||
maxWidth: 800,
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
backgroundColor: token.colorBgContainer,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
color: token.colorText,
|
||||
fontFamily: token.fontFamily,
|
||||
fontSize: token.fontSizeLG,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: token.fontSizeHeading1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
height: "1.5em",
|
||||
marginRight: "0.5em",
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
</div>
|
||||
Spoolman
|
||||
</Title>
|
||||
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
|
||||
<ResourceStatsCard
|
||||
resource="spool"
|
||||
value={spools.data?.total || 0}
|
||||
loading={spools.isLoading}
|
||||
icon={<FileOutlined />}
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
resource="filament"
|
||||
value={filaments.data?.total || 0}
|
||||
loading={filaments.isLoading}
|
||||
icon={<HighlightOutlined />}
|
||||
/>
|
||||
<ResourceStatsCard
|
||||
resource="vendor"
|
||||
value={vendors.data?.total || 0}
|
||||
loading={vendors.isLoading}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
{/* Top section: Pie chart + stats/alerts side by side */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={10}>
|
||||
<MaterialDistributionChart square />
|
||||
</Col>
|
||||
<Col xs={24} md={14}>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||
<Col>
|
||||
<Statistic
|
||||
title={t("spool.spool")}
|
||||
value={spoolsResult?.total ?? 0}
|
||||
loading={spoolsQuery.isLoading}
|
||||
valueStyle={{ fontSize: 36, fontWeight: 600 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
|
||||
{t("spool.titles.create")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<QuickStats compact />
|
||||
<AlertCards compact />
|
||||
</Col>
|
||||
</Row>
|
||||
{!hasSpools && (
|
||||
<>
|
||||
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="home.description"
|
||||
components={{
|
||||
helpPageLink: <Link to="/help" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spool list */}
|
||||
<Card
|
||||
title={t("home.all_spools")}
|
||||
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Table<ISpool>
|
||||
dataSource={spoolsResult?.data as ISpool[]}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,11 +17,7 @@ export function LocationContainer() {
|
||||
const locationsSpoolOrders = useLocationsSpoolOrders();
|
||||
const setLocationsSpoolOrders = useSetSetting<Record<string, number[]>>("locations_spoolorders");
|
||||
|
||||
const {
|
||||
data: spoolData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useList<ISpool>({
|
||||
const { result: spoolData, query } = useList<ISpool>({
|
||||
resource: "spool",
|
||||
meta: {
|
||||
queryParams: {
|
||||
@@ -32,6 +28,8 @@ export function LocationContainer() {
|
||||
mode: "off",
|
||||
},
|
||||
});
|
||||
const isLoading = query.isLoading;
|
||||
const isError = query.isError;
|
||||
|
||||
// Group spools by location
|
||||
const spoolLocations = (() => {
|
||||
|
||||
@@ -34,7 +34,9 @@ export function useRenameSpoolLocation() {
|
||||
return await response.text();
|
||||
},
|
||||
onMutate: async (value) => {
|
||||
await queryClient.cancelQueries(queryKeyList);
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: queryKeyList
|
||||
});
|
||||
|
||||
// Optimistically update all spools with matching location to the new one
|
||||
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
|
||||
@@ -71,7 +73,9 @@ export function useRenameSpoolLocation() {
|
||||
},
|
||||
onSuccess: (_data, _value) => {
|
||||
// Mutation succeeded, refetch
|
||||
queryClient.invalidateQueries(queryKey);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKey
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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
@@ -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;
|
||||
@@ -22,6 +22,9 @@ export interface QRCodePrintSettings {
|
||||
showContent?: boolean;
|
||||
showQRCodeMode?: "no" | "simple" | "withIcon";
|
||||
textSize?: number;
|
||||
qrCodePadding?: number; // in mm, default 2
|
||||
qrCodeMaxWidth?: number; // percentage 0-100, 0 = no constraint (auto)
|
||||
qrCodeSize?: number; // in mm, 0 = auto
|
||||
printSettings: PrintSettings;
|
||||
}
|
||||
|
||||
@@ -99,24 +102,45 @@ function applyTextFormatting(text: string): ReactElement[] {
|
||||
|
||||
export function renderLabelContents(template: string, spool: ISpool): ReactElement {
|
||||
// Find all {tags} in the template string and loop over them
|
||||
// let matches = [...template.matchAll(/(?:{(.*?))?{(.*?)}(.*?)(?:}(.*?))?/gs)];
|
||||
let matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)];
|
||||
let label_text = template;
|
||||
matches.forEach((match) => {
|
||||
if ((match[0].match(/{/g) || []).length == 1) {
|
||||
const braceCount = (match[0].match(/{/g) || []).length;
|
||||
if (braceCount == 1) {
|
||||
// Simple tag: {tag}
|
||||
let tag = match[0].replace(/[{}]/g, "");
|
||||
let tagValue = getTagValue(tag, spool);
|
||||
label_text = label_text.replace(match[0], tagValue);
|
||||
} else if ((match[0].match(/{/g) || []).length == 2) {
|
||||
let structure = match[0].match(/{(.*?){(.*?)}(.*?)}/);
|
||||
if (structure != null) {
|
||||
const tag = structure[2];
|
||||
let tagValue = getTagValue(tag, spool);
|
||||
if (tagValue == "?") {
|
||||
label_text = label_text.replace(match[0], "");
|
||||
} else {
|
||||
label_text = label_text.replace(match[0], structure[1] + tagValue + structure[3]);
|
||||
} else if (braceCount >= 2) {
|
||||
// Conditional block with one or more inner tags: {prefix {tag1} middle {tag2} suffix}
|
||||
// First, extract the outer content and find all inner tags
|
||||
const outerContent = match[0].slice(1, -1); // Remove outer braces
|
||||
const innerTagMatches = [...outerContent.matchAll(/{([^{}]+)}/g)];
|
||||
|
||||
if (innerTagMatches.length === 0) {
|
||||
// No inner tags found, leave as-is
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any inner tag resolves to "?" (missing value)
|
||||
let allTagsValid = true;
|
||||
let processedContent = outerContent;
|
||||
|
||||
for (const innerMatch of innerTagMatches) {
|
||||
const tag = innerMatch[1];
|
||||
const tagValue = getTagValue(tag, spool);
|
||||
if (tagValue === "?" || tagValue === null || tagValue === undefined) {
|
||||
allTagsValid = false;
|
||||
break;
|
||||
}
|
||||
processedContent = processedContent.replace(innerMatch[0], String(tagValue));
|
||||
}
|
||||
|
||||
if (allTagsValid) {
|
||||
label_text = label_text.replace(match[0], processedContent);
|
||||
} else {
|
||||
// If any tag is missing, remove the entire conditional block
|
||||
label_text = label_text.replace(match[0], "");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
|
||||
const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent;
|
||||
const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon";
|
||||
const textSize = printSettings?.textSize || 3;
|
||||
const qrCodePadding = printSettings?.qrCodePadding ?? 2;
|
||||
const qrCodeMaxWidth = printSettings?.qrCodeMaxWidth ?? 0; // 0 = auto (no constraint)
|
||||
const qrCodeSize = printSettings?.qrCodeSize ?? 0; // 0 = auto
|
||||
|
||||
const elements = items.map((item) => {
|
||||
return (
|
||||
@@ -155,6 +158,107 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.qrCodePadding")} tooltip={t("printing.qrcode.qrCodePaddingTooltip")}>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Slider
|
||||
disabled={showQRCodeMode === "no"}
|
||||
tooltip={{ formatter: (value) => `${value} mm` }}
|
||||
min={0}
|
||||
max={5}
|
||||
value={qrCodePadding}
|
||||
step={0.5}
|
||||
onChange={(value) => {
|
||||
printSettings.qrCodePadding = value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<InputNumber
|
||||
disabled={showQRCodeMode === "no"}
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.5}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={qrCodePadding}
|
||||
addonAfter="mm"
|
||||
onChange={(value) => {
|
||||
printSettings.qrCodePadding = value ?? 2;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.qrCodeMaxWidth")} tooltip={t("printing.qrcode.qrCodeMaxWidthTooltip")}>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Slider
|
||||
disabled={showQRCodeMode === "no"}
|
||||
tooltip={{ formatter: (value) => (value === 0 ? t("printing.qrcode.auto") : `${value}%`) }}
|
||||
min={0}
|
||||
max={100}
|
||||
value={qrCodeMaxWidth}
|
||||
step={5}
|
||||
onChange={(value) => {
|
||||
printSettings.qrCodeMaxWidth = value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<InputNumber
|
||||
disabled={showQRCodeMode === "no"}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={qrCodeMaxWidth}
|
||||
addonAfter="%"
|
||||
placeholder={t("printing.qrcode.auto")}
|
||||
onChange={(value) => {
|
||||
printSettings.qrCodeMaxWidth = value ?? 0;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("printing.qrcode.qrCodeSize")} tooltip={t("printing.qrcode.qrCodeSizeTooltip")}>
|
||||
<Row>
|
||||
<Col span={12}>
|
||||
<Slider
|
||||
disabled={showQRCodeMode === "no"}
|
||||
tooltip={{ formatter: (value) => (value === 0 ? t("printing.qrcode.auto") : `${value} mm`) }}
|
||||
min={0}
|
||||
max={50}
|
||||
value={qrCodeSize}
|
||||
step={1}
|
||||
onChange={(value) => {
|
||||
printSettings.qrCodeSize = value;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<InputNumber
|
||||
disabled={showQRCodeMode === "no"}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
style={{ margin: "0 16px" }}
|
||||
value={qrCodeSize}
|
||||
addonAfter="mm"
|
||||
placeholder={t("printing.qrcode.auto")}
|
||||
onChange={(value) => {
|
||||
printSettings.qrCodeSize = value ?? 0;
|
||||
setPrintSettings(printSettings);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
|
||||
{extraSettings}
|
||||
</>
|
||||
@@ -168,14 +272,15 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
|
||||
}
|
||||
|
||||
.print-page .print-qrcode-container {
|
||||
max-width: ${showContent ? "50%" : "100%"};
|
||||
max-width: ${qrCodeMaxWidth > 0 ? `${qrCodeMaxWidth}%` : "fit-content"};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.print-page .print-qrcode {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
padding: 2mm;
|
||||
width: ${qrCodeSize > 0 ? `${qrCodeSize}mm` : "auto"} !important;
|
||||
height: ${qrCodeSize > 0 ? `${qrCodeSize}mm` : "auto"} !important;
|
||||
padding: ${qrCodePadding}mm;
|
||||
}
|
||||
|
||||
.print-page .print-qrcode-title {
|
||||
|
||||
@@ -31,7 +31,7 @@ const SpoolQRCodePrintingDialog: React.FC<SpoolQRCodePrintingDialog> = ({ spoolI
|
||||
? JSON.parse(baseUrlSetting.data?.value)
|
||||
: window.location.origin;
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", false);
|
||||
const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", true);
|
||||
|
||||
const itemQueries = useGetSpoolsByIds(spoolIds);
|
||||
const items = itemQueries
|
||||
@@ -157,8 +157,8 @@ const SpoolQRCodePrintingDialog: React.FC<SpoolQRCodePrintingDialog> = ({ spoolI
|
||||
`**{filament.vendor.name} - {filament.name}
|
||||
#{id} - {filament.material}**
|
||||
Spool Weight: {filament.spool_weight} g
|
||||
{ET: {filament.settings_extruder_temp} °C}
|
||||
{BT: {filament.settings_bed_temp} °C}
|
||||
{ET: {filament.settings_extruder_temp_min}-{filament.settings_extruder_temp_max} °C}
|
||||
{BT: {filament.settings_bed_temp_min}-{filament.settings_bed_temp_max} °C}
|
||||
{Lot Nr: {lot_nr}}
|
||||
{{comment}}
|
||||
{filament.comment}
|
||||
@@ -201,6 +201,10 @@ Spool Weight: {filament.spool_weight} g
|
||||
{ tag: "filament.comment" },
|
||||
{ tag: "filament.settings_extruder_temp" },
|
||||
{ tag: "filament.settings_bed_temp" },
|
||||
{ tag: "filament.settings_extruder_temp_min" },
|
||||
{ tag: "filament.settings_extruder_temp_max" },
|
||||
{ tag: "filament.settings_bed_temp_min" },
|
||||
{ tag: "filament.settings_bed_temp_max" },
|
||||
{ tag: "filament.color_hex" },
|
||||
{ tag: "filament.multi_color_hexes" },
|
||||
{ tag: "filament.multi_color_direction" },
|
||||
|
||||
@@ -42,7 +42,7 @@ const SpoolSelectModal: React.FC<Props> = ({ description, onContinue }) => {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { tableProps, sorters, filters, current, pageSize } = useTable<ISpoolCollapsed>({
|
||||
const { tableProps, sorters, filters, currentPage, pageSize } = useTable<ISpoolCollapsed>({
|
||||
resource: "spool",
|
||||
meta: {
|
||||
queryParams: {
|
||||
@@ -52,7 +52,7 @@ const SpoolSelectModal: React.FC<Props> = ({ description, onContinue }) => {
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "off",
|
||||
current: 1,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
sorters: {
|
||||
@@ -75,7 +75,7 @@ const SpoolSelectModal: React.FC<Props> = ({ description, onContinue }) => {
|
||||
const tableState: TableState = {
|
||||
sorters,
|
||||
filters,
|
||||
pagination: { current, pageSize },
|
||||
pagination: { currentPage: currentPage, pageSize },
|
||||
};
|
||||
|
||||
// Collapse the dataSource to a mutable list and add a filament_name field
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DownloadOutlined, LockOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useTranslate } from "@refinedev/core";
|
||||
import { Button, Checkbox, Form, Input, message } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { Button, Checkbox, Divider, Form, Input, message, Modal, Popconfirm, Space, Switch, Upload } from "antd";
|
||||
import type { UploadFile } from "antd";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { getAPIURL } from "../../utils/url";
|
||||
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
|
||||
import { ColorModeContext, THEME_COLORS, ThemeColorKey } from "../../contexts/color-mode";
|
||||
import { useAuth } from "../../contexts/auth";
|
||||
|
||||
export function GeneralSettings() {
|
||||
const settings = useGetSettings();
|
||||
@@ -11,6 +16,7 @@ export function GeneralSettings() {
|
||||
const [form] = Form.useForm();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const t = useTranslate();
|
||||
const { themeColor, setThemeColor } = useContext(ColorModeContext);
|
||||
|
||||
// Set initial form values
|
||||
useEffect(() => {
|
||||
@@ -104,13 +110,368 @@ export function GeneralSettings() {
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("settings.general.theme_color.label")}>
|
||||
<Space>
|
||||
{(Object.keys(THEME_COLORS) as ThemeColorKey[]).map((colorKey) => (
|
||||
<Button
|
||||
key={colorKey}
|
||||
type={themeColor === colorKey ? "primary" : "default"}
|
||||
style={{
|
||||
backgroundColor: themeColor === colorKey ? THEME_COLORS[colorKey] : undefined,
|
||||
borderColor: THEME_COLORS[colorKey],
|
||||
minWidth: 80,
|
||||
}}
|
||||
onClick={() => setThemeColor(colorKey)}
|
||||
>
|
||||
{t(`settings.general.theme_color.${colorKey}`)}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
|
||||
<Button type="primary" htmlType="submit" loading={settings.isFetching || setCurrency.isLoading}>
|
||||
<Button type="primary" htmlType="submit" loading={settings.isFetching || setCurrency.isPending}>
|
||||
{t("buttons.save")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<AuthSettings messageApi={messageApi} t={t} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<BackupRestore messageApi={messageApi} t={t} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<AboutSection />
|
||||
|
||||
{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 {
|
||||
messageApi: ReturnType<typeof message.useMessage>[0];
|
||||
t: ReturnType<typeof useTranslate>;
|
||||
}
|
||||
|
||||
function BackupRestore({ messageApi, t }: BackupRestoreProps) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await fetch(`${getAPIURL()}/backup/export`);
|
||||
if (!response.ok) throw new Error("Export failed");
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename = response.headers.get("content-disposition")?.match(/filename="(.+)"/)?.[1]
|
||||
|| `spoolman_backup_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
messageApi.success(t("settings.backup.export_success"));
|
||||
} catch (error) {
|
||||
messageApi.error(t("settings.backup.export_error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (replace: boolean) => {
|
||||
if (fileList.length === 0) {
|
||||
messageApi.warning(t("settings.backup.select_file"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileList[0].originFileObj as Blob);
|
||||
|
||||
const response = await fetch(`${getAPIURL()}/backup/import?replace=${replace}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
messageApi.success(
|
||||
`${t("settings.backup.import_success")}: ${result.vendors_imported} vendors, ${result.filaments_imported} filaments, ${result.spools_imported} spools`
|
||||
);
|
||||
setFileList([]);
|
||||
// Reload the page to refresh all data
|
||||
window.location.reload();
|
||||
} else {
|
||||
messageApi.error(`${t("settings.backup.import_error")}: ${result.errors.join(", ")}`);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(t("settings.backup.import_error"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
|
||||
<h3>{t("settings.backup.title")}</h3>
|
||||
<p style={{ marginBottom: "1em", color: "#888" }}>
|
||||
{t("settings.backup.description")}
|
||||
</p>
|
||||
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<h4>{t("settings.backup.export_title")}</h4>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
{t("settings.backup.export_button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{t("settings.backup.import_title")}</h4>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<Upload
|
||||
accept=".json"
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
beforeUpload={() => false}
|
||||
onChange={({ fileList }) => setFileList(fileList)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>{t("settings.backup.select_file")}</Button>
|
||||
</Upload>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleImport(false)}
|
||||
loading={importing}
|
||||
disabled={fileList.length === 0}
|
||||
>
|
||||
{t("settings.backup.import_merge")}
|
||||
</Button>
|
||||
|
||||
<Popconfirm
|
||||
title={t("settings.backup.replace_warning_title")}
|
||||
description={t("settings.backup.replace_warning")}
|
||||
onConfirm={() => handleImport(true)}
|
||||
okText={t("buttons.yes")}
|
||||
cancelText={t("buttons.no")}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
loading={importing}
|
||||
disabled={fileList.length === 0}
|
||||
>
|
||||
{t("settings.backup.import_replace")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
</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 { Menu, theme } from "antd";
|
||||
import { Content } from "antd/es/layout/layout";
|
||||
@@ -8,6 +8,7 @@ import React from "react";
|
||||
import { Route, Routes, useNavigate } from "react-router";
|
||||
import { ExtraFieldsSettings } from "./extraFieldsSettings";
|
||||
import { GeneralSettings } from "./generalSettings";
|
||||
import { UserSettings } from "./userSettings";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -58,6 +59,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
|
||||
}}
|
||||
items={[
|
||||
{ key: "", label: t("settings.general.tab"), icon: <ToolOutlined /> },
|
||||
{ key: "users", label: t("settings.users.tab"), icon: <TeamOutlined /> },
|
||||
{
|
||||
key: "extra",
|
||||
label: t("settings.extra_fields.tab"),
|
||||
@@ -88,6 +90,7 @@ export const Settings: React.FC<IResourceComponentsProps> = () => {
|
||||
<main>
|
||||
<Routes>
|
||||
<Route index element={<GeneralSettings />} />
|
||||
<Route path="/users" element={<UserSettings />} />
|
||||
<Route path="/extra/:entityType" element={<ExtraFieldsSettings />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
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
@@ -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
@@ -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,19 +1,22 @@
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Create, useForm } from "@refinedev/antd";
|
||||
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
||||
import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { FilamentCreateModal } from "../../components/filamentCreateModal";
|
||||
import { useSpoolmanLocations } from "../../components/otherModels";
|
||||
import { searchMatches } from "../../utils/filtering";
|
||||
import "../../utils/overrides.css";
|
||||
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
|
||||
import { useFormShortcuts } from "../../utils/useFormShortcuts";
|
||||
import { createFilamentFromExternal } from "../filaments/functions";
|
||||
import { IFilament } from "../filaments/model";
|
||||
import { useGetFilamentSelectOptions } from "./functions";
|
||||
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
|
||||
|
||||
@@ -31,6 +34,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
const t = useTranslate();
|
||||
const extraFields = useGetFields(EntityType.spool);
|
||||
const currency = useCurrency();
|
||||
const [isFilamentModalOpen, setIsFilamentModalOpen] = useState(false);
|
||||
|
||||
const { form, formProps, formLoading, onFinish, redirect } = useForm<
|
||||
ISpool,
|
||||
@@ -47,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
|
||||
const initialWeightValue = Form.useWatch("initial_weight", form);
|
||||
const spoolWeightValue = Form.useWatch("spool_weight", form);
|
||||
const extraWeightValue = Form.useWatch("extra_weight", form);
|
||||
|
||||
if (props.mode === "clone") {
|
||||
// Clear out the values that we don't want to clone
|
||||
@@ -78,8 +83,26 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
internalSelectOptions,
|
||||
externalSelectOptions,
|
||||
allExternalFilaments,
|
||||
refetch: refetchFilaments,
|
||||
} = useGetFilamentSelectOptions();
|
||||
|
||||
// Track pending filament selection after inline creation
|
||||
const [pendingFilamentId, setPendingFilamentId] = useState<number | null>(null);
|
||||
|
||||
const handleFilamentCreated = async (filament: IFilament) => {
|
||||
setPendingFilamentId(filament.id);
|
||||
await refetchFilaments();
|
||||
setIsFilamentModalOpen(false);
|
||||
};
|
||||
|
||||
// Set the filament_id once the new filament appears in the options
|
||||
useEffect(() => {
|
||||
if (pendingFilamentId !== null && internalSelectOptions?.some((opt) => opt.value === pendingFilamentId)) {
|
||||
form.setFieldValue("filament_id", pendingFilamentId);
|
||||
setPendingFilamentId(null);
|
||||
}
|
||||
}, [pendingFilamentId, internalSelectOptions]);
|
||||
|
||||
const selectedFilamentID = Form.useWatch("filament_id", form);
|
||||
const selectedFilament = useMemo(() => {
|
||||
// id is a number of it's an internal filament, and a string of it's an external filament.
|
||||
@@ -128,6 +151,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
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
|
||||
// This is necessary because the form is rendered before the extra fields are loaded
|
||||
useEffect(() => {
|
||||
@@ -189,10 +218,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
return initialWeightValue ?? selectedFilament?.weight ?? 0;
|
||||
};
|
||||
|
||||
const getExtraWeight = (): number => {
|
||||
return extraWeightValue ?? 0;
|
||||
};
|
||||
|
||||
const getGrossWeight = (): number => {
|
||||
const net_weight = getFilamentWeight();
|
||||
const spool_weight = getSpoolWeight();
|
||||
return net_weight + spool_weight;
|
||||
const extra_weight = getExtraWeight();
|
||||
return net_weight + spool_weight + extra_weight;
|
||||
};
|
||||
|
||||
const getMeasuredWeight = (): number => {
|
||||
@@ -268,6 +302,11 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<FilamentCreateModal
|
||||
isOpen={isFilamentModalOpen}
|
||||
onSuccess={handleFilamentCreated}
|
||||
onClose={() => setIsFilamentModalOpen(false)}
|
||||
/>
|
||||
<Form {...formProps} layout="vertical">
|
||||
<Form.Item
|
||||
label={t("spool.fields.first_used")}
|
||||
@@ -310,6 +349,20 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
options={filamentOptions}
|
||||
showSearch
|
||||
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsFilamentModalOpen(true)}
|
||||
style={{ width: "100%", textAlign: "left" }}
|
||||
>
|
||||
{t("filament.titles.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedFilament?.is_internal === false && (
|
||||
@@ -364,6 +417,30 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("spool.fields.extra_weight")}
|
||||
help={t("spool.fields_help.extra_weight")}
|
||||
name={["extra_weight"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("spool.fields.color_hex")}
|
||||
help={t("spool.fields_help.color_hex")}
|
||||
name={["color_hex"]}
|
||||
getValueFromEvent={(e) => e?.toHex()}
|
||||
>
|
||||
<ColorPicker allowClear format="hex" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
|
||||
<InputNumber value={usedWeight} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { Edit, useForm } from "@refinedev/antd";
|
||||
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||
import { Alert, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
||||
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||
import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { message } from "antd/lib";
|
||||
import dayjs from "dayjs";
|
||||
@@ -37,7 +38,14 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
const [searchParams, _] = useSearchParams();
|
||||
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",
|
||||
onLiveEvent() {
|
||||
// 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 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
|
||||
if (formProps.initialValues) {
|
||||
@@ -131,8 +151,8 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
const [usedWeight, setUsedWeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const newFilamentWeight = selectedFilament?.weight || 0;
|
||||
const newSpoolWeight = selectedFilament?.spool_weight || 0;
|
||||
const newFilamentWeight = getFilamentWeight();
|
||||
const newSpoolWeight = getSpoolWeight();
|
||||
if (newFilamentWeight > 0) {
|
||||
form.setFieldValue("initial_weight", newFilamentWeight);
|
||||
}
|
||||
@@ -170,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
return initialWeightValue ?? selectedFilament?.weight ?? 0;
|
||||
};
|
||||
|
||||
const getExtraWeight = (): number => {
|
||||
return extraWeightValue ?? 0;
|
||||
};
|
||||
|
||||
const getGrossWeight = (): number => {
|
||||
const net_weight = getFilamentWeight();
|
||||
const spool_weight = getSpoolWeight();
|
||||
return net_weight + spool_weight;
|
||||
const extra_weight = getExtraWeight();
|
||||
return net_weight + spool_weight + extra_weight;
|
||||
};
|
||||
|
||||
const getMeasuredWeight = (): number => {
|
||||
@@ -231,7 +256,28 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
}, [initialUsedWeight]);
|
||||
|
||||
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}
|
||||
<Form {...formProps} layout="vertical">
|
||||
<Form.Item
|
||||
@@ -354,6 +400,30 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("spool.fields.extra_weight")}
|
||||
help={t("spool.fields_help.extra_weight")}
|
||||
name={["extra_weight"]}
|
||||
rules={[
|
||||
{
|
||||
required: false,
|
||||
type: "number",
|
||||
min: 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber addonAfter="g" precision={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("spool.fields.color_hex")}
|
||||
help={t("spool.fields_help.color_hex")}
|
||||
name={["color_hex"]}
|
||||
getValueFromEvent={(e) => e?.toHex()}
|
||||
>
|
||||
<ColorPicker allowClear format="hex" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
|
||||
<InputNumber value={usedWeight} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSelect, useTranslate } from "@refinedev/core";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Form, InputNumber, Modal, Radio } from "antd";
|
||||
import { useForm } from "antd/es/form/Form";
|
||||
import type { InputNumberRef } from "rc-input-number";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { formatLength, formatWeight } from "../../utils/parsing";
|
||||
import { SpoolType, useGetExternalDBFilaments } from "../../utils/queryExternalDB";
|
||||
@@ -23,6 +24,36 @@ export async function setSpoolArchived(spool: ISpool, archived: boolean) {
|
||||
await fetch(request, init);
|
||||
}
|
||||
|
||||
export async function bulkArchiveSpools(spoolIds: number[], archived: boolean) {
|
||||
const response = await fetch(getAPIURL() + "/spool/bulk/archive", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spool_ids: spoolIds, archived }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to archive spools");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function bulkDeleteSpools(spoolIds: number[]) {
|
||||
const response = await fetch(getAPIURL() + "/spool/bulk/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spool_ids: spoolIds }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to delete spools");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function bulkUpdateSpools(spoolIds: number[], data: { location?: string }) {
|
||||
const response = await fetch(getAPIURL() + "/spool/bulk/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spool_ids: spoolIds, ...data }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to update spools");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use some spool filament from this spool. Either specify length or weight.
|
||||
* @param spool The spool
|
||||
@@ -63,7 +94,6 @@ export async function useSpoolFilamentMeasure(spool: ISpool, weight: number) {
|
||||
await fetch(request, init);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an array of queries using the useQueries hook from @tanstack/react-query.
|
||||
* Each query fetches a spool by its ID from the server.
|
||||
@@ -126,7 +156,7 @@ interface SelectOption {
|
||||
export function useGetFilamentSelectOptions() {
|
||||
// Setup hooks
|
||||
const t = useTranslate();
|
||||
const { queryResult: internalFilaments } = useSelect<IFilament>({
|
||||
const { query: internalFilaments } = useSelect<IFilament>({
|
||||
resource: "filament",
|
||||
});
|
||||
const externalFilaments = useGetExternalDBFilaments();
|
||||
@@ -190,6 +220,7 @@ export function useGetFilamentSelectOptions() {
|
||||
internalSelectOptions: filamentSelectInternal,
|
||||
externalSelectOptions: filamentSelectExternal,
|
||||
allExternalFilaments: externalFilaments.data,
|
||||
refetch: internalFilaments.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,7 +232,7 @@ export function useSpoolAdjustModal() {
|
||||
|
||||
const [curSpool, setCurSpool] = useState<ISpool | null>(null);
|
||||
const [measurementType, setMeasurementType] = useState<MeasurementType>("length");
|
||||
const inputNumberRef = useRef<HTMLInputElement | null>(null);
|
||||
const inputNumberRef = useRef<InputNumberRef | null>(null);
|
||||
|
||||
const openSpoolAdjustModal = useCallback((spool: ISpool) => {
|
||||
setCurSpool(spool);
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
import {
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
FilterOutlined,
|
||||
InboxOutlined,
|
||||
PlusSquareOutlined,
|
||||
PrinterOutlined,
|
||||
ToolOutlined,
|
||||
ToTopOutlined,
|
||||
AppstoreOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
FilterOutlined,
|
||||
InboxOutlined,
|
||||
PlusSquareOutlined,
|
||||
PrinterOutlined,
|
||||
TableOutlined,
|
||||
ToolOutlined,
|
||||
ToTopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { List, useTable } from "@refinedev/antd";
|
||||
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
|
||||
import { Button, Dropdown, Modal, Table } from "antd";
|
||||
import { Button, Dropdown, Input, Modal, Table } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Action,
|
||||
ActionsColumn,
|
||||
CustomFieldColumn,
|
||||
DateColumn,
|
||||
FilteredQueryColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
SpoolIconColumn,
|
||||
Action,
|
||||
ActionsColumn,
|
||||
ColorFilterColumn,
|
||||
CustomFieldColumn,
|
||||
DateColumn,
|
||||
FilteredQueryColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
SpoolIconColumn,
|
||||
} from "../../components/column";
|
||||
import { useLiveify } from "../../components/liveify";
|
||||
import {
|
||||
useSpoolmanFilamentFilter,
|
||||
useSpoolmanLocations,
|
||||
useSpoolmanLotNumbers,
|
||||
useSpoolmanMaterials,
|
||||
useSpoolmanColors,
|
||||
useSpoolmanFilamentFilter,
|
||||
useSpoolmanLocations,
|
||||
useSpoolmanLotNumbers,
|
||||
useSpoolmanMaterials,
|
||||
} from "../../components/otherModels";
|
||||
import { removeUndefined } from "../../utils/filtering";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
|
||||
import { useCurrencyFormatter } from "../../utils/settings";
|
||||
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
||||
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
||||
import { ISpool } from "./model";
|
||||
import { BatchActionBar } from "./components/BatchActionBar";
|
||||
import { SpoolGallery } from "./components/SpoolGallery";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -48,6 +54,7 @@ interface ISpoolCollapsed extends ISpool {
|
||||
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
|
||||
"filament.id": number;
|
||||
"filament.material"?: string;
|
||||
"filament.color_hex"?: string;
|
||||
}
|
||||
|
||||
function collapseSpool(element: ISpool): ISpoolCollapsed {
|
||||
@@ -65,6 +72,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
|
||||
"filament.combined_name": filament_name,
|
||||
"filament.id": element.filament.id,
|
||||
"filament.material": element.filament.material,
|
||||
"filament.color_hex": element.color_hex ?? element.filament.color_hex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +80,7 @@ function translateColumnI18nKey(columnName: string): string {
|
||||
columnName = columnName.replace(".", "_");
|
||||
if (columnName === "filament_combined_name") columnName = "filament_name";
|
||||
else if (columnName === "filament_material") columnName = "material";
|
||||
else if (columnName === "filament_color_hex") columnName = "color";
|
||||
return `spool.fields.${columnName}`;
|
||||
}
|
||||
|
||||
@@ -80,8 +89,10 @@ const namespace = "spoolList-v2";
|
||||
const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
||||
"id",
|
||||
"filament.combined_name",
|
||||
"filament.color_hex",
|
||||
"filament.material",
|
||||
"price",
|
||||
"remaining_value",
|
||||
"used_weight",
|
||||
"remaining_weight",
|
||||
"used_length",
|
||||
@@ -94,7 +105,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
|
||||
"comment",
|
||||
];
|
||||
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> = () => {
|
||||
@@ -113,21 +125,29 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
// State for the switch to show archived spools
|
||||
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
|
||||
|
||||
// State for search and view mode
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"table" | "gallery">("table");
|
||||
|
||||
// State for batch selection
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
// Fetch data from the API
|
||||
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
|
||||
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
|
||||
// calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard.
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } =
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } =
|
||||
useTable<ISpoolCollapsed>({
|
||||
meta: {
|
||||
queryParams: {
|
||||
["allow_archived"]: showArchived,
|
||||
...(searchQuery ? { q: searchQuery } : {}),
|
||||
},
|
||||
},
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "server",
|
||||
current: initialState.pagination.current,
|
||||
currentPage: initialState.pagination.currentPage,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
@@ -165,7 +185,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
const tableState: TableState = {
|
||||
sorters,
|
||||
filters,
|
||||
pagination: { current, pageSize },
|
||||
pagination: { currentPage: currentPage, pageSize },
|
||||
showColumns,
|
||||
};
|
||||
useStoreInitialState(namespace, tableState);
|
||||
@@ -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) {
|
||||
tableProps.pagination.showSizeChanger = true;
|
||||
}
|
||||
@@ -284,11 +356,17 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
onClick={() => {
|
||||
setFilters([], "replace");
|
||||
setSorters([{ field: "id", order: "asc" }]);
|
||||
setCurrent(1);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{t("buttons.clearFilters")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
|
||||
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
|
||||
>
|
||||
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
@@ -326,6 +404,18 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
)}
|
||||
>
|
||||
{spoolAdjustModal}
|
||||
<Input.Search
|
||||
placeholder={t("spool.search_placeholder")}
|
||||
allowClear
|
||||
onSearch={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ marginBottom: 16, maxWidth: 400 }}
|
||||
/>
|
||||
{viewMode === "gallery" ? (
|
||||
<SpoolGallery dataSource={dataSource} loading={tableProps.loading as boolean} />
|
||||
) : (
|
||||
<Table
|
||||
{...tableProps}
|
||||
sticky
|
||||
@@ -333,6 +423,11 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
scroll={{ x: "max-content" }}
|
||||
dataSource={dataSource}
|
||||
rowKey="id"
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (newSelectedRowKeys) => setSelectedRowKeys(newSelectedRowKeys),
|
||||
preserveSelectedRowKeys: true,
|
||||
}}
|
||||
// Make archived rows greyed out
|
||||
onRow={(record) => {
|
||||
if (record.archived) {
|
||||
@@ -358,15 +453,25 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
id: "filament.combined_name",
|
||||
i18nkey: "spool.fields.filament_name",
|
||||
color: (record: ISpoolCollapsed) =>
|
||||
record.filament.multi_color_hexes
|
||||
? {
|
||||
colors: record.filament.multi_color_hexes.split(","),
|
||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: record.filament.color_hex,
|
||||
record.color_hex
|
||||
? record.color_hex
|
||||
: record.filament.multi_color_hexes
|
||||
? {
|
||||
colors: record.filament.multi_color_hexes.split(","),
|
||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: record.filament.color_hex,
|
||||
dataId: "filament.combined_name",
|
||||
filterValueQuery: useSpoolmanFilamentFilter(),
|
||||
}),
|
||||
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({
|
||||
...commonProps,
|
||||
id: "filament.material",
|
||||
@@ -387,6 +492,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
return currencyFormatter.format(obj.price);
|
||||
},
|
||||
}),
|
||||
SortedColumn({
|
||||
...commonProps,
|
||||
id: "remaining_value",
|
||||
i18ncat: "spool",
|
||||
align: "right",
|
||||
width: 100,
|
||||
render: (_, obj: ISpoolCollapsed) => {
|
||||
if (obj.remaining_value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return currencyFormatter.format(obj.remaining_value);
|
||||
},
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
id: "used_weight",
|
||||
@@ -395,6 +513,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
unit: "g",
|
||||
maxDecimals: 0,
|
||||
width: 110,
|
||||
sorter: true,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
@@ -404,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
maxDecimals: 0,
|
||||
defaultText: t("unknown"),
|
||||
width: 110,
|
||||
sorter: true,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
@@ -412,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
unit: "mm",
|
||||
maxDecimals: 0,
|
||||
width: 120,
|
||||
sorter: true,
|
||||
}),
|
||||
NumberColumn({
|
||||
...commonProps,
|
||||
@@ -421,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
maxDecimals: 0,
|
||||
defaultText: t("unknown"),
|
||||
width: 120,
|
||||
sorter: true,
|
||||
}),
|
||||
FilteredQueryColumn({
|
||||
...commonProps,
|
||||
@@ -466,6 +588,15 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
|
||||
ActionsColumn(t("table.actions"), actions),
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
<BatchActionBar
|
||||
selectedIds={selectedRowKeys as number[]}
|
||||
onArchive={handleBatchArchive}
|
||||
onUnarchive={handleBatchUnarchive}
|
||||
onDelete={handleBatchDelete}
|
||||
onMove={handleBatchMove}
|
||||
onClear={() => setSelectedRowKeys([])}
|
||||
/>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,16 +15,41 @@ export interface ISpool {
|
||||
price?: number;
|
||||
initial_weight?: number;
|
||||
spool_weight?: number;
|
||||
extra_weight?: number;
|
||||
color_hex?: string;
|
||||
remaining_weight?: number;
|
||||
used_weight: number;
|
||||
remaining_length?: number;
|
||||
remaining_value?: number;
|
||||
used_length: number;
|
||||
location?: string;
|
||||
lot_nr?: string;
|
||||
comment?: string;
|
||||
archived: boolean;
|
||||
needs_weighing: boolean;
|
||||
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
|
||||
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
|
||||
|
||||
export interface ISpoolAdjustment {
|
||||
id: number;
|
||||
spool_id: number;
|
||||
timestamp: string;
|
||||
adjustment_type: "weight" | "length";
|
||||
value: number;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getBasePath } from "../../utils/url";
|
||||
import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons";
|
||||
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
|
||||
import { IResourceComponentsProps, useInvalidate, useShow, useTranslate } from "@refinedev/core";
|
||||
import { Button, Modal, Typography } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Collapse, Modal, Table, Typography } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React from "react";
|
||||
@@ -12,9 +12,10 @@ import SpoolIcon from "../../components/spoolIcon";
|
||||
import { enrichText } from "../../utils/parsing";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { useCurrencyFormatter } from "../../utils/settings";
|
||||
import { getAPIURL, getBasePath } from "../../utils/url";
|
||||
import { IFilament } from "../filaments/model";
|
||||
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
|
||||
import { ISpool } from "./model";
|
||||
import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -24,16 +25,37 @@ const { confirm } = Modal;
|
||||
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
const extraFields = useGetFields(EntityType.spool);
|
||||
const filamentExtraFields = useGetFields(EntityType.filament);
|
||||
const currencyFormatter = useCurrencyFormatter();
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
const { queryResult } = useShow<ISpool>({
|
||||
const { query } = useShow<ISpool>({
|
||||
liveMode: "auto",
|
||||
});
|
||||
const { data, isLoading } = queryResult;
|
||||
const { data, isLoading } = query;
|
||||
|
||||
const record = data?.data;
|
||||
|
||||
// Fetch adjustment history
|
||||
const { data: adjustmentsData, isLoading: adjustmentsLoading } = useQuery({
|
||||
queryKey: ["spool-adjustments", record?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${getAPIURL()}/spool/${record?.id}/adjustments`);
|
||||
return (await res.json()) as ISpoolAdjustment[];
|
||||
},
|
||||
enabled: !!record?.id,
|
||||
});
|
||||
|
||||
// Fetch print job history
|
||||
const { data: printJobsData, isLoading: printJobsLoading } = useQuery({
|
||||
queryKey: ["spool-print-jobs", record?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${getAPIURL()}/print-job?spool_id=${record?.id}`);
|
||||
return (await res.json()) as IPrintJob[];
|
||||
},
|
||||
enabled: !!record?.id,
|
||||
});
|
||||
|
||||
const spoolPrice = (item?: ISpool) => {
|
||||
const price = item?.price ?? item?.filament.price;
|
||||
if (price === undefined) {
|
||||
@@ -105,12 +127,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const colorObj = record?.filament.multi_color_hexes
|
||||
? {
|
||||
colors: record.filament.multi_color_hexes.split(","),
|
||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: record?.filament.color_hex;
|
||||
const colorObj = record?.color_hex
|
||||
? record.color_hex
|
||||
: record?.filament.multi_color_hexes
|
||||
? {
|
||||
colors: record.filament.multi_color_hexes.split(","),
|
||||
vertical: record.filament.multi_color_direction === "longitudinal",
|
||||
}
|
||||
: record?.filament.color_hex;
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -118,17 +142,19 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
title={record ? formatTitle(record) : ""}
|
||||
headerButtons={({ defaultButtons }) => (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ToolOutlined />}
|
||||
onClick={() => record && openSpoolAdjustModal(record)}
|
||||
>
|
||||
<Button type="primary" icon={<ToolOutlined />} onClick={() => record && openSpoolAdjustModal(record)}>
|
||||
{t("spool.titles.adjust")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PrinterOutlined />}
|
||||
href={getBasePath() + "/spool/print?spools=" + record?.id + "&return=" + encodeURIComponent(window.location.pathname)}
|
||||
href={
|
||||
getBasePath() +
|
||||
"/spool/print?spools=" +
|
||||
record?.id +
|
||||
"&return=" +
|
||||
encodeURIComponent(window.location.pathname)
|
||||
}
|
||||
>
|
||||
{t("printing.qrcode.button")}
|
||||
</Button>
|
||||
@@ -150,7 +176,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
<Title level={5}>{t("spool.fields.id")}</Title>
|
||||
<NumberField value={record?.id ?? ""} />
|
||||
<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) : ""} />
|
||||
<Title level={5}>{t("spool.fields.price")}</Title>
|
||||
<TextField value={spoolPrice(record)} />
|
||||
@@ -222,6 +248,130 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
|
||||
{extraFields?.data?.map((field, index) => (
|
||||
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
|
||||
))}
|
||||
{filamentExtraFields?.data && filamentExtraFields.data.length > 0 && (
|
||||
<>
|
||||
<Title level={4}>{t("spool.fields.filament_fields")}</Title>
|
||||
{filamentExtraFields.data.map((field, index) => (
|
||||
<ExtraFieldDisplay key={index} field={field} value={record?.filament.extra[field.key]} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Title level={4} style={{ marginTop: 24 }}>
|
||||
{t("spool.fields.adjustment_history")}
|
||||
</Title>
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: t("spool.fields.show_history"),
|
||||
children: (
|
||||
<Table<ISpoolAdjustment>
|
||||
dataSource={adjustmentsData}
|
||||
loading={adjustmentsLoading}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
columns={[
|
||||
{
|
||||
title: t("spool.fields.adjustment_timestamp"),
|
||||
dataIndex: "timestamp",
|
||||
key: "timestamp",
|
||||
render: (value: string) => (
|
||||
<span title={dayjs.utc(value).local().format()}>
|
||||
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.adjustment_type"),
|
||||
dataIndex: "adjustment_type",
|
||||
key: "adjustment_type",
|
||||
render: (value: string) => t(`spool.fields.adjustment_type_${value}`),
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.adjustment_value"),
|
||||
dataIndex: "value",
|
||||
key: "value",
|
||||
render: (value: number, record: ISpoolAdjustment) => {
|
||||
const unit = record.adjustment_type === "weight" ? "g" : "mm";
|
||||
const formatted = value.toFixed(2);
|
||||
return `${value > 0 ? "+" : ""}${formatted} ${unit}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("spool.fields.adjustment_comment"),
|
||||
dataIndex: "comment",
|
||||
key: "comment",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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 dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
|
||||
import { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
import { useFormShortcuts } from "../../utils/useFormShortcuts";
|
||||
import { IVendor, IVendorParsedExtras } from "./model";
|
||||
|
||||
dayjs.extend(utc);
|
||||
@@ -41,6 +42,12 @@ export const VendorCreate: React.FC<IResourceComponentsProps & CreateOrCloneProp
|
||||
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
|
||||
// This is necessary because the form is rendered before the extra fields are loaded
|
||||
useEffect(() => {
|
||||
|
||||
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 { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
|
||||
import { Alert, DatePicker, Form, Input, InputNumber, message, Typography } from "antd";
|
||||
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
|
||||
import { Alert, Button, DatePicker, Form, Input, InputNumber, message, Typography } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
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 { EntityType, useGetFields } from "../../utils/queryFields";
|
||||
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> = () => {
|
||||
const t = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
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",
|
||||
onLiveEvent() {
|
||||
// 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
|
||||
if (formProps.initialValues) {
|
||||
formProps.initialValues = ParsedExtras(formProps.initialValues);
|
||||
@@ -49,7 +70,28 @@ export const VendorEdit: React.FC<IResourceComponentsProps> = () => {
|
||||
};
|
||||
|
||||
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}
|
||||
<Form {...formProps} layout="vertical">
|
||||
<Form.Item
|
||||
|
||||
69
client/src/pages/vendors/list.tsx
vendored
@@ -7,12 +7,12 @@ import utc from "dayjs/plugin/utc";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
ActionsColumn,
|
||||
CustomFieldColumn,
|
||||
DateColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
ActionsColumn,
|
||||
CustomFieldColumn,
|
||||
DateColumn,
|
||||
NumberColumn,
|
||||
RichColumn,
|
||||
SortedColumn,
|
||||
} from "../../components/column";
|
||||
import { useLiveify } from "../../components/liveify";
|
||||
import { removeUndefined } from "../../utils/filtering";
|
||||
@@ -38,32 +38,33 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
|
||||
const initialState = useInitialTableState(namespace);
|
||||
|
||||
// Fetch data from the API
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<IVendor>({
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "server",
|
||||
current: initialState.pagination.current,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "server",
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "server",
|
||||
initial: initialState.filters,
|
||||
},
|
||||
liveMode: "manual",
|
||||
onLiveEvent(event) {
|
||||
if (event.type === "created" || event.type === "deleted") {
|
||||
// updated is handled by the liveify
|
||||
invalidate({
|
||||
resource: "vendor",
|
||||
invalidates: ["list"],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } =
|
||||
useTable<IVendor>({
|
||||
syncWithLocation: false,
|
||||
pagination: {
|
||||
mode: "server",
|
||||
currentPage: initialState.pagination.currentPage,
|
||||
pageSize: initialState.pagination.pageSize,
|
||||
},
|
||||
sorters: {
|
||||
mode: "server",
|
||||
initial: initialState.sorters,
|
||||
},
|
||||
filters: {
|
||||
mode: "server",
|
||||
initial: initialState.filters,
|
||||
},
|
||||
liveMode: "manual",
|
||||
onLiveEvent(event) {
|
||||
if (event.type === "created" || event.type === "deleted") {
|
||||
// updated is handled by the liveify
|
||||
invalidate({
|
||||
resource: "vendor",
|
||||
invalidates: ["list"],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Create state for the columns to show
|
||||
const [showColumns, setShowColumns] = useState<string[]>(initialState.showColumns ?? allColumns);
|
||||
@@ -72,7 +73,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
|
||||
const tableState: TableState = {
|
||||
sorters,
|
||||
filters,
|
||||
pagination: { current, pageSize },
|
||||
pagination: { currentPage, pageSize },
|
||||
showColumns,
|
||||
};
|
||||
useStoreInitialState(namespace, tableState);
|
||||
@@ -117,7 +118,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
|
||||
onClick={() => {
|
||||
setFilters([], "replace");
|
||||
setSorters([{ field: "id", order: "asc" }]);
|
||||
setCurrent(1);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{t("buttons.clearFilters")}
|
||||
|
||||
4
client/src/pages/vendors/show.tsx
vendored
@@ -17,10 +17,10 @@ export const VendorShow: React.FC<IResourceComponentsProps> = () => {
|
||||
const t = useTranslate();
|
||||
const extraFields = useGetFields(EntityType.vendor);
|
||||
|
||||
const { queryResult } = useShow<IVendor>({
|
||||
const { query } = useShow<IVendor>({
|
||||
liveMode: "auto",
|
||||
});
|
||||
const { data, isLoading } = queryResult;
|
||||
const { data, isLoading } = query;
|
||||
|
||||
const record = data?.data;
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
90
client/src/utils/materialDefaults.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Standard material densities for common 3D printing filaments.
|
||||
* Values are in g/cm³.
|
||||
*
|
||||
* Reference: https://www.simplify3d.com/resources/materials-guide/
|
||||
*/
|
||||
export const MATERIAL_DENSITY_MAP: Record<string, number> = {
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default filament diameter in mm (most common standard).
|
||||
*/
|
||||
export const DEFAULT_DIAMETER = 1.75;
|
||||
|
||||
/**
|
||||
* Default density in g/cm³ (PLA, most common material).
|
||||
*/
|
||||
export const DEFAULT_DENSITY = 1.24;
|
||||
|
||||
/**
|
||||
* List of known materials for AutoComplete suggestions.
|
||||
*/
|
||||
export const KNOWN_MATERIALS = Object.keys(MATERIAL_DENSITY_MAP);
|
||||
|
||||
/**
|
||||
* Get the density for a material type using fuzzy matching.
|
||||
* Handles variations like "PLA Silk", "PETG-CF", "Wood PLA", etc.
|
||||
*
|
||||
* @param material - The material string to look up
|
||||
* @returns The density if a match is found, undefined otherwise
|
||||
*/
|
||||
export function getDensityForMaterial(material: string | undefined): number | undefined {
|
||||
if (!material) return undefined;
|
||||
|
||||
const normalizedInput = material.toUpperCase().trim();
|
||||
|
||||
// Exact match first
|
||||
if (MATERIAL_DENSITY_MAP[normalizedInput]) {
|
||||
return MATERIAL_DENSITY_MAP[normalizedInput];
|
||||
}
|
||||
|
||||
// Fuzzy match: check if input starts with or contains a known material
|
||||
// Sort by length descending to match longer patterns first (e.g., "PC/ABS" before "PC")
|
||||
const sortedMaterials = KNOWN_MATERIALS.sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const knownMaterial of sortedMaterials) {
|
||||
// Check if the input starts with the material (e.g., "PLA Silk" starts with "PLA")
|
||||
if (normalizedInput.startsWith(knownMaterial)) {
|
||||
return MATERIAL_DENSITY_MAP[knownMaterial];
|
||||
}
|
||||
// Check if the input contains the material as a word boundary
|
||||
// This handles cases like "Wood PLA" or "CF-PETG"
|
||||
const regex = new RegExp(`\\b${knownMaterial}\\b`, "i");
|
||||
if (regex.test(normalizedInput)) {
|
||||
return MATERIAL_DENSITY_MAP[knownMaterial];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter materials for AutoComplete based on input.
|
||||
* Returns materials that contain the search string (case-insensitive).
|
||||
*
|
||||
* @param searchValue - The search string
|
||||
* @returns Filtered list of material suggestions
|
||||
*/
|
||||
export function filterMaterials(searchValue: string): string[] {
|
||||
if (!searchValue) return KNOWN_MATERIALS;
|
||||
|
||||
const normalizedSearch = searchValue.toUpperCase().trim();
|
||||
return KNOWN_MATERIALS.filter((material) => material.includes(normalizedSearch));
|
||||
}
|
||||
@@ -1,3 +1,29 @@
|
||||
/* 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 {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* Constrain form width for better readability on wide screens */
|
||||
.ant-form {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -28,14 +28,16 @@ export interface ExternalFilament {
|
||||
name: string;
|
||||
material: string;
|
||||
density: number;
|
||||
weight: number;
|
||||
weight?: number;
|
||||
spool_weight?: number;
|
||||
spool_type?: SpoolType;
|
||||
diameter: number;
|
||||
color_hex?: string;
|
||||
color_hexes?: string[];
|
||||
extruder_temp?: number;
|
||||
extruder_temp_max?: number;
|
||||
bed_temp?: number;
|
||||
bed_temp_max?: number;
|
||||
finish?: Finish;
|
||||
multi_color_direction?: MultiColorDirection;
|
||||
pattern?: Pattern;
|
||||
|
||||
@@ -66,7 +66,9 @@ export function useSetField(entity_type: EntityType) {
|
||||
},
|
||||
onMutate: async ({ key, params }) => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries(["fields", entity_type]);
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["fields", entity_type]
|
||||
});
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousFields = queryClient.getQueryData<Field[]>(["fields", entity_type]);
|
||||
@@ -101,7 +103,9 @@ export function useSetField(entity_type: EntityType) {
|
||||
},
|
||||
onSettled: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(["fields", entity_type]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["fields", entity_type]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -124,7 +128,9 @@ export function useDeleteField(entity_type: EntityType) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(["fields", entity_type]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["fields", entity_type]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ export function useSetSetting<T>(key: string) {
|
||||
return response.json();
|
||||
},
|
||||
onMutate: async (value) => {
|
||||
await queryClient.cancelQueries(["settings", key]);
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["settings", key]
|
||||
});
|
||||
const previousValue = queryClient.getQueryData<SettingResponseValue>(["settings", key]);
|
||||
queryClient.setQueryData<SettingResponseValue>(["settings", key], (old) =>
|
||||
old ? { ...old, value: JSON.stringify(value) } : undefined
|
||||
@@ -64,7 +66,9 @@ export function useSetSetting<T>(key: string) {
|
||||
},
|
||||
onSuccess: (_data, _value) => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(["settings", key]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["settings", key]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CrudFilter, CrudSort } from "@refinedev/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isLocalStorageAvailable } from "./support";
|
||||
interface Pagination {
|
||||
current: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
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]);
|
||||
}
|
||||
@@ -1,8 +1,55 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
|
||||
export default defineConfig({
|
||||
base: "",
|
||||
plugins: [react(), svgr()],
|
||||
plugins: [
|
||||
react(),
|
||||
svgr(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
includeAssets: ["favicon.ico", "favicon.svg", "apple-touch-icon-180x180.png"],
|
||||
manifest: {
|
||||
name: "Spoolman",
|
||||
short_name: "Spoolman",
|
||||
description: "Keep track of your inventory of 3D-printer filament spools.",
|
||||
icons: [
|
||||
{
|
||||
src: "pwa-64x64.png",
|
||||
sizes: "64x64",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "maskable-icon-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
background_color: "#1F1F1F",
|
||||
theme_color: "#DC7734",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
},
|
||||
workbox: {
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
39
codex.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `spoolman/` holds the FastAPI backend, SQLAlchemy models, and API routes (`spoolman/api/v1/`).
|
||||
- `client/` is the React + TypeScript UI (Vite), with pages under `client/src/pages/` and shared components under `client/src/components/`.
|
||||
- `migrations/` contains Alembic migrations; versions live in `migrations/versions/`.
|
||||
- `tests_integration/` holds pytest integration tests and Docker Compose configs for multiple databases.
|
||||
- `scripts/` provides install/start helpers; `Dockerfile` and `entrypoint.sh` support container builds.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `pdm install` installs backend dependencies (including dev tools).
|
||||
- `pdm run app` starts the API server (`uvicorn spoolman.main:app`).
|
||||
- `pdm run itest` runs integration tests across all databases.
|
||||
- `pdm run docs` generates API documentation.
|
||||
- `python tests_integration/run.py sqlite postgres` runs tests for selected DBs.
|
||||
- `cd client && npm ci` installs UI deps; `npm run dev` starts the UI dev server; `npm run build` creates a production build.
|
||||
- `bash scripts/install.sh` bootstraps a local install; `bash scripts/start.sh` runs using `.env`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Python: Black + Ruff, 120-char lines (`pyproject.toml`). Prefer async patterns in backend code.
|
||||
- TypeScript/React: follow existing conventions (pages in lowercase file names, shared components in `PascalCase`).
|
||||
- Keep naming aligned with existing modules (e.g., `spool`, `filament`, `vendor`).
|
||||
|
||||
## Testing Guidelines
|
||||
- Tests use `pytest` with async support; files follow `test_*.py` and functions start with `test_`.
|
||||
- Integration tests are the primary suite; run via `pdm run itest` or `python tests_integration/run.py`.
|
||||
|
||||
## Database Migrations
|
||||
- Update models in `spoolman/database/models.py`.
|
||||
- Start the server once, then generate a migration: `pdm run alembic revision -m "desc" --autogenerate`.
|
||||
- Review and format migrations with Black/Ruff before committing.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow commit history conventions (e.g., `feat:`, `docs:`, `fix:`) and keep messages concise.
|
||||
- PRs should include a short summary, testing performed, and screenshots for UI changes.
|
||||
- Link relevant issues where applicable.
|
||||
|
||||
## Configuration Tips
|
||||
- Copy `.env.example` to `.env` for local runs; key variables include `SPOOLMAN_DB_TYPE`, `SPOOLMAN_PORT`, and `VITE_APIURL`.
|
||||
@@ -14,4 +14,4 @@ echo User GID: $(id -g app)
|
||||
echo "Starting uvicorn..."
|
||||
|
||||
# Execute the uvicorn command with any additional arguments
|
||||
exec su-exec "app" uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"
|
||||
exec gosu "app" uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add_temperature_ranges.
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 415a8f855e14
|
||||
Create Date: 2025-01-15 02:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a1b2c3d4e5f6"
|
||||
down_revision = "415a8f855e14"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add temperature range columns and migrate existing data."""
|
||||
# Add new columns for temperature ranges
|
||||
op.add_column("filament", sa.Column("settings_extruder_temp_min", sa.Integer(), nullable=True))
|
||||
op.add_column("filament", sa.Column("settings_extruder_temp_max", sa.Integer(), nullable=True))
|
||||
op.add_column("filament", sa.Column("settings_bed_temp_min", sa.Integer(), nullable=True))
|
||||
op.add_column("filament", sa.Column("settings_bed_temp_max", sa.Integer(), nullable=True))
|
||||
|
||||
# Migrate existing single temp values to both min and max
|
||||
# This preserves the existing data while enabling the new range functionality
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE filament
|
||||
SET settings_extruder_temp_min = settings_extruder_temp,
|
||||
settings_extruder_temp_max = settings_extruder_temp
|
||||
WHERE settings_extruder_temp IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE filament
|
||||
SET settings_bed_temp_min = settings_bed_temp,
|
||||
settings_bed_temp_max = settings_bed_temp
|
||||
WHERE settings_bed_temp IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove temperature range columns."""
|
||||
op.drop_column("filament", "settings_bed_temp_max")
|
||||
op.drop_column("filament", "settings_bed_temp_min")
|
||||
op.drop_column("filament", "settings_extruder_temp_max")
|
||||
op.drop_column("filament", "settings_extruder_temp_min")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add_spool_adjustment_history.
|
||||
|
||||
Revision ID: b2c3d4e5f6g7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2025-01-15 03:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b2c3d4e5f6g7"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create spool_adjustment table for tracking history."""
|
||||
op.create_table(
|
||||
"spool_adjustment",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("spool_id", sa.Integer(), nullable=False),
|
||||
sa.Column("timestamp", sa.DateTime(), nullable=False),
|
||||
sa.Column("adjustment_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("value", sa.Float(), nullable=False),
|
||||
sa.Column("comment", sa.String(length=256), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["spool_id"],
|
||||
["spool.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_spool_adjustment_id"), "spool_adjustment", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_spool_adjustment_spool_id"), "spool_adjustment", ["spool_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop spool_adjustment table."""
|
||||
op.drop_index(op.f("ix_spool_adjustment_spool_id"), table_name="spool_adjustment")
|
||||
op.drop_index(op.f("ix_spool_adjustment_id"), table_name="spool_adjustment")
|
||||
op.drop_table("spool_adjustment")
|
||||
@@ -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")
|
||||
@@ -1,9 +1,11 @@
|
||||
[project]
|
||||
name = "spoolman"
|
||||
version = "0.22.1"
|
||||
version = "0.23C.5"
|
||||
description = "A web service that keeps track of 3D printing spools."
|
||||
authors = [
|
||||
{ name = "Tony", email = "tony@nastynas.xyz" },
|
||||
{ name = "Donkie", email = "daniel.cf.hultgren@gmail.com" },
|
||||
{ name = "Claude", email = "noreply@anthropic.com" },
|
||||
]
|
||||
dependencies = [
|
||||
"uvicorn~=0.34",
|
||||
@@ -23,8 +25,12 @@ dependencies = [
|
||||
"prometheus-client~=0.21",
|
||||
"httpx~=0.28",
|
||||
"hishel~=0.1",
|
||||
"python-multipart~=0.0",
|
||||
"passlib>=1.7",
|
||||
"bcrypt>=4.0",
|
||||
"python-jose[cryptography]>=3.3",
|
||||
]
|
||||
requires-python = ">=3.9,<=3.12"
|
||||
requires-python = ">=3.9,<3.13"
|
||||
|
||||
[project.license]
|
||||
text = "MIT"
|
||||
|
||||
178
scripts/parse_temps_api.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse temperature values from filament comments using the API."""
|
||||
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
|
||||
|
||||
def parse_temps_from_comment(comment: str) -> dict:
|
||||
"""Parse extruder and bed temps from a comment string.
|
||||
|
||||
Patterns to match:
|
||||
- "extrude 190-230" or "extruder 190-230" or "nozzle 190-230"
|
||||
- "bed 50-70"
|
||||
- "190-230 / 50-70" (extruder / bed)
|
||||
- "190-230C" or "190-230°C"
|
||||
- Single values like "extrude 210" → min=max=210
|
||||
"""
|
||||
result = {
|
||||
"extruder_temp_min": None,
|
||||
"extruder_temp_max": None,
|
||||
"bed_temp_min": None,
|
||||
"bed_temp_max": None,
|
||||
}
|
||||
|
||||
if not comment:
|
||||
return result
|
||||
|
||||
comment_lower = comment.lower()
|
||||
|
||||
# Pattern for temperature ranges: 190-230 or 190 - 230
|
||||
temp_range_pattern = r"(\d{2,3})\s*[-–]\s*(\d{2,3})"
|
||||
# Pattern for single temp: 210
|
||||
single_temp_pattern = r"(\d{2,3})"
|
||||
|
||||
# Try to find extruder temp (usually higher values, 150-300 range)
|
||||
extruder_keywords = ["extrude", "extruder", "nozzle", "hotend", "hot end", "printing temp"]
|
||||
for keyword in extruder_keywords:
|
||||
if keyword in comment_lower:
|
||||
# Find temp after keyword
|
||||
idx = comment_lower.index(keyword)
|
||||
search_area = comment[idx:idx+50] # Look in next 50 chars
|
||||
|
||||
# Try range first
|
||||
match = re.search(temp_range_pattern, search_area)
|
||||
if match:
|
||||
result["extruder_temp_min"] = int(match.group(1))
|
||||
result["extruder_temp_max"] = int(match.group(2))
|
||||
break
|
||||
# Try single value
|
||||
match = re.search(single_temp_pattern, search_area[len(keyword):])
|
||||
if match:
|
||||
val = int(match.group(1))
|
||||
if 150 <= val <= 350: # Reasonable extruder temp range
|
||||
result["extruder_temp_min"] = val
|
||||
result["extruder_temp_max"] = val
|
||||
break
|
||||
|
||||
# Try to find bed temp (usually lower values, 30-120 range)
|
||||
bed_keywords = ["bed", "plate", "build plate"]
|
||||
for keyword in bed_keywords:
|
||||
if keyword in comment_lower:
|
||||
# Find temp after keyword
|
||||
idx = comment_lower.index(keyword)
|
||||
search_area = comment[idx:idx+40] # Look in next 40 chars
|
||||
|
||||
# Try range first
|
||||
match = re.search(temp_range_pattern, search_area)
|
||||
if match:
|
||||
t1, t2 = int(match.group(1)), int(match.group(2))
|
||||
# Validate: bed temps should be 30-120°C typically
|
||||
if 30 <= t1 <= 120 and 30 <= t2 <= 120:
|
||||
result["bed_temp_min"] = t1
|
||||
result["bed_temp_max"] = t2
|
||||
break
|
||||
# Try single value
|
||||
match = re.search(single_temp_pattern, search_area[len(keyword):])
|
||||
if match:
|
||||
val = int(match.group(1))
|
||||
if 30 <= val <= 120: # Reasonable bed temp range
|
||||
result["bed_temp_min"] = val
|
||||
result["bed_temp_max"] = val
|
||||
break
|
||||
|
||||
# If no keywords found, try to find generic temp pattern
|
||||
# Format: "190-230 / 50-70" (extruder / bed separated by /)
|
||||
if result["extruder_temp_min"] is None and "/" in comment:
|
||||
parts = comment.split("/")
|
||||
if len(parts) >= 2:
|
||||
# First part might be extruder
|
||||
match = re.search(temp_range_pattern, parts[0])
|
||||
if match:
|
||||
t1, t2 = int(match.group(1)), int(match.group(2))
|
||||
if t1 > 100: # Likely extruder temp
|
||||
result["extruder_temp_min"] = t1
|
||||
result["extruder_temp_max"] = t2
|
||||
|
||||
# Second part might be bed
|
||||
match = re.search(temp_range_pattern, parts[1])
|
||||
if match:
|
||||
t1, t2 = int(match.group(1)), int(match.group(2))
|
||||
if t1 < 150: # Likely bed temp
|
||||
result["bed_temp_min"] = t1
|
||||
result["bed_temp_max"] = t2
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main(api_url: str, dry_run: bool = True):
|
||||
"""Main function to parse temps from comments."""
|
||||
print(f"Connecting to: {api_url}")
|
||||
|
||||
# Get all filaments
|
||||
response = requests.get(f"{api_url}/filament")
|
||||
response.raise_for_status()
|
||||
filaments = response.json()
|
||||
|
||||
print(f"\nFound {len(filaments)} filaments total")
|
||||
|
||||
updated_count = 0
|
||||
for fil in filaments:
|
||||
comment = fil.get("comment")
|
||||
if not comment:
|
||||
continue
|
||||
|
||||
parsed = parse_temps_from_comment(comment)
|
||||
|
||||
# Check if we found any temps
|
||||
has_extruder = parsed["extruder_temp_min"] is not None
|
||||
has_bed = parsed["bed_temp_min"] is not None
|
||||
|
||||
if has_extruder or has_bed:
|
||||
print(f"\n--- Filament #{fil['id']}: {fil.get('name', 'unnamed')} ---")
|
||||
print(f" Comment: {comment}")
|
||||
print(f" Parsed extruder: {parsed['extruder_temp_min']}-{parsed['extruder_temp_max']} °C" if has_extruder else " No extruder temp found")
|
||||
print(f" Parsed bed: {parsed['bed_temp_min']}-{parsed['bed_temp_max']} °C" if has_bed else " No bed temp found")
|
||||
|
||||
current_extruder_min = fil.get("settings_extruder_temp_min")
|
||||
current_bed_min = fil.get("settings_bed_temp_min")
|
||||
print(f" Current extruder: {current_extruder_min}-{fil.get('settings_extruder_temp_max')}" if current_extruder_min else " Current extruder: Not set")
|
||||
print(f" Current bed: {current_bed_min}-{fil.get('settings_bed_temp_max')}" if current_bed_min else " Current bed: Not set")
|
||||
|
||||
if not dry_run:
|
||||
update_data = {}
|
||||
|
||||
# Only update if not already set
|
||||
if has_extruder and not current_extruder_min:
|
||||
update_data["settings_extruder_temp_min"] = parsed["extruder_temp_min"]
|
||||
update_data["settings_extruder_temp_max"] = parsed["extruder_temp_max"]
|
||||
if has_bed and not current_bed_min:
|
||||
update_data["settings_bed_temp_min"] = parsed["bed_temp_min"]
|
||||
update_data["settings_bed_temp_max"] = parsed["bed_temp_max"]
|
||||
|
||||
# Clear comment after parsing
|
||||
update_data["comment"] = ""
|
||||
|
||||
if update_data:
|
||||
response = requests.patch(f"{api_url}/filament/{fil['id']}", json=update_data)
|
||||
response.raise_for_status()
|
||||
print(" → Updated and cleared comment")
|
||||
|
||||
updated_count += 1
|
||||
|
||||
if dry_run:
|
||||
print(f"\n[DRY RUN] Would update {updated_count} filaments")
|
||||
print("Run with --apply to actually make changes")
|
||||
else:
|
||||
print(f"\n✓ Updated {updated_count} filaments")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Parse temps from filament comments via API")
|
||||
parser.add_argument("--url", default="http://192.168.0.11:8000/api/v1", help="API URL (default: http://192.168.0.11:8000/api/v1)")
|
||||
parser.add_argument("--apply", action="store_true", help="Actually apply changes (default is dry run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
main(api_url=args.url, dry_run=not args.apply)
|
||||
190
scripts/parse_temps_from_comments.py
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse temperature values from filament comments and update temp range fields."""
|
||||
|
||||
import re
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the spoolman module to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from spoolman.database.models import Filament
|
||||
|
||||
|
||||
def parse_temps_from_comment(comment: str) -> dict:
|
||||
"""Parse extruder and bed temps from a comment string.
|
||||
|
||||
Patterns to match:
|
||||
- "extrude 190-230" or "extruder 190-230" or "nozzle 190-230"
|
||||
- "bed 50-70"
|
||||
- "190-230 / 50-70" (extruder / bed)
|
||||
- "190-230C" or "190-230°C"
|
||||
- Single values like "extrude 210" → min=max=210
|
||||
"""
|
||||
result = {
|
||||
"extruder_temp_min": None,
|
||||
"extruder_temp_max": None,
|
||||
"bed_temp_min": None,
|
||||
"bed_temp_max": None,
|
||||
}
|
||||
|
||||
if not comment:
|
||||
return result
|
||||
|
||||
comment_lower = comment.lower()
|
||||
|
||||
# Pattern for temperature ranges: 190-230 or 190 - 230
|
||||
temp_range_pattern = r"(\d{2,3})\s*[-–]\s*(\d{2,3})"
|
||||
# Pattern for single temp: 210
|
||||
single_temp_pattern = r"(\d{2,3})"
|
||||
|
||||
# Try to find extruder temp (usually higher values, 150-300 range)
|
||||
extruder_keywords = ["extrude", "extruder", "nozzle", "hotend", "hot end", "printing temp"]
|
||||
for keyword in extruder_keywords:
|
||||
if keyword in comment_lower:
|
||||
# Find temp after keyword
|
||||
idx = comment_lower.index(keyword)
|
||||
search_area = comment[idx:idx+50] # Look in next 50 chars
|
||||
|
||||
# Try range first
|
||||
match = re.search(temp_range_pattern, search_area)
|
||||
if match:
|
||||
result["extruder_temp_min"] = int(match.group(1))
|
||||
result["extruder_temp_max"] = int(match.group(2))
|
||||
break
|
||||
# Try single value
|
||||
match = re.search(single_temp_pattern, search_area[len(keyword):])
|
||||
if match:
|
||||
val = int(match.group(1))
|
||||
if 150 <= val <= 350: # Reasonable extruder temp range
|
||||
result["extruder_temp_min"] = val
|
||||
result["extruder_temp_max"] = val
|
||||
break
|
||||
|
||||
# Try to find bed temp (usually lower values, 0-120 range)
|
||||
bed_keywords = ["bed", "plate", "build plate"]
|
||||
for keyword in bed_keywords:
|
||||
if keyword in comment_lower:
|
||||
# Find temp after keyword
|
||||
idx = comment_lower.index(keyword)
|
||||
search_area = comment[idx:idx+40] # Look in next 40 chars
|
||||
|
||||
# Try range first
|
||||
match = re.search(temp_range_pattern, search_area)
|
||||
if match:
|
||||
result["bed_temp_min"] = int(match.group(1))
|
||||
result["bed_temp_max"] = int(match.group(2))
|
||||
break
|
||||
# Try single value
|
||||
match = re.search(single_temp_pattern, search_area[len(keyword):])
|
||||
if match:
|
||||
val = int(match.group(1))
|
||||
if 0 <= val <= 150: # Reasonable bed temp range
|
||||
result["bed_temp_min"] = val
|
||||
result["bed_temp_max"] = val
|
||||
break
|
||||
|
||||
# If no keywords found, try to find generic temp pattern
|
||||
# Format: "190-230 / 50-70" (extruder / bed separated by /)
|
||||
if result["extruder_temp_min"] is None and "/" in comment:
|
||||
parts = comment.split("/")
|
||||
if len(parts) >= 2:
|
||||
# First part might be extruder
|
||||
match = re.search(temp_range_pattern, parts[0])
|
||||
if match:
|
||||
t1, t2 = int(match.group(1)), int(match.group(2))
|
||||
if t1 > 100: # Likely extruder temp
|
||||
result["extruder_temp_min"] = t1
|
||||
result["extruder_temp_max"] = t2
|
||||
|
||||
# Second part might be bed
|
||||
match = re.search(temp_range_pattern, parts[1])
|
||||
if match:
|
||||
t1, t2 = int(match.group(1)), int(match.group(2))
|
||||
if t1 < 150: # Likely bed temp
|
||||
result["bed_temp_min"] = t1
|
||||
result["bed_temp_max"] = t2
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def main(db_path: str = None, dry_run: bool = True):
|
||||
"""Main function to parse temps from comments."""
|
||||
# Determine database URL
|
||||
if db_path:
|
||||
db_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
else:
|
||||
# Default to local spoolman database
|
||||
default_path = Path.home() / ".local/share/spoolman/spoolman.db"
|
||||
if not default_path.exists():
|
||||
# Try the project directory
|
||||
default_path = Path(__file__).parent.parent / "spoolman.db"
|
||||
db_url = f"sqlite+aiosqlite:///{default_path}"
|
||||
|
||||
print(f"Connecting to: {db_url}")
|
||||
|
||||
engine = create_async_engine(db_url, echo=False)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
# Get all filaments with comments
|
||||
result = await session.execute(select(Filament))
|
||||
filaments = result.scalars().all()
|
||||
|
||||
print(f"\nFound {len(filaments)} filaments total")
|
||||
|
||||
updated_count = 0
|
||||
for fil in filaments:
|
||||
if not fil.comment:
|
||||
continue
|
||||
|
||||
parsed = parse_temps_from_comment(fil.comment)
|
||||
|
||||
# Check if we found any temps
|
||||
has_extruder = parsed["extruder_temp_min"] is not None
|
||||
has_bed = parsed["bed_temp_min"] is not None
|
||||
|
||||
if has_extruder or has_bed:
|
||||
print(f"\n--- Filament #{fil.id}: {fil.name or 'unnamed'} ---")
|
||||
print(f" Comment: {fil.comment}")
|
||||
print(f" Parsed extruder: {parsed['extruder_temp_min']}-{parsed['extruder_temp_max']} °C" if has_extruder else " No extruder temp found")
|
||||
print(f" Parsed bed: {parsed['bed_temp_min']}-{parsed['bed_temp_max']} °C" if has_bed else " No bed temp found")
|
||||
print(f" Current extruder: {fil.settings_extruder_temp_min}-{fil.settings_extruder_temp_max}" if fil.settings_extruder_temp_min else " Current extruder: Not set")
|
||||
print(f" Current bed: {fil.settings_bed_temp_min}-{fil.settings_bed_temp_max}" if fil.settings_bed_temp_min else " Current bed: Not set")
|
||||
|
||||
if not dry_run:
|
||||
# Only update if not already set
|
||||
if has_extruder and not fil.settings_extruder_temp_min:
|
||||
fil.settings_extruder_temp_min = parsed["extruder_temp_min"]
|
||||
fil.settings_extruder_temp_max = parsed["extruder_temp_max"]
|
||||
if has_bed and not fil.settings_bed_temp_min:
|
||||
fil.settings_bed_temp_min = parsed["bed_temp_min"]
|
||||
fil.settings_bed_temp_max = parsed["bed_temp_max"]
|
||||
|
||||
# Clear comment after parsing
|
||||
fil.comment = None
|
||||
print(" → Updated and cleared comment")
|
||||
|
||||
updated_count += 1
|
||||
|
||||
if not dry_run and updated_count > 0:
|
||||
await session.commit()
|
||||
print(f"\n✓ Committed changes to {updated_count} filaments")
|
||||
elif dry_run:
|
||||
print(f"\n[DRY RUN] Would update {updated_count} filaments")
|
||||
print("Run with --apply to actually make changes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Parse temps from filament comments")
|
||||
parser.add_argument("--db", help="Path to SQLite database file")
|
||||
parser.add_argument("--apply", action="store_true", help="Actually apply changes (default is dry run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(main(db_path=args.db, dry_run=not args.apply))
|
||||
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
@@ -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,
|
||||
)
|
||||
293
spoolman/api/v1/backup.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Database backup and restore API endpoints."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.models import (
|
||||
Filament,
|
||||
FilamentField,
|
||||
Setting,
|
||||
Spool,
|
||||
SpoolAdjustment,
|
||||
SpoolField,
|
||||
Vendor,
|
||||
VendorField,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/backup",
|
||||
tags=["backup"],
|
||||
)
|
||||
|
||||
|
||||
class BackupData(BaseModel):
|
||||
"""Schema for backup data."""
|
||||
|
||||
version: str = Field(default="1.0", description="Backup format version")
|
||||
exported_at: str = Field(description="ISO timestamp of export")
|
||||
vendors: list[dict[str, Any]] = Field(default_factory=list)
|
||||
filaments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
spools: list[dict[str, Any]] = Field(default_factory=list)
|
||||
spool_adjustments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
settings: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""Result of import operation."""
|
||||
|
||||
success: bool
|
||||
vendors_imported: int = 0
|
||||
filaments_imported: int = 0
|
||||
spools_imported: int = 0
|
||||
adjustments_imported: int = 0
|
||||
settings_imported: int = 0
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _serialize_datetime(obj: Any) -> Any:
|
||||
"""Convert datetime objects to ISO strings."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return obj
|
||||
|
||||
|
||||
def _model_to_dict(obj: Any, exclude: Optional[set[str]] = None) -> dict[str, Any]:
|
||||
"""Convert SQLAlchemy model to dict, handling extra fields."""
|
||||
exclude = exclude or set()
|
||||
result = {}
|
||||
|
||||
for column in obj.__table__.columns:
|
||||
if column.name not in exclude:
|
||||
value = getattr(obj, column.name)
|
||||
result[column.name] = _serialize_datetime(value)
|
||||
|
||||
# Handle extra fields if present
|
||||
if hasattr(obj, "extra") and obj.extra:
|
||||
result["extra"] = {field.key: field.value for field in obj.extra}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
name="Export all data",
|
||||
description="Export all Spoolman data as a single JSON file for backup or migration.",
|
||||
response_class=Response,
|
||||
)
|
||||
async def export_all(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
) -> Response:
|
||||
"""Export all database data as JSON."""
|
||||
|
||||
# Fetch all vendors
|
||||
vendors_result = await db.execute(select(Vendor))
|
||||
vendors = [_model_to_dict(v) for v in vendors_result.unique().scalars().all()]
|
||||
|
||||
# Fetch all filaments
|
||||
filaments_result = await db.execute(select(Filament))
|
||||
filaments = [_model_to_dict(f) for f in filaments_result.unique().scalars().all()]
|
||||
|
||||
# Fetch all spools
|
||||
spools_result = await db.execute(select(Spool))
|
||||
spools = [_model_to_dict(s) for s in spools_result.unique().scalars().all()]
|
||||
|
||||
# Fetch all spool adjustments
|
||||
adjustments_result = await db.execute(select(SpoolAdjustment))
|
||||
adjustments = [_model_to_dict(a, exclude={"spool"}) for a in adjustments_result.scalars().all()]
|
||||
|
||||
# Fetch all settings
|
||||
settings_result = await db.execute(select(Setting))
|
||||
settings = [_model_to_dict(s) for s in settings_result.scalars().all()]
|
||||
|
||||
backup = BackupData(
|
||||
version="1.0",
|
||||
exported_at=datetime.utcnow().isoformat(),
|
||||
vendors=vendors,
|
||||
filaments=filaments,
|
||||
spools=spools,
|
||||
spool_adjustments=adjustments,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
# Generate filename with timestamp
|
||||
filename = f"spoolman_backup_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
|
||||
return Response(
|
||||
content=json.dumps(backup.model_dump(), indent=2, default=str),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import",
|
||||
name="Import data",
|
||||
description="Import Spoolman data from a backup JSON file.",
|
||||
response_model=ImportResult,
|
||||
)
|
||||
async def import_all(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
file: Annotated[UploadFile, File(description="Backup JSON file to import")],
|
||||
replace: bool = False,
|
||||
) -> ImportResult:
|
||||
"""Import database data from JSON backup.
|
||||
|
||||
Args:
|
||||
file: The backup JSON file to import
|
||||
replace: If True, clear existing data before import. If False, merge/skip conflicts.
|
||||
"""
|
||||
result = ImportResult(success=False)
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content)
|
||||
|
||||
# Validate version
|
||||
version = data.get("version", "1.0")
|
||||
if version != "1.0":
|
||||
result.errors.append(f"Unsupported backup version: {version}")
|
||||
return result
|
||||
|
||||
# If replace mode, clear existing data (order matters due to foreign keys)
|
||||
if replace:
|
||||
await db.execute(SpoolAdjustment.__table__.delete())
|
||||
await db.execute(SpoolField.__table__.delete())
|
||||
await db.execute(Spool.__table__.delete())
|
||||
await db.execute(FilamentField.__table__.delete())
|
||||
await db.execute(Filament.__table__.delete())
|
||||
await db.execute(VendorField.__table__.delete())
|
||||
await db.execute(Vendor.__table__.delete())
|
||||
await db.commit()
|
||||
|
||||
# Track ID mappings for relationships (old_id -> new_id)
|
||||
vendor_id_map: dict[int, int] = {}
|
||||
filament_id_map: dict[int, int] = {}
|
||||
spool_id_map: dict[int, int] = {}
|
||||
|
||||
# Import vendors
|
||||
for vendor_data in data.get("vendors", []):
|
||||
old_id = vendor_data.pop("id", None)
|
||||
extra_fields = vendor_data.pop("extra", {})
|
||||
|
||||
# Parse datetime
|
||||
if "registered" in vendor_data and isinstance(vendor_data["registered"], str):
|
||||
vendor_data["registered"] = datetime.fromisoformat(vendor_data["registered"])
|
||||
|
||||
vendor = Vendor(**vendor_data)
|
||||
db.add(vendor)
|
||||
await db.flush() # Get the new ID
|
||||
|
||||
if old_id is not None:
|
||||
vendor_id_map[old_id] = vendor.id
|
||||
|
||||
# Add extra fields
|
||||
for key, value in extra_fields.items():
|
||||
db.add(VendorField(vendor_id=vendor.id, key=key, value=value))
|
||||
|
||||
result.vendors_imported += 1
|
||||
|
||||
# Import filaments
|
||||
for filament_data in data.get("filaments", []):
|
||||
old_id = filament_data.pop("id", None)
|
||||
extra_fields = filament_data.pop("extra", {})
|
||||
|
||||
# Remap vendor_id
|
||||
if filament_data.get("vendor_id") and filament_data["vendor_id"] in vendor_id_map:
|
||||
filament_data["vendor_id"] = vendor_id_map[filament_data["vendor_id"]]
|
||||
|
||||
# Parse datetime
|
||||
if "registered" in filament_data and isinstance(filament_data["registered"], str):
|
||||
filament_data["registered"] = datetime.fromisoformat(filament_data["registered"])
|
||||
|
||||
filament = Filament(**filament_data)
|
||||
db.add(filament)
|
||||
await db.flush()
|
||||
|
||||
if old_id is not None:
|
||||
filament_id_map[old_id] = filament.id
|
||||
|
||||
# Add extra fields
|
||||
for key, value in extra_fields.items():
|
||||
db.add(FilamentField(filament_id=filament.id, key=key, value=value))
|
||||
|
||||
result.filaments_imported += 1
|
||||
|
||||
# Import spools
|
||||
for spool_data in data.get("spools", []):
|
||||
old_id = spool_data.pop("id", None)
|
||||
extra_fields = spool_data.pop("extra", {})
|
||||
|
||||
# Remap filament_id
|
||||
if spool_data.get("filament_id") and spool_data["filament_id"] in filament_id_map:
|
||||
spool_data["filament_id"] = filament_id_map[spool_data["filament_id"]]
|
||||
|
||||
# Parse datetimes
|
||||
for field in ["registered", "first_used", "last_used"]:
|
||||
if field in spool_data and isinstance(spool_data[field], str):
|
||||
spool_data[field] = datetime.fromisoformat(spool_data[field])
|
||||
|
||||
spool = Spool(**spool_data)
|
||||
db.add(spool)
|
||||
await db.flush()
|
||||
|
||||
if old_id is not None:
|
||||
spool_id_map[old_id] = spool.id
|
||||
|
||||
# Add extra fields
|
||||
for key, value in extra_fields.items():
|
||||
db.add(SpoolField(spool_id=spool.id, key=key, value=value))
|
||||
|
||||
result.spools_imported += 1
|
||||
|
||||
# Import spool adjustments
|
||||
for adj_data in data.get("spool_adjustments", []):
|
||||
adj_data.pop("id", None) # Remove old ID
|
||||
|
||||
# Remap spool_id
|
||||
if adj_data.get("spool_id") and adj_data["spool_id"] in spool_id_map:
|
||||
adj_data["spool_id"] = spool_id_map[adj_data["spool_id"]]
|
||||
else:
|
||||
# Skip if spool doesn't exist
|
||||
continue
|
||||
|
||||
# Parse timestamp
|
||||
if "timestamp" in adj_data and isinstance(adj_data["timestamp"], str):
|
||||
adj_data["timestamp"] = datetime.fromisoformat(adj_data["timestamp"])
|
||||
|
||||
adjustment = SpoolAdjustment(**adj_data)
|
||||
db.add(adjustment)
|
||||
result.adjustments_imported += 1
|
||||
|
||||
# Import settings
|
||||
for setting_data in data.get("settings", []):
|
||||
# Parse datetime
|
||||
if "last_updated" in setting_data and isinstance(setting_data["last_updated"], str):
|
||||
setting_data["last_updated"] = datetime.fromisoformat(setting_data["last_updated"])
|
||||
|
||||
# Use merge to handle existing settings
|
||||
setting = Setting(**setting_data)
|
||||
await db.merge(setting)
|
||||
result.settings_imported += 1
|
||||
|
||||
await db.commit()
|
||||
result.success = True
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
result.errors.append(f"Invalid JSON: {e}")
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
result.errors.append(str(e))
|
||||
await db.rollback()
|
||||
|
||||
return result
|
||||
@@ -75,15 +75,39 @@ class FilamentParameters(BaseModel):
|
||||
settings_extruder_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden extruder temperature, in °C.",
|
||||
description="Overridden extruder temperature, in °C. Deprecated: use settings_extruder_temp_min/max.",
|
||||
examples=[210],
|
||||
)
|
||||
settings_bed_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden bed temperature, in °C.",
|
||||
description="Overridden bed temperature, in °C. Deprecated: use settings_bed_temp_min/max.",
|
||||
examples=[60],
|
||||
)
|
||||
settings_extruder_temp_min: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Minimum extruder temperature, in °C.",
|
||||
examples=[190],
|
||||
)
|
||||
settings_extruder_temp_max: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Maximum extruder temperature, in °C.",
|
||||
examples=[220],
|
||||
)
|
||||
settings_bed_temp_min: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Minimum bed temperature, in °C.",
|
||||
examples=[50],
|
||||
)
|
||||
settings_bed_temp_max: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Maximum bed temperature, in °C.",
|
||||
examples=[70],
|
||||
)
|
||||
color_hex: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
@@ -342,7 +366,7 @@ async def find(
|
||||
else:
|
||||
filter_by_ids = None
|
||||
|
||||
db_items, total_count = await filament.find(
|
||||
db_items, total_count, spool_stats = await filament.find(
|
||||
db=db,
|
||||
ids=filter_by_ids,
|
||||
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
|
||||
@@ -359,7 +383,14 @@ async def find(
|
||||
# Set x-total-count header for pagination
|
||||
return JSONResponse(
|
||||
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,
|
||||
),
|
||||
headers={"x-total-count": str(total_count)},
|
||||
@@ -454,6 +485,10 @@ async def create( # noqa: ANN201
|
||||
comment=body.comment,
|
||||
settings_extruder_temp=body.settings_extruder_temp,
|
||||
settings_bed_temp=body.settings_bed_temp,
|
||||
settings_extruder_temp_min=body.settings_extruder_temp_min,
|
||||
settings_extruder_temp_max=body.settings_extruder_temp_max,
|
||||
settings_bed_temp_min=body.settings_bed_temp_min,
|
||||
settings_bed_temp_max=body.settings_bed_temp_max,
|
||||
color_hex=body.color_hex,
|
||||
multi_color_hexes=body.multi_color_hexes,
|
||||
multi_color_direction=body.multi_color_direction,
|
||||
|
||||
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!")
|
||||
@@ -149,15 +149,39 @@ class Filament(BaseModel):
|
||||
settings_extruder_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden extruder temperature, in °C.",
|
||||
description="Overridden extruder temperature, in °C. Deprecated: use settings_extruder_temp_min/max.",
|
||||
examples=[210],
|
||||
)
|
||||
settings_bed_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden bed temperature, in °C.",
|
||||
description="Overridden bed temperature, in °C. Deprecated: use settings_bed_temp_min/max.",
|
||||
examples=[60],
|
||||
)
|
||||
settings_extruder_temp_min: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Minimum extruder temperature, in °C.",
|
||||
examples=[190],
|
||||
)
|
||||
settings_extruder_temp_max: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Maximum extruder temperature, in °C.",
|
||||
examples=[220],
|
||||
)
|
||||
settings_bed_temp_min: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Minimum bed temperature, in °C.",
|
||||
examples=[50],
|
||||
)
|
||||
settings_bed_temp_max: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Maximum bed temperature, in °C.",
|
||||
examples=[70],
|
||||
)
|
||||
color_hex: Optional[str] = Field(
|
||||
None,
|
||||
min_length=6,
|
||||
@@ -197,9 +221,25 @@ class Filament(BaseModel):
|
||||
"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
|
||||
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."""
|
||||
return Filament(
|
||||
id=item.id,
|
||||
@@ -216,6 +256,10 @@ class Filament(BaseModel):
|
||||
comment=item.comment,
|
||||
settings_extruder_temp=item.settings_extruder_temp,
|
||||
settings_bed_temp=item.settings_bed_temp,
|
||||
settings_extruder_temp_min=item.settings_extruder_temp_min,
|
||||
settings_extruder_temp_max=item.settings_extruder_temp_max,
|
||||
settings_bed_temp_min=item.settings_bed_temp_min,
|
||||
settings_bed_temp_max=item.settings_bed_temp_max,
|
||||
color_hex=item.color_hex,
|
||||
multi_color_hexes=item.multi_color_hexes,
|
||||
multi_color_direction=(
|
||||
@@ -223,6 +267,8 @@ class Filament(BaseModel):
|
||||
),
|
||||
external_id=item.external_id,
|
||||
extra={field.key: field.value for field in item.extra},
|
||||
spool_count=spool_count,
|
||||
total_remaining_weight=total_remaining_weight,
|
||||
)
|
||||
|
||||
|
||||
@@ -265,6 +311,18 @@ class Spool(BaseModel):
|
||||
description=("Weight of an empty spool (tare weight)."),
|
||||
examples=[246],
|
||||
)
|
||||
extra_weight: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description=("Extra weight to account for, such as DryPods, custom spool holders, etc."),
|
||||
examples=[50],
|
||||
)
|
||||
color_hex: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=8,
|
||||
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
|
||||
examples=["FF0000"],
|
||||
)
|
||||
used_weight: float = Field(
|
||||
ge=0,
|
||||
description="Consumed weight of filament from the spool in grams.",
|
||||
@@ -279,6 +337,12 @@ class Spool(BaseModel):
|
||||
),
|
||||
examples=[5612.4],
|
||||
)
|
||||
remaining_value: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description="Estimated remaining value of filament on the spool based on remaining weight and price.",
|
||||
examples=[12.50],
|
||||
)
|
||||
used_length: float = Field(
|
||||
ge=0,
|
||||
description="Consumed length of filament from the spool in millimeters.",
|
||||
@@ -303,6 +367,10 @@ class Spool(BaseModel):
|
||||
examples=[""],
|
||||
)
|
||||
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(
|
||||
description=(
|
||||
"Extra fields for this spool. All values are JSON-encoded data. "
|
||||
@@ -319,14 +387,14 @@ class Spool(BaseModel):
|
||||
remaining_length: Optional[float] = None
|
||||
|
||||
if item.initial_weight is not None:
|
||||
remaining_weight = max(item.initial_weight - item.used_weight, 0)
|
||||
remaining_weight = item.initial_weight - item.used_weight
|
||||
remaining_length = length_from_weight(
|
||||
weight=remaining_weight,
|
||||
density=filament.density,
|
||||
diameter=filament.diameter,
|
||||
)
|
||||
elif filament.weight is not None:
|
||||
remaining_weight = max(filament.weight - item.used_weight, 0)
|
||||
remaining_weight = filament.weight - item.used_weight
|
||||
remaining_length = length_from_weight(
|
||||
weight=remaining_weight,
|
||||
density=filament.density,
|
||||
@@ -339,6 +407,13 @@ class Spool(BaseModel):
|
||||
diameter=filament.diameter,
|
||||
)
|
||||
|
||||
# Calculate remaining value based on remaining weight and price
|
||||
remaining_value: Optional[float] = None
|
||||
price = item.price if item.price is not None else filament.price
|
||||
initial_weight = item.initial_weight if item.initial_weight is not None else filament.weight
|
||||
if remaining_weight is not None and price is not None and initial_weight is not None and initial_weight > 0:
|
||||
remaining_value = round((remaining_weight / initial_weight) * price, 2)
|
||||
|
||||
return Spool(
|
||||
id=item.id,
|
||||
registered=item.registered,
|
||||
@@ -348,18 +423,44 @@ class Spool(BaseModel):
|
||||
price=item.price,
|
||||
initial_weight=item.initial_weight,
|
||||
spool_weight=item.spool_weight,
|
||||
extra_weight=item.extra_weight,
|
||||
used_weight=item.used_weight,
|
||||
used_length=used_length,
|
||||
remaining_weight=remaining_weight,
|
||||
remaining_length=remaining_length,
|
||||
remaining_value=remaining_value,
|
||||
location=item.location,
|
||||
lot_nr=item.lot_nr,
|
||||
comment=item.comment,
|
||||
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},
|
||||
)
|
||||
|
||||
|
||||
class SpoolAdjustment(BaseModel):
|
||||
"""Record of a spool weight/length adjustment."""
|
||||
|
||||
id: int = Field(description="Unique internal ID of this adjustment.")
|
||||
spool_id: int = Field(description="The spool this adjustment belongs to.")
|
||||
timestamp: SpoolmanDateTime = Field(description="When the adjustment was made. UTC Timezone.")
|
||||
adjustment_type: str = Field(description="Type of adjustment: 'weight' or 'length'.")
|
||||
value: float = Field(description="Amount adjusted (negative = used, positive = added).")
|
||||
comment: Optional[str] = Field(None, description="Optional user comment about this adjustment.")
|
||||
|
||||
@staticmethod
|
||||
def from_db(item: models.SpoolAdjustment) -> "SpoolAdjustment":
|
||||
"""Create a new Pydantic SpoolAdjustment object from a database object."""
|
||||
return SpoolAdjustment(
|
||||
id=item.id,
|
||||
spool_id=item.spool_id,
|
||||
timestamp=item.timestamp,
|
||||
adjustment_type=item.adjustment_type,
|
||||
value=item.value,
|
||||
comment=item.comment,
|
||||
)
|
||||
|
||||
|
||||
class Info(BaseModel):
|
||||
version: str = Field(examples=["0.7.0"])
|
||||
debug_mode: bool = Field(examples=[False])
|
||||
@@ -370,6 +471,7 @@ class Info(BaseModel):
|
||||
db_type: str = Field(examples=["sqlite"])
|
||||
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
|
||||
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
|
||||
auth_enabled: bool = Field(default=False, examples=[False])
|
||||
|
||||
|
||||
class HealthCheck(BaseModel):
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Filament related endpoints."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import httpx
|
||||
import sqlalchemy
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.database import filament, spool
|
||||
from spoolman.database import filament, models, spool
|
||||
from spoolman.database.database import get_db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,6 +51,33 @@ async def find_materials(
|
||||
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(
|
||||
"/article-number",
|
||||
name="Find article numbers",
|
||||
@@ -146,3 +177,299 @@ async def rename_location(
|
||||
logger.info("Renaming location %s to %s", location, body.name)
|
||||
await spool.rename_location(db=db, current_name=location, new_name=body.name)
|
||||
return body.name
|
||||
|
||||
|
||||
class UsageDataPoint(BaseModel):
|
||||
"""A single data point for usage analytics."""
|
||||
|
||||
date: str = Field(description="Date in YYYY-MM-DD format.")
|
||||
weight_used: float = Field(description="Total weight used in grams (positive value).")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analytics/usage",
|
||||
name="Get usage analytics",
|
||||
description="Get filament usage data aggregated by day for the specified period.",
|
||||
response_model=list[UsageDataPoint],
|
||||
)
|
||||
async def get_usage_analytics(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
days: Annotated[
|
||||
int,
|
||||
Query(description="Number of days to look back.", ge=1, le=365),
|
||||
] = 30,
|
||||
) -> list[UsageDataPoint]:
|
||||
"""Get usage analytics aggregated by day."""
|
||||
# Calculate the start date
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Query adjustments grouped by date
|
||||
# Only include negative values (filament used, not added)
|
||||
stmt = (
|
||||
sqlalchemy.select(
|
||||
func.date(models.SpoolAdjustment.timestamp).label("date"),
|
||||
func.sum(
|
||||
sqlalchemy.case(
|
||||
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
|
||||
else_=0,
|
||||
)
|
||||
).label("weight_used"),
|
||||
)
|
||||
.where(models.SpoolAdjustment.timestamp >= start_date)
|
||||
.where(models.SpoolAdjustment.adjustment_type == "weight")
|
||||
.group_by(func.date(models.SpoolAdjustment.timestamp))
|
||||
.order_by(func.date(models.SpoolAdjustment.timestamp))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# Fill in missing dates with 0
|
||||
data_by_date = {str(row.date): row.weight_used for row in rows}
|
||||
all_dates = []
|
||||
current_date = start_date.date()
|
||||
end_date = datetime.utcnow().date()
|
||||
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
all_dates.append(
|
||||
UsageDataPoint(
|
||||
date=date_str,
|
||||
weight_used=data_by_date.get(date_str, 0),
|
||||
)
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return all_dates
|
||||
|
||||
|
||||
class MaterialUsage(BaseModel):
|
||||
"""Usage breakdown by material."""
|
||||
|
||||
material: str = Field(description="Material name.")
|
||||
weight_used: float = Field(description="Total weight used in grams.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analytics/by-material",
|
||||
name="Get usage by material",
|
||||
description="Get filament usage breakdown by material type.",
|
||||
response_model=list[MaterialUsage],
|
||||
)
|
||||
async def get_usage_by_material(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
days: Annotated[
|
||||
Optional[int],
|
||||
Query(description="Number of days to look back. If not set, returns all-time usage.", ge=1, le=365),
|
||||
] = None,
|
||||
) -> list[MaterialUsage]:
|
||||
"""Get usage breakdown by material."""
|
||||
# Join adjustments with spools and filaments to get material
|
||||
stmt = (
|
||||
sqlalchemy.select(
|
||||
func.coalesce(models.Filament.material, "Unknown").label("material"),
|
||||
func.sum(
|
||||
sqlalchemy.case(
|
||||
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
|
||||
else_=0,
|
||||
)
|
||||
).label("weight_used"),
|
||||
)
|
||||
.join(models.Spool, models.SpoolAdjustment.spool_id == models.Spool.id)
|
||||
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
||||
.where(models.SpoolAdjustment.adjustment_type == "weight")
|
||||
)
|
||||
|
||||
if days is not None:
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
stmt = stmt.where(models.SpoolAdjustment.timestamp >= start_date)
|
||||
|
||||
stmt = stmt.group_by(models.Filament.material).order_by(func.sum(-models.SpoolAdjustment.value).desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows]
|
||||
|
||||
|
||||
class CostByMaterial(BaseModel):
|
||||
"""Cost breakdown by material."""
|
||||
|
||||
material: str = Field(description="Material name.")
|
||||
total_cost: float = Field(description="Total cost spent on this material.")
|
||||
total_weight: float = Field(description="Total initial weight purchased in grams.")
|
||||
|
||||
|
||||
class CostStats(BaseModel):
|
||||
"""Overall cost statistics."""
|
||||
|
||||
total_spent: float = Field(description="Sum of all spool purchase prices.")
|
||||
total_weight_purchased: float = Field(description="Total initial weight of all spools in grams.")
|
||||
avg_cost_per_kg: Optional[float] = Field(description="Average cost per kilogram of filament.")
|
||||
cost_by_material: list[CostByMaterial] = Field(description="Cost breakdown by material type.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analytics/cost",
|
||||
name="Get cost statistics",
|
||||
description="Get overall cost tracking statistics across all spools.",
|
||||
response_model=CostStats,
|
||||
)
|
||||
async def get_cost_stats(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
) -> CostStats:
|
||||
"""Get cost statistics including total spent and cost per kg."""
|
||||
# Use COALESCE(spool.price, filament.price) for effective price
|
||||
effective_price = func.coalesce(models.Spool.price, models.Filament.price)
|
||||
effective_weight = func.coalesce(models.Spool.initial_weight, models.Filament.weight)
|
||||
|
||||
# Total spent and total weight across all spools (including archived)
|
||||
totals_stmt = (
|
||||
sqlalchemy.select(
|
||||
func.sum(effective_price).label("total_spent"),
|
||||
func.sum(effective_weight).label("total_weight"),
|
||||
)
|
||||
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
||||
.where(effective_price.isnot(None))
|
||||
)
|
||||
totals_result = await db.execute(totals_stmt)
|
||||
totals_row = totals_result.one()
|
||||
|
||||
total_spent = totals_row.total_spent or 0.0
|
||||
total_weight_purchased = totals_row.total_weight or 0.0
|
||||
avg_cost_per_kg = (total_spent / total_weight_purchased * 1000) if total_weight_purchased > 0 else None
|
||||
|
||||
# Cost by material
|
||||
material_stmt = (
|
||||
sqlalchemy.select(
|
||||
func.coalesce(models.Filament.material, "Unknown").label("material"),
|
||||
func.sum(effective_price).label("total_cost"),
|
||||
func.sum(effective_weight).label("total_weight"),
|
||||
)
|
||||
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
||||
.where(effective_price.isnot(None))
|
||||
.group_by(models.Filament.material)
|
||||
.order_by(func.sum(effective_price).desc())
|
||||
)
|
||||
material_result = await db.execute(material_stmt)
|
||||
material_rows = material_result.all()
|
||||
|
||||
cost_by_material = [
|
||||
CostByMaterial(
|
||||
material=row.material or "Unknown",
|
||||
total_cost=row.total_cost or 0.0,
|
||||
total_weight=row.total_weight or 0.0,
|
||||
)
|
||||
for row in material_rows
|
||||
]
|
||||
|
||||
return CostStats(
|
||||
total_spent=total_spent,
|
||||
total_weight_purchased=total_weight_purchased,
|
||||
avg_cost_per_kg=avg_cost_per_kg,
|
||||
cost_by_material=cost_by_material,
|
||||
)
|
||||
|
||||
|
||||
# --- filamentcolors.xyz integration ---
|
||||
|
||||
FILAMENTCOLORS_API = "https://filamentcolors.xyz/api"
|
||||
|
||||
|
||||
class FilamentColorSwatch(BaseModel):
|
||||
"""A color swatch from filamentcolors.xyz."""
|
||||
|
||||
id: int
|
||||
color_name: str
|
||||
hex_color: Optional[str] = None
|
||||
manufacturer_name: str
|
||||
filament_type: str
|
||||
image_front: Optional[str] = None
|
||||
|
||||
|
||||
class FilamentColorManufacturer(BaseModel):
|
||||
"""A manufacturer from filamentcolors.xyz."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class FilamentColorSearchResult(BaseModel):
|
||||
"""Search results from filamentcolors.xyz."""
|
||||
|
||||
count: int
|
||||
swatches: list[FilamentColorSwatch]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filamentcolors/manufacturers",
|
||||
name="Get filamentcolors.xyz manufacturers",
|
||||
description="List all manufacturers from filamentcolors.xyz.",
|
||||
response_model=list[FilamentColorManufacturer],
|
||||
)
|
||||
async def get_filamentcolors_manufacturers() -> list[FilamentColorManufacturer]:
|
||||
"""Proxy manufacturer list from filamentcolors.xyz."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{FILAMENTCOLORS_API}/manufacturer/", params={"format": "json", "limit": 500})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
return [
|
||||
FilamentColorManufacturer(id=m["id"], name=m["name"])
|
||||
for m in data.get("results", [])
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filamentcolors/search",
|
||||
name="Search filamentcolors.xyz swatches",
|
||||
description="Search color swatches from filamentcolors.xyz by manufacturer and/or color name.",
|
||||
response_model=FilamentColorSearchResult,
|
||||
)
|
||||
async def search_filamentcolors(
|
||||
*,
|
||||
manufacturer: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Manufacturer name to filter by."),
|
||||
] = None,
|
||||
color: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Color name to search for."),
|
||||
] = None,
|
||||
filament_type: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Filament type to filter by (e.g., PLA, PETG)."),
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
) -> FilamentColorSearchResult:
|
||||
"""Proxy swatch search from filamentcolors.xyz."""
|
||||
params: dict = {"format": "json", "limit": limit, "page": page}
|
||||
if manufacturer:
|
||||
params["manufacturer__name"] = manufacturer
|
||||
if color:
|
||||
params["color_name__icontains"] = color
|
||||
if filament_type:
|
||||
params["filament_type__name__icontains"] = filament_type
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{FILAMENTCOLORS_API}/swatch/", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
swatches = [
|
||||
FilamentColorSwatch(
|
||||
id=s["id"],
|
||||
color_name=s.get("color_name", ""),
|
||||
hex_color=s.get("hex_color"),
|
||||
manufacturer_name=s.get("manufacturer", {}).get("name", ""),
|
||||
filament_type=s.get("filament_type", {}).get("name", ""),
|
||||
image_front=s.get("image_front"),
|
||||
)
|
||||
for s in data.get("results", [])
|
||||
]
|
||||
|
||||
return FilamentColorSearchResult(count=data.get("count", 0), swatches=swatches)
|
||||
|
||||
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.ws import websocket_manager
|
||||
|
||||
from . import 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__)
|
||||
|
||||
@@ -57,6 +57,7 @@ async def info() -> models.Info:
|
||||
db_type=str(env.get_database_type() or "sqlite"),
|
||||
git_commit=env.get_commit_hash(),
|
||||
build_date=env.get_build_date(),
|
||||
auth_enabled=auth.is_auth_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@@ -112,3 +113,7 @@ app.include_router(field.router)
|
||||
app.include_router(other.router)
|
||||
app.include_router(externaldb.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(backup_module.router)
|
||||
app.include_router(print_job.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(location_module.router)
|
||||
|
||||