Compare commits

..

1 Commits

Author SHA1 Message Date
Hex f94d807f0f CUB-135: Subscribe to SSE in React and update UI
Dev Build / build-test (pull_request) Successful in 2m26s
2026-05-09 18:09:56 -04:00
17 changed files with 8 additions and 1301 deletions
-214
View File
@@ -1,214 +0,0 @@
# Extrudex — Project Context
> **Last updated:** 2026-05-21
> **Repo:** `CubeCraft-Creations/Extrudex` | **Host:** `code.cubecraftcreations.com`
> **Local clone:** `/mnt/ai-storage/projects/Extrudex` | **Default branch:** `dev`
> **Discord:** `DISCORD_DEV_EXTRUDEX_CHANNEL_ID`
> **Linear Epic:** [CUB-110](https://linear.app/cubecraft-creations/issue/CUB-110)
---
## Overview
Custom filament inventory and print tracking system for CubeCraft Creations. Tracks spool consumption, material costs, and per-print COGS across a fleet of 6 FDM printers (5 Bambu Lab + 1 Elegoo Centauri Carbon) + resin printers. Kiosk interface on Raspberry Pi 5 with USB barcode scanner.
**Active refactor in progress:** .NET → Go + React (CUB-110 epic).
## Tech Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Backend (legacy) | ASP.NET Core 9 + EF Core | Being replaced by Go |
| Backend (new) | Go 1.24+ | Chi router, pgx (PostgreSQL), gorilla/websocket, paho.mqtt.golang |
| Frontend (legacy) | Angular 17+ | Being replaced by React |
| Frontend (new) | React 18 + TypeScript + Tailwind CSS | Vite |
| Database | PostgreSQL 16+ | snake_case naming |
| Real-time | SSE (Server-Sent Events) | Replaces SignalR |
| Printer integrations | Moonraker REST + WebSocket, MQTT TLS | Elegoo/Klipper + Bambu Lab |
| CI/CD | Gitea Actions | `.gitea/workflows/dev.yml` |
| Deployment | Docker/Compose, Pi 5 kiosk | |
## Key Design Decisions
1. **Custom system over Spoolman** — Full control over data model and integrations
2. **MaterialFinish required** on every spool; default is "Basic"
3. **MaterialModifier is optional**
4. **Grams derived from:** `mm_extruded × filament_cross_section_area × material_density`
5. **Push over poll** — SSE + MQTT preferred; Moonraker polling is fallback
6. **Snake_case** for all PostgreSQL identifiers
## Project Structure
```
Extrudex/
├── backend/ # Original .NET backend (being replaced)
│ ├── API/Controllers/ # Filaments, Spools, PrintJobs, Printers, Materials, QR, UsageLogs, Cost
│ ├── API/DTOs/ # DTOs per domain
│ ├── API/Validators/ # FluentValidation rules
│ ├── API/Hubs/ # SignalR PrinterHub
│ ├── API/Jobs/ # Background sync jobs
│ ├── Domain/Entities/ # EF Core entities (Printer, Spool, PrintJob, MaterialBase, etc.)
│ ├── Domain/Enums/ # ConnectionType, DataSource, JobStatus, PrinterStatus, QrResourceType
│ ├── Domain/Interfaces/ # Service interfaces
│ ├── Infrastructure/Data/ # EF DbContext, Configurations, Migrations, SeedData
│ ├── Infrastructure/Services/ # MoonrakerClient, CostPerPrint, UsageLog, FilamentUsage, QR
│ └── Program.cs # ASP.NET entry point
├── cmd/server/main.go # Go entrypoint (new), worker discovery, graceful shutdown
├── internal/ # Go backend (new)
│ ├── config/config.go # Env vars
│ ├── db/db.go # PostgreSQL connection pool (pgx)
│ ├── models/models.go # Go domain structs
│ ├── router/router.go # Chi HTTP routes
│ ├── sse/broadcaster.go # SSE broadcaster
│ ├── sse/events.go # SSE event types
│ ├── sse/handler.go # SSE HTTP handler
│ ├── handlers/ # API handlers (filament, health, material, print_job, printer, usage_log)
│ ├── repositories/ # DB access layer
│ └── services/ # Business logic + validation
├── migrations/ # Go-migrate SQL migrations
│ ├── 000001_initial_schema.up.sql
│ ├── 000001_initial_schema.down.sql
│ ├── 000002_seed_data.up.sql
│ └── 000002_seed_data.down.sql
├── frontend/ # React frontend (new)
│ ├── src/
│ │ ├── App.tsx
│ │ ├── main.tsx
│ │ ├── components/ # ColorSwatch, LoadingSpinner, RecentPrints, SummaryCard
│ │ ├── pages/
│ │ │ ├── Dashboard.tsx # Main dashboard with summary cards + recent prints
│ │ │ └── InventoryPage.tsx # Filament inventory list (table + filters)
│ │ ├── services/
│ │ │ ├── api.ts # Axios client
│ │ │ └── filamentService.ts
│ │ ├── stores/ # State stores (WIP)
│ │ ├── types/
│ │ │ ├── filament.ts
│ │ │ └── index.ts
│ │ └── hooks/
│ ├── Dockerfile + nginx.conf
│ └── vite.config.ts, tailwind.config.js
├── design/ # UX/design specs + mockup images
│ ├── homepage-spec.md
│ ├── 01-filament-inventory-list.md
│ ├── 02-spool-detail-view.md
│ ├── 03-smart-intake-workflow.md
│ ├── smart-intake-identify-spec.md
│ └── *.jpg / *.png # Mockup images (kiosk + mobile)
├── docker-compose.dev.yml
└── deploy.sh
```
## Database Schema (PostgreSQL — .NET EF Core Migrations)
Core entities managed by EF Core with full configurations:
- **Printers** — id, name, type (BambuLab/Moonraker), connection_type, status, ip_address, mac_address, ams_count
- **AmsUnit / AmsSlot** — Bambu Lab AMS tray tracking
- **Spools** — filament inventory with material, color, weight, location, QR support
- **MaterialBase / MaterialFinish / MaterialModifier** — material taxonomy
- **PrintJobs** — job tracking with status, cost, filament consumption
- **UsageLogs** — per-printer filament consumption records
- **FilamentUsage** — aggregated per-spool consumption tracking
- **FilamentUsageSyncJob** — background Moonraker polling
- **MoonrakerPrinterSyncJob** — background printer status sync
## API Endpoints (.NET)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/filaments | List filaments (paginated, filterable) |
| POST | /api/filaments | Create filament |
| GET | /api/filaments/{id} | Get filament detail |
| PUT | /api/filaments/{id} | Update filament |
| DELETE | /api/filaments/{id} | Delete filament |
| GET | /api/spools | List spools |
| POST | /api/spools | Create spool |
| GET | /api/spools/{id} | Get spool detail |
| PUT | /api/spools/{id} | Update spool |
| DELETE | /api/spools/{id} | Delete spool |
| GET | /api/print-jobs | List print jobs |
| POST | /api/print-jobs | Create print job |
| GET | /api/print-jobs/{id} | Get print job detail |
| GET | /api/printers | List printers |
| POST | /api/printers | Register printer |
| GET | /api/printers/{id} | Get printer detail |
| GET | /api/printers/{id}/costs | Per-print cost summary |
| GET | /api/printers/{id}/usage | Filament usage history |
| GET | /api/materials/bases | List material bases |
| POST | /api/materials/bases | Create material base |
| GET | /api/materials/finishes | List material finishes |
| POST | /api/materials/finishes | Create material finish |
| GET | /api/materials/modifiers | List material modifiers |
| POST | /api/materials/modifiers | Create modifier |
| GET | /api/materials/lookups | Unified material lookup |
| GET | /api/qr/resolve/{code} | QR code resolve |
| GET | /api/usage-logs | List usage logs |
| POST | /api/usage-logs | Create usage log |
## CUB-110 Epic Progress (Go + React Rewrite)
| Sub-task | Title | Status | Agent |
|----------|-------|--------|-------|
| CUB-111 | Design PostgreSQL schema | Done | Hex |
| CUB-112 | Scaffold Go backend | Done | Dex |
| CUB-113 | Core CRUD API endpoints | Done | Dex |
| CUB-116 | Scaffold React frontend | In Review | Rex |
| CUB-117 | Moonraker + MQTT integrations | In Progress | Dex |
| CUB-118 | (deprecated → CUB-135 + CUB-136) | — | — |
| CUB-130 | Add/Edit Filament form | In Review | Rex |
| CUB-131 | Filament Detail page | In Review | Rex |
| CUB-132 | Filament Inventory list | Done | Rex |
| CUB-133 | Dashboard summary cards | In Review | Rex |
| CUB-134 | Printers list page | Todo | Rex |
| CUB-135 | Subscribe to SSE in React | In Review | Rex |
| CUB-136 | Add SSE endpoint in Go | In Review | Dex |
| CUB-128 | Settings page | Todo | — |
| CUB-129 | Print Jobs list page | Todo | — |
| CUB-114 | Pi 5 kiosk deployment | Todo | — |
## Active Branches
| Branch | Agent | Issue | Status |
|--------|-------|-------|--------|
| `agent/dex/CUB-117-moonraker-mqtt-go` | Dex | CUB-117 | Local commit ready |
| `agent/dex/CUB-136-sse-endpoint` | Dex | CUB-136 | PR open |
| `agent/rex/CUB-130-filament-form` | Rex | CUB-130 | PR open |
| `agent/rex/CUB-131-filament-detail` | Rex | CUB-131 | PR open |
| `agent/rex/CUB-133-dashboard-cards` | Rex | CUB-133 | PR open |
| `agent/rex/CUB-135-sse-subscribe` | Rex | CUB-135 | PR open |
## Known Limitations
- Go rewrite is in progress — backend `internal/` is new Go code, but legacy .NET code still serves endpoints
- Kiosk deployment not finalized (CUB-114)
- Smart intake workflow is designed but not implemented
- MQTT Bambu Lab integration is partially complete (CUB-117)
## Default Agent Assignments
| Area | Agent | Notes |
|------|-------|-------|
| Backend (Go API, Moonraker, MQTT) | Dex | gitea-dex MCP |
| Database (PostgreSQL schema, migrations) | Hex | gitea-hex MCP |
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
| Design (wireframes, UX) | Sketch | |
## Getting Started
```bash
cd /mnt/ai-storage/projects/Extrudex
git checkout dev
git pull origin dev
# Legacy .NET backend
cd backend && dotnet run
# New Go backend
go run cmd/server/main.go
# React frontend
cd frontend && npm install && npm run dev
# Docker
docker compose -f docker-compose.dev.yml up --build
```
+3 -331
View File
@@ -25,11 +25,9 @@
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-react-refresh": "^0.4.14",
"globals": "^17.6.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "~5.6.0", "typescript": "~5.6.0",
"typescript-eslint": "^8.60.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
}, },
@@ -824,18 +822,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.4", "version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
@@ -1672,285 +1658,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
"integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.60.0",
"@typescript-eslint/type-utils": "8.60.0",
"@typescript-eslint/utils": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.60.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz",
"integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.60.0",
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/typescript-estree": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz",
"integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==",
"dev": true,
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.60.0",
"@typescript-eslint/types": "^8.60.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz",
"integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz",
"integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz",
"integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/typescript-estree": "8.60.0",
"@typescript-eslint/utils": "8.60.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz",
"integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz",
"integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==",
"dev": true,
"dependencies": {
"@typescript-eslint/project-service": "8.60.0",
"@typescript-eslint/tsconfig-utils": "8.60.0",
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/visitor-keys": "8.60.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz",
"integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.60.0",
"@typescript-eslint/types": "8.60.0",
"@typescript-eslint/typescript-estree": "8.60.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz",
"integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "8.60.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2821,9 +2528,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "17.6.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -3797,18 +3504,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3834,29 +3529,6 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz",
"integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==",
"dev": true,
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.60.0",
"@typescript-eslint/parser": "8.60.0",
"@typescript-eslint/typescript-estree": "8.60.0",
"@typescript-eslint/utils": "8.60.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
-2
View File
@@ -27,11 +27,9 @@
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-react-refresh": "^0.4.14",
"globals": "^17.6.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "~5.6.0", "typescript": "~5.6.0",
"typescript-eslint": "^8.60.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
} }
+1 -5
View File
@@ -1,8 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route } from 'react-router-dom' import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import InventoryPage from './pages/InventoryPage' import InventoryPage from './pages/InventoryPage'
import FilamentDetailPage from './pages/FilamentDetailPage'
import ToastContainer from './components/ToastContainer' import ToastContainer from './components/ToastContainer'
import { useSSE } from './hooks/useSSE' import { useSSE } from './hooks/useSSE'
@@ -25,9 +23,7 @@ export default function App() {
</header> </header>
<main className="p-4"> <main className="p-4">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<InventoryPage />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/filament/:id" element={<FilamentDetailPage />} />
</Routes> </Routes>
</main> </main>
</div> </div>
@@ -1,8 +0,0 @@
export default function LoadingSpinner() {
return (
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-emerald-500/30 border-t-emerald-400 rounded-full animate-spin" />
<p className="text-slate-400 text-sm">Loading dashboard</p>
</div>
)
}
-76
View File
@@ -1,76 +0,0 @@
import type { PrintJob } from '../types'
import { Clock, FileText } from 'lucide-react'
interface RecentPrintsProps {
jobs: PrintJob[]
}
export default function RecentPrints({ jobs }: RecentPrintsProps) {
if (jobs.length === 0) {
return (
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6 text-center text-slate-400">
<FileText size={32} className="mx-auto mb-3 text-slate-500" />
<p>No recent print jobs</p>
</div>
)
}
const statusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'completed': return 'text-emerald-400 bg-emerald-500/10'
case 'in_progress': return 'text-sky-400 bg-sky-500/10'
case 'failed': return 'text-red-400 bg-red-500/10'
default: return 'text-slate-400 bg-slate-500/10'
}
}
return (
<div className="rounded-xl border border-slate-700 bg-slate-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-700">
<th className="px-4 py-3 text-sm font-medium text-slate-400">Name</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Status</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Duration</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Filament</th>
<th className="px-4 py-3 text-sm font-medium text-slate-400">Cost</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id} className="border-b border-slate-700/50 last:border-0 hover:bg-slate-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-slate-100 font-medium">{job.name}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${statusColor(job.status)}`}>
{job.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-300">
<div className="flex items-center gap-1">
<Clock size={14} />
{formatDuration(job.duration_seconds)}
</div>
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{job.filament_used_grams?.toFixed(1) ?? '-'} g
</td>
<td className="px-4 py-3 text-sm text-slate-300">
${job.cost_usd?.toFixed(2) ?? '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function formatDuration(seconds: number): string {
if (!seconds) return '-'
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (hrs > 0) return `${hrs}h ${mins}m`
return `${mins}m`
}
-31
View File
@@ -1,31 +0,0 @@
import type { LucideIcon } from 'lucide-react'
interface SummaryCardProps {
title: string
value: string | number
icon: LucideIcon
color: 'emerald' | 'amber' | 'sky' | 'violet'
}
const colorMap = {
emerald: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/20', text: 'text-emerald-400', icon: 'text-emerald-400' },
amber: { bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400', icon: 'text-amber-400' },
sky: { bg: 'bg-sky-500/10', border: 'border-sky-500/20', text: 'text-sky-400', icon: 'text-sky-400' },
violet: { bg: 'bg-violet-500/10', border: 'border-violet-500/20', text: 'text-violet-400', icon: 'text-violet-400' },
}
export default function SummaryCard({ title, value, icon: Icon, color }: SummaryCardProps) {
const c = colorMap[color]
return (
<div className={`rounded-xl border ${c.border} ${c.bg} p-5 flex items-start justify-between`}>
<div>
<p className="text-sm font-medium text-slate-400 mb-1">{title}</p>
<p className={`text-3xl font-bold ${c.text}`}>{value}</p>
</div>
<div className={`p-2 rounded-lg ${c.bg}`}>
<Icon size={24} className={c.icon} />
</div>
</div>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export function useSSE(enabled = true) {
// Update printer list cache if it exists // Update printer list cache if it exists
queryClient.setQueryData(['printers'], (old: unknown) => { queryClient.setQueryData(['printers'], (old: unknown) => {
if (!Array.isArray(old)) return old if (!Array.isArray(old)) return old
return old.map((p: Record<string, unknown>) => return old.map((p: any) =>
p.id === payload.printer_id p.id === payload.printer_id
? { ...p, status: payload.status, name: payload.printer_name } ? { ...p, status: payload.status, name: payload.printer_name }
: p : p
-15
View File
@@ -1,25 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App' import App from './App'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App /> <App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>, </StrictMode>,
) )
-139
View File
@@ -1,139 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { getFilaments, getPrintJobs } from '../services/api'
import SummaryCard from '../components/SummaryCard'
import RecentPrints from '../components/RecentPrints'
import LoadingSpinner from '../components/LoadingSpinner'
import { Package, AlertTriangle, Printer, DollarSign, Plus, List } from 'lucide-react'
export default function Dashboard() {
const {
data: filamentData,
isLoading: filamentLoading,
error: filamentError,
} = useQuery({
queryKey: ['filaments', 'count'],
queryFn: () => getFilaments({ limit: 1 }),
})
const {
data: lowStockData,
isLoading: lowStockLoading,
error: lowStockError,
} = useQuery({
queryKey: ['filaments', 'lowStock'],
queryFn: () => getFilaments({ low_stock: true, limit: 1 }),
})
const {
data: recentPrints,
isLoading: printsLoading,
error: printsError,
} = useQuery({
queryKey: ['printJobs', 'recent'],
queryFn: () => getPrintJobs({ limit: 5 }),
})
const {
data: allPrints,
isLoading: costLoading,
error: costError,
} = useQuery({
queryKey: ['printJobs', 'all'],
queryFn: () => getPrintJobs({ limit: 1000 }),
})
const totalSpools = filamentData?.total ?? 0
const lowStockCount = lowStockData?.total ?? 0
const recentPrintJobs = recentPrints?.data ?? []
// Calculate cost this month from all print jobs
const now = new Date()
const currentMonth = now.getMonth()
const currentYear = now.getFullYear()
const monthlyCost =
allPrints?.data
.filter((job) => {
const d = new Date(job.started_at)
return d.getMonth() === currentMonth && d.getFullYear() === currentYear
})
.reduce((sum, job) => sum + (job.cost_usd ?? 0), 0) ?? 0
const isLoading = filamentLoading || lowStockLoading || printsLoading || costLoading
const error = filamentError || lowStockError || printsError || costError
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-900">
<LoadingSpinner />
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-900">
<div className="p-6 rounded-lg bg-red-900/30 border border-red-500 text-red-200 max-w-md">
<h2 className="text-xl font-bold mb-2">Error</h2>
<p className="text-sm">{(error as Error).message || 'Failed to load dashboard data.'}</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-900 p-4 md:p-8">
<div className="max-w-6xl mx-auto">
<header className="mb-8">
<h1 className="text-3xl font-bold text-emerald-400 mb-2">Dashboard</h1>
<p className="text-slate-400">Overview of your Extrudex inventory and prints</p>
</header>
{/* Summary Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<SummaryCard
title="Total Spools"
value={totalSpools}
icon={Package}
color="emerald"
/>
<SummaryCard
title="Low Stock"
value={lowStockCount}
icon={AlertTriangle}
color={lowStockCount > 0 ? 'amber' : 'emerald'}
/>
<SummaryCard
title="Recent Prints"
value={recentPrintJobs.length}
icon={Printer}
color="sky"
/>
<SummaryCard
title="Cost This Month"
value={`$${monthlyCost.toFixed(2)}`}
icon={DollarSign}
color="violet"
/>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3 mb-8">
<button className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors active:scale-95 touch-manipulation">
<Plus size={18} />
Add Spool
</button>
<button className="flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-slate-100 font-medium transition-colors active:scale-95 touch-manipulation">
<List size={18} />
View Inventory
</button>
</div>
{/* Recent Prints Section */}
<section>
<h2 className="text-xl font-semibold text-slate-100 mb-4">Recent Prints</h2>
<RecentPrints jobs={recentPrintJobs} />
</section>
</div>
</div>
)
}
-391
View File
@@ -1,391 +0,0 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Pencil, Trash2, AlertTriangle, Loader2, Archive } from 'lucide-react'
import ColorSwatch from '../components/ColorSwatch'
import { fetchFilamentById, fetchUsageLogs, deleteFilament } from '../services/filamentService'
import type { UsageLog } from '../types/filament'
export default function FilamentDetailPage() {
const { id } = useParams<{ id: string }>()
const spoolId = Number(id)
const navigate = useNavigate()
const queryClient = useQueryClient()
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const {
data: spool,
isLoading: spoolLoading,
error: spoolError,
refetch: refetchSpool,
} = useQuery({
queryKey: ['filament', spoolId],
queryFn: () => fetchFilamentById(spoolId),
enabled: !isNaN(spoolId),
})
const {
data: usageLogs,
isLoading: usageLoading,
error: usageError,
refetch: refetchUsage,
} = useQuery({
queryKey: ['usage-logs', spoolId],
queryFn: () => fetchUsageLogs(spoolId),
enabled: !isNaN(spoolId),
})
const handleDelete = async () => {
setDeleting(true)
try {
await deleteFilament(spoolId)
queryClient.invalidateQueries({ queryKey: ['filaments'] })
navigate('/')
} catch {
setDeleting(false)
setDeleteConfirm(false)
}
}
// ── Loading state ──
if (spoolLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-3 text-slate-400">
<Loader2 size={32} className="animate-spin text-emerald-400" />
<span>Loading spool details</span>
</div>
)
}
// ── Error state ──
if (spoolError || !spool) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-900/30">
<AlertTriangle size={28} className="text-red-400" />
</div>
<p className="text-red-400 font-medium">Failed to load spool details.</p>
<div className="flex gap-3">
<button
onClick={() => refetchSpool()}
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-slate-200 hover:bg-slate-600 transition-colors"
>
Retry
</button>
<button
onClick={() => navigate('/')}
className="rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-700 transition-colors"
>
Back to Inventory
</button>
</div>
</div>
)
}
const pctRemaining = spool.initial_grams > 0
? (spool.remaining_grams / spool.initial_grams) * 100
: 0
const isLow = spool.remaining_grams <= (spool.low_stock_threshold_grams || 0)
return (
<div className="space-y-6">
{/* ── Header Bar ── */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2 rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm font-medium text-slate-200 hover:bg-slate-700 active:bg-slate-600 transition-colors w-fit"
>
<ArrowLeft size={16} />
Back to Inventory
</button>
<div className="flex items-center gap-2">
<button className="inline-flex items-center gap-2 rounded-lg bg-slate-700 px-4 py-2 text-sm font-semibold text-slate-200 hover:bg-slate-600 active:bg-slate-500 transition-colors">
<Pencil size={16} />
Edit
</button>
<button
onClick={() => setDeleteConfirm(true)}
className="inline-flex items-center gap-2 rounded-lg bg-red-900/30 border border-red-700 px-4 py-2 text-sm font-semibold text-red-300 hover:bg-red-900/50 active:bg-red-900/70 transition-colors"
>
<Trash2 size={16} />
Delete
</button>
</div>
</div>
{/* ── Spool Info Card ── */}
<div className="rounded-xl border border-slate-700 bg-slate-800 overflow-hidden">
{/* Card header */}
<div className="px-6 py-5 border-b border-slate-700 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3">
<ColorSwatch colorHex={spool.color_hex} size={36} />
<div>
<h1 className="text-xl font-bold text-slate-100">{spool.name}</h1>
<p className="text-sm text-slate-400">
{spool.brand && <span>{spool.brand} · </span>}
{spool.diameter_mm}mm
</p>
</div>
</div>
<div>
{isLow ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-red-900/40 border border-red-700 px-3 py-1 text-sm font-medium text-red-300">
<AlertTriangle size={14} /> Low Stock
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-900/30 border border-emerald-700 px-3 py-1 text-sm font-medium text-emerald-300">
In Stock
</span>
)}
</div>
</div>
{/* Card body — 2-column grid */}
<div className="px-6 py-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4">
<DetailItem label="Material" value={spool.material_base?.name ?? '—'} />
<DetailItem label="Finish" value={spool.material_finish?.name ?? '—'} />
<DetailItem
label="Modifier"
value={spool.material_modifier?.name ?? 'None'}
/>
<DetailItem label="Color" value={spool.color_hex.toUpperCase()} />
<DetailItem label="Brand" value={spool.brand || '—'} />
<DetailItem label="Diameter" value={`${spool.diameter_mm}mm`} />
<DetailItem
label="Remaining"
value={
<span className="font-semibold tabular-nums">
{spool.remaining_grams.toLocaleString()} g
</span>
}
/>
<DetailItem
label="Initial"
value={`${spool.initial_grams.toLocaleString()} g`}
/>
<DetailItem
label="Cost"
value={
spool.cost_usd != null
? `$${spool.cost_usd.toFixed(2)}`
: '—'
}
/>
</div>
{/* Progress Bar */}
<div className="px-6 pb-5 pt-1">
<div className="flex items-center justify-between text-sm mb-1.5">
<span className="text-slate-400">
<span className="text-slate-200 font-semibold tabular-nums">
{spool.remaining_grams.toLocaleString()}g
</span>{' '}
of {spool.initial_grams.toLocaleString()}g
</span>
<span className="text-slate-400 font-medium tabular-nums">
{pctRemaining.toFixed(1)}%
</span>
</div>
<div className="w-full h-3 rounded-full bg-slate-700 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
pctRemaining <= 10
? 'bg-red-500'
: pctRemaining <= 25
? 'bg-amber-500'
: 'bg-emerald-500'
}`}
style={{ width: `${Math.min(100, pctRemaining)}%` }}
/>
</div>
</div>
</div>
{/* ── Usage History ── */}
<div className="rounded-xl border border-slate-700 bg-slate-800 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-700">
<h2 className="text-lg font-semibold text-slate-100">Usage History</h2>
</div>
{/* Loading sub-state */}
{usageLoading && (
<div className="px-6 py-8 text-center text-slate-400">
<Loader2 size={20} className="animate-spin inline mr-2" />
Loading usage history
</div>
)}
{/* Usage error sub-state */}
{usageError && !usageLoading && (
<div className="px-6 py-8 text-center">
<p className="text-red-400 text-sm">
Failed to load usage history.
</p>
<button
onClick={() => refetchUsage()}
className="mt-2 text-sm text-emerald-400 hover:text-emerald-300 underline"
>
Retry
</button>
</div>
)}
{/* Empty state */}
{!usageLoading && !usageError && usageLogs && usageLogs.length === 0 && (
<div className="px-6 py-10 text-center">
<Archive size={32} className="mx-auto text-slate-600 mb-2" />
<p className="text-slate-400 text-sm">No usage recorded yet.</p>
<p className="text-slate-500 text-xs mt-1">
Usage data will appear here when this spool is used in print jobs.
</p>
</div>
)}
{/* Table — desktop */}
{!usageLoading && !usageError && usageLogs && usageLogs.length > 0 && (
<>
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-800/50 text-slate-300">
<tr>
<th className="px-6 py-3 text-left font-semibold">Date</th>
<th className="px-6 py-3 text-left font-semibold">Print Job</th>
<th className="px-6 py-3 text-right font-semibold">mm Extruded</th>
<th className="px-6 py-3 text-right font-semibold">Grams Used</th>
<th className="px-6 py-3 text-right font-semibold">Cost</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700">
{usageLogs.map((log: UsageLog) => (
<tr
key={log.id}
className="hover:bg-slate-700/30 transition-colors"
>
<td className="px-6 py-3 text-slate-200 tabular-nums">
{new Date(log.logged_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</td>
<td className="px-6 py-3 text-slate-300">
{log.print_job_name || `Job #${log.print_job_id ?? log.id}`}
</td>
<td className="px-6 py-3 text-right text-slate-200 tabular-nums">
{log.mm_extruded.toLocaleString()}
</td>
<td className="px-6 py-3 text-right text-slate-200 tabular-nums">
{log.grams_used.toFixed(2)} g
</td>
<td className="px-6 py-3 text-right text-slate-200 tabular-nums">
{log.cost_usd != null ? `$${log.cost_usd.toFixed(3)}` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden divide-y divide-slate-700">
{usageLogs.map((log: UsageLog) => (
<div key={log.id} className="px-4 py-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-200">
{log.print_job_name || `Job #${log.print_job_id ?? log.id}`}
</span>
<span className="text-xs text-slate-400 tabular-nums">
{new Date(log.logged_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">
Extruded:{' '}
<span className="text-slate-300 tabular-nums">
{log.mm_extruded.toLocaleString()} mm
</span>
</span>
<span className="text-slate-400">
Used:{' '}
<span className="text-slate-300 tabular-nums">
{log.grams_used.toFixed(2)} g
</span>
</span>
<span className="text-slate-400">
{log.cost_usd != null ? (
<>
Cost:{' '}
<span className="text-slate-300 tabular-nums">
${log.cost_usd.toFixed(3)}
</span>
</>
) : (
'—'
)}
</span>
</div>
</div>
))}
</div>
</>
)}
</div>
{/* ── Delete Confirmation Modal ── */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-xl bg-slate-800 border border-slate-700 p-6 shadow-2xl space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-900/30 flex-shrink-0">
<AlertTriangle size={20} className="text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-100">
Delete &ldquo;{spool.name}&rdquo;?
</h3>
<p className="text-sm text-slate-400">
This permanently removes the spool and all usage history. This cannot be undone.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(false)}
disabled={deleting}
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-slate-200 hover:bg-slate-600 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors disabled:opacity-50"
>
{deleting && <Loader2 size={16} className="animate-spin" />}
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
/** Small detail row for the info card grid */
function DetailItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wider text-slate-500 mb-0.5">
{label}
</dt>
<dd className="text-sm text-slate-200">{value}</dd>
</div>
)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export default function InventoryPage() {
queryFn: () => fetchFilaments(filter), queryFn: () => fetchFilaments(filter),
}) })
const filaments = useMemo(() => data?.data ?? [], [data?.data]) const filaments = data?.data ?? []
const total = data?.total ?? 0 const total = data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
-19
View File
@@ -1,19 +0,0 @@
import axios from 'axios'
import type { ListResponse, FilamentSpool, PrintJob } from '../types'
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
})
export async function getFilaments(params?: { low_stock?: boolean; limit?: number; offset?: number }) {
const res = await api.get<ListResponse<FilamentSpool>>('/filaments', { params })
return res.data
}
export async function getPrintJobs(params?: { limit?: number; offset?: number }) {
const res = await api.get<ListResponse<PrintJob>>('/print-jobs', { params })
return res.data
}
export default api
+1 -11
View File
@@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import type { FilamentSpool, ListResponse, UsageLog, FilamentFilter } from '../types/filament' import type { FilamentSpool, ListResponse, FilamentFilter } from '../types/filament'
const API_BASE = '/api' const API_BASE = '/api'
@@ -19,16 +19,6 @@ export async function fetchFilaments(filter: FilamentFilter): Promise<ListRespon
return res.data return res.data
} }
export async function fetchFilamentById(id: number): Promise<FilamentSpool> {
const res = await axios.get<FilamentSpool>(`${API_BASE}/filaments/${id}`)
return res.data
}
export async function fetchUsageLogs(spoolId: number): Promise<UsageLog[]> {
const res = await axios.get<UsageLog[]>(`${API_BASE}/usage-logs?spool_id=${spoolId}`)
return res.data
}
export async function deleteFilament(id: number): Promise<void> { export async function deleteFilament(id: number): Promise<void> {
await axios.delete(`${API_BASE}/filaments/${id}`) await axios.delete(`${API_BASE}/filaments/${id}`)
} }
-13
View File
@@ -59,19 +59,6 @@ export interface ListResponse<T> {
offset: number offset: number
} }
export interface UsageLog {
id: number
spool_id: number
print_job_id?: number
print_job_name?: string
mm_extruded: number
grams_used: number
cost_usd?: number
logged_at: string
notes?: string
created_at: string
}
export interface FilamentFilter { export interface FilamentFilter {
material?: string material?: string
finish?: string finish?: string
-42
View File
@@ -1,42 +0,0 @@
export interface FilamentSpool {
id: number;
name: string;
material_base_id: number;
material_finish_id: number;
material_modifier_id: number;
color_hex: string;
brand: string;
diameter_mm: number;
initial_grams: number;
remaining_grams: number;
spool_weight_grams: number;
cost_usd: number;
low_stock_threshold_grams: number;
notes: string;
barcode: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface PrintJob {
id: number;
name: string;
printer_id: number;
filament_id: number;
status: string;
duration_seconds: number;
filament_used_grams: number;
cost_usd: number;
started_at: string;
completed_at: string | null;
created_at: string;
updated_at: string;
}
export interface ListResponse<T> {
data: T[];
total: number;
limit: number;
offset: number;
}
-1
View File
@@ -15,7 +15,6 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "5.0",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]