Compare commits

...

51 Commits

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:21:13 -06:00
c321d916b5 fix: Show alerts below stats normally, fix usage cost calculation
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Alerts shown below QuickStats without hover reveal
- Alerts also listed after usage analytics section
- Usage cost now shows cost of material consumed (unit cost * usage)
  instead of all-time purchase total which was misleading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:05:07 -06:00
67eaed32d5 feat: Move alerts below stats, reveal on hover
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Alerts now appear below the quick stats badges and are collapsed by
default, showing just a subtle "N alerts" indicator that expands
on mouseover.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:38:14 -06:00
0556be9e3b docs: Update CLAUDE.md with completed features #9, #12, #13
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Mark issues 9, 12, 13 as done and add implementation details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:40:58 -06:00
02da984b6e feat: Add batch operations, dashboard redesign, hierarchical locations
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
## Batch Operations (#13)
- Add POST /spool/bulk/archive, /bulk/delete, /bulk/update endpoints
- Add row selection with checkboxes to spool list
- Add floating BatchActionBar with archive/unarchive/delete/move actions
- Add confirmation modals for all batch operations

## Dashboard Redesign (#12)
- Install recharts for pie charts
- Add AlertCards (low stock, pending jobs, needs weighing)
- Add MaterialDistributionChart (pie chart by material)
- Add QuickStats row (total spools, filaments, vendors, weight)
- Refactor home page layout with new components

## Hierarchical Locations (#9)
- Add Location model with self-referential parent relationship
- Add migration for location table and spool.location_id FK
- Add location database operations (CRUD, tree, full path)
- Add location API endpoints (list, tree, CRUD)
- Add LocationTreeSelect component with nested tree display
- Add LocationCreateModal for inline location creation
- Keep legacy location string field for backward compatibility

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:17:25 -06:00
9bfc322f32 feat: Add color column with filter to spool list
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Add optional color column to spool list showing filament color swatches
with multi-select filtering capability.

Backend:
- GET /api/v1/color endpoint to list distinct filament colors
- filament.color_hex query param on spool list for filtering

Frontend:
- ColorFilterColumn component renders color swatches
- useSpoolmanColors() hook fetches available colors
- Filter dropdown shows swatches with hex codes
- Column hidden by default (enable via column selector)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:11:18 -06:00
8fc06cdb18 Add light border to color swatches in dark mode (#34)
- Set data-theme attribute on body for CSS dark mode detection
- Add white border (30% opacity) to spool icons in dark mode
- Add --swatch-border-color CSS variable for consistent theming
- Dark/black filament colors now visible against dark backgrounds

Fixes upstream issue: https://github.com/Donkie/Spoolman/issues/597

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 01:05:58 -06:00
f63f70ce86 Make auth toggle a settings option instead of env var
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled
- Auth enabled state now stored in database setting (auth_enabled)
- Add POST /auth/toggle endpoint to enable/disable auth from UI
- Add auth toggle section to Settings > General page
- Enabling auth without users prompts to create first admin
- Disabling auth requires admin password confirmation
- Auth setting cached in memory and loaded on startup

Removes need for SPOOLMAN_AUTH_ENABLED env var

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 01:01:14 -06:00
e782b89929 Bump version to 0.23C.4
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Includes:
- Issue #22: Authentication system
- Issue #38: Sticky Save/Cancel buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:53:02 -06:00
a730dd1ec9 fix(auth): Fix passlib/bcrypt dependency resolution
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
2026-01-16 00:49:47 -06:00
3613e7739a feat(auth): Add optional authentication system (#22)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Backend:
- Add User model with username, hashed password, role, is_active
- Add passlib[bcrypt] and python-jose[cryptography] dependencies
- Create auth.py with JWT utilities, password hashing, role checking
- Add /auth endpoints: status, setup, login, me, users CRUD
- Add role-based permission system (viewer/operator/editor/admin)
- Environment: SPOOLMAN_AUTH_ENABLED, SPOOLMAN_AUTH_SECRET_KEY
- Migration for user table

Frontend:
- Add AuthProvider context with login/logout/setup
- Add LoginPage component with setup mode for first user
- Add ProtectedRoute wrapper for auth-required pages
- Add axios interceptor to attach JWT token to requests
- Add User Management tab in Settings (admin only)
- Add translation keys for auth UI

When SPOOLMAN_AUTH_ENABLED=true:
- Users must login to access the app
- First visit shows setup page to create admin account
- Role-based access control for future endpoint protection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:15:36 -06:00
94612a6dd3 feat(ux): Sticky Save/Cancel buttons on forms (#38)
- Add sticky positioning to form action buttons (always visible at bottom)
- Add Ctrl+S / Cmd+S keyboard shortcut to save forms
- Create reusable useFormShortcuts hook
- Apply to all create forms: spool, filament, vendor

Fixes: https://github.com/Donkie/Spoolman/issues/274

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:09:06 -06:00
88747307e5 fix(spool): Add remaining_weight filter for low stock detection
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Add remaining_weight_lt and remaining_weight_gt filter params to spool API
- Filter uses computed formula: initial_weight - used_weight (falls back to filament.weight)
- Update home page to use queryParams instead of Refine filters
- Fixes low stock badge count on dashboard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:44:20 -06:00
41d07f069b fix(home): correct pagination property and type annotations
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Use 'currentPage' instead of 'current' for pagination
- Remove explicit ISpool type on filter callback (inferred)
- Add type assertion for Table dataSource

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:35:56 -06:00
f2fecede35 fix(home): use correct refine useList API pattern
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Fix TypeScript errors by using { result, query } destructuring:
- result.data and result.total for list data
- query.isLoading for loading state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:34:54 -06:00
267fadfc54 feat(color): add hex code display and click-to-copy functionality
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Enhances SpoolIcon component with optional showHex prop:
- Displays hex color code next to swatch when enabled
- Click to copy hex code to clipboard
- Shows tooltip with copy hint
- Supports both single and multi-color filaments

Enabled on detail pages:
- Filament show page
- Spool show page

Added translation keys for copy feedback messages.

Closes #33

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:33:21 -06:00
48bd516c0f feat(filament): add spool count and total remaining weight columns
Adds computed spool statistics to filament list:
- spool_count: Number of non-archived spools of this filament
- total_remaining_weight: Sum of remaining weight across all spools

Backend changes:
- Modified database/filament.py find() to compute stats via subquery
- Added fields to Filament pydantic model in api/v1/models.py
- Updated filament API endpoint to include stats in response

Frontend changes:
- Added fields to IFilament interface
- Added columns to filament list table
- Added translation keys

Closes #15

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:31:47 -06:00
0a741c9712 feat(home): redesign dashboard with spool list and low stock alerts
Replaces stat card layout with functional dashboard:
- Prominent spool count with "Add Spool" button
- Paginated list of all spools sorted by last used
- Low stock alert badge (< 100g threshold)
- Color swatch, name, material, and remaining weight columns
- Clickable rows navigate to spool detail
- Secondary stats for filaments and vendors

Closes #12

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:27:14 -06:00
2dd7c1198c Remove 3dfilamentprofiles integration (Issue #7)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
3dfilamentprofiles.com has Vercel bot protection blocking automated requests.
Their GitHub repo only has sample data (~3 filaments).
SpoolmanDB already has 6,957+ filaments, making this unnecessary.

Removed:
- spoolman/externaldb.py: ExternalSource enum, Filament3DFP model, 3dfp sync
- client/src/utils/queryExternalDB.ts: ExternalSource enum, source field
- client/src/components/filamentImportModal.tsx: Source filter tabs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:00:35 -06:00
0401dd680c Update Issue #7 status - 3dfilamentprofiles only has sample data
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
The integration is partial: backend/frontend exist but only sample-filaments.json
(~3 filaments) is available. Full dataset requires their parser.py or website API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:42:57 -06:00
9365e399be Add Print Queue UI and slicer post-processing script
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Create Print Queue page showing pending/completed/cancelled jobs
- Add needs_weighing filter to spool API for flagged spools
- Add IPrintJob interface and needs_weighing to ISpool model
- Add slicer_post_process.py for Elegoo/Orca Slicer integration
- Add translations for print queue feature
- Bump version to 0.23C.3

The workflow:
1. Slicer post-processing script creates pending print job
2. After print, user confirms (deducts filament) or cancels (flags for weighing)
3. Spools needing weigh-in are shown in Print Queue UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:40:22 -06:00
4cd58ebae9 Add Print Job tracking for slicer integration
Backend for tracking print jobs from Elegoo/Orca Slicer:

- PrintJob model: tracks pending/completed/cancelled jobs
- API endpoints: create, list, complete, cancel jobs
- needs_weighing flag on Spool: for cancelled print recalibration
- Database migration for new table and column

Workflow:
1. Slicer post-processing script creates pending job
2. User completes job → auto-deducts filament
3. User cancels job → flags spool for manual weigh-in

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:10:56 -06:00
695f1adeed Update CLAUDE.md with Issue #11 (Orca Slicer) and recent features
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:42:38 -06:00
75ddebb963 Bump version to 0.23C.2
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Fixed temp range display in show page and label templates
- Added prev/next navigation buttons to edit pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:13:00 -06:00
db8bf59b15 Fix TypeScript errors in prev/next navigation
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:11:01 -06:00
5c29936231 Add prev/next navigation buttons to edit pages
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Filament, spool, and vendor edit pages now have Prev/Next buttons
- Navigate between items without returning to the list
- Buttons are disabled at the beginning/end of the list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:10:04 -06:00
e264d7ed4e Add scripts to parse temps from comments
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- parse_temps_api.py - Parse temps via API (for remote instances)
- parse_temps_from_comments.py - Parse temps via direct DB access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:00:00 -06:00
46cc0656d6 Fix temp range display in show page and label templates
- Show page now displays temp ranges (min-max) instead of single values
- Fixed label template rendering to handle multiple tags in conditional blocks
- Added "not_set" translation key

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:59:54 -06:00
10b98957b8 docs: Update CLAUDE.md with latest features and deployment info
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:48:22 -06:00
767e0aea95 chore: Bump version to 0.23C.1
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Features in this release:
- Clickable dashboard cards
- Theme color selector (orange, teal, red, green, blue)
- Temperature range fields in filament edit page
- QR code size setting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:45:47 -06:00
021d287865 feat: Add clickable dashboard cards and theme color selector
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Dashboard cards now link to list page when clicked (except + button)
- Added theme color selector in Settings (orange, teal, red, green, blue)
- Theme persists in localStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:43:19 -06:00
49d4bf85fd chore: Bump version to 0.23C.0 (C = Claude assisted)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Authors: Tony, Donkie (upstream), Claude

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:38:30 -06:00
a18daa8134 fix: Update filament edit page to use temperature ranges
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:37:00 -06:00
bf775f3745 feat: Add QR code size setting for fixed-size QR codes
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:30:11 -06:00
a97914f6c8 feat: Default QR codes to use HTTP URLs instead of custom protocol
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:25:00 -06:00
59f8f5d9f7 style: Keep teal theme color
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:19:32 -06:00
1e3e956dd1 style: Remove dev styling for production deployment
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:17:38 -06:00
bf458b6077 fix: Add python-multipart dependency for file uploads
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:09:09 -06:00
a1572694e3 docs: Update CLAUDE.md with Issue #10 and Docker deployment notes
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:50:06 -06:00
613176dd42 feat: Add database import/export functionality (Issue #10)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Backend:
- New /api/v1/backup/export endpoint - exports all data as single JSON
- New /api/v1/backup/import endpoint - imports JSON with merge or replace
- Handles vendors, filaments, spools, adjustments, settings
- Remaps IDs to maintain relationships during import

Frontend:
- Added Backup & Restore section to Settings page
- Download Backup button for export
- Import with merge or replace options
- Warning confirmation for replace mode

This enables migrating between PostgreSQL and SQLite databases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 22:47:09 -06:00
34eaa6a9b1 docs: Update CLAUDE.md with known issues and next steps
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:05:12 -06:00
671d3bd3d1 feat: Add 3dfilamentprofiles.com integration (Issue #7)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Backend: Added ExternalSource enum, Filament3DFP model, and transform function
- Backend: Updated _sync() to fetch from both SpoolmanDB and 3dfilamentprofiles
- Frontend: Added source tabs to FilamentImportModal (All/SpoolmanDB/3DFP)
- Frontend: Updated ExternalFilament interface with source and temp range fields
- Frontend: Updated filament import handler to use temp range fields

Also includes:
- Dev mode styling (teal theme, DEV badge and banner)
- Translation keys for source filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:03:25 -06:00
78 changed files with 8982 additions and 892 deletions

402
CLAUDE.md
View File

@@ -122,7 +122,7 @@ Environment variables (see `.env.example`):
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
## UX Improvement Issues
## UX Improvement Issues (Gitea)
| # | Title | Status |
|---|-------|--------|
@@ -132,7 +132,55 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
| 4 | Layout Max-Width | ✅ DONE |
| 5 | Temperature Ranges | ✅ DONE |
| 6 | Spool Adjustment History | ✅ DONE |
| 7 | 3dfilamentprofiles Integration | Open |
| 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
@@ -213,3 +261,353 @@ 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.

389
client/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "spoolman-ui",
"version": "0.22.1",
"version": "0.23C.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spoolman-ui",
"version": "0.22.1",
"version": "0.23C.2",
"dependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@loadable/component": "^5.16.7",
@@ -34,6 +34,7 @@
"react-i18next": "^16.5.1",
"react-router": "^7.11.0",
"react-to-print": "^3.2.0",
"recharts": "^3.7.0",
"uuid": "^13.0.0",
"vite-plugin-svgr": "^4.5.0",
"zustand": "^5.0.9"
@@ -3359,6 +3360,48 @@
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@reduxjs/toolkit/node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/@refinedev/antd": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz",
@@ -4187,6 +4230,18 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -4511,6 +4566,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
@@ -4622,6 +4740,12 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -5886,6 +6010,15 @@
"node": ">=0.10.0"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -6157,6 +6290,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -6263,6 +6517,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -6772,6 +7032,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -8439,6 +8709,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8577,6 +8857,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -11919,6 +12208,29 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -12020,6 +12332,42 @@
"node": ">= 4"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/redeyed": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz",
@@ -12039,6 +12387,15 @@
"@babel/runtime": "^7.9.2"
}
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -12207,6 +12564,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -14129,6 +14492,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "spoolman-ui",
"version": "0.22.1",
"version": "0.23C.2",
"engines": {
"node": "20.x"
},
@@ -33,6 +33,7 @@
"react-i18next": "^16.5.1",
"react-router": "^7.11.0",
"react-to-print": "^3.2.0",
"recharts": "^3.7.0",
"uuid": "^13.0.0",
"vite-plugin-svgr": "^4.5.0",
"zustand": "^5.0.9"

View File

@@ -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",
@@ -128,6 +153,8 @@
"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": {
@@ -154,6 +181,7 @@
},
"spool": {
"spool": "Spools",
"search_placeholder": "Search by name, material, vendor, location...",
"fields": {
"id": "ID",
"filament_name": "Filament",
@@ -161,15 +189,19 @@
"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",
@@ -177,8 +209,11 @@
"registered": "Registered",
"comment": "Comment",
"archived": "Archived",
"filament_fields": "Filament Custom Fields",
"adjustment_history": "Adjustment History",
"show_history": "Show History",
"print_history": "Print History",
"show_print_history": "Show Print History",
"adjustment_timestamp": "Date/Time",
"adjustment_type": "Type",
"adjustment_type_weight": "Weight",
@@ -194,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."
@@ -226,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": {
@@ -252,7 +323,10 @@
"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.",
@@ -276,13 +350,26 @@
"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.",
"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."
"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": {
@@ -312,10 +399,54 @@
"name_required": "Name is required"
}
},
"print_job": {
"fields": {
"created": "Created",
"filename": "Filename",
"filament_used": "Filament Used",
"status": "Status",
"finished": "Finished"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
}
},
"home": {
"home": "Home",
"welcome": "Welcome to your Spoolman instance!",
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
"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",
@@ -344,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>",
@@ -376,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": {
@@ -417,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"
}
}

View File

@@ -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,10 +100,11 @@ function App() {
return (
<BrowserRouter basename={getBasePath() + "/"}>
<RefineKbarProvider>
<ColorModeContextProvider>
<ConfigProvider locale={antdLocale}>
<Refine
<AuthProvider>
<RefineKbarProvider>
<ColorModeContextProvider>
<ConfigProvider locale={antdLocale}>
<Refine
dataProvider={dataProvider(getAPIURL())}
notificationProvider={SpoolmanNotificationProvider}
i18nProvider={i18nProvider}
@@ -142,6 +155,14 @@ function App() {
icon: <UserOutlined />,
},
},
{
name: "print-queue",
list: "/print-queue",
meta: {
canDelete: false,
icon: <PrinterOutlined />,
},
},
{
name: "locations",
list: "/locations",
@@ -174,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" />} />
@@ -225,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>
@@ -235,9 +263,10 @@ function App() {
<ReactQueryDevtools />
<Favicon url={getBasePath() + "/favicon.svg"} />
</Refine>
</ConfigProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</ConfigProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,143 @@
import { useTranslate } from "@refinedev/core";
import { Form, Input, Modal, Select, message } from "antd";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getAPIURL } from "../utils/url";
interface LocationTreeNode {
id: number;
name: string;
location_type?: string;
children: LocationTreeNode[];
}
interface LocationCreateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
parentId?: number;
}
const LOCATION_TYPES = ["room", "shelf", "bin", "drawer", "box", "other"];
function flattenTree(nodes: LocationTreeNode[], prefix = ""): { value: number; label: string }[] {
const result: { value: number; label: string }[] = [];
for (const node of nodes) {
const label = prefix ? `${prefix} > ${node.name}` : node.name;
result.push({ value: node.id, label });
if (node.children.length > 0) {
result.push(...flattenTree(node.children, label));
}
}
return result;
}
export function LocationCreateModal({
isOpen,
onClose,
onSuccess,
parentId,
}: LocationCreateModalProps) {
const t = useTranslate();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const { data: treeData } = useQuery<LocationTreeNode[]>({
queryKey: ["locations", "tree"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/location/tree");
if (!response.ok) {
throw new Error("Failed to fetch location tree");
}
return response.json();
},
enabled: isOpen,
});
const parentOptions = treeData ? flattenTree(treeData) : [];
const handleSubmit = async (values: { name: string; parent_id?: number; location_type?: string }) => {
setLoading(true);
try {
const response = await fetch(getAPIURL() + "/location", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to create location");
}
messageApi.success(t("notifications.createSuccess", { resource: t("location.location") }));
form.resetFields();
onSuccess();
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Failed to create location");
} finally {
setLoading(false);
}
};
return (
<>
{contextHolder}
<Modal
title={t("location.titles.create")}
open={isOpen}
onOk={() => form.submit()}
onCancel={() => {
form.resetFields();
onClose();
}}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ parent_id: parentId }}
>
<Form.Item
name="name"
label={t("location.fields.name")}
rules={[{ required: true, message: t("location.fields.name_required") }]}
>
<Input placeholder={t("location.fields.name_placeholder")} />
</Form.Item>
<Form.Item
name="parent_id"
label={t("location.fields.parent")}
>
<Select
allowClear
showSearch
placeholder={t("location.fields.parent_placeholder")}
options={parentOptions}
filterOption={(input, option) =>
(option?.label as string).toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item
name="location_type"
label={t("location.fields.location_type")}
>
<Select
allowClear
placeholder={t("location.fields.location_type_placeholder")}
options={LOCATION_TYPES.map((type) => ({
value: type,
label: t(`location.types.${type}`),
}))}
/>
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -0,0 +1,110 @@
import { PlusOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { TreeSelect, Divider, Button, Spin } from "antd";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getAPIURL } from "../utils/url";
import { LocationCreateModal } from "./LocationCreateModal";
interface LocationTreeNode {
id: number;
name: string;
location_type?: string;
description?: string;
children: LocationTreeNode[];
}
interface TreeDataNode {
value: number;
title: string;
children?: TreeDataNode[];
}
function transformTree(nodes: LocationTreeNode[]): TreeDataNode[] {
return nodes.map((node) => ({
value: node.id,
title: node.location_type ? `${node.name} (${node.location_type})` : node.name,
children: node.children.length > 0 ? transformTree(node.children) : undefined,
}));
}
interface LocationTreeSelectProps {
value?: number;
onChange?: (value: number | undefined) => void;
allowCreate?: boolean;
placeholder?: string;
style?: React.CSSProperties;
}
export function LocationTreeSelect({
value,
onChange,
allowCreate = true,
placeholder,
style,
}: LocationTreeSelectProps) {
const t = useTranslate();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data, isLoading, refetch } = useQuery<LocationTreeNode[]>({
queryKey: ["locations", "tree"],
queryFn: async () => {
const response = await fetch(getAPIURL() + "/location/tree");
if (!response.ok) {
throw new Error("Failed to fetch location tree");
}
return response.json();
},
});
const treeData = data ? transformTree(data) : [];
const handleCreateSuccess = () => {
setIsCreateModalOpen(false);
refetch();
};
return (
<>
<TreeSelect
style={style}
value={value}
onChange={onChange}
treeData={treeData}
placeholder={placeholder ?? t("spool.fields.location")}
allowClear
showSearch
treeDefaultExpandAll
loading={isLoading}
notFoundContent={isLoading ? <Spin size="small" /> : undefined}
dropdownRender={(menu) => (
<>
{menu}
{allowCreate && (
<>
<Divider style={{ margin: "8px 0" }} />
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalOpen(true)}
style={{ width: "100%" }}
>
{t("location.create")}
</Button>
</>
)}
</>
)}
filterTreeNode={(input, treeNode) =>
(treeNode.title as string).toLowerCase().includes(input.toLowerCase())
}
/>
<LocationCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
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,50 @@ 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 }) => (
<ThemedLayout
Header={() => <Header sticky />}
Sider={() => (
<ThemedSider
fixed
Title={({ collapsed }) => <ThemedTitle collapsed={collapsed} text="Spoolman" icon={<Logo />} />}
Title={({ collapsed }) => <DevTitle collapsed={collapsed} />}
/>
)}
Footer={() => <SpoolmanFooter />}
>
{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,

View File

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

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

View File

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

View File

@@ -1,17 +1,22 @@
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];
} else {
@@ -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;
}

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

View File

@@ -1,24 +1,45 @@
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");
@@ -27,21 +48,28 @@ 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={{
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
token: {
colorPrimary: "#dc7734",
colorPrimary: primaryColor,
colorInfo: primaryColor,
},
}}
>

View File

@@ -5,11 +5,12 @@ 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, useMemo, 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,
@@ -20,6 +21,7 @@ import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from ".
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";
@@ -83,6 +85,12 @@ 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",
@@ -127,9 +135,9 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
multi_color_hexes: filament.color_hexes?.join(",") || undefined,
multi_color_direction: filament.multi_color_direction,
settings_extruder_temp_min: filament.extruder_temp || undefined,
settings_extruder_temp_max: 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 || undefined,
settings_bed_temp_max: filament.bed_temp_max || filament.bed_temp || undefined,
});
};
@@ -241,19 +249,22 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item

View File

@@ -1,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")}

View File

@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
"article_number",
"settings_extruder_temp",
"settings_bed_temp",
"spool_count",
"total_remaining_weight",
"registered",
"comment",
];
@@ -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",

View File

@@ -24,6 +24,8 @@ export interface IFilament {
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

View File

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

View File

@@ -0,0 +1,119 @@
import { ExclamationCircleOutlined, PrinterOutlined, DashboardOutlined } from "@ant-design/icons";
import { useList, useTranslate } from "@refinedev/core";
import { Badge, Card, Col, Row, theme } from "antd";
import { Link } from "react-router";
import { ISpool } from "../../spools/model";
import { IPrintJob } from "../../spools/model";
const { useToken } = theme;
const LOW_STOCK_THRESHOLD = 100;
interface AlertCardProps {
icon: React.ReactNode;
label: string;
count: number;
to: string;
color: string;
loading?: boolean;
}
function AlertCard({ icon, label, count, to, color, loading }: AlertCardProps) {
const { token } = useToken();
if (count === 0 && !loading) return null;
return (
<Col xs={24} sm={8}>
<Link to={to}>
<Badge count={count} style={{ backgroundColor: color }} overflowCount={99}>
<Card size="small" hoverable loading={loading}>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color, fontSize: 18 }}>{icon}</span>
<span>{label}</span>
</span>
</Card>
</Badge>
</Link>
</Col>
);
}
interface AlertCardsProps {
compact?: boolean;
}
export function AlertCards({ compact }: AlertCardsProps) {
const t = useTranslate();
const { token } = useToken();
// Low stock spools
const { result: lowStockResult, query: lowStockQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1 },
meta: {
queryParams: {
allow_archived: false,
remaining_weight_lt: LOW_STOCK_THRESHOLD,
},
},
});
// Pending print jobs
const { result: pendingJobsResult, query: pendingJobsQuery } = useList<IPrintJob>({
resource: "print-job",
pagination: { pageSize: 1 },
filters: [{ field: "status", operator: "eq", value: "pending" }],
});
// Spools needing weighing
const { result: needsWeighingResult, query: needsWeighingQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1 },
meta: {
queryParams: {
allow_archived: false,
needs_weighing: true,
},
},
});
const alerts = [
{
icon: <ExclamationCircleOutlined />,
label: t("home.alerts.low_stock"),
count: lowStockResult?.total ?? 0,
to: `/spool?remaining_weight_lt=${LOW_STOCK_THRESHOLD}`,
color: token.colorError,
loading: lowStockQuery.isLoading,
},
{
icon: <PrinterOutlined />,
label: t("home.alerts.pending_jobs"),
count: pendingJobsResult?.total ?? 0,
to: "/print-queue",
color: token.colorWarning,
loading: pendingJobsQuery.isLoading,
},
{
icon: <DashboardOutlined />,
label: t("home.alerts.needs_weighing"),
count: needsWeighingResult?.total ?? 0,
to: "/spool?needs_weighing=true",
color: token.colorInfo,
loading: needsWeighingQuery.isLoading,
},
];
const visibleAlerts = alerts.filter((a) => a.count > 0 || a.loading);
if (visibleAlerts.length === 0) return null;
return (
<Row gutter={[compact ? 8 : 12, compact ? 8 : 12]} style={{ marginTop: compact ? 8 : 16 }}>
{visibleAlerts.map((alert, i) => (
<AlertCard key={i} {...alert} />
))}
</Row>
);
}

View File

@@ -0,0 +1,105 @@
import { useList, useTranslate } from "@refinedev/core";
import { Card, theme } from "antd";
import { useMemo } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
import { ISpool } from "../../spools/model";
const { useToken } = theme;
// Color palette for the pie chart
const COLORS = [
"#1890ff", // blue
"#52c41a", // green
"#faad14", // gold
"#f5222d", // red
"#722ed1", // purple
"#13c2c2", // cyan
"#eb2f96", // magenta
"#fa8c16", // orange
"#a0d911", // lime
"#2f54eb", // geekblue
];
interface MaterialDistributionChartProps {
square?: boolean;
}
export function MaterialDistributionChart({ square }: MaterialDistributionChartProps) {
const t = useTranslate();
const { token } = useToken();
// Fetch all spools for aggregation
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1000 },
meta: {
queryParams: {
allow_archived: false,
},
},
});
// Aggregate by material
const chartData = useMemo(() => {
const data = spoolsResult?.data || [];
const materialCounts: Record<string, number> = {};
data.forEach((spool) => {
const material = spool.filament.material || t("unknown");
materialCounts[material] = (materialCounts[material] || 0) + 1;
});
return Object.entries(materialCounts)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 10); // Top 10 materials
}, [spoolsResult?.data, t]);
if (spoolsQuery.isLoading || chartData.length === 0) {
return null;
}
return (
<Card
title={t("home.charts.material_distribution")}
size="small"
style={square ? { aspectRatio: "1", display: "flex", flexDirection: "column" } : { marginBottom: 16 }}
styles={square ? { body: { flex: 1, padding: "8px 12px", display: "flex", flexDirection: "column" } } : undefined}
>
<ResponsiveContainer width="100%" height={square ? "100%" : 250}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={square ? 40 : 50}
outerRadius={square ? 70 : 80}
paddingAngle={2}
dataKey="value"
label={square ? undefined : ({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
labelLine={square ? false : { stroke: token.colorTextSecondary }}
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => [`${value} spools`, t("spool.spool")]}
contentStyle={{
backgroundColor: token.colorBgElevated,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadius,
}}
/>
{square && (
<Legend
layout="horizontal"
verticalAlign="bottom"
wrapperStyle={{ fontSize: 11 }}
/>
)}
</PieChart>
</ResponsiveContainer>
</Card>
);
}

View File

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

View File

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

View File

@@ -1,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 { Trans } from "react-i18next";
import { Link } from "react-router";
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.result || spools.result.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.result?.total || 0}
loading={spools.query.isLoading}
icon={<FileOutlined />}
/>
<ResourceStatsCard
resource="filament"
value={filaments.result?.total || 0}
loading={filaments.query.isLoading}
icon={<HighlightOutlined />}
/>
<ResourceStatsCard
resource="vendor"
value={vendors.result?.total || 0}
loading={vendors.query.isLoading}
icon={<UserOutlined />}
/>
{/* 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>
);
};

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

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

View File

@@ -24,6 +24,7 @@ export interface QRCodePrintSettings {
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;
}
@@ -101,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], "");
}
}
});

View File

@@ -43,6 +43,7 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
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 (
@@ -224,6 +225,40 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
</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}
</>
@@ -243,8 +278,8 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
}
.print-page .print-qrcode {
width: auto !important;
height: auto !important;
width: ${qrCodeSize > 0 ? `${qrCodeSize}mm` : "auto"} !important;
height: ${qrCodeSize > 0 ? `${qrCodeSize}mm` : "auto"} !important;
padding: ${qrCodePadding}mm;
}

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,91 @@
import { DeleteOutlined, InboxOutlined, UndoOutlined, EnvironmentOutlined, CloseOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { Affix, Button, Card, Space, Modal, Select } from "antd";
import { useState } from "react";
import { useSpoolmanLocations } from "../../../components/otherModels";
interface BatchActionBarProps {
selectedIds: number[];
onArchive: () => void;
onUnarchive: () => void;
onDelete: () => void;
onMove: (location: string) => void;
onClear: () => void;
}
export function BatchActionBar(props: BatchActionBarProps) {
const t = useTranslate();
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<string>();
const locations = useSpoolmanLocations(isMoveModalOpen);
if (props.selectedIds.length === 0) return null;
const handleMoveConfirm = () => {
if (selectedLocation) {
props.onMove(selectedLocation);
setIsMoveModalOpen(false);
setSelectedLocation(undefined);
}
};
return (
<>
<Affix offsetBottom={20}>
<Card
size="small"
style={{
boxShadow: "0 -2px 8px rgba(0,0,0,0.15)",
borderRadius: 8,
maxWidth: 600,
margin: "0 auto",
}}
>
<Space wrap>
<span style={{ fontWeight: 500 }}>
{t("batch.selected", { count: props.selectedIds.length })}
</span>
<Button icon={<InboxOutlined />} onClick={props.onArchive}>
{t("buttons.archive")}
</Button>
<Button icon={<UndoOutlined />} onClick={props.onUnarchive}>
{t("buttons.unArchive")}
</Button>
<Button icon={<EnvironmentOutlined />} onClick={() => setIsMoveModalOpen(true)}>
{t("batch.move")}
</Button>
<Button danger icon={<DeleteOutlined />} onClick={props.onDelete}>
{t("buttons.delete")}
</Button>
<Button type="text" icon={<CloseOutlined />} onClick={props.onClear}>
{t("batch.clear_selection")}
</Button>
</Space>
</Card>
</Affix>
<Modal
title={t("batch.move_title")}
open={isMoveModalOpen}
onOk={handleMoveConfirm}
onCancel={() => {
setIsMoveModalOpen(false);
setSelectedLocation(undefined);
}}
okButtonProps={{ disabled: !selectedLocation }}
>
<p>{t("batch.move_description", { count: props.selectedIds.length })}</p>
<Select
style={{ width: "100%" }}
placeholder={t("spool.fields.location")}
value={selectedLocation}
onChange={setSelectedLocation}
loading={locations.isLoading}
options={locations.data?.map((loc) => ({ label: loc, value: loc })) || []}
showSearch
allowClear
/>
</Modal>
</>
);
}

View File

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

View File

@@ -1,11 +1,11 @@
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { HttpError, IResourceComponentsProps, 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 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";
@@ -14,6 +14,7 @@ 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";
@@ -33,7 +34,6 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
const currency = useCurrency();
const invalidate = useInvalidate();
const [isFilamentModalOpen, setIsFilamentModalOpen] = useState(false);
const { form, formProps, formLoading, onFinish, redirect } = useForm<
@@ -51,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
if (props.mode === "clone") {
// Clear out the values that we don't want to clone
@@ -82,19 +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) => {
// Invalidate filament cache so the select options refresh
await invalidate({
resource: "filament",
invalidates: ["list"],
});
// Select the newly created filament
form.setFieldValue("filament_id", filament.id);
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.
@@ -143,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(() => {
@@ -204,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 => {
@@ -398,6 +417,30 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -1,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) {
@@ -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>

View File

@@ -24,6 +24,36 @@ export async function setSpoolArchived(spool: ISpool, archived: boolean) {
await fetch(request, init);
}
export async function bulkArchiveSpools(spoolIds: number[], archived: boolean) {
const response = await fetch(getAPIURL() + "/spool/bulk/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds, archived }),
});
if (!response.ok) throw new Error("Failed to archive spools");
return response.json();
}
export async function bulkDeleteSpools(spoolIds: number[]) {
const response = await fetch(getAPIURL() + "/spool/bulk/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds }),
});
if (!response.ok) throw new Error("Failed to delete spools");
return response.json();
}
export async function bulkUpdateSpools(spoolIds: number[], data: { location?: string }) {
const response = await fetch(getAPIURL() + "/spool/bulk/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds, ...data }),
});
if (!response.ok) throw new Error("Failed to update spools");
return response.json();
}
/**
* Use some spool filament from this spool. Either specify length or weight.
* @param spool The spool
@@ -190,6 +220,7 @@ export function useGetFilamentSelectOptions() {
internalSelectOptions: filamentSelectInternal,
externalSelectOptions: filamentSelectExternal,
allExternalFilaments: externalFilaments.data,
refetch: internalFilaments.refetch,
};
}

View File

@@ -1,16 +1,18 @@
import {
AppstoreOutlined,
EditOutlined,
EyeOutlined,
FilterOutlined,
InboxOutlined,
PlusSquareOutlined,
PrinterOutlined,
TableOutlined,
ToolOutlined,
ToTopOutlined,
} from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Modal, Table } from "antd";
import { Button, Dropdown, Input, Modal, Table } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react";
@@ -18,6 +20,7 @@ import { useNavigate } from "react-router";
import {
Action,
ActionsColumn,
ColorFilterColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
@@ -28,6 +31,7 @@ import {
} from "../../components/column";
import { useLiveify } from "../../components/liveify";
import {
useSpoolmanColors,
useSpoolmanFilamentFilter,
useSpoolmanLocations,
useSpoolmanLotNumbers,
@@ -37,8 +41,10 @@ import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
import { useCurrencyFormatter } from "../../utils/settings";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model";
import { BatchActionBar } from "./components/BatchActionBar";
import { SpoolGallery } from "./components/SpoolGallery";
dayjs.extend(utc);
@@ -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,6 +125,13 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
// State for the switch to show archived spools
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
// State for search and view mode
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"table" | "gallery">("table");
// State for batch selection
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// Fetch data from the API
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
@@ -122,6 +141,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
meta: {
queryParams: {
["allow_archived"]: showArchived,
...(searchQuery ? { q: searchQuery } : {}),
},
},
syncWithLocation: false,
@@ -205,6 +225,58 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
}
};
// Batch action handlers
const handleBatchArchive = () => {
confirm({
title: t("batch.archive_confirm"),
content: t("batch.archive_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.archive"),
okType: "primary",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkArchiveSpools(selectedRowKeys as number[], true);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchUnarchive = async () => {
confirm({
title: t("batch.unarchive_confirm"),
content: t("batch.unarchive_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.unArchive"),
okType: "primary",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkArchiveSpools(selectedRowKeys as number[], false);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchDelete = () => {
confirm({
title: t("batch.delete_confirm"),
content: t("batch.delete_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.delete"),
okType: "danger",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkDeleteSpools(selectedRowKeys as number[]);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchMove = async (location: string) => {
await bulkUpdateSpools(selectedRowKeys as number[], { location });
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
};
if (tableProps.pagination) {
tableProps.pagination.showSizeChanger = true;
}
@@ -289,6 +361,12 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
>
{t("buttons.clearFilters")}
</Button>
<Button
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
>
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
</Button>
<Dropdown
trigger={["click"]}
menu={{
@@ -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>
);
};

View File

@@ -15,17 +15,33 @@ 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 } };

View File

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

View File

@@ -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(() => {

View File

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

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

View File

@@ -1,3 +1,12 @@
/* Color swatch border for dark mode visibility (#34) */
:root {
--swatch-border-color: rgba(68, 68, 68, 0.3);
}
body[data-theme="dark"] {
--swatch-border-color: rgba(255, 255, 255, 0.3);
}
#qty-input {
text-align: center !important;
}
@@ -6,3 +15,15 @@
.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;
}

View File

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

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

39
codex.md Normal file
View 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`.

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
"""Add hierarchical locations.
Revision ID: e5f6g7h8i9j0
Revises: d4e5f6g7h8i9
Create Date: 2025-01-21 01:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "e5f6g7h8i9j0"
down_revision = "d4e5f6g7h8i9"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create location table and add location_id to spool."""
# Create location table
op.create_table(
"location",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("location_type", sa.String(length=32), nullable=True),
sa.Column("description", sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(
["parent_id"],
["location.id"],
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_location_id"), "location", ["id"], unique=False)
op.create_index(op.f("ix_location_parent_id"), "location", ["parent_id"], unique=False)
# Add location_id column to spool table (SQLite compatible - no FK constraint)
op.add_column(
"spool",
sa.Column("location_id", sa.Integer(), nullable=True),
)
op.create_index(op.f("ix_spool_location_id"), "spool", ["location_id"], unique=False)
def downgrade() -> None:
"""Drop location table and remove location_id from spool."""
op.drop_index(op.f("ix_spool_location_id"), table_name="spool")
op.drop_column("spool", "location_id")
op.drop_index(op.f("ix_location_parent_id"), table_name="location")
op.drop_index(op.f("ix_location_id"), table_name="location")
op.drop_table("location")

View File

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

View File

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

1567
pdm.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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,6 +25,10 @@ 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.13"

178
scripts/parse_temps_api.py Normal file
View 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)

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

View File

@@ -366,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,
@@ -383,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)},

164
spoolman/api/v1/location.py Normal file
View File

@@ -0,0 +1,164 @@
"""Location related endpoints."""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message
from spoolman.database import location
from spoolman.database.database import get_db_session
from spoolman.database import models
router = APIRouter(
prefix="/location",
tags=["location"],
)
class LocationResponse(BaseModel):
"""Location response model."""
id: int
name: str
parent_id: Optional[int] = None
location_type: Optional[str] = None
description: Optional[str] = None
full_path: Optional[str] = None # Only populated for single-item requests
spool_count: Optional[int] = None # Only populated when requested
@staticmethod
def from_db(item: models.Location, full_path: Optional[str] = None) -> "LocationResponse":
return LocationResponse(
id=item.id,
name=item.name,
parent_id=item.parent_id,
location_type=item.location_type,
description=item.description,
full_path=full_path,
)
class LocationTreeNode(BaseModel):
"""Location tree node for nested structure."""
id: int
name: str
location_type: Optional[str] = None
description: Optional[str] = None
children: list["LocationTreeNode"] = []
class LocationParameters(BaseModel):
"""Parameters for creating a location."""
name: str = Field(max_length=64, description="Location name.")
parent_id: Optional[int] = Field(None, description="Parent location ID.")
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
class LocationUpdateParameters(BaseModel):
"""Parameters for updating a location."""
name: Optional[str] = Field(None, max_length=64, description="Location name.")
parent_id: Optional[int] = Field(None, description="Parent location ID.")
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
@router.get(
"",
name="Find locations",
description="Get all locations as a flat list.",
response_model=list[LocationResponse],
)
async def find_locations(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[LocationResponse]:
db_items = await location.find_all(db=db)
return [LocationResponse.from_db(item) for item in db_items]
@router.get(
"/tree",
name="Get location tree",
description="Get all locations as a nested tree structure.",
response_model=list[LocationTreeNode],
)
async def get_location_tree(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[dict]:
return await location.get_tree(db=db)
@router.get(
"/{location_id}",
name="Get location",
description="Get a specific location by ID.",
response_model=LocationResponse,
responses={404: {"model": Message}},
)
async def get_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
) -> LocationResponse:
db_item = await location.get_by_id(db=db, location_id=location_id)
full_path = await location.get_full_path(db=db, location_id=location_id)
spool_count = await location.get_spool_count(db=db, location_id=location_id)
response = LocationResponse.from_db(db_item, full_path=full_path)
response.spool_count = spool_count
return response
@router.post(
"",
name="Create location",
description="Create a new location.",
response_model=LocationResponse,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def create_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: LocationParameters,
) -> LocationResponse:
db_item = await location.create(
db=db,
name=body.name,
parent_id=body.parent_id,
location_type=body.location_type,
description=body.description,
)
return LocationResponse.from_db(db_item)
@router.patch(
"/{location_id}",
name="Update location",
description="Update a location.",
response_model=LocationResponse,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def update_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
body: LocationUpdateParameters,
) -> LocationResponse:
data = body.model_dump(exclude_unset=True)
db_item = await location.update(db=db, location_id=location_id, data=data)
return LocationResponse.from_db(db_item)
@router.delete(
"/{location_id}",
name="Delete location",
description="Delete a location. Spools using this location will have their location_id set to null.",
responses={404: {"model": Message}},
)
async def delete_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
) -> Message:
await location.delete(db=db, location_id=location_id)
return Message(message="Success!")

View File

@@ -221,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,
@@ -251,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,
)
@@ -293,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.",
@@ -307,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.",
@@ -331,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. "
@@ -347,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,
@@ -367,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,
@@ -376,14 +423,17 @@ 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},
)
@@ -421,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):

View File

@@ -1,13 +1,17 @@
"""Filament related endpoints."""
import logging
from typing import Annotated
from datetime import datetime, timedelta
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
import httpx
import sqlalchemy
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field, RootModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool
from spoolman.database import filament, models, spool
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
@@ -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)

View 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!")

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
from . import 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)

View File

@@ -51,6 +51,18 @@ class SpoolParameters(BaseModel):
description="The weight of an empty spool, in grams. (tare weight)",
examples=[200],
)
extra_weight: Optional[float] = Field(
None,
ge=0,
description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.",
examples=[50],
)
color_hex: Optional[str] = Field(
None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
remaining_weight: Optional[float] = Field(
None,
ge=0,
@@ -84,6 +96,7 @@ class SpoolParameters(BaseModel):
examples=[""],
)
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(default=False, description="Whether this spool needs manual weigh-in.")
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this spool.",
@@ -211,6 +224,17 @@ async def find(
),
),
] = None,
filament_color_hex: Annotated[
Optional[str],
Query(
alias="filament.color_hex",
title="Filament Color",
description=(
"Filter by exact filament color hex code (without #). Separate multiple colors with a comma."
),
examples=["FF0000", "FF0000,00FF00,0000FF"],
),
] = None,
filament_vendor_name: Annotated[
Optional[str],
Query(
@@ -263,6 +287,33 @@ async def find(
bool,
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
] = False,
needs_weighing: Annotated[
Optional[bool],
Query(title="Needs Weighing", description="Filter by spools that need manual weigh-in."),
] = None,
remaining_weight_lt: Annotated[
Optional[float],
Query(
title="Remaining Weight Less Than",
description="Filter by spools with remaining weight (in grams) less than this value.",
),
] = None,
remaining_weight_gt: Annotated[
Optional[float],
Query(
title="Remaining Weight Greater Than",
description="Filter by spools with remaining weight (in grams) greater than this value.",
),
] = None,
q: Annotated[
Optional[str],
Query(
title="Search",
description=(
"Global search term. Searches across filament name, vendor name, material, and location."
),
),
] = None,
sort: Annotated[
Optional[str],
Query(
@@ -299,14 +350,19 @@ async def find(
db_items, total_count = await spool.find(
db=db,
q=q,
filament_name=filament_name if filament_name is not None else filament_name_old,
filament_id=filament_ids,
filament_material=filament_material if filament_material is not None else filament_material_old,
filament_color_hex=filament_color_hex,
vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old,
vendor_id=filament_vendor_ids,
location=location,
lot_nr=lot_nr,
allow_archived=allow_archived,
needs_weighing=needs_weighing,
remaining_weight_lt=remaining_weight_lt,
remaining_weight_gt=remaining_weight_gt,
sort_by=sort_by,
limit=limit,
offset=offset,
@@ -415,6 +471,8 @@ async def create( # noqa: ANN201
price=body.price,
initial_weight=body.initial_weight,
spool_weight=body.spool_weight,
extra_weight=body.extra_weight,
color_hex=body.color_hex,
remaining_weight=body.remaining_weight,
used_weight=body.used_weight,
first_used=body.first_used,
@@ -500,6 +558,69 @@ async def delete(
return Message(message="Success!")
# Bulk operations
class BulkArchiveParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to archive/unarchive.")
archived: bool = Field(description="Whether to archive (true) or unarchive (false).")
class BulkDeleteParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to delete.")
class BulkUpdateParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to update.")
location: Optional[str] = Field(None, max_length=64, description="New location for all spools.")
@router.post(
"/bulk/archive",
name="Bulk archive spools",
description="Archive or unarchive multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_archive(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkArchiveParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data={"archived": body.archived})
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.post(
"/bulk/delete",
name="Bulk delete spools",
description="Delete multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkDeleteParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.delete(db=db, spool_id=spool_id)
return Message(message=f"Deleted {len(body.spool_ids)} spools.")
@router.post(
"/bulk/update",
name="Bulk update spools",
description="Update the same fields on multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_update(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkUpdateParameters,
) -> Message:
data = body.model_dump(exclude={"spool_ids"}, exclude_unset=True)
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data=data)
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.put(
"/{spool_id}/use",
name="Use spool filament",

237
spoolman/auth.py Normal file
View File

@@ -0,0 +1,237 @@
"""Authentication utilities and dependencies."""
import json
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Annotated, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import models
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
# JWT Configuration
SECRET_KEY = os.environ.get("SPOOLMAN_AUTH_SECRET_KEY", secrets.token_urlsafe(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("SPOOLMAN_AUTH_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours default
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 scheme - auto_error=False allows unauthenticated access when auth is disabled
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login", auto_error=False)
# Cached auth enabled state (loaded from database on startup)
_auth_enabled_cache: bool = False
def is_auth_enabled() -> bool:
"""Check if authentication is enabled (from cached database setting)."""
return _auth_enabled_cache
def set_auth_enabled_cache(enabled: bool) -> None:
"""Update the cached auth enabled state."""
global _auth_enabled_cache
_auth_enabled_cache = enabled
logger.info("Auth enabled cache updated: %s", enabled)
async def load_auth_enabled_from_db(db: AsyncSession) -> bool:
"""Load auth_enabled setting from database and update cache."""
from spoolman.settings import SETTINGS
setting = await db.get(models.Setting, "auth_enabled")
if setting is not None:
enabled = json.loads(setting.value)
else:
# Use default from settings definition
enabled = json.loads(SETTINGS["auth_enabled"].default)
set_auth_enabled_cache(enabled)
return enabled
class Role(str, Enum):
"""User roles with increasing permissions."""
VIEWER = "viewer" # Can only view
OPERATOR = "operator" # Can view + record usage
EDITOR = "editor" # Can view + use + create + edit
ADMIN = "admin" # Full access
# Role hierarchy - each role includes permissions of roles below it
ROLE_HIERARCHY = {
Role.VIEWER: 0,
Role.OPERATOR: 1,
Role.EDITOR: 2,
Role.ADMIN: 3,
}
class Token(BaseModel):
"""JWT token response."""
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Data extracted from JWT token."""
username: str
role: Role
class UserResponse(BaseModel):
"""User data returned by API (no password)."""
id: int
username: str
role: Role
is_active: bool
created_at: datetime
@staticmethod
def from_db(user: models.User) -> "UserResponse":
"""Convert database model to response model."""
return UserResponse(
id=user.id,
username=user.username,
role=Role(user.role),
is_active=user.is_active,
created_at=user.created_at,
)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[models.User]:
"""Get a user by username."""
result = await db.execute(select(models.User).where(models.User.username == username))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, username: str, password: str) -> Optional[models.User]:
"""Authenticate a user by username and password."""
user = await get_user_by_username(db, username)
if not user or not verify_password(password, user.hashed_password):
return None
if not user.is_active:
return None
return user
async def get_current_user(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> Optional[models.User]:
"""Get the current authenticated user from JWT token.
Returns None if auth is disabled or no token provided.
Raises HTTPException if token is invalid.
"""
if not is_auth_enabled():
return None
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(minimum_role: Role):
"""Dependency factory that requires a minimum role level.
Usage:
@router.post("/spool", dependencies=[Depends(require_role(Role.EDITOR))])
async def create_spool(...):
...
"""
async def role_checker(
current_user: Annotated[Optional[models.User], Depends(get_current_user)],
) -> Optional[models.User]:
# If auth is disabled, allow all operations
if not is_auth_enabled():
return None
if current_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
user_role = Role(current_user.role)
if ROLE_HIERARCHY[user_role] < ROLE_HIERARCHY[minimum_role]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires {minimum_role.value} role or higher",
)
return current_user
return role_checker
# Common dependencies for different permission levels
RequireViewer = Depends(require_role(Role.VIEWER))
RequireOperator = Depends(require_role(Role.OPERATOR))
RequireEditor = Depends(require_role(Role.EDITOR))
RequireAdmin = Depends(require_role(Role.ADMIN))

View File

@@ -114,13 +114,16 @@ async def find(
sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[models.Filament], int]:
) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
"""Find a list of filament objects by search criteria.
Sort by a field by passing a dict with the field name as key and the sort order as value.
The field name can contain nested fields, e.g. vendor.name.
Returns a tuple containing the list of items and the total count of matching items.
Returns a tuple containing:
- list of filaments
- total count of matching items
- dict mapping filament_id to (spool_count, total_remaining_weight)
"""
stmt = (
select(models.Filament)
@@ -160,7 +163,25 @@ async def find(
if total_count is None:
total_count = len(result)
return result, total_count
# Fetch spool statistics for all filaments in the result
spool_stats: dict[int, tuple[int, float]] = {}
if result:
filament_ids = [f.id for f in result]
stats_stmt = (
select(
models.Spool.filament_id,
func.count(models.Spool.id).label("spool_count"),
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
)
.where(models.Spool.filament_id.in_(filament_ids))
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
.group_by(models.Spool.filament_id)
)
stats_rows = await db.execute(stats_stmt)
for row in stats_rows.all():
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
return result, total_count, spool_stats
async def update(
@@ -220,6 +241,16 @@ async def find_materials(
return [row[0] for row in rows.all() if row[0] is not None]
async def find_colors(
*,
db: AsyncSession,
) -> list[str]:
"""Find a list of unique filament colors (hex codes) from the filament table."""
stmt = select(models.Filament.color_hex).distinct()
rows = await db.execute(stmt)
return sorted([row[0] for row in rows.all() if row[0] is not None])
async def find_article_numbers(
*,
db: AsyncSession,

View File

@@ -0,0 +1,148 @@
"""Database operations for Location model."""
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
async def create(
*,
db: AsyncSession,
name: str,
parent_id: Optional[int] = None,
location_type: Optional[str] = None,
description: Optional[str] = None,
) -> models.Location:
"""Create a new location."""
# Verify parent exists if specified
if parent_id is not None:
parent = await db.get(models.Location, parent_id)
if parent is None:
raise ItemNotFoundError(f"Parent location with ID {parent_id} not found.")
db_item = models.Location(
name=name,
parent_id=parent_id,
location_type=location_type,
description=description,
)
db.add(db_item)
await db.commit()
return db_item
async def get_by_id(db: AsyncSession, location_id: int) -> models.Location:
"""Get a location by ID."""
stmt = select(models.Location).where(models.Location.id == location_id)
result = await db.execute(stmt)
db_item = result.scalar_one_or_none()
if db_item is None:
raise ItemNotFoundError(f"Location with ID {location_id} not found.")
return db_item
async def find(
*,
db: AsyncSession,
parent_id: Optional[int] = None,
) -> list[models.Location]:
"""Find locations, optionally filtered by parent."""
stmt = select(models.Location).order_by(models.Location.name)
if parent_id is not None:
stmt = stmt.where(models.Location.parent_id == parent_id)
result = await db.execute(stmt)
return list(result.scalars().all())
async def find_all(db: AsyncSession) -> list[models.Location]:
"""Get all locations."""
stmt = select(models.Location).order_by(models.Location.name)
result = await db.execute(stmt)
return list(result.scalars().all())
async def update(
*,
db: AsyncSession,
location_id: int,
data: dict,
) -> models.Location:
"""Update a location."""
db_item = await get_by_id(db, location_id)
# Verify parent exists if changing parent
if "parent_id" in data and data["parent_id"] is not None:
# Prevent setting parent to self
if data["parent_id"] == location_id:
raise ValueError("Location cannot be its own parent.")
parent = await db.get(models.Location, data["parent_id"])
if parent is None:
raise ItemNotFoundError(f"Parent location with ID {data['parent_id']} not found.")
for key, value in data.items():
setattr(db_item, key, value)
await db.commit()
return db_item
async def delete(db: AsyncSession, location_id: int) -> None:
"""Delete a location."""
db_item = await get_by_id(db, location_id)
await db.delete(db_item)
await db.commit()
async def get_full_path(db: AsyncSession, location_id: int) -> str:
"""Get the full path of a location as a string (e.g., 'Room > Shelf > Bin')."""
parts = []
current_id: Optional[int] = location_id
while current_id is not None:
location = await get_by_id(db, current_id)
parts.insert(0, location.name)
current_id = location.parent_id
return " > ".join(parts)
async def get_tree(db: AsyncSession) -> list[dict]:
"""Get all locations as a nested tree structure."""
all_locations = await find_all(db)
# Build lookup dict
by_id = {loc.id: loc for loc in all_locations}
# Build tree
def build_node(loc: models.Location) -> dict:
return {
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"children": [
build_node(by_id[child.id])
for child in all_locations
if child.parent_id == loc.id
],
}
# Get root nodes (no parent)
roots = [loc for loc in all_locations if loc.parent_id is None]
return [build_node(loc) for loc in roots]
async def get_spool_count(db: AsyncSession, location_id: int) -> int:
"""Get the number of spools at this location (not including children)."""
stmt = (
select(func.count(models.Spool.id))
.where(models.Spool.location_id == location_id)
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712
)
result = await db.execute(stmt)
return result.scalar() or 0

View File

@@ -12,6 +12,30 @@ class Base(AsyncAttrs, DeclarativeBase):
pass
class Location(Base):
"""Hierarchical storage location (room, shelf, bin, etc.)."""
__tablename__ = "location"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(64))
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("location.id", ondelete="SET NULL"), index=True)
location_type: Mapped[Optional[str]] = mapped_column(String(32), comment="Type: room, shelf, bin, etc.")
description: Mapped[Optional[str]] = mapped_column(String(256))
# Self-referential relationships
parent: Mapped[Optional["Location"]] = relationship(
"Location",
remote_side="Location.id",
back_populates="children",
)
children: Mapped[list["Location"]] = relationship(
"Location",
back_populates="parent",
)
spools: Mapped[list["Spool"]] = relationship(back_populates="location_obj")
class Vendor(Base):
__tablename__ = "vendor"
@@ -76,10 +100,29 @@ class Spool(Base):
initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column()
location: Mapped[Optional[str]] = mapped_column(String(64))
extra_weight: Mapped[Optional[float]] = mapped_column(
default=None,
comment="Extra weight to account for (DryPods, custom holders, etc.).",
)
color_hex: Mapped[Optional[str]] = mapped_column(
String(8),
default=None,
comment="Spool-level color override (hex without #).",
)
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"),
index=True,
comment="FK to hierarchical location.",
)
location_obj: Mapped[Optional["Location"]] = relationship(back_populates="spools")
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column()
needs_weighing: Mapped[Optional[bool]] = mapped_column(
default=False,
comment="Flag indicating spool needs manual weigh-in (e.g., after cancelled print).",
)
extra: Mapped[list["SpoolField"]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
@@ -106,6 +149,26 @@ class SpoolAdjustment(Base):
comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.")
class PrintJob(Base):
"""Track pending/completed print jobs from slicer."""
__tablename__ = "print_job"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
spool: Mapped["Spool"] = relationship()
created: Mapped[datetime] = mapped_column(comment="When the job was created (slice time).")
finished: Mapped[Optional[datetime]] = mapped_column(comment="When the job was completed/cancelled.")
filename: Mapped[str] = mapped_column(String(256), comment="G-code filename.")
filament_used_g: Mapped[float] = mapped_column(comment="Estimated filament usage in grams.")
filament_used_mm: Mapped[Optional[float]] = mapped_column(comment="Estimated filament usage in mm.")
status: Mapped[str] = mapped_column(
String(16),
default="pending",
comment="Status: 'pending', 'completed', 'cancelled'.",
)
class Setting(Base):
__tablename__ = "setting"
@@ -139,3 +202,20 @@ class SpoolField(Base):
spool: Mapped["Spool"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class User(Base):
"""User account for authentication."""
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(
String(16),
default="viewer",
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column()

View File

@@ -0,0 +1,157 @@
"""Database operations for print jobs."""
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from spoolman.database.models import PrintJob, Spool, SpoolAdjustment
from spoolman.exceptions import ItemNotFoundError
async def create(
*,
db: AsyncSession,
spool_id: int,
filename: str,
filament_used_g: float,
filament_used_mm: Optional[float] = None,
) -> PrintJob:
"""Create a new pending print job."""
# Verify spool exists
spool = await db.get(Spool, spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {spool_id} not found.")
job = PrintJob(
spool_id=spool_id,
created=datetime.utcnow(),
filename=filename,
filament_used_g=filament_used_g,
filament_used_mm=filament_used_mm,
status="pending",
)
db.add(job)
await db.commit()
await db.refresh(job)
# Load spool relationship
result = await db.execute(
select(PrintJob).where(PrintJob.id == job.id).options(joinedload(PrintJob.spool))
)
return result.scalar_one()
async def get_by_id(db: AsyncSession, job_id: int) -> PrintJob:
"""Get a print job by ID."""
result = await db.execute(
select(PrintJob).where(PrintJob.id == job_id).options(joinedload(PrintJob.spool))
)
job = result.scalar_one_or_none()
if job is None:
raise ItemNotFoundError(f"Print job with ID {job_id} not found.")
return job
async def find(
*,
db: AsyncSession,
status: Optional[str] = None,
spool_id: Optional[int] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[PrintJob], int]:
"""Find print jobs with optional filters."""
query = select(PrintJob).options(joinedload(PrintJob.spool))
if status:
query = query.where(PrintJob.status == status)
if spool_id:
query = query.where(PrintJob.spool_id == spool_id)
# Get total count
count_query = select(PrintJob)
if status:
count_query = count_query.where(PrintJob.status == status)
if spool_id:
count_query = count_query.where(PrintJob.spool_id == spool_id)
count_result = await db.execute(count_query)
total = len(count_result.all())
# Apply ordering and pagination
query = query.order_by(PrintJob.created.desc())
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all()), total
async def complete(db: AsyncSession, job_id: int, comment: Optional[str] = None) -> PrintJob:
"""Complete a print job - deduct filament from spool."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Deduct filament from spool
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.used_weight += job.filament_used_g
if spool.first_used is None:
spool.first_used = datetime.utcnow()
spool.last_used = datetime.utcnow()
# Record adjustment
adjustment = SpoolAdjustment(
spool_id=spool.id,
timestamp=datetime.utcnow(),
adjustment_type="weight",
value=-job.filament_used_g,
comment=comment or f"Print job: {job.filename}",
)
db.add(adjustment)
# Update job status
job.status = "completed"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def cancel(db: AsyncSession, job_id: int) -> PrintJob:
"""Cancel a print job - flag spool for weighing."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Flag spool for weighing
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.needs_weighing = True
# Update job status
job.status = "cancelled"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def delete(db: AsyncSession, job_id: int) -> None:
"""Delete a print job."""
job = await get_by_id(db, job_id)
await db.delete(job)
await db.commit()

View File

@@ -41,6 +41,8 @@ async def create(
remaining_weight: Optional[float] = None,
initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None,
extra_weight: Optional[float] = None,
color_hex: Optional[str] = None,
used_weight: Optional[float] = None,
first_used: Optional[datetime] = None,
last_used: Optional[datetime] = None,
@@ -69,7 +71,10 @@ async def create(
"remaining_weight can only be used if the initial_weight is "
"defined or the filament has a weight set.",
)
used_weight = max(initial_weight - remaining_weight, 0)
if remaining_weight > initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
initial_weight = remaining_weight
used_weight = initial_weight - remaining_weight
else:
used_weight = 0
@@ -84,6 +89,8 @@ async def create(
registered=datetime.utcnow().replace(microsecond=0),
initial_weight=initial_weight,
spool_weight=spool_weight,
extra_weight=extra_weight,
color_hex=color_hex,
used_weight=used_weight,
price=price,
first_used=first_used,
@@ -115,14 +122,19 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool:
async def find( # noqa: C901, PLR0912
*,
db: AsyncSession,
q: Optional[str] = None,
filament_name: Optional[str] = None,
filament_id: Optional[Union[int, Sequence[int]]] = None,
filament_material: Optional[str] = None,
filament_color_hex: Optional[Union[str, Sequence[str]]] = None,
vendor_name: Optional[str] = None,
vendor_id: Optional[Union[int, Sequence[int]]] = None,
location: Optional[str] = None,
lot_nr: Optional[str] = None,
allow_archived: bool = False,
needs_weighing: Optional[bool] = None,
remaining_weight_lt: Optional[float] = None,
remaining_weight_gt: Optional[float] = None,
sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None,
offset: int = 0,
@@ -149,6 +161,29 @@ async def find( # noqa: C901, PLR0912
stmt = add_where_clause_str_opt(stmt, models.Spool.location, location)
stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr)
# Global search: OR across multiple text fields
if q is not None and q.strip():
search_term = f"%{q.strip()}%"
stmt = stmt.where(
sqlalchemy.or_(
models.Filament.name.ilike(search_term),
models.Filament.material.ilike(search_term),
models.Vendor.name.ilike(search_term),
models.Spool.location.ilike(search_term),
models.Spool.lot_nr.ilike(search_term),
models.Spool.comment.ilike(search_term),
)
)
# Filter by color_hex (exact match, supports multiple comma-separated values)
if filament_color_hex is not None:
if isinstance(filament_color_hex, str):
colors = [c.strip() for c in filament_color_hex.split(",") if c.strip()]
else:
colors = list(filament_color_hex)
if colors:
stmt = stmt.where(models.Filament.color_hex.in_(colors))
if not allow_archived:
# Since the archived field is nullable, and default is false, we need to check for both false or null
stmt = stmt.where(
@@ -158,6 +193,24 @@ async def find( # noqa: C901, PLR0912
),
)
if needs_weighing is not None:
if needs_weighing:
stmt = stmt.where(models.Spool.needs_weighing.is_(True))
else:
stmt = stmt.where(
sqlalchemy.or_(
models.Spool.needs_weighing.is_(False),
models.Spool.needs_weighing.is_(None),
),
)
# Filter by remaining_weight (computed as initial_weight - used_weight, falling back to filament.weight)
remaining_weight_expr = coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight
if remaining_weight_lt is not None:
stmt = stmt.where(remaining_weight_expr < remaining_weight_lt)
if remaining_weight_gt is not None:
stmt = stmt.where(remaining_weight_expr > remaining_weight_gt)
total_count = None
if limit is not None:
@@ -190,6 +243,18 @@ async def find( # noqa: C901, PLR0912
sorts.append(models.Filament.name)
elif fieldstr == "price":
sorts.append(coalesce(models.Spool.price, models.Filament.price))
elif fieldstr.startswith("extra."):
# Sort by custom field value
extra_key = fieldstr[len("extra."):]
extra_alias = sqlalchemy.orm.aliased(models.SpoolField)
stmt = stmt.outerjoin(
extra_alias,
sqlalchemy.and_(
extra_alias.spool_id == models.Spool.id,
extra_alias.key == extra_key,
),
)
sorts.append(extra_alias.value)
else:
sorts.append(parse_nested_field(models.Spool, fieldstr))
@@ -227,7 +292,10 @@ async def update(
elif k == "remaining_weight":
if spool.initial_weight is None:
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.")
spool.used_weight = max(spool.initial_weight - v, 0)
if v > spool.initial_weight:
# Spool is heavier than expected (manufacturing tolerance), adjust initial_weight
spool.initial_weight = v
spool.used_weight = spool.initial_weight - v
elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v))
elif k == "extra":
@@ -409,7 +477,12 @@ async def measure(
"""
spool_result = await db.execute(
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
sqlalchemy.select(
models.Spool.initial_weight,
models.Spool.used_weight,
models.Spool.spool_weight,
models.Spool.extra_weight,
).where(
models.Spool.id == spool_id,
),
)
@@ -421,6 +494,7 @@ async def measure(
initial_weight = spool_info[0]
spool_weight = spool_info[2]
extra_weight = spool_info[3] or 0 # Default to 0 if not set
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# Get filament weight and spool_weight
result = await db.execute(
@@ -442,7 +516,7 @@ async def measure(
if initial_weight is None or initial_weight == 0:
raise SpoolMeasureError("Initial weight is not set.")
initial_gross_weight = initial_weight + spool_weight
initial_gross_weight = initial_weight + spool_weight + extra_weight
# if the measurement is greater than the initial weight, set the initial weight to the measurement
if weight > initial_gross_weight:
@@ -454,9 +528,10 @@ async def measure(
# Calculate the weight used since last measure
weight_to_use = current_use - weight
# If the measured weight is less than the empty weight, use the rest of the spool
if (initial_gross_weight - weight_to_use) < spool_weight:
weight_to_use = current_use - spool_weight
# If the measured weight is less than the empty weight (+ extra), use the rest of the spool
empty_gross_weight = spool_weight + extra_weight
if (initial_gross_weight - weight_to_use) < empty_gross_weight:
weight_to_use = current_use - empty_gross_weight
return await use_weight(db, spool_id, weight_to_use, comment=comment)

View File

@@ -62,7 +62,7 @@ class ExternalFilament(BaseModel):
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
material: str = Field(description="Filament material.", examples=["PLA"])
density: float = Field(description="Density in g/cm3.", examples=[1.23])
weight: float = Field(description="Net weight of a single spool.", examples=[1000])
weight: Optional[float] = Field(default=None, description="Net weight of a single spool.", examples=[1000])
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", examples=[140])
spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
diameter: float = Field(description="Filament in mm.", examples=[1.75])
@@ -77,7 +77,9 @@ class ExternalFilament(BaseModel):
examples=[["2c3232", "5f5f5f"]],
)
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
extruder_temp_max: Optional[int] = Field(default=None, description="Max extruder temp in °C.", examples=[220])
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
bed_temp_max: Optional[int] = Field(default=None, description="Max bed temp in °C.", examples=[60])
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
multi_color_direction: Optional[MultiColorDirection] = Field(
default=None,
@@ -167,21 +169,35 @@ def get_materials_file() -> Path:
async def _sync() -> None:
logger.info("Syncing external DB.")
"""Sync filaments and materials from SpoolmanDB."""
logger.info("Syncing external DB (SpoolmanDB).")
url = get_external_db_url()
spoolmandb_url = get_external_db_url()
if not spoolmandb_url.strip():
logger.warning("External DB URL is empty. Skipping sync.")
return
filaments = _parse_filaments_from_bytes(await _download_file(urljoin(url, "filaments.json")))
materials = _parse_materials_from_bytes(await _download_file(urljoin(url, "materials.json")))
# Sync filaments
try:
filaments = _parse_filaments_from_bytes(
await _download_file(urljoin(spoolmandb_url, "filaments.json"))
)
_write_to_local_cache("filaments.json", filaments.model_dump_json().encode())
logger.info("SpoolmanDB filaments synced: %d filaments", len(filaments.root))
except Exception as e:
logger.warning("Failed to sync SpoolmanDB filaments: %s", e)
_write_to_local_cache("filaments.json", filaments.json().encode())
_write_to_local_cache("materials.json", materials.json().encode())
# Sync materials
try:
materials = _parse_materials_from_bytes(
await _download_file(urljoin(spoolmandb_url, "materials.json"))
)
_write_to_local_cache("materials.json", materials.model_dump_json().encode())
logger.info("SpoolmanDB materials synced: %d materials", len(materials.root))
except Exception as e:
logger.warning("Failed to sync SpoolmanDB materials: %s", e)
logger.info(
"External DB synced. Filaments: %d, Materials: %d",
len(filaments.root),
len(materials.root),
)
logger.info("External DB sync complete.")
def schedule_tasks(scheduler: Scheduler) -> None:

View File

@@ -13,7 +13,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
from prometheus_client import generate_latest
from scheduler.asyncio.scheduler import Scheduler
from spoolman import env, externaldb
from spoolman import auth, env, externaldb
from spoolman.api.v1.router import app as v1_app
from spoolman.client import SinglePageApplication
from spoolman.database import database
@@ -173,6 +173,12 @@ async def startup() -> None:
project_root = Path(__file__).parent.parent
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC221
# Load auth enabled setting from database
logger.info("Loading auth settings...")
async for db in database.get_db_session():
auth_enabled = await auth.load_auth_enabled_from_db(db)
logger.info("Authentication enabled: %s", auth_enabled)
# Setup scheduler
schedule = Scheduler()
database.schedule_tasks(schedule)

View File

@@ -72,3 +72,4 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
register_setting("locations", SettingType.ARRAY, json.dumps([]))
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
register_setting("auth_enabled", SettingType.BOOLEAN, json.dumps(False))