From ec82092bc7d8714cb1c5162473ca86c91d663ac3 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 12 Feb 2026 07:07:50 +0100 Subject: [PATCH] Sync --- DOCKERHUB.md | 2 +- backend/app/api/routes/cloud.py | 51 +- backend/app/api/routes/inventory.py | 936 ++ backend/app/core/catalog_defaults.py | 243 + backend/app/core/database.py | 115 + backend/app/core/permissions.py | 18 + backend/app/main.py | 175 + backend/app/models/__init__.py | 12 + backend/app/models/color_catalog.py | 20 + backend/app/models/spool.py | 44 + backend/app/models/spool_assignment.py | 30 + backend/app/models/spool_catalog.py | 18 + backend/app/models/spool_k_profile.py | 31 + backend/app/models/spool_usage_history.py | 21 + backend/app/schemas/cloud.py | 1 + backend/app/schemas/spool.py | 108 + backend/app/schemas/spool_usage.py | 17 + backend/app/services/spool_tag_matcher.py | 273 + backend/app/services/usage_tracker.py | 179 + frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 183 +- frontend/src/components/AssignSpoolModal.tsx | 203 + .../src/components/ColorCatalogSettings.tsx | 583 ++ frontend/src/components/ColumnConfigModal.tsx | 187 + .../src/components/ConfigureAmsSlotModal.tsx | 100 +- frontend/src/components/FilamentHoverCard.tsx | 55 +- frontend/src/components/Layout.tsx | 24 +- frontend/src/components/LinkSpoolModal.tsx | 250 +- .../src/components/SpoolCatalogSettings.tsx | 397 + frontend/src/components/SpoolFormModal.tsx | 484 + frontend/src/components/SpoolUsageHistory.tsx | 101 + frontend/src/components/SpoolmanSettings.tsx | 611 +- .../spool-form/AdditionalSection.tsx | 154 + .../components/spool-form/ColorSection.tsx | 173 + .../components/spool-form/FilamentSection.tsx | 245 + .../spool-form/PAProfileSection.tsx | 268 + .../src/components/spool-form/constants.ts | 101 + frontend/src/components/spool-form/types.ts | 124 + frontend/src/components/spool-form/utils.ts | 253 + frontend/src/hooks/useWebSocket.ts | 24 + frontend/src/i18n/locales/de.ts | 245 +- frontend/src/i18n/locales/en.ts | 249 +- frontend/src/i18n/locales/ja.ts | 242 +- frontend/src/pages/InventoryPage.tsx | 1055 ++ frontend/src/pages/PrintersPage.tsx | 144 +- frontend/src/pages/SettingsPage.tsx | 17 +- static/assets/index-8p-dzNdz.js | 8683 ---------------- static/assets/index-B4os6TlG.js | 8684 +++++++++++++++++ static/assets/index-C8xaQF5N.css | 1 + static/assets/index-DLgJjh2G.css | 1 - static/index.html | 4 +- 51 files changed, 16968 insertions(+), 9173 deletions(-) create mode 100644 backend/app/api/routes/inventory.py create mode 100644 backend/app/core/catalog_defaults.py create mode 100644 backend/app/models/color_catalog.py create mode 100644 backend/app/models/spool.py create mode 100644 backend/app/models/spool_assignment.py create mode 100644 backend/app/models/spool_catalog.py create mode 100644 backend/app/models/spool_k_profile.py create mode 100644 backend/app/models/spool_usage_history.py create mode 100644 backend/app/schemas/spool.py create mode 100644 backend/app/schemas/spool_usage.py create mode 100644 backend/app/services/spool_tag_matcher.py create mode 100644 backend/app/services/usage_tracker.py create mode 100644 frontend/src/components/AssignSpoolModal.tsx create mode 100644 frontend/src/components/ColorCatalogSettings.tsx create mode 100644 frontend/src/components/ColumnConfigModal.tsx create mode 100644 frontend/src/components/SpoolCatalogSettings.tsx create mode 100644 frontend/src/components/SpoolFormModal.tsx create mode 100644 frontend/src/components/SpoolUsageHistory.tsx create mode 100644 frontend/src/components/spool-form/AdditionalSection.tsx create mode 100644 frontend/src/components/spool-form/ColorSection.tsx create mode 100644 frontend/src/components/spool-form/FilamentSection.tsx create mode 100644 frontend/src/components/spool-form/PAProfileSection.tsx create mode 100644 frontend/src/components/spool-form/constants.ts create mode 100644 frontend/src/components/spool-form/types.ts create mode 100644 frontend/src/components/spool-form/utils.ts create mode 100644 frontend/src/pages/InventoryPage.tsx delete mode 100644 static/assets/index-8p-dzNdz.js create mode 100644 static/assets/index-B4os6TlG.js create mode 100644 static/assets/index-C8xaQF5N.css delete mode 100644 static/assets/index-DLgJjh2G.css diff --git a/DOCKERHUB.md b/DOCKERHUB.md index bcaef6d61..fe3770ffc 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -92,7 +92,7 @@ docker compose pull && docker compose up -d | Series | Models | Status | |---|---|---| -| H2 | H2D | Tested | +| H2 | H2C, H2D, H2D Pro, H2S | Tested | | X1 | X1 Carbon, X1E | Tested | | P1 | P1P, P1S | Compatible | | P2 | P2S | Compatible | diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index f3dbbaf28..898b731f2 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -246,11 +246,12 @@ async def get_slicer_settings( for api_key, our_type in type_mapping.items(): type_data = data.get(api_key, {}) - # Combine public and private presets, private (user's own) first - all_settings = type_data.get("private", []) + type_data.get("public", []) + private_settings = type_data.get("private", []) + public_settings = type_data.get("public", []) parsed = [] - for s in all_settings: + # Private (custom) presets first + for s in private_settings: parsed.append( SlicerSetting( setting_id=s.get("setting_id", s.get("id", "")), @@ -259,6 +260,20 @@ async def get_slicer_settings( version=s.get("version"), user_id=s.get("user_id"), updated_time=s.get("updated_time"), + is_custom=True, + ) + ) + # Public (default) presets + for s in public_settings: + parsed.append( + SlicerSetting( + setting_id=s.get("setting_id", s.get("id", "")), + name=s.get("name", "Unknown"), + type=our_type, + version=s.get("version"), + user_id=s.get("user_id"), + updated_time=s.get("updated_time"), + is_custom=False, ) ) setattr(result, our_type, parsed) @@ -302,6 +317,22 @@ async def get_setting_detail( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/filaments", response_model=list[SlicerSetting]) +async def get_filament_presets( + version: str = "02.04.00.70", + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ), +): + """ + Get just filament presets (convenience endpoint). + + Returns all filament presets with custom presets first. + Uses the same cache as get_slicer_settings. + """ + settings = await get_slicer_settings(version=version, db=db) + return settings.filament + + # Cache for filament preset info (setting_id -> {name, k}) _filament_cache: dict[str, dict] = {} _filament_cache_time: float = 0 @@ -844,6 +875,20 @@ def _load_fields(preset_type: str) -> dict: return data +@router.get("/builtin-filaments") +async def get_builtin_filaments( + _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ), +): + """ + Get built-in filament names as a fallback source. + + Returns the static _BUILTIN_FILAMENT_NAMES table as a list of + {filament_id, name} objects. Used by the frontend when cloud + and local profiles are unavailable. + """ + return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()] + + @router.get("/fields/{preset_type}") async def get_preset_fields( preset_type: Literal["filament", "print", "process", "printer"], diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py new file mode 100644 index 000000000..eb8b0d4c3 --- /dev/null +++ b/backend/app/api/routes/inventory.py @@ -0,0 +1,936 @@ +import json +import logging + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG +from backend.app.core.database import get_db +from backend.app.core.permissions import Permission +from backend.app.models.color_catalog import ColorCatalogEntry +from backend.app.models.spool import Spool +from backend.app.models.spool_assignment import SpoolAssignment +from backend.app.models.spool_catalog import SpoolCatalogEntry +from backend.app.models.spool_k_profile import SpoolKProfile +from backend.app.models.user import User +from backend.app.schemas.spool import ( + SpoolAssignmentCreate, + SpoolAssignmentResponse, + SpoolCreate, + SpoolKProfileBase, + SpoolKProfileResponse, + SpoolResponse, + SpoolUpdate, +) +from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/inventory", tags=["inventory"]) + +# Material temperature defaults (nozzle min/max) +MATERIAL_TEMPS: dict[str, tuple[int, int]] = { + "PLA": (190, 230), + "PETG": (220, 260), + "ABS": (240, 270), + "ASA": (240, 270), + "TPU": (200, 240), + "PA": (260, 290), + "PC": (250, 280), + "PVA": (190, 210), + "PLA-CF": (210, 240), + "PETG-CF": (240, 270), + "PA-CF": (270, 300), +} + +# FilamentColors.xyz API +FILAMENT_COLORS_API = "https://filamentcolors.xyz/api" + + +# ── Spool Catalog Schemas ────────────────────────────────────────────────── + + +class CatalogEntryResponse(BaseModel): + id: int + name: str + weight: int + is_default: bool + + class Config: + from_attributes = True + + +class CatalogEntryCreate(BaseModel): + name: str + weight: int + + +class CatalogEntryUpdate(BaseModel): + name: str + weight: int + + +# ── Color Catalog Schemas ────────────────────────────────────────────────── + + +class ColorEntryResponse(BaseModel): + id: int + manufacturer: str + color_name: str + hex_color: str + material: str | None + is_default: bool + + class Config: + from_attributes = True + + +class ColorEntryCreate(BaseModel): + manufacturer: str + color_name: str + hex_color: str + material: str | None = None + + +class ColorEntryUpdate(BaseModel): + manufacturer: str + color_name: str + hex_color: str + material: str | None = None + + +class ColorLookupResult(BaseModel): + found: bool + hex_color: str | None = None + material: str | None = None + + +# ── Spool Catalog CRUD ───────────────────────────────────────────────────── + + +@router.get("/catalog", response_model=list[CatalogEntryResponse]) +async def get_spool_catalog( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Get all spool catalog entries.""" + result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name)) + return list(result.scalars().all()) + + +@router.post("/catalog", response_model=CatalogEntryResponse) +async def add_catalog_entry( + entry: CatalogEntryCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Add a new spool catalog entry.""" + row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False) + db.add(row) + await db.commit() + await db.refresh(row) + return row + + +@router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse) +async def update_catalog_entry( + entry_id: int, + entry: CatalogEntryUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Update a spool catalog entry.""" + result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id)) + row = result.scalar_one_or_none() + if not row: + raise HTTPException(404, "Entry not found") + row.name = entry.name + row.weight = entry.weight + await db.commit() + await db.refresh(row) + return row + + +@router.delete("/catalog/{entry_id}") +async def delete_catalog_entry( + entry_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Delete a spool catalog entry.""" + result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id)) + row = result.scalar_one_or_none() + if not row: + raise HTTPException(404, "Entry not found") + await db.delete(row) + await db.commit() + return {"status": "deleted"} + + +@router.post("/catalog/reset") +async def reset_spool_catalog( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Reset spool catalog to defaults.""" + await db.execute(select(SpoolCatalogEntry)) # ensure table loaded + # Delete all + result = await db.execute(select(SpoolCatalogEntry)) + for row in result.scalars().all(): + await db.delete(row) + # Re-seed defaults + for name, weight in DEFAULT_SPOOL_CATALOG: + db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True)) + await db.commit() + return {"status": "reset"} + + +# ── Color Catalog CRUD ───────────────────────────────────────────────────── + + +@router.get("/colors", response_model=list[ColorEntryResponse]) +async def get_color_catalog( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Get all color catalog entries.""" + result = await db.execute( + select(ColorCatalogEntry).order_by( + ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name + ) + ) + return list(result.scalars().all()) + + +@router.post("/colors", response_model=ColorEntryResponse) +async def add_color_entry( + entry: ColorEntryCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Add a new color catalog entry.""" + row = ColorCatalogEntry( + manufacturer=entry.manufacturer, + color_name=entry.color_name, + hex_color=entry.hex_color, + material=entry.material, + is_default=False, + ) + db.add(row) + await db.commit() + await db.refresh(row) + return row + + +@router.put("/colors/{entry_id}", response_model=ColorEntryResponse) +async def update_color_entry( + entry_id: int, + entry: ColorEntryUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Update a color catalog entry.""" + result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id)) + row = result.scalar_one_or_none() + if not row: + raise HTTPException(404, "Entry not found") + row.manufacturer = entry.manufacturer + row.color_name = entry.color_name + row.hex_color = entry.hex_color + row.material = entry.material + await db.commit() + await db.refresh(row) + return row + + +@router.delete("/colors/{entry_id}") +async def delete_color_entry( + entry_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Delete a color catalog entry.""" + result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id)) + row = result.scalar_one_or_none() + if not row: + raise HTTPException(404, "Entry not found") + await db.delete(row) + await db.commit() + return {"status": "deleted"} + + +@router.post("/colors/reset") +async def reset_color_catalog( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Reset color catalog to defaults.""" + result = await db.execute(select(ColorCatalogEntry)) + for row in result.scalars().all(): + await db.delete(row) + for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG: + db.add( + ColorCatalogEntry( + manufacturer=manufacturer, + color_name=color_name, + hex_color=hex_color, + material=material, + is_default=True, + ) + ) + await db.commit() + return {"status": "reset"} + + +@router.get("/colors/lookup", response_model=ColorLookupResult) +async def lookup_color( + manufacturer: str, + color_name: str, + material: str | None = None, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Look up a color by manufacturer and color name.""" + query = select(ColorCatalogEntry).where( + ColorCatalogEntry.manufacturer == manufacturer, + ColorCatalogEntry.color_name == color_name, + ) + if material: + query = query.where(ColorCatalogEntry.material == material) + query = query.limit(1) + result = await db.execute(query) + row = result.scalar_one_or_none() + if row: + return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material) + return ColorLookupResult(found=False) + + +@router.get("/colors/search", response_model=list[ColorEntryResponse]) +async def search_colors( + manufacturer: str | None = None, + material: str | None = None, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Search colors by manufacturer and/or material.""" + query = select(ColorCatalogEntry) + if manufacturer: + query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower())) + if material: + query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower())) + query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100) + result = await db.execute(query) + return list(result.scalars().all()) + + +@router.post("/colors/sync") +async def sync_from_filamentcolors( + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Sync colors from FilamentColors.xyz API with progress streaming.""" + + async def generate(): + from backend.app.core.database import async_session + + added = 0 + skipped = 0 + total_fetched = 0 + total_available = 0 + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + page = 1 + while True: + response = await client.get( + f"{FILAMENT_COLORS_API}/swatch/", + params={"page": page}, + ) + response.raise_for_status() + data = response.json() + total_available = data.get("count", total_available) + results = data.get("results", []) + if not results: + break + + async with async_session() as db: + for swatch in results: + total_fetched += 1 + manufacturer_data = swatch.get("manufacturer") + manufacturer_name = ( + manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else "" + ) + filament_type_data = swatch.get("filament_type") + mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None + color_name_val = swatch.get("color_name", "") + hex_color_val = swatch.get("hex_color", "") + + if not manufacturer_name or not color_name_val or not hex_color_val: + skipped += 1 + continue + + if not hex_color_val.startswith("#"): + hex_color_val = f"#{hex_color_val}" + + # Check if entry already exists + existing = await db.execute( + select(ColorCatalogEntry) + .where( + ColorCatalogEntry.manufacturer == manufacturer_name, + ColorCatalogEntry.color_name == color_name_val, + ColorCatalogEntry.material == mat, + ) + .limit(1) + ) + if existing.scalar_one_or_none(): + skipped += 1 + else: + db.add( + ColorCatalogEntry( + manufacturer=manufacturer_name, + color_name=color_name_val, + hex_color=hex_color_val.upper(), + material=mat, + is_default=False, + ) + ) + added += 1 + + await db.commit() + + progress = { + "type": "progress", + "added": added, + "skipped": skipped, + "total_fetched": total_fetched, + "total_available": total_available, + } + yield f"data: {json.dumps(progress)}\n\n" + + if not data.get("next") or total_fetched >= total_available: + break + page += 1 + + result = { + "type": "complete", + "added": added, + "skipped": skipped, + "total_fetched": total_fetched, + "total_available": total_available, + } + yield f"data: {json.dumps(result)}\n\n" + + except httpx.HTTPError as e: + logger.error("HTTP error syncing from FilamentColors.xyz: %s", e) + yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" + except Exception as e: + logger.error("Error syncing from FilamentColors.xyz: %s", e) + yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + +# ── Spool CRUD ─────────────────────────────────────────────────────────────── + + +@router.get("/spools", response_model=list[SpoolResponse]) +async def list_spools( + include_archived: bool = False, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """List all spools, excluding archived by default.""" + query = select(Spool).options(selectinload(Spool.k_profiles)) + if not include_archived: + query = query.where(Spool.archived_at.is_(None)) + query = query.order_by(Spool.material, Spool.brand, Spool.color_name) + result = await db.execute(query) + return list(result.scalars().all()) + + +@router.get("/spools/{spool_id}", response_model=SpoolResponse) +async def get_spool( + spool_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Get a single spool with k_profiles.""" + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + spool = result.scalar_one_or_none() + if not spool: + raise HTTPException(404, "Spool not found") + return spool + + +@router.post("/spools", response_model=SpoolResponse) +async def create_spool( + spool_data: SpoolCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Create a new spool.""" + spool = Spool(**spool_data.model_dump()) + db.add(spool) + await db.commit() + await db.refresh(spool) + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id)) + return result.scalar_one() + + +@router.patch("/spools/{spool_id}", response_model=SpoolResponse) +async def update_spool( + spool_id: int, + spool_data: SpoolUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Update a spool.""" + result = await db.execute(select(Spool).where(Spool.id == spool_id)) + spool = result.scalar_one_or_none() + if not spool: + raise HTTPException(404, "Spool not found") + + for field, value in spool_data.model_dump(exclude_unset=True).items(): + setattr(spool, field, value) + + await db.commit() + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + return result.scalar_one() + + +@router.delete("/spools/{spool_id}") +async def delete_spool( + spool_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Hard delete a spool.""" + result = await db.execute(select(Spool).where(Spool.id == spool_id)) + spool = result.scalar_one_or_none() + if not spool: + raise HTTPException(404, "Spool not found") + + await db.delete(spool) + await db.commit() + return {"status": "deleted"} + + +@router.post("/spools/{spool_id}/archive", response_model=SpoolResponse) +async def archive_spool( + spool_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Soft-delete a spool by setting archived_at.""" + from datetime import datetime, timezone + + result = await db.execute(select(Spool).where(Spool.id == spool_id)) + spool = result.scalar_one_or_none() + if not spool: + raise HTTPException(404, "Spool not found") + + spool.archived_at = datetime.now(timezone.utc) + await db.commit() + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + return result.scalar_one() + + +@router.post("/spools/{spool_id}/restore", response_model=SpoolResponse) +async def restore_spool( + spool_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Restore an archived spool.""" + result = await db.execute(select(Spool).where(Spool.id == spool_id)) + spool = result.scalar_one_or_none() + if not spool: + raise HTTPException(404, "Spool not found") + + spool.archived_at = None + await db.commit() + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + return result.scalar_one() + + +# ── K-Profiles ─────────────────────────────────────────────────────────────── + + +@router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse]) +async def list_k_profiles( + spool_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """List K-profiles for a spool.""" + result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id)) + return list(result.scalars().all()) + + +@router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse]) +async def replace_k_profiles( + spool_id: int, + profiles: list[SpoolKProfileBase], + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Replace all K-profiles for a spool (batch save).""" + # Verify spool exists + result = await db.execute(select(Spool).where(Spool.id == spool_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, "Spool not found") + + # Delete existing + existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id)) + for old in existing.scalars().all(): + await db.delete(old) + + # Create new + new_profiles = [] + for p in profiles: + kp = SpoolKProfile(spool_id=spool_id, **p.model_dump()) + db.add(kp) + new_profiles.append(kp) + + await db.commit() + for kp in new_profiles: + await db.refresh(kp) + return new_profiles + + +# ── Spool Assignments ──────────────────────────────────────────────────────── + + +@router.get("/assignments", response_model=list[SpoolAssignmentResponse]) +async def list_assignments( + printer_id: int | None = None, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """List spool assignments, optionally filtered by printer.""" + query = select(SpoolAssignment).options(selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles)) + if printer_id is not None: + query = query.where(SpoolAssignment.printer_id == printer_id) + result = await db.execute(query) + return list(result.scalars().all()) + + +@router.post("/assignments", response_model=SpoolAssignmentResponse) +async def assign_spool( + data: SpoolAssignmentCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Assign a spool to an AMS slot and auto-configure via MQTT.""" + from backend.app.services.printer_manager import printer_manager + + # 1. Validate spool exists and is not archived + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id)) + spool = result.scalar_one_or_none() + if not spool: + raise HTTPException(404, "Spool not found") + if spool.archived_at: + raise HTTPException(400, "Cannot assign an archived spool") + + # 2. Get current AMS tray state for fingerprint + fingerprint_color = None + fingerprint_type = None + state = printer_manager.get_status(data.printer_id) + if state and state.raw_data: + tray = _find_tray_in_ams_data( + state.raw_data.get("ams", {}).get("ams", []), + data.ams_id, + data.tray_id, + ) + if tray: + fingerprint_color = tray.get("tray_color", "") + fingerprint_type = tray.get("tray_type", "") + + # 3. Upsert assignment (replace if same printer+ams+tray) + existing = await db.execute( + select(SpoolAssignment).where( + SpoolAssignment.printer_id == data.printer_id, + SpoolAssignment.ams_id == data.ams_id, + SpoolAssignment.tray_id == data.tray_id, + ) + ) + old = existing.scalar_one_or_none() + if old: + await db.delete(old) + await db.flush() + + assignment = SpoolAssignment( + spool_id=data.spool_id, + printer_id=data.printer_id, + ams_id=data.ams_id, + tray_id=data.tray_id, + fingerprint_color=fingerprint_color, + fingerprint_type=fingerprint_type, + ) + db.add(assignment) + await db.commit() + await db.refresh(assignment) + + # 4. Auto-configure AMS slot via MQTT + configured = False + try: + client = printer_manager.get_client(data.printer_id) + if client: + # Build filament setting from spool data + tray_type = spool.material + tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material + tray_color = spool.rgba or "FFFFFFFF" + tray_info_idx = spool.slicer_filament or "" + setting_id = "" + + # Temperature: use spool overrides if set, else material defaults + temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240)) + if spool.nozzle_temp_min is not None: + temp_min = spool.nozzle_temp_min + if spool.nozzle_temp_max is not None: + temp_max = spool.nozzle_temp_max + + # a. Set filament setting + client.ams_set_filament_setting( + ams_id=data.ams_id, + tray_id=data.tray_id, + tray_info_idx=tray_info_idx, + tray_type=tray_type, + tray_sub_brands=tray_sub_brands, + tray_color=tray_color, + nozzle_temp_min=temp_min, + nozzle_temp_max=temp_max, + setting_id=setting_id, + ) + + # b. Look up K-profile for this spool + printer + nozzle + nozzle_diameter = "0.4" + if state and state.nozzles: + nd = state.nozzles[0].nozzle_diameter + if nd: + nozzle_diameter = nd + + matching_kp = None + for kp in spool.k_profiles: + if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter: + matching_kp = kp + break + + if matching_kp and matching_kp.cali_idx is not None: + client.extrusion_cali_sel( + ams_id=data.ams_id, + tray_id=data.tray_id, + cali_idx=matching_kp.cali_idx, + filament_id=tray_info_idx, + nozzle_diameter=nozzle_diameter, + setting_id=matching_kp.setting_id, + ) + + configured = True + logger.info( + "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d", + data.ams_id, + data.tray_id, + spool.id, + data.printer_id, + ) + except Exception as e: + logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e) + + # Return assignment with spool data + result = await db.execute( + select(SpoolAssignment) + .options(selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles)) + .where(SpoolAssignment.id == assignment.id) + ) + resp = result.scalar_one() + response = SpoolAssignmentResponse.model_validate(resp) + response.configured = configured + return response + + +@router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}") +async def unassign_spool( + printer_id: int, + ams_id: int, + tray_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Unassign a spool from an AMS slot.""" + result = await db.execute( + select(SpoolAssignment).where( + SpoolAssignment.printer_id == printer_id, + SpoolAssignment.ams_id == ams_id, + SpoolAssignment.tray_id == tray_id, + ) + ) + assignment = result.scalar_one_or_none() + if not assignment: + raise HTTPException(404, "Assignment not found") + + await db.delete(assignment) + await db.commit() + return {"status": "deleted"} + + +# ── Tag Linking ─────────────────────────────────────────────────────────────── + + +class LinkTagRequest(BaseModel): + tag_uid: str | None = None + tray_uuid: str | None = None + tag_type: str | None = None + data_origin: str | None = "nfc_link" + + +@router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse) +async def link_tag_to_spool( + spool_id: int, + data: LinkTagRequest, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Link an RFID tag_uid/tray_uuid to an existing spool.""" + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + spool = result.scalar_one_or_none() + if not spool: + raise HTTPException(404, "Spool not found") + if spool.archived_at: + raise HTTPException(400, "Cannot link tag to archived spool") + + # Check for conflicts: tag already linked to another active spool + if data.tag_uid: + conflict = await db.execute( + select(Spool).where( + Spool.tag_uid == data.tag_uid, + Spool.id != spool_id, + Spool.archived_at.is_(None), + ) + ) + if conflict.scalar_one_or_none(): + raise HTTPException(409, "Tag UID already linked to another active spool") + # Auto-clear from archived spools (tag recycling) + archived_with_tag = await db.execute( + select(Spool).where( + Spool.tag_uid == data.tag_uid, + Spool.id != spool_id, + Spool.archived_at.is_not(None), + ) + ) + for old_spool in archived_with_tag.scalars().all(): + old_spool.tag_uid = None + + if data.tray_uuid: + conflict = await db.execute( + select(Spool).where( + Spool.tray_uuid == data.tray_uuid, + Spool.id != spool_id, + Spool.archived_at.is_(None), + ) + ) + if conflict.scalar_one_or_none(): + raise HTTPException(409, "Tray UUID already linked to another active spool") + archived_with_uuid = await db.execute( + select(Spool).where( + Spool.tray_uuid == data.tray_uuid, + Spool.id != spool_id, + Spool.archived_at.is_not(None), + ) + ) + for old_spool in archived_with_uuid.scalars().all(): + old_spool.tray_uuid = None + + if data.tag_uid is not None: + spool.tag_uid = data.tag_uid + if data.tray_uuid is not None: + spool.tray_uuid = data.tray_uuid + if data.tag_type is not None: + spool.tag_type = data.tag_type + if data.data_origin is not None: + spool.data_origin = data.data_origin + + await db.commit() + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + return result.scalar_one() + + +# ── Usage History ───────────────────────────────────────────────────────────── + + +@router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse]) +async def get_spool_usage_history( + spool_id: int, + limit: int = 50, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Get usage history for a specific spool.""" + from backend.app.models.spool_usage_history import SpoolUsageHistory + + # Verify spool exists + spool_result = await db.execute(select(Spool).where(Spool.id == spool_id)) + if not spool_result.scalar_one_or_none(): + raise HTTPException(404, "Spool not found") + + result = await db.execute( + select(SpoolUsageHistory) + .where(SpoolUsageHistory.spool_id == spool_id) + .order_by(SpoolUsageHistory.created_at.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + + +@router.get("/usage", response_model=list[SpoolUsageHistoryResponse]) +async def get_all_usage_history( + limit: int = 100, + printer_id: int | None = None, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """Get global usage history, optionally filtered by printer.""" + from backend.app.models.spool_usage_history import SpoolUsageHistory + + query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit) + if printer_id is not None: + query = query.where(SpoolUsageHistory.printer_id == printer_id) + result = await db.execute(query) + return list(result.scalars().all()) + + +@router.delete("/spools/{spool_id}/usage") +async def clear_spool_usage_history( + spool_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Clear usage history for a spool.""" + from backend.app.models.spool_usage_history import SpoolUsageHistory + + result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id)) + for row in result.scalars().all(): + await db.delete(row) + await db.commit() + return {"status": "cleared"} + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None: + """Find a specific tray in the AMS data structure.""" + if not ams_data: + return None + for ams_unit in ams_data: + if int(ams_unit.get("id", -1)) != ams_id: + continue + for tray in ams_unit.get("tray", []): + if int(tray.get("id", -1)) == tray_id: + return tray + return None diff --git a/backend/app/core/catalog_defaults.py b/backend/app/core/catalog_defaults.py new file mode 100644 index 000000000..c41dd94d1 --- /dev/null +++ b/backend/app/core/catalog_defaults.py @@ -0,0 +1,243 @@ +"""Default spool and color catalog entries.""" + +# (name, weight_in_grams) +DEFAULT_SPOOL_CATALOG: list[tuple[str, int]] = [ + ("3D FilaPrint - Cardboard", 210), + ("3D FilaPrint - Plastic", 238), + ("3D Fuel - Plastic", 264), + ("3D Power - Plastic", 220), + ("3D Solutech - Plastic", 173), + ("3DE - Cardboard", 136), + ("3DE - Plastic", 181), + ("3DHOJOR - Cardboard", 157), + ("3DJake - Cardboard", 209), + ("3DJake - Plastic", 232), + ("3DJake 250g - Plastic", 91), + ("3DJake ecoPLA - Plastic", 210), + ("3DXTech - Plastic", 258), + ("Acccreate - Plastic", 161), + ("Amazon Basics - Plastic", 234), + ("Amolen - Plastic", 150), + ("AMZ3D - Plastic", 233), + ("Anycubic - Cardboard", 125), + ("Anycubic - Plastic", 127), + ("Atomic Filament - Plastic", 272), + ("Aurapol - Plastic", 220), + ("Azure Film - Plastic", 163), + ("Bambu Lab - Plastic High Temp", 216), + ("Bambu Lab - Plastic Low Temp", 250), + ("Bambu Lab - Plastic White", 253), + ("BQ - Plastic", 218), + ("Colorfabb - Plastic", 236), + ("Colorfabb 750g - Cardboard", 152), + ("Colorfabb 750g - Plastic", 254), + ("Comgrow - Cardboard", 166), + ("Creality - Cardboard", 180), + ("Creality - Plastic", 135), + ("Das Filament - Plastic", 211), + ("Devil Design - Plastic", 256), + ("Duramic 3D - Cardboard", 136), + ("Elegoo - Cardboard", 153), + ("Elegoo - Plastic", 111), + ("Eryone - Cardboard", 156), + ("Eryone - Plastic", 187), + ("eSUN - Cardboard", 147), + ("eSUN - Plastic", 240), + ("eSUN 2.5kg - Plastic", 634), + ("Extrudr - Plastic", 244), + ("Fiberlogy - Plastic", 260), + ("Filament PM - Plastic", 224), + ("Fillamentum - Plastic", 230), + ("Flashforge - Plastic", 167), + ("FormFutura - Cardboard", 155), + ("FormFutura 750g - Plastic", 212), + ("Geeetech - Plastic", 178), + ("Gembird - Cardboard", 143), + ("Hatchbox - Plastic", 225), + ("Inland - Cardboard", 142), + ("Inland - Plastic", 210), + ("Jayo - Cardboard", 120), + ("Jayo - Plastic", 126), + ("Jayo 250g - Plastic", 58), + ("Kingroon - Cardboard", 155), + ("Kingroon - Plastic", 156), + ("KVP - Plastic", 263), + ("Matter Hackers - Plastic", 215), + ("MG Chemicals - Cardboard", 150), + ("MG Chemicals - Plastic", 239), + ("Mika3D - Plastic", 175), + ("MonoPrice - Plastic", 221), + ("Overture - Cardboard", 150), + ("Overture - Plastic", 237), + ("PolyMaker - Cardboard", 137), + ("PolyMaker - Plastic", 220), + ("PolyMaker 3kg - Cardboard", 418), + ("PolyTerra PLA - Cardboard", 147), + ("PrimaSelect - Plastic", 222), + ("ProtoPasta - Cardboard", 80), + ("Prusament - Plastic", 201), + ("Prusament - Plastic w/ Cardboard Core", 196), + ("Rosa3D - Plastic", 245), + ("Sakata3D - Plastic", 205), + ("Snapmaker - Cardboard", 148), + ("Sovol - Cardboard", 145), + ("Spectrum - Cardboard", 180), + ("Spectrum - Plastic", 257), + ("Sunlu - Plastic", 117), + ("Sunlu - Plastic V2", 165), + ("Sunlu - Plastic V3", 179), + ("Sunlu 250g - Plastic", 55), + ("UltiMaker - Plastic", 235), + ("Voolt3D - Plastic", 190), + ("Voxelab - Plastic", 171), + ("Wanhao - Plastic", 267), + ("Ziro - Plastic", 166), + ("ZYLtech - Plastic", 179), +] + +# (manufacturer, color_name, hex_color, material) +DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [ + # Bambu Lab PLA Basic + ("Bambu Lab", "Jade White", "#FFFFFF", "PLA Basic"), + ("Bambu Lab", "Black", "#000000", "PLA Basic"), + ("Bambu Lab", "Silver", "#A6A9AA", "PLA Basic"), + ("Bambu Lab", "Light Gray", "#C0C0C0", "PLA Basic"), + ("Bambu Lab", "Gray", "#8E9089", "PLA Basic"), + ("Bambu Lab", "Dark Gray", "#616364", "PLA Basic"), + ("Bambu Lab", "Red", "#C12E1F", "PLA Basic"), + ("Bambu Lab", "Magenta", "#EC008C", "PLA Basic"), + ("Bambu Lab", "Hot Pink", "#FF69B4", "PLA Basic"), + ("Bambu Lab", "Pink", "#F55A74", "PLA Basic"), + ("Bambu Lab", "Beige", "#F7E6DE", "PLA Basic"), + ("Bambu Lab", "Yellow", "#FFFF00", "PLA Basic"), + ("Bambu Lab", "Sunflower Yellow", "#FEC600", "PLA Basic"), + ("Bambu Lab", "Gold", "#E4BD68", "PLA Basic"), + ("Bambu Lab", "Orange", "#FF8C00", "PLA Basic"), + ("Bambu Lab", "Pumpkin Orange", "#FF9016", "PLA Basic"), + ("Bambu Lab", "Bright Green", "#66FF00", "PLA Basic"), + ("Bambu Lab", "Bambu Green", "#00AE42", "PLA Basic"), + ("Bambu Lab", "Mistletoe Green", "#3F8E43", "PLA Basic"), + ("Bambu Lab", "Turquoise", "#00B1B7", "PLA Basic"), + ("Bambu Lab", "Cyan", "#0086D6", "PLA Basic"), + ("Bambu Lab", "Blue", "#0A2989", "PLA Basic"), + ("Bambu Lab", "Blue Grey", "#647988", "PLA Basic"), + ("Bambu Lab", "Cobalt Blue", "#0047AB", "PLA Basic"), + ("Bambu Lab", "Purple", "#5E43B7", "PLA Basic"), + ("Bambu Lab", "Indigo Purple", "#482960", "PLA Basic"), + ("Bambu Lab", "Brown", "#9D432C", "PLA Basic"), + ("Bambu Lab", "Cocoa Brown", "#5C4033", "PLA Basic"), + ("Bambu Lab", "Bronze", "#847D48", "PLA Basic"), + # Bambu Lab PLA Matte + ("Bambu Lab", "Ivory White", "#EBEBE3", "PLA Matte"), + ("Bambu Lab", "Bone White", "#F5F5DC", "PLA Matte"), + ("Bambu Lab", "Lemon Yellow", "#FFF44F", "PLA Matte"), + ("Bambu Lab", "Mandarin Orange", "#FF7518", "PLA Matte"), + ("Bambu Lab", "Scarlet Red", "#FF2400", "PLA Matte"), + ("Bambu Lab", "Lilac Purple", "#C8A2C8", "PLA Matte"), + ("Bambu Lab", "Grape Purple", "#6F2DA8", "PLA Matte"), + ("Bambu Lab", "Grass Green", "#6BB173", "PLA Matte"), + ("Bambu Lab", "Dark Green", "#656A4D", "PLA Matte"), + ("Bambu Lab", "Sakura Pink", "#EAB8CA", "PLA Matte"), + ("Bambu Lab", "Charcoal", "#36454F", "PLA Matte"), + # Bambu Lab PLA Silk + ("Bambu Lab", "Blue", "#4F9CCC", "PLA Silk"), + ("Bambu Lab", "Gold", "#CFB53B", "PLA Silk"), + ("Bambu Lab", "Silver", "#C0C0C0", "PLA Silk"), + ("Bambu Lab", "Copper", "#B87333", "PLA Silk"), + ("Bambu Lab", "Green", "#50C878", "PLA Silk"), + ("Bambu Lab", "Red", "#DC143C", "PLA Silk"), + # Bambu Lab PLA Sparkle + ("Bambu Lab", "Alpine Green Sparkle", "#4F6359", "PLA Sparkle"), + ("Bambu Lab", "Galaxy Black Sparkle", "#1C1C1C", "PLA Sparkle"), + ("Bambu Lab", "Space Gray Sparkle", "#4A4A4A", "PLA Sparkle"), + # Bambu Lab PETG Basic + ("Bambu Lab", "Black", "#000000", "PETG Basic"), + ("Bambu Lab", "White", "#FFFFFF", "PETG Basic"), + ("Bambu Lab", "Gray", "#808080", "PETG Basic"), + ("Bambu Lab", "Translucent", "#F0F0F0", "PETG Basic"), + # Bambu Lab PETG-HF + ("Bambu Lab", "White", "#F0F1F0", "PETG-HF"), + ("Bambu Lab", "Black", "#000000", "PETG-HF"), + ("Bambu Lab", "Gray", "#A3A6A6", "PETG-HF"), + ("Bambu Lab", "Red", "#C33F45", "PETG-HF"), + ("Bambu Lab", "Orange", "#FF7146", "PETG-HF"), + ("Bambu Lab", "Blue", "#1E90FF", "PETG-HF"), + ("Bambu Lab", "Translucent Orange", "#EF8E5B", "PETG-HF"), + # Bambu Lab ABS + ("Bambu Lab", "Black", "#000000", "ABS"), + ("Bambu Lab", "White", "#FFFFFF", "ABS"), + ("Bambu Lab", "Gray", "#808080", "ABS"), + ("Bambu Lab", "Red", "#FF0000", "ABS"), + # Bambu Lab ASA + ("Bambu Lab", "Black", "#000000", "ASA"), + ("Bambu Lab", "White", "#FFFFFF", "ASA"), + ("Bambu Lab", "Gray", "#808080", "ASA"), + # Bambu Lab TPU + ("Bambu Lab", "White", "#F0EFE3", "TPU 95A"), + ("Bambu Lab", "Black", "#000000", "TPU 95A"), + ("Bambu Lab", "Gray", "#8C9091", "TPU 95A"), + ("Bambu Lab", "Red", "#FF0000", "TPU 95A"), + # Bambu Lab PLA-CF / PAHT-CF / PETG-CF + ("Bambu Lab", "Black", "#1A1A1A", "PLA-CF"), + ("Bambu Lab", "Black", "#1A1A1A", "PAHT-CF"), + ("Bambu Lab", "Black", "#1A1A1A", "PETG-CF"), + # Bambu Lab Support Materials + ("Bambu Lab", "Natural", "#F5F5DC", "PLA Support"), + ("Bambu Lab", "Natural", "#F5F5DC", "PVA Support"), + # Polymaker PolyTerra PLA + ("Polymaker", "Cotton White", "#F5F5F5", "PolyTerra PLA"), + ("Polymaker", "Charcoal Black", "#2B2B2B", "PolyTerra PLA"), + ("Polymaker", "Marble White", "#E8E8E8", "PolyTerra PLA"), + ("Polymaker", "Fossil Grey", "#6B6B6B", "PolyTerra PLA"), + ("Polymaker", "Shadow Black", "#1A1A1A", "PolyTerra PLA"), + ("Polymaker", "Army Red", "#8B0000", "PolyTerra PLA"), + ("Polymaker", "Lava Red", "#CF1020", "PolyTerra PLA"), + ("Polymaker", "Sakura Pink", "#FFB7C5", "PolyTerra PLA"), + ("Polymaker", "Rose", "#FF007F", "PolyTerra PLA"), + ("Polymaker", "Peach", "#FFCBA4", "PolyTerra PLA"), + ("Polymaker", "Banana", "#FFE135", "PolyTerra PLA"), + ("Polymaker", "Savannah Yellow", "#F4C430", "PolyTerra PLA"), + ("Polymaker", "Sunrise Orange", "#FF6600", "PolyTerra PLA"), + ("Polymaker", "Muted Green", "#4F7942", "PolyTerra PLA"), + ("Polymaker", "Forest Green", "#228B22", "PolyTerra PLA"), + ("Polymaker", "Mint", "#98FF98", "PolyTerra PLA"), + ("Polymaker", "Lavender Purple", "#B57EDC", "PolyTerra PLA"), + ("Polymaker", "Sapphire Blue", "#0F52BA", "PolyTerra PLA"), + ("Polymaker", "Ice", "#D6ECEF", "PolyTerra PLA"), + # Prusament PLA + ("Prusament", "Jet Black", "#1A1A1A", "PLA"), + ("Prusament", "Galaxy Black", "#1F1F1F", "PLA"), + ("Prusament", "Pristine White", "#FFFFFF", "PLA"), + ("Prusament", "Gentleman's Grey", "#5A5A5A", "PLA"), + ("Prusament", "Lipstick Red", "#C21E1E", "PLA"), + ("Prusament", "Orange", "#FF6600", "PLA"), + ("Prusament", "Pineapple Yellow", "#FFD700", "PLA"), + ("Prusament", "Jungle Green", "#29AB87", "PLA"), + ("Prusament", "Azure Blue", "#007FFF", "PLA"), + ("Prusament", "Royal Blue", "#4169E1", "PLA"), + ("Prusament", "Mystic Purple", "#7B68EE", "PLA"), + # eSUN PLA+ + ("eSUN", "White", "#FFFFFF", "PLA+"), + ("eSUN", "Black", "#000000", "PLA+"), + ("eSUN", "Grey", "#808080", "PLA+"), + ("eSUN", "Red", "#FF0000", "PLA+"), + ("eSUN", "Blue", "#0000FF", "PLA+"), + ("eSUN", "Green", "#00FF00", "PLA+"), + ("eSUN", "Yellow", "#FFFF00", "PLA+"), + ("eSUN", "Orange", "#FFA500", "PLA+"), + ("eSUN", "Purple", "#800080", "PLA+"), + ("eSUN", "Pink", "#FFC0CB", "PLA+"), + # Hatchbox PLA + ("Hatchbox", "White", "#FFFFFF", "PLA"), + ("Hatchbox", "Black", "#000000", "PLA"), + ("Hatchbox", "Gray", "#808080", "PLA"), + ("Hatchbox", "Red", "#FF0000", "PLA"), + ("Hatchbox", "Blue", "#0000FF", "PLA"), + ("Hatchbox", "Green", "#00FF00", "PLA"), + ("Hatchbox", "Yellow", "#FFFF00", "PLA"), + ("Hatchbox", "Orange", "#FFA500", "PLA"), + ("Hatchbox", "Purple", "#800080", "PLA"), + ("Hatchbox", "Pink", "#FFC0CB", "PLA"), + ("Hatchbox", "True Blue", "#0073CF", "PLA"), + ("Hatchbox", "True Green", "#008000", "PLA"), +] diff --git a/backend/app/core/database.py b/backend/app/core/database.py index c3808010e..61f62c2e9 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -59,6 +59,7 @@ async def init_db(): ams_history, api_key, archive, + color_catalog, external_link, filament, github_backup, @@ -78,6 +79,11 @@ async def init_db(): settings, slot_preset, smart_plug, + spool, + spool_assignment, + spool_catalog, + spool_k_profile, + spool_usage_history, user, ) @@ -93,6 +99,10 @@ async def init_db(): # Seed default groups and migrate existing users await seed_default_groups() + # Seed default catalog entries + await seed_spool_catalog() + await seed_color_catalog() + async def run_migrations(conn): """Add new columns to existing tables if they don't exist.""" @@ -1118,6 +1128,57 @@ async def run_migrations(conn): except OperationalError: pass # Already applied + # Migration: Add inventory spool tracking columns + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN added_full BOOLEAN")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN last_used DATETIME")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN encode_time DATETIME")) + except OperationalError: + pass # Already applied + + # Migration: Add RFID tag matching columns to spool + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN tag_uid VARCHAR(16)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN tray_uuid VARCHAR(32)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN data_origin VARCHAR(20)")) + except OperationalError: + pass # Already applied + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN tag_type VARCHAR(20)")) + except OperationalError: + pass # Already applied + + # Migration: Create spool_usage_history table for filament consumption tracking + try: + await conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS spool_usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE, + printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, + print_name VARCHAR(500), + weight_used REAL NOT NULL DEFAULT 0, + percent_used INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'completed', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + except OperationalError: + pass # Already applied + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" @@ -1286,3 +1347,57 @@ async def seed_default_groups(): logger.info("Migrated user '%s' to Operators group", user.username) await session.commit() + + +async def seed_spool_catalog(): + """Seed the spool catalog with default entries if empty.""" + import logging + + from sqlalchemy import func, select + + from backend.app.core.catalog_defaults import DEFAULT_SPOOL_CATALOG + from backend.app.models.spool_catalog import SpoolCatalogEntry + + logger = logging.getLogger(__name__) + + async with async_session() as session: + result = await session.execute(select(func.count()).select_from(SpoolCatalogEntry)) + count = result.scalar() or 0 + if count > 0: + return # Already seeded + + for name, weight in DEFAULT_SPOOL_CATALOG: + session.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True)) + await session.commit() + logger.info("Seeded %d default spool catalog entries", len(DEFAULT_SPOOL_CATALOG)) + + +async def seed_color_catalog(): + """Seed the color catalog with default entries if empty.""" + import logging + + from sqlalchemy import func, select + + from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG + from backend.app.models.color_catalog import ColorCatalogEntry + + logger = logging.getLogger(__name__) + + async with async_session() as session: + result = await session.execute(select(func.count()).select_from(ColorCatalogEntry)) + count = result.scalar() or 0 + if count > 0: + return # Already seeded + + for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG: + session.add( + ColorCatalogEntry( + manufacturer=manufacturer, + color_name=color_name, + hex_color=hex_color, + material=material, + is_default=True, + ) + ) + await session.commit() + logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG)) diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index a6c56d409..ff2ac4fd8 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -62,6 +62,12 @@ class Permission(StrEnum): FILAMENTS_UPDATE = "filaments:update" FILAMENTS_DELETE = "filaments:delete" + # Inventory (Spool Inventory, Spool Catalog, Color Catalog) + INVENTORY_READ = "inventory:read" + INVENTORY_CREATE = "inventory:create" + INVENTORY_UPDATE = "inventory:update" + INVENTORY_DELETE = "inventory:delete" + # Smart Plugs SMART_PLUGS_READ = "smart_plugs:read" SMART_PLUGS_CREATE = "smart_plugs:create" @@ -201,6 +207,12 @@ PERMISSION_CATEGORIES = { Permission.FILAMENTS_UPDATE, Permission.FILAMENTS_DELETE, ], + "Inventory": [ + Permission.INVENTORY_READ, + Permission.INVENTORY_CREATE, + Permission.INVENTORY_UPDATE, + Permission.INVENTORY_DELETE, + ], "Smart Plugs": [ Permission.SMART_PLUGS_READ, Permission.SMART_PLUGS_CREATE, @@ -335,6 +347,11 @@ DEFAULT_GROUPS = { Permission.FILAMENTS_CREATE.value, Permission.FILAMENTS_UPDATE.value, Permission.FILAMENTS_DELETE.value, + # Inventory - full access + Permission.INVENTORY_READ.value, + Permission.INVENTORY_CREATE.value, + Permission.INVENTORY_UPDATE.value, + Permission.INVENTORY_DELETE.value, # Smart Plugs - full access Permission.SMART_PLUGS_READ.value, Permission.SMART_PLUGS_CREATE.value, @@ -390,6 +407,7 @@ DEFAULT_GROUPS = { Permission.LIBRARY_READ.value, Permission.PROJECTS_READ.value, Permission.FILAMENTS_READ.value, + Permission.INVENTORY_READ.value, Permission.SMART_PLUGS_READ.value, Permission.CAMERA_VIEW.value, Permission.MAINTENANCE_READ.value, diff --git a/backend/app/main.py b/backend/app/main.py index fa0e59f29..b1c0a110a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -184,6 +184,7 @@ from backend.app.api.routes import ( firmware, github_backup, groups, + inventory, kprofiles, library, local_presets, @@ -497,6 +498,11 @@ async def on_printer_status_change(printer_id: int, state: PrinterState): ) +def _is_bambu_uuid(tray_uuid: str) -> bool: + """Check if a tray UUID looks like a valid Bambu Lab RFID UUID (non-empty, non-zero).""" + return bool(tray_uuid) and tray_uuid not in ("", "0" * len(tray_uuid)) + + async def on_ams_change(printer_id: int, ams_data: list): """Handle AMS data changes - sync to Spoolman if enabled and auto mode.""" logger = logging.getLogger(__name__) @@ -522,6 +528,138 @@ async def on_ams_change(printer_id: int, ams_data: list): except Exception as e: logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e) + # Auto-unlink spool assignments with stale fingerprints + try: + async with async_session() as db: + from sqlalchemy.orm import selectinload + + from backend.app.api.routes.inventory import _find_tray_in_ams_data + from backend.app.models.spool_assignment import SpoolAssignment as SA + + result = await db.execute(select(SA).where(SA.printer_id == printer_id).options(selectinload(SA.spool))) + stale = [] + for assignment in result.scalars().all(): + current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id) + if not current_tray: + stale.append(assignment) # Slot empty + else: + cur_color = current_tray.get("tray_color", "") + cur_type = current_tray.get("tray_type", "") + fp_color = assignment.fingerprint_color or "" + fp_type = assignment.fingerprint_type or "" + if cur_color != fp_color or cur_type != fp_type: + stale.append(assignment) # Spool changed + elif _is_bambu_uuid(current_tray.get("tray_uuid", "")): + # Only unlink if the assigned spool doesn't match this tag + tray_uuid = current_tray.get("tray_uuid", "") + tag_uid = current_tray.get("tag_uid", "") + spool = assignment.spool + if spool and ( + (spool.tray_uuid and spool.tray_uuid == tray_uuid) + or (spool.tag_uid and spool.tag_uid == tag_uid) + ): + continue # Same spool — keep assignment + stale.append(assignment) # Different Bambu spool inserted + for a in stale: + await db.delete(a) + if stale: + await db.commit() + logger.info("Auto-unlinked %d stale spool assignments for printer %d", len(stale), printer_id) + except Exception as e: + logger.warning("Spool assignment cleanup failed: %s", e) + + # Auto-manage inventory spools from AMS tray data (skip if Spoolman manages AMS) + try: + async with async_session() as db: + from backend.app.api.routes.settings import get_setting + from backend.app.models.spool_assignment import SpoolAssignment as SA + from backend.app.services.spool_tag_matcher import ( + auto_assign_spool, + create_spool_from_tray, + get_spool_by_tag, + is_bambu_tag, + is_valid_tag, + ) + + _spoolman_on = await get_setting(db, "spoolman_enabled") + if not _spoolman_on or _spoolman_on.lower() != "true": + for ams_unit in ams_data: + if not isinstance(ams_unit, dict): + continue + ams_id = int(ams_unit.get("id", 0)) + for tray in ams_unit.get("tray", []): + if not isinstance(tray, dict): + continue + tray_id = int(tray.get("id", 0)) + tag_uid = tray.get("tag_uid", "") + tray_uuid = tray.get("tray_uuid", "") + tray_info_idx = tray.get("tray_info_idx", "") + if not tray.get("tray_type"): + continue # Empty slot + # Skip if assignment already exists for this slot + existing = await db.execute( + select(SA).where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id) + ) + if existing.scalar_one_or_none(): + continue + + if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx): + # BL spool with RFID tag: auto-match or auto-create + spool = await get_spool_by_tag(db, tag_uid, tray_uuid) + if not spool: + spool = await create_spool_from_tray(db, tray) + await auto_assign_spool( + printer_id, + ams_id, + tray_id, + spool, + printer_manager, + db, + ) + await db.commit() + await ws_manager.broadcast( + { + "type": "spool_auto_assigned", + "printer_id": printer_id, + "ams_id": ams_id, + "tray_id": tray_id, + "spool_id": spool.id, + } + ) + logger.info( + "RFID auto-assigned spool %d to printer %d AMS%d-T%d", + spool.id, + printer_id, + ams_id, + tray_id, + ) + elif is_valid_tag(tag_uid, tray_uuid): + # Non-BL spool with some tag — let user choose + await ws_manager.broadcast( + { + "type": "unknown_tag", + "printer_id": printer_id, + "ams_id": ams_id, + "tray_id": tray_id, + "tag_uid": tag_uid, + "tray_uuid": tray_uuid, + } + ) + else: + # No tag at all — let user choose from inventory + await ws_manager.broadcast( + { + "type": "unknown_tag", + "printer_id": printer_id, + "ams_id": ams_id, + "tray_id": tray_id, + "tag_uid": "", + "tray_uuid": "", + } + ) + except Exception as e: + logger.warning("RFID spool auto-assign failed: %s", e) + try: async with async_session() as db: from backend.app.api.routes.settings import get_setting @@ -753,6 +891,19 @@ async def on_print_start(printer_id: int, data: dict): except Exception: pass # Don't fail print start callback if MQTT fails + # Capture AMS tray remain% for filament consumption tracking (skip if Spoolman handles usage) + try: + async with async_session() as db: + from backend.app.api.routes.settings import get_setting + + _spoolman_on = await get_setting(db, "spoolman_enabled") + if not _spoolman_on or _spoolman_on.lower() != "true": + from backend.app.services.usage_tracker import on_print_start as usage_on_print_start + + await usage_on_print_start(printer_id, data, printer_manager) + except Exception as e: + logger.warning("Usage tracker on_print_start failed: %s", e) + # Track if notification was sent (to avoid sending twice) notification_sent = False @@ -1867,6 +2018,29 @@ async def on_print_complete(printer_id: int, data: dict): log_timing("Archive status update") + # Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage) + try: + async with async_session() as db: + from backend.app.api.routes.settings import get_setting + + _spoolman_on = await get_setting(db, "spoolman_enabled") + if not _spoolman_on or _spoolman_on.lower() != "true": + from backend.app.services.usage_tracker import on_print_complete as usage_on_print_complete + + async with async_session() as db: + usage_results = await usage_on_print_complete(printer_id, data, printer_manager, db) + if usage_results: + await ws_manager.broadcast( + { + "type": "spool_usage_logged", + "printer_id": printer_id, + "usage": usage_results, + } + ) + log_timing("Usage tracker") + except Exception as e: + logger.warning("Usage tracker on_print_complete failed: %s", e) + # Report filament usage to Spoolman if print completed successfully if data.get("status") == "completed": try: @@ -2936,6 +3110,7 @@ app.include_router(groups.router, prefix=app_settings.api_prefix) app.include_router(printers.router, prefix=app_settings.api_prefix) app.include_router(archives.router, prefix=app_settings.api_prefix) app.include_router(filaments.router, prefix=app_settings.api_prefix) +app.include_router(inventory.router, prefix=app_settings.api_prefix) app.include_router(settings_routes.router, prefix=app_settings.api_prefix) app.include_router(cloud.router, prefix=app_settings.api_prefix) app.include_router(local_presets.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 30b5f09c1..1a53df1c8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from backend.app.models.ams_history import AMSSensorHistory from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive +from backend.app.models.color_catalog import ColorCatalogEntry from backend.app.models.filament import Filament from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog from backend.app.models.group import Group, user_groups @@ -16,6 +17,11 @@ from backend.app.models.printer import Printer from backend.app.models.project import Project from backend.app.models.settings import Settings from backend.app.models.smart_plug import SmartPlug +from backend.app.models.spool import Spool +from backend.app.models.spool_assignment import SpoolAssignment +from backend.app.models.spool_catalog import SpoolCatalogEntry +from backend.app.models.spool_k_profile import SpoolKProfile +from backend.app.models.spool_usage_history import SpoolUsageHistory from backend.app.models.user import User __all__ = [ @@ -43,4 +49,10 @@ __all__ = [ "GitHubBackupLog", "LocalPreset", "OrcaBaseProfile", + "Spool", + "SpoolKProfile", + "SpoolAssignment", + "SpoolCatalogEntry", + "SpoolUsageHistory", + "ColorCatalogEntry", ] diff --git a/backend/app/models/color_catalog.py b/backend/app/models/color_catalog.py new file mode 100644 index 000000000..fa2a66bdf --- /dev/null +++ b/backend/app/models/color_catalog.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class ColorCatalogEntry(Base): + """Color catalog entry for automatic color lookup when adding spools.""" + + __tablename__ = "color_catalog" + + id: Mapped[int] = mapped_column(primary_key=True) + manufacturer: Mapped[str] = mapped_column(String(200)) + color_name: Mapped[str] = mapped_column(String(200)) + hex_color: Mapped[str] = mapped_column(String(7)) # #RRGGBB + material: Mapped[str | None] = mapped_column(String(100)) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py new file mode 100644 index 000000000..893e4db66 --- /dev/null +++ b/backend/app/models/spool.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Float, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class Spool(Base): + """Spool inventory item for tracking filament spools and their properties.""" + + __tablename__ = "spool" + + id: Mapped[int] = mapped_column(primary_key=True) + material: Mapped[str] = mapped_column(String(50)) # PLA, PETG, ABS, etc. + subtype: Mapped[str | None] = mapped_column(String(50)) # Basic, Matte, Silk, etc. + color_name: Mapped[str | None] = mapped_column(String(100)) # "Jade White" + rgba: Mapped[str | None] = mapped_column(String(8)) # RRGGBBAA hex + brand: Mapped[str | None] = mapped_column(String(100)) # "Polymaker" + label_weight: Mapped[int] = mapped_column(Integer, default=1000) # Advertised net weight (g) + core_weight: Mapped[int] = mapped_column(Integer, default=250) # Empty spool weight (g) + weight_used: Mapped[float] = mapped_column(Float, default=0) # Consumed grams + slicer_filament: Mapped[str | None] = mapped_column(String(50)) # Preset ID (e.g. "GFL99") + slicer_filament_name: Mapped[str | None] = mapped_column(String(100)) # Preset name for slicer + nozzle_temp_min: Mapped[int | None] = mapped_column() # Override min temp + nozzle_temp_max: Mapped[int | None] = mapped_column() # Override max temp + note: Mapped[str | None] = mapped_column(String(500)) + added_full: Mapped[bool | None] = mapped_column() # Whether spool was added as full (unused) + last_used: Mapped[datetime | None] = mapped_column(DateTime) # Last time this spool was used in a print + encode_time: Mapped[datetime | None] = mapped_column(DateTime) # When spool was encoded/written to tag + tag_uid: Mapped[str | None] = mapped_column(String(16)) # RFID tag UID (16 hex chars) + tray_uuid: Mapped[str | None] = mapped_column(String(32)) # Bambu Lab spool UUID (32 hex chars) + data_origin: Mapped[str | None] = mapped_column(String(20)) # How data was populated: manual, rfid_auto, nfc_link + tag_type: Mapped[str | None] = mapped_column(String(20)) # Tag vendor: bambulab, generic, etc. + archived_at: Mapped[datetime | None] = mapped_column(DateTime) # NULL = active + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan") + assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan") + + +from backend.app.models.spool_assignment import SpoolAssignment # noqa: E402 +from backend.app.models.spool_k_profile import SpoolKProfile # noqa: E402 diff --git a/backend/app/models/spool_assignment.py b/backend/app/models/spool_assignment.py new file mode 100644 index 000000000..3230baea7 --- /dev/null +++ b/backend/app/models/spool_assignment.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class SpoolAssignment(Base): + """Assignment of a spool to a specific AMS slot on a printer.""" + + __tablename__ = "spool_assignment" + + id: Mapped[int] = mapped_column(primary_key=True) + spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE")) + printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) + ams_id: Mapped[int] = mapped_column(Integer) # 0-3, 128+ (HT), 254/255 (ext) + tray_id: Mapped[int] = mapped_column(Integer) # 0-3 + fingerprint_color: Mapped[str | None] = mapped_column(String(8)) # tray_color snapshot + fingerprint_type: Mapped[str | None] = mapped_column(String(50)) # tray_type snapshot + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + spool: Mapped["Spool"] = relationship(back_populates="assignments") + printer: Mapped["Printer"] = relationship() + + __table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id"),) + + +from backend.app.models.printer import Printer # noqa: E402, F401 +from backend.app.models.spool import Spool # noqa: E402, F401 diff --git a/backend/app/models/spool_catalog.py b/backend/app/models/spool_catalog.py new file mode 100644 index 000000000..d2370b982 --- /dev/null +++ b/backend/app/models/spool_catalog.py @@ -0,0 +1,18 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class SpoolCatalogEntry(Base): + """Spool weight catalog entry for weight lookup when adding spools.""" + + __tablename__ = "spool_catalog" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200)) + weight: Mapped[int] = mapped_column(Integer) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/models/spool_k_profile.py b/backend/app/models/spool_k_profile.py new file mode 100644 index 000000000..165b86a54 --- /dev/null +++ b/backend/app/models/spool_k_profile.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class SpoolKProfile(Base): + """K-value calibration profile for a spool on a specific printer/nozzle combo.""" + + __tablename__ = "spool_k_profile" + + id: Mapped[int] = mapped_column(primary_key=True) + spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE")) + printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) + extruder: Mapped[int] = mapped_column(Integer, default=0) # 0 or 1 (H2D) + nozzle_diameter: Mapped[str] = mapped_column(String(10), default="0.4") # "0.4", "0.6" + nozzle_type: Mapped[str | None] = mapped_column(String(50)) + k_value: Mapped[float] = mapped_column(Float) # e.g. 0.020 + name: Mapped[str | None] = mapped_column(String(100)) # Profile display name + cali_idx: Mapped[int | None] = mapped_column(Integer) # Calibration index on printer + setting_id: Mapped[str | None] = mapped_column(String(50)) # Full setting ID + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + spool: Mapped["Spool"] = relationship(back_populates="k_profiles") + printer: Mapped["Printer"] = relationship() + + +from backend.app.models.printer import Printer # noqa: E402, F401 +from backend.app.models.spool import Spool # noqa: E402, F401 diff --git a/backend/app/models/spool_usage_history.py b/backend/app/models/spool_usage_history.py new file mode 100644 index 000000000..15fcb0543 --- /dev/null +++ b/backend/app/models/spool_usage_history.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class SpoolUsageHistory(Base): + """Record of filament consumption for a spool during a print.""" + + __tablename__ = "spool_usage_history" + + id: Mapped[int] = mapped_column(primary_key=True) + spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE")) + printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL")) + print_name: Mapped[str | None] = mapped_column(String(500)) + weight_used: Mapped[float] = mapped_column(Float, default=0) + percent_used: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[str] = mapped_column(String(20), default="completed") # completed/failed/aborted + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py index a4f8c80f7..f315b4000 100644 --- a/backend/app/schemas/cloud.py +++ b/backend/app/schemas/cloud.py @@ -49,6 +49,7 @@ class SlicerSetting(BaseModel): version: str | None = None user_id: str | None = None updated_time: str | None = None + is_custom: bool = False class SlicerSettingsResponse(BaseModel): diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py new file mode 100644 index 000000000..00d25928d --- /dev/null +++ b/backend/app/schemas/spool.py @@ -0,0 +1,108 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class SpoolBase(BaseModel): + material: str = Field(..., min_length=1, max_length=50) + subtype: str | None = None + color_name: str | None = None + rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$") + brand: str | None = None + label_weight: int = 1000 + core_weight: int = 250 + weight_used: float = 0 + slicer_filament: str | None = None + slicer_filament_name: str | None = None + nozzle_temp_min: int | None = None + nozzle_temp_max: int | None = None + note: str | None = None + tag_uid: str | None = None + tray_uuid: str | None = None + data_origin: str | None = None + tag_type: str | None = None + + +class SpoolCreate(SpoolBase): + pass + + +class SpoolUpdate(BaseModel): + material: str | None = None + subtype: str | None = None + color_name: str | None = None + rgba: str | None = None + brand: str | None = None + label_weight: int | None = None + core_weight: int | None = None + weight_used: float | None = None + slicer_filament: str | None = None + slicer_filament_name: str | None = None + nozzle_temp_min: int | None = None + nozzle_temp_max: int | None = None + note: str | None = None + tag_uid: str | None = None + tray_uuid: str | None = None + data_origin: str | None = None + tag_type: str | None = None + + +class SpoolKProfileBase(BaseModel): + printer_id: int + extruder: int = 0 + nozzle_diameter: str = "0.4" + nozzle_type: str | None = None + k_value: float + name: str | None = None + cali_idx: int | None = None + setting_id: str | None = None + + +class SpoolKProfileResponse(SpoolKProfileBase): + id: int + spool_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class SpoolResponse(SpoolBase): + id: int + added_full: bool | None = None + last_used: datetime | None = None + encode_time: datetime | None = None + tag_uid: str | None = None + tray_uuid: str | None = None + data_origin: str | None = None + tag_type: str | None = None + archived_at: datetime | None = None + created_at: datetime + updated_at: datetime + k_profiles: list[SpoolKProfileResponse] = [] + + class Config: + from_attributes = True + + +class SpoolAssignmentCreate(BaseModel): + spool_id: int + printer_id: int + ams_id: int + tray_id: int + + +class SpoolAssignmentResponse(BaseModel): + id: int + spool_id: int + printer_id: int + ams_id: int + tray_id: int + fingerprint_color: str | None = None + fingerprint_type: str | None = None + created_at: datetime + spool: SpoolResponse | None = None + configured: bool = False + + class Config: + from_attributes = True diff --git a/backend/app/schemas/spool_usage.py b/backend/app/schemas/spool_usage.py new file mode 100644 index 000000000..2ba82ac05 --- /dev/null +++ b/backend/app/schemas/spool_usage.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class SpoolUsageHistoryResponse(BaseModel): + id: int + spool_id: int + printer_id: int | None = None + print_name: str | None = None + weight_used: float + percent_used: int + status: str + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/services/spool_tag_matcher.py b/backend/app/services/spool_tag_matcher.py new file mode 100644 index 000000000..62319db86 --- /dev/null +++ b/backend/app/services/spool_tag_matcher.py @@ -0,0 +1,273 @@ +"""RFID tag matching and auto-assignment for spool inventory.""" + +import logging + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from backend.app.models.spool import Spool +from backend.app.models.spool_assignment import SpoolAssignment + +logger = logging.getLogger(__name__) + +# Zero-value constants for tag validation +ZERO_TAG_UID = "0000000000000000" +ZERO_TRAY_UUID = "00000000000000000000000000000000" + + +def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool: + """Check if a tag/UUID pair contains a non-zero, non-empty value.""" + uid_valid = bool(tag_uid) and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid) + uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid) + return uid_valid or uuid_valid + + +def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool: + """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset).""" + uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid) + has_preset = bool(tray_info_idx) + return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset) + + +async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool: + """Create a new Spool inventory entry from AMS tray MQTT data. + + Extracts material, subtype, color, temps, and tag info from the tray dict. + Looks up core_weight from the spool catalog if a Bambu Lab entry matches. + """ + from backend.app.models.color_catalog import ColorCatalogEntry + from backend.app.models.spool_catalog import SpoolCatalogEntry + + tray_type = tray_data.get("tray_type", "") # "PLA" + tray_sub_brands = tray_data.get("tray_sub_brands", "") # "PLA Basic" + tray_color = tray_data.get("tray_color", "FFFFFFFF") # RRGGBBAA + tray_id_name = tray_data.get("tray_id_name", "") # Color name e.g. "Jade White" + tag_uid = tray_data.get("tag_uid", "") + tray_uuid = tray_data.get("tray_uuid", "") + tray_info_idx = tray_data.get("tray_info_idx", "") + nozzle_min = tray_data.get("nozzle_temp_min", 0) + nozzle_max = tray_data.get("nozzle_temp_max", 0) + label_weight = int(tray_data.get("tray_weight", 1000)) + + # Parse material and subtype from tray_sub_brands ("PLA Basic" → material="PLA", subtype="Basic") + material = tray_type or "PLA" + subtype = None + if tray_sub_brands and " " in tray_sub_brands: + parts = tray_sub_brands.split(" ", 1) + if parts[0].upper() == material.upper(): + subtype = parts[1] + else: + # tray_sub_brands is the full material name (e.g. "PETG-HF") + material = tray_sub_brands + elif tray_sub_brands and tray_sub_brands.upper() != material.upper(): + material = tray_sub_brands + + # Try to find color name from color catalog + color_name = tray_id_name or None + rgba = tray_color if tray_color else None + + # Look up color catalog for a better color name if we only have hex + if not color_name and rgba and len(rgba) >= 6: + hex_prefix = f"#{rgba[:6].upper()}" + cat_result = await db.execute( + select(ColorCatalogEntry) + .where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix) + .where(func.upper(ColorCatalogEntry.manufacturer) == "BAMBU LAB") + .limit(1) + ) + entry = cat_result.scalar_one_or_none() + if entry: + color_name = entry.color_name + + # Look up core weight from spool catalog + core_weight = 250 # Default for Bambu Lab plastic spools + cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10)) + for entry in cat_result.scalars().all(): + # Pick the best match (prefer exact, fallback to first Bambu Lab entry) + core_weight = entry.weight + break + + spool = Spool( + material=material, + subtype=subtype, + color_name=color_name, + rgba=rgba, + brand="Bambu Lab", + label_weight=label_weight, + core_weight=core_weight, + weight_used=0, + slicer_filament=tray_info_idx or None, + nozzle_temp_min=int(nozzle_min) if nozzle_min else None, + nozzle_temp_max=int(nozzle_max) if nozzle_max else None, + tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None, + tray_uuid=tray_uuid if tray_uuid and tray_uuid != ZERO_TRAY_UUID else None, + data_origin="rfid_auto", + tag_type="bambulab", + ) + db.add(spool) + await db.flush() + + logger.info( + "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)", + spool.id, + material, + subtype or "", + color_name or "", + tag_uid, + tray_uuid, + ) + return spool + + +async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None: + """Look up an active spool by RFID tag UID or Bambu Lab tray UUID. + + Prefers tray_uuid match over tag_uid (more reliable). + """ + # Try tray_uuid first (Bambu Lab spools — more reliable) + if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid): + result = await db.execute( + select(Spool) + .options(selectinload(Spool.k_profiles)) + .where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None)) + .limit(1) + ) + spool = result.scalar_one_or_none() + if spool: + return spool + + # Fall back to tag_uid + if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid): + result = await db.execute( + select(Spool) + .options(selectinload(Spool.k_profiles)) + .where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None)) + .limit(1) + ) + spool = result.scalar_one_or_none() + if spool: + return spool + + return None + + +async def auto_assign_spool( + printer_id: int, + ams_id: int, + tray_id: int, + spool: Spool, + printer_manager, + db: AsyncSession, +) -> SpoolAssignment: + """Create a SpoolAssignment and auto-configure the AMS slot via MQTT. + + Reuses the same MQTT configuration logic as the manual assign endpoint. + """ + from backend.app.api.routes.inventory import MATERIAL_TEMPS + + # Get current tray state for fingerprint + fingerprint_color = None + fingerprint_type = None + state = printer_manager.get_status(printer_id) + if state and state.raw_data: + from backend.app.api.routes.inventory import _find_tray_in_ams_data + + ams = state.raw_data.get("ams", []) + if isinstance(ams, dict): + ams = ams.get("ams", []) + tray = _find_tray_in_ams_data( + ams, + ams_id, + tray_id, + ) + if tray: + fingerprint_color = tray.get("tray_color", "") + fingerprint_type = tray.get("tray_type", "") + + # Upsert: remove old assignment for this slot + existing = await db.execute( + select(SpoolAssignment).where( + SpoolAssignment.printer_id == printer_id, + SpoolAssignment.ams_id == ams_id, + SpoolAssignment.tray_id == tray_id, + ) + ) + old = existing.scalar_one_or_none() + if old: + await db.delete(old) + await db.flush() + + assignment = SpoolAssignment( + spool_id=spool.id, + printer_id=printer_id, + ams_id=ams_id, + tray_id=tray_id, + fingerprint_color=fingerprint_color, + fingerprint_type=fingerprint_type, + ) + db.add(assignment) + await db.flush() + + # Auto-configure AMS slot via MQTT + try: + client = printer_manager.get_client(printer_id) + if client: + tray_type = spool.material + tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material + tray_color = spool.rgba or "FFFFFFFF" + tray_info_idx = spool.slicer_filament or "" + setting_id = "" + + temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240)) + if spool.nozzle_temp_min is not None: + temp_min = spool.nozzle_temp_min + if spool.nozzle_temp_max is not None: + temp_max = spool.nozzle_temp_max + + client.ams_set_filament_setting( + ams_id=ams_id, + tray_id=tray_id, + tray_info_idx=tray_info_idx, + tray_type=tray_type, + tray_sub_brands=tray_sub_brands, + tray_color=tray_color, + nozzle_temp_min=temp_min, + nozzle_temp_max=temp_max, + setting_id=setting_id, + ) + + # Apply K-profile if available + nozzle_diameter = "0.4" + if state and state.nozzles: + nd = state.nozzles[0].nozzle_diameter + if nd: + nozzle_diameter = nd + + matching_kp = None + for kp in spool.k_profiles: + if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter: + matching_kp = kp + break + + if matching_kp and matching_kp.cali_idx is not None: + client.extrusion_cali_sel( + ams_id=ams_id, + tray_id=tray_id, + cali_idx=matching_kp.cali_idx, + filament_id=tray_info_idx, + nozzle_diameter=nozzle_diameter, + setting_id=matching_kp.setting_id, + ) + + logger.info( + "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d (RFID match)", + ams_id, + tray_id, + spool.id, + printer_id, + ) + except Exception as e: + logger.warning("MQTT auto-configure failed for spool %d (RFID match): %s", spool.id, e) + + return assignment diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py new file mode 100644 index 000000000..a8ebbc8ad --- /dev/null +++ b/backend/app/services/usage_tracker.py @@ -0,0 +1,179 @@ +"""Automatic filament consumption tracking. + +Captures AMS tray remain% at print start, then computes consumption +deltas at print complete to update spool weight_used and last_used. +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.spool import Spool +from backend.app.models.spool_assignment import SpoolAssignment +from backend.app.models.spool_usage_history import SpoolUsageHistory + +logger = logging.getLogger(__name__) + + +@dataclass +class PrintSession: + printer_id: int + print_name: str + started_at: datetime + tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict) + + +# Module-level storage, keyed by printer_id +_active_sessions: dict[int, PrintSession] = {} + + +async def on_print_start(printer_id: int, data: dict, printer_manager) -> None: + """Capture AMS tray remain% at print start.""" + state = printer_manager.get_status(printer_id) + if not state or not state.raw_data: + logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id) + return + + ams_data = state.raw_data.get("ams", {}).get("ams", []) + if not ams_data: + logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id) + return + + tray_remain_start: dict[tuple[int, int], int] = {} + for ams_unit in ams_data: + ams_id = int(ams_unit.get("id", 0)) + for tray in ams_unit.get("tray", []): + tray_id = int(tray.get("id", 0)) + remain = tray.get("remain", -1) + if isinstance(remain, int) and 0 <= remain <= 100: + tray_remain_start[(ams_id, tray_id)] = remain + + if not tray_remain_start: + logger.debug("[UsageTracker] No valid remain%% data for printer %d", printer_id) + return + + print_name = data.get("subtask_name", "") or data.get("filename", "unknown") + + session = PrintSession( + printer_id=printer_id, + print_name=print_name, + started_at=datetime.now(timezone.utc), + tray_remain_start=tray_remain_start, + ) + _active_sessions[printer_id] = session + logger.info( + "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s", + printer_id, + len(tray_remain_start), + {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()}, + ) + + +async def on_print_complete( + printer_id: int, + data: dict, + printer_manager, + db: AsyncSession, +) -> list[dict]: + """Compute consumption deltas and update spool weight_used/last_used. + + Returns a list of dicts describing what was logged (for WebSocket broadcast). + """ + session = _active_sessions.pop(printer_id, None) + if not session: + logger.debug("[UsageTracker] No active session for printer %d, skipping", printer_id) + return [] + + # Read current remain% + state = printer_manager.get_status(printer_id) + if not state or not state.raw_data: + logger.warning("[UsageTracker] No state at print complete for printer %d", printer_id) + return [] + + ams_data = state.raw_data.get("ams", {}).get("ams", []) + status = data.get("status", "completed") + results = [] + + for ams_unit in ams_data: + ams_id = int(ams_unit.get("id", 0)) + for tray in ams_unit.get("tray", []): + tray_id = int(tray.get("id", 0)) + key = (ams_id, tray_id) + + if key not in session.tray_remain_start: + continue + + current_remain = tray.get("remain", -1) + if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100: + continue + + start_remain = session.tray_remain_start[key] + delta_pct = start_remain - current_remain + + if delta_pct <= 0: + continue # No consumption or tray was refilled + + # Look up SpoolAssignment for this slot + result = await db.execute( + select(SpoolAssignment).where( + SpoolAssignment.printer_id == printer_id, + SpoolAssignment.ams_id == ams_id, + SpoolAssignment.tray_id == tray_id, + ) + ) + assignment = result.scalar_one_or_none() + if not assignment: + continue + + # Load spool + spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id)) + spool = spool_result.scalar_one_or_none() + if not spool: + continue + + # Compute weight consumed + weight_grams = (delta_pct / 100.0) * spool.label_weight + + # Update spool + spool.weight_used = (spool.weight_used or 0) + weight_grams + spool.last_used = datetime.now(timezone.utc) + + # Insert usage history record + history = SpoolUsageHistory( + spool_id=spool.id, + printer_id=printer_id, + print_name=session.print_name, + weight_used=round(weight_grams, 1), + percent_used=delta_pct, + status=status, + ) + db.add(history) + + results.append( + { + "spool_id": spool.id, + "weight_used": round(weight_grams, 1), + "percent_used": delta_pct, + "ams_id": ams_id, + "tray_id": tray_id, + } + ) + + logger.info( + "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)", + spool.id, + weight_grams, + delta_pct, + printer_id, + ams_id, + tray_id, + status, + ) + + if results: + await db.commit() + + return results diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d62d353d7..c3a072159 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { FileManagerPage } from './pages/FileManagerPage'; import { CameraPage } from './pages/CameraPage'; import { StreamOverlayPage } from './pages/StreamOverlayPage'; import { ExternalLinkPage } from './pages/ExternalLinkPage'; +import InventoryPage from './pages/InventoryPage'; import { SystemInfoPage } from './pages/SystemInfoPage'; import { LoginPage } from './pages/LoginPage'; import { SetupPage } from './pages/SetupPage'; @@ -122,6 +123,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1bec8600e..619101610 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -812,6 +812,29 @@ export interface SlicerSetting { version: string | null; user_id: string | null; updated_time: string | null; + is_custom: boolean; +} + +export interface SpoolCatalogEntry { + id: number; + name: string; + weight: number; + is_default: boolean; +} + +export interface ColorCatalogEntry { + id: number; + manufacturer: string; + color_name: string; + hex_color: string; + material: string | null; + is_default: boolean; +} + +export interface ColorLookupResult { + found: boolean; + hex_color: string | null; + material: string | null; } export interface SlicerSettingsResponse { @@ -853,6 +876,12 @@ export interface SlicerSettingDeleteResponse { message: string; } +// Built-in filament fallback (static table from backend) +export interface BuiltinFilament { + filament_id: string; + name: string; +} + // Local preset types (OrcaSlicer imports) export interface LocalPreset { id: number; @@ -1686,6 +1715,84 @@ export interface LinkedSpoolsMap { linked: Record; // tag (uppercase) -> spool info } +// Inventory types +export interface InventorySpool { + id: number; + material: string; + subtype: string | null; + color_name: string | null; + rgba: string | null; + brand: string | null; + label_weight: number; + core_weight: number; + weight_used: number; + slicer_filament: string | null; + slicer_filament_name: string | null; + nozzle_temp_min: number | null; + nozzle_temp_max: number | null; + note: string | null; + added_full: boolean | null; + last_used: string | null; + encode_time: string | null; + tag_uid: string | null; + tray_uuid: string | null; + data_origin: string | null; + tag_type: string | null; + archived_at: string | null; + created_at: string; + updated_at: string; + k_profiles?: SpoolKProfile[]; +} + +export interface SpoolUsageRecord { + id: number; + spool_id: number; + printer_id: number | null; + print_name: string | null; + weight_used: number; + percent_used: number; + status: string; + created_at: string; +} + +export interface SpoolKProfile { + id: number; + spool_id: number; + printer_id: number; + extruder: number; + nozzle_diameter: string; + nozzle_type: string | null; + k_value: number; + name: string | null; + cali_idx: number | null; + setting_id: string | null; + created_at: string; +} + +export interface SpoolKProfileInput { + printer_id: number; + extruder?: number; + nozzle_diameter?: string; + nozzle_type?: string | null; + k_value: number; + name?: string | null; + cali_idx?: number | null; + setting_id?: string | null; +} + +export interface SpoolAssignment { + id: number; + spool_id: number; + printer_id: number; + ams_id: number; + tray_id: number; + fingerprint_color: string | null; + fingerprint_type: string | null; + spool?: InventorySpool | null; + configured: boolean; + created_at: string; +} + // Update types export interface VersionInfo { version: string; @@ -2019,7 +2126,7 @@ export const api = { request<{ message: string; auth_enabled: boolean }>('/auth/disable', { method: 'POST', }), - + // Advanced Authentication testSMTP: (data: TestSMTPRequest) => request('/auth/smtp/test', { @@ -2903,6 +3010,8 @@ export const api = { request<{ success: boolean }>('/cloud/logout', { method: 'POST' }), getCloudSettings: (version = '02.04.00.70') => request(`/cloud/settings?version=${version}`), + getBuiltinFilaments: () => + request('/cloud/builtin-filaments'), getCloudSettingDetail: (settingId: string) => request(`/cloud/settings/${settingId}`), createCloudSetting: (data: SlicerSettingCreate) => @@ -3242,6 +3351,78 @@ export const api = { body: JSON.stringify(data), }), + // Inventory + getSpools: (includeArchived = false) => + request(`/inventory/spools?include_archived=${includeArchived}`), + getSpool: (id: number) => request(`/inventory/spools/${id}`), + createSpool: (data: Omit) => + request('/inventory/spools', { + method: 'POST', + body: JSON.stringify(data), + }), + updateSpool: (id: number, data: Partial>) => + request(`/inventory/spools/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteSpool: (id: number) => + request<{ status: string }>(`/inventory/spools/${id}`, { method: 'DELETE' }), + archiveSpool: (id: number) => + request(`/inventory/spools/${id}/archive`, { method: 'POST' }), + restoreSpool: (id: number) => + request(`/inventory/spools/${id}/restore`, { method: 'POST' }), + getSpoolKProfiles: (spoolId: number) => + request(`/inventory/spools/${spoolId}/k-profiles`), + saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) => + request(`/inventory/spools/${spoolId}/k-profiles`, { + method: 'PUT', + body: JSON.stringify(profiles), + }), + getAssignments: (printerId?: number) => + request(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`), + assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) => + request('/inventory/assignments', { + method: 'POST', + body: JSON.stringify(data), + }), + unassignSpool: (printerId: number, amsId: number, trayId: number) => + request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }), + getSpoolCatalog: () => + request('/inventory/catalog'), + addCatalogEntry: (data: { name: string; weight: number }) => + request('/inventory/catalog', { method: 'POST', body: JSON.stringify(data) }), + updateCatalogEntry: (id: number, data: { name: string; weight: number }) => + request(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteCatalogEntry: (id: number) => + request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }), + resetSpoolCatalog: () => + request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }), + getColorCatalog: () => + request('/inventory/colors'), + addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) => + request('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }), + updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) => + request(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteColorEntry: (id: number) => + request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }), + resetColorCatalog: () => + request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }), + lookupColor: (manufacturer: string, colorName: string, material?: string) => + request(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`), + linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) => + request(`/inventory/spools/${spoolId}/link-tag`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + getSpoolUsageHistory: (spoolId: number, limit = 50) => + request(`/inventory/spools/${spoolId}/usage?limit=${limit}`), + getAllUsageHistory: (limit = 100, printerId?: number) => + request(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`), + clearSpoolUsageHistory: (spoolId: number) => + request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }), + getFilamentPresets: () => + request('/cloud/filaments'), + // Updates getVersion: () => request('/updates/version'), checkForUpdates: () => request('/updates/check'), diff --git a/frontend/src/components/AssignSpoolModal.tsx b/frontend/src/components/AssignSpoolModal.tsx new file mode 100644 index 000000000..6983f7861 --- /dev/null +++ b/frontend/src/components/AssignSpoolModal.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { X, Loader2, Package, Check, Search } from 'lucide-react'; +import { api } from '../api/client'; +import type { InventorySpool } from '../api/client'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; + +interface AssignSpoolModalProps { + isOpen: boolean; + onClose: () => void; + printerId: number; + amsId: number; + trayId: number; + trayInfo?: { + type: string; + color: string; + location: string; + }; +} + +export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo }: AssignSpoolModalProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const [selectedSpoolId, setSelectedSpoolId] = useState(null); + const [searchFilter, setSearchFilter] = useState(''); + + const { data: spools, isLoading } = useQuery({ + queryKey: ['inventory-spools'], + queryFn: () => api.getSpools(), + enabled: isOpen, + }); + + const assignMutation = useMutation({ + mutationFn: (spoolId: number) => + api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); + queryClient.invalidateQueries({ queryKey: ['printer-status'] }); + showToast(t('inventory.assignSuccess'), 'success'); + onClose(); + }, + onError: (error: Error) => { + showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error'); + }, + }); + + if (!isOpen) return null; + + const filteredSpools = spools?.filter((spool: InventorySpool) => { + if (!searchFilter) return true; + const q = searchFilter.toLowerCase(); + return ( + spool.material.toLowerCase().includes(q) || + (spool.brand?.toLowerCase().includes(q) ?? false) || + (spool.color_name?.toLowerCase().includes(q) ?? false) || + (spool.subtype?.toLowerCase().includes(q) ?? false) + ); + }); + + const handleAssign = () => { + if (selectedSpoolId) { + assignMutation.mutate(selectedSpoolId); + } + }; + + return ( +
+
+ +
+ {/* Header */} +
+
+ +

{t('inventory.assignSpool')}

+
+ +
+ + {/* Content */} +
+ {/* Tray info */} + {trayInfo && ( +
+

{t('inventory.selectSpool')}:

+
+ {trayInfo.color && ( + + )} + {trayInfo.type || 'Empty'} + ({trayInfo.location}) +
+
+ )} + + {/* Search filter */} +
+ + setSearchFilter(e.target.value)} + placeholder={t('inventory.searchSpools')} + className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green" + /> +
+ + {/* Spool list */} +
+ {isLoading ? ( +
+ +
+ ) : filteredSpools && filteredSpools.length > 0 ? ( +
+ {filteredSpools.map((spool: InventorySpool) => ( + + ))} +
+ ) : ( +
+

{t('inventory.noSpools')}

+
+ )} +
+
+ + {/* Footer */} +
+ + +
+ + {assignMutation.isError && ( +
+ {(assignMutation.error as Error).message} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ColorCatalogSettings.tsx b/frontend/src/components/ColorCatalogSettings.tsx new file mode 100644 index 000000000..777c585f5 --- /dev/null +++ b/frontend/src/components/ColorCatalogSettings.tsx @@ -0,0 +1,583 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react'; +import { api, getAuthToken } from '../api/client'; +import type { ColorCatalogEntry } from '../api/client'; +import { useToast } from '../contexts/ToastContext'; +import { Card, CardHeader, CardContent } from './Card'; +import { ConfirmModal } from './ConfirmModal'; + +export function ColorCatalogSettings() { + const { t } = useTranslation(); + const { showToast } = useToast(); + const [catalog, setCatalog] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [filterManufacturer, setFilterManufacturer] = useState('Bambu Lab'); + const fileInputRef = useRef(null); + + // Add/Edit form state + const [showAddForm, setShowAddForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formManufacturer, setFormManufacturer] = useState(''); + const [formColorName, setFormColorName] = useState(''); + const [formHexColor, setFormHexColor] = useState('#FFFFFF'); + const [formMaterial, setFormMaterial] = useState(''); + const [saving, setSaving] = useState(false); + + // Sync state + const [syncing, setSyncing] = useState(false); + const [syncProgress, setSyncProgress] = useState<{ fetched: number; total: number } | null>(null); + + // Confirmation modals + const [deleteEntry, setDeleteEntry] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + + const loadCatalog = useCallback(async () => { + try { + const entries = await api.getColorCatalog(); + setCatalog(entries); + } catch { + showToast(t('settings.colorCatalog.loadFailed'), 'error'); + } finally { + setLoading(false); + } + }, [showToast, t]); + + useEffect(() => { + loadCatalog(); + }, [loadCatalog]); + + const manufacturers = [...new Set(catalog.map(e => e.manufacturer))].sort(); + + const filteredCatalog = catalog.filter(entry => { + const matchesSearch = search === '' || + entry.manufacturer.toLowerCase().includes(search.toLowerCase()) || + entry.color_name.toLowerCase().includes(search.toLowerCase()) || + (entry.material?.toLowerCase().includes(search.toLowerCase()) ?? false); + const matchesManufacturer = filterManufacturer === '' || entry.manufacturer === filterManufacturer; + return matchesSearch && matchesManufacturer; + }); + + const resetForm = () => { + setFormManufacturer(''); + setFormColorName(''); + setFormHexColor('#FFFFFF'); + setFormMaterial(''); + }; + + const handleAdd = async () => { + if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) { + showToast(t('settings.colorCatalog.fieldsRequired'), 'error'); + return; + } + setSaving(true); + try { + const entry = await api.addColorEntry({ + manufacturer: formManufacturer.trim(), + color_name: formColorName.trim(), + hex_color: formHexColor, + material: formMaterial.trim() || null, + }); + setCatalog(prev => [...prev, entry].sort((a, b) => + a.manufacturer.localeCompare(b.manufacturer) || + (a.material || '').localeCompare(b.material || '') || + a.color_name.localeCompare(b.color_name) + )); + setShowAddForm(false); + resetForm(); + showToast(t('settings.colorCatalog.colorAdded'), 'success'); + } catch { + showToast(t('settings.colorCatalog.addFailed'), 'error'); + } finally { + setSaving(false); + } + }; + + const startEdit = (entry: ColorCatalogEntry) => { + setEditingId(entry.id); + setFormManufacturer(entry.manufacturer); + setFormColorName(entry.color_name); + setFormHexColor(entry.hex_color); + setFormMaterial(entry.material || ''); + }; + + const cancelEdit = () => { + setEditingId(null); + resetForm(); + }; + + const handleUpdate = async (id: number) => { + if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) { + showToast(t('settings.colorCatalog.fieldsRequired'), 'error'); + return; + } + setSaving(true); + try { + const updated = await api.updateColorEntry(id, { + manufacturer: formManufacturer.trim(), + color_name: formColorName.trim(), + hex_color: formHexColor, + material: formMaterial.trim() || null, + }); + setCatalog(prev => + prev.map(e => e.id === id ? updated : e).sort((a, b) => + a.manufacturer.localeCompare(b.manufacturer) || + (a.material || '').localeCompare(b.material || '') || + a.color_name.localeCompare(b.color_name) + ) + ); + setEditingId(null); + resetForm(); + showToast(t('settings.colorCatalog.colorUpdated'), 'success'); + } catch { + showToast(t('settings.colorCatalog.updateFailed'), 'error'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!deleteEntry) return; + try { + await api.deleteColorEntry(deleteEntry.id); + setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id)); + showToast(t('settings.colorCatalog.colorDeleted'), 'success'); + } catch { + showToast(t('settings.colorCatalog.deleteFailed'), 'error'); + } finally { + setDeleteEntry(null); + } + }; + + const handleReset = async () => { + setShowResetConfirm(false); + setLoading(true); + try { + await api.resetColorCatalog(); + await loadCatalog(); + showToast(t('settings.colorCatalog.resetSuccess'), 'success'); + } catch { + showToast(t('settings.colorCatalog.resetFailed'), 'error'); + setLoading(false); + } + }; + + const handleSync = async () => { + setSyncing(true); + setSyncProgress(null); + try { + const headers: Record = {}; + const token = getAuthToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + const response = await fetch('/api/v1/inventory/colors/sync', { method: 'POST', headers }); + if (!response.ok) throw new Error('Failed to start sync'); + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body'); + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.type === 'progress') { + setSyncProgress({ fetched: data.total_fetched, total: data.total_available }); + } else if (data.type === 'complete') { + if (data.added === 0) { + showToast(t('settings.colorCatalog.syncUpToDate', { count: data.total_fetched }), 'success'); + } else { + showToast(t('settings.colorCatalog.syncComplete', { added: data.added, skipped: data.skipped }), 'success'); + } + } else if (data.type === 'error') { + showToast(`${t('settings.colorCatalog.syncError')}: ${data.error}`, 'error'); + } + } catch { + // Ignore parse errors + } + } + } + } + await loadCatalog(); + } catch { + showToast(t('settings.colorCatalog.syncFailed'), 'error'); + } finally { + setSyncing(false); + setSyncProgress(null); + } + }; + + const handleExport = () => { + const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({ + manufacturer, color_name, hex_color, material, + })); + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'color-catalog.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(t('settings.colorCatalog.exported', { count: catalog.length }), 'success'); + }; + + const handleImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text) as Array<{ + manufacturer: string; color_name: string; hex_color: string; material?: string | null; + }>; + if (!Array.isArray(data)) throw new Error('Invalid format'); + + let added = 0; + let skipped = 0; + for (const item of data) { + if (!item.manufacturer || !item.color_name || !item.hex_color) { skipped++; continue; } + const exists = catalog.some(c => + c.manufacturer.toLowerCase() === item.manufacturer.toLowerCase() && + c.color_name.toLowerCase() === item.color_name.toLowerCase() && + (c.material || '').toLowerCase() === (item.material || '').toLowerCase() + ); + if (exists) { skipped++; continue; } + try { + const entry = await api.addColorEntry({ + manufacturer: item.manufacturer, + color_name: item.color_name, + hex_color: item.hex_color, + material: item.material || null, + }); + setCatalog(prev => [...prev, entry].sort((a, b) => + a.manufacturer.localeCompare(b.manufacturer) || + (a.material || '').localeCompare(b.material || '') || + a.color_name.localeCompare(b.color_name) + )); + added++; + } catch { skipped++; } + } + showToast(t('settings.colorCatalog.imported', { added, skipped }), 'success'); + } catch { + showToast(t('settings.colorCatalog.importFailed'), 'error'); + } + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + return ( + + +
+ +

{t('settings.colorCatalog.title')}

+ ({catalog.length}) +
+
+ + + + + + +
+
+ +

+ {t('settings.colorCatalog.description')} +

+ + {/* Search and filter */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + {/* Add form */} + {showAddForm && ( +
+

{t('settings.colorCatalog.addNewColor')}

+
+ setFormManufacturer(e.target.value)} + /> + setFormColorName(e.target.value)} + /> +
+ setFormHexColor(e.target.value)} + /> + setFormHexColor(e.target.value)} + /> +
+ setFormMaterial(e.target.value)} + /> +
+ + +
+
+
+ )} + + {/* Filter info */} + {(search || filterManufacturer) && ( +
+ {t('settings.colorCatalog.showing', { filtered: filteredCatalog.length, total: catalog.length })} +
+ )} + + {/* Catalog list */} + {loading ? ( +
+ + {t('common.loading')} +
+ ) : ( +
+ + + + + + + + + + + + + {filteredCatalog.length === 0 ? ( + + + + ) : ( + filteredCatalog.map(entry => ( + + {editingId === entry.id ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + )} + + )) + )} + +
{t('settings.colorCatalog.manufacturer')}{t('settings.colorCatalog.colorName')}{t('settings.colorCatalog.hex')}{t('inventory.material')}
+ {search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')} +
+ setFormHexColor(e.target.value)} + /> + + setFormManufacturer(e.target.value)} + /> + + setFormColorName(e.target.value)} + /> + + setFormHexColor(e.target.value)} + /> + + setFormMaterial(e.target.value)} + /> + +
+ + +
+
+
+
{entry.manufacturer}{entry.color_name}{entry.hex_color}{entry.material || '-'} +
+ + +
+
+
+ )} +
+ + {/* Delete confirmation */} + {deleteEntry && ( + setDeleteEntry(null)} + /> + )} + + {/* Reset confirmation */} + {showResetConfirm && ( + setShowResetConfirm(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/ColumnConfigModal.tsx b/frontend/src/components/ColumnConfigModal.tsx new file mode 100644 index 000000000..9d6d3b0df --- /dev/null +++ b/frontend/src/components/ColumnConfigModal.tsx @@ -0,0 +1,187 @@ +import { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GripVertical, Eye, EyeOff, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; + +export interface ColumnConfig { + id: string; + label: string; + visible: boolean; +} + +interface ColumnConfigModalProps { + isOpen: boolean; + onClose: () => void; + columns: ColumnConfig[]; + defaultColumns: ColumnConfig[]; + onSave: (columns: ColumnConfig[]) => void; +} + +export function ColumnConfigModal({ isOpen, onClose, columns, defaultColumns, onSave }: ColumnConfigModalProps) { + const { t } = useTranslation(); + const [localColumns, setLocalColumns] = useState(columns); + const [draggedIndex, setDraggedIndex] = useState(null); + const draggedIndexRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setLocalColumns(columns.map((c) => ({ ...c }))); + } + }, [isOpen, columns]); + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const toggleVisibility = (index: number) => { + setLocalColumns((prev) => + prev.map((col, i) => (i === index ? { ...col, visible: !col.visible } : col)) + ); + }; + + const moveColumn = (fromIndex: number, toIndex: number) => { + if (toIndex < 0 || toIndex >= localColumns.length) return; + setLocalColumns((prev) => { + const newColumns = [...prev]; + const [moved] = newColumns.splice(fromIndex, 1); + newColumns.splice(toIndex, 0, moved); + return newColumns; + }); + }; + + const handleDragStart = (e: React.DragEvent, index: number) => { + draggedIndexRef.current = index; + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const from = draggedIndexRef.current; + if (from !== null && from !== index) { + moveColumn(from, index); + draggedIndexRef.current = index; + setDraggedIndex(index); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragEnd = () => { + draggedIndexRef.current = null; + setDraggedIndex(null); + }; + + const resetToDefaults = () => { + setLocalColumns(defaultColumns.map((c) => ({ ...c }))); + }; + + const handleSave = () => { + onSave(localColumns); + onClose(); + }; + + const visibleCount = localColumns.filter((c) => c.visible).length; + + return ( +
+ e.stopPropagation()}> + + {/* Header */} +

{t('inventory.configureColumns')}

+

+ {t('inventory.configureColumnsDesc')} + + ({visibleCount} {t('inventory.of')} {localColumns.length} {t('inventory.visible')}) + +

+ + {/* Column list */} +
+ {localColumns.map((column, index) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={handleDrop} + onDragEnd={handleDragEnd} + > + {/* Drag Handle */} +
+ +
+ + {/* Column Name */} + {column.label} + + {/* Move Buttons */} +
+ + +
+ + {/* Visibility Toggle */} + +
+ ))} +
+ + {/* Footer */} +
+ + + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ConfigureAmsSlotModal.tsx b/frontend/src/components/ConfigureAmsSlotModal.tsx index 7adffa5cf..9cdc7248a 100644 --- a/frontend/src/components/ConfigureAmsSlotModal.tsx +++ b/frontend/src/components/ConfigureAmsSlotModal.tsx @@ -226,11 +226,12 @@ export function ConfigureAmsSlotModal({ const [showSuccess, setShowSuccess] = useState(false); const [showExtendedColors, setShowExtendedColors] = useState(false); - // Fetch cloud settings - const { data: cloudSettings, isLoading: settingsLoading } = useQuery({ + // Fetch cloud settings (gracefully handle 401 when logged out) + const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({ queryKey: ['cloudSettings'], queryFn: () => api.getCloudSettings(), enabled: isOpen, + retry: false, }); // Fetch local presets @@ -240,6 +241,14 @@ export function ConfigureAmsSlotModal({ enabled: isOpen, }); + // Fetch built-in filament names (static fallback) + const { data: builtinFilaments, isLoading: builtinLoading } = useQuery({ + queryKey: ['builtinFilaments'], + queryFn: () => api.getBuiltinFilaments(), + enabled: isOpen, + staleTime: Infinity, + }); + // Fetch K profiles const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({ queryKey: ['kprofiles', printerId, nozzleDiameter], @@ -252,23 +261,29 @@ export function ConfigureAmsSlotModal({ mutationFn: async () => { if (!selectedPresetId) throw new Error('No filament preset selected'); - // Check if this is a local preset + // Determine preset source const isLocal = selectedPresetId.startsWith('local_'); + const isBuiltin = selectedPresetId.startsWith('builtin_'); const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null; + const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null; const localPreset = isLocal ? localPresets?.filament.find(p => p.id === localId) : null; + const builtinPreset = isBuiltin + ? builtinFilaments?.find(b => b.filament_id === builtinFilamentId) + : null; - // Get the selected cloud preset details (null for local presets) - const selectedPreset = !isLocal + // Get the selected cloud preset details (null for local/builtin presets) + const selectedPreset = (!isLocal && !isBuiltin) ? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId) : null; - if (!isLocal && !selectedPreset) throw new Error('Selected preset not found'); + if (!isLocal && !isBuiltin && !selectedPreset) throw new Error('Selected preset not found'); if (isLocal && !localPreset) throw new Error('Selected local preset not found'); + if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found'); // Parse the preset name for filament info - const presetName = isLocal ? localPreset!.name : selectedPreset!.name; + const presetName = isLocal ? localPreset!.name : isBuiltin ? builtinPreset!.name : selectedPreset!.name; const parsed = parsePresetName(presetName); // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020) @@ -287,6 +302,10 @@ export function ConfigureAmsSlotModal({ // Local presets have no Bambu Cloud mapping trayInfoIdx = ''; settingId = ''; + } else if (isBuiltin) { + // Built-in presets use the filament_id directly as tray_info_idx + trayInfoIdx = builtinFilamentId!; + settingId = ''; } else { // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id trayInfoIdx = convertToTrayInfoIdx(selectedPresetId); @@ -312,7 +331,7 @@ export function ConfigureAmsSlotModal({ let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190; let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230; - if (!isLocal || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) { + if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) { // Fall back to material-based defaults const material = (isLocal ? (localPreset?.filament_type || parsed.material) : parsed.material).toUpperCase(); if (material.includes('PLA')) { @@ -368,8 +387,8 @@ export function ConfigureAmsSlotModal({ // Save the preset mapping so we can display the correct name in the UI // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx, // which can't be resolved to a name via the filamentInfo API - const mappingPresetId = isLocal ? `local_${localId}` : selectedPresetId; - const mappingSource = isLocal ? 'local' : 'cloud'; + const mappingPresetId = isLocal ? `local_${localId}` : isBuiltin ? `builtin_${builtinFilamentId}` : selectedPresetId; + const mappingSource = isLocal ? 'local' : isBuiltin ? 'builtin' : 'cloud'; try { await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource); } catch (e) { @@ -405,15 +424,28 @@ export function ConfigureAmsSlotModal({ }, }); - // Unified preset item for the list (cloud + local) - type PresetItem = { id: string; name: string; source: 'cloud' | 'local'; isUser: boolean }; + // Unified preset item for the list (cloud + local + builtin fallback) + type PresetItem = { id: string; name: string; source: 'cloud' | 'local' | 'builtin'; isUser: boolean }; - // Filter filament presets based on search (merged cloud + local) + // Filter filament presets based on search (merged cloud + local + builtin) const filteredPresets = useMemo(() => { const query = searchQuery.toLowerCase(); const items: PresetItem[] = []; - // Add local presets first + // Collect IDs already covered by cloud and local to avoid duplicates in fallback + const coveredIds = new Set(); + + // 1. Cloud presets + if (cloudSettings?.filament) { + for (const cp of cloudSettings.filament) { + coveredIds.add(cp.setting_id); + if (!query || cp.name.toLowerCase().includes(query)) { + items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) }); + } + } + } + + // 2. Local presets if (localPresets?.filament) { for (const lp of localPresets.filament) { if (!query || lp.name.toLowerCase().includes(query)) { @@ -422,35 +454,46 @@ export function ConfigureAmsSlotModal({ } } - // Add cloud presets - if (cloudSettings?.filament) { - for (const cp of cloudSettings.filament) { - if (!query || cp.name.toLowerCase().includes(query)) { - items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) }); + // 3. Built-in filament names (fallback — only add entries not already covered) + if (builtinFilaments) { + for (const bf of builtinFilaments) { + if (coveredIds.has(bf.filament_id)) continue; + // Convert filament_id to setting_id format for cloud compatibility + // e.g. "GFA00" → cloud setting_id would be "GFSA00" (insert S after GF) + const settingId = bf.filament_id.startsWith('GF') + ? 'GFS' + bf.filament_id.slice(2) + : bf.filament_id; + if (coveredIds.has(settingId)) continue; + if (!query || bf.name.toLowerCase().includes(query)) { + items.push({ id: `builtin_${bf.filament_id}`, name: bf.name, source: 'builtin', isUser: false }); } } } - // Sort: local first, then user cloud presets, then built-in, alphabetically within groups + // Sort: cloud user presets first, then cloud built-in, then local, then builtin fallback return items.sort((a, b) => { - if (a.source === 'local' && b.source !== 'local') return -1; - if (a.source !== 'local' && b.source === 'local') return 1; + const sourceOrder = { cloud: 0, local: 1, builtin: 2 }; + if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source]; if (a.isUser && !b.isUser) return -1; if (!a.isUser && b.isUser) return 1; return a.name.localeCompare(b.name); }); - }, [cloudSettings?.filament, localPresets?.filament, searchQuery]); + }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery]); // Get full preset name for K profile filtering (brand + material, without printer suffix) const selectedPresetInfo = useMemo(() => { if (!selectedPresetId) return null; - // Resolve the name from either local or cloud presets + // Resolve the name from cloud, local, or builtin presets let presetName: string | null = null; if (selectedPresetId.startsWith('local_')) { const localId = parseInt(selectedPresetId.replace('local_', ''), 10); const lp = localPresets?.filament.find(p => p.id === localId); presetName = lp?.name || null; + } else if (selectedPresetId.startsWith('builtin_')) { + const filamentId = selectedPresetId.replace('builtin_', ''); + const bf = builtinFilaments?.find(b => b.filament_id === filamentId); + presetName = bf?.name || null; } else if (cloudSettings?.filament) { const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId); presetName = cp?.name || null; @@ -470,7 +513,7 @@ export function ConfigureAmsSlotModal({ material: parsed.material, brand: parsed.brand, }; - }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament]); + }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments]); // For backwards compatibility with the label const selectedMaterial = selectedPresetInfo?.fullName || ''; @@ -596,7 +639,7 @@ export function ConfigureAmsSlotModal({ if (!isOpen) return null; - const isLoading = settingsLoading || localLoading || kprofilesLoading; + const isLoading = (settingsLoading && !cloudError) || localLoading || builtinLoading || kprofilesLoading; const canSave = selectedPresetId && !configureMutation.isPending; // Get display color (custom or slot default) @@ -703,6 +746,11 @@ export function ConfigureAmsSlotModal({ {t('profiles.localProfiles.badge')} )} + {preset.source === 'builtin' && ( + + {t('configureAmsSlot.builtin')} + + )} {preset.isUser && ( {t('configureAmsSlot.custom')} diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index 9b074245a..0335368bc 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react'; +import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react'; interface FilamentData { vendor: 'Bambu Lab' | 'Generic'; @@ -21,6 +21,12 @@ interface SpoolmanConfig { spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link) } +interface InventoryConfig { + onAssignSpool?: () => void; + onUnassignSpool?: () => void; + assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null } | null; +} + interface ConfigureSlotConfig { enabled: boolean; onConfigure?: () => void; @@ -32,6 +38,7 @@ interface FilamentHoverCardProps { disabled?: boolean; className?: string; spoolman?: SpoolmanConfig; + inventory?: InventoryConfig; configureSlot?: ConfigureSlotConfig; } @@ -39,7 +46,7 @@ interface FilamentHoverCardProps { * A hover card that displays filament details when hovering over AMS slots. * Replaces the basic browser tooltip with a styled popover. */ -export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) { +export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); @@ -319,6 +326,50 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
)} + {/* Inventory section - assign/unassign for non-Bambu spools */} + {inventory && (data.vendor !== 'Bambu Lab' || !data.trayUuid) && ( +
+ {inventory.assignedSpool ? ( + <> +
+ + + {t('inventory.assigned')} + +
+

+ {inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''} + {inventory.assignedSpool.material} + {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''} +

+ {inventory.onUnassignSpool && ( + + )} + + ) : inventory.onAssignSpool ? ( + + ) : null} +
+ )} + {/* Configure slot section - always show if enabled */} {configureSlot?.enabled && (
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 477b87355..4cb46aa34 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Package, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; @@ -29,6 +29,7 @@ export const defaultNavItems: NavItem[] = [ { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' }, { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' }, + { id: 'inventory', to: '/inventory', icon: Package, labelKey: 'nav.inventory' }, { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' }, { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; @@ -118,6 +119,13 @@ export function Layout() { refetchInterval: 60 * 60 * 1000, // Check every hour }); + // Fetch Spoolman settings to determine if inventory should be hidden + const { data: spoolmanSettings } = useQuery({ + queryKey: ['spoolman-settings'], + queryFn: api.getSpoolmanSettings, + staleTime: 5 * 60 * 1000, + }); + // Fetch external links for sidebar const { data: externalLinks } = useQuery({ queryKey: ['external-links'], @@ -189,13 +197,13 @@ export function Layout() { // Determine if settings should be hidden (user role and auth enabled) const hideSettings = authEnabled && user?.role === 'user'; + // Hide inventory when Spoolman mode is active + const hideInventory = spoolmanSettings?.spoolman_enabled === 'true'; // Add items in stored order for (const id of sidebarOrder) { - // Skip settings if user is not admin - if (hideSettings && id === 'settings') { - continue; - } + if (hideSettings && id === 'settings') continue; + if (hideInventory && id === 'inventory') continue; if (navItemsMap.has(id) || extLinksMap.has(id)) { result.push(id); seen.add(id); @@ -204,10 +212,8 @@ export function Layout() { // Add any new internal nav items not in stored order for (const item of defaultNavItems) { - // Skip settings if user is not admin - if (hideSettings && item.id === 'settings') { - continue; - } + if (hideSettings && item.id === 'settings') continue; + if (hideInventory && item.id === 'inventory') continue; if (!seen.has(item.id)) { result.push(item.id); seen.add(item.id); diff --git a/frontend/src/components/LinkSpoolModal.tsx b/frontend/src/components/LinkSpoolModal.tsx index 4be13430d..cd5eaa4d9 100644 --- a/frontend/src/components/LinkSpoolModal.tsx +++ b/frontend/src/components/LinkSpoolModal.tsx @@ -1,190 +1,154 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { X, Loader2, Link2, Check } from 'lucide-react'; +import { X, Loader2, Search, Link } from 'lucide-react'; import { api } from '../api/client'; +import type { InventorySpool } from '../api/client'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; interface LinkSpoolModalProps { isOpen: boolean; onClose: () => void; + tagUid: string; trayUuid: string; - trayInfo?: { - type: string; - color: string; - location: string; - }; + printerId: number; + amsId: number; + trayId: number; } -export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) { +export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, amsId, trayId }: LinkSpoolModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); - const [selectedSpoolId, setSelectedSpoolId] = useState(null); + const [search, setSearch] = useState(''); - // Fetch unlinked spools - const { data: unlinkedSpools, isLoading } = useQuery({ - queryKey: ['unlinked-spools'], - queryFn: api.getUnlinkedSpools, + const { data: spools, isLoading } = useQuery({ + queryKey: ['inventory-spools'], + queryFn: () => api.getSpools(false), enabled: isOpen, }); - // Link mutation + // Filter to untagged spools matching search + const filteredSpools = useMemo(() => { + if (!spools) return []; + return spools.filter((s: InventorySpool) => { + if (s.tag_uid || s.tray_uuid) return false; // Already tagged + if (!search) return true; + const q = search.toLowerCase(); + return ( + s.material.toLowerCase().includes(q) || + (s.brand && s.brand.toLowerCase().includes(q)) || + (s.color_name && s.color_name.toLowerCase().includes(q)) + ); + }); + }, [spools, search]); + const linkMutation = useMutation({ - mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid), + mutationFn: (spoolId: number) => + api.linkTagToSpool(spoolId, { + tag_uid: tagUid || undefined, + tray_uuid: trayUuid || undefined, + tag_type: trayUuid ? 'bambulab' : 'generic', + data_origin: 'nfc_link', + }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] }); - queryClient.invalidateQueries({ queryKey: ['linked-spools'] }); - queryClient.invalidateQueries({ queryKey: ['spoolman-status'] }); - showToast(t('spoolman.linkSuccess'), 'success'); + queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); + queryClient.invalidateQueries({ queryKey: ['inventory-assignments'] }); + showToast(t('inventory.tagLinked'), 'success'); onClose(); }, - onError: (error: Error) => { - showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error'); + onError: (err: Error) => { + showToast(err.message || t('inventory.tagLinkFailed'), 'error'); }, }); if (!isOpen) return null; - const handleLink = () => { - if (selectedSpoolId) { - linkMutation.mutate(selectedSpoolId); - } - }; - return (
- {/* Backdrop */} -
- - {/* Modal */} -
+
+
{/* Header */} -
-
- -

{t('spoolman.linkToSpoolman')}

+
+
+

+ + {t('inventory.linkToSpool')} +

+

+ AMS {amsId} T{trayId} · Printer #{printerId} +

-
- {/* Content */} -
- {/* Tray info */} - {trayInfo && ( -
-

Linking AMS tray:

-
- {trayInfo.color && ( - - )} - {trayInfo.type} - ({trayInfo.location}) -
-
- )} - - {/* Spool UUID */} -
-

{t('spoolman.spoolId')}:

- {trayUuid} + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder={t('inventory.searchSpools')} + className="w-full pl-9 pr-3 py-2 bg-bambu-dark rounded-lg border border-white/10 text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green" + />
- - {/* Spool list */} -
-

- {t('spoolman.selectSpool')}: + {(tagUid || trayUuid) && ( +

+ Tag: {tagUid || trayUuid}

+ )} +
- {isLoading ? ( -
- -
- ) : unlinkedSpools && unlinkedSpools.length > 0 ? ( -
- {unlinkedSpools.map((spool) => ( - - ))} -
- ) : ( -
-

{t('spoolman.noUnlinkedSpools')}

-
- )} -
+ {/* Spool List */} +
+ {isLoading ? ( +
+ +
+ ) : filteredSpools.length === 0 ? ( +

+ {t('inventory.noSpoolsMatch')} +

+ ) : ( + filteredSpools.map((spool: InventorySpool) => ( + + )) + )}
{/* Footer */} -
- -
- - {/* Error */} - {linkMutation.isError && ( -
- {(linkMutation.error as Error).message} -
- )}
); diff --git a/frontend/src/components/SpoolCatalogSettings.tsx b/frontend/src/components/SpoolCatalogSettings.tsx new file mode 100644 index 000000000..9cecee779 --- /dev/null +++ b/frontend/src/components/SpoolCatalogSettings.tsx @@ -0,0 +1,397 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react'; +import { api } from '../api/client'; +import type { SpoolCatalogEntry } from '../api/client'; +import { useToast } from '../contexts/ToastContext'; +import { Card, CardHeader, CardContent } from './Card'; +import { ConfirmModal } from './ConfirmModal'; + +export function SpoolCatalogSettings() { + const { t } = useTranslation(); + const { showToast } = useToast(); + const [catalog, setCatalog] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const fileInputRef = useRef(null); + + // Add/Edit form state + const [showAddForm, setShowAddForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formName, setFormName] = useState(''); + const [formWeight, setFormWeight] = useState(''); + const [saving, setSaving] = useState(false); + + // Confirmation modals + const [deleteEntry, setDeleteEntry] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + + const loadCatalog = useCallback(async () => { + try { + const entries = await api.getSpoolCatalog(); + setCatalog(entries); + } catch { + showToast(t('settings.catalog.loadFailed'), 'error'); + } finally { + setLoading(false); + } + }, [showToast, t]); + + useEffect(() => { + loadCatalog(); + }, [loadCatalog]); + + const filteredCatalog = catalog.filter(entry => + entry.name.toLowerCase().includes(search.toLowerCase()) + ); + + const handleAdd = async () => { + if (!formName.trim() || !formWeight) { + showToast(t('settings.catalog.nameWeightRequired'), 'error'); + return; + } + setSaving(true); + try { + const entry = await api.addCatalogEntry({ name: formName.trim(), weight: parseInt(formWeight) }); + setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name))); + setShowAddForm(false); + setFormName(''); + setFormWeight(''); + showToast(t('settings.catalog.entryAdded'), 'success'); + } catch { + showToast(t('settings.catalog.addFailed'), 'error'); + } finally { + setSaving(false); + } + }; + + const startEdit = (entry: SpoolCatalogEntry) => { + setEditingId(entry.id); + setFormName(entry.name); + setFormWeight(entry.weight.toString()); + }; + + const cancelEdit = () => { + setEditingId(null); + setFormName(''); + setFormWeight(''); + }; + + const handleUpdate = async (id: number) => { + if (!formName.trim() || !formWeight) { + showToast(t('settings.catalog.nameWeightRequired'), 'error'); + return; + } + setSaving(true); + try { + const updated = await api.updateCatalogEntry(id, { name: formName.trim(), weight: parseInt(formWeight) }); + setCatalog(prev => prev.map(e => e.id === id ? updated : e).sort((a, b) => a.name.localeCompare(b.name))); + setEditingId(null); + setFormName(''); + setFormWeight(''); + showToast(t('settings.catalog.entryUpdated'), 'success'); + } catch { + showToast(t('settings.catalog.updateFailed'), 'error'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!deleteEntry) return; + try { + await api.deleteCatalogEntry(deleteEntry.id); + setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id)); + showToast(t('settings.catalog.entryDeleted'), 'success'); + } catch { + showToast(t('settings.catalog.deleteFailed'), 'error'); + } finally { + setDeleteEntry(null); + } + }; + + const handleReset = async () => { + setShowResetConfirm(false); + setLoading(true); + try { + await api.resetSpoolCatalog(); + await loadCatalog(); + showToast(t('settings.catalog.resetSuccess'), 'success'); + } catch { + showToast(t('settings.catalog.resetFailed'), 'error'); + setLoading(false); + } + }; + + const handleExport = () => { + const exportData = catalog.map(({ name, weight }) => ({ name, weight })); + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'spool-catalog.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(t('settings.catalog.exported', { count: catalog.length }), 'success'); + }; + + const handleImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text) as Array<{ name: string; weight: number }>; + if (!Array.isArray(data)) throw new Error('Invalid format'); + + let added = 0; + let skipped = 0; + for (const item of data) { + if (!item.name || typeof item.weight !== 'number') { skipped++; continue; } + const exists = catalog.some(c => c.name.toLowerCase() === item.name.toLowerCase()); + if (exists) { skipped++; continue; } + try { + const entry = await api.addCatalogEntry({ name: item.name, weight: item.weight }); + setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name))); + added++; + } catch { skipped++; } + } + showToast(t('settings.catalog.imported', { added, skipped }), 'success'); + } catch { + showToast(t('settings.catalog.importFailed'), 'error'); + } + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + return ( + + +
+ +

{t('settings.catalog.spoolCatalog')}

+ ({catalog.length}) +
+
+ + + + + +
+
+ +

+ {t('settings.catalog.spoolCatalogDescription')} +

+ + {/* Search */} +
+ + setSearch(e.target.value)} + /> +
+ + {/* Add form */} + {showAddForm && ( +
+

{t('settings.catalog.addNewEntry')}

+
+
+ setFormName(e.target.value)} + /> +
+ setFormWeight(e.target.value)} + /> + g + + +
+
+ )} + + {/* Catalog list */} + {loading ? ( +
+ + {t('common.loading')} +
+ ) : ( +
+ + + + + + + + + + + {filteredCatalog.length === 0 ? ( + + + + ) : ( + filteredCatalog.map(entry => ( + + {editingId === entry.id ? ( + <> + + + + + + ) : ( + <> + + + + + + )} + + )) + )} + +
{t('common.name')}{t('settings.catalog.weight')}{t('settings.catalog.type')}
+ {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')} +
+ setFormName(e.target.value)} + /> + + setFormWeight(e.target.value)} + /> + + - + +
+ + +
+
{entry.name}{entry.weight}g + {entry.is_default ? ( + + {t('settings.catalog.default')} + + ) : ( + + {t('settings.catalog.custom')} + + )} + +
+ + +
+
+
+ )} +
+ + {/* Delete confirmation */} + {deleteEntry && ( + setDeleteEntry(null)} + /> + )} + + {/* Reset confirmation */} + {showResetConfirm && ( + setShowResetConfirm(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx new file mode 100644 index 000000000..75045f6d5 --- /dev/null +++ b/frontend/src/components/SpoolFormModal.tsx @@ -0,0 +1,484 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { X, Loader2, Save, Beaker, Palette } from 'lucide-react'; +import { api } from '../api/client'; +import type { InventorySpool, SlicerSetting, SpoolCatalogEntry } from '../api/client'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; +import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types'; +import { defaultFormData, validateForm } from './spool-form/types'; +import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, saveRecentColor } from './spool-form/utils'; +import { FilamentSection } from './spool-form/FilamentSection'; +import { ColorSection } from './spool-form/ColorSection'; +import { AdditionalSection } from './spool-form/AdditionalSection'; +import { PAProfileSection } from './spool-form/PAProfileSection'; +import { SpoolUsageHistory } from './SpoolUsageHistory'; + +type TabId = 'filament' | 'pa-profile'; + +interface SpoolFormModalProps { + isOpen: boolean; + onClose: () => void; + spool?: InventorySpool | null; + printersWithCalibrations?: PrinterWithCalibrations[]; +} + +export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [] }: SpoolFormModalProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const isEditing = !!spool; + + // Form state + const [formData, setFormData] = useState(defaultFormData); + const [errors, setErrors] = useState>>({}); + const [activeTab, setActiveTab] = useState('filament'); + + // Cloud presets + const [cloudAuthenticated, setCloudAuthenticated] = useState(false); + const [loadingCloudPresets, setLoadingCloudPresets] = useState(false); + const [cloudPresets, setCloudPresets] = useState([]); + const [presetInputValue, setPresetInputValue] = useState(''); + + // Spool catalog + const [spoolCatalog, setSpoolCatalog] = useState([]); + + // Color state + const [recentColors, setRecentColors] = useState([]); + + // PA Profile state + const [fetchedCalibrations, setFetchedCalibrations] = useState([]); + const [selectedProfiles, setSelectedProfiles] = useState>(new Set()); + const [expandedPrinters, setExpandedPrinters] = useState>(new Set()); + + // Use prop if provided, otherwise use self-fetched data + const resolvedCalibrations = printersWithCalibrations.length > 0 + ? printersWithCalibrations + : fetchedCalibrations; + + // Count selected PA profiles for tab badge + const selectedProfileCount = useMemo(() => { + return selectedProfiles.size; + }, [selectedProfiles]); + + // Load recent colors on mount + useEffect(() => { + setRecentColors(loadRecentColors()); + }, []); + + // Fetch cloud presets and catalog when modal opens + useEffect(() => { + if (isOpen) { + const fetchData = async () => { + setLoadingCloudPresets(true); + try { + const status = await api.getCloudStatus(); + setCloudAuthenticated(status.is_authenticated); + if (status.is_authenticated) { + const presets = await api.getFilamentPresets(); + setCloudPresets(presets); + } + } catch (e) { + console.error('Failed to fetch cloud presets:', e); + setCloudAuthenticated(false); + } finally { + setLoadingCloudPresets(false); + } + }; + fetchData(); + api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error); + + // Fetch printer calibrations if not provided via props + if (printersWithCalibrations.length === 0) { + (async () => { + try { + const printers = await api.getPrinters(); + const statuses = await Promise.all( + printers.map(p => api.getPrinterStatus(p.id).catch(() => null)), + ); + const results: PrinterWithCalibrations[] = []; + for (let i = 0; i < printers.length; i++) { + const printer = printers[i]; + const status = statuses[i]; + const connected = status?.connected ?? false; + let calibrations: PrinterWithCalibrations['calibrations'] = []; + if (connected) { + try { + const kRes = await api.getKProfiles(printer.id); + calibrations = kRes.profiles.map(p => ({ + cali_idx: p.slot_id, + filament_id: p.filament_id, + setting_id: p.setting_id || '', + name: p.name, + k_value: parseFloat(p.k_value) || 0, + n_coef: parseFloat(p.n_coef) || 0, + extruder_id: p.extruder_id, + nozzle_diameter: p.nozzle_diameter, + })); + } catch { + // Printer may not support K-profiles + } + } + results.push({ printer: { ...printer, connected }, calibrations }); + } + setFetchedCalibrations(results); + } catch (e) { + console.error('Failed to fetch printer calibrations:', e); + } + })(); + } + } + }, [isOpen]); + + // Build filament options from cloud presets + const filamentOptions = useMemo( + () => buildFilamentOptions(cloudPresets, new Set()), + [cloudPresets], + ); + + // Extract brands from presets + const availableBrands = useMemo( + () => extractBrandsFromPresets(cloudPresets), + [cloudPresets], + ); + + // Find selected preset option + const selectedPresetOption = useMemo( + () => findPresetOption(formData.slicer_filament, filamentOptions), + [formData.slicer_filament, filamentOptions], + ); + + // Reset form when modal opens/closes or spool changes + useEffect(() => { + if (isOpen) { + if (spool) { + setFormData({ + material: spool.material || '', + subtype: spool.subtype || '', + brand: spool.brand || '', + color_name: spool.color_name || '', + rgba: spool.rgba || '808080FF', + label_weight: spool.label_weight || 1000, + core_weight: spool.core_weight || 250, + slicer_filament: spool.slicer_filament || '', + note: spool.note || '', + }); + setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || ''); + + // Load K-profiles for this spool + if (spool.k_profiles && spool.k_profiles.length > 0) { + const profileKeys = new Set(); + for (const p of spool.k_profiles) { + if (p.cali_idx !== null && p.cali_idx !== undefined) { + profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`); + } + } + setSelectedProfiles(profileKeys); + } else { + setSelectedProfiles(new Set()); + } + } else { + setFormData(defaultFormData); + setPresetInputValue(''); + setSelectedProfiles(new Set()); + } + setErrors({}); + setActiveTab('filament'); + } + }, [isOpen, spool]); + + // Expand all printers in PA profile section when calibrations are available + useEffect(() => { + if (isOpen && resolvedCalibrations.length > 0) { + setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id)))); + } + }, [isOpen, resolvedCalibrations]); + + // Update field helper + const updateField = (key: K, value: SpoolFormData[K]) => { + setFormData(prev => ({ ...prev, [key]: value })); + if (errors[key]) { + setErrors(prev => ({ ...prev, [key]: undefined })); + } + }; + + // Handle color selection + const handleColorUsed = (color: ColorPreset) => { + setRecentColors(prev => saveRecentColor(color, prev)); + }; + + // Mutations + const createMutation = useMutation({ + mutationFn: (data: Record) => + api.createSpool(data as Parameters[0]), + onSuccess: async (newSpool) => { + queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); + // Save K-profiles if any selected + if (selectedProfiles.size > 0 && newSpool?.id) { + await saveKProfiles(newSpool.id); + } + showToast(t('inventory.spoolCreated'), 'success'); + onClose(); + }, + onError: (error: Error) => { + showToast(error.message, 'error'); + }, + }); + + const updateMutation = useMutation({ + mutationFn: (data: Record) => + api.updateSpool(spool!.id, data as Parameters[1]), + onSuccess: async () => { + queryClient.invalidateQueries({ queryKey: ['inventory-spools'] }); + // Save K-profiles + if (spool?.id) { + await saveKProfiles(spool.id); + } + showToast(t('inventory.spoolUpdated'), 'success'); + onClose(); + }, + onError: (error: Error) => { + showToast(error.message, 'error'); + }, + }); + + // Save K-profiles for selected calibrations + const saveKProfiles = async (spoolId: number) => { + if (selectedProfiles.size === 0) { + // Clear existing K-profiles + try { + await api.saveSpoolKProfiles(spoolId, []); + } catch { + // Ignore + } + return; + } + + const profiles = []; + for (const key of selectedProfiles) { + const [printerIdStr, caliIdxStr, extruderStr] = key.split(':'); + const printerId = parseInt(printerIdStr); + const caliIdx = parseInt(caliIdxStr); + const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr); + + // Find the matching calibration + const pc = resolvedCalibrations.find(p => p.printer.id === printerId); + if (pc) { + const cal = pc.calibrations.find(c => c.cali_idx === caliIdx); + if (cal) { + profiles.push({ + printer_id: printerId, + extruder, + nozzle_diameter: cal.nozzle_diameter || '0.4', + k_value: cal.k_value, + name: cal.name || null, + cali_idx: cal.cali_idx, + setting_id: cal.setting_id || null, + }); + } + } + } + + if (profiles.length > 0) { + try { + await api.saveSpoolKProfiles(spoolId, profiles); + } catch (e) { + console.error('Failed to save K-profiles:', e); + } + } + }; + + if (!isOpen) return null; + + const handleSubmit = () => { + const validation = validateForm(formData); + if (!validation.isValid) { + setErrors(validation.errors); + // Switch to filament tab if there are errors there + if (validation.errors.slicer_filament || validation.errors.material) { + setActiveTab('filament'); + } + return; + } + + // Find preset name from selected option + const presetName = selectedPresetOption?.displayName || presetInputValue || null; + + const data: Record = { + material: formData.material, + subtype: formData.subtype || null, + brand: formData.brand || null, + color_name: formData.color_name || null, + rgba: formData.rgba || null, + label_weight: formData.label_weight, + core_weight: formData.core_weight, + weight_used: spool?.weight_used ?? 0, + slicer_filament: formData.slicer_filament || null, + slicer_filament_name: presetName, + nozzle_temp_min: null, + nozzle_temp_max: null, + note: formData.note || null, + }; + + if (isEditing) { + updateMutation.mutate(data); + } else { + createMutation.mutate(data); + } + }; + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+ +
+ {/* Header */} +
+

+ {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')} +

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === 'filament' ? ( +
+ {/* Filament Info Section */} +
+

+ {t('inventory.filamentInfo')} +

+ + {errors.slicer_filament && ( +

{errors.slicer_filament}

+ )} + {errors.material && ( +

{errors.material}

+ )} +
+ + {/* Color Section */} +
+

+ {t('inventory.color')} +

+ +
+ + {/* Additional Section */} +
+

+ {t('inventory.additional')} +

+ +
+ + {/* Usage History (only when editing) */} + {isEditing && spool && ( +
+ +
+ )} +
+ ) : ( + + )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/SpoolUsageHistory.tsx b/frontend/src/components/SpoolUsageHistory.tsx new file mode 100644 index 000000000..594338538 --- /dev/null +++ b/frontend/src/components/SpoolUsageHistory.tsx @@ -0,0 +1,101 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Loader2, Trash2, Clock } from 'lucide-react'; +import { api } from '../api/client'; +import type { SpoolUsageRecord } from '../api/client'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; + +interface SpoolUsageHistoryProps { + spoolId: number; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) + + ' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); +} + +const STATUS_COLORS: Record = { + completed: 'text-bambu-green', + failed: 'text-red-400', + aborted: 'text-yellow-400', +}; + +export function SpoolUsageHistory({ spoolId }: SpoolUsageHistoryProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const { data: history, isLoading } = useQuery({ + queryKey: ['spool-usage', spoolId], + queryFn: () => api.getSpoolUsageHistory(spoolId), + }); + + const clearMutation = useMutation({ + mutationFn: () => api.clearSpoolUsageHistory(spoolId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['spool-usage', spoolId] }); + showToast(t('inventory.historyCleared'), 'success'); + }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!history || history.length === 0) { + return ( +
+ + {t('inventory.noUsageHistory')} +
+ ); + } + + return ( +
+
+

{t('inventory.usageHistory')}

+ +
+
+ {history.map((record: SpoolUsageRecord) => ( +
+
+ {formatDate(record.created_at)} + {record.print_name && ( + + {record.print_name} + + )} +
+
+ {record.weight_used.toFixed(1)}g + ({record.percent_used}%) + + {record.status} + +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/SpoolmanSettings.tsx b/frontend/src/components/SpoolmanSettings.tsx index c6a7748d1..6a77f9074 100644 --- a/frontend/src/components/SpoolmanSettings.tsx +++ b/frontend/src/components/SpoolmanSettings.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle } from 'lucide-react'; +import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react'; import { api } from '../api/client'; import type { SpoolmanSyncResult, Printer } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; @@ -146,7 +146,7 @@ export function SpoolmanSettings() {
-

Spoolman Integration

+

{t('settings.filamentTracking')}

@@ -164,7 +164,7 @@ export function SpoolmanSettings() {
-

Spoolman Integration

+

{t('settings.filamentTracking')}

{saveMutation.isPending && ( @@ -173,303 +173,356 @@ export function SpoolmanSettings() {

- Connect to Spoolman for filament inventory tracking. AMS data will sync automatically. + {t('settings.filamentTrackingDesc')}

- {/* Info banner about sync requirements */} -
-
- -
-

How Sync Works

-
    -
  • Only official Bambu Lab spools with RFID are synced
  • -
  • New spools are auto-created in Spoolman on first sync
  • -
  • Non-Bambu Lab spools (third-party, refilled) are skipped
  • -
-

Linking Existing Spools

-

- To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman". -

-
-
-
- - {/* Enable toggle */} -
-
-

Enable Spoolman

-

- Sync filament data with Spoolman server -

-
- -
- - {/* URL input */} -
- - setLocalUrl(e.target.value)} - disabled={!localEnabled} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none disabled:opacity-50" - /> -

- URL of your Spoolman server (e.g., http://localhost:7912) -

-
- - {/* Sync mode */} -
- - -

- {localSyncMode === 'auto' - ? 'AMS data syncs automatically when changes are detected' - : 'Only sync when manually triggered'} -

-
- - {/* Disable Weight Sync toggle - only show when sync mode is auto */} - {localSyncMode === 'auto' && ( -
-
-

{t('spoolman.disableWeightSync')}

-

- {t('spoolman.disableWeightSyncDesc')} -

+
+ + + {t('settings.trackingModeBuiltIn')} +
- -
- )} - - {/* Report Partial Usage toggle - only show when weight sync is disabled */} - {localDisableWeightSync && ( -
-
-

{t('spoolman.reportPartialUsage')}

-

- {t('spoolman.reportPartialUsageDesc')} -

-
- -
- )} - - {/* Connection status */} - {localEnabled && ( -
-
-
- Status: - {statusLoading ? ( - - ) : status?.connected ? ( - - - Connected - - ) : ( - - - Disconnected - - )} -
-
- {status?.connected ? ( - - ) : ( - - )} -
-
- - {/* Error display */} - {connectMutation.isError && ( -
- {(connectMutation.error as Error).message} +

+ {t('settings.trackingModeBuiltInDesc')} +

+ {!localEnabled && ( +
+ + {t('common.enabled')}
)} + - {/* Manual sync section */} - {status?.connected && ( -
-
-

Sync AMS Data

-

- Manually sync printer AMS data to Spoolman + {/* Spoolman */} + +

+ + {/* Built-in Inventory details */} + {!localEnabled && ( +
+
+ +
+
    +
  • {t('settings.builtInFeatureRfid')}
  • +
  • {t('settings.builtInFeatureUsage')}
  • +
  • {t('settings.builtInFeatureCatalog')}
  • +
+
+
+
+ )} + + {/* Spoolman settings - only shown when Spoolman mode is selected */} + {localEnabled && ( +
+ {/* Info banner about sync requirements */} +
+
+ +
+

{t('settings.howSyncWorks')}

+
    +
  • {t('settings.syncInfoRfidOnly')}
  • +
  • {t('settings.syncInfoAutoCreate')}
  • +
  • {t('settings.syncInfoThirdPartySkipped')}
  • +
+

{t('settings.linkingExistingSpools')}

+

+ {t('settings.linkingExistingSpoolsDesc')}

+
+
+ + {/* URL input */} +
+ + setLocalUrl(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none" + /> +

+ {t('settings.spoolmanUrlHint')} +

+
+ + {/* Sync mode */} +
+ + +

+ {localSyncMode === 'auto' + ? t('settings.syncModeAutoDesc') + : t('settings.syncModeManualDesc')} +

+
+ + {/* Disable Weight Sync toggle - only show when sync mode is auto */} + {localSyncMode === 'auto' && ( +
+
+

{t('spoolman.disableWeightSync')}

+

+ {t('spoolman.disableWeightSyncDesc')} +

+
+ +
+ )} + + {/* Report Partial Usage toggle - only show when weight sync is disabled */} + {localDisableWeightSync && ( +
+
+

{t('spoolman.reportPartialUsage')}

+

+ {t('spoolman.reportPartialUsageDesc')} +

+
+ +
+ )} + + {/* Connection status */} +
+
- {/* Printer selector */} -
- - -
- {/* Sync button */} - + {disconnectMutation.isPending ? ( + + ) : ( + + )} + {t('settings.disconnect')} + + ) : ( + + )}
- )} - {/* Sync result */} - {syncSuccess && syncResult && ( -
- {/* Main result */} -
- {syncResult.success - ? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully` - : `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`} + {/* Error display */} + {connectMutation.isError && ( +
+ {(connectMutation.error as Error).message}
+ )} - {/* Skipped spools */} - {syncResult.skipped_count > 0 && ( -
-
-
- - - {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped - -
- {syncResult.skipped_count > 5 && ( - - )} + {/* Manual sync section */} + {status?.connected && ( +
+
+

{t('settings.syncAmsData')}

+

+ {t('settings.syncAmsDataDesc')} +

+
+
+ {/* Printer selector */} +
+ +
-
    - {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => ( -
  • - {s.color && ( - - )} - {s.location} - - {s.reason} -
  • - ))} - {!showAllSkipped && syncResult.skipped_count > 5 && ( -
  • - ...and {syncResult.skipped_count - 5} more -
  • + {/* Sync button */} +
+ {t('spoolman.sync')} +
- )} +
+ )} - {/* Errors */} - {syncResult.errors.length > 0 && ( -
-
Errors:
-
    - {syncResult.errors.map((err, i) => ( -
  • {err}
  • - ))} -
+ {/* Sync result */} + {syncSuccess && syncResult && ( +
+ {/* Main result */} +
+ {syncResult.success + ? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully` + : `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
- )} -
- )} + + {/* Skipped spools */} + {syncResult.skipped_count > 0 && ( +
+
+
+ + + {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped + +
+ {syncResult.skipped_count > 5 && ( + + )} +
+
    + {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => ( +
  • + {s.color && ( + + )} + {s.location} + - {s.reason} +
  • + ))} + {!showAllSkipped && syncResult.skipped_count > 5 && ( +
  • + ...and {syncResult.skipped_count - 5} more +
  • + )} +
+
+ )} + + {/* Errors */} + {syncResult.errors.length > 0 && ( +
+
Errors:
+
    + {syncResult.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} +
+ )} +
)} diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx new file mode 100644 index 000000000..26fa53fe9 --- /dev/null +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -0,0 +1,154 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { Scale } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { AdditionalSectionProps } from './types'; + +function SpoolWeightPicker({ + catalog, + value, + onChange, +}: { + catalog: { id: number; name: string; weight: number }[]; + value: number; + onChange: (weight: number) => void; +}) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const dropdownRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + const filtered = useMemo(() => { + if (!search) return catalog; + const s = search.toLowerCase(); + return catalog.filter(e => + e.name.toLowerCase().includes(s) || + e.weight.toString().includes(s), + ); + }, [catalog, search]); + + // Display value: show catalog name if selected, or the weight + const displayValue = useMemo(() => { + if (isOpen) return search; + if (selectedId) { + const entry = catalog.find(e => e.id === selectedId); + if (entry) return entry.name; + } + const match = catalog.find(e => e.weight === value); + if (match) return match.name; + return ''; + }, [isOpen, search, selectedId, catalog, value]); + + return ( +
+ +
+
+ { + setIsOpen(true); + setSearch(''); + }} + onChange={(e) => { + setSearch(e.target.value); + setIsOpen(true); + }} + /> + {isOpen && ( +
+ {filtered.length === 0 ? ( +
{t('inventory.noResults')}
+ ) : ( + filtered.map(entry => ( + + )) + )} +
+ )} +
+
+ { + const val = parseInt(e.target.value); + if (!isNaN(val) && val >= 0) onChange(val); + }} + /> + g +
+
+
+ ); +} + +export function AdditionalSection({ + formData, + updateField, + spoolCatalog, +}: AdditionalSectionProps) { + const { t } = useTranslation(); + + return ( +
+ {/* Empty Spool Weight */} + updateField('core_weight', weight)} + /> + + {/* Note */} +
+ +