Compare commits

...

76 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
6d0ecec068 docs: Update CLAUDE.md with Issue #6 completion
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 20:19:27 -06:00
d804329b77 feat: Add spool adjustment history tracking (#6)
Track history of all spool weight/length adjustments with timestamps and comments:

Backend:
- Add SpoolAdjustment database model with spool_id, timestamp, type, value, comment
- Add Alembic migration for spool_adjustment table
- Add SpoolAdjustment Pydantic model for API responses
- Update use_weight, use_length, and measure functions to record adjustments
- Add GET /api/v1/spool/{id}/adjustments endpoint for history retrieval
- Add optional comment parameter to use/measure endpoints

Frontend:
- Add ISpoolAdjustment interface to spool model
- Add collapsible adjustment history table to spool show page
- Display timestamp, type (weight/length), amount, and comment
- Add translation keys for history table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:18:56 -06:00
388669d0c6 docs: Update CLAUDE.md with Issue #5 completion
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 Issue #5 (Temperature Ranges) as complete
- Add Issue #6 (Spool Adjustment History) as next up
- Document implementation details for Issue #5

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:05:32 -06:00
3451996a8f feat: Add temperature ranges for extruder and bed temps (#5)
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
Store extruder and bed temperatures as min/max ranges instead of
single values for more accurate filament specifications.

Backend changes:
- Add 4 new columns: settings_extruder_temp_min/max, settings_bed_temp_min/max
- Keep old columns for backward compatibility (marked deprecated)
- Alembic migration copies existing values to both min and max
- Update Pydantic models and API endpoints

Frontend changes:
- Filament model updated with new fields
- Create form uses min/max input pairs with Space.Compact
- Import from external DB populates both min and max with same value
- Label template updated to show ranges: "ET: 190-220 °C"
- Template help includes new tags

Labels now display temperature ranges like "190-220°C" which is more
useful for printing than a single recommended value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:05:02 -06:00
dc166450ce docs: Update CLAUDE.md with Issue #4 completion
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 Issue #4 (Layout Max-Width) as complete
- Add Issue #5 (Temperature Ranges) as next up

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:00:00 -06:00
66e956f021 feat: Add layout max-width for better UX on wide screens (#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
Changes:
- Add 1400px max-width container in SpoolmanLayout
- Add 800px max-width for forms via CSS override

This prevents the UI from feeling too spread out on wide monitors
and MacBooks, improving readability and reducing eye movement.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:59:38 -06:00
e14efa2b89 docs: Update CLAUDE.md with Issue #3 completion
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 Issue #3 (QR Code Sizing Fixes) as complete
- Add Issue #4 (Layout Max-Width) as next up
- Document implementation details for Issue #3

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:58:14 -06:00
d17a66b0ec feat: Add configurable QR code sizing options (#3)
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
Addresses https://github.com/Donkie/Spoolman/issues/671

Changes:
- Remove fixed 50% max-width constraint on QR code container
- Add qrCodePadding setting (0-10mm, default 2mm) for white space
- Add qrCodeMaxWidth setting (0-100%, 0 = auto/fit-content)
- Add UI controls (slider + input) for both new settings
- Settings are disabled when QR code is turned off

The previous behavior forced QR codes to 50% width when label text
was shown, wasting label space on small labels. New default uses
fit-content for optimal sizing while allowing manual control.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:57:52 -06:00
cbb2940193 docs: Update CLAUDE.md with Issue #2 completion
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 Issue #2 (Smart Density Defaults) as complete
- Add Issue #3 (QR Code Sizing) as next up
- Document implementation details for Issue #2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:55:04 -06:00
478806dcc8 feat: Add smart density defaults based on material type (#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
Auto-fill density when entering material type in filament forms:
- Add materialDefaults.ts with standard densities for common materials
  (PLA, ABS, PETG, TPU, PC, etc.)
- Material field now uses AutoComplete with suggestions
- Density auto-fills on material select or blur (fuzzy matching)
- New filaments default to 1.75mm diameter and 1.24 g/cm³ (PLA)
- Applied to both full filament create page and quick create modal

Reduces tedious data entry for common materials while still allowing
custom values for specialty filaments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:54:30 -06:00
27d85eb479 docs: Update CLAUDE.md with UX improvement progress
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
- Added issue tracking table
- Documented Issue #1 completion (inline creation modals)
- Added Issue #2 implementation plan (smart density defaults)
- Added material density reference table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:45:17 -06:00
ea5b06493c feat: Add inline creation modals for filament and vendor (#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
When creating a spool, users can now create a new filament directly
from the filament dropdown without leaving the page. Similarly, when
creating a filament, users can create a new vendor inline.

New components:
- VendorCreateModal: Simple modal for quick vendor creation
- FilamentCreateModal: Modal with essential fields + nested vendor creation

Changes:
- Added dropdownRender to filament Select in spool create page
- Added dropdownRender to vendor Select in filament create page
- Added translation keys for success messages and placeholders
- Pre-fills sensible defaults (diameter 1.75mm, density 1.24 g/cm³)

Closes #1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:33:25 -06:00
a55194ba16 Add CLAUDE.md with project guidance and Gitea config
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 development commands, architecture overview, and
Gitea repository configuration for issue tracking.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:21:50 -06:00
Donkie
cda756557d Updated react qr scanner 2026-01-06 13:05:54 +01:00
Donkie
b627e151a8 Updated client packages 2026-01-06 01:54:09 +01:00
Donkie
848f5dcc94 Cleaned up favicons, PWA icon fixes 2026-01-06 00:28:31 +01:00
Donkie
6adb6bedc2 Merge pull request #762 from sixiaolong1117/master
Supports PWA.
2026-01-06 00:19:14 +01:00
Donkie
edda1e965a some fixes 2026-01-06 00:18:20 +01:00
SI Xiaolong
7bb0c6b1ec Supports PWA.
PWA is supported using vite-plugin-pwa.
2026-01-05 23:58:58 +01:00
Donkie
9124ce0b86 Merge pull request #747 from Shadowsith/745-docker-size-optimization
Reduce Docker container size
2026-01-05 23:54:42 +01:00
Donkie
753140ecc3 Replaced su-exec with gosu, fixed pdm using wrong python version 2026-01-05 23:53:41 +01:00
Donkie
9baf91380a Merge pull request #717 from ajford/spool_edit_weight_iss477
Fix Spool Edit page Empty Weight and Initial Weight bug
2026-01-05 22:58:42 +01:00
Philip Mayer
9df3f0c91b Add changes based on code review https://github.com/Donkie/Spoolman/issues/745 2025-09-22 21:53:45 +02:00
Philip Mayer
e49976bf8a Reduce docker container size by usage of slim bookworm #745 2025-08-21 00:04:56 +02:00
ajford
25f78e90e6 Fixes #477 by defaulting to spool values first 2025-06-27 00:29:23 -05:00
110 changed files with 16798 additions and 2983 deletions

613
CLAUDE.md Normal file
View File

@@ -0,0 +1,613 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Spoolman is a self-hosted web service for managing 3D printer filament spools. It tracks inventory, monitors usage, and integrates with OctoPrint and Klipper/Moonraker.
**Tech Stack:**
- Backend: Python 3.9-3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic
- Frontend: React 19, TypeScript, Vite, Ant Design, Refine framework
- Databases: SQLite (default), PostgreSQL, MySQL, CockroachDB
## Common Commands
### Python (PDM)
```bash
pdm install # Install all dependencies including dev
pdm sync --prod --no-editable # Install production dependencies only
pdm run app # Run backend server (uvicorn)
pdm run itest # Run integration tests (all databases)
pdm run docs # Generate API documentation
```
### Frontend
```bash
cd client
npm ci # Install dependencies
npm run dev # Development server with hot reload
npm run build # Production build
npm run tsc # Type check only
```
### Linting/Formatting
```bash
black . # Format Python code
ruff check . --fix # Lint and auto-fix Python
pre-commit run --all-files # Run all pre-commit hooks
```
### Integration Tests
Tests run in Docker containers against multiple database types:
```bash
python tests_integration/run.py sqlite # Single database
python tests_integration/run.py sqlite postgres # Multiple databases
pdm run itest # All databases
```
Run a single test:
```bash
pytest tests_integration/tests/spool/test_add.py::test_add_spool_remaining_weight -v
```
### Database Migrations
1. Edit models in `spoolman/database/models.py`
2. Run the server to update local SQLite
3. `pdm run alembic revision -m "description" --autogenerate`
4. Review generated migration, format with Black/Ruff
## Architecture
### Backend (`/spoolman/`)
- `main.py` - FastAPI app entry, startup/shutdown, SPA mounting
- `env.py` - Environment variable parsing and configuration
- `ws.py` - WebSocket manager for real-time updates
- `api/v1/` - REST endpoints and WebSocket handlers
- `router.py` - Main router with WebSocket root endpoint
- `spool.py`, `filament.py`, `vendor.py` - CRUD endpoints per resource
- `models.py` - Pydantic response models
- `database/` - SQLAlchemy models and async database operations
- `models.py` - ORM definitions (Vendor, Filament, Spool, Setting, *Field)
- `database.py` - Connection setup, backup logic, migrations
### Frontend (`/client/src/`)
- `App.tsx` - Routing and Refine framework setup
- `pages/` - Page components (spools/, filaments/, vendors/, settings/)
- `components/dataProvider.ts` - REST API integration
- `components/liveProvider.ts` - WebSocket live updates
### Data Model
```
Vendor (1) ──→ (many) Filament (1) ──→ (many) Spool
```
All entities support custom fields via *Field tables (VendorField, FilamentField, SpoolField).
### API Patterns
- REST: Standard CRUD at `/api/v1/{resource}`
- WebSocket: Real-time events at `WS /api/v1/` or `WS /api/v1/{resource}`
- Query params: `?skip=0&limit=50&sort_by=name&sort_order=asc`
### Key Configuration
Environment variables (see `.env.example`):
- `SPOOLMAN_DB_TYPE` - Database type (sqlite/mysql/postgres/cockroachdb)
- `SPOOLMAN_PORT` - Server port (default: 7912)
- `SPOOLMAN_DEBUG_MODE` - Enables permissive CORS
- `VITE_APIURL` - Frontend API base URL (e.g., `/api/v1`)
## Code Quality
- Line length: 120 characters
- Python target: 3.9
- Pre-commit hooks enforce Black formatting and Ruff linting
- Backend is fully async using SQLAlchemy AsyncSession
## Gitea Repository
- **URL:** http://192.168.0.5:3022/tonym/spoolman2
- **API Token:** 8a04b3cb5dbb54e2d895b707305523c3ad83a945
- **Upstream:** https://github.com/Donkie/Spoolman (remote: `upstream`)
This is a fork with UX improvements. Issues are tracked on the Gitea instance above.
## UX Improvement Issues (Gitea)
| # | Title | Status |
|---|-------|--------|
| 1 | Inline Creation Modals | ✅ DONE |
| 2 | Smart Density Defaults | ✅ DONE |
| 3 | QR Code Sizing Fixes | ✅ DONE |
| 4 | Layout Max-Width | ✅ DONE |
| 5 | Temperature Ranges | ✅ DONE |
| 6 | Spool Adjustment History | ✅ DONE |
| 7 | 3dfilamentprofiles Integration | ❌ REMOVED |
| 8 | (duplicate of #7) | ✅ DONE |
| 9 | Hierarchical Locations (Room/Bin) | ✅ DONE |
| 10 | Database Import/Export | ✅ DONE |
| 11 | Slicer Integration / Print Jobs | ✅ DONE |
| 12 | Redesign Home Page Dashboard | ✅ DONE |
| 13 | Batch Operations (Multi-Select Edit) | ✅ DONE |
| 14 | Extra Weight Field (DryPods, etc.) | ✅ DONE |
| 15 | Spool Count per Filament in List | ✅ DONE |
| 16 | Filter Spools by Color | ✅ DONE |
| 17 | Image Attachments for Filaments | **Open** |
| 18 | QR Code Labels for Filaments | **Open** |
| 19 | Integration with filamentcolors.xyz | ✅ DONE |
| 20 | Gallery View for Spools (Color Grid) | ✅ DONE |
| 21 | QR Code Version/Complexity Config | **Open** |
| 23 | Reporting & Data Export | **Open** |
| 24 | NFC/RFID Tag Support | **Open** |
| 25 | Sort Spools by Custom Fields | ✅ DONE |
| 26 | Label Template Enhancements | **Open** |
| 27 | QR Scanner Camera Device Selector | **Open** |
| 28 | Mobile-Friendly UI / Responsive | **Open** |
| 29 | Table Column Customization | **Open** |
| 30 | Printer Management & Parts Inventory | **Open** |
| 31 | Special Filament Properties | **Open** |
| 32 | SpoolType Model (Cardboard/Plastic/Metal) | **Open** |
| 34 | Color Swatch Border for Dark Mode | ✅ DONE |
| 35 | Global Search Box | ✅ DONE |
| 36 | Spool-Level Overrides (Color, Price) | ✅ DONE |
| 37 | Plugin System for Extensions | **Open** |
| 39 | Usage Statistics & Cost Tracking | ✅ DONE |
| 40 | Storage vs Active Spool Status | **Open** |
| 41 | Load Cell / Scale Integration | **Open** |
| 42 | Allow Spools Heavier Than Max | ✅ DONE |
| 43 | Show Filament Fields on Spool View | ✅ DONE |
| 44 | HomeAssistant Add-On Packaging | **Open** |
| 45 | Filament Swatch Card Generator | **Open** |
| 46 | Project-Based Spool Grouping | **Open** |
| 47 | G-code Filament Usage Estimation | **Open** |
### Other Features (not tracked in Gitea)
- ✅ Theme Colors (5 options)
- ✅ Clickable Dashboard Cards
- ✅ QR Code Size Setting
- ✅ Parse Temps from Comments
- ✅ Prev/Next Navigation on Edit Pages
- ✅ Price/Cost Tracking (remaining value, inventory value)
- ✅ Print History on Spool Detail
- ✅ Usage Analytics Dashboard
- ✅ Color Column in Spool List (with filter)
### Issue #1 Complete - Inline Creation Modals
Added ability to create filament/vendor inline without leaving the page:
- `client/src/components/vendorCreateModal.tsx` - Quick vendor creation
- `client/src/components/filamentCreateModal.tsx` - Quick filament creation with nested vendor modal
- Modified spool create to add "Create Filament" in dropdown
- Modified filament create to add "Create Manufacturer" in dropdown
- Uses `dropdownRender` pattern (same as location select)
- Uses `useInvalidate` to refresh lists after creation
### Issue #2 Complete - Smart Density Defaults
Auto-fill density based on material type:
- `client/src/utils/materialDefaults.ts` - Density lookup table with fuzzy matching
- Material field converted to AutoComplete (dropdown suggestions + free text)
- Fuzzy matching: "PLA Silk" → PLA (1.24 g/cm³)
- New filaments default to 1.75mm diameter and 1.24 g/cm³ (PLA)
- Applied to both full create page and FilamentCreateModal
- Edit page not affected (preserves user customizations)
Material densities in lookup:
```
PLA: 1.24, ABS: 1.04, PETG: 1.27, NYLON: 1.52, TPU: 1.21
PC: 1.3, WOOD: 1.28, CF: 1.3, PC/ABS: 1.19, HIPS: 1.03
PVA: 1.23, ASA: 1.05, PP: 0.9, POM: 1.4, PMMA: 1.18, FPE: 2.16
```
### Issue #3 Complete - QR Code Sizing Fixes
See upstream issue: https://github.com/Donkie/Spoolman/issues/671
Changes:
- Removed fixed 50% max-width constraint (now uses fit-content by default)
- Added `qrCodePadding` setting (0-10mm, default 2mm)
- Added `qrCodeMaxWidth` setting (0-100%, 0 = auto)
- Added UI controls with slider + input number for both settings
Files modified:
- `client/src/pages/printing/printing.tsx` - Added settings to interface
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - CSS and UI controls
### Issue #4 Complete - Layout Max-Width
Changes:
- Added 1400px max-width container in `SpoolmanLayout`
- Added 800px max-width for forms via CSS override in `overrides.css`
- Content is centered on wide screens
### Issue #5 Complete - Temperature Ranges
Store extruder and bed temperatures as min/max ranges:
- Added 4 new DB columns: `settings_extruder_temp_min/max`, `settings_bed_temp_min/max`
- Old single-value columns kept for backward compatibility (deprecated)
- Alembic migration copies existing values to both min and max
- Frontend uses Space.Compact with min/max input pairs
- Label template shows ranges: "ET: 190-220 °C"
Files:
- `spoolman/database/models.py` - New columns
- `migrations/versions/2025_01_15_0200-*.py` - Migration
- `spoolman/api/v1/models.py`, `filament.py` - API changes
- `client/src/pages/filaments/create.tsx` - Form updates
- `client/src/pages/printing/spoolQrCodePrintingDialog.tsx` - Template tags
### Issue #6 Complete - Spool Adjustment History
Track history of all spool weight/length adjustments with timestamps and comments.
Backend:
- `spoolman/database/models.py` - Added `SpoolAdjustment` model with relationship to Spool
- `migrations/versions/2025_01_15_0300-*.py` - Migration for spool_adjustment table
- `spoolman/api/v1/models.py` - Added `SpoolAdjustment` Pydantic model
- `spoolman/api/v1/spool.py` - Added comment field to use/measure endpoints, GET /spool/{id}/adjustments endpoint
- `spoolman/database/spool.py` - Updated use_weight, use_length, measure functions to record adjustments
Frontend:
- `client/src/pages/spools/model.tsx` - Added `ISpoolAdjustment` interface
- `client/src/pages/spools/show.tsx` - Collapsible adjustment history table
- `client/public/locales/en/common.json` - Translation keys for history table
### Issue #7 REMOVED - 3dfilamentprofiles Integration
**Removed** - 3dfilamentprofiles.com has Vercel bot protection blocking automated requests.
Their GitHub repo only has sample data (~3 filaments). SpoolmanDB has 6,957+ filaments, so this was unnecessary.
### Issue #10 Complete - Database Import/Export
Full backup/restore functionality for migrating between databases (PostgreSQL ↔ SQLite).
Backend (`spoolman/api/v1/backup.py`):
- `GET /api/v1/backup/export` - Downloads all data as single JSON file
- `POST /api/v1/backup/import` - Imports JSON with `replace` option
- Exports: vendors, filaments, spools, spool_adjustments, settings
- Handles ID remapping to maintain relationships
- Requires `python-multipart` package for file uploads
Frontend (`client/src/pages/settings/generalSettings.tsx`):
- "Backup & Restore" section in Settings page
- "Download Backup" button for export
- File upload with "Import (Merge)" or "Import (Replace All)" options
- Confirmation dialog for destructive replace operation
Migration workflow:
1. Export from PostgreSQL instance via Settings → Download Backup
2. Reconfigure container for SQLite
3. Import via Settings → Import (Replace All)
---
## Local Development Setup
To run locally against the Unraid PostgreSQL database:
```bash
# Create virtualenv and install deps
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[postgres]"
# Start backend (reads .env file)
source .venv/bin/activate
export $(grep -v '^#' .env | xargs)
uvicorn spoolman.main:app --host 0.0.0.0 --port 7912
# Start frontend (separate terminal)
cd client
VITE_APIURL=http://localhost:7912/api/v1 npm run dev
```
Access at http://localhost:5173
### Issue #11 Complete - Theme Colors
Added theme color selector with 5 color options:
- `client/src/contexts/color-mode/index.tsx` - Theme color system with THEME_COLORS constant
- Colors: orange, teal, red, green, blue
- Persists in localStorage
- Settings page has color selector buttons
### Issue #12 Complete - Clickable Dashboard Cards
Dashboard cards now link to their respective list pages:
- `client/src/pages/home/index.tsx` - Card body is clickable, links to list
- Only the + button stays independent (for create)
### Issue #13 Complete - QR Code Size Setting
Added explicit QR code size control:
- `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto)
### Issue #15 Complete - Print Job Tracking / Slicer Integration
Track pending print jobs from slicer and manage filament deduction after prints complete.
**Workflow:**
1. Slicer post-processing script parses G-code for filament usage
2. Script creates pending print job in Spoolman via API
3. After print completes, user visits Print Queue UI
4. Complete: Deducts filament from spool, records adjustment history
5. Cancel: Flags spool for manual weigh-in (no automatic deduction)
**Backend:**
- `spoolman/database/models.py` - `PrintJob` model, `needs_weighing` field on Spool
- `spoolman/database/print_job.py` - CRUD operations for print jobs
- `spoolman/api/v1/print_job.py` - REST API endpoints
- `POST /print-job` - Create pending job
- `POST /print-job/{id}/complete` - Complete job, deduct filament
- `POST /print-job/{id}/cancel` - Cancel job, flag spool for weighing
- `GET /print-job` - List jobs with status filter
- `migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py`
**Frontend:**
- `client/src/pages/print-queue/index.tsx` - Print Queue UI page
- `client/src/pages/spools/model.tsx` - `IPrintJob` interface, `needs_weighing` on ISpool
**Slicer Script:**
- `scripts/slicer_post_process.py` - Elegoo/Orca Slicer post-processing script
- Parses G-code comments for filament usage (weight in grams)
- Creates pending print job via API
- Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID`
- Or config file: `~/.config/spoolman/slicer.conf`
### Color Column in Spool List
Added optional color column to the spool list with filtering capability.
**Backend:**
- `spoolman/database/filament.py` - `find_colors()` function to get distinct color hex codes
- `spoolman/api/v1/other.py` - `GET /api/v1/color` endpoint to list all colors
- `spoolman/api/v1/spool.py` - Added `filament.color_hex` query parameter for filtering
- `spoolman/database/spool.py` - Filter implementation (supports comma-separated values)
**Frontend:**
- `client/src/components/column.tsx` - `ColorFilterColumn` component renders color swatches
- `client/src/components/otherModels.tsx` - `useSpoolmanColors()` hook to fetch colors
- `client/src/pages/spools/list.tsx` - Added color column (hidden by default)
- `client/public/locales/en/common.json` - Added "Color" translation key
**Features:**
- Color displayed as small swatch with hex tooltip
- Filter dropdown shows swatches with hex codes
- Multi-select filtering (can filter by multiple colors)
- Column hidden by default (enable via column selector)
### Issue #9 Complete - Hierarchical Locations
Storage locations can now be organized in a tree structure (Room > Shelf > Bin).
**Backend:**
- `spoolman/database/models.py` - Added `Location` model with self-referential parent
- `spoolman/database/location.py` - CRUD operations, tree building, full path resolution
- `spoolman/api/v1/location.py` - REST API for locations
- `migrations/versions/2025_01_21_0100-*.py` - Migration for location table
**Frontend:**
- `client/src/components/LocationTreeSelect.tsx` - Tree-based location picker
- `client/src/components/LocationCreateModal.tsx` - Inline location creation
**Features:**
- Hierarchical structure with parent/child relationships
- Location types: room, shelf, bin, drawer, box, other
- Legacy flat location string preserved for backward compatibility
- API endpoints: GET /location, GET /location/tree, POST/PATCH/DELETE
### Issue #12 Complete - Dashboard Redesign
Redesigned home page with better visualizations and quick stats.
**Backend:** No changes needed - uses existing endpoints.
**Frontend:**
- `client/src/pages/home/index.tsx` - Refactored layout
- `client/src/pages/home/components/AlertCards.tsx` - Low stock, pending jobs, needs weighing alerts
- `client/src/pages/home/components/MaterialDistributionChart.tsx` - Pie chart by material (recharts)
- `client/src/pages/home/components/QuickStats.tsx` - Total spools, filaments, vendors, weight
**Dependencies:**
- Added `recharts` package for charts
### Issue #13 Complete - Batch Operations
Multi-select spools with bulk archive/delete/move actions.
**Backend:**
- `spoolman/api/v1/spool.py` - Added bulk endpoints:
- POST /spool/bulk/archive
- POST /spool/bulk/delete
- POST /spool/bulk/update
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added row selection
- `client/src/pages/spools/components/BatchActionBar.tsx` - Floating action bar
- `client/src/pages/spools/functions.tsx` - Bulk helper functions
**Features:**
- Checkbox selection with select-all
- Floating action bar shows when items selected
- Archive, unarchive, delete, move to location
- Confirmation modals for all destructive actions
### Issue #14 Complete - Extra Weight Field
Track extra weight from DryPods, custom spool holders, or other accessories.
**Backend:**
- `spoolman/database/models.py` - Added `extra_weight` column to Spool model
- `spoolman/database/spool.py` - Updated `measure()` to account for extra weight
- `spoolman/api/v1/spool.py` - Added `extra_weight` to create/update parameters
- `migrations/versions/2025_01_21_0200-*.py` - Migration for extra_weight column
**Frontend:**
- `client/src/pages/spools/create.tsx` - Added extra_weight form field
- `client/src/pages/spools/edit.tsx` - Added extra_weight form field
- Weight calculations updated to include extra_weight in gross weight
**Features:**
- Optional field for tracking additional weight on spool
- Automatically subtracted when measuring spool weight
- Useful for DryPods, custom spool holders, reel adapters
### Issue #15 Complete - Spool Count per Filament
Already implemented - filament list shows `spool_count` and `total_remaining_weight` columns.
### Price & Cost Tracking
Track spool purchase price and calculate remaining value.
**Backend:**
- `spoolman/api/v1/models.py` - Added `remaining_value` computed field
- Formula: `remaining_value = (remaining_weight / initial_weight) * price`
- Price falls back to filament price if not set on spool
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added remaining_value column (hidden by default)
- `client/src/pages/home/components/QuickStats.tsx` - Added "Inventory Value" stat
### Print History on Spool Detail
Show print job history on the spool detail page.
**Frontend:**
- `client/src/pages/spools/show.tsx` - Added collapsible print history table
- Fetches print jobs filtered by spool_id
- Shows: Created date, Filename, Filament Used, Status, Finished date
### Usage Analytics Dashboard
Time-series charts showing filament consumption trends.
**Backend:**
- `spoolman/api/v1/other.py` - New analytics endpoints:
- `GET /analytics/usage?days=N` - Daily usage from SpoolAdjustments
- `GET /analytics/by-material?days=N` - Usage breakdown by material
**Frontend:**
- `client/src/pages/home/components/UsageAnalytics.tsx` - New component
- Area chart showing daily consumption (recharts)
- Period selector: 7, 30, or 90 days
- Material breakdown cards showing usage by filament type
---
## Deployment
### Production (Unraid)
Deployed on Unraid with br0 network:
- **URL:** http://192.168.0.11:8000 or https://spoolman.nastynas.xyz
- **Database:** SQLite
- **Network:** br0 (own IP: 192.168.0.11)
Update script: `/mnt/user/appdata/spoolman2-build/update.sh`
```bash
ssh root@192.168.0.5 "/mnt/user/appdata/spoolman2-build/update.sh"
```
---
### Issue #42 Complete - Allow Spools Heavier Than Max
Removed weight validation cap that prevented spools from exceeding their theoretical initial weight.
**Backend:**
- `spoolman/database/spool.py` - Removed `max(..., 0)` clamps from create/update; when remaining > initial, adjusts initial upward
- `spoolman/api/v1/models.py` - Removed `max(..., 0)` from remaining_weight computation
### Issue #43 Complete - Show Filament Fields on Spool View
Display filament custom fields on the spool detail page.
**Frontend:**
- `client/src/pages/spools/show.tsx` - Added `filamentExtraFields` section after spool extra fields
### Issue #25 Complete - Sort by Custom Fields & Remaining Weight
**Backend:**
- `spoolman/database/spool.py` - Added `extra.{key}` sort support via outerjoin on SpoolField table
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added `sorter: true` to weight/length NumberColumns
- `client/src/components/column.tsx` - Changed CustomFieldColumn to sortable with `dataId: extra.{key}`
### Issue #35 Complete - Global Search Box
**Backend:**
- `spoolman/api/v1/spool.py` - Added `q` parameter to find endpoint
- `spoolman/database/spool.py` - OR-based search across filament name, material, vendor name, location, lot_nr, comment
**Frontend:**
- `client/src/pages/spools/list.tsx` - Added `Input.Search` component, passes `q` via meta.queryParams
### Issue #20 Complete - Gallery View for Spools
Color grid view for spools with progress bars and material labels.
**Frontend:**
- `client/src/pages/spools/components/SpoolGallery.tsx` - Grid of color cards with progress bars
- `client/src/pages/spools/list.tsx` - View mode toggle (table/gallery)
### Issue #36 Complete - Spool-Level Overrides
Per-spool color override with ColorPicker (price and initial_weight already existed).
**Backend:**
- `spoolman/database/models.py` - Added `color_hex` field to Spool model
- `spoolman/api/v1/spool.py` - Added `color_hex` to create/update params
- `migrations/versions/2025_01_22_0100-g7h8i9j0k1l2_add_spool_color_hex.py`
**Frontend:**
- `client/src/pages/spools/create.tsx`, `edit.tsx` - ColorPicker form field
- All display locations updated to prefer `spool.color_hex` over `filament.color_hex`
### Issue #39 Complete - Usage Statistics & Cost Tracking
Added cost analytics endpoint and total spent / avg cost per kg stats.
**Backend:**
- `spoolman/api/v1/other.py` - Added `GET /analytics/cost` endpoint returning total_spent, avg_cost_per_kg, cost_by_material
**Frontend:**
- `client/src/pages/home/components/QuickStats.tsx` - Added "Total Spent" and "Avg Cost/kg" stat cards
- `client/src/pages/home/components/UsageAnalytics.tsx` - Added cost per material in breakdown cards
### Issue #19 Complete - filamentcolors.xyz Integration
Color lookup from filamentcolors.xyz database (2134 swatches, 317 manufacturers).
**Backend:**
- `spoolman/api/v1/other.py` - Proxy endpoints:
- `GET /filamentcolors/manufacturers` - List all manufacturers
- `GET /filamentcolors/search` - Search swatches by manufacturer, color, filament type
**Frontend:**
- `client/src/components/filamentColorLookup.tsx` - Modal with manufacturer filter + color grid
- Added search button next to ColorPicker on filament create and edit forms
- Click a swatch to apply its measured hex color
### Bug Fixes
- **Filament Select Refresh**: Fixed race condition where form value was set before options re-rendered. Uses `pendingFilamentId` state + useEffect.
- **Parse Temps from Comments**: Added regex-based temperature parser button on filament edit page.
## Known Issues / TODO
### Issue #7 - 3dfilamentprofiles Integration REMOVED
The 3dfilamentprofiles.com website has Vercel bot protection that blocks automated requests. Their GitHub repo (jklewa/filament-profiles-data) only provides sample data (~3 filaments). Since SpoolmanDB already has 6,957+ filaments from major brands, the 3dfp integration was removed rather than maintaining dead code.

View File

@@ -1,4 +1,4 @@
FROM python:3.12-bookworm AS python-builder
FROM python:3.12-slim-bookworm AS python-builder
# Install dependencies
RUN apt-get update && apt-get install -y \
@@ -32,28 +32,17 @@ COPY --chown=app:app spoolman /home/app/spoolman/spoolman
COPY --chown=app:app alembic.ini /home/app/spoolman/
COPY --chown=app:app README.md /home/app/spoolman/
FROM python:3.12-bookworm AS python-runner
FROM python:3.12-slim-bookworm AS python-runner
LABEL org.opencontainers.image.source=https://github.com/Donkie/Spoolman
LABEL org.opencontainers.image.description="Keep track of your inventory of 3D-printer filament spools."
LABEL org.opencontainers.image.licenses=MIT
# Install latest su-exec
RUN set -ex; \
\
curl -o /usr/local/bin/su-exec.c https://raw.githubusercontent.com/ncopa/su-exec/master/su-exec.c; \
\
fetch_deps='gcc libc-dev'; \
apt-get update; \
apt-get install -y --no-install-recommends $fetch_deps; \
rm -rf /var/lib/apt/lists/*; \
gcc -Wall \
/usr/local/bin/su-exec.c -o/usr/local/bin/su-exec; \
chown root:root /usr/local/bin/su-exec; \
chmod 0755 /usr/local/bin/su-exec; \
rm /usr/local/bin/su-exec.c; \
\
apt-get purge -y --auto-remove $fetch_deps
# Install gosu for privilege dropping
RUN apt-get update && apt-get install -y \
gosu \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Add local user so we don't run as root
RUN groupmod -g 1000 users \

1
client/.gitignore vendored
View File

@@ -7,6 +7,7 @@
# testing
/coverage
/dev-dist
# production
/build

8442
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +1,59 @@
{
"name": "spoolman-ui",
"version": "0.22.1",
"version": "0.23C.2",
"engines": {
"node": "20.x"
},
"private": true,
"type": "module",
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@loadable/component": "^5.16.7",
"@refinedev/antd": "^5.46.1",
"@refinedev/core": "^4.57.9",
"@refinedev/kbar": "^1.3.16",
"@refinedev/react-router": "^1.0.1",
"@refinedev/simple-rest": "^5.0.10",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-query-devtools": "^4.36.1",
"@types/loadable__component": "^5.13.9",
"@types/lodash": "^4.17.17",
"@refinedev/antd": "^6.0.3",
"@refinedev/core": "^5.0.7",
"@refinedev/kbar": "^2.0.1",
"@refinedev/react-router": "^2.0.3",
"@refinedev/simple-rest": "^6.0.1",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-query-devtools": "^5.91.2",
"@types/loadable__component": "^5.13.10",
"@types/lodash": "^4.17.21",
"@types/uuid": "^10.0.0",
"@yudiel/react-qr-scanner": "^1.2.10",
"antd": "^5.25.4",
"axios": "^1.12.2",
"@yudiel/react-qr-scanner": "^2.5.0",
"axios": "^1.13.2",
"html-to-image": "^1.11.13",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"lodash": "^4.17.21",
"react": "^19.1.0",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.2",
"react-router": "^7.6.2",
"react-to-print": "^3.1.0",
"uuid": "^11.1.0",
"vite-plugin-svgr": "^4.3.0",
"zustand": "^5.0.5"
"react-dom": "^19.2.3",
"react-i18next": "^16.5.1",
"react-router": "^7.11.0",
"react-to-print": "^3.2.0",
"recharts": "^3.7.0",
"uuid": "^13.0.0",
"vite-plugin-svgr": "^4.5.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@refinedev/cli": "^2.16.46",
"@refinedev/cli": "^2.16.50",
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/node": "^22.15.30",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"@vitejs/plugin-react": "^4.5.1",
"eslint": "^9.28.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"typescript": "^5.8.3",
"vite": "^6.4.1",
"vite-plugin-mkcert": "^1.17.8"
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vite-plugin-mkcert": "^1.17.9",
"vite-plugin-pwa": "^1.2.0"
},
"scripts": {
"dev": "refine dev",

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

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",
@@ -123,7 +148,14 @@
"no": "No",
"simple": "Simple",
"withIcon": "With Icon"
}
},
"qrCodePadding": "QR Code Padding",
"qrCodePaddingTooltip": "White space around the QR code for better scanning reliability",
"qrCodeMaxWidth": "QR Code Max Width",
"qrCodeMaxWidthTooltip": "Maximum width of QR code as percentage of label. Set to 0 for automatic sizing.",
"qrCodeSize": "QR Code Size",
"qrCodeSizeTooltip": "Fixed size of QR code in millimeters. Set to 0 for automatic sizing based on label.",
"auto": "Auto"
},
"spoolSelect": {
"title": "Select Spools",
@@ -149,6 +181,7 @@
},
"spool": {
"spool": "Spools",
"search_placeholder": "Search by name, material, vendor, location...",
"fields": {
"id": "ID",
"filament_name": "Filament",
@@ -156,22 +189,37 @@
"filament_internal": "Internal",
"filament_external": "External",
"price": "Price",
"color": "Color",
"material": "Material",
"weight_to_use": "Weight",
"used_weight": "Used Weight",
"remaining_weight": "Remaining Weight",
"remaining_value": "Remaining Value",
"measured_weight": "Measured Weight",
"used_length": "Used Length",
"remaining_length": "Remaining Length",
"initial_weight": "Initial Weight",
"spool_weight": "Empty Weight",
"extra_weight": "Extra Weight",
"color_hex": "Color Override",
"location": "Location",
"lot_nr": "Lot Nr",
"first_used": "First Used",
"last_used": "Last Used",
"registered": "Registered",
"comment": "Comment",
"archived": "Archived"
"archived": "Archived",
"filament_fields": "Filament Custom Fields",
"adjustment_history": "Adjustment History",
"show_history": "Show History",
"print_history": "Print History",
"show_print_history": "Show Print History",
"adjustment_timestamp": "Date/Time",
"adjustment_type": "Type",
"adjustment_type_weight": "Weight",
"adjustment_type_length": "Length",
"adjustment_value": "Amount",
"adjustment_comment": "Comment"
},
"fields_help": {
"price": "Price of a full spool. If not set, the price of the filament will be assumed instead.",
@@ -181,6 +229,8 @@
"measured_weight": "How much the filament and spool weigh.",
"initial_weight": "The initial weight of filament on the spool (net weight). Will use the weight from the filament object if not set.",
"spool_weight": "The weight of the spool when it is empty. Leave empty to use the value from the filament or the manufacturer.",
"extra_weight": "Additional weight to account for when measuring, such as DryPods, custom spool holders, or other accessories.",
"color_hex": "Override the filament color for this specific spool.",
"location": "Where the spool is located if you have multiple locations where you store your spools.",
"lot_nr": "Manufacturer's lot number. Can be used to ensure a print has consistent color if multiple spools are used.",
"external_filament": "You have selected a filament from the external database. A filament object (and possibly a manufacturer object) will be created automatically when you create this spool. This can create duplicate filament objects if you have already created a filament object for this filament."
@@ -213,6 +263,40 @@
"last_used": "Last used {{date}}"
}
},
"location": {
"location": "Location",
"locations": "Locations",
"create": "Create Location",
"titles": {
"create": "Create Location",
"edit": "Edit Location",
"list": "Locations"
},
"fields": {
"name": "Name",
"name_required": "Location name is required",
"name_placeholder": "e.g., Shelf A",
"parent": "Parent Location",
"parent_placeholder": "Select parent (optional)",
"location_type": "Type",
"location_type_placeholder": "Select type (optional)",
"description": "Description",
"spool_count": "Spools",
"full_path": "Full Path"
},
"types": {
"room": "Room",
"shelf": "Shelf",
"bin": "Bin",
"drawer": "Drawer",
"box": "Box",
"other": "Other"
},
"messages": {
"delete_confirm": "Are you sure you want to delete this location?",
"has_children": "This location has child locations"
}
},
"filament": {
"filament": "Filaments",
"fields": {
@@ -231,18 +315,24 @@
"comment": "Comment",
"settings_extruder_temp": "Extruder Temp",
"settings_bed_temp": "Bed Temp",
"temp_min": "Min",
"temp_max": "Max",
"color_hex": "Color",
"single_color": "Single",
"multi_color": "Multi",
"coaxial": "Coextruded",
"longitudinal": "Longitudinal",
"external_id": "External ID",
"spools": "Show Spools"
"spools": "Show Spools",
"not_set": "Not Set",
"spool_count": "Spools",
"total_remaining_weight": "Total Remaining"
},
"fields_help": {
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",
"material": "E.g. PLA, ABS, PETG, etc.",
"price": "Price of a full spool.",
"density": "Auto-filled based on material. Standard: PLA 1.24, ABS 1.04, PETG 1.27 g/cm³.",
"weight": "The filament weight of a full spool (net weight). This should not include the weight of the spool itself, only the filament. It is what is usually written on the packaging.",
"spool_weight": "The weight of an empty spool. Used to determine measured weight of a spool.",
"article_number": "E.g. EAN, UPC, etc.",
@@ -259,10 +349,27 @@
"form": {
"filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
"import_external": "Import from External",
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary."
"import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.<br><br>A Manufacturer object will be created automatically when you click Ok, if necessary.",
"all_sources": "All Sources",
"select_filament": "Select a filament...",
"created_success": "Created filament \"{{name}}\"",
"vendor_placeholder": "Select a manufacturer",
"name_placeholder": "E.g. Matte Black",
"material_placeholder": "PLA, PETG, ABS, etc.",
"parse_temps": "Parse temps from comment",
"temps_parsed": "Temperature fields updated from comment",
"no_temps_found": "No temperature patterns found in comment"
},
"buttons": {
"add_spool": "Add Spool"
},
"color_lookup": {
"button": "Find color from filamentcolors.xyz",
"title": "Find Color - filamentcolors.xyz",
"manufacturer": "Manufacturer",
"color_search": "Search color name...",
"results": "results",
"showing_page": "page {{page}}"
}
},
"vendor": {
@@ -287,13 +394,59 @@
"show_title": "[Manufacturer #{{id}}] {{name}}"
},
"form": {
"vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!"
"vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
"created_success": "Created manufacturer \"{{name}}\"",
"name_required": "Name is required"
}
},
"print_job": {
"fields": {
"created": "Created",
"filename": "Filename",
"filament_used": "Filament Used",
"status": "Status",
"finished": "Finished"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
}
},
"home": {
"home": "Home",
"welcome": "Welcome to your Spoolman instance!",
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started."
"description": "It looks like you haven't added any spools yet. See the <helpPageLink>Help page</helpPageLink> for help getting started.",
"all_spools": "All Spools",
"low_stock_alert": "{{count}} spools below {{threshold}}g",
"low_stock_warning": "Low stock",
"alerts": {
"low_stock": "Low Stock",
"pending_jobs": "Pending Jobs",
"needs_weighing": "Needs Weighing"
},
"charts": {
"material_distribution": "Material Distribution"
},
"quick_stats": {
"total_spools": "Total Spools",
"total_filaments": "Filament Types",
"total_vendors": "Vendors",
"total_weight": "Total Remaining",
"total_value": "Inventory Value",
"total_spent": "Total Spent",
"avg_cost_per_kg": "Avg Cost/kg"
},
"analytics": {
"title": "Usage Analytics",
"7_days": "7 Days",
"30_days": "30 Days",
"90_days": "90 Days",
"total_used": "Total used: {{weight}} kg",
"used": "Used",
"spent": "Spent",
"by_material": "Usage by Material"
}
},
"help": {
"help": "Help",
@@ -322,8 +475,48 @@
"round_prices": {
"label": "Round prices",
"tooltip": "Round prices to the nearest whole number."
},
"theme_color": {
"label": "Theme Color",
"orange": "Orange",
"teal": "Teal",
"red": "Red",
"green": "Green",
"blue": "Blue"
}
},
"backup": {
"title": "Backup & Restore",
"description": "Export all your data for backup or migrate to a different database.",
"export_title": "Export Data",
"export_button": "Download Backup",
"export_success": "Backup downloaded successfully",
"export_error": "Failed to export data",
"import_title": "Import Data",
"select_file": "Select backup file",
"import_merge": "Import (Merge)",
"import_replace": "Import (Replace All)",
"import_success": "Data imported successfully",
"import_error": "Failed to import data",
"replace_warning_title": "Replace all data?",
"replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!"
},
"auth": {
"title": "Authentication",
"description": "Require users to log in to access Spoolman. Once enabled, you'll be redirected to the login page.",
"status_enabled": "Authentication is enabled",
"status_disabled": "Authentication is disabled",
"setup_title": "Enable Authentication",
"setup_description": "Create an admin account to enable authentication. This user will have full access to manage Spoolman.",
"enable_button": "Create Admin & Enable",
"disable_title": "Disable Authentication",
"disable_description": "Enter an admin password to confirm disabling authentication. Anyone will be able to access Spoolman without logging in.",
"disable_button": "Disable Authentication",
"admin_password": "Admin Password",
"password_required": "Password is required",
"enabled_success": "Authentication enabled",
"disabled_success": "Authentication disabled"
},
"extra_fields": {
"tab": "Extra Fields",
"description": "<p>Here you can add extra custom fields to your entities.</p><p>Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",
@@ -354,6 +547,39 @@
"key_not_changed": "Please change the key to something else.",
"delete_confirm": "Delete field {{name}}?",
"delete_confirm_description": "This will delete the field and all associated data for all entities."
},
"users": {
"tab": "Users",
"title": "User Management",
"add": "Add User",
"edit": "Edit User",
"username": "Username",
"password": "Password",
"role": "Role",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"you": "You",
"created": "User created successfully",
"updated": "User updated successfully",
"deleted": "User deleted successfully",
"delete_confirm": "Are you sure you want to delete this user?",
"username_required": "Username is required",
"username_min": "Username must be at least 3 characters",
"password_required": "Password is required",
"password_min": "Password must be at least 6 characters",
"password_change_hint": "Leave blank to keep current password",
"leave_blank": "Leave blank to keep current",
"role_viewer": "Viewer - Can only view data",
"role_operator": "Operator - Can view and record usage",
"role_editor": "Editor - Can view, use, create, and edit",
"role_admin": "Admin - Full access including user management",
"auth_disabled": "Authentication is not enabled",
"auth_disabled_hint": "Enable authentication in Settings > General to manage users",
"admin_required": "Admin Access Required",
"admin_required_hint": "Only administrators can manage users",
"logout": "Log Out"
}
},
"documentTitle": {
@@ -395,5 +621,53 @@
"new_location": "New Location",
"no_location": "No Location",
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
},
"print-queue": {
"print-queue": "Print Queue"
},
"print_queue": {
"title": "Print Queue",
"pending_jobs": "Pending Jobs",
"needs_weighing": "Spools Needing Weigh-In",
"recent_history": "Recent History",
"no_pending_jobs": "No pending print jobs",
"no_history": "No completed or cancelled jobs yet",
"fields": {
"filename": "Filename",
"spool": "Spool",
"filament_used": "Filament Used",
"created": "Created",
"finished": "Finished",
"status": "Status"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
},
"stats": {
"pending": "Pending",
"needs_weighing": "Need Weigh-In",
"completed_today": "Completed Today",
"total_jobs": "Total Jobs"
},
"complete": "Complete",
"cancel": "Cancel",
"cancel_job": "Cancel Job",
"weigh_in": "Weigh In",
"clear_flag": "Clear Flag",
"clear_flag_tooltip": "Mark as weighed without updating weight",
"confirm_complete": "Complete Print Job?",
"confirm_complete_description": "This will deduct {{grams}}g from the spool.",
"confirm_cancel": "Cancel Print Job?",
"confirm_cancel_description": "The spool will be flagged for manual weigh-in since filament usage is unknown.",
"confirm_delete": "Delete Print Job?",
"confirm_delete_description": "This will permanently remove this job from history.",
"job_completed": "Print job completed",
"job_complete_error": "Failed to complete print job",
"job_cancelled": "Print job cancelled - spool flagged for weigh-in",
"job_cancel_error": "Failed to cancel print job",
"job_deleted": "Print job deleted",
"weighing_cleared": "Weigh-in flag cleared"
}
}

View File

@@ -1,22 +0,0 @@
{
"name": "Spoolman",
"short_name": "Spoolman",
"description": "Keep track of your inventory of 3D-printer filament spools.",
"icons": [
{
"purpose": "maskable",
"sizes": "512x512",
"src": "icon512_maskable.png",
"type": "image/png"
},
{
"purpose": "any",
"sizes": "512x512",
"src": "icon512_rounded.png",
"type": "image/png"
}
],
"background_color": "#1F1F1F",
"theme_color": "#DC7734",
"display": "standalone"
}

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
client/public/pwa-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

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,17 +100,11 @@ function App() {
return (
<BrowserRouter basename={getBasePath() + "/"}>
<RefineKbarProvider>
<ColorModeContextProvider>
<ConfigProvider
locale={antdLocale}
theme={{
token: {
colorPrimary: "#dc7734",
},
}}
>
<Refine
<AuthProvider>
<RefineKbarProvider>
<ColorModeContextProvider>
<ConfigProvider locale={antdLocale}>
<Refine
dataProvider={dataProvider(getAPIURL())}
notificationProvider={SpoolmanNotificationProvider}
i18nProvider={i18nProvider}
@@ -149,6 +155,14 @@ function App() {
icon: <UserOutlined />,
},
},
{
name: "print-queue",
list: "/print-queue",
meta: {
canDelete: false,
icon: <PrinterOutlined />,
},
},
{
name: "locations",
list: "/locations",
@@ -181,11 +195,17 @@ function App() {
}}
>
<Routes>
{/* Login route - outside protected area */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route
element={
<SpoolmanLayout>
<Outlet />
</SpoolmanLayout>
<ProtectedRoute>
<SpoolmanLayout>
<Outlet />
</SpoolmanLayout>
</ProtectedRoute>
}
>
<Route index element={<LoadablePage name="home" />} />
@@ -232,6 +252,7 @@ function App() {
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
<Route path="/help" element={<LoadablePage name="help" />} />
<Route path="/locations" element={<LoadablePage name="locations" />} />
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
@@ -242,9 +263,10 @@ function App() {
<ReactQueryDevtools />
<Favicon url={getBasePath() + "/favicon.svg"} />
</Refine>
</ConfigProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</ConfigProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</AuthProvider>
</BrowserRouter>
);
}

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

@@ -116,8 +116,8 @@ function Column<Obj extends Entity>(
if (open && props.onFilterDropdownOpen) {
props.onFilterDropdownOpen();
}
}
}
},
};
if (props.dataId) {
columnProps.key = props.dataId;
}
@@ -186,7 +186,7 @@ export function RichColumn<Obj extends Entity>(
}
interface FilteredQueryColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[]>;
filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[], unknown>;
allowMultipleFilters?: boolean;
}
@@ -220,6 +220,52 @@ export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColu
return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading });
}
interface ColorFilterColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
filterValueQuery: UseQueryResult<ColumnFilterItem[], unknown>;
colorGetter: (record: Obj) => string | undefined;
}
export function ColorFilterColumn<Obj extends Entity>(props: ColorFilterColumnProps<Obj>) {
const query = props.filterValueQuery;
let filters: ColumnFilterItem[] = [];
if (query.data) {
filters = query.data;
}
const typedFilters = typeFilters<Obj>(props.tableState.filters);
const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj));
const onFilterDropdownOpen = () => {
query.refetch();
};
return Column({
...props,
filters,
filteredValue,
onFilterDropdownOpen,
loadingFilters: query.isLoading,
allowMultipleFilters: true,
render: (_: unknown, record: Obj) => {
const colorHex = props.colorGetter(record);
if (!colorHex) return null;
return (
<div
style={{
width: "1.5em",
height: "1.5em",
backgroundColor: "#" + colorHex,
borderRadius: "3px",
border: "1px solid var(--swatch-border-color, rgba(68, 68, 68, 0.3))",
}}
title={"#" + colorHex}
/>
);
},
});
}
interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> {
unit: string;
maxDecimals?: number;
@@ -394,8 +440,9 @@ export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProp
const commonProps = {
...props,
id: ["extra", field.key],
dataId: `extra.${field.key}` as keyof Obj & string,
title: field.name,
sorter: false,
sorter: true,
transform: (value: unknown) => {
if (value === null || value === undefined) {
return undefined;

View File

@@ -19,7 +19,7 @@ const dataProvider = (
if (pagination && pagination.mode == "server") {
const pageSize = pagination.pageSize ?? 10;
const offset = ((pagination.current ?? 1) - 1) * pageSize;
const offset = ((pagination.currentPage ?? 1) - 1) * pageSize;
queryParams["limit"] = pageSize;
queryParams["offset"] = offset;
}

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

@@ -0,0 +1,238 @@
import { useSelect } from "@refinedev/antd";
import { useInvalidate, useTranslate } from "@refinedev/core";
import { AutoComplete, Button, ColorPicker, Divider, Form, Input, InputNumber, Modal, Select, message } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import { useMemo, useState } from "react";
import { Color } from "antd/es/color-picker";
import { IFilament } from "../pages/filaments/model";
import { IVendor } from "../pages/vendors/model";
import {
DEFAULT_DENSITY,
DEFAULT_DIAMETER,
getDensityForMaterial,
KNOWN_MATERIALS,
} from "../utils/materialDefaults";
import { getAPIURL } from "../utils/url";
import { VendorCreateModal } from "./vendorCreateModal";
export interface FilamentCreateModalProps {
isOpen: boolean;
onSuccess: (filament: IFilament) => void;
onClose: () => void;
}
/**
* Modal for quickly creating a filament inline without navigating away from the current page.
* Includes essential fields and the ability to create a vendor inline.
*/
export function FilamentCreateModal(props: FilamentCreateModalProps) {
const [form] = Form.useForm();
const t = useTranslate();
const invalidate = useInvalidate();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
const [materialSearch, setMaterialSearch] = useState("");
// Material options for AutoComplete
const materialOptions = useMemo(() => {
const filtered = materialSearch
? KNOWN_MATERIALS.filter((m) => m.toLowerCase().includes(materialSearch.toLowerCase()))
: KNOWN_MATERIALS;
return filtered.map((material) => ({ value: material, label: material }));
}, [materialSearch]);
// Auto-fill density when material is selected or entered
const handleMaterialChange = (value: string) => {
const density = getDensityForMaterial(value);
if (density !== undefined) {
form.setFieldValue("density", density);
}
};
// Vendor select
const { selectProps: vendorSelectProps } = useSelect<IVendor>({
resource: "vendor",
optionLabel: "name",
});
const handleVendorCreated = async (vendor: IVendor) => {
// Invalidate vendor cache so select options refresh
await invalidate({
resource: "vendor",
invalidates: ["list"],
});
form.setFieldValue("vendor_id", vendor.id);
setIsVendorModalOpen(false);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setIsSubmitting(true);
// Convert ColorPicker value to hex string if present
let colorHex: string | undefined = undefined;
if (values.color_hex) {
const color = values.color_hex as Color;
colorHex = typeof color === "string" ? color : color.toHexString();
}
const body = {
name: values.name || undefined,
vendor_id: values.vendor_id || undefined,
material: values.material || undefined,
density: values.density,
diameter: values.diameter,
weight: values.weight || undefined,
color_hex: colorHex,
};
const response = await fetch(getAPIURL() + "/filament", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to create filament");
}
const filament: IFilament = await response.json();
// Invalidate filament cache so lists refresh
await invalidate({
resource: "filament",
invalidates: ["list"],
});
message.success(t("filament.form.created_success", { name: filament.name || `#${filament.id}` }));
props.onSuccess(filament);
form.resetFields();
props.onClose();
} catch (error) {
message.error(error instanceof Error ? error.message : "Failed to create filament");
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
form.resetFields();
props.onClose();
};
// Set default values when modal opens
const handleAfterOpenChange = (open: boolean) => {
if (open) {
form.setFieldsValue({
diameter: DEFAULT_DIAMETER,
density: DEFAULT_DENSITY,
});
}
};
return (
<>
<VendorCreateModal
isOpen={isVendorModalOpen}
onSuccess={handleVendorCreated}
onClose={() => setIsVendorModalOpen(false)}
/>
<Modal
title={t("filament.titles.create")}
open={props.isOpen}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={isSubmitting}
afterOpenChange={handleAfterOpenChange}
destroyOnClose
width={500}
>
<Form form={form} layout="vertical">
{/* Vendor with inline create option */}
<Form.Item
label={t("filament.fields.vendor")}
name="vendor_id"
>
<Select
{...vendorSelectProps}
allowClear
placeholder={t("filament.form.vendor_placeholder")}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => setIsVendorModalOpen(true)}
style={{ width: "100%", textAlign: "left" }}
>
{t("vendor.titles.create")}
</Button>
</>
)}
/>
</Form.Item>
<Form.Item
label={t("filament.fields.name")}
name="name"
>
<Input maxLength={64} placeholder={t("filament.form.name_placeholder")} />
</Form.Item>
<Form.Item
label={t("filament.fields.material")}
name="material"
>
<AutoComplete
options={materialOptions}
onSearch={setMaterialSearch}
onSelect={handleMaterialChange}
onBlur={(e) => handleMaterialChange((e.target as HTMLInputElement).value)}
placeholder={t("filament.form.material_placeholder")}
maxLength={64}
/>
</Form.Item>
<Form.Item
label={t("filament.fields.color_hex")}
name="color_hex"
>
<ColorPicker format="hex" />
</Form.Item>
<Form.Item
label={t("filament.fields.density")}
name="density"
rules={[{ required: true, type: "number", min: 0.01, max: 100 }]}
help={t("filament.fields_help.density")}
>
<InputNumber addonAfter="g/cm³" precision={2} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label={t("filament.fields.diameter")}
name="diameter"
rules={[{ required: true, type: "number", min: 0.01, max: 10 }]}
>
<InputNumber addonAfter="mm" precision={2} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label={t("filament.fields.weight")}
name="weight"
help={t("filament.fields_help.weight")}
>
<InputNumber addonAfter="g" precision={0} min={0} style={{ width: "100%" }} />
</Form.Item>
</Form>
</Modal>
</>
);
}

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,5 +1,5 @@
import { DownOutlined } from "@ant-design/icons";
import type { RefineThemedLayoutV2HeaderProps } from "@refinedev/antd";
import type { RefineThemedLayoutHeaderProps } from "@refinedev/antd";
import { useGetLocale, useSetLocale } from "@refinedev/core";
import { Layout as AntdLayout, Button, Dropdown, MenuProps, Space, Switch, theme } from "antd";
import React, { useContext } from "react";
@@ -10,7 +10,7 @@ import QRCodeScannerModal from "../qrCodeScanner";
const { useToken } = theme;
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({ sticky }) => {
export const Header: React.FC<RefineThemedLayoutHeaderProps> = ({ sticky }) => {
const { token } = useToken();
const locale = useGetLocale();
const changeLanguage = useSetLocale();

View File

@@ -1,12 +1,15 @@
import { ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2 } from "@refinedev/antd";
import { ThemedLayout, ThemedSider, ThemedTitle } from "@refinedev/antd";
import { useTranslate } from "@refinedev/core";
import { Button } from "antd";
import { Badge, Button } from "antd";
import { Footer } from "antd/es/layout/layout";
import Logo from "../icon.svg?react";
import { getBasePath } from "../utils/url";
import { Header } from "./header";
import { Version } from "./version";
// DEV VERSION indicator
const IS_DEV = false;
const SpoolmanFooter: React.FC = () => {
const t = useTranslate();
@@ -50,17 +53,57 @@ interface SpoolmanLayoutProps {
children: React.ReactNode;
}
const DevTitle: React.FC<{ collapsed: boolean }> = ({ collapsed }) => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ThemedTitle collapsed={collapsed} text="Spoolman" icon={<Logo />} />
{IS_DEV && !collapsed && (
<Badge
count="DEV"
style={{
backgroundColor: "#0891b2",
fontSize: 10,
fontWeight: "bold",
marginLeft: -8,
marginTop: -20,
}}
/>
)}
</div>
);
export const SpoolmanLayout: React.FC<SpoolmanLayoutProps> = ({ children }) => (
<ThemedLayoutV2
<ThemedLayout
Header={() => <Header sticky />}
Sider={() => (
<ThemedSiderV2
<ThemedSider
fixed
Title={({ collapsed }) => <ThemedTitleV2 collapsed={collapsed} text="Spoolman" icon={<Logo />} />}
Title={({ collapsed }) => <DevTitle collapsed={collapsed} />}
/>
)}
Footer={() => <SpoolmanFooter />}
>
{children}
</ThemedLayoutV2>
{IS_DEV && (
<div
style={{
background: "linear-gradient(90deg, #0891b2 0%, #06b6d4 50%, #0891b2 100%)",
color: "white",
textAlign: "center",
padding: "4px 0",
fontSize: 12,
fontWeight: "bold",
letterSpacing: 1,
}}
>
🚀 DEV BUILD - Connected to Unraid PostgreSQL
</div>
)}
<div
style={{
maxWidth: 1400,
margin: "0 auto",
}}
>
{children}
</div>
</ThemedLayout>
);

View File

@@ -17,12 +17,12 @@ type Props = NumberFieldProps & {
*
* @see {@link https://refine.dev/docs/ui-frameworks/antd/components/fields/number} for more details.
*/
export const NumberFieldUnit: React.FC<Props> = ({ value, locale, options, ...rest }) => {
export const NumberFieldUnit: React.FC<Props> = ({ value, locale, options, unit }) => {
const number = Number(value);
return (
<Text {...rest}>
{toLocaleStringSupportsOptions() ? number.toLocaleString(locale, options) : number} {rest.unit}
<Text>
{toLocaleStringSupportsOptions() ? number.toLocaleString(locale, options) : number} {unit}
</Text>
);
};

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

@@ -1,6 +1,6 @@
import { CameraOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { QrScanner } from "@yudiel/react-qr-scanner";
import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner";
import { FloatButton, Modal, Space } from "antd";
import React, { useState } from "react";
import { useNavigate } from "react-router";
@@ -11,7 +11,12 @@ const QRCodeScannerModal: React.FC = () => {
const t = useTranslate();
const navigate = useNavigate();
const onScan = (result: string) => {
const onScan = (detectedCodes: IDetectedBarcode[]) => {
if (detectedCodes.length === 0) {
return;
}
const result = detectedCodes[0].rawValue;
// Check for the spoolman ID format
const match = result.match(/^web\+spoolman:s-(?<id>[0-9]+)$/);
if (match && match.groups) {
@@ -28,31 +33,17 @@ const QRCodeScannerModal: React.FC = () => {
return (
<>
<FloatButton type="primary" onClick={() => setVisible(true)} icon={<CameraOutlined />} shape="circle" />
<Modal open={visible} destroyOnClose onCancel={() => setVisible(false)} footer={null} title={t("scanner.title")}>
<Modal open={visible} destroyOnHidden onCancel={() => setVisible(false)} footer={null} title={t("scanner.title")}>
<Space direction="vertical" style={{ width: "100%" }}>
<p>{t("scanner.description")}</p>
<QrScanner
<Scanner
constraints={{
facingMode: "environment",
}}
viewFinder={
lastError
? () => (
<div
style={{
position: "absolute",
textAlign: "center",
width: "100%",
top: "50%",
}}
>
<p>{lastError}</p>
</div>
)
: undefined
}
onDecode={onScan}
onError={(error: Error) => {
onScan={onScan}
formats={["qr_code"]}
onError={(err: unknown) => {
const error = err as Error;
console.error(error);
if (error.name === "NotAllowedError") {
setLastError(t("scanner.error.notAllowed"));
@@ -71,7 +62,20 @@ const QRCodeScannerModal: React.FC = () => {
setLastError(t("scanner.error.unknown", { error: error.name }));
}
}}
/>
>
{lastError && (
<div
style={{
position: "absolute",
textAlign: "center",
width: "100%",
top: "50%",
}}
>
<p>{lastError}</p>
</div>
)}
</Scanner>
</Space>
</Modal>
</>

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,16 +1,21 @@
import { CopyOutlined } from "@ant-design/icons";
import { message, Tooltip } from "antd";
import { useTranslate } from "@refinedev/core";
import "./spoolIcon.css";
interface Props {
color: string | { colors: string[]; vertical: boolean };
size?: "small" | "large";
no_margin? : boolean
no_margin?: boolean;
showHex?: boolean;
}
export default function SpoolIcon(props: Readonly<Props>) {
const t = useTranslate();
let dirClass = "vertical";
let cols = [];
let size = props.size ? props.size : "small";
let no_margin = props.no_margin ? "no-margin" : "";
let cols: string[] = [];
const size = props.size ? props.size : "small";
const no_margin = props.no_margin ? "no-margin" : "";
if (typeof props.color === "string") {
cols = [props.color];
@@ -19,16 +24,49 @@ export default function SpoolIcon(props: Readonly<Props>) {
cols = props.color.colors;
}
return (
// Normalize hex codes (remove # if present, uppercase)
const normalizedCols = cols.map((col) => col.replace("#", "").toUpperCase());
const hexDisplay = normalizedCols.length === 1 ? `#${normalizedCols[0]}` : normalizedCols.map((c) => `#${c}`).join(", ");
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(hexDisplay);
message.success(t("color.copied", { color: hexDisplay }));
} catch {
message.error(t("color.copy_failed"));
}
};
const swatchContent = (
<div className={"spool-icon " + dirClass + " " + size + " " + no_margin}>
{cols.map((col) => (
{normalizedCols.map((col) => (
<div
key={col}
style={{
backgroundColor: "#" + col.replace("#", ""),
backgroundColor: "#" + col,
}}
></div>
))}
</div>
);
if (props.showHex) {
return (
<Tooltip title={`${t("color.click_to_copy")}: ${hexDisplay}`}>
<div
className="spool-icon-with-hex"
onClick={copyToClipboard}
style={{ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 }}
>
{swatchContent}
<span className="hex-code" style={{ fontFamily: "monospace", fontSize: "0.85em", opacity: 0.8 }}>
{hexDisplay}
<CopyOutlined style={{ marginLeft: 4, fontSize: "0.9em" }} />
</span>
</div>
</Tooltip>
);
}
return swatchContent;
}

View File

@@ -0,0 +1,98 @@
import { useTranslate } from "@refinedev/core";
import { Form, Input, InputNumber, Modal, message } from "antd";
import TextArea from "antd/es/input/TextArea";
import { useState } from "react";
import { IVendor } from "../pages/vendors/model";
import { getAPIURL } from "../utils/url";
export interface VendorCreateModalProps {
isOpen: boolean;
onSuccess: (vendor: IVendor) => void;
onClose: () => void;
}
/**
* Modal for quickly creating a vendor inline without navigating away from the current page.
* Only requires the vendor name - all other fields are optional.
*/
export function VendorCreateModal(props: VendorCreateModalProps) {
const [form] = Form.useForm();
const t = useTranslate();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setIsSubmitting(true);
const body: Omit<IVendor, "id" | "registered" | "extra"> = {
name: values.name,
comment: values.comment || undefined,
empty_spool_weight: values.empty_spool_weight || undefined,
};
const response = await fetch(getAPIURL() + "/vendor", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to create vendor");
}
const vendor: IVendor = await response.json();
message.success(t("vendor.form.created_success", { name: vendor.name }));
props.onSuccess(vendor);
form.resetFields();
props.onClose();
} catch (error) {
message.error(error instanceof Error ? error.message : "Failed to create vendor");
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
form.resetFields();
props.onClose();
};
return (
<Modal
title={t("vendor.titles.create")}
open={props.isOpen}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={isSubmitting}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
label={t("vendor.fields.name")}
name="name"
rules={[{ required: true, message: t("vendor.form.name_required") }]}
>
<Input maxLength={64} autoFocus />
</Form.Item>
<Form.Item
label={t("vendor.fields.comment")}
name="comment"
>
<TextArea maxLength={1024} rows={2} />
</Form.Item>
<Form.Item
label={t("vendor.fields.empty_spool_weight")}
name="empty_spool_weight"
help={t("vendor.fields_help.empty_spool_weight")}
>
<InputNumber min={0} addonAfter="g" precision={1} />
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -31,7 +31,7 @@ export const Version: React.FC = () => {
return <Spin />;
}
if (infoResult.isError) {
if (infoResult.isError || !infoResult.data) {
return <span>Unknown</span>;
}

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,25 +1,45 @@
import { RefineThemes } from "@refinedev/antd";
import { ConfigProvider, theme } from "antd";
import { createContext, PropsWithChildren, useEffect, useState } from "react";
// Theme color options
export const THEME_COLORS = {
orange: "#f97316",
teal: "#0891b2",
red: "#dc2626",
green: "#16a34a",
blue: "#2563eb",
} as const;
export type ThemeColorKey = keyof typeof THEME_COLORS;
type ColorModeContextType = {
mode: string;
setMode: (mode: string) => void;
themeColor: ThemeColorKey;
setThemeColor: (color: ThemeColorKey) => void;
};
export const ColorModeContext = createContext<ColorModeContextType>({} as ColorModeContextType);
export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children }) => {
const colorModeFromLocalStorage = localStorage.getItem("colorMode");
const themeColorFromLocalStorage = localStorage.getItem("themeColor") as ThemeColorKey | null;
const isSystemPreferenceDark = window?.matchMedia("(prefers-color-scheme: dark)").matches;
const systemPreference = isSystemPreferenceDark ? "dark" : "light";
const [mode, setMode] = useState(colorModeFromLocalStorage || systemPreference);
const [themeColor, setThemeColorState] = useState<ThemeColorKey>(themeColorFromLocalStorage || "teal");
useEffect(() => {
window.localStorage.setItem("colorMode", mode);
// Set data attribute on body for CSS targeting
document.body.setAttribute("data-theme", mode);
}, [mode]);
useEffect(() => {
window.localStorage.setItem("themeColor", themeColor);
}, [themeColor]);
const setColorMode = () => {
if (mode === "light") {
setMode("dark");
@@ -28,20 +48,29 @@ export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({ children
}
};
const setThemeColor = (color: ThemeColorKey) => {
setThemeColorState(color);
};
const { darkAlgorithm, defaultAlgorithm } = theme;
const primaryColor = THEME_COLORS[themeColor];
return (
<ColorModeContext.Provider
value={{
setMode: setColorMode,
mode,
themeColor,
setThemeColor,
}}
>
<ConfigProvider
// you can change the theme colors here. example: ...RefineThemes.Magenta,
theme={{
...RefineThemes.Yellow,
algorithm: mode === "light" ? defaultAlgorithm : darkAlgorithm,
token: {
colorPrimary: primaryColor,
colorInfo: primaryColor,
},
}}
>
{children}

View File

@@ -1,4 +1,4 @@
import '@ant-design/v5-patch-for-react-19';
import "@ant-design/v5-patch-for-react-19";
import React from "react";
import { createRoot } from "react-dom/client";

View File

@@ -1,17 +1,27 @@
import { Create, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useInvalidate, useTranslate } from "@refinedev/core";
import { Button, ColorPicker, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { AutoComplete, Button, ColorPicker, Divider, Form, Input, InputNumber, Radio, Select, Space, Typography } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { FilamentImportModal } from "../../components/filamentImportModal";
import { VendorCreateModal } from "../../components/vendorCreateModal";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
import {
DEFAULT_DENSITY,
DEFAULT_DIAMETER,
getDensityForMaterial,
KNOWN_MATERIALS,
} from "../../utils/materialDefaults";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { ExternalFilament } from "../../utils/queryExternalDB";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { getOrCreateVendorFromExternal } from "../vendors/functions";
import { IVendor } from "../vendors/model";
import { IFilament, IFilamentParsedExtras } from "./model";
@@ -31,8 +41,18 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
const extraFields = useGetFields(EntityType.filament);
const currency = useCurrency();
const [isImportExtOpen, setIsImportExtOpen] = useState(false);
const [isVendorModalOpen, setIsVendorModalOpen] = useState(false);
const invalidate = useInvalidate();
const [colorType, setColorType] = useState<"single" | "multi">("single");
const [materialSearch, setMaterialSearch] = useState("");
// Material options for AutoComplete
const materialOptions = useMemo(() => {
const filtered = materialSearch
? KNOWN_MATERIALS.filter((m) => m.toLowerCase().includes(materialSearch.toLowerCase()))
: KNOWN_MATERIALS;
return filtered.map((material) => ({ value: material, label: material }));
}, [materialSearch]);
const { form, formProps, formLoading, onFinish, redirect } = useForm<
IFilament,
@@ -53,6 +73,10 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
// Parse the extra fields from string values into real types
formProps.initialValues = ParsedExtras(formProps.initialValues);
} else {
// Set defaults for new filaments
formProps.initialValues.density = formProps.initialValues.density ?? DEFAULT_DENSITY;
formProps.initialValues.diameter = formProps.initialValues.diameter ?? DEFAULT_DIAMETER;
}
const handleSubmit = async (redirectTo: "list" | "create") => {
@@ -61,11 +85,35 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
redirect(redirectTo);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
const { selectProps: vendorSelect } = useSelect<IVendor>({
resource: "vendor",
optionLabel: "name",
});
const handleVendorCreated = async (vendor: IVendor) => {
// Invalidate vendor cache so select options refresh
await invalidate({
resource: "vendor",
invalidates: ["list"],
});
form.setFieldValue("vendor_id", vendor.id);
setIsVendorModalOpen(false);
};
// Auto-fill density when material is selected or entered
const handleMaterialChange = (value: string) => {
const density = getDensityForMaterial(value);
if (density !== undefined) {
form.setFieldValue("density", density);
}
};
const importFilament = async (filament: ExternalFilament) => {
const vendor = await getOrCreateVendorFromExternal(filament.manufacturer);
await invalidate({
@@ -86,8 +134,10 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
color_hex: filament.color_hex,
multi_color_hexes: filament.color_hexes?.join(",") || undefined,
multi_color_direction: filament.multi_color_direction,
settings_extruder_temp: filament.extruder_temp || undefined,
settings_bed_temp: filament.bed_temp || undefined,
settings_extruder_temp_min: filament.extruder_temp || undefined,
settings_extruder_temp_max: filament.extruder_temp_max || filament.extruder_temp || undefined,
settings_bed_temp_min: filament.bed_temp || undefined,
settings_bed_temp_max: filament.bed_temp_max || filament.bed_temp || undefined,
});
};
@@ -132,6 +182,11 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
}}
onClose={() => setIsImportExtOpen(false)}
/>
<VendorCreateModal
isOpen={isVendorModalOpen}
onSuccess={handleVendorCreated}
onClose={() => setIsVendorModalOpen(false)}
/>
<Form {...formProps} layout="vertical">
<Form.Item
label={t("filament.fields.name")}
@@ -165,6 +220,20 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
filterOption={(input, option) =>
typeof option?.label === "string" && option?.label.toLowerCase().includes(input.toLowerCase())
}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => setIsVendorModalOpen(true)}
style={{ width: "100%", textAlign: "left" }}
>
{t("vendor.titles.create")}
</Button>
</>
)}
/>
</Form.Item>
<Form.Item label={t("filament.fields.color_hex")}>
@@ -180,19 +249,22 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker format="hex" />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item
@@ -233,7 +305,14 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
},
]}
>
<Input maxLength={64} />
<AutoComplete
options={materialOptions}
onSearch={setMaterialSearch}
onSelect={handleMaterialChange}
onBlur={(e) => handleMaterialChange((e.target as HTMLInputElement).value)}
placeholder={t("filament.form.material_placeholder")}
maxLength={64}
/>
</Form.Item>
<Form.Item
label={t("filament.fields.price")}
@@ -310,31 +389,43 @@ export const FilamentCreate: React.FC<IResourceComponentsProps & CreateOrClonePr
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("filament.fields.settings_extruder_temp")}
name={["settings_extruder_temp"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="°C" precision={0} />
<Form.Item label={t("filament.fields.settings_extruder_temp")}>
<Space.Compact>
<Form.Item
name={["settings_extruder_temp_min"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
<Form.Item
name={["settings_extruder_temp_max"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item
label={t("filament.fields.settings_bed_temp")}
name={["settings_bed_temp"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="°C" precision={0} />
<Form.Item label={t("filament.fields.settings_bed_temp")}>
<Space.Compact>
<Form.Item
name={["settings_bed_temp_min"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
<Form.Item
name={["settings_bed_temp_max"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item
label={t("filament.fields.article_number")}

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

@@ -7,21 +7,21 @@ import utc from "dayjs/plugin/utc";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import {
ActionsColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
NumberColumn,
RichColumn,
SortedColumn,
SpoolIconColumn,
ActionsColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
NumberColumn,
RichColumn,
SortedColumn,
SpoolIconColumn,
} from "../../components/column";
import { useLiveify } from "../../components/liveify";
import {
useSpoolmanArticleNumbers,
useSpoolmanFilamentNames,
useSpoolmanMaterials,
useSpoolmanVendors,
useSpoolmanArticleNumbers,
useSpoolmanFilamentNames,
useSpoolmanMaterials,
useSpoolmanVendors,
} from "../../components/otherModels";
import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
"article_number",
"settings_extruder_temp",
"settings_bed_temp",
"spool_count",
"total_remaining_weight",
"registered",
"comment",
];
@@ -88,12 +90,12 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
// calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard.
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } =
const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } =
useTable<IFilamentCollapsed>({
syncWithLocation: false,
pagination: {
mode: "server",
current: initialState.pagination.current,
currentPage: initialState.pagination.currentPage,
pageSize: initialState.pagination.pageSize,
},
sorters: {
@@ -131,7 +133,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
const tableState: TableState = {
sorters,
filters,
pagination: { current, pageSize },
pagination: { currentPage: currentPage, pageSize },
showColumns,
};
useStoreInitialState(namespace, tableState);
@@ -175,7 +177,7 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
onClick={() => {
setFilters([], "replace");
setSorters([{ field: "id", order: "asc" }]);
setCurrent(1);
setCurrentPage(1);
}}
>
{t("buttons.clearFilters")}
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
width: 100,
}),
NumberColumn({
...commonProps,
id: "spool_count",
i18ncat: "filament",
unit: "",
maxDecimals: 0,
width: 80,
}),
NumberColumn({
...commonProps,
id: "total_remaining_weight",
i18ncat: "filament",
unit: "g",
maxDecimals: 0,
width: 120,
}),
DateColumn({
...commonProps,
id: "registered",

View File

@@ -15,11 +15,17 @@ export interface IFilament {
comment?: string;
settings_extruder_temp?: number;
settings_bed_temp?: number;
settings_extruder_temp_min?: number;
settings_extruder_temp_max?: number;
settings_bed_temp_min?: number;
settings_bed_temp_max?: number;
color_hex?: string;
multi_color_hexes?: string;
multi_color_direction?: string;
external_id?: string;
extra: { [key: string]: string };
spool_count?: number;
total_remaining_weight?: number;
}
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types

View File

@@ -21,10 +21,10 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
const navigate = useNavigate();
const extraFields = useGetFields(EntityType.filament);
const currencyFormatter = useCurrencyFormatter();
const { queryResult } = useShow<IFilament>({
const { query } = useShow<IFilament>({
liveMode: "auto",
});
const { data, isLoading } = queryResult;
const { data, isLoading } = query;
const record = data?.data;
@@ -88,8 +88,7 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("filament.fields.name")}</Title>
<TextField value={record?.name} />
<Title level={5}>{t("filament.fields.color_hex")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
{record?.color_hex && <TextField value={`#${record?.color_hex}`} />}
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
<Title level={5}>{t("filament.fields.material")}</Title>
<TextField value={record?.material} />
<Title level={5}>{t("filament.fields.price")}</Title>
@@ -131,16 +130,20 @@ export const FilamentShow: React.FC<IResourceComponentsProps> = () => {
}}
/>
<Title level={5}>{t("filament.fields.settings_extruder_temp")}</Title>
{!record?.settings_extruder_temp ? (
<TextField value="Not Set" />
{!record?.settings_extruder_temp_min && !record?.settings_extruder_temp_max ? (
<TextField value={t("filament.fields.not_set")} />
) : record?.settings_extruder_temp_min === record?.settings_extruder_temp_max || !record?.settings_extruder_temp_max ? (
<NumberFieldUnit value={record?.settings_extruder_temp_min ?? ""} unit="°C" />
) : (
<NumberFieldUnit value={record?.settings_extruder_temp ?? ""} unit="°C" />
<TextField value={`${record?.settings_extruder_temp_min ?? "?"} - ${record?.settings_extruder_temp_max ?? "?"} °C`} />
)}
<Title level={5}>{t("filament.fields.settings_bed_temp")}</Title>
{!record?.settings_bed_temp ? (
<TextField value="Not Set" />
{!record?.settings_bed_temp_min && !record?.settings_bed_temp_max ? (
<TextField value={t("filament.fields.not_set")} />
) : record?.settings_bed_temp_min === record?.settings_bed_temp_max || !record?.settings_bed_temp_max ? (
<NumberFieldUnit value={record?.settings_bed_temp_min ?? ""} unit="°C" />
) : (
<NumberFieldUnit value={record?.settings_bed_temp ?? ""} unit="°C" />
<TextField value={`${record?.settings_bed_temp_min ?? "?"} - ${record?.settings_bed_temp_max ?? "?"} °C`} />
)}
<Title level={5}>{t("filament.fields.article_number")}</Title>
<TextField value={record?.article_number} />

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 { Link } from "react-router";
import { Trans } from "react-i18next";
import Logo from "../../icon.svg?react";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router";
import SpoolIcon from "../../components/spoolIcon";
import { ISpool } from "../spools/model";
import { AlertCards } from "./components/AlertCards";
import { MaterialDistributionChart } from "./components/MaterialDistributionChart";
import { QuickStats } from "./components/QuickStats";
import { UsageAnalytics } from "./components/UsageAnalytics";
dayjs.extend(utc);
const { useToken } = theme;
const LOW_STOCK_THRESHOLD = 100; // grams
export const Home: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const navigate = useNavigate();
const spools = useList<ISpool>({
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
// Fetch all spools (not archived) for the main list
const { result: spoolsResult, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
pagination: { pageSize: 1 },
});
const filaments = useList<ISpool>({
resource: "filament",
pagination: { pageSize: 1 },
});
const vendors = useList<ISpool>({
resource: "vendor",
pagination: { pageSize: 1 },
pagination: { currentPage, pageSize },
sorters: [{ field: "last_used", order: "desc" }],
meta: {
queryParams: {
allow_archived: false,
},
},
});
const hasSpools = !spools.data || spools.data.data.length > 0;
const ResourceStatsCard = (props: { loading: boolean; value: number; resource: string; icon: ReactNode }) => (
<Col xs={12} md={6}>
<Card
loading={props.loading}
actions={[
<Link to={`/${props.resource}`}>
<UnorderedListOutlined />
</Link>,
<Link to={`/${props.resource}/create`}>
<PlusOutlined />
</Link>,
]}
>
<Statistic title={t(`${props.resource}.${props.resource}`)} value={props.value} prefix={props.icon} />
</Card>
</Col>
);
const spoolColumns = [
{
title: "",
dataIndex: "color",
key: "color",
width: 50,
render: (_: unknown, record: ISpool) => {
const colorObj = record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex;
return colorObj ? <SpoolIcon color={colorObj} /> : null;
},
},
{
title: t("spool.fields.filament_name"),
dataIndex: "filament",
key: "filament",
render: (_: unknown, record: ISpool) => {
const vendorName = record.filament.vendor?.name;
const filamentName = record.filament.name ?? `Filament #${record.filament.id}`;
return vendorName ? `${vendorName} - ${filamentName}` : filamentName;
},
},
{
title: t("spool.fields.material"),
dataIndex: ["filament", "material"],
key: "material",
width: 100,
},
{
title: t("spool.fields.remaining_weight"),
dataIndex: "remaining_weight",
key: "remaining_weight",
width: 120,
align: "right" as const,
render: (weight: number | undefined) => {
if (weight === undefined) return t("unknown");
const isLowStock = weight < LOW_STOCK_THRESHOLD;
return (
<span style={{ color: isLowStock ? token.colorError : undefined, fontWeight: isLowStock ? 600 : undefined }}>
{Math.round(weight)} g
{isLowStock && (
<Tooltip title={t("home.low_stock_warning")}>
<ExclamationCircleOutlined style={{ marginLeft: 6 }} />
</Tooltip>
)}
</span>
);
},
},
];
return (
<Content
style={{
padding: "2em 20px",
padding: "1.5em 20px",
minHeight: 280,
maxWidth: 800,
maxWidth: 900,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
color: token.colorText,
fontFamily: token.fontFamily,
fontSize: token.fontSizeLG,
lineHeight: 1.5,
}}
>
<Title
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: token.fontSizeHeading1,
}}
>
<div
style={{
display: "inline-block",
height: "1.5em",
marginRight: "0.5em",
}}
>
<Logo />
</div>
Spoolman
</Title>
<Row justify="center" gutter={[16, 16]} style={{ marginTop: "3em" }}>
<ResourceStatsCard
resource="spool"
value={spools.data?.total || 0}
loading={spools.isLoading}
icon={<FileOutlined />}
/>
<ResourceStatsCard
resource="filament"
value={filaments.data?.total || 0}
loading={filaments.isLoading}
icon={<HighlightOutlined />}
/>
<ResourceStatsCard
resource="vendor"
value={vendors.data?.total || 0}
loading={vendors.isLoading}
icon={<UserOutlined />}
/>
{/* Top section: Pie chart + stats/alerts side by side */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={24} md={10}>
<MaterialDistributionChart square />
</Col>
<Col xs={24} md={14}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Statistic
title={t("spool.spool")}
value={spoolsResult?.total ?? 0}
loading={spoolsQuery.isLoading}
valueStyle={{ fontSize: 36, fontWeight: 600 }}
/>
</Col>
<Col>
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/spool/create")}>
{t("spool.titles.create")}
</Button>
</Col>
</Row>
<QuickStats compact />
<AlertCards compact />
</Col>
</Row>
{!hasSpools && (
<>
<p style={{ marginTop: 32 }}>{t("home.welcome")}</p>
<p>
<Trans
i18nKey="home.description"
components={{
helpPageLink: <Link to="/help" />,
}}
/>
</p>
</>
)}
{/* Spool list */}
<Card
title={t("home.all_spools")}
extra={<Link to="/spool">{t("buttons.viewAll")}</Link>}
styles={{ body: { padding: 0 } }}
style={{ marginBottom: 16 }}
>
<Table<ISpool>
dataSource={spoolsResult?.data as ISpool[]}
columns={spoolColumns}
rowKey="id"
loading={spoolsQuery.isLoading}
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => navigate(`/spool/show/${record.id}`),
style: { cursor: "pointer" },
})}
/>
<div style={{ padding: "12px 16px", borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={spoolsResult?.total ?? 0}
onChange={setCurrentPage}
showSizeChanger={false}
size="small"
/>
</div>
</Card>
{/* Usage analytics & insights */}
<UsageAnalytics />
{/* Alerts */}
<AlertCards />
</Content>
);
};

View File

@@ -17,11 +17,7 @@ export function LocationContainer() {
const locationsSpoolOrders = useLocationsSpoolOrders();
const setLocationsSpoolOrders = useSetSetting<Record<string, number[]>>("locations_spoolorders");
const {
data: spoolData,
isLoading,
isError,
} = useList<ISpool>({
const { result: spoolData, query } = useList<ISpool>({
resource: "spool",
meta: {
queryParams: {
@@ -32,6 +28,8 @@ export function LocationContainer() {
mode: "off",
},
});
const isLoading = query.isLoading;
const isError = query.isError;
// Group spools by location
const spoolLocations = (() => {

View File

@@ -34,7 +34,9 @@ export function useRenameSpoolLocation() {
return await response.text();
},
onMutate: async (value) => {
await queryClient.cancelQueries(queryKeyList);
await queryClient.cancelQueries({
queryKey: queryKeyList
});
// Optimistically update all spools with matching location to the new one
queryClient.setQueriesData<GetListResponse<ISpool>>({ queryKey: queryKeyList }, (old) => {
@@ -71,7 +73,9 @@ export function useRenameSpoolLocation() {
},
onSuccess: (_data, _value) => {
// Mutation succeeded, refetch
queryClient.invalidateQueries(queryKey);
queryClient.invalidateQueries({
queryKey: queryKey
});
},
});
}

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

@@ -22,6 +22,9 @@ export interface QRCodePrintSettings {
showContent?: boolean;
showQRCodeMode?: "no" | "simple" | "withIcon";
textSize?: number;
qrCodePadding?: number; // in mm, default 2
qrCodeMaxWidth?: number; // percentage 0-100, 0 = no constraint (auto)
qrCodeSize?: number; // in mm, 0 = auto
printSettings: PrintSettings;
}
@@ -99,24 +102,45 @@ function applyTextFormatting(text: string): ReactElement[] {
export function renderLabelContents(template: string, spool: ISpool): ReactElement {
// Find all {tags} in the template string and loop over them
// let matches = [...template.matchAll(/(?:{(.*?))?{(.*?)}(.*?)(?:}(.*?))?/gs)];
let matches = [...template.matchAll(/{(?:[^}{]|{[^}{]*})*}/gs)];
let label_text = template;
matches.forEach((match) => {
if ((match[0].match(/{/g) || []).length == 1) {
const braceCount = (match[0].match(/{/g) || []).length;
if (braceCount == 1) {
// Simple tag: {tag}
let tag = match[0].replace(/[{}]/g, "");
let tagValue = getTagValue(tag, spool);
label_text = label_text.replace(match[0], tagValue);
} else if ((match[0].match(/{/g) || []).length == 2) {
let structure = match[0].match(/{(.*?){(.*?)}(.*?)}/);
if (structure != null) {
const tag = structure[2];
let tagValue = getTagValue(tag, spool);
if (tagValue == "?") {
label_text = label_text.replace(match[0], "");
} else {
label_text = label_text.replace(match[0], structure[1] + tagValue + structure[3]);
} else if (braceCount >= 2) {
// Conditional block with one or more inner tags: {prefix {tag1} middle {tag2} suffix}
// First, extract the outer content and find all inner tags
const outerContent = match[0].slice(1, -1); // Remove outer braces
const innerTagMatches = [...outerContent.matchAll(/{([^{}]+)}/g)];
if (innerTagMatches.length === 0) {
// No inner tags found, leave as-is
return;
}
// Check if any inner tag resolves to "?" (missing value)
let allTagsValid = true;
let processedContent = outerContent;
for (const innerMatch of innerTagMatches) {
const tag = innerMatch[1];
const tagValue = getTagValue(tag, spool);
if (tagValue === "?" || tagValue === null || tagValue === undefined) {
allTagsValid = false;
break;
}
processedContent = processedContent.replace(innerMatch[0], String(tagValue));
}
if (allTagsValid) {
label_text = label_text.replace(match[0], processedContent);
} else {
// If any tag is missing, remove the entire conditional block
label_text = label_text.replace(match[0], "");
}
}
});

View File

@@ -41,6 +41,9 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
const showContent = printSettings?.showContent === undefined ? true : printSettings?.showContent;
const showQRCodeMode = printSettings?.showQRCodeMode || "withIcon";
const textSize = printSettings?.textSize || 3;
const qrCodePadding = printSettings?.qrCodePadding ?? 2;
const qrCodeMaxWidth = printSettings?.qrCodeMaxWidth ?? 0; // 0 = auto (no constraint)
const qrCodeSize = printSettings?.qrCodeSize ?? 0; // 0 = auto
const elements = items.map((item) => {
return (
@@ -155,6 +158,107 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.qrcode.qrCodePadding")} tooltip={t("printing.qrcode.qrCodePaddingTooltip")}>
<Row>
<Col span={12}>
<Slider
disabled={showQRCodeMode === "no"}
tooltip={{ formatter: (value) => `${value} mm` }}
min={0}
max={5}
value={qrCodePadding}
step={0.5}
onChange={(value) => {
printSettings.qrCodePadding = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
disabled={showQRCodeMode === "no"}
min={0}
max={10}
step={0.5}
style={{ margin: "0 16px" }}
value={qrCodePadding}
addonAfter="mm"
onChange={(value) => {
printSettings.qrCodePadding = value ?? 2;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.qrcode.qrCodeMaxWidth")} tooltip={t("printing.qrcode.qrCodeMaxWidthTooltip")}>
<Row>
<Col span={12}>
<Slider
disabled={showQRCodeMode === "no"}
tooltip={{ formatter: (value) => (value === 0 ? t("printing.qrcode.auto") : `${value}%`) }}
min={0}
max={100}
value={qrCodeMaxWidth}
step={5}
onChange={(value) => {
printSettings.qrCodeMaxWidth = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
disabled={showQRCodeMode === "no"}
min={0}
max={100}
step={5}
style={{ margin: "0 16px" }}
value={qrCodeMaxWidth}
addonAfter="%"
placeholder={t("printing.qrcode.auto")}
onChange={(value) => {
printSettings.qrCodeMaxWidth = value ?? 0;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t("printing.qrcode.qrCodeSize")} tooltip={t("printing.qrcode.qrCodeSizeTooltip")}>
<Row>
<Col span={12}>
<Slider
disabled={showQRCodeMode === "no"}
tooltip={{ formatter: (value) => (value === 0 ? t("printing.qrcode.auto") : `${value} mm`) }}
min={0}
max={50}
value={qrCodeSize}
step={1}
onChange={(value) => {
printSettings.qrCodeSize = value;
setPrintSettings(printSettings);
}}
/>
</Col>
<Col span={12}>
<InputNumber
disabled={showQRCodeMode === "no"}
min={0}
max={100}
step={1}
style={{ margin: "0 16px" }}
value={qrCodeSize}
addonAfter="mm"
placeholder={t("printing.qrcode.auto")}
onChange={(value) => {
printSettings.qrCodeSize = value ?? 0;
setPrintSettings(printSettings);
}}
/>
</Col>
</Row>
</Form.Item>
{extraSettings}
</>
@@ -168,14 +272,15 @@ const QRCodePrintingDialog: React.FC<QRCodePrintingDialogProps> = ({
}
.print-page .print-qrcode-container {
max-width: ${showContent ? "50%" : "100%"};
max-width: ${qrCodeMaxWidth > 0 ? `${qrCodeMaxWidth}%` : "fit-content"};
display: flex;
flex-shrink: 0;
}
.print-page .print-qrcode {
width: auto !important;
height: auto !important;
padding: 2mm;
width: ${qrCodeSize > 0 ? `${qrCodeSize}mm` : "auto"} !important;
height: ${qrCodeSize > 0 ? `${qrCodeSize}mm` : "auto"} !important;
padding: ${qrCodePadding}mm;
}
.print-page .print-qrcode-title {

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
@@ -157,8 +157,8 @@ const SpoolQRCodePrintingDialog: React.FC<SpoolQRCodePrintingDialog> = ({ spoolI
`**{filament.vendor.name} - {filament.name}
#{id} - {filament.material}**
Spool Weight: {filament.spool_weight} g
{ET: {filament.settings_extruder_temp} °C}
{BT: {filament.settings_bed_temp} °C}
{ET: {filament.settings_extruder_temp_min}-{filament.settings_extruder_temp_max} °C}
{BT: {filament.settings_bed_temp_min}-{filament.settings_bed_temp_max} °C}
{Lot Nr: {lot_nr}}
{{comment}}
{filament.comment}
@@ -201,6 +201,10 @@ Spool Weight: {filament.spool_weight} g
{ tag: "filament.comment" },
{ tag: "filament.settings_extruder_temp" },
{ tag: "filament.settings_bed_temp" },
{ tag: "filament.settings_extruder_temp_min" },
{ tag: "filament.settings_extruder_temp_max" },
{ tag: "filament.settings_bed_temp_min" },
{ tag: "filament.settings_bed_temp_max" },
{ tag: "filament.color_hex" },
{ tag: "filament.multi_color_hexes" },
{ tag: "filament.multi_color_direction" },

View File

@@ -42,7 +42,7 @@ const SpoolSelectModal: React.FC<Props> = ({ description, onContinue }) => {
const [messageApi, contextHolder] = message.useMessage();
const navigate = useNavigate();
const { tableProps, sorters, filters, current, pageSize } = useTable<ISpoolCollapsed>({
const { tableProps, sorters, filters, currentPage, pageSize } = useTable<ISpoolCollapsed>({
resource: "spool",
meta: {
queryParams: {
@@ -52,7 +52,7 @@ const SpoolSelectModal: React.FC<Props> = ({ description, onContinue }) => {
syncWithLocation: false,
pagination: {
mode: "off",
current: 1,
currentPage: 1,
pageSize: 10,
},
sorters: {
@@ -75,7 +75,7 @@ const SpoolSelectModal: React.FC<Props> = ({ description, onContinue }) => {
const tableState: TableState = {
sorters,
filters,
pagination: { current, pageSize },
pagination: { currentPage: currentPage, pageSize },
};
// Collapse the dataSource to a mutable list and add a filament_name field

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.isLoading}>
<Button type="primary" htmlType="submit" loading={settings.isFetching || setCurrency.isPending}>
{t("buttons.save")}
</Button>
</Form.Item>
</Form>
<Divider />
<AuthSettings messageApi={messageApi} t={t} />
<Divider />
<BackupRestore messageApi={messageApi} t={t} />
<Divider />
<AboutSection />
{contextHolder}
</>
);
}
interface AuthSettingsProps {
messageApi: ReturnType<typeof message.useMessage>[0];
t: ReturnType<typeof useTranslate>;
}
function AuthSettings({ messageApi, t }: AuthSettingsProps) {
const { authStatus, refreshAuthStatus, setup } = useAuth();
const [toggling, setToggling] = useState(false);
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [setupForm] = Form.useForm();
const [disableForm] = Form.useForm();
const handleToggle = async (enabled: boolean) => {
if (enabled) {
// If enabling and no users exist, show setup modal
if (!authStatus?.has_users) {
setSetupModalOpen(true);
return;
}
// If users exist, just enable
await toggleAuth(true);
} else {
// Disabling - show password confirmation modal
setDisableModalOpen(true);
}
};
const toggleAuth = async (enabled: boolean, password?: string) => {
setToggling(true);
try {
const res = await fetch(`${getAPIURL()}/auth/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to toggle authentication");
}
await refreshAuthStatus();
messageApi.success(
enabled
? t("settings.auth.enabled_success")
: t("settings.auth.disabled_success")
);
if (enabled) {
// Redirect to login after enabling
window.location.href = "/login";
}
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Failed to toggle authentication");
} finally {
setToggling(false);
}
};
const handleSetup = async (values: { username: string; password: string }) => {
setToggling(true);
try {
// Create the first admin user
await setup(values.username, values.password);
// Enable auth
await toggleAuth(true);
setSetupModalOpen(false);
setupForm.resetFields();
} catch (err) {
messageApi.error(err instanceof Error ? err.message : "Setup failed");
} finally {
setToggling(false);
}
};
const handleDisable = async (values: { password: string }) => {
await toggleAuth(false, values.password);
setDisableModalOpen(false);
disableForm.resetFields();
};
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>
<LockOutlined style={{ marginRight: 8 }} />
{t("settings.auth.title")}
</h3>
<p style={{ marginBottom: "1em", color: "#888" }}>
{t("settings.auth.description")}
</p>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Switch
checked={authStatus?.enabled ?? false}
onChange={handleToggle}
loading={toggling}
/>
<span>
{authStatus?.enabled
? t("settings.auth.status_enabled")
: t("settings.auth.status_disabled")}
</span>
</div>
{/* Setup Modal - Create first admin user */}
<Modal
title={t("settings.auth.setup_title")}
open={setupModalOpen}
onCancel={() => {
setSetupModalOpen(false);
setupForm.resetFields();
}}
onOk={() => setupForm.submit()}
okText={t("settings.auth.enable_button")}
confirmLoading={toggling}
>
<p style={{ marginBottom: 16 }}>{t("settings.auth.setup_description")}</p>
<Form form={setupForm} layout="vertical" onFinish={handleSetup}>
<Form.Item
name="username"
label={t("settings.users.username")}
rules={[
{ required: true, message: t("settings.users.username_required") },
{ min: 3, message: t("settings.users.username_min") },
]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label={t("settings.users.password")}
rules={[
{ required: true, message: t("settings.users.password_required") },
{ min: 6, message: t("settings.users.password_min") },
]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
{/* Disable Modal - Require password confirmation */}
<Modal
title={t("settings.auth.disable_title")}
open={disableModalOpen}
onCancel={() => {
setDisableModalOpen(false);
disableForm.resetFields();
}}
onOk={() => disableForm.submit()}
okText={t("settings.auth.disable_button")}
okButtonProps={{ danger: true }}
confirmLoading={toggling}
>
<p style={{ marginBottom: 16 }}>{t("settings.auth.disable_description")}</p>
<Form form={disableForm} layout="vertical" onFinish={handleDisable}>
<Form.Item
name="password"
label={t("settings.auth.admin_password")}
rules={[{ required: true, message: t("settings.auth.password_required") }]}
>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
);
}
interface BackupRestoreProps {
messageApi: ReturnType<typeof message.useMessage>[0];
t: ReturnType<typeof useTranslate>;
}
function BackupRestore({ messageApi, t }: BackupRestoreProps) {
const [importing, setImporting] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const handleExport = async () => {
try {
const response = await fetch(`${getAPIURL()}/backup/export`);
if (!response.ok) throw new Error("Export failed");
const blob = await response.blob();
const filename = response.headers.get("content-disposition")?.match(/filename="(.+)"/)?.[1]
|| `spoolman_backup_${new Date().toISOString().slice(0, 10)}.json`;
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
messageApi.success(t("settings.backup.export_success"));
} catch (error) {
messageApi.error(t("settings.backup.export_error"));
}
};
const handleImport = async (replace: boolean) => {
if (fileList.length === 0) {
messageApi.warning(t("settings.backup.select_file"));
return;
}
setImporting(true);
try {
const formData = new FormData();
formData.append("file", fileList[0].originFileObj as Blob);
const response = await fetch(`${getAPIURL()}/backup/import?replace=${replace}`, {
method: "POST",
body: formData,
});
const result = await response.json();
if (result.success) {
messageApi.success(
`${t("settings.backup.import_success")}: ${result.vendors_imported} vendors, ${result.filaments_imported} filaments, ${result.spools_imported} spools`
);
setFileList([]);
// Reload the page to refresh all data
window.location.reload();
} else {
messageApi.error(`${t("settings.backup.import_error")}: ${result.errors.join(", ")}`);
}
} catch (error) {
messageApi.error(t("settings.backup.import_error"));
} finally {
setImporting(false);
}
};
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>{t("settings.backup.title")}</h3>
<p style={{ marginBottom: "1em", color: "#888" }}>
{t("settings.backup.description")}
</p>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div>
<h4>{t("settings.backup.export_title")}</h4>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{t("settings.backup.export_button")}
</Button>
</div>
<div>
<h4>{t("settings.backup.import_title")}</h4>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Upload
accept=".json"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList)}
>
<Button icon={<UploadOutlined />}>{t("settings.backup.select_file")}</Button>
</Upload>
<Space>
<Button
type="primary"
onClick={() => handleImport(false)}
loading={importing}
disabled={fileList.length === 0}
>
{t("settings.backup.import_merge")}
</Button>
<Popconfirm
title={t("settings.backup.replace_warning_title")}
description={t("settings.backup.replace_warning")}
onConfirm={() => handleImport(true)}
okText={t("buttons.yes")}
cancelText={t("buttons.no")}
>
<Button
danger
loading={importing}
disabled={fileList.length === 0}
>
{t("settings.backup.import_replace")}
</Button>
</Popconfirm>
</Space>
</Space>
</div>
</Space>
</div>
);
}
function AboutSection() {
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>About</h3>
<p style={{ color: "#888", lineHeight: 1.8 }}>
Based on{" "}
<a href="https://github.com/Donkie/Spoolman" target="_blank" rel="noopener noreferrer">
Spoolman
</a>{" "}
by{" "}
<a href="https://github.com/Donkie" target="_blank" rel="noopener noreferrer">
Donkie
</a>
<br />
UX enhancements developed with{" "}
<a href="https://claude.ai" target="_blank" rel="noopener noreferrer">
Claude
</a>{" "}
(Anthropic)
</p>
</div>
);
}

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,19 +1,22 @@
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Create, useForm } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useTranslate } from "@refinedev/core";
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import { Alert, Button, ColorPicker, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { FilamentCreateModal } from "../../components/filamentCreateModal";
import { useSpoolmanLocations } from "../../components/otherModels";
import { searchMatches } from "../../utils/filtering";
import "../../utils/overrides.css";
import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { getCurrencySymbol, useCurrency } from "../../utils/settings";
import { useFormShortcuts } from "../../utils/useFormShortcuts";
import { createFilamentFromExternal } from "../filaments/functions";
import { IFilament } from "../filaments/model";
import { useGetFilamentSelectOptions } from "./functions";
import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
@@ -31,6 +34,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
const currency = useCurrency();
const [isFilamentModalOpen, setIsFilamentModalOpen] = useState(false);
const { form, formProps, formLoading, onFinish, redirect } = useForm<
ISpool,
@@ -47,6 +51,7 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
const initialWeightValue = Form.useWatch("initial_weight", form);
const spoolWeightValue = Form.useWatch("spool_weight", form);
const extraWeightValue = Form.useWatch("extra_weight", form);
if (props.mode === "clone") {
// Clear out the values that we don't want to clone
@@ -78,8 +83,26 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
internalSelectOptions,
externalSelectOptions,
allExternalFilaments,
refetch: refetchFilaments,
} = useGetFilamentSelectOptions();
// Track pending filament selection after inline creation
const [pendingFilamentId, setPendingFilamentId] = useState<number | null>(null);
const handleFilamentCreated = async (filament: IFilament) => {
setPendingFilamentId(filament.id);
await refetchFilaments();
setIsFilamentModalOpen(false);
};
// Set the filament_id once the new filament appears in the options
useEffect(() => {
if (pendingFilamentId !== null && internalSelectOptions?.some((opt) => opt.value === pendingFilamentId)) {
form.setFieldValue("filament_id", pendingFilamentId);
setPendingFilamentId(null);
}
}, [pendingFilamentId, internalSelectOptions]);
const selectedFilamentID = Form.useWatch("filament_id", form);
const selectedFilament = useMemo(() => {
// id is a number of it's an internal filament, and a string of it's an external filament.
@@ -128,6 +151,12 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
redirect(redirectTo);
};
// Keyboard shortcut: Ctrl+S / Cmd+S to save
const handleSaveShortcut = useCallback(() => {
handleSubmit("list");
}, []);
useFormShortcuts(handleSaveShortcut);
// Use useEffect to update the form's initialValues when the extra fields are loaded
// This is necessary because the form is rendered before the extra fields are loaded
useEffect(() => {
@@ -189,10 +218,15 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -268,6 +302,11 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
</>
)}
>
<FilamentCreateModal
isOpen={isFilamentModalOpen}
onSuccess={handleFilamentCreated}
onClose={() => setIsFilamentModalOpen(false)}
/>
<Form {...formProps} layout="vertical">
<Form.Item
label={t("spool.fields.first_used")}
@@ -310,6 +349,20 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
options={filamentOptions}
showSearch
filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => setIsFilamentModalOpen(true)}
style={{ width: "100%", textAlign: "left" }}
>
{t("filament.titles.create")}
</Button>
</>
)}
/>
</Form.Item>
{selectedFilament?.is_internal === false && (
@@ -364,6 +417,30 @@ export const SpoolCreate: React.FC<IResourceComponentsProps & CreateOrCloneProps
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

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) {
@@ -131,8 +151,8 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
const [usedWeight, setUsedWeight] = useState(0);
useEffect(() => {
const newFilamentWeight = selectedFilament?.weight || 0;
const newSpoolWeight = selectedFilament?.spool_weight || 0;
const newFilamentWeight = getFilamentWeight();
const newSpoolWeight = getSpoolWeight();
if (newFilamentWeight > 0) {
form.setFieldValue("initial_weight", newFilamentWeight);
}
@@ -170,10 +190,15 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
return initialWeightValue ?? selectedFilament?.weight ?? 0;
};
const getExtraWeight = (): number => {
return extraWeightValue ?? 0;
};
const getGrossWeight = (): number => {
const net_weight = getFilamentWeight();
const spool_weight = getSpoolWeight();
return net_weight + spool_weight;
const extra_weight = getExtraWeight();
return net_weight + spool_weight + extra_weight;
};
const getMeasuredWeight = (): number => {
@@ -231,7 +256,28 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
}, [initialUsedWeight]);
return (
<Edit saveButtonProps={saveButtonProps}>
<Edit
saveButtonProps={saveButtonProps}
headerButtons={({ defaultButtons }) => (
<>
<Button
icon={<LeftOutlined />}
disabled={!prevId}
onClick={() => prevId && navigate(`/spool/edit/${prevId}`)}
>
{t("buttons.prev")}
</Button>
<Button
icon={<RightOutlined />}
disabled={!nextId}
onClick={() => nextId && navigate(`/spool/edit/${nextId}`)}
>
{t("buttons.next")}
</Button>
{defaultButtons}
</>
)}
>
{contextHolder}
<Form {...formProps} layout="vertical">
<Form.Item
@@ -354,6 +400,30 @@ export const SpoolEdit: React.FC<IResourceComponentsProps> = () => {
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.extra_weight")}
help={t("spool.fields_help.extra_weight")}
name={["extra_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("spool.fields.color_hex")}
help={t("spool.fields_help.color_hex")}
name={["color_hex"]}
getValueFromEvent={(e) => e?.toHex()}
>
<ColorPicker allowClear format="hex" />
</Form.Item>
<Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
<InputNumber value={usedWeight} />
</Form.Item>

View File

@@ -2,6 +2,7 @@ import { useSelect, useTranslate } from "@refinedev/core";
import { useQueries } from "@tanstack/react-query";
import { Form, InputNumber, Modal, Radio } from "antd";
import { useForm } from "antd/es/form/Form";
import type { InputNumberRef } from "rc-input-number";
import { useCallback, useMemo, useRef, useState } from "react";
import { formatLength, formatWeight } from "../../utils/parsing";
import { SpoolType, useGetExternalDBFilaments } from "../../utils/queryExternalDB";
@@ -23,6 +24,36 @@ export async function setSpoolArchived(spool: ISpool, archived: boolean) {
await fetch(request, init);
}
export async function bulkArchiveSpools(spoolIds: number[], archived: boolean) {
const response = await fetch(getAPIURL() + "/spool/bulk/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds, archived }),
});
if (!response.ok) throw new Error("Failed to archive spools");
return response.json();
}
export async function bulkDeleteSpools(spoolIds: number[]) {
const response = await fetch(getAPIURL() + "/spool/bulk/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds }),
});
if (!response.ok) throw new Error("Failed to delete spools");
return response.json();
}
export async function bulkUpdateSpools(spoolIds: number[], data: { location?: string }) {
const response = await fetch(getAPIURL() + "/spool/bulk/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spool_ids: spoolIds, ...data }),
});
if (!response.ok) throw new Error("Failed to update spools");
return response.json();
}
/**
* Use some spool filament from this spool. Either specify length or weight.
* @param spool The spool
@@ -63,7 +94,6 @@ export async function useSpoolFilamentMeasure(spool: ISpool, weight: number) {
await fetch(request, init);
}
/**
* Returns an array of queries using the useQueries hook from @tanstack/react-query.
* Each query fetches a spool by its ID from the server.
@@ -126,7 +156,7 @@ interface SelectOption {
export function useGetFilamentSelectOptions() {
// Setup hooks
const t = useTranslate();
const { queryResult: internalFilaments } = useSelect<IFilament>({
const { query: internalFilaments } = useSelect<IFilament>({
resource: "filament",
});
const externalFilaments = useGetExternalDBFilaments();
@@ -190,6 +220,7 @@ export function useGetFilamentSelectOptions() {
internalSelectOptions: filamentSelectInternal,
externalSelectOptions: filamentSelectExternal,
allExternalFilaments: externalFilaments.data,
refetch: internalFilaments.refetch,
};
}
@@ -201,7 +232,7 @@ export function useSpoolAdjustModal() {
const [curSpool, setCurSpool] = useState<ISpool | null>(null);
const [measurementType, setMeasurementType] = useState<MeasurementType>("length");
const inputNumberRef = useRef<HTMLInputElement | null>(null);
const inputNumberRef = useRef<InputNumberRef | null>(null);
const openSpoolAdjustModal = useCallback((spool: ISpool) => {
setCurSpool(spool);

View File

@@ -1,44 +1,50 @@
import {
EditOutlined,
EyeOutlined,
FilterOutlined,
InboxOutlined,
PlusSquareOutlined,
PrinterOutlined,
ToolOutlined,
ToTopOutlined,
AppstoreOutlined,
EditOutlined,
EyeOutlined,
FilterOutlined,
InboxOutlined,
PlusSquareOutlined,
PrinterOutlined,
TableOutlined,
ToolOutlined,
ToTopOutlined,
} from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Modal, Table } from "antd";
import { Button, Dropdown, Input, Modal, Table } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import {
Action,
ActionsColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
NumberColumn,
RichColumn,
SortedColumn,
SpoolIconColumn,
Action,
ActionsColumn,
ColorFilterColumn,
CustomFieldColumn,
DateColumn,
FilteredQueryColumn,
NumberColumn,
RichColumn,
SortedColumn,
SpoolIconColumn,
} from "../../components/column";
import { useLiveify } from "../../components/liveify";
import {
useSpoolmanFilamentFilter,
useSpoolmanLocations,
useSpoolmanLotNumbers,
useSpoolmanMaterials,
useSpoolmanColors,
useSpoolmanFilamentFilter,
useSpoolmanLocations,
useSpoolmanLotNumbers,
useSpoolmanMaterials,
} from "../../components/otherModels";
import { removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
import { useCurrencyFormatter } from "../../utils/settings";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { bulkArchiveSpools, bulkDeleteSpools, bulkUpdateSpools, setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model";
import { BatchActionBar } from "./components/BatchActionBar";
import { SpoolGallery } from "./components/SpoolGallery";
dayjs.extend(utc);
@@ -48,6 +54,7 @@ interface ISpoolCollapsed extends ISpool {
"filament.combined_name": string; // Eg. "Prusa - PLA Red"
"filament.id": number;
"filament.material"?: string;
"filament.color_hex"?: string;
}
function collapseSpool(element: ISpool): ISpoolCollapsed {
@@ -65,6 +72,7 @@ function collapseSpool(element: ISpool): ISpoolCollapsed {
"filament.combined_name": filament_name,
"filament.id": element.filament.id,
"filament.material": element.filament.material,
"filament.color_hex": element.color_hex ?? element.filament.color_hex,
};
}
@@ -72,6 +80,7 @@ function translateColumnI18nKey(columnName: string): string {
columnName = columnName.replace(".", "_");
if (columnName === "filament_combined_name") columnName = "filament_name";
else if (columnName === "filament_material") columnName = "material";
else if (columnName === "filament_color_hex") columnName = "color";
return `spool.fields.${columnName}`;
}
@@ -80,8 +89,10 @@ const namespace = "spoolList-v2";
const allColumns: (keyof ISpoolCollapsed & string)[] = [
"id",
"filament.combined_name",
"filament.color_hex",
"filament.material",
"price",
"remaining_value",
"used_weight",
"remaining_weight",
"used_length",
@@ -94,7 +105,8 @@ const allColumns: (keyof ISpoolCollapsed & string)[] = [
"comment",
];
const defaultColumns = allColumns.filter(
(column_id) => ["registered", "used_length", "remaining_length", "lot_nr"].indexOf(column_id) === -1
(column_id) =>
["registered", "used_length", "remaining_length", "lot_nr", "filament.color_hex", "remaining_value"].indexOf(column_id) === -1
);
export const SpoolList: React.FC<IResourceComponentsProps> = () => {
@@ -113,21 +125,29 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
// State for the switch to show archived spools
const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
// State for search and view mode
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<"table" | "gallery">("table");
// State for batch selection
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// Fetch data from the API
// To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
// This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
// calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard.
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } =
const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } =
useTable<ISpoolCollapsed>({
meta: {
queryParams: {
["allow_archived"]: showArchived,
...(searchQuery ? { q: searchQuery } : {}),
},
},
syncWithLocation: false,
pagination: {
mode: "server",
current: initialState.pagination.current,
currentPage: initialState.pagination.currentPage,
pageSize: initialState.pagination.pageSize,
},
sorters: {
@@ -165,7 +185,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
const tableState: TableState = {
sorters,
filters,
pagination: { current, pageSize },
pagination: { currentPage: currentPage, pageSize },
showColumns,
};
useStoreInitialState(namespace, tableState);
@@ -205,6 +225,58 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
}
};
// Batch action handlers
const handleBatchArchive = () => {
confirm({
title: t("batch.archive_confirm"),
content: t("batch.archive_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.archive"),
okType: "primary",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkArchiveSpools(selectedRowKeys as number[], true);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchUnarchive = async () => {
confirm({
title: t("batch.unarchive_confirm"),
content: t("batch.unarchive_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.unArchive"),
okType: "primary",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkArchiveSpools(selectedRowKeys as number[], false);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchDelete = () => {
confirm({
title: t("batch.delete_confirm"),
content: t("batch.delete_confirm_content", { count: selectedRowKeys.length }),
okText: t("buttons.delete"),
okType: "danger",
cancelText: t("buttons.cancel"),
async onOk() {
await bulkDeleteSpools(selectedRowKeys as number[]);
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
},
});
};
const handleBatchMove = async (location: string) => {
await bulkUpdateSpools(selectedRowKeys as number[], { location });
invalidate({ resource: "spool", invalidates: ["list"] });
setSelectedRowKeys([]);
};
if (tableProps.pagination) {
tableProps.pagination.showSizeChanger = true;
}
@@ -284,11 +356,17 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
onClick={() => {
setFilters([], "replace");
setSorters([{ field: "id", order: "asc" }]);
setCurrent(1);
setCurrentPage(1);
}}
>
{t("buttons.clearFilters")}
</Button>
<Button
icon={viewMode === "table" ? <AppstoreOutlined /> : <TableOutlined />}
onClick={() => setViewMode(viewMode === "table" ? "gallery" : "table")}
>
{viewMode === "table" ? t("buttons.galleryView") : t("buttons.tableView")}
</Button>
<Dropdown
trigger={["click"]}
menu={{
@@ -326,6 +404,18 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
)}
>
{spoolAdjustModal}
<Input.Search
placeholder={t("spool.search_placeholder")}
allowClear
onSearch={(value) => {
setSearchQuery(value);
setCurrentPage(1);
}}
style={{ marginBottom: 16, maxWidth: 400 }}
/>
{viewMode === "gallery" ? (
<SpoolGallery dataSource={dataSource} loading={tableProps.loading as boolean} />
) : (
<Table
{...tableProps}
sticky
@@ -333,6 +423,11 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
scroll={{ x: "max-content" }}
dataSource={dataSource}
rowKey="id"
rowSelection={{
selectedRowKeys,
onChange: (newSelectedRowKeys) => setSelectedRowKeys(newSelectedRowKeys),
preserveSelectedRowKeys: true,
}}
// Make archived rows greyed out
onRow={(record) => {
if (record.archived) {
@@ -358,15 +453,25 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
id: "filament.combined_name",
i18nkey: "spool.fields.filament_name",
color: (record: ISpoolCollapsed) =>
record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex,
record.color_hex
? record.color_hex
: record.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record.filament.color_hex,
dataId: "filament.combined_name",
filterValueQuery: useSpoolmanFilamentFilter(),
}),
ColorFilterColumn({
...commonProps,
id: "filament.color_hex",
i18nkey: "spool.fields.color",
filterValueQuery: useSpoolmanColors(),
colorGetter: (record: ISpoolCollapsed) => record.color_hex ?? record.filament.color_hex,
width: 60,
}),
FilteredQueryColumn({
...commonProps,
id: "filament.material",
@@ -387,6 +492,19 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
return currencyFormatter.format(obj.price);
},
}),
SortedColumn({
...commonProps,
id: "remaining_value",
i18ncat: "spool",
align: "right",
width: 100,
render: (_, obj: ISpoolCollapsed) => {
if (obj.remaining_value === undefined) {
return "";
}
return currencyFormatter.format(obj.remaining_value);
},
}),
NumberColumn({
...commonProps,
id: "used_weight",
@@ -395,6 +513,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "g",
maxDecimals: 0,
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -404,6 +523,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 110,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -412,6 +532,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
unit: "mm",
maxDecimals: 0,
width: 120,
sorter: true,
}),
NumberColumn({
...commonProps,
@@ -421,6 +542,7 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0,
defaultText: t("unknown"),
width: 120,
sorter: true,
}),
FilteredQueryColumn({
...commonProps,
@@ -466,6 +588,15 @@ export const SpoolList: React.FC<IResourceComponentsProps> = () => {
ActionsColumn(t("table.actions"), actions),
])}
/>
)}
<BatchActionBar
selectedIds={selectedRowKeys as number[]}
onArchive={handleBatchArchive}
onUnarchive={handleBatchUnarchive}
onDelete={handleBatchDelete}
onMove={handleBatchMove}
onClear={() => setSelectedRowKeys([])}
/>
</List>
);
};

View File

@@ -15,16 +15,41 @@ export interface ISpool {
price?: number;
initial_weight?: number;
spool_weight?: number;
extra_weight?: number;
color_hex?: string;
remaining_weight?: number;
used_weight: number;
remaining_length?: number;
remaining_value?: number;
used_length: number;
location?: string;
lot_nr?: string;
comment?: string;
archived: boolean;
needs_weighing: boolean;
extra: { [key: string]: string };
}
export interface IPrintJob {
id: number;
spool_id: number;
spool?: ISpool;
created: string;
finished?: string;
filename: string;
filament_used_g: number;
filament_used_mm?: number;
status: "pending" | "completed" | "cancelled";
}
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
export interface ISpoolAdjustment {
id: number;
spool_id: number;
timestamp: string;
adjustment_type: "weight" | "length";
value: number;
comment?: string;
}

View File

@@ -1,8 +1,8 @@
import { getBasePath } from "../../utils/url";
import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons";
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useShow, useTranslate } from "@refinedev/core";
import { Button, Modal, Typography } from "antd";
import { useQuery } from "@tanstack/react-query";
import { Button, Collapse, Modal, Table, Typography } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import React from "react";
@@ -12,9 +12,10 @@ import SpoolIcon from "../../components/spoolIcon";
import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useCurrencyFormatter } from "../../utils/settings";
import { getAPIURL, getBasePath } from "../../utils/url";
import { IFilament } from "../filaments/model";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model";
import { IPrintJob, ISpool, ISpoolAdjustment } from "./model";
dayjs.extend(utc);
@@ -24,16 +25,37 @@ const { confirm } = Modal;
export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const extraFields = useGetFields(EntityType.spool);
const filamentExtraFields = useGetFields(EntityType.filament);
const currencyFormatter = useCurrencyFormatter();
const invalidate = useInvalidate();
const { queryResult } = useShow<ISpool>({
const { query } = useShow<ISpool>({
liveMode: "auto",
});
const { data, isLoading } = queryResult;
const { data, isLoading } = query;
const record = data?.data;
// Fetch adjustment history
const { data: adjustmentsData, isLoading: adjustmentsLoading } = useQuery({
queryKey: ["spool-adjustments", record?.id],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/spool/${record?.id}/adjustments`);
return (await res.json()) as ISpoolAdjustment[];
},
enabled: !!record?.id,
});
// Fetch print job history
const { data: printJobsData, isLoading: printJobsLoading } = useQuery({
queryKey: ["spool-print-jobs", record?.id],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/print-job?spool_id=${record?.id}`);
return (await res.json()) as IPrintJob[];
},
enabled: !!record?.id,
});
const spoolPrice = (item?: ISpool) => {
const price = item?.price ?? item?.filament.price;
if (price === undefined) {
@@ -105,12 +127,14 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
});
};
const colorObj = record?.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record?.filament.color_hex;
const colorObj = record?.color_hex
? record.color_hex
: record?.filament.multi_color_hexes
? {
colors: record.filament.multi_color_hexes.split(","),
vertical: record.filament.multi_color_direction === "longitudinal",
}
: record?.filament.color_hex;
return (
<Show
@@ -118,17 +142,19 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
title={record ? formatTitle(record) : ""}
headerButtons={({ defaultButtons }) => (
<>
<Button
type="primary"
icon={<ToolOutlined />}
onClick={() => record && openSpoolAdjustModal(record)}
>
<Button type="primary" icon={<ToolOutlined />} onClick={() => record && openSpoolAdjustModal(record)}>
{t("spool.titles.adjust")}
</Button>
<Button
type="primary"
icon={<PrinterOutlined />}
href={getBasePath() + "/spool/print?spools=" + record?.id + "&return=" + encodeURIComponent(window.location.pathname)}
href={
getBasePath() +
"/spool/print?spools=" +
record?.id +
"&return=" +
encodeURIComponent(window.location.pathname)
}
>
{t("printing.qrcode.button")}
</Button>
@@ -150,7 +176,7 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
<Title level={5}>{t("spool.fields.id")}</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>{t("spool.fields.filament")}</Title>
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin />}
{colorObj && <SpoolIcon color={colorObj} size="large" no_margin showHex />}
<TextField value={record ? filamentURL(record?.filament) : ""} />
<Title level={5}>{t("spool.fields.price")}</Title>
<TextField value={spoolPrice(record)} />
@@ -222,6 +248,130 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))}
{filamentExtraFields?.data && filamentExtraFields.data.length > 0 && (
<>
<Title level={4}>{t("spool.fields.filament_fields")}</Title>
{filamentExtraFields.data.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.filament.extra[field.key]} />
))}
</>
)}
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.adjustment_history")}
</Title>
<Collapse
items={[
{
key: "1",
label: t("spool.fields.show_history"),
children: (
<Table<ISpoolAdjustment>
dataSource={adjustmentsData}
loading={adjustmentsLoading}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{
title: t("spool.fields.adjustment_timestamp"),
dataIndex: "timestamp",
key: "timestamp",
render: (value: string) => (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
),
},
{
title: t("spool.fields.adjustment_type"),
dataIndex: "adjustment_type",
key: "adjustment_type",
render: (value: string) => t(`spool.fields.adjustment_type_${value}`),
},
{
title: t("spool.fields.adjustment_value"),
dataIndex: "value",
key: "value",
render: (value: number, record: ISpoolAdjustment) => {
const unit = record.adjustment_type === "weight" ? "g" : "mm";
const formatted = value.toFixed(2);
return `${value > 0 ? "+" : ""}${formatted} ${unit}`;
},
},
{
title: t("spool.fields.adjustment_comment"),
dataIndex: "comment",
key: "comment",
},
]}
/>
),
},
]}
/>
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.print_history")}
</Title>
<Collapse
items={[
{
key: "1",
label: t("spool.fields.show_print_history"),
children: (
<Table<IPrintJob>
dataSource={printJobsData}
loading={printJobsLoading}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{
title: t("print_job.fields.created"),
dataIndex: "created",
key: "created",
render: (value: string) => (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
),
},
{
title: t("print_job.fields.filename"),
dataIndex: "filename",
key: "filename",
ellipsis: true,
},
{
title: t("print_job.fields.filament_used"),
dataIndex: "filament_used_g",
key: "filament_used_g",
render: (value: number) => `${value.toFixed(1)} g`,
},
{
title: t("print_job.fields.status"),
dataIndex: "status",
key: "status",
render: (value: string) => t(`print_job.status.${value}`),
},
{
title: t("print_job.fields.finished"),
dataIndex: "finished",
key: "finished",
render: (value: string | null) =>
value ? (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
) : (
"-"
),
},
]}
/>
),
},
]}
/>
</Show>
);
};

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

@@ -7,12 +7,12 @@ import utc from "dayjs/plugin/utc";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import {
ActionsColumn,
CustomFieldColumn,
DateColumn,
NumberColumn,
RichColumn,
SortedColumn,
ActionsColumn,
CustomFieldColumn,
DateColumn,
NumberColumn,
RichColumn,
SortedColumn,
} from "../../components/column";
import { useLiveify } from "../../components/liveify";
import { removeUndefined } from "../../utils/filtering";
@@ -38,32 +38,33 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
const initialState = useInitialTableState(namespace);
// Fetch data from the API
const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable<IVendor>({
syncWithLocation: false,
pagination: {
mode: "server",
current: initialState.pagination.current,
pageSize: initialState.pagination.pageSize,
},
sorters: {
mode: "server",
initial: initialState.sorters,
},
filters: {
mode: "server",
initial: initialState.filters,
},
liveMode: "manual",
onLiveEvent(event) {
if (event.type === "created" || event.type === "deleted") {
// updated is handled by the liveify
invalidate({
resource: "vendor",
invalidates: ["list"],
});
}
},
});
const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } =
useTable<IVendor>({
syncWithLocation: false,
pagination: {
mode: "server",
currentPage: initialState.pagination.currentPage,
pageSize: initialState.pagination.pageSize,
},
sorters: {
mode: "server",
initial: initialState.sorters,
},
filters: {
mode: "server",
initial: initialState.filters,
},
liveMode: "manual",
onLiveEvent(event) {
if (event.type === "created" || event.type === "deleted") {
// updated is handled by the liveify
invalidate({
resource: "vendor",
invalidates: ["list"],
});
}
},
});
// Create state for the columns to show
const [showColumns, setShowColumns] = useState<string[]>(initialState.showColumns ?? allColumns);
@@ -72,7 +73,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
const tableState: TableState = {
sorters,
filters,
pagination: { current, pageSize },
pagination: { currentPage, pageSize },
showColumns,
};
useStoreInitialState(namespace, tableState);
@@ -117,7 +118,7 @@ export const VendorList: React.FC<IResourceComponentsProps> = () => {
onClick={() => {
setFilters([], "replace");
setSorters([{ field: "id", order: "asc" }]);
setCurrent(1);
setCurrentPage(1);
}}
>
{t("buttons.clearFilters")}

View File

@@ -17,10 +17,10 @@ export const VendorShow: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const extraFields = useGetFields(EntityType.vendor);
const { queryResult } = useShow<IVendor>({
const { query } = useShow<IVendor>({
liveMode: "auto",
});
const { data, isLoading } = queryResult;
const { data, isLoading } = query;
const record = data?.data;

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

@@ -0,0 +1,90 @@
/**
* Standard material densities for common 3D printing filaments.
* Values are in g/cm³.
*
* Reference: https://www.simplify3d.com/resources/materials-guide/
*/
export const MATERIAL_DENSITY_MAP: Record<string, number> = {
PLA: 1.24,
ABS: 1.04,
PETG: 1.27,
NYLON: 1.52,
TPU: 1.21,
PC: 1.3,
WOOD: 1.28,
CF: 1.3,
"PC/ABS": 1.19,
HIPS: 1.03,
PVA: 1.23,
ASA: 1.05,
PP: 0.9,
POM: 1.4,
PMMA: 1.18,
FPE: 2.16,
};
/**
* Default filament diameter in mm (most common standard).
*/
export const DEFAULT_DIAMETER = 1.75;
/**
* Default density in g/cm³ (PLA, most common material).
*/
export const DEFAULT_DENSITY = 1.24;
/**
* List of known materials for AutoComplete suggestions.
*/
export const KNOWN_MATERIALS = Object.keys(MATERIAL_DENSITY_MAP);
/**
* Get the density for a material type using fuzzy matching.
* Handles variations like "PLA Silk", "PETG-CF", "Wood PLA", etc.
*
* @param material - The material string to look up
* @returns The density if a match is found, undefined otherwise
*/
export function getDensityForMaterial(material: string | undefined): number | undefined {
if (!material) return undefined;
const normalizedInput = material.toUpperCase().trim();
// Exact match first
if (MATERIAL_DENSITY_MAP[normalizedInput]) {
return MATERIAL_DENSITY_MAP[normalizedInput];
}
// Fuzzy match: check if input starts with or contains a known material
// Sort by length descending to match longer patterns first (e.g., "PC/ABS" before "PC")
const sortedMaterials = KNOWN_MATERIALS.sort((a, b) => b.length - a.length);
for (const knownMaterial of sortedMaterials) {
// Check if the input starts with the material (e.g., "PLA Silk" starts with "PLA")
if (normalizedInput.startsWith(knownMaterial)) {
return MATERIAL_DENSITY_MAP[knownMaterial];
}
// Check if the input contains the material as a word boundary
// This handles cases like "Wood PLA" or "CF-PETG"
const regex = new RegExp(`\\b${knownMaterial}\\b`, "i");
if (regex.test(normalizedInput)) {
return MATERIAL_DENSITY_MAP[knownMaterial];
}
}
return undefined;
}
/**
* Filter materials for AutoComplete based on input.
* Returns materials that contain the search string (case-insensitive).
*
* @param searchValue - The search string
* @returns Filtered list of material suggestions
*/
export function filterMaterials(searchValue: string): string[] {
if (!searchValue) return KNOWN_MATERIALS;
const normalizedSearch = searchValue.toUpperCase().trim();
return KNOWN_MATERIALS.filter((material) => material.includes(normalizedSearch));
}

View File

@@ -1,3 +1,29 @@
/* Color swatch border for dark mode visibility (#34) */
:root {
--swatch-border-color: rgba(68, 68, 68, 0.3);
}
body[data-theme="dark"] {
--swatch-border-color: rgba(255, 255, 255, 0.3);
}
#qty-input {
text-align: center !important;
}
/* Constrain form width for better readability on wide screens */
.ant-form {
max-width: 800px;
}
/* Sticky Save/Cancel buttons on forms (#38) */
.ant-card-actions {
position: sticky;
bottom: 0;
z-index: 100;
background: var(--ant-color-bg-container, #fff);
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
margin: 0 -24px -24px -24px;
padding: 12px 24px;
border-radius: 0 0 8px 8px;
}

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

@@ -66,7 +66,9 @@ export function useSetField(entity_type: EntityType) {
},
onMutate: async ({ key, params }) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(["fields", entity_type]);
await queryClient.cancelQueries({
queryKey: ["fields", entity_type]
});
// Snapshot the previous value
const previousFields = queryClient.getQueryData<Field[]>(["fields", entity_type]);
@@ -101,7 +103,9 @@ export function useSetField(entity_type: EntityType) {
},
onSettled: () => {
// Invalidate and refetch
queryClient.invalidateQueries(["fields", entity_type]);
queryClient.invalidateQueries({
queryKey: ["fields", entity_type]
});
},
});
}
@@ -124,7 +128,9 @@ export function useDeleteField(entity_type: EntityType) {
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries(["fields", entity_type]);
queryClient.invalidateQueries({
queryKey: ["fields", entity_type]
});
},
});
}

View File

@@ -52,7 +52,9 @@ export function useSetSetting<T>(key: string) {
return response.json();
},
onMutate: async (value) => {
await queryClient.cancelQueries(["settings", key]);
await queryClient.cancelQueries({
queryKey: ["settings", key]
});
const previousValue = queryClient.getQueryData<SettingResponseValue>(["settings", key]);
queryClient.setQueryData<SettingResponseValue>(["settings", key], (old) =>
old ? { ...old, value: JSON.stringify(value) } : undefined
@@ -64,7 +66,9 @@ export function useSetSetting<T>(key: string) {
},
onSuccess: (_data, _value) => {
// Invalidate and refetch
queryClient.invalidateQueries(["settings", key]);
queryClient.invalidateQueries({
queryKey: ["settings", key]
});
},
});
}

View File

@@ -2,7 +2,7 @@ import { CrudFilter, CrudSort } from "@refinedev/core";
import { useEffect, useState } from "react";
import { isLocalStorageAvailable } from "./support";
interface Pagination {
current: number;
currentPage: number;
pageSize: number;
}

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

View File

@@ -1,8 +1,55 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import svgr from "vite-plugin-svgr";
export default defineConfig({
base: "",
plugins: [react(), svgr()],
plugins: [
react(),
svgr(),
VitePWA({
registerType: "autoUpdate",
devOptions: {
enabled: true,
},
includeAssets: ["favicon.ico", "favicon.svg", "apple-touch-icon-180x180.png"],
manifest: {
name: "Spoolman",
short_name: "Spoolman",
description: "Keep track of your inventory of 3D-printer filament spools.",
icons: [
{
src: "pwa-64x64.png",
sizes: "64x64",
type: "image/png",
},
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "maskable-icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
background_color: "#1F1F1F",
theme_color: "#DC7734",
display: "standalone",
start_url: "/",
scope: "/",
},
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
},
}),
],
});

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

@@ -14,4 +14,4 @@ echo User GID: $(id -g app)
echo "Starting uvicorn..."
# Execute the uvicorn command with any additional arguments
exec su-exec "app" uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"
exec gosu "app" uvicorn spoolman.main:app --host $SPOOLMAN_HOST --port $SPOOLMAN_PORT "$@"

View File

@@ -0,0 +1,56 @@
"""add_temperature_ranges.
Revision ID: a1b2c3d4e5f6
Revises: 415a8f855e14
Create Date: 2025-01-15 02:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "415a8f855e14"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add temperature range columns and migrate existing data."""
# Add new columns for temperature ranges
op.add_column("filament", sa.Column("settings_extruder_temp_min", sa.Integer(), nullable=True))
op.add_column("filament", sa.Column("settings_extruder_temp_max", sa.Integer(), nullable=True))
op.add_column("filament", sa.Column("settings_bed_temp_min", sa.Integer(), nullable=True))
op.add_column("filament", sa.Column("settings_bed_temp_max", sa.Integer(), nullable=True))
# Migrate existing single temp values to both min and max
# This preserves the existing data while enabling the new range functionality
connection = op.get_bind()
connection.execute(
sa.text(
"""
UPDATE filament
SET settings_extruder_temp_min = settings_extruder_temp,
settings_extruder_temp_max = settings_extruder_temp
WHERE settings_extruder_temp IS NOT NULL
"""
)
)
connection.execute(
sa.text(
"""
UPDATE filament
SET settings_bed_temp_min = settings_bed_temp,
settings_bed_temp_max = settings_bed_temp
WHERE settings_bed_temp IS NOT NULL
"""
)
)
def downgrade() -> None:
"""Remove temperature range columns."""
op.drop_column("filament", "settings_bed_temp_max")
op.drop_column("filament", "settings_bed_temp_min")
op.drop_column("filament", "settings_extruder_temp_max")
op.drop_column("filament", "settings_extruder_temp_min")

View File

@@ -0,0 +1,43 @@
"""add_spool_adjustment_history.
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2025-01-15 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b2c3d4e5f6g7"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create spool_adjustment table for tracking history."""
op.create_table(
"spool_adjustment",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("spool_id", sa.Integer(), nullable=False),
sa.Column("timestamp", sa.DateTime(), nullable=False),
sa.Column("adjustment_type", sa.String(length=16), nullable=False),
sa.Column("value", sa.Float(), nullable=False),
sa.Column("comment", sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(
["spool_id"],
["spool.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_spool_adjustment_id"), "spool_adjustment", ["id"], unique=False)
op.create_index(op.f("ix_spool_adjustment_spool_id"), "spool_adjustment", ["spool_id"], unique=False)
def downgrade() -> None:
"""Drop spool_adjustment table."""
op.drop_index(op.f("ix_spool_adjustment_spool_id"), table_name="spool_adjustment")
op.drop_index(op.f("ix_spool_adjustment_id"), table_name="spool_adjustment")
op.drop_table("spool_adjustment")

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,8 +25,12 @@ dependencies = [
"prometheus-client~=0.21",
"httpx~=0.28",
"hishel~=0.1",
"python-multipart~=0.0",
"passlib>=1.7",
"bcrypt>=4.0",
"python-jose[cryptography]>=3.3",
]
requires-python = ">=3.9,<=3.12"
requires-python = ">=3.9,<3.13"
[project.license]
text = "MIT"

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

@@ -75,15 +75,39 @@ class FilamentParameters(BaseModel):
settings_extruder_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden extruder temperature, in °C.",
description="Overridden extruder temperature, in °C. Deprecated: use settings_extruder_temp_min/max.",
examples=[210],
)
settings_bed_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden bed temperature, in °C.",
description="Overridden bed temperature, in °C. Deprecated: use settings_bed_temp_min/max.",
examples=[60],
)
settings_extruder_temp_min: Optional[int] = Field(
None,
ge=0,
description="Minimum extruder temperature, in °C.",
examples=[190],
)
settings_extruder_temp_max: Optional[int] = Field(
None,
ge=0,
description="Maximum extruder temperature, in °C.",
examples=[220],
)
settings_bed_temp_min: Optional[int] = Field(
None,
ge=0,
description="Minimum bed temperature, in °C.",
examples=[50],
)
settings_bed_temp_max: Optional[int] = Field(
None,
ge=0,
description="Maximum bed temperature, in °C.",
examples=[70],
)
color_hex: Optional[str] = Field(
None,
description=(
@@ -342,7 +366,7 @@ async def find(
else:
filter_by_ids = None
db_items, total_count = await filament.find(
db_items, total_count, spool_stats = await filament.find(
db=db,
ids=filter_by_ids,
vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
@@ -359,7 +383,14 @@ async def find(
# Set x-total-count header for pagination
return JSONResponse(
content=jsonable_encoder(
(Filament.from_db(db_item) for db_item in db_items),
(
Filament.from_db(
db_item,
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
)
for db_item in db_items
),
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
@@ -454,6 +485,10 @@ async def create( # noqa: ANN201
comment=body.comment,
settings_extruder_temp=body.settings_extruder_temp,
settings_bed_temp=body.settings_bed_temp,
settings_extruder_temp_min=body.settings_extruder_temp_min,
settings_extruder_temp_max=body.settings_extruder_temp_max,
settings_bed_temp_min=body.settings_bed_temp_min,
settings_bed_temp_max=body.settings_bed_temp_max,
color_hex=body.color_hex,
multi_color_hexes=body.multi_color_hexes,
multi_color_direction=body.multi_color_direction,

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

@@ -149,15 +149,39 @@ class Filament(BaseModel):
settings_extruder_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden extruder temperature, in °C.",
description="Overridden extruder temperature, in °C. Deprecated: use settings_extruder_temp_min/max.",
examples=[210],
)
settings_bed_temp: Optional[int] = Field(
None,
ge=0,
description="Overridden bed temperature, in °C.",
description="Overridden bed temperature, in °C. Deprecated: use settings_bed_temp_min/max.",
examples=[60],
)
settings_extruder_temp_min: Optional[int] = Field(
None,
ge=0,
description="Minimum extruder temperature, in °C.",
examples=[190],
)
settings_extruder_temp_max: Optional[int] = Field(
None,
ge=0,
description="Maximum extruder temperature, in °C.",
examples=[220],
)
settings_bed_temp_min: Optional[int] = Field(
None,
ge=0,
description="Minimum bed temperature, in °C.",
examples=[50],
)
settings_bed_temp_max: Optional[int] = Field(
None,
ge=0,
description="Maximum bed temperature, in °C.",
examples=[70],
)
color_hex: Optional[str] = Field(
None,
min_length=6,
@@ -197,9 +221,25 @@ class Filament(BaseModel):
"Query the /fields endpoint for more details about the fields."
),
)
spool_count: Optional[int] = Field(
None,
ge=0,
description="Number of spools of this filament (excluding archived). Computed field.",
examples=[5],
)
total_remaining_weight: Optional[float] = Field(
None,
ge=0,
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
examples=[2500.0],
)
@staticmethod
def from_db(item: models.Filament) -> "Filament":
def from_db(
item: models.Filament,
spool_count: Optional[int] = None,
total_remaining_weight: Optional[float] = None,
) -> "Filament":
"""Create a new Pydantic filament object from a database filament object."""
return Filament(
id=item.id,
@@ -216,6 +256,10 @@ class Filament(BaseModel):
comment=item.comment,
settings_extruder_temp=item.settings_extruder_temp,
settings_bed_temp=item.settings_bed_temp,
settings_extruder_temp_min=item.settings_extruder_temp_min,
settings_extruder_temp_max=item.settings_extruder_temp_max,
settings_bed_temp_min=item.settings_bed_temp_min,
settings_bed_temp_max=item.settings_bed_temp_max,
color_hex=item.color_hex,
multi_color_hexes=item.multi_color_hexes,
multi_color_direction=(
@@ -223,6 +267,8 @@ class Filament(BaseModel):
),
external_id=item.external_id,
extra={field.key: field.value for field in item.extra},
spool_count=spool_count,
total_remaining_weight=total_remaining_weight,
)
@@ -265,6 +311,18 @@ class Spool(BaseModel):
description=("Weight of an empty spool (tare weight)."),
examples=[246],
)
extra_weight: Optional[float] = Field(
default=None,
ge=0,
description=("Extra weight to account for, such as DryPods, custom spool holders, etc."),
examples=[50],
)
color_hex: Optional[str] = Field(
default=None,
max_length=8,
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
examples=["FF0000"],
)
used_weight: float = Field(
ge=0,
description="Consumed weight of filament from the spool in grams.",
@@ -279,6 +337,12 @@ class Spool(BaseModel):
),
examples=[5612.4],
)
remaining_value: Optional[float] = Field(
default=None,
ge=0,
description="Estimated remaining value of filament on the spool based on remaining weight and price.",
examples=[12.50],
)
used_length: float = Field(
ge=0,
description="Consumed length of filament from the spool in millimeters.",
@@ -303,6 +367,10 @@ class Spool(BaseModel):
examples=[""],
)
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(
default=False,
description="Whether this spool needs manual weigh-in (e.g., after a cancelled print).",
)
extra: dict[str, str] = Field(
description=(
"Extra fields for this spool. All values are JSON-encoded data. "
@@ -319,14 +387,14 @@ class Spool(BaseModel):
remaining_length: Optional[float] = None
if item.initial_weight is not None:
remaining_weight = max(item.initial_weight - item.used_weight, 0)
remaining_weight = item.initial_weight - item.used_weight
remaining_length = length_from_weight(
weight=remaining_weight,
density=filament.density,
diameter=filament.diameter,
)
elif filament.weight is not None:
remaining_weight = max(filament.weight - item.used_weight, 0)
remaining_weight = filament.weight - item.used_weight
remaining_length = length_from_weight(
weight=remaining_weight,
density=filament.density,
@@ -339,6 +407,13 @@ class Spool(BaseModel):
diameter=filament.diameter,
)
# Calculate remaining value based on remaining weight and price
remaining_value: Optional[float] = None
price = item.price if item.price is not None else filament.price
initial_weight = item.initial_weight if item.initial_weight is not None else filament.weight
if remaining_weight is not None and price is not None and initial_weight is not None and initial_weight > 0:
remaining_value = round((remaining_weight / initial_weight) * price, 2)
return Spool(
id=item.id,
registered=item.registered,
@@ -348,18 +423,44 @@ class Spool(BaseModel):
price=item.price,
initial_weight=item.initial_weight,
spool_weight=item.spool_weight,
extra_weight=item.extra_weight,
used_weight=item.used_weight,
used_length=used_length,
remaining_weight=remaining_weight,
remaining_length=remaining_length,
remaining_value=remaining_value,
location=item.location,
lot_nr=item.lot_nr,
comment=item.comment,
archived=item.archived if item.archived is not None else False,
needs_weighing=item.needs_weighing if item.needs_weighing is not None else False,
extra={field.key: field.value for field in item.extra},
)
class SpoolAdjustment(BaseModel):
"""Record of a spool weight/length adjustment."""
id: int = Field(description="Unique internal ID of this adjustment.")
spool_id: int = Field(description="The spool this adjustment belongs to.")
timestamp: SpoolmanDateTime = Field(description="When the adjustment was made. UTC Timezone.")
adjustment_type: str = Field(description="Type of adjustment: 'weight' or 'length'.")
value: float = Field(description="Amount adjusted (negative = used, positive = added).")
comment: Optional[str] = Field(None, description="Optional user comment about this adjustment.")
@staticmethod
def from_db(item: models.SpoolAdjustment) -> "SpoolAdjustment":
"""Create a new Pydantic SpoolAdjustment object from a database object."""
return SpoolAdjustment(
id=item.id,
spool_id=item.spool_id,
timestamp=item.timestamp,
adjustment_type=item.adjustment_type,
value=item.value,
comment=item.comment,
)
class Info(BaseModel):
version: str = Field(examples=["0.7.0"])
debug_mode: bool = Field(examples=[False])
@@ -370,6 +471,7 @@ class Info(BaseModel):
db_type: str = Field(examples=["sqlite"])
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
auth_enabled: bool = Field(default=False, examples=[False])
class HealthCheck(BaseModel):

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)

Some files were not shown because too many files have changed in this diff Show More