maziggy 3 miesięcy temu
rodzic
commit
ec82092bc7
51 zmienionych plików z 8268 dodań i 474 usunięć
  1. 1 1
      DOCKERHUB.md
  2. 48 3
      backend/app/api/routes/cloud.py
  3. 936 0
      backend/app/api/routes/inventory.py
  4. 243 0
      backend/app/core/catalog_defaults.py
  5. 115 0
      backend/app/core/database.py
  6. 18 0
      backend/app/core/permissions.py
  7. 175 0
      backend/app/main.py
  8. 12 0
      backend/app/models/__init__.py
  9. 20 0
      backend/app/models/color_catalog.py
  10. 44 0
      backend/app/models/spool.py
  11. 30 0
      backend/app/models/spool_assignment.py
  12. 18 0
      backend/app/models/spool_catalog.py
  13. 31 0
      backend/app/models/spool_k_profile.py
  14. 21 0
      backend/app/models/spool_usage_history.py
  15. 1 0
      backend/app/schemas/cloud.py
  16. 108 0
      backend/app/schemas/spool.py
  17. 17 0
      backend/app/schemas/spool_usage.py
  18. 273 0
      backend/app/services/spool_tag_matcher.py
  19. 179 0
      backend/app/services/usage_tracker.py
  20. 2 0
      frontend/src/App.tsx
  21. 182 1
      frontend/src/api/client.ts
  22. 203 0
      frontend/src/components/AssignSpoolModal.tsx
  23. 583 0
      frontend/src/components/ColorCatalogSettings.tsx
  24. 187 0
      frontend/src/components/ColumnConfigModal.tsx
  25. 74 26
      frontend/src/components/ConfigureAmsSlotModal.tsx
  26. 53 2
      frontend/src/components/FilamentHoverCard.tsx
  27. 15 9
      frontend/src/components/Layout.tsx
  28. 107 143
      frontend/src/components/LinkSpoolModal.tsx
  29. 397 0
      frontend/src/components/SpoolCatalogSettings.tsx
  30. 484 0
      frontend/src/components/SpoolFormModal.tsx
  31. 101 0
      frontend/src/components/SpoolUsageHistory.tsx
  32. 317 264
      frontend/src/components/SpoolmanSettings.tsx
  33. 154 0
      frontend/src/components/spool-form/AdditionalSection.tsx
  34. 173 0
      frontend/src/components/spool-form/ColorSection.tsx
  35. 245 0
      frontend/src/components/spool-form/FilamentSection.tsx
  36. 268 0
      frontend/src/components/spool-form/PAProfileSection.tsx
  37. 101 0
      frontend/src/components/spool-form/constants.ts
  38. 124 0
      frontend/src/components/spool-form/types.ts
  39. 253 0
      frontend/src/components/spool-form/utils.ts
  40. 24 0
      frontend/src/hooks/useWebSocket.ts
  41. 243 2
      frontend/src/i18n/locales/de.ts
  42. 247 2
      frontend/src/i18n/locales/en.ts
  43. 239 3
      frontend/src/i18n/locales/ja.ts
  44. 1055 0
      frontend/src/pages/InventoryPage.tsx
  45. 133 11
      frontend/src/pages/PrintersPage.tsx
  46. 12 5
      frontend/src/pages/SettingsPage.tsx
  47. 0 0
      static/assets/index-8p-dzNdz.js
  48. 0 0
      static/assets/index-B4os6TlG.js
  49. 0 0
      static/assets/index-C8xaQF5N.css
  50. 0 0
      static/assets/index-DLgJjh2G.css
  51. 2 2
      static/index.html

+ 1 - 1
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 |

+ 48 - 3
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"],

+ 936 - 0
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

+ 243 - 0
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"),
+]

+ 115 - 0
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))

+ 18 - 0
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,

+ 175 - 0
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)

+ 12 - 0
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",
 ]

+ 20 - 0
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())

+ 44 - 0
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

+ 30 - 0
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

+ 18 - 0
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())

+ 31 - 0
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

+ 21 - 0
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())

+ 1 - 0
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):

+ 108 - 0
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

+ 17 - 0
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

+ 273 - 0
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

+ 179 - 0
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

+ 2 - 0
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() {
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
+                  <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />

+ 182 - 1
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<string, LinkedSpoolInfo>; // 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<TestSMTPResponse>('/auth/smtp/test', {
@@ -2903,6 +3010,8 @@ export const api = {
     request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
   getCloudSettings: (version = '02.04.00.70') =>
     request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
+  getBuiltinFilaments: () =>
+    request<BuiltinFilament[]>('/cloud/builtin-filaments'),
   getCloudSettingDetail: (settingId: string) =>
     request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
   createCloudSetting: (data: SlicerSettingCreate) =>
@@ -3242,6 +3351,78 @@ export const api = {
       body: JSON.stringify(data),
     }),
 
+  // Inventory
+  getSpools: (includeArchived = false) =>
+    request<InventorySpool[]>(`/inventory/spools?include_archived=${includeArchived}`),
+  getSpool: (id: number) => request<InventorySpool>(`/inventory/spools/${id}`),
+  createSpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
+    request<InventorySpool>('/inventory/spools', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
+    request<InventorySpool>(`/inventory/spools/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteSpool: (id: number) =>
+    request<{ status: string }>(`/inventory/spools/${id}`, { method: 'DELETE' }),
+  archiveSpool: (id: number) =>
+    request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
+  restoreSpool: (id: number) =>
+    request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
+  getSpoolKProfiles: (spoolId: number) =>
+    request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
+  saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
+    request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`, {
+      method: 'PUT',
+      body: JSON.stringify(profiles),
+    }),
+  getAssignments: (printerId?: number) =>
+    request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
+  assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
+    request<SpoolAssignment>('/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<SpoolCatalogEntry[]>('/inventory/catalog'),
+  addCatalogEntry: (data: { name: string; weight: number }) =>
+    request<SpoolCatalogEntry>('/inventory/catalog', { method: 'POST', body: JSON.stringify(data) }),
+  updateCatalogEntry: (id: number, data: { name: string; weight: number }) =>
+    request<SpoolCatalogEntry>(`/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<ColorCatalogEntry[]>('/inventory/colors'),
+  addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
+    request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
+  updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
+    request<ColorCatalogEntry>(`/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<ColorLookupResult>(`/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<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  getSpoolUsageHistory: (spoolId: number, limit = 50) =>
+    request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
+  getAllUsageHistory: (limit = 100, printerId?: number) =>
+    request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
+  clearSpoolUsageHistory: (spoolId: number) =>
+    request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
+  getFilamentPresets: () =>
+    request<SlicerSetting[]>('/cloud/filaments'),
+
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),

+ 203 - 0
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<number | null>(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 (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+      />
+
+      <div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <Package className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">{t('inventory.assignSpool')}</h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-4 space-y-4">
+          {/* Tray info */}
+          {trayInfo && (
+            <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+              <p className="text-xs text-bambu-gray mb-1">{t('inventory.selectSpool')}:</p>
+              <div className="flex items-center gap-2">
+                {trayInfo.color && (
+                  <span
+                    className="w-4 h-4 rounded-full border border-white/20"
+                    style={{ backgroundColor: `#${trayInfo.color}` }}
+                  />
+                )}
+                <span className="text-white font-medium">{trayInfo.type || 'Empty'}</span>
+                <span className="text-bambu-gray">({trayInfo.location})</span>
+              </div>
+            </div>
+          )}
+
+          {/* Search filter */}
+          <div className="relative">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+            <input
+              type="text"
+              value={searchFilter}
+              onChange={(e) => 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"
+            />
+          </div>
+
+          {/* Spool list */}
+          <div>
+            {isLoading ? (
+              <div className="flex justify-center py-8">
+                <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+              </div>
+            ) : filteredSpools && filteredSpools.length > 0 ? (
+              <div className="max-h-64 overflow-y-auto space-y-2">
+                {filteredSpools.map((spool: InventorySpool) => (
+                  <button
+                    key={spool.id}
+                    onClick={() => setSelectedSpoolId(spool.id)}
+                    className={`w-full p-3 rounded-lg border text-left transition-colors ${
+                      selectedSpoolId === spool.id
+                        ? 'bg-bambu-green/20 border-bambu-green'
+                        : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
+                    }`}
+                  >
+                    <div className="flex items-center gap-2">
+                      {spool.rgba && (
+                        <span
+                          className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
+                          style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}
+                        />
+                      )}
+                      <div className="flex-1 min-w-0">
+                        <p className="text-white font-medium truncate">
+                          {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
+                        </p>
+                        <p className="text-xs text-bambu-gray">
+                          {spool.color_name || ''}
+                          {spool.label_weight ? ` - ${spool.label_weight}g` : ''}
+                          {spool.weight_used > 0 ? ` (${Math.round(spool.weight_used)}g used)` : ''}
+                        </p>
+                      </div>
+                      {selectedSpoolId === spool.id && (
+                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </div>
+                  </button>
+                ))}
+              </div>
+            ) : (
+              <div className="text-center py-8 text-bambu-gray">
+                <p>{t('inventory.noSpools')}</p>
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
+          <Button variant="secondary" onClick={onClose}>
+            {t('common.cancel')}
+          </Button>
+          <Button
+            onClick={handleAssign}
+            disabled={!selectedSpoolId || assignMutation.isPending}
+          >
+            {assignMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                {t('inventory.assigning')}
+              </>
+            ) : (
+              <>
+                <Package className="w-4 h-4" />
+                {t('inventory.assignSpool')}
+              </>
+            )}
+          </Button>
+        </div>
+
+        {assignMutation.isError && (
+          <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+            {(assignMutation.error as Error).message}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 583 - 0
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<ColorCatalogEntry[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [search, setSearch] = useState('');
+  const [filterManufacturer, setFilterManufacturer] = useState<string>('Bambu Lab');
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // Add/Edit form state
+  const [showAddForm, setShowAddForm] = useState(false);
+  const [editingId, setEditingId] = useState<number | null>(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<ColorCatalogEntry | null>(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<string, string> = {};
+      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<HTMLInputElement>) => {
+    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 (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center gap-2 mb-3">
+          <Palette className="w-5 h-5 text-bambu-gray" />
+          <h2 className="text-lg font-semibold text-white">{t('settings.colorCatalog.title')}</h2>
+          <span className="text-sm text-bambu-gray">({catalog.length})</span>
+        </div>
+        <div className="flex items-center gap-2 flex-wrap">
+          <button
+            onClick={handleExport}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+          >
+            <Download className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.export')}</span>
+          </button>
+          <button
+            onClick={() => fileInputRef.current?.click()}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+          >
+            <Upload className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.import')}</span>
+          </button>
+          <input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
+          <button
+            onClick={handleSync}
+            disabled={syncing}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.colorCatalog.syncTooltip')}
+          >
+            {syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Cloud className="w-4 h-4" />}
+            <span className="hidden sm:inline">
+              {syncing
+                ? syncProgress
+                  ? `${Math.min(syncProgress.fetched, syncProgress.total)} / ${syncProgress.total}`
+                  : t('settings.colorCatalog.starting')
+                : t('settings.colorCatalog.sync')}
+            </span>
+          </button>
+          <button
+            onClick={() => setShowResetConfirm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+          >
+            <RotateCcw className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.reset')}</span>
+          </button>
+          <button
+            onClick={() => setShowAddForm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
+          >
+            <Plus className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.add')}</span>
+          </button>
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <p className="text-sm text-bambu-gray">
+          {t('settings.colorCatalog.description')}
+        </p>
+
+        {/* Search and filter */}
+        <div className="flex gap-2 flex-wrap">
+          <div className="relative flex-1 min-w-[200px]">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+            <input
+              type="text"
+              className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+              placeholder={t('settings.colorCatalog.searchColors')}
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+            />
+          </div>
+          <select
+            className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            value={filterManufacturer}
+            onChange={(e) => setFilterManufacturer(e.target.value)}
+          >
+            <option value="">{t('settings.colorCatalog.allManufacturers')}</option>
+            {manufacturers.map(m => (
+              <option key={m} value={m}>{m}</option>
+            ))}
+          </select>
+        </div>
+
+        {/* Add form */}
+        {showAddForm && (
+          <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            <h3 className="text-sm font-medium text-white mb-3">{t('settings.colorCatalog.addNewColor')}</h3>
+            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-2 items-end">
+              <input
+                type="text"
+                className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                placeholder={t('settings.colorCatalog.manufacturer')}
+                value={formManufacturer}
+                onChange={(e) => setFormManufacturer(e.target.value)}
+              />
+              <input
+                type="text"
+                className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                placeholder={t('settings.colorCatalog.colorName')}
+                value={formColorName}
+                onChange={(e) => setFormColorName(e.target.value)}
+              />
+              <div className="flex items-center gap-2">
+                <input
+                  type="color"
+                  className="w-10 h-10 rounded cursor-pointer border border-bambu-dark-tertiary"
+                  value={formHexColor}
+                  onChange={(e) => setFormHexColor(e.target.value)}
+                />
+                <input
+                  type="text"
+                  className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  placeholder="#FFFFFF"
+                  value={formHexColor}
+                  onChange={(e) => setFormHexColor(e.target.value)}
+                />
+              </div>
+              <input
+                type="text"
+                className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                placeholder={t('settings.colorCatalog.materialOptional')}
+                value={formMaterial}
+                onChange={(e) => setFormMaterial(e.target.value)}
+              />
+              <div className="flex gap-2">
+                <button
+                  onClick={handleAdd}
+                  disabled={saving}
+                  className="flex-1 px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center justify-center gap-1"
+                >
+                  {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                  {t('common.add')}
+                </button>
+                <button
+                  onClick={() => { setShowAddForm(false); resetForm(); }}
+                  className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
+                >
+                  <X className="w-4 h-4" />
+                </button>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {/* Filter info */}
+        {(search || filterManufacturer) && (
+          <div className="text-xs text-bambu-gray">
+            {t('settings.colorCatalog.showing', { filtered: filteredCatalog.length, total: catalog.length })}
+          </div>
+        )}
+
+        {/* Catalog list */}
+        {loading ? (
+          <div className="flex items-center justify-center py-8 text-bambu-gray">
+            <Loader2 className="w-5 h-5 animate-spin mr-2" />
+            {t('common.loading')}
+          </div>
+        ) : (
+          <div className="max-h-[400px] overflow-auto border border-bambu-dark-tertiary rounded-lg">
+            <table className="w-full text-sm">
+              <thead className="bg-bambu-dark sticky top-0">
+                <tr>
+                  <th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
+                  <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
+                  <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.colorName')}</th>
+                  <th className="px-3 py-2 text-left text-bambu-gray font-medium w-24">{t('settings.colorCatalog.hex')}</th>
+                  <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
+                  <th className="px-3 py-2 w-16"></th>
+                </tr>
+              </thead>
+              <tbody>
+                {filteredCatalog.length === 0 ? (
+                  <tr>
+                    <td colSpan={6} className="px-3 py-8 text-center text-bambu-gray">
+                      {search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')}
+                    </td>
+                  </tr>
+                ) : (
+                  filteredCatalog.map(entry => (
+                    <tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
+                      {editingId === entry.id ? (
+                        <>
+                          <td className="px-3 py-2">
+                            <input
+                              type="color"
+                              className="w-8 h-8 rounded cursor-pointer border border-bambu-dark-tertiary"
+                              value={formHexColor}
+                              onChange={(e) => setFormHexColor(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-3 py-2">
+                            <input
+                              type="text"
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                              value={formManufacturer}
+                              onChange={(e) => setFormManufacturer(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-3 py-2">
+                            <input
+                              type="text"
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                              value={formColorName}
+                              onChange={(e) => setFormColorName(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-3 py-2">
+                            <input
+                              type="text"
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                              value={formHexColor}
+                              onChange={(e) => setFormHexColor(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-3 py-2">
+                            <input
+                              type="text"
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                              value={formMaterial}
+                              onChange={(e) => setFormMaterial(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-3 py-2">
+                            <div className="flex justify-end gap-1">
+                              <button
+                                onClick={() => handleUpdate(entry.id)}
+                                disabled={saving}
+                                className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
+                              >
+                                {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                              </button>
+                              <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
+                                <X className="w-4 h-4" />
+                              </button>
+                            </div>
+                          </td>
+                        </>
+                      ) : (
+                        <>
+                          <td className="px-3 py-2">
+                            <div
+                              className="w-8 h-8 rounded border border-bambu-dark-tertiary"
+                              style={{ backgroundColor: entry.hex_color }}
+                              title={entry.hex_color}
+                            />
+                          </td>
+                          <td className="px-3 py-2 text-white">{entry.manufacturer}</td>
+                          <td className="px-3 py-2 text-white">{entry.color_name}</td>
+                          <td className="px-3 py-2 font-mono text-xs text-bambu-gray">{entry.hex_color}</td>
+                          <td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
+                          <td className="px-3 py-2">
+                            <div className="flex justify-end gap-1">
+                              <button
+                                onClick={() => startEdit(entry)}
+                                className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
+                              >
+                                <Pencil className="w-4 h-4" />
+                              </button>
+                              <button
+                                onClick={() => setDeleteEntry(entry)}
+                                className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
+                              >
+                                <Trash2 className="w-4 h-4" />
+                              </button>
+                            </div>
+                          </td>
+                        </>
+                      )}
+                    </tr>
+                  ))
+                )}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </CardContent>
+
+      {/* Delete confirmation */}
+      {deleteEntry && (
+        <ConfirmModal
+          title={t('settings.colorCatalog.deleteColor')}
+          message={t('settings.colorCatalog.deleteConfirm', { name: `${deleteEntry.manufacturer} - ${deleteEntry.color_name}` })}
+          confirmText={t('common.delete')}
+          variant="danger"
+          onConfirm={handleDelete}
+          onCancel={() => setDeleteEntry(null)}
+        />
+      )}
+
+      {/* Reset confirmation */}
+      {showResetConfirm && (
+        <ConfirmModal
+          title={t('settings.colorCatalog.resetCatalog')}
+          message={t('settings.colorCatalog.resetConfirm')}
+          confirmText={t('common.reset')}
+          variant="danger"
+          onConfirm={handleReset}
+          onCancel={() => setShowResetConfirm(false)}
+        />
+      )}
+    </Card>
+  );
+}

+ 187 - 0
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<ColumnConfig[]>(columns);
+  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
+  const draggedIndexRef = useRef<number | null>(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 (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
+      <Card className="w-full max-w-md max-h-[80vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-6 flex flex-col min-h-0">
+          {/* Header */}
+          <h3 className="text-lg font-semibold text-white mb-2">{t('inventory.configureColumns')}</h3>
+          <p className="text-sm text-bambu-gray mb-4">
+            {t('inventory.configureColumnsDesc')}
+            <span className="ml-2 text-bambu-gray/60">
+              ({visibleCount} {t('inventory.of')} {localColumns.length} {t('inventory.visible')})
+            </span>
+          </p>
+
+          {/* Column list */}
+          <div className="space-y-1 overflow-y-auto flex-1 min-h-0 pr-1">
+            {localColumns.map((column, index) => (
+              <div
+                key={column.id}
+                className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
+                  draggedIndex === index
+                    ? 'border-bambu-green bg-bambu-green/10'
+                    : 'border-bambu-dark-tertiary bg-bambu-dark-tertiary/50'
+                } ${!column.visible ? 'opacity-50' : ''}`}
+                draggable
+                onDragStart={(e) => handleDragStart(e, index)}
+                onDragOver={(e) => handleDragOver(e, index)}
+                onDrop={handleDrop}
+                onDragEnd={handleDragEnd}
+              >
+                {/* Drag Handle */}
+                <div className="cursor-grab text-bambu-gray/50 hover:text-bambu-gray">
+                  <GripVertical className="w-4 h-4" />
+                </div>
+
+                {/* Column Name */}
+                <span className="flex-1 font-medium text-sm text-white">{column.label}</span>
+
+                {/* Move Buttons */}
+                <div className="flex items-center gap-0.5">
+                  <button
+                    onClick={() => moveColumn(index, index - 1)}
+                    disabled={index === 0}
+                    className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed"
+                    title={t('inventory.moveUp')}
+                  >
+                    <ChevronUp className="w-4 h-4" />
+                  </button>
+                  <button
+                    onClick={() => moveColumn(index, index + 1)}
+                    disabled={index === localColumns.length - 1}
+                    className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed"
+                    title={t('inventory.moveDown')}
+                  >
+                    <ChevronDown className="w-4 h-4" />
+                  </button>
+                </div>
+
+                {/* Visibility Toggle */}
+                <button
+                  onClick={() => toggleVisibility(index)}
+                  className={`p-1.5 rounded transition-colors ${
+                    column.visible
+                      ? 'text-bambu-green hover:bg-bambu-green/10'
+                      : 'text-bambu-gray/50 hover:bg-bambu-dark-secondary'
+                  }`}
+                  title={column.visible ? t('inventory.hideColumn') : t('inventory.showColumn')}
+                >
+                  {column.visible ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
+                </button>
+              </div>
+            ))}
+          </div>
+
+          {/* Footer */}
+          <div className="flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary">
+            <Button variant="secondary" onClick={resetToDefaults} className="mr-auto">
+              <RotateCcw className="w-4 h-4" />
+              {t('inventory.reset')}
+            </Button>
+            <Button variant="secondary" onClick={onClose}>
+              {t('inventory.cancel')}
+            </Button>
+            <Button onClick={handleSave}>
+              {t('inventory.applyChanges')}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 74 - 26
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<string>();
+
+    // 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')}
                                 </span>
                               )}
+                              {preset.source === 'builtin' && (
+                                <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
+                                  {t('configureAmsSlot.builtin')}
+                                </span>
+                              )}
                               {preset.isUser && (
                                 <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
                                   {t('configureAmsSlot.custom')}

+ 53 - 2
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
                 </div>
               )}
 
+              {/* Inventory section - assign/unassign for non-Bambu spools */}
+              {inventory && (data.vendor !== 'Bambu Lab' || !data.trayUuid) && (
+                <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
+                  {inventory.assignedSpool ? (
+                    <>
+                      <div className="flex items-center gap-1.5">
+                        <Package className="w-3 h-3 text-bambu-green" />
+                        <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                          {t('inventory.assigned')}
+                        </span>
+                      </div>
+                      <p className="text-xs text-white truncate">
+                        {inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}
+                        {inventory.assignedSpool.material}
+                        {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
+                      </p>
+                      {inventory.onUnassignSpool && (
+                        <button
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            inventory.onUnassignSpool?.();
+                          }}
+                          className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400"
+                        >
+                          <Unlink className="w-3.5 h-3.5" />
+                          {t('inventory.unassignSpool')}
+                        </button>
+                      )}
+                    </>
+                  ) : inventory.onAssignSpool ? (
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        inventory.onAssignSpool?.();
+                      }}
+                      className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                    >
+                      <Package className="w-3.5 h-3.5" />
+                      {t('inventory.assignSpool')}
+                    </button>
+                  ) : null}
+                </div>
+              )}
+
               {/* Configure slot section - always show if enabled */}
               {configureSlot?.enabled && (
                 <div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>

+ 15 - 9
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);

+ 107 - 143
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<number | null>(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 (
     <div className="fixed inset-0 z-50 flex items-center justify-center">
-      {/* Backdrop */}
-      <div
-        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
-        onClick={onClose}
-      />
-
-      {/* Modal */}
-      <div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+      <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
+      <div className="relative bg-bambu-dark-secondary rounded-xl shadow-xl w-full max-w-md mx-4 max-h-[80vh] flex flex-col border border-bambu-dark-tertiary">
         {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2">
-            <Link2 className="w-5 h-5 text-bambu-green" />
-            <h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
+        <div className="flex items-center justify-between p-4 border-b border-white/10">
+          <div>
+            <h3 className="text-lg font-semibold text-white flex items-center gap-2">
+              <Link className="w-5 h-5 text-bambu-green" />
+              {t('inventory.linkToSpool')}
+            </h3>
+            <p className="text-xs text-bambu-gray mt-1">
+              AMS {amsId} T{trayId} &middot; Printer #{printerId}
+            </p>
           </div>
-          <button
-            onClick={onClose}
-            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
-          >
+          <button onClick={onClose} className="p-1 text-bambu-gray hover:text-white rounded transition-colors">
             <X className="w-5 h-5" />
           </button>
         </div>
 
-        {/* Content */}
-        <div className="p-4 space-y-4">
-          {/* Tray info */}
-          {trayInfo && (
-            <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-              <p className="text-xs text-bambu-gray mb-1">Linking AMS tray:</p>
-              <div className="flex items-center gap-2">
-                {trayInfo.color && (
-                  <span
-                    className="w-4 h-4 rounded-full border border-white/20"
-                    style={{ backgroundColor: `#${trayInfo.color}` }}
-                  />
-                )}
-                <span className="text-white font-medium">{trayInfo.type}</span>
-                <span className="text-bambu-gray">({trayInfo.location})</span>
-              </div>
-            </div>
-          )}
-
-          {/* Spool UUID */}
-          <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-            <p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
-            <code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
+        {/* Search */}
+        <div className="p-4 border-b border-white/10">
+          <div className="relative">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+            <input
+              type="text"
+              value={search}
+              onChange={(e) => 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"
+            />
           </div>
-
-          {/* Spool list */}
-          <div>
-            <p className="text-sm text-bambu-gray mb-2">
-              {t('spoolman.selectSpool')}:
+          {(tagUid || trayUuid) && (
+            <p className="text-xs text-bambu-gray mt-2 font-mono truncate" title={tagUid || trayUuid}>
+              Tag: {tagUid || trayUuid}
             </p>
+          )}
+        </div>
 
-            {isLoading ? (
-              <div className="flex justify-center py-8">
-                <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
-              </div>
-            ) : unlinkedSpools && unlinkedSpools.length > 0 ? (
-              <div className="max-h-64 overflow-y-auto space-y-2">
-                {unlinkedSpools.map((spool) => (
-                  <button
-                    key={spool.id}
-                    onClick={() => setSelectedSpoolId(spool.id)}
-                    className={`w-full p-3 rounded-lg border text-left transition-colors ${
-                      selectedSpoolId === spool.id
-                        ? 'bg-bambu-green/20 border-bambu-green'
-                        : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
-                    }`}
-                  >
-                    <div className="flex items-center gap-2">
-                      {spool.filament_color_hex && (
-                        <span
-                          className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
-                          style={{ backgroundColor: `#${spool.filament_color_hex}` }}
-                        />
-                      )}
-                      <div className="flex-1 min-w-0">
-                        <p className="text-white font-medium truncate">
-                          {spool.filament_name || 'Unknown filament'}
-                        </p>
-                        <p className="text-xs text-bambu-gray">
-                          {spool.filament_material || 'Unknown'}
-                          {spool.remaining_weight !== null && ` - ${Math.round(spool.remaining_weight)}g`}
-                          {spool.location && ` - ${spool.location}`}
-                        </p>
-                      </div>
-                      {selectedSpoolId === spool.id && (
-                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-                      )}
-                    </div>
-                  </button>
-                ))}
-              </div>
-            ) : (
-              <div className="text-center py-8 text-bambu-gray">
-                <p>{t('spoolman.noUnlinkedSpools')}</p>
-              </div>
-            )}
-          </div>
+        {/* Spool List */}
+        <div className="flex-1 overflow-y-auto p-2 min-h-0">
+          {isLoading ? (
+            <div className="flex justify-center py-8">
+              <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+            </div>
+          ) : filteredSpools.length === 0 ? (
+            <p className="text-center text-bambu-gray py-8 text-sm">
+              {t('inventory.noSpoolsMatch')}
+            </p>
+          ) : (
+            filteredSpools.map((spool: InventorySpool) => (
+              <button
+                key={spool.id}
+                onClick={() => linkMutation.mutate(spool.id)}
+                disabled={linkMutation.isPending}
+                className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 transition-colors text-left"
+              >
+                <span
+                  className="w-6 h-6 rounded-full border border-white/20 flex-shrink-0"
+                  style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
+                />
+                <div className="flex-1 min-w-0">
+                  <div className="text-sm text-white font-medium truncate">
+                    {spool.brand ? `${spool.brand} ` : ''}{spool.material}
+                    {spool.subtype ? ` ${spool.subtype}` : ''}
+                  </div>
+                  <div className="text-xs text-bambu-gray truncate">
+                    {spool.color_name || 'No color'} &middot; #{spool.id}
+                  </div>
+                </div>
+                <span className="text-xs text-bambu-gray">
+                  {Math.round(spool.label_weight - spool.weight_used)}g
+                </span>
+              </button>
+            ))
+          )}
         </div>
 
         {/* Footer */}
-        <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
-          <Button variant="secondary" onClick={onClose}>
-            {t('common.cancel')}
-          </Button>
-          <Button
-            onClick={handleLink}
-            disabled={!selectedSpoolId || linkMutation.isPending}
-          >
-            {linkMutation.isPending ? (
-              <>
-                <Loader2 className="w-4 h-4 animate-spin" />
-                {t('spoolman.syncing')}
-              </>
-            ) : (
-              <>
-                <Link2 className="w-4 h-4" />
-                {t('spoolman.linkToSpoolman')}
-              </>
-            )}
+        <div className="p-4 border-t border-white/10 flex justify-end">
+          <Button variant="ghost" onClick={onClose}>
+            {t('inventory.cancel') || 'Cancel'}
           </Button>
         </div>
-
-        {/* Error */}
-        {linkMutation.isError && (
-          <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
-            {(linkMutation.error as Error).message}
-          </div>
-        )}
       </div>
     </div>
   );

+ 397 - 0
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<SpoolCatalogEntry[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [search, setSearch] = useState('');
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // Add/Edit form state
+  const [showAddForm, setShowAddForm] = useState(false);
+  const [editingId, setEditingId] = useState<number | null>(null);
+  const [formName, setFormName] = useState('');
+  const [formWeight, setFormWeight] = useState('');
+  const [saving, setSaving] = useState(false);
+
+  // Confirmation modals
+  const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(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<HTMLInputElement>) => {
+    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 (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center gap-2 mb-3">
+          <Database className="w-5 h-5 text-bambu-gray" />
+          <h2 className="text-lg font-semibold text-white">{t('settings.catalog.spoolCatalog')}</h2>
+          <span className="text-sm text-bambu-gray">({catalog.length})</span>
+        </div>
+        <div className="flex items-center gap-2 flex-wrap">
+          <button
+            onClick={handleExport}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.exportTooltip')}
+          >
+            <Download className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.export')}</span>
+          </button>
+          <button
+            onClick={() => fileInputRef.current?.click()}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.importTooltip')}
+          >
+            <Upload className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.import')}</span>
+          </button>
+          <input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
+          <button
+            onClick={() => setShowResetConfirm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.resetTooltip')}
+          >
+            <RotateCcw className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.reset')}</span>
+          </button>
+          <button
+            onClick={() => setShowAddForm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
+          >
+            <Plus className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.add')}</span>
+          </button>
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <p className="text-sm text-bambu-gray">
+          {t('settings.catalog.spoolCatalogDescription')}
+        </p>
+
+        {/* Search */}
+        <div className="relative">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+          <input
+            type="text"
+            className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+            placeholder={t('settings.catalog.searchCatalog')}
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+          />
+        </div>
+
+        {/* Add form */}
+        {showAddForm && (
+          <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            <h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
+            <div className="flex gap-2 items-center">
+              <div className="flex-1 min-w-0">
+                <input
+                  type="text"
+                  className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  placeholder={t('settings.catalog.namePlaceholder')}
+                  value={formName}
+                  onChange={(e) => setFormName(e.target.value)}
+                />
+              </div>
+              <input
+                type="number"
+                className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
+                placeholder="g"
+                value={formWeight}
+                onChange={(e) => setFormWeight(e.target.value)}
+              />
+              <span className="text-bambu-gray shrink-0">g</span>
+              <button
+                onClick={handleAdd}
+                disabled={saving}
+                className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
+              >
+                {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                {t('common.add')}
+              </button>
+              <button
+                onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
+                className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            </div>
+          </div>
+        )}
+
+        {/* Catalog list */}
+        {loading ? (
+          <div className="flex items-center justify-center py-8 text-bambu-gray">
+            <Loader2 className="w-5 h-5 animate-spin mr-2" />
+            {t('common.loading')}
+          </div>
+        ) : (
+          <div className="max-h-[400px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
+            <table className="w-full text-sm">
+              <thead className="bg-bambu-dark sticky top-0">
+                <tr>
+                  <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
+                  <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
+                  <th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
+                  <th className="px-4 py-2 w-24"></th>
+                </tr>
+              </thead>
+              <tbody>
+                {filteredCatalog.length === 0 ? (
+                  <tr>
+                    <td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
+                      {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
+                    </td>
+                  </tr>
+                ) : (
+                  filteredCatalog.map(entry => (
+                    <tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
+                      {editingId === entry.id ? (
+                        <>
+                          <td className="px-4 py-2">
+                            <input
+                              type="text"
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
+                              value={formName}
+                              onChange={(e) => setFormName(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-4 py-2">
+                            <input
+                              type="number"
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
+                              value={formWeight}
+                              onChange={(e) => setFormWeight(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-4 py-2 text-center">
+                            <span className="text-xs text-bambu-gray">-</span>
+                          </td>
+                          <td className="px-4 py-2">
+                            <div className="flex justify-end gap-1">
+                              <button
+                                onClick={() => handleUpdate(entry.id)}
+                                disabled={saving}
+                                className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
+                              >
+                                {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                              </button>
+                              <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
+                                <X className="w-4 h-4" />
+                              </button>
+                            </div>
+                          </td>
+                        </>
+                      ) : (
+                        <>
+                          <td className="px-4 py-2 text-white">{entry.name}</td>
+                          <td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
+                          <td className="px-4 py-2 text-center">
+                            {entry.is_default ? (
+                              <span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
+                                {t('settings.catalog.default')}
+                              </span>
+                            ) : (
+                              <span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
+                                {t('settings.catalog.custom')}
+                              </span>
+                            )}
+                          </td>
+                          <td className="px-4 py-2">
+                            <div className="flex justify-end gap-1">
+                              <button
+                                onClick={() => startEdit(entry)}
+                                className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
+                              >
+                                <Pencil className="w-4 h-4" />
+                              </button>
+                              <button
+                                onClick={() => setDeleteEntry(entry)}
+                                className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
+                              >
+                                <Trash2 className="w-4 h-4" />
+                              </button>
+                            </div>
+                          </td>
+                        </>
+                      )}
+                    </tr>
+                  ))
+                )}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </CardContent>
+
+      {/* Delete confirmation */}
+      {deleteEntry && (
+        <ConfirmModal
+          title={t('settings.catalog.deleteEntry')}
+          message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}
+          confirmText={t('common.delete')}
+          variant="danger"
+          onConfirm={handleDelete}
+          onCancel={() => setDeleteEntry(null)}
+        />
+      )}
+
+      {/* Reset confirmation */}
+      {showResetConfirm && (
+        <ConfirmModal
+          title={t('settings.catalog.resetCatalog')}
+          message={t('settings.catalog.resetConfirm')}
+          confirmText={t('common.reset')}
+          variant="danger"
+          onConfirm={handleReset}
+          onCancel={() => setShowResetConfirm(false)}
+        />
+      )}
+    </Card>
+  );
+}

+ 484 - 0
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<SpoolFormData>(defaultFormData);
+  const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
+  const [activeTab, setActiveTab] = useState<TabId>('filament');
+
+  // Cloud presets
+  const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
+  const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
+  const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
+  const [presetInputValue, setPresetInputValue] = useState('');
+
+  // Spool catalog
+  const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
+
+  // Color state
+  const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
+
+  // PA Profile state
+  const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
+  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
+  const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(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<string>();
+          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 = <K extends keyof SpoolFormData>(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<string, unknown>) =>
+      api.createSpool(data as Parameters<typeof api.createSpool>[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<string, unknown>) =>
+      api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[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<string, unknown> = {
+      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 (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+      />
+
+      <div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+          <h2 className="text-lg font-semibold text-white">
+            {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
+          </h2>
+          <button
+            onClick={onClose}
+            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Tabs */}
+        <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
+          <button
+            onClick={() => setActiveTab('filament')}
+            className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
+              activeTab === 'filament'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : 'text-bambu-gray hover:text-white'
+            }`}
+          >
+            <Palette className="w-4 h-4" />
+            {t('inventory.filamentInfoTab')}
+          </button>
+          <button
+            onClick={() => setActiveTab('pa-profile')}
+            className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
+              activeTab === 'pa-profile'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : 'text-bambu-gray hover:text-white'
+            }`}
+          >
+            <Beaker className="w-4 h-4" />
+            {t('inventory.paProfileTab')}
+            {selectedProfileCount > 0 && (
+              <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
+                {selectedProfileCount}
+              </span>
+            )}
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-4 overflow-y-auto flex-1">
+          {activeTab === 'filament' ? (
+            <div className="space-y-6">
+              {/* Filament Info Section */}
+              <div>
+                <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
+                  {t('inventory.filamentInfo')}
+                </h3>
+                <FilamentSection
+                  formData={formData}
+                  updateField={updateField}
+                  cloudAuthenticated={cloudAuthenticated}
+                  loadingCloudPresets={loadingCloudPresets}
+                  presetInputValue={presetInputValue}
+                  setPresetInputValue={setPresetInputValue}
+                  selectedPresetOption={selectedPresetOption}
+                  filamentOptions={filamentOptions}
+                  availableBrands={availableBrands}
+                />
+                {errors.slicer_filament && (
+                  <p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
+                )}
+                {errors.material && (
+                  <p className="mt-1 text-xs text-red-400">{errors.material}</p>
+                )}
+              </div>
+
+              {/* Color Section */}
+              <div>
+                <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
+                  {t('inventory.color')}
+                </h3>
+                <ColorSection
+                  formData={formData}
+                  updateField={updateField}
+                  recentColors={recentColors}
+                  onColorUsed={handleColorUsed}
+                />
+              </div>
+
+              {/* Additional Section */}
+              <div>
+                <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
+                  {t('inventory.additional')}
+                </h3>
+                <AdditionalSection
+                  formData={formData}
+                  updateField={updateField}
+                  spoolCatalog={spoolCatalog}
+                />
+              </div>
+
+              {/* Usage History (only when editing) */}
+              {isEditing && spool && (
+                <div>
+                  <SpoolUsageHistory spoolId={spool.id} />
+                </div>
+              )}
+            </div>
+          ) : (
+            <PAProfileSection
+              formData={formData}
+              updateField={updateField}
+              printersWithCalibrations={resolvedCalibrations}
+              selectedProfiles={selectedProfiles}
+              setSelectedProfiles={setSelectedProfiles}
+              expandedPrinters={expandedPrinters}
+              setExpandedPrinters={setExpandedPrinters}
+            />
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
+          <Button variant="secondary" onClick={onClose}>
+            {t('common.cancel')}
+          </Button>
+          <Button
+            onClick={handleSubmit}
+            disabled={isPending}
+          >
+            {isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                {t('common.saving')}
+              </>
+            ) : (
+              <>
+                <Save className="w-4 h-4" />
+                {isEditing ? t('common.save') : t('inventory.addSpool')}
+              </>
+            )}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 101 - 0
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<string, string> = {
+  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 (
+      <div className="flex justify-center py-4">
+        <Loader2 className="w-5 h-5 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  if (!history || history.length === 0) {
+    return (
+      <div className="text-center py-4 text-bambu-gray text-sm">
+        <Clock className="w-5 h-5 mx-auto mb-2 opacity-50" />
+        {t('inventory.noUsageHistory')}
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-2">
+      <div className="flex items-center justify-between">
+        <h4 className="text-sm font-medium text-white">{t('inventory.usageHistory')}</h4>
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={() => clearMutation.mutate()}
+          disabled={clearMutation.isPending}
+          className="text-xs text-bambu-gray hover:text-red-400"
+        >
+          <Trash2 className="w-3 h-3 mr-1" />
+          {t('inventory.clearHistory')}
+        </Button>
+      </div>
+      <div className="max-h-48 overflow-y-auto space-y-1">
+        {history.map((record: SpoolUsageRecord) => (
+          <div
+            key={record.id}
+            className="flex items-center justify-between p-2 rounded bg-bambu-dark/50 text-xs"
+          >
+            <div className="flex-1 min-w-0">
+              <span className="text-bambu-gray">{formatDate(record.created_at)}</span>
+              {record.print_name && (
+                <span className="text-white ml-2 truncate" title={record.print_name}>
+                  {record.print_name}
+                </span>
+              )}
+            </div>
+            <div className="flex items-center gap-2 flex-shrink-0 ml-2">
+              <span className="text-white font-medium">{record.weight_used.toFixed(1)}g</span>
+              <span className="text-bambu-gray">({record.percent_used}%)</span>
+              <span className={STATUS_COLORS[record.status] || 'text-bambu-gray'}>
+                {record.status}
+              </span>
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 317 - 264
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() {
         <CardHeader>
           <div className="flex items-center gap-2">
             <Database className="w-5 h-5 text-bambu-green" />
-            <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
+            <h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
           </div>
         </CardHeader>
         <CardContent>
@@ -164,7 +164,7 @@ export function SpoolmanSettings() {
         <div className="flex items-center justify-between">
           <div className="flex items-center gap-2">
             <Database className="w-5 h-5 text-bambu-green" />
-            <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
+            <h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
           </div>
           {saveMutation.isPending && (
             <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
@@ -173,303 +173,356 @@ export function SpoolmanSettings() {
       </CardHeader>
       <CardContent className="space-y-4">
         <p className="text-sm text-bambu-gray">
-          Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
+          {t('settings.filamentTrackingDesc')}
         </p>
 
-        {/* Info banner about sync requirements */}
-        <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
-          <div className="flex gap-2">
-            <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
-            <div className="text-xs text-blue-300">
-              <p className="font-medium mb-1">How Sync Works</p>
-              <ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
-                <li>Only official Bambu Lab spools with RFID are synced</li>
-                <li>New spools are auto-created in Spoolman on first sync</li>
-                <li>Non-Bambu Lab spools (third-party, refilled) are skipped</li>
-              </ul>
-              <p className="font-medium mt-2 mb-1">Linking Existing Spools</p>
-              <p className="text-blue-300/80">
-                To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".
-              </p>
+        {/* Mode selector cards */}
+        <div className="grid grid-cols-2 gap-3">
+          {/* Built-in Inventory */}
+          <button
+            type="button"
+            onClick={() => setLocalEnabled(false)}
+            className={`p-3 rounded-lg border-2 text-left transition-colors ${
+              !localEnabled
+                ? 'border-bambu-green bg-bambu-green/10'
+                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
+            }`}
+          >
+            <div className="flex items-center gap-2 mb-1.5">
+              <Package className={`w-4 h-4 ${!localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+              <span className={`text-sm font-medium ${!localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
+                {t('settings.trackingModeBuiltIn')}
+              </span>
             </div>
-          </div>
-        </div>
-
-        {/* Enable toggle */}
-        <div className="flex items-center justify-between">
-          <div>
-            <p className="text-white">Enable Spoolman</p>
-            <p className="text-sm text-bambu-gray">
-              Sync filament data with Spoolman server
+            <p className={`text-xs ${!localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
+              {t('settings.trackingModeBuiltInDesc')}
             </p>
-          </div>
-          <label className="relative inline-flex items-center cursor-pointer">
-            <input
-              type="checkbox"
-              checked={localEnabled}
-              onChange={(e) => setLocalEnabled(e.target.checked)}
-              className="sr-only peer"
-            />
-            <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-          </label>
-        </div>
-
-        {/* URL input */}
-        <div>
-          <label className="block text-sm text-bambu-gray mb-1">
-            Spoolman URL
-          </label>
-          <input
-            type="text"
-            placeholder="http://192.168.1.100:7912"
-            value={localUrl}
-            onChange={(e) => 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"
-          />
-          <p className="text-xs text-bambu-gray mt-1">
-            URL of your Spoolman server (e.g., http://localhost:7912)
-          </p>
-        </div>
+            {!localEnabled && (
+              <div className="flex items-center gap-1 mt-2">
+                <Check className="w-3 h-3 text-bambu-green" />
+                <span className="text-xs text-bambu-green">{t('common.enabled')}</span>
+              </div>
+            )}
+          </button>
 
-        {/* Sync mode */}
-        <div>
-          <label className="block text-sm text-bambu-gray mb-1">
-            Sync Mode
-          </label>
-          <select
-            value={localSyncMode}
-            onChange={(e) => setLocalSyncMode(e.target.value)}
-            disabled={!localEnabled}
-            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
+          {/* Spoolman */}
+          <button
+            type="button"
+            onClick={() => setLocalEnabled(true)}
+            className={`p-3 rounded-lg border-2 text-left transition-colors ${
+              localEnabled
+                ? 'border-bambu-green bg-bambu-green/10'
+                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
+            }`}
           >
-            <option value="auto">Automatic</option>
-            <option value="manual">Manual Only</option>
-          </select>
-          <p className="text-xs text-bambu-gray mt-1">
-            {localSyncMode === 'auto'
-              ? 'AMS data syncs automatically when changes are detected'
-              : 'Only sync when manually triggered'}
-          </p>
+            <div className="flex items-center gap-2 mb-1.5">
+              <ExternalLink className={`w-4 h-4 ${localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+              <span className={`text-sm font-medium ${localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
+                Spoolman
+              </span>
+            </div>
+            <p className={`text-xs ${localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
+              {t('settings.trackingModeSpoolmanDesc')}
+            </p>
+            {localEnabled && (
+              <div className="flex items-center gap-1 mt-2">
+                <Check className="w-3 h-3 text-bambu-green" />
+                <span className="text-xs text-bambu-green">{t('common.enabled')}</span>
+              </div>
+            )}
+          </button>
         </div>
 
-        {/* Disable Weight Sync toggle - only show when sync mode is auto */}
-        {localSyncMode === 'auto' && (
-          <div className="flex items-center justify-between">
-            <div>
-              <p className="text-white">{t('spoolman.disableWeightSync')}</p>
-              <p className="text-sm text-bambu-gray">
-                {t('spoolman.disableWeightSyncDesc')}
-              </p>
+        {/* Built-in Inventory details */}
+        {!localEnabled && (
+          <div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
+            <div className="flex gap-2">
+              <Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
+              <div className="text-xs text-bambu-gray">
+                <ul className="list-disc list-inside space-y-0.5">
+                  <li>{t('settings.builtInFeatureRfid')}</li>
+                  <li>{t('settings.builtInFeatureUsage')}</li>
+                  <li>{t('settings.builtInFeatureCatalog')}</li>
+                </ul>
+              </div>
             </div>
-            <label className="relative inline-flex items-center cursor-pointer">
-              <input
-                type="checkbox"
-                checked={localDisableWeightSync}
-                onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
-                disabled={!localEnabled}
-                className="sr-only peer"
-              />
-              <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-            </label>
           </div>
         )}
 
-        {/* Report Partial Usage toggle - only show when weight sync is disabled */}
-        {localDisableWeightSync && (
-          <div className="flex items-center justify-between">
-            <div>
-              <p className="text-white">{t('spoolman.reportPartialUsage')}</p>
-              <p className="text-sm text-bambu-gray">
-                {t('spoolman.reportPartialUsageDesc')}
-              </p>
+        {/* Spoolman settings - only shown when Spoolman mode is selected */}
+        {localEnabled && (
+          <div className="space-y-4">
+            {/* Info banner about sync requirements */}
+            <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
+              <div className="flex gap-2">
+                <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
+                <div className="text-xs text-blue-300">
+                  <p className="font-medium mb-1">{t('settings.howSyncWorks')}</p>
+                  <ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
+                    <li>{t('settings.syncInfoRfidOnly')}</li>
+                    <li>{t('settings.syncInfoAutoCreate')}</li>
+                    <li>{t('settings.syncInfoThirdPartySkipped')}</li>
+                  </ul>
+                  <p className="font-medium mt-2 mb-1">{t('settings.linkingExistingSpools')}</p>
+                  <p className="text-blue-300/80">
+                    {t('settings.linkingExistingSpoolsDesc')}
+                  </p>
+                </div>
+              </div>
             </div>
-            <label className="relative inline-flex items-center cursor-pointer">
+
+            {/* URL input */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('settings.spoolmanUrl')}
+              </label>
               <input
-                type="checkbox"
-                checked={localReportPartialUsage}
-                onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
-                disabled={!localEnabled}
-                className="sr-only peer"
+                type="text"
+                placeholder="http://192.168.1.100:7912"
+                value={localUrl}
+                onChange={(e) => 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"
               />
-              <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-            </label>
-          </div>
-        )}
+              <p className="text-xs text-bambu-gray mt-1">
+                {t('settings.spoolmanUrlHint')}
+              </p>
+            </div>
 
-        {/* Connection status */}
-        {localEnabled && (
-          <div className="pt-2 border-t border-bambu-dark-tertiary">
-            <div className="flex items-center justify-between mb-3">
-              <div className="flex items-center gap-2">
-                <span className="text-sm text-bambu-gray">Status:</span>
-                {statusLoading ? (
-                  <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
-                ) : status?.connected ? (
-                  <span className="flex items-center gap-1 text-sm text-green-500">
-                    <Check className="w-4 h-4" />
-                    Connected
-                  </span>
-                ) : (
-                  <span className="flex items-center gap-1 text-sm text-red-500">
-                    <X className="w-4 h-4" />
-                    Disconnected
-                  </span>
-                )}
-              </div>
-              <div className="flex gap-2">
-                {status?.connected ? (
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => disconnectMutation.mutate()}
-                    disabled={disconnectMutation.isPending}
-                  >
-                    {disconnectMutation.isPending ? (
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                    ) : (
-                      <Link2Off className="w-4 h-4" />
-                    )}
-                    Disconnect
-                  </Button>
-                ) : (
-                  <Button
-                    size="sm"
-                    onClick={() => connectMutation.mutate()}
-                    disabled={connectMutation.isPending || !localUrl}
-                  >
-                    {connectMutation.isPending ? (
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                    ) : (
-                      <Link2 className="w-4 h-4" />
-                    )}
-                    Connect
-                  </Button>
-                )}
-              </div>
+            {/* Sync mode */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('settings.syncMode')}
+              </label>
+              <select
+                value={localSyncMode}
+                onChange={(e) => setLocalSyncMode(e.target.value)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              >
+                <option value="auto">{t('settings.syncModeAuto')}</option>
+                <option value="manual">{t('settings.syncModeManual')}</option>
+              </select>
+              <p className="text-xs text-bambu-gray mt-1">
+                {localSyncMode === 'auto'
+                  ? t('settings.syncModeAutoDesc')
+                  : t('settings.syncModeManualDesc')}
+              </p>
             </div>
 
-            {/* Error display */}
-            {connectMutation.isError && (
-              <div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
-                {(connectMutation.error as Error).message}
+            {/* Disable Weight Sync toggle - only show when sync mode is auto */}
+            {localSyncMode === 'auto' && (
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">{t('spoolman.disableWeightSync')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('spoolman.disableWeightSyncDesc')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localDisableWeightSync}
+                    onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
               </div>
             )}
 
-            {/* Manual sync section */}
-            {status?.connected && (
-              <div className="space-y-3">
+            {/* Report Partial Usage toggle - only show when weight sync is disabled */}
+            {localDisableWeightSync && (
+              <div className="flex items-center justify-between">
                 <div>
-                  <p className="text-sm text-white">Sync AMS Data</p>
-                  <p className="text-xs text-bambu-gray">
-                    Manually sync printer AMS data to Spoolman
+                  <p className="text-white">{t('spoolman.reportPartialUsage')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('spoolman.reportPartialUsageDesc')}
                   </p>
                 </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localReportPartialUsage}
+                    onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+            )}
+
+            {/* Connection status */}
+            <div className="pt-2 border-t border-bambu-dark-tertiary">
+              <div className="flex items-center justify-between mb-3">
                 <div className="flex items-center gap-2">
-                  {/* Printer selector */}
-                  <div className="relative flex-1">
-                    <select
-                      value={selectedPrinterId}
-                      onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
-                      className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  <span className="text-sm text-bambu-gray">{t('settings.status')}:</span>
+                  {statusLoading ? (
+                    <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+                  ) : status?.connected ? (
+                    <span className="flex items-center gap-1 text-sm text-green-500">
+                      <Check className="w-4 h-4" />
+                      {t('settings.spoolmanConnected')}
+                    </span>
+                  ) : (
+                    <span className="flex items-center gap-1 text-sm text-red-500">
+                      <X className="w-4 h-4" />
+                      {t('settings.spoolmanDisconnected')}
+                    </span>
+                  )}
+                </div>
+                <div className="flex gap-2">
+                  {status?.connected ? (
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => disconnectMutation.mutate()}
+                      disabled={disconnectMutation.isPending}
                     >
-                      <option value="all">All Printers</option>
-                      {printers?.map((printer: Printer) => (
-                        <option key={printer.id} value={printer.id}>
-                          {printer.name}
-                        </option>
-                      ))}
-                    </select>
-                    <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
-                  </div>
-                  {/* Sync button */}
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={handleSync}
-                    disabled={isSyncing}
-                  >
-                    {isSyncing ? (
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                    ) : (
-                      <RefreshCw className="w-4 h-4" />
-                    )}
-                    Sync
-                  </Button>
+                      {disconnectMutation.isPending ? (
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                      ) : (
+                        <Link2Off className="w-4 h-4" />
+                      )}
+                      {t('settings.disconnect')}
+                    </Button>
+                  ) : (
+                    <Button
+                      size="sm"
+                      onClick={() => connectMutation.mutate()}
+                      disabled={connectMutation.isPending || !localUrl}
+                    >
+                      {connectMutation.isPending ? (
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                      ) : (
+                        <Link2 className="w-4 h-4" />
+                      )}
+                      {t('settings.connect')}
+                    </Button>
+                  )}
                 </div>
               </div>
-            )}
 
-            {/* Sync result */}
-            {syncSuccess && syncResult && (
-              <div className="mt-3 space-y-2">
-                {/* Main result */}
-                <div
-                  className={`p-2 rounded text-sm ${
-                    syncResult.success
-                      ? 'bg-green-500/20 border border-green-500/50 text-green-400'
-                      : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
-                  }`}
-                >
-                  {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 && (
+                <div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+                  {(connectMutation.error as Error).message}
                 </div>
+              )}
 
-                {/* Skipped spools */}
-                {syncResult.skipped_count > 0 && (
-                  <div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
-                    <div className="flex items-center justify-between text-amber-400 mb-1">
-                      <div className="flex items-center gap-1.5">
-                        <AlertTriangle className="w-3.5 h-3.5" />
-                        <span className="font-medium">
-                          {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
-                        </span>
-                      </div>
-                      {syncResult.skipped_count > 5 && (
-                        <button
-                          onClick={() => setShowAllSkipped(!showAllSkipped)}
-                          className="text-xs text-amber-400 hover:text-amber-300 underline"
-                        >
-                          {showAllSkipped ? 'Show less' : 'Show all'}
-                        </button>
-                      )}
+              {/* Manual sync section */}
+              {status?.connected && (
+                <div className="space-y-3">
+                  <div>
+                    <p className="text-sm text-white">{t('settings.syncAmsData')}</p>
+                    <p className="text-xs text-bambu-gray">
+                      {t('settings.syncAmsDataDesc')}
+                    </p>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    {/* Printer selector */}
+                    <div className="relative flex-1">
+                      <select
+                        value={selectedPrinterId}
+                        onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
+                        className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                      >
+                        <option value="all">{t('settings.allPrinters')}</option>
+                        {printers?.map((printer: Printer) => (
+                          <option key={printer.id} value={printer.id}>
+                            {printer.name}
+                          </option>
+                        ))}
+                      </select>
+                      <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
                     </div>
-                    <ul className="text-xs text-amber-300/80 space-y-0.5">
-                      {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
-                        <li key={i} className="flex items-center gap-2">
-                          {s.color && (
-                            <span
-                              className="w-3 h-3 rounded-full border border-white/20"
-                              style={{ backgroundColor: `#${s.color}` }}
-                            />
-                          )}
-                          <span>{s.location}</span>
-                          <span className="text-amber-300/60">- {s.reason}</span>
-                        </li>
-                      ))}
-                      {!showAllSkipped && syncResult.skipped_count > 5 && (
-                        <li className="text-amber-300/60 italic">
-                          ...and {syncResult.skipped_count - 5} more
-                        </li>
+                    {/* Sync button */}
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={handleSync}
+                      disabled={isSyncing}
+                    >
+                      {isSyncing ? (
+                        <Loader2 className="w-4 h-4 animate-spin" />
+                      ) : (
+                        <RefreshCw className="w-4 h-4" />
                       )}
-                    </ul>
+                      {t('spoolman.sync')}
+                    </Button>
                   </div>
-                )}
+                </div>
+              )}
 
-                {/* Errors */}
-                {syncResult.errors.length > 0 && (
-                  <div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
-                    <div className="text-red-400 font-medium mb-1">Errors:</div>
-                    <ul className="text-xs text-red-300/80 space-y-0.5">
-                      {syncResult.errors.map((err, i) => (
-                        <li key={i}>{err}</li>
-                      ))}
-                    </ul>
+              {/* Sync result */}
+              {syncSuccess && syncResult && (
+                <div className="mt-3 space-y-2">
+                  {/* Main result */}
+                  <div
+                    className={`p-2 rounded text-sm ${
+                      syncResult.success
+                        ? 'bg-green-500/20 border border-green-500/50 text-green-400'
+                        : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
+                    }`}
+                  >
+                    {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' : ''}`}
                   </div>
-                )}
-              </div>
-            )}
+
+                  {/* Skipped spools */}
+                  {syncResult.skipped_count > 0 && (
+                    <div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
+                      <div className="flex items-center justify-between text-amber-400 mb-1">
+                        <div className="flex items-center gap-1.5">
+                          <AlertTriangle className="w-3.5 h-3.5" />
+                          <span className="font-medium">
+                            {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
+                          </span>
+                        </div>
+                        {syncResult.skipped_count > 5 && (
+                          <button
+                            onClick={() => setShowAllSkipped(!showAllSkipped)}
+                            className="text-xs text-amber-400 hover:text-amber-300 underline"
+                          >
+                            {showAllSkipped ? 'Show less' : 'Show all'}
+                          </button>
+                        )}
+                      </div>
+                      <ul className="text-xs text-amber-300/80 space-y-0.5">
+                        {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
+                          <li key={i} className="flex items-center gap-2">
+                            {s.color && (
+                              <span
+                                className="w-3 h-3 rounded-full border border-white/20"
+                                style={{ backgroundColor: `#${s.color}` }}
+                              />
+                            )}
+                            <span>{s.location}</span>
+                            <span className="text-amber-300/60">- {s.reason}</span>
+                          </li>
+                        ))}
+                        {!showAllSkipped && syncResult.skipped_count > 5 && (
+                          <li className="text-amber-300/60 italic">
+                            ...and {syncResult.skipped_count - 5} more
+                          </li>
+                        )}
+                      </ul>
+                    </div>
+                  )}
+
+                  {/* Errors */}
+                  {syncResult.errors.length > 0 && (
+                    <div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
+                      <div className="text-red-400 font-medium mb-1">Errors:</div>
+                      <ul className="text-xs text-red-300/80 space-y-0.5">
+                        {syncResult.errors.map((err, i) => (
+                          <li key={i}>{err}</li>
+                        ))}
+                      </ul>
+                    </div>
+                  )}
+                </div>
+              )}
+            </div>
           </div>
         )}
       </CardContent>

+ 154 - 0
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<number | null>(null);
+  const dropdownRef = useRef<HTMLDivElement>(null);
+  const inputRef = useRef<HTMLInputElement>(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 (
+    <div>
+      <label className="block text-sm font-medium text-bambu-gray mb-1">
+        <span className="flex items-center gap-2">
+          <Scale className="w-3.5 h-3.5 text-bambu-gray" />
+          {t('inventory.coreWeight')}
+        </span>
+      </label>
+      <div className="flex gap-2 items-center">
+        <div className="flex-1 min-w-0 relative" ref={dropdownRef}>
+          <input
+            ref={inputRef}
+            type="text"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+            placeholder={t('inventory.searchSpoolWeight')}
+            value={displayValue}
+            onFocus={() => {
+              setIsOpen(true);
+              setSearch('');
+            }}
+            onChange={(e) => {
+              setSearch(e.target.value);
+              setIsOpen(true);
+            }}
+          />
+          {isOpen && (
+            <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
+              {filtered.length === 0 ? (
+                <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
+              ) : (
+                filtered.map(entry => (
+                  <button
+                    key={entry.id}
+                    type="button"
+                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
+                      (selectedId ? entry.id === selectedId : entry.weight === value)
+                        ? 'bg-bambu-green/10 text-bambu-green'
+                        : 'text-white'
+                    }`}
+                    onClick={() => {
+                      setSelectedId(entry.id);
+                      onChange(entry.weight);
+                      setIsOpen(false);
+                      setSearch('');
+                    }}
+                  >
+                    <span className="truncate">{entry.name}</span>
+                    <span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{entry.weight}g</span>
+                  </button>
+                ))
+              )}
+            </div>
+          )}
+        </div>
+        <div className="flex items-center gap-1 shrink-0">
+          <input
+            type="number"
+            className="w-16 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm text-center font-mono focus:outline-none focus:border-bambu-green"
+            value={value}
+            min={0}
+            max={2000}
+            onChange={(e) => {
+              const val = parseInt(e.target.value);
+              if (!isNaN(val) && val >= 0) onChange(val);
+            }}
+          />
+          <span className="text-bambu-gray text-sm">g</span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function AdditionalSection({
+  formData,
+  updateField,
+  spoolCatalog,
+}: AdditionalSectionProps) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="space-y-4">
+      {/* Empty Spool Weight */}
+      <SpoolWeightPicker
+        catalog={spoolCatalog}
+        value={formData.core_weight}
+        onChange={(weight) => updateField('core_weight', weight)}
+      />
+
+      {/* Note */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
+        <textarea
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]"
+          placeholder={t('inventory.notePlaceholder')}
+          value={formData.note}
+          onChange={(e) => updateField('note', e.target.value)}
+        />
+      </div>
+    </div>
+  );
+}

+ 173 - 0
frontend/src/components/spool-form/ColorSection.tsx

@@ -0,0 +1,173 @@
+import { useState, useMemo } from 'react';
+import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { ColorSectionProps } from './types';
+import { QUICK_COLORS, ALL_COLORS } from './constants';
+
+export function ColorSection({
+  formData,
+  updateField,
+  recentColors,
+  onColorUsed,
+}: ColorSectionProps) {
+  const { t } = useTranslation();
+  const [showAllColors, setShowAllColors] = useState(false);
+  const [colorSearch, setColorSearch] = useState('');
+
+  // Current hex without # prefix
+  const currentHex = formData.rgba.replace('#', '').substring(0, 6);
+
+  const isSelected = (hex: string) => {
+    return currentHex.toUpperCase() === hex.toUpperCase();
+  };
+
+  const selectColor = (hex: string, name: string) => {
+    // Store as RRGGBBAA (with FF alpha)
+    updateField('rgba', hex.toUpperCase() + 'FF');
+    updateField('color_name', name);
+    onColorUsed({ name, hex });
+  };
+
+  // Colors to show based on search/expand state
+  const filteredColors = useMemo(() => {
+    if (colorSearch) {
+      return ALL_COLORS.filter(c =>
+        c.name.toLowerCase().includes(colorSearch.toLowerCase()),
+      );
+    }
+    return showAllColors ? ALL_COLORS : QUICK_COLORS;
+  }, [colorSearch, showAllColors]);
+
+  return (
+    <div className="space-y-3">
+      {/* Color preview banner */}
+      <div
+        className="h-10 rounded-lg border border-bambu-dark-tertiary"
+        style={{ backgroundColor: `#${currentHex}` }}
+      />
+
+      {/* Recently Used Colors */}
+      {recentColors.length > 0 && (
+        <div className="flex items-center gap-2">
+          <div className="flex items-center gap-1.5 text-xs text-bambu-gray shrink-0">
+            <Clock className="w-3 h-3" />
+            <span>{t('inventory.recentColors')}</span>
+          </div>
+          <div className="flex flex-wrap gap-1.5">
+            {recentColors.map(color => (
+              <button
+                key={color.hex}
+                type="button"
+                onClick={() => selectColor(color.hex, color.name)}
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 ${
+                  isSelected(color.hex)
+                    ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
+                    : 'border-bambu-dark-tertiary'
+                }`}
+                style={{ backgroundColor: `#${color.hex}` }}
+                title={color.name}
+              />
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Color Search */}
+      <div className="relative">
+        <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+        <input
+          type="text"
+          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/50 focus:outline-none focus:border-bambu-green"
+          placeholder={t('inventory.searchColors')}
+          value={colorSearch}
+          onChange={(e) => setColorSearch(e.target.value)}
+        />
+      </div>
+
+      {/* Color Swatches Grid */}
+      <div className="space-y-1.5">
+        <div className="flex items-center justify-between text-xs text-bambu-gray">
+          <span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
+          {!colorSearch && (
+            <button
+              type="button"
+              onClick={() => setShowAllColors(!showAllColors)}
+              className="flex items-center gap-1 hover:text-white transition-colors"
+            >
+              {showAllColors ? (
+                <>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
+              ) : (
+                <>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
+              )}
+            </button>
+          )}
+        </div>
+        <div className="flex flex-wrap gap-1.5">
+          {filteredColors.map(color => (
+            <button
+              key={color.hex}
+              type="button"
+              onClick={() => selectColor(color.hex, color.name)}
+              className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                isSelected(color.hex)
+                  ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
+                  : 'border-bambu-dark-tertiary'
+              }`}
+              style={{ backgroundColor: `#${color.hex}` }}
+              title={color.name}
+            >
+              <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                {color.name}
+              </span>
+            </button>
+          ))}
+          {filteredColors.length === 0 && (
+            <p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
+          )}
+        </div>
+      </div>
+
+      {/* Manual Color Input */}
+      <div className="grid grid-cols-2 gap-3">
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.colorName')}</label>
+          <input
+            type="text"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+            placeholder={t('inventory.colorNamePlaceholder')}
+            value={formData.color_name}
+            onChange={(e) => updateField('color_name', e.target.value)}
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.hexColor')}</label>
+          <div className="flex gap-2">
+            <div className="relative flex-1">
+              <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray">#</span>
+              <input
+                type="text"
+                className="w-full pl-7 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono uppercase focus:outline-none focus:border-bambu-green"
+                placeholder="RRGGBB"
+                value={currentHex.toUpperCase()}
+                onChange={(e) => {
+                  const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '');
+                  if (val.length <= 8) updateField('rgba', val.toUpperCase() + (val.length <= 6 ? 'FF' : ''));
+                }}
+              />
+            </div>
+            <input
+              type="color"
+              className="w-11 h-[38px] rounded-lg cursor-pointer border border-bambu-dark-tertiary shrink-0 bg-transparent"
+              value={`#${currentHex}`}
+              onChange={(e) => {
+                const hex = e.target.value.replace('#', '').toUpperCase();
+                updateField('rgba', hex + 'FF');
+              }}
+              title={t('inventory.pickColor')}
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 245 - 0
frontend/src/components/spool-form/FilamentSection.tsx

@@ -0,0 +1,245 @@
+import { useState, useRef, useEffect, useMemo } from 'react';
+import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { FilamentSectionProps, FilamentOption } from './types';
+import { MATERIALS, KNOWN_VARIANTS } from './constants';
+import { parsePresetName } from './utils';
+
+export function FilamentSection({
+  formData,
+  updateField,
+  cloudAuthenticated,
+  loadingCloudPresets,
+  presetInputValue,
+  setPresetInputValue,
+  selectedPresetOption,
+  filamentOptions,
+  availableBrands,
+}: FilamentSectionProps) {
+  const { t } = useTranslation();
+  const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
+  const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
+  const [brandSearch, setBrandSearch] = useState('');
+  const presetRef = useRef<HTMLDivElement>(null);
+  const brandRef = useRef<HTMLDivElement>(null);
+
+  // Close dropdowns on outside click
+  useEffect(() => {
+    const handleClick = (e: MouseEvent) => {
+      if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
+        setPresetDropdownOpen(false);
+      }
+      if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
+        setBrandDropdownOpen(false);
+      }
+    };
+    document.addEventListener('mousedown', handleClick);
+    return () => document.removeEventListener('mousedown', handleClick);
+  }, []);
+
+  // Filtered presets based on search
+  const filteredPresets = useMemo(() => {
+    if (!presetInputValue) return filamentOptions;
+    const search = presetInputValue.toLowerCase();
+    return filamentOptions.filter(o =>
+      o.displayName.toLowerCase().includes(search) ||
+      o.code.toLowerCase().includes(search),
+    );
+  }, [filamentOptions, presetInputValue]);
+
+  // Filtered brands
+  const filteredBrands = useMemo(() => {
+    if (!brandSearch) return availableBrands;
+    const search = brandSearch.toLowerCase();
+    return availableBrands.filter(b => b.toLowerCase().includes(search));
+  }, [availableBrands, brandSearch]);
+
+  // Handle preset selection
+  const handlePresetSelect = (option: FilamentOption) => {
+    updateField('slicer_filament', option.code);
+    setPresetInputValue(option.displayName);
+    setPresetDropdownOpen(false);
+
+    // Auto-fill material, brand, subtype from preset name
+    const parsed = parsePresetName(option.name);
+    if (parsed.material) updateField('material', parsed.material);
+    if (parsed.brand) updateField('brand', parsed.brand);
+    if (parsed.variant) updateField('subtype', parsed.variant);
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Cloud status indicator */}
+      <div className="flex items-center gap-2 text-xs text-bambu-gray">
+        {loadingCloudPresets ? (
+          <><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
+        ) : cloudAuthenticated ? (
+          <><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
+        ) : (
+          <><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
+        )}
+      </div>
+
+      {/* Slicer Preset (autocomplete) */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">
+          {t('inventory.slicerPreset')} *
+        </label>
+        <div className="relative" ref={presetRef}>
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+          <input
+            type="text"
+            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/50 focus:outline-none focus:border-bambu-green"
+            placeholder={t('inventory.searchPresets')}
+            value={presetInputValue}
+            onChange={(e) => {
+              setPresetInputValue(e.target.value);
+              setPresetDropdownOpen(true);
+            }}
+            onFocus={() => {
+              setPresetDropdownOpen(true);
+              setPresetInputValue('');
+            }}
+          />
+          {presetDropdownOpen && (
+            <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
+              {filteredPresets.length === 0 ? (
+                <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
+              ) : (
+                filteredPresets.map(option => (
+                  <button
+                    key={option.code}
+                    type="button"
+                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
+                      selectedPresetOption?.code === option.code
+                        ? 'bg-bambu-green/10 text-bambu-green'
+                        : 'text-white'
+                    }`}
+                    onClick={() => handlePresetSelect(option)}
+                  >
+                    <span className="truncate">{option.displayName}</span>
+                    <span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{option.code}</span>
+                  </button>
+                ))
+              )}
+            </div>
+          )}
+        </div>
+        {selectedPresetOption && (
+          <div className="mt-1 text-xs text-bambu-gray">
+            {t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
+          </div>
+        )}
+      </div>
+
+      {/* Material */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
+        <select
+          value={formData.material}
+          onChange={(e) => updateField('material', e.target.value)}
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+        >
+          <option value="">{t('inventory.selectMaterial')}</option>
+          {MATERIALS.map((m) => (
+            <option key={m} value={m}>{m}</option>
+          ))}
+        </select>
+      </div>
+
+      {/* Brand (dropdown with search) */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')}</label>
+        <div className="relative" ref={brandRef}>
+          <input
+            type="text"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+            placeholder={t('inventory.searchBrand')}
+            value={brandDropdownOpen ? brandSearch : formData.brand}
+            onChange={(e) => {
+              setBrandSearch(e.target.value);
+              setBrandDropdownOpen(true);
+            }}
+            onFocus={() => {
+              setBrandDropdownOpen(true);
+              setBrandSearch('');
+            }}
+          />
+          <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+          {brandDropdownOpen && (
+            <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
+              {filteredBrands.length === 0 ? (
+                <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
+              ) : (
+                filteredBrands.map(brand => (
+                  <button
+                    key={brand}
+                    type="button"
+                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                      formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+                    }`}
+                    onClick={() => {
+                      updateField('brand', brand);
+                      setBrandDropdownOpen(false);
+                      setBrandSearch('');
+                    }}
+                  >
+                    {brand}
+                  </button>
+                ))
+              )}
+              {/* Allow custom brand */}
+              {brandSearch && !filteredBrands.includes(brandSearch) && (
+                <button
+                  type="button"
+                  className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
+                  onClick={() => {
+                    updateField('brand', brandSearch);
+                    setBrandDropdownOpen(false);
+                    setBrandSearch('');
+                  }}
+                >
+                  {t('inventory.useCustomBrand', { brand: brandSearch })}
+                </button>
+              )}
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Variant / Subtype */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
+        <div className="relative">
+          <input
+            type="text"
+            value={formData.subtype}
+            onChange={(e) => updateField('subtype', e.target.value)}
+            list="variant-suggestions"
+            placeholder="Basic, Matte, Silk..."
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+          />
+          <datalist id="variant-suggestions">
+            {KNOWN_VARIANTS.map(v => (
+              <option key={v} value={v} />
+            ))}
+          </datalist>
+        </div>
+      </div>
+
+      {/* Label Weight */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.labelWeight')}</label>
+        <div className="relative">
+          <input
+            type="number"
+            value={formData.label_weight}
+            onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
+            className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+          />
+          <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 268 - 0
frontend/src/components/spool-form/PAProfileSection.tsx

@@ -0,0 +1,268 @@
+import { ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { CalibrationProfile, PAProfileSectionProps } from './types';
+import { isMatchingCalibration } from './utils';
+
+export function PAProfileSection({
+  formData,
+  printersWithCalibrations,
+  selectedProfiles,
+  setSelectedProfiles,
+  expandedPrinters,
+  setExpandedPrinters,
+}: PAProfileSectionProps) {
+  const { t } = useTranslation();
+
+  const togglePrinterExpanded = (printerId: string) => {
+    setExpandedPrinters((prev) => {
+      const next = new Set(prev);
+      if (next.has(printerId)) next.delete(printerId);
+      else next.add(printerId);
+      return next;
+    });
+  };
+
+  const toggleProfileSelected = (printerId: string, caliIdx: number, extruderId?: number | null) => {
+    const key = `${printerId}:${caliIdx}:${extruderId ?? 'null'}`;
+    const printerNozzleKey = `${printerId}:${extruderId ?? 'null'}`;
+
+    setSelectedProfiles((prev) => {
+      const next = new Set(prev);
+      if (next.has(key)) {
+        next.delete(key);
+      } else {
+        // Remove existing profile for same printer/nozzle
+        for (const existingKey of Array.from(next)) {
+          const parts = existingKey.split(':');
+          const existingPrinterNozzle = `${parts[0]}:${parts[2]}`;
+          if (existingPrinterNozzle === printerNozzleKey) {
+            next.delete(existingKey);
+          }
+        }
+        next.add(key);
+      }
+      return next;
+    });
+  };
+
+  // Auto-select best matching profiles
+  const autoSelectProfiles = () => {
+    const newSelection = new Set<string>();
+
+    for (const { printer, calibrations } of printersWithCalibrations) {
+      if (!printer.connected) continue;
+
+      const matchingCals = calibrations.filter(cal =>
+        isMatchingCalibration(cal, formData),
+      );
+
+      // Group by extruder
+      const byExtruder = new Map<string, CalibrationProfile[]>();
+      for (const cal of matchingCals) {
+        const extKey = `${cal.extruder_id ?? 'null'}`;
+        if (!byExtruder.has(extKey)) byExtruder.set(extKey, []);
+        byExtruder.get(extKey)!.push(cal);
+      }
+
+      // Select best (highest K) for each extruder
+      for (const [extKey, cals] of byExtruder) {
+        if (cals.length > 0) {
+          const sorted = [...cals].sort((a, b) => b.k_value - a.k_value);
+          const best = sorted[0];
+          newSelection.add(`${printer.id}:${best.cali_idx}:${extKey}`);
+        }
+      }
+    }
+
+    setSelectedProfiles(newSelection);
+  };
+
+  if (!formData.material) {
+    return (
+      <div className="p-6 bg-bambu-dark rounded-lg text-center">
+        <p className="text-bambu-gray">
+          {t('inventory.selectMaterialFirst')}
+        </p>
+      </div>
+    );
+  }
+
+  if (printersWithCalibrations.length === 0) {
+    return (
+      <div className="p-6 bg-bambu-dark rounded-lg text-center">
+        <p className="text-bambu-gray">
+          {t('inventory.noPrintersConfigured')}
+        </p>
+      </div>
+    );
+  }
+
+  // Count total matching profiles
+  const totalMatching = printersWithCalibrations.reduce((sum, { printer, calibrations }) => {
+    if (!printer.connected) return sum;
+    return sum + calibrations.filter(cal => isMatchingCalibration(cal, formData)).length;
+  }, 0);
+
+  const renderProfile = (printer: { id: number }, cal: CalibrationProfile) => {
+    const key = `${printer.id}:${cal.cali_idx}:${cal.extruder_id ?? 'null'}`;
+    const isSelected = selectedProfiles.has(key);
+    return (
+      <label
+        key={`${cal.cali_idx}-${cal.extruder_id}`}
+        className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${
+          isSelected
+            ? 'bg-bambu-green/10 border-bambu-green/30'
+            : 'bg-bambu-dark border-transparent hover:bg-bambu-dark/80'
+        }`}
+      >
+        <input
+          type="checkbox"
+          checked={isSelected}
+          onChange={() => toggleProfileSelected(String(printer.id), cal.cali_idx, cal.extruder_id)}
+          className="w-4 h-4 rounded border-bambu-dark-tertiary text-bambu-green focus:ring-bambu-green"
+        />
+        <div className="flex-1 min-w-0">
+          <span className={`text-sm font-medium ${isSelected ? 'text-bambu-green' : 'text-white'}`}>
+            {cal.name || cal.filament_id}
+          </span>
+        </div>
+        <div className="flex items-center gap-2 shrink-0">
+          <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
+            K={cal.k_value.toFixed(3)}
+          </span>
+        </div>
+      </label>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Header with auto-select */}
+      <div className="flex items-center justify-between">
+        <p className="text-xs text-bambu-gray">
+          {t('inventory.matchingFilter')}: {formData.brand || t('inventory.anyBrand')} / {formData.material} / {formData.subtype || t('inventory.anyVariant')}
+        </p>
+        {totalMatching > 0 && (
+          <button
+            type="button"
+            onClick={autoSelectProfiles}
+            className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
+          >
+            <Sparkles className="w-3.5 h-3.5" />
+            {t('inventory.autoSelect')} ({totalMatching})
+          </button>
+        )}
+      </div>
+
+      {/* Printer sections */}
+      <div className="space-y-3">
+        {printersWithCalibrations.map(({ printer, calibrations }) => {
+          const isExpanded = expandedPrinters.has(String(printer.id));
+          const matchingCals = calibrations.filter(cal => isMatchingCalibration(cal, formData));
+          const matchingCount = matchingCals.length;
+
+          // Multi-nozzle grouping
+          const isMultiNozzle = matchingCals.some(cal =>
+            cal.extruder_id !== undefined && cal.extruder_id !== null && cal.extruder_id > 0,
+          );
+          const leftNozzleCals = matchingCals.filter(cal => cal.extruder_id === 1);
+          const rightNozzleCals = matchingCals.filter(cal =>
+            cal.extruder_id === 0 || cal.extruder_id === undefined || cal.extruder_id === null,
+          );
+
+          return (
+            <div
+              key={printer.id}
+              className="border border-bambu-dark-tertiary rounded-lg overflow-hidden"
+            >
+              {/* Printer Header */}
+              <button
+                type="button"
+                onClick={() => togglePrinterExpanded(String(printer.id))}
+                className="w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors"
+              >
+                <div className="flex items-center gap-3">
+                  {isExpanded ? (
+                    <ChevronDown className="w-4 h-4 text-bambu-gray" />
+                  ) : (
+                    <ChevronRight className="w-4 h-4 text-bambu-gray" />
+                  )}
+                  <span className="font-medium text-white">
+                    {printer.name}
+                  </span>
+                  {matchingCount > 0 ? (
+                    <span className="text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
+                      {matchingCount} {matchingCount !== 1 ? t('inventory.matches') : t('inventory.match')}
+                    </span>
+                  ) : (
+                    <span className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray">
+                      {t('inventory.noMatches')}
+                    </span>
+                  )}
+                </div>
+                <span className={`text-xs px-2 py-1 rounded-full ${
+                  printer.connected
+                    ? 'bg-green-500/20 text-green-500'
+                    : 'bg-bambu-gray/20 text-bambu-gray'
+                }`}>
+                  {printer.connected ? t('inventory.connected') : t('inventory.offline')}
+                </span>
+              </button>
+
+              {/* Calibration Profiles */}
+              {isExpanded && (
+                <div className="px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary">
+                  {!printer.connected ? (
+                    <p className="text-sm text-bambu-gray italic py-2">
+                      {t('inventory.printerOffline')}
+                    </p>
+                  ) : matchingCount === 0 ? (
+                    <p className="text-sm text-bambu-gray italic py-2">
+                      {t('inventory.noKProfilesMatch')}
+                    </p>
+                  ) : isMultiNozzle ? (
+                    <>
+                      {leftNozzleCals.length > 0 && (
+                        <div className="space-y-2">
+                          <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
+                            {t('inventory.leftNozzle')}
+                          </p>
+                          <div className="space-y-2">
+                            {leftNozzleCals.map(cal => renderProfile(printer, cal))}
+                          </div>
+                        </div>
+                      )}
+                      {rightNozzleCals.length > 0 && (
+                        <div className="space-y-2">
+                          <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
+                            {t('inventory.rightNozzle')}
+                          </p>
+                          <div className="space-y-2">
+                            {rightNozzleCals.map(cal => renderProfile(printer, cal))}
+                          </div>
+                        </div>
+                      )}
+                    </>
+                  ) : (
+                    <div className="space-y-2">
+                      {matchingCals.map(cal => renderProfile(printer, cal))}
+                    </div>
+                  )}
+                </div>
+              )}
+            </div>
+          );
+        })}
+      </div>
+
+      {/* Summary */}
+      {selectedProfiles.size > 0 && (
+        <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
+          <p className="text-sm text-white">
+            <span className="font-semibold">{selectedProfiles.size}</span> {t('inventory.profilesSelected')}
+          </p>
+        </div>
+      )}
+    </div>
+  );
+}

+ 101 - 0
frontend/src/components/spool-form/constants.ts

@@ -0,0 +1,101 @@
+import type { ColorPreset } from './types';
+
+// Material options
+export const MATERIALS = [
+  'PLA', 'PETG', 'ABS', 'TPU', 'ASA', 'PC', 'PA', 'PVA', 'HIPS',
+  'PA-CF', 'PETG-CF', 'PLA-CF',
+];
+
+// Common spool weights
+export const WEIGHTS = [250, 500, 750, 1000, 2000, 3000];
+
+// Default brand options (will be augmented with cloud presets)
+export const DEFAULT_BRANDS = [
+  'Bambu', 'PolyLite', 'PolyTerra', 'eSUN', 'Overture',
+  'Fiberon', 'SUNLU', 'Inland', 'Hatchbox', 'Generic',
+];
+
+// Known filament variants/subtypes
+export const KNOWN_VARIANTS = [
+  'Basic', 'Matte', 'Silk', 'Tough', 'HF', 'High Flow', 'Engineering',
+  'Galaxy', 'Glow', 'Marble', 'Metal', 'Rainbow', 'Sparkle', 'Wood',
+  'Translucent', 'Transparent', 'Clear', 'Lite', 'Pro', 'Plus', 'Max',
+  'Super', 'Ultra', 'Flex', 'Soft', 'Hard', 'Strong', 'Impact',
+  'Heat Resistant', 'UV Resistant', 'ESD', 'Conductive', 'Magnetic',
+  'Gradient', 'Dual Color', 'Tri Color', 'Multicolor',
+];
+
+// Quick color swatches - most common colors (shown by default)
+export const QUICK_COLORS: ColorPreset[] = [
+  { name: 'Black', hex: '000000' },
+  { name: 'White', hex: 'FFFFFF' },
+  { name: 'Gray', hex: '808080' },
+  { name: 'Red', hex: 'FF0000' },
+  { name: 'Orange', hex: 'FFA500' },
+  { name: 'Yellow', hex: 'FFFF00' },
+  { name: 'Green', hex: '00AE42' },
+  { name: 'Blue', hex: '0066FF' },
+  { name: 'Purple', hex: '8B00FF' },
+  { name: 'Pink', hex: 'FF69B4' },
+  { name: 'Brown', hex: '8B4513' },
+  { name: 'Silver', hex: 'C0C0C0' },
+];
+
+// Extended color palette (shown when expanded)
+export const EXTENDED_COLORS: ColorPreset[] = [
+  // Reds
+  { name: 'Dark Red', hex: '8B0000' },
+  { name: 'Crimson', hex: 'DC143C' },
+  { name: 'Coral', hex: 'FF7F50' },
+  { name: 'Salmon', hex: 'FA8072' },
+  // Oranges
+  { name: 'Dark Orange', hex: 'FF8C00' },
+  { name: 'Peach', hex: 'FFDAB9' },
+  // Yellows
+  { name: 'Gold', hex: 'FFD700' },
+  { name: 'Khaki', hex: 'F0E68C' },
+  { name: 'Lemon', hex: 'FFF44F' },
+  // Greens
+  { name: 'Lime', hex: '32CD32' },
+  { name: 'Forest Green', hex: '228B22' },
+  { name: 'Olive', hex: '808000' },
+  { name: 'Mint', hex: '98FF98' },
+  { name: 'Teal', hex: '008080' },
+  // Blues
+  { name: 'Navy', hex: '000080' },
+  { name: 'Sky Blue', hex: '87CEEB' },
+  { name: 'Royal Blue', hex: '4169E1' },
+  { name: 'Cyan', hex: '00FFFF' },
+  { name: 'Turquoise', hex: '40E0D0' },
+  // Purples
+  { name: 'Violet', hex: 'EE82EE' },
+  { name: 'Magenta', hex: 'FF00FF' },
+  { name: 'Indigo', hex: '4B0082' },
+  { name: 'Lavender', hex: 'E6E6FA' },
+  { name: 'Plum', hex: 'DDA0DD' },
+  // Pinks
+  { name: 'Hot Pink', hex: 'FF69B4' },
+  { name: 'Rose', hex: 'FF007F' },
+  { name: 'Blush', hex: 'FFB6C1' },
+  // Browns
+  { name: 'Chocolate', hex: 'D2691E' },
+  { name: 'Tan', hex: 'D2B48C' },
+  { name: 'Beige', hex: 'F5F5DC' },
+  { name: 'Maroon', hex: '800000' },
+  // Neutrals
+  { name: 'Dark Gray', hex: '404040' },
+  { name: 'Light Gray', hex: 'D3D3D3' },
+  { name: 'Charcoal', hex: '36454F' },
+  { name: 'Ivory', hex: 'FFFFF0' },
+  // Bambu specific
+  { name: 'Bambu Green', hex: '00AE42' },
+  { name: 'Jade White', hex: 'E8E8E8' },
+  { name: 'Titan Gray', hex: '5A5A5A' },
+];
+
+// All colors combined
+export const ALL_COLORS: ColorPreset[] = [...QUICK_COLORS, ...EXTENDED_COLORS];
+
+// Local storage keys
+export const RECENT_COLORS_KEY = 'bambuddy-recent-colors';
+export const MAX_RECENT_COLORS = 8;

+ 124 - 0
frontend/src/components/spool-form/types.ts

@@ -0,0 +1,124 @@
+import type { Printer, SpoolKProfile } from '../../api/client';
+
+// Form data structure
+export interface SpoolFormData {
+  material: string;
+  subtype: string;
+  brand: string;
+  color_name: string;
+  rgba: string;
+  label_weight: number;
+  core_weight: number;
+  slicer_filament: string;
+  note: string;
+}
+
+export const defaultFormData: SpoolFormData = {
+  material: '',
+  subtype: '',
+  brand: '',
+  color_name: '',
+  rgba: '808080FF',
+  label_weight: 1000,
+  core_weight: 250,
+  slicer_filament: '',
+  note: '',
+};
+
+// Printer with calibrations type
+export interface PrinterWithCalibrations {
+  printer: Printer & { connected?: boolean };
+  calibrations: CalibrationProfile[];
+}
+
+// Calibration profile from printer status
+export interface CalibrationProfile {
+  cali_idx: number;
+  filament_id: string;
+  setting_id: string;
+  name: string;
+  k_value: number;
+  n_coef: number;
+  extruder_id?: number | null;
+  nozzle_diameter?: string;
+}
+
+// Filament option from presets
+export interface FilamentOption {
+  code: string;
+  name: string;
+  displayName: string;
+  isCustom: boolean;
+  allCodes: string[];
+}
+
+// Color preset
+export interface ColorPreset {
+  name: string;
+  hex: string;
+}
+
+// Section props base
+export interface SectionProps {
+  formData: SpoolFormData;
+  updateField: <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => void;
+}
+
+// Filament section props
+export interface FilamentSectionProps extends SectionProps {
+  cloudAuthenticated: boolean;
+  loadingCloudPresets: boolean;
+  presetInputValue: string;
+  setPresetInputValue: (value: string) => void;
+  selectedPresetOption?: FilamentOption;
+  filamentOptions: FilamentOption[];
+  availableBrands: string[];
+}
+
+// Color section props
+export interface ColorSectionProps extends SectionProps {
+  recentColors: ColorPreset[];
+  onColorUsed: (color: ColorPreset) => void;
+}
+
+// Additional section props
+export interface AdditionalSectionProps extends SectionProps {
+  spoolCatalog: { id: number; name: string; weight: number }[];
+}
+
+// PA Profile section props
+export interface PAProfileSectionProps extends SectionProps {
+  printersWithCalibrations: PrinterWithCalibrations[];
+  selectedProfiles: Set<string>;
+  setSelectedProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
+  expandedPrinters: Set<string>;
+  setExpandedPrinters: React.Dispatch<React.SetStateAction<Set<string>>>;
+}
+
+// Validation result
+export interface ValidationResult {
+  isValid: boolean;
+  errors: Partial<Record<keyof SpoolFormData, string>>;
+}
+
+export function validateForm(formData: SpoolFormData): ValidationResult {
+  const errors: Partial<Record<keyof SpoolFormData, string>> = {};
+
+  if (!formData.slicer_filament) {
+    errors.slicer_filament = 'Slicer preset is required';
+  }
+
+  if (!formData.material) {
+    errors.material = 'Material is required';
+  }
+
+  return {
+    isValid: Object.keys(errors).length === 0,
+    errors,
+  };
+}
+
+// Existing K-profile for a spool (from saved data)
+export interface SavedKProfile extends SpoolKProfile {
+  printer_serial?: string;
+}

+ 253 - 0
frontend/src/components/spool-form/utils.ts

@@ -0,0 +1,253 @@
+import type { SlicerSetting } from '../../api/client';
+import type { ColorPreset, FilamentOption } from './types';
+import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
+
+// Fallback filament presets when cloud is not available
+const FALLBACK_PRESETS: FilamentOption[] = [
+  { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
+  { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
+  { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
+  { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
+  { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
+  { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
+  { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
+  { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
+  { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
+  { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
+  { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
+  { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
+  { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
+  { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
+  { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
+  { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
+  { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
+];
+
+// Parse a slicer preset name to extract brand, material, and variant
+export function parsePresetName(name: string): { brand: string; material: string; variant: string } {
+  // Remove @printer suffix (e.g., "@Bambu Lab H2D 0.4 nozzle")
+  let cleanName = name.replace(/@.*$/, '').trim();
+  // Remove (Custom) tag
+  cleanName = cleanName.replace(/\(Custom\)/i, '').trim();
+  // Remove leading # or * markers
+  cleanName = cleanName.replace(/^[#*]+\s*/, '').trim();
+
+  // Materials list - order matters (longer/more specific first)
+  const materials = [
+    'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',
+    'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',
+    'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PCTG', 'PEEK', 'PEI',
+  ];
+
+  // Find material in the name
+  let material = '';
+  let materialIdx = -1;
+  for (const m of materials) {
+    const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());
+    if (idx !== -1) {
+      material = m;
+      materialIdx = idx;
+      break;
+    }
+  }
+
+  // Brand is everything before the material
+  let brand = '';
+  if (materialIdx > 0) {
+    brand = cleanName.substring(0, materialIdx).trim();
+    brand = brand.replace(/[-_\s]+$/, '');
+  }
+
+  // Everything after material is potential variant
+  let afterMaterial = '';
+  if (materialIdx !== -1 && material) {
+    afterMaterial = cleanName.substring(materialIdx + material.length).trim();
+    afterMaterial = afterMaterial.replace(/^[-_\s]+/, '');
+  }
+
+  // Check for known variant - could be before OR after material
+  let variant = '';
+
+  // First check after material (most common)
+  for (const v of KNOWN_VARIANTS) {
+    if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {
+      variant = v;
+      break;
+    }
+  }
+
+  // If no variant found after material, check if brand contains a known variant
+  if (!variant && brand) {
+    for (const v of KNOWN_VARIANTS) {
+      const variantPattern = new RegExp(`\\s+${v}$`, 'i');
+      if (variantPattern.test(brand)) {
+        variant = v;
+        brand = brand.replace(variantPattern, '').trim();
+        break;
+      }
+    }
+  }
+
+  return { brand, material, variant };
+}
+
+// Extract unique brands from cloud presets
+export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
+  const brandSet = new Set<string>(DEFAULT_BRANDS);
+
+  for (const preset of presets) {
+    const { brand } = parsePresetName(preset.name);
+    if (brand && brand.length > 1) {
+      brandSet.add(brand);
+    }
+  }
+
+  return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
+}
+
+// Build filament options from cloud presets
+export function buildFilamentOptions(
+  cloudPresets: SlicerSetting[],
+  configuredPrinterModels: Set<string>,
+): FilamentOption[] {
+  if (cloudPresets.length > 0) {
+    const customPresets: FilamentOption[] = [];
+    const defaultPresetsMap = new Map<string, FilamentOption>();
+
+    for (const preset of cloudPresets) {
+      if (preset.is_custom) {
+        // Custom presets: include if matches configured printers or no printer filter
+        const presetNameUpper = preset.name.toUpperCase();
+        const matchesPrinter = configuredPrinterModels.size === 0 ||
+          Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
+          !presetNameUpper.includes('@');
+
+        if (matchesPrinter) {
+          customPresets.push({
+            code: preset.setting_id,
+            name: preset.name,
+            displayName: `${preset.name} (Custom)`,
+            isCustom: true,
+            allCodes: [preset.setting_id],
+          });
+        }
+      } else {
+        // Default presets: deduplicate by base name
+        const baseName = preset.name.replace(/@.*$/, '').trim();
+        const existing = defaultPresetsMap.get(baseName);
+        if (existing) {
+          existing.allCodes.push(preset.setting_id);
+        } else {
+          defaultPresetsMap.set(baseName, {
+            code: preset.setting_id,
+            name: baseName,
+            displayName: baseName,
+            isCustom: false,
+            allCodes: [preset.setting_id],
+          });
+        }
+      }
+    }
+
+    return [
+      ...customPresets,
+      ...Array.from(defaultPresetsMap.values()),
+    ].sort((a, b) => a.displayName.localeCompare(b.displayName));
+  }
+
+  // Fallback to hardcoded defaults
+  return FALLBACK_PRESETS;
+}
+
+// Find selected preset option
+export function findPresetOption(
+  slicerFilament: string,
+  filamentOptions: FilamentOption[],
+): FilamentOption | undefined {
+  if (!slicerFilament) return undefined;
+
+  // First try exact match on primary code
+  let option = filamentOptions.find(o => o.code === slicerFilament);
+  if (!option) {
+    // Try matching against any code in allCodes
+    option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
+  }
+  if (!option) {
+    // Try case-insensitive match
+    const slicerLower = slicerFilament.toLowerCase();
+    option = filamentOptions.find(o =>
+      o.code.toLowerCase() === slicerLower ||
+      o.allCodes.some(c => c.toLowerCase() === slicerLower),
+    );
+  }
+  return option;
+}
+
+// Recent colors management
+export function loadRecentColors(): ColorPreset[] {
+  try {
+    const stored = localStorage.getItem(RECENT_COLORS_KEY);
+    if (stored) {
+      return JSON.parse(stored) as ColorPreset[];
+    }
+  } catch {
+    // Ignore errors
+  }
+  return [];
+}
+
+export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
+  const filtered = currentRecent.filter(
+    c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
+  );
+  const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
+
+  try {
+    localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
+  } catch {
+    // Ignore errors
+  }
+
+  return updated;
+}
+
+// Check if a calibration matches based on brand, material, and variant
+export function isMatchingCalibration(
+  cal: { name?: string; filament_id?: string },
+  formData: { material: string; brand: string; subtype: string },
+): boolean {
+  if (!formData.material) return false;
+
+  const profileName = cal.name || '';
+
+  // Remove flow type prefixes
+  const cleanName = profileName
+    .replace(/^High Flow[_\s]+/i, '')
+    .replace(/^Standard[_\s]+/i, '')
+    .replace(/^HF[_\s]+/i, '')
+    .replace(/^S[_\s]+/i, '')
+    .trim();
+
+  const parsed = parsePresetName(cleanName);
+
+  // Match material (required)
+  const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
+  if (!materialMatch) return false;
+
+  // Match brand if specified in form
+  if (formData.brand) {
+    const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
+      formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
+    if (!brandMatch) return false;
+  }
+
+  // Match variant/subtype if specified in form
+  if (formData.subtype) {
+    const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
+      formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
+      cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
+    if (!variantMatch) return false;
+  }
+
+  return true;
+}

+ 24 - 0
frontend/src/hooks/useWebSocket.ts

@@ -226,6 +226,30 @@ export function useWebSocket() {
           }
         }));
         break;
+
+      case 'spool_auto_assigned':
+        // RFID tag matched - refresh inventory data
+        debouncedInvalidate('inventory-spools');
+        debouncedInvalidate('inventory-assignments');
+        break;
+
+      case 'spool_usage_logged':
+        // Filament consumption recorded - refresh spool data
+        debouncedInvalidate('inventory-spools');
+        break;
+
+      case 'unknown_tag':
+        // Unknown RFID tag detected - dispatch event for UI
+        window.dispatchEvent(new CustomEvent('unknown-tag', {
+          detail: {
+            printer_id: (message as unknown as { printer_id?: number }).printer_id,
+            ams_id: (message as unknown as { ams_id?: number }).ams_id,
+            tray_id: (message as unknown as { tray_id?: number }).tray_id,
+            tag_uid: (message as unknown as { tag_uid?: string }).tag_uid,
+            tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
+          }
+        }));
+        break;
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
 

+ 243 - 2
frontend/src/i18n/locales/de.ts

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profile',
     maintenance: 'Wartung',
     projects: 'Projekte',
+    inventory: 'Inventar',
     files: 'Dateimanager',
     settings: 'Einstellungen',
     system: 'System',
@@ -74,6 +75,8 @@ export default {
     dismiss: 'Schließen',
     apply: 'Anwenden',
     reset: 'Zurücksetzen',
+    export: 'Exportieren',
+    import: 'Importieren',
     clear: 'Leeren',
     selectAll: 'Alle auswählen',
     deselectAll: 'Auswahl aufheben',
@@ -1142,11 +1145,37 @@ export default {
       turnOn: 'Einschalten',
       turnOff: 'Ausschalten',
     },
-    // Spoolman
-    spoolmanEnabled: 'Spoolman-Integration aktivieren',
+    // Filament Tracking Mode
+    filamentTracking: 'Filament-Verfolgung',
+    filamentTrackingDesc: 'Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.',
+    trackingModeBuiltIn: 'Integriertes Inventar',
+    trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
+    trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',
+    builtInFeatureRfid: 'Erkennt automatisch Bambu Lab RFID-Spulen im AMS',
+    builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',
+    builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',
+    // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
+    spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',
     spoolmanConnected: 'Verbunden',
     spoolmanDisconnected: 'Nicht verbunden',
+    status: 'Status',
+    connect: 'Verbinden',
+    disconnect: 'Trennen',
+    howSyncWorks: 'So funktioniert die Synchronisierung',
+    syncInfoRfidOnly: 'Nur offizielle Bambu Lab Spulen mit RFID werden synchronisiert',
+    syncInfoAutoCreate: 'Neue Spulen werden bei der ersten Synchronisierung automatisch in Spoolman erstellt',
+    syncInfoThirdPartySkipped: 'Nicht-Bambu-Lab-Spulen (Drittanbieter, nachgefüllt) werden übersprungen',
+    linkingExistingSpools: 'Vorhandene Spulen verknüpfen',
+    linkingExistingSpoolsDesc: 'Um vorhandene Spoolman-Spulen mit Ihrem AMS zu verknüpfen, fahren Sie über einen AMS-Slot und klicken Sie auf "Mit Spoolman verknüpfen".',
+    syncMode: 'Synchronisierungsmodus',
+    syncModeAuto: 'Automatisch',
+    syncModeManual: 'Nur manuell',
+    syncModeAutoDesc: 'AMS-Daten werden automatisch synchronisiert, wenn Änderungen erkannt werden',
+    syncModeManualDesc: 'Nur bei manueller Auslösung synchronisieren',
+    syncAmsData: 'AMS-Daten synchronisieren',
+    syncAmsDataDesc: 'AMS-Daten des Druckers manuell mit Spoolman synchronisieren',
+    allPrinters: 'Alle Drucker',
     // Default printer
     noDefaultPrinter: 'Kein Standard (jedes Mal fragen)',
     // Sidebar
@@ -1376,6 +1405,77 @@ export default {
       cameraConnected: 'Kamera verbunden{{resolution}}',
     },
     testConnection: 'Verbindung testen',
+    catalog: {
+      spoolCatalog: 'Spulenkatalog',
+      spoolCatalogDescription: 'Leerspulengewichte nach Marke/Typ. Wird für die automatische Gewichtssuche beim Hinzufügen von Spulen verwendet.',
+      searchCatalog: 'Katalog durchsuchen...',
+      addNewEntry: 'Neuen Eintrag hinzufügen',
+      namePlaceholder: 'Name (z.B. Bambu Lab - Plastik)',
+      weight: 'Gewicht',
+      type: 'Typ',
+      default: 'Standard',
+      custom: 'Benutzerdefiniert',
+      noMatch: 'Keine Einträge entsprechen Ihrer Suche',
+      empty: 'Keine Einträge im Katalog',
+      deleteEntry: 'Eintrag löschen',
+      deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
+      resetCatalog: 'Katalog zurücksetzen',
+      resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Einträge werden entfernt.',
+      loadFailed: 'Spulenkatalog konnte nicht geladen werden',
+      nameWeightRequired: 'Name und Gewicht sind erforderlich',
+      entryAdded: 'Eintrag hinzugefügt',
+      addFailed: 'Eintrag konnte nicht hinzugefügt werden',
+      entryUpdated: 'Eintrag aktualisiert',
+      updateFailed: 'Eintrag konnte nicht aktualisiert werden',
+      entryDeleted: 'Eintrag gelöscht',
+      deleteFailed: 'Eintrag konnte nicht gelöscht werden',
+      resetSuccess: 'Katalog auf Standardwerte zurückgesetzt',
+      resetFailed: 'Katalog konnte nicht zurückgesetzt werden',
+      exported: '{{count}} Einträge exportiert',
+      imported: '{{added}} Einträge importiert ({{skipped}} übersprungen)',
+      importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
+      exportTooltip: 'Katalog als JSON exportieren',
+      importTooltip: 'Katalog aus JSON importieren',
+      resetTooltip: 'Auf Standardwerte zurücksetzen',
+    },
+    colorCatalog: {
+      title: 'Farbkatalog',
+      description: 'Filamentfarben nach Hersteller/Material. Wird für die automatische Farbsuche beim Hinzufügen von Spulen verwendet.',
+      searchColors: 'Farben durchsuchen...',
+      allManufacturers: 'Alle Hersteller',
+      addNewColor: 'Neue Farbe hinzufügen',
+      manufacturer: 'Hersteller',
+      colorName: 'Farbname',
+      hex: 'Hex',
+      materialOptional: 'Material (optional)',
+      showing: '{{filtered}} von {{total}} Farben angezeigt',
+      noMatch: 'Keine Farben entsprechen Ihrer Suche',
+      empty: 'Keine Farben im Katalog',
+      deleteColor: 'Farbe löschen',
+      deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
+      resetCatalog: 'Farbkatalog zurücksetzen',
+      resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Farben werden entfernt.',
+      sync: 'Sync',
+      starting: 'Starten...',
+      syncTooltip: 'Von FilamentColors.xyz synchronisieren (2000+ Farben)',
+      loadFailed: 'Farbkatalog konnte nicht geladen werden',
+      fieldsRequired: 'Hersteller, Farbname und Hex-Farbe sind erforderlich',
+      colorAdded: 'Farbe hinzugefügt',
+      addFailed: 'Farbe konnte nicht hinzugefügt werden',
+      colorUpdated: 'Farbe aktualisiert',
+      updateFailed: 'Farbe konnte nicht aktualisiert werden',
+      colorDeleted: 'Farbe gelöscht',
+      deleteFailed: 'Farbe konnte nicht gelöscht werden',
+      resetSuccess: 'Farbkatalog auf Standardwerte zurückgesetzt',
+      resetFailed: 'Katalog konnte nicht zurückgesetzt werden',
+      syncUpToDate: 'Bereits aktuell ({{count}} Farben geprüft)',
+      syncComplete: '{{added}} neue Farben hinzugefügt ({{skipped}} bereits vorhanden)',
+      syncError: 'Sync-Fehler',
+      syncFailed: 'Synchronisierung von FilamentColors.xyz fehlgeschlagen',
+      exported: '{{count}} Farben exportiert',
+      imported: '{{added}} Farben importiert ({{skipped}} übersprungen)',
+      importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
+    },
   },
 
   // Notifications (for push notifications)
@@ -2327,6 +2427,146 @@ export default {
     reportPartialUsageDesc: 'Wenn ein Druck fehlschlägt oder abgebrochen wird, den geschätzten Filamentverbrauch bis zu diesem Zeitpunkt basierend auf dem Schichtfortschritt melden.',
   },
 
+  // Inventar
+  inventory: {
+    title: 'Spulen-Inventar',
+    addSpool: 'Spule hinzufügen',
+    editSpool: 'Spule bearbeiten',
+    material: 'Material',
+    selectMaterial: 'Material auswählen...',
+    subtype: 'Untertyp',
+    brand: 'Marke',
+    searchBrand: 'Marke suchen...',
+    useCustomBrand: '"{{brand}}" verwenden',
+    colorName: 'Farbname',
+    colorNamePlaceholder: 'Jade White, Fire Red...',
+    color: 'Farbe',
+    hexColor: 'Hex-Farbe',
+    pickColor: 'Benutzerdefinierte Farbe wählen',
+    labelWeight: 'Nenngewicht',
+    coreWeight: 'Leergewicht der Spule',
+    searchSpoolWeight: 'Spulengewicht suchen...',
+    weightUsed: 'Verbraucht',
+    slicerFilament: 'Slicer-Filament-ID',
+    slicerFilamentName: 'Slicer-Preset-Name',
+    slicerPreset: 'Slicer-Preset',
+    searchPresets: 'Filament-Presets suchen...',
+    selectedPreset: 'Ausgewählt',
+    noPresetsFound: 'Keine Presets gefunden',
+    tempOverrides: 'Temperatur-Überschreibungen',
+    note: 'Notiz',
+    notePlaceholder: 'Zusätzliche Notizen zu dieser Spule...',
+    archive: 'Archivieren',
+    restore: 'Wiederherstellen',
+    noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',
+    kProfiles: 'K-Profile',
+    addKProfile: 'K-Profil hinzufügen',
+    assignSpool: 'Spule zuweisen',
+    unassignSpool: 'Zuweisung aufheben',
+    assignSuccess: 'Spule zugewiesen und AMS-Slot konfiguriert',
+    assignFailed: 'Spulenzuweisung fehlgeschlagen',
+    selectSpool: 'Wählen Sie eine Spule für diesen Slot',
+    assigned: 'Zugewiesen',
+    assigning: 'Wird zugewiesen...',
+    searchSpools: 'Spulen suchen...',
+    allMaterials: 'Alle Materialien',
+    filterByBrand: 'Nach Marke filtern...',
+    showArchived: 'Archivierte anzeigen',
+    spoolCreated: 'Spule erstellt',
+    spoolUpdated: 'Spule aktualisiert',
+    spoolDeleted: 'Spule gelöscht',
+    spoolArchived: 'Spule archiviert',
+    spoolRestored: 'Spule wiederhergestellt',
+    deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
+    advancedSettings: 'Erweiterte Einstellungen',
+    filamentInfoTab: 'Filament-Info',
+    paProfileTab: 'PA-Profil',
+    filamentInfo: 'Filament',
+    additional: 'Zusätzlich',
+    loadingPresets: 'Cloud-Presets werden geladen...',
+    cloudConnected: 'Cloud verbunden',
+    cloudNotConnected: 'Cloud nicht verbunden (Standardwerte)',
+    recentColors: 'Zuletzt',
+    searchColors: 'Farben suchen...',
+    searchResults: 'Suchergebnisse',
+    allColors: 'Alle Farben',
+    commonColors: 'Häufige Farben',
+    showLess: 'Weniger',
+    showAll: 'Alle',
+    noColorsFound: 'Keine Farben gefunden',
+    noResults: 'Keine Ergebnisse',
+    selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',
+    noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',
+    matchingFilter: 'Filter',
+    anyBrand: 'Jede Marke',
+    anyVariant: 'Jede Variante',
+    autoSelect: 'Auto-Auswahl',
+    matches: 'Treffer',
+    match: 'Treffer',
+    noMatches: 'Keine Treffer',
+    connected: 'Verbunden',
+    offline: 'Offline',
+    printerOffline: 'Drucker ist offline. Verbinden Sie ihn, um Kalibrierungsprofile anzuzeigen.',
+    noKProfilesMatch: 'Keine K-Profile stimmen mit dem gewählten Filament überein.',
+    leftNozzle: 'Linke Düse',
+    rightNozzle: 'Rechte Düse',
+    profilesSelected: 'Kalibrierungsprofil(e) ausgewählt',
+    // Stats & enhanced table
+    totalInventory: 'Gesamtbestand',
+    totalConsumed: 'Gesamtverbrauch',
+    byMaterial: 'Nach Material',
+    inPrinter: 'Im Drucker',
+    lowStock: 'Niedriger Bestand',
+    sinceTracking: 'Seit Beginn der Erfassung',
+    loadedInAms: 'Im AMS/Ext geladen',
+    remaining: 'Verbleibend',
+    lowStockThreshold: '<20% verbleibend',
+    search: 'Spulen suchen...',
+    showing: 'Zeige',
+    to: 'bis',
+    of: 'von',
+    show: 'Zeige',
+    spools: 'Spulen',
+    spool: 'Spule',
+    page: 'Seite',
+    noSpoolsMatch: 'Keine Ergebnisse',
+    noSpoolsMatchDesc: 'Versuchen Sie, Ihre Suche oder Filter anzupassen.',
+    active: 'Aktiv',
+    archived: 'Archiviert',
+    all: 'Alle',
+    used: 'Verwendet',
+    new: 'Neu',
+    clearFilters: 'Filter löschen',
+    table: 'Tabelle',
+    cards: 'Karten',
+    net: 'Netto',
+    // Column config
+    columns: 'Spalten',
+    configureColumns: 'Spalten konfigurieren',
+    configureColumnsDesc: 'Ziehen zum Neuordnen oder Pfeile verwenden. Sichtbarkeit mit dem Augensymbol umschalten.',
+    visible: 'sichtbar',
+    reset: 'Zurücksetzen',
+    cancel: 'Abbrechen',
+    applyChanges: 'Änderungen anwenden',
+    moveUp: 'Nach oben',
+    moveDown: 'Nach unten',
+    hideColumn: 'Spalte ausblenden',
+    showColumn: 'Spalte einblenden',
+    // Tag-Verknüpfung
+    linkToSpool: 'Mit Spule verknüpfen',
+    tagLinked: 'Tag mit Spule verknüpft',
+    tagLinkFailed: 'Tag-Verknüpfung fehlgeschlagen',
+    tagAlreadyLinked: 'Tag bereits mit anderer Spule verknüpft',
+    unknownTag: 'Unbekannter RFID-Tag erkannt',
+    // Verbrauchshistorie
+    usageHistory: 'Verbrauchshistorie',
+    noUsageHistory: 'Noch kein Verbrauch erfasst',
+    printName: 'Druckname',
+    weightConsumed: 'Verbrauchtes Gewicht',
+    clearHistory: 'Löschen',
+    historyCleared: 'Verbrauchshistorie gelöscht',
+  },
+
   // Timelapse
   timelapse: {
     title: 'Zeitraffer',
@@ -2944,6 +3184,7 @@ export default {
     noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.',
     noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',
     custom: 'Benutzerdefiniert',
+    builtin: 'Integriert',
     settingsSentToPrinter: 'Einstellungen an Drucker gesendet',
     filamentProfile: 'Filamentprofil',
   },

+ 247 - 2
frontend/src/i18n/locales/en.ts

@@ -8,6 +8,7 @@ export default {
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     projects: 'Projects',
+    inventory: 'Inventory',
     files: 'File Manager',
     settings: 'Settings',
     system: 'System',
@@ -74,6 +75,8 @@ export default {
     dismiss: 'Dismiss',
     apply: 'Apply',
     reset: 'Reset',
+    export: 'Export',
+    import: 'Import',
     clear: 'Clear',
     selectAll: 'Select All',
     deselectAll: 'Deselect All',
@@ -1142,11 +1145,37 @@ export default {
       turnOn: 'Turn On',
       turnOff: 'Turn Off',
     },
-    // Spoolman
-    spoolmanEnabled: 'Enable Spoolman Integration',
+    // Filament Tracking Mode
+    filamentTracking: 'Filament Tracking',
+    filamentTrackingDesc: 'Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.',
+    trackingModeBuiltIn: 'Built-in Inventory',
+    trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
+    trackingModeSpoolmanDesc: 'External filament management server',
+    builtInFeatureRfid: 'Automatically detects Bambu Lab RFID spools in AMS',
+    builtInFeatureUsage: 'Tracks filament consumption per print',
+    builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',
+    // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
+    spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',
     spoolmanConnected: 'Connected',
     spoolmanDisconnected: 'Disconnected',
+    status: 'Status',
+    connect: 'Connect',
+    disconnect: 'Disconnect',
+    howSyncWorks: 'How Sync Works',
+    syncInfoRfidOnly: 'Only official Bambu Lab spools with RFID are synced',
+    syncInfoAutoCreate: 'New spools are auto-created in Spoolman on first sync',
+    syncInfoThirdPartySkipped: 'Non-Bambu Lab spools (third-party, refilled) are skipped',
+    linkingExistingSpools: 'Linking Existing Spools',
+    linkingExistingSpoolsDesc: 'To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".',
+    syncMode: 'Sync Mode',
+    syncModeAuto: 'Automatic',
+    syncModeManual: 'Manual Only',
+    syncModeAutoDesc: 'AMS data syncs automatically when changes are detected',
+    syncModeManualDesc: 'Only sync when manually triggered',
+    syncAmsData: 'Sync AMS Data',
+    syncAmsDataDesc: 'Manually sync printer AMS data to Spoolman',
+    allPrinters: 'All Printers',
     // Default printer
     noDefaultPrinter: 'No default (ask each time)',
     // Sidebar
@@ -1376,6 +1405,77 @@ export default {
       cameraConnected: 'Camera connected{{resolution}}',
     },
     testConnection: 'Test Connection',
+    catalog: {
+      spoolCatalog: 'Spool Catalog',
+      spoolCatalogDescription: 'Empty spool weights by brand/type. Used for automatic weight lookup when adding spools.',
+      searchCatalog: 'Search catalog...',
+      addNewEntry: 'Add New Entry',
+      namePlaceholder: 'Name (e.g., Bambu Lab - Plastic)',
+      weight: 'Weight',
+      type: 'Type',
+      default: 'Default',
+      custom: 'Custom',
+      noMatch: 'No entries match your search',
+      empty: 'No entries in catalog',
+      deleteEntry: 'Delete Entry',
+      deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
+      resetCatalog: 'Reset Catalog',
+      resetConfirm: 'Reset catalog to defaults? This will remove all custom entries.',
+      loadFailed: 'Failed to load spool catalog',
+      nameWeightRequired: 'Name and weight are required',
+      entryAdded: 'Entry added',
+      addFailed: 'Failed to add entry',
+      entryUpdated: 'Entry updated',
+      updateFailed: 'Failed to update entry',
+      entryDeleted: 'Entry deleted',
+      deleteFailed: 'Failed to delete entry',
+      resetSuccess: 'Catalog reset to defaults',
+      resetFailed: 'Failed to reset catalog',
+      exported: 'Exported {{count}} entries',
+      imported: 'Imported {{added}} entries ({{skipped}} skipped)',
+      importFailed: 'Failed to import: invalid JSON format',
+      exportTooltip: 'Export catalog to JSON',
+      importTooltip: 'Import catalog from JSON',
+      resetTooltip: 'Reset to defaults',
+    },
+    colorCatalog: {
+      title: 'Color Catalog',
+      description: 'Filament colors by manufacturer/material. Used for automatic color lookup when adding spools.',
+      searchColors: 'Search colors...',
+      allManufacturers: 'All manufacturers',
+      addNewColor: 'Add New Color',
+      manufacturer: 'Manufacturer',
+      colorName: 'Color Name',
+      hex: 'Hex',
+      materialOptional: 'Material (optional)',
+      showing: 'Showing {{filtered}} of {{total}} colors',
+      noMatch: 'No colors match your search',
+      empty: 'No colors in catalog',
+      deleteColor: 'Delete Color',
+      deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
+      resetCatalog: 'Reset Color Catalog',
+      resetConfirm: 'Reset catalog to defaults? This will remove all custom colors.',
+      sync: 'Sync',
+      starting: 'Starting...',
+      syncTooltip: 'Sync from FilamentColors.xyz (2000+ colors, may take a minute)',
+      loadFailed: 'Failed to load color catalog',
+      fieldsRequired: 'Manufacturer, color name, and hex color are required',
+      colorAdded: 'Color added',
+      addFailed: 'Failed to add color',
+      colorUpdated: 'Color updated',
+      updateFailed: 'Failed to update color',
+      colorDeleted: 'Color deleted',
+      deleteFailed: 'Failed to delete color',
+      resetSuccess: 'Color catalog reset to defaults',
+      resetFailed: 'Failed to reset catalog',
+      syncUpToDate: 'Already up to date ({{count}} colors checked)',
+      syncComplete: 'Added {{added}} new colors ({{skipped}} already existed)',
+      syncError: 'Sync error',
+      syncFailed: 'Failed to sync from FilamentColors.xyz',
+      exported: 'Exported {{count}} colors',
+      imported: 'Imported {{added}} colors ({{skipped}} skipped)',
+      importFailed: 'Failed to import: invalid JSON format',
+    },
   },
 
   // Notifications (for push notifications)
@@ -2327,6 +2427,150 @@ export default {
     reportPartialUsageDesc: 'When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.',
   },
 
+  // Inventory
+  inventory: {
+    title: 'Spool Inventory',
+    addSpool: 'Add Spool',
+    editSpool: 'Edit Spool',
+    material: 'Material',
+    selectMaterial: 'Select material...',
+    subtype: 'Subtype',
+    brand: 'Brand',
+    searchBrand: 'Search brand...',
+    useCustomBrand: 'Use "{{brand}}"',
+    colorName: 'Color Name',
+    colorNamePlaceholder: 'Jade White, Fire Red...',
+    color: 'Color',
+    hexColor: 'Hex Color',
+    pickColor: 'Pick custom color',
+    labelWeight: 'Label Weight',
+    coreWeight: 'Empty Spool Weight',
+    searchSpoolWeight: 'Search spool weight...',
+    weightUsed: 'Used',
+    slicerFilament: 'Slicer Filament ID',
+    slicerFilamentName: 'Slicer Preset Name',
+    slicerPreset: 'Slicer Preset',
+    searchPresets: 'Search filament presets...',
+    selectedPreset: 'Selected',
+    noPresetsFound: 'No presets found',
+    tempOverrides: 'Temperature Overrides',
+    note: 'Note',
+    notePlaceholder: 'Any additional notes about this spool...',
+    archive: 'Archive',
+    restore: 'Restore',
+    noSpools: 'No spools yet. Add your first spool to get started.',
+    kProfiles: 'K-Profiles',
+    addKProfile: 'Add K-Profile',
+    assignSpool: 'Assign Spool',
+    unassignSpool: 'Unassign',
+    assignSuccess: 'Spool assigned and AMS slot configured',
+    assignFailed: 'Failed to assign spool',
+    selectSpool: 'Select a spool to assign to this slot',
+    assigned: 'Assigned',
+    assigning: 'Assigning...',
+    searchSpools: 'Search spools...',
+    allMaterials: 'All Materials',
+    filterByBrand: 'Filter by brand...',
+    showArchived: 'Show archived',
+    spoolCreated: 'Spool created',
+    spoolUpdated: 'Spool updated',
+    spoolDeleted: 'Spool deleted',
+    spoolArchived: 'Spool archived',
+    spoolRestored: 'Spool restored',
+    deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
+    advancedSettings: 'Advanced Settings',
+    // Tabs
+    filamentInfoTab: 'Filament Info',
+    paProfileTab: 'PA Profile',
+    filamentInfo: 'Filament',
+    additional: 'Additional',
+    // Cloud
+    loadingPresets: 'Loading cloud presets...',
+    cloudConnected: 'Cloud connected',
+    cloudNotConnected: 'Cloud not connected (using defaults)',
+    // Colors
+    recentColors: 'Recent',
+    searchColors: 'Search colors...',
+    searchResults: 'Search results',
+    allColors: 'All colors',
+    commonColors: 'Common colors',
+    showLess: 'Show less',
+    showAll: 'Show all',
+    noColorsFound: 'No colors match your search',
+    noResults: 'No matches found',
+    // PA Profiles
+    selectMaterialFirst: 'Please select a material first in the Filament Info tab.',
+    noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',
+    matchingFilter: 'Matching',
+    anyBrand: 'Any brand',
+    anyVariant: 'Any variant',
+    autoSelect: 'Auto-select',
+    matches: 'matches',
+    match: 'match',
+    noMatches: 'No matches',
+    connected: 'Connected',
+    offline: 'Offline',
+    printerOffline: 'Printer is offline. Connect to view calibration profiles.',
+    noKProfilesMatch: 'No K-profiles match the selected filament.',
+    leftNozzle: 'Left Nozzle',
+    rightNozzle: 'Right Nozzle',
+    profilesSelected: 'calibration profile(s) selected',
+    // Stats & enhanced table
+    totalInventory: 'Total Inventory',
+    totalConsumed: 'Total Consumed',
+    byMaterial: 'By Material',
+    inPrinter: 'In Printer',
+    lowStock: 'Low Stock',
+    sinceTracking: 'Since tracking started',
+    loadedInAms: 'Loaded in AMS/Ext',
+    remaining: 'Remaining',
+    lowStockThreshold: '<20% remaining',
+    search: 'Search spools...',
+    showing: 'Showing',
+    to: 'to',
+    of: 'of',
+    show: 'Show',
+    spools: 'spools',
+    spool: 'spool',
+    page: 'Page',
+    noSpoolsMatch: 'No results found',
+    noSpoolsMatchDesc: 'Try adjusting your search or filters to find what you\'re looking for.',
+    active: 'Active',
+    archived: 'Archived',
+    all: 'All',
+    used: 'Used',
+    new: 'New',
+    clearFilters: 'Clear filters',
+    table: 'Table',
+    cards: 'Cards',
+    net: 'Net',
+    // Column config
+    columns: 'Columns',
+    configureColumns: 'Configure Columns',
+    configureColumnsDesc: 'Drag to reorder columns or use arrows. Toggle visibility with the eye icon.',
+    visible: 'visible',
+    reset: 'Reset',
+    cancel: 'Cancel',
+    applyChanges: 'Apply Changes',
+    moveUp: 'Move up',
+    moveDown: 'Move down',
+    hideColumn: 'Hide column',
+    showColumn: 'Show column',
+    // Tag linking
+    linkToSpool: 'Link to Spool',
+    tagLinked: 'Tag linked to spool',
+    tagLinkFailed: 'Failed to link tag',
+    tagAlreadyLinked: 'Tag already linked to another spool',
+    unknownTag: 'Unknown RFID tag detected',
+    // Usage history
+    usageHistory: 'Usage History',
+    noUsageHistory: 'No usage recorded yet',
+    printName: 'Print Name',
+    weightConsumed: 'Weight Consumed',
+    clearHistory: 'Clear',
+    historyCleared: 'Usage history cleared',
+  },
+
   // Timelapse
   timelapse: {
     title: 'Timelapse',
@@ -2945,6 +3189,7 @@ export default {
     noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.',
     noMatchingPresets: 'No matching presets found.',
     custom: 'Custom',
+    builtin: 'Built-in',
     settingsSentToPrinter: 'Settings sent to printer',
     filamentProfile: 'Filament Profile',
   },

+ 239 - 3
frontend/src/i18n/locales/ja.ts

@@ -7,6 +7,7 @@ export default {
     profiles: 'プロファイル',
     maintenance: 'メンテナンス',
     projects: 'プロジェクト',
+    inventory: 'インベントリ',
     files: 'ファイル管理',
     settings: '設定',
     system: 'システム',
@@ -91,6 +92,8 @@ export default {
     enable: '有効化',
     linkNotFound: 'リンクが見つかりません',
     reset: 'リセット',
+    export: 'エクスポート',
+    import: 'インポート',
     selectAll: 'すべて選択',
     deselectAll: 'すべて選択解除',
     unchanged: '変更なし',
@@ -113,8 +116,6 @@ export default {
     show: '表示',
     hide: '非表示',
     back: '戻る',
-    export: 'エクスポート',
-    import: 'インポート',
     retry: 'リトライ',
     model: 'モデル',
     ok: 'OK',
@@ -1384,10 +1385,108 @@ export default {
     leaveEmptyForAnonymous: '匿名の場合は空のまま',
     leaveEmptyForNoAuth: '認証なしの場合は空のまま',
     enterDescriptionOptional: '説明を入力(任意)',
-    spoolmanEnabled: 'Spoolman連携を有効化',
+    // フィラメント追跡モード
+    filamentTracking: 'フィラメント追跡',
+    filamentTrackingDesc: 'フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。',
+    trackingModeBuiltIn: '内蔵インベントリ',
+    trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
+    trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',
+    builtInFeatureRfid: 'AMS内のBambu Lab RFIDスプールを自動検出',
+    builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',
+    builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',
+    // Spoolman設定
     spoolmanUrl: 'Spoolman URL',
+    spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',
     spoolmanConnected: '接続中',
     spoolmanDisconnected: '未接続',
+    status: 'ステータス',
+    connect: '接続',
+    disconnect: '切断',
+    howSyncWorks: '同期の仕組み',
+    syncInfoRfidOnly: 'RFIDを搭載した公式Bambu Labスプールのみ同期されます',
+    syncInfoAutoCreate: '新しいスプールは初回同期時にSpoolmanに自動作成されます',
+    syncInfoThirdPartySkipped: 'Bambu Lab以外のスプール(サードパーティ、リフィル)はスキップされます',
+    linkingExistingSpools: '既存スプールのリンク',
+    linkingExistingSpoolsDesc: '既存のSpoolmanスプールをAMSにリンクするには、AMSスロットにカーソルを合わせて「Spoolmanにリンク」をクリックしてください。',
+    syncMode: '同期モード',
+    syncModeAuto: '自動',
+    syncModeManual: '手動のみ',
+    syncModeAutoDesc: '変更が検出されるとAMSデータが自動的に同期されます',
+    syncModeManualDesc: '手動でトリガーした場合のみ同期',
+    syncAmsData: 'AMSデータを同期',
+    syncAmsDataDesc: 'プリンターのAMSデータをSpoolmanに手動同期',
+    allPrinters: '全プリンター',
+    catalog: {
+      spoolCatalog: 'スプールカタログ',
+      spoolCatalogDescription: 'ブランド/タイプ別の空スプール重量。スプール追加時の自動重量検索に使用されます。',
+      searchCatalog: 'カタログを検索...',
+      addNewEntry: '新しいエントリを追加',
+      namePlaceholder: '名前(例:Bambu Lab - プラスチック)',
+      weight: '重量',
+      type: 'タイプ',
+      default: 'デフォルト',
+      custom: 'カスタム',
+      noMatch: '検索に一致するエントリがありません',
+      empty: 'カタログにエントリがありません',
+      deleteEntry: 'エントリを削除',
+      deleteConfirm: '「{{name}}」を削除してもよろしいですか?',
+      resetCatalog: 'カタログをリセット',
+      resetConfirm: 'カタログをデフォルトにリセットしますか?カスタムエントリはすべて削除されます。',
+      loadFailed: 'スプールカタログの読み込みに失敗しました',
+      nameWeightRequired: '名前と重量は必須です',
+      entryAdded: 'エントリを追加しました',
+      addFailed: 'エントリの追加に失敗しました',
+      entryUpdated: 'エントリを更新しました',
+      updateFailed: 'エントリの更新に失敗しました',
+      entryDeleted: 'エントリを削除しました',
+      deleteFailed: 'エントリの削除に失敗しました',
+      resetSuccess: 'カタログをデフォルトにリセットしました',
+      resetFailed: 'カタログのリセットに失敗しました',
+      exported: '{{count}}件のエントリをエクスポートしました',
+      imported: '{{added}}件のエントリをインポートしました({{skipped}}件スキップ)',
+      importFailed: 'インポートに失敗しました:無効なJSON形式',
+      exportTooltip: 'カタログをJSONにエクスポート',
+      importTooltip: 'JSONからカタログをインポート',
+      resetTooltip: 'デフォルトにリセット',
+    },
+    colorCatalog: {
+      title: 'カラーカタログ',
+      description: 'メーカー/素材別のフィラメントカラー。スプール追加時の自動カラー検索に使用されます。',
+      searchColors: 'カラーを検索...',
+      allManufacturers: 'すべてのメーカー',
+      addNewColor: '新しいカラーを追加',
+      manufacturer: 'メーカー',
+      colorName: 'カラー名',
+      hex: 'Hex',
+      materialOptional: '素材(任意)',
+      showing: '{{total}}件中{{filtered}}件を表示',
+      noMatch: '検索に一致するカラーがありません',
+      empty: 'カタログにカラーがありません',
+      deleteColor: 'カラーを削除',
+      deleteConfirm: '「{{name}}」を削除してもよろしいですか?',
+      resetCatalog: 'カラーカタログをリセット',
+      resetConfirm: 'カタログをデフォルトにリセットしますか?カスタムカラーはすべて削除されます。',
+      sync: '同期',
+      starting: '開始中...',
+      syncTooltip: 'FilamentColors.xyzから同期(2000+カラー)',
+      loadFailed: 'カラーカタログの読み込みに失敗しました',
+      fieldsRequired: 'メーカー、カラー名、Hexカラーは必須です',
+      colorAdded: 'カラーを追加しました',
+      addFailed: 'カラーの追加に失敗しました',
+      colorUpdated: 'カラーを更新しました',
+      updateFailed: 'カラーの更新に失敗しました',
+      colorDeleted: 'カラーを削除しました',
+      deleteFailed: 'カラーの削除に失敗しました',
+      resetSuccess: 'カラーカタログをデフォルトにリセットしました',
+      resetFailed: 'カタログのリセットに失敗しました',
+      syncUpToDate: '最新の状態です({{count}}件のカラーを確認)',
+      syncComplete: '{{added}}件の新しいカラーを追加しました({{skipped}}件は既に存在)',
+      syncError: '同期エラー',
+      syncFailed: 'FilamentColors.xyzからの同期に失敗しました',
+      exported: '{{count}}件のカラーをエクスポートしました',
+      imported: '{{added}}件のカラーをインポートしました({{skipped}}件スキップ)',
+      importFailed: 'インポートに失敗しました:無効なJSON形式',
+    },
   },
   notification: {
     printStarted: {
@@ -2260,6 +2359,142 @@ export default {
     linkFailed: 'スプールのリンクに失敗しました',
     spoolId: 'スプールID',
   },
+  inventory: {
+    title: 'スプール在庫管理',
+    addSpool: 'スプールを追加',
+    editSpool: 'スプールを編集',
+    material: '素材',
+    selectMaterial: '素材を選択...',
+    subtype: 'サブタイプ',
+    brand: 'ブランド',
+    searchBrand: 'ブランドを検索...',
+    useCustomBrand: '「{{brand}}」を使用',
+    colorName: '色名',
+    colorNamePlaceholder: 'Jade White, Fire Red...',
+    color: '色',
+    hexColor: 'HEXカラー',
+    pickColor: 'カスタムカラーを選択',
+    labelWeight: '表示重量',
+    coreWeight: '空スプール重量',
+    searchSpoolWeight: 'スプール重量を検索...',
+    weightUsed: '使用量',
+    slicerFilament: 'スライサーフィラメントID',
+    slicerFilamentName: 'スライサープリセット名',
+    slicerPreset: 'スライサープリセット',
+    searchPresets: 'フィラメントプリセットを検索...',
+    selectedPreset: '選択済み',
+    noPresetsFound: 'プリセットが見つかりません',
+    tempOverrides: '温度オーバーライド',
+    note: 'メモ',
+    notePlaceholder: 'このスプールに関する追加メモ...',
+    archive: 'アーカイブ',
+    restore: '復元',
+    noSpools: 'スプールがありません。最初のスプールを追加してください。',
+    kProfiles: 'Kプロファイル',
+    addKProfile: 'Kプロファイルを追加',
+    assignSpool: 'スプールを割り当て',
+    unassignSpool: '割り当て解除',
+    assignSuccess: 'スプールを割り当て、AMSスロットを設定しました',
+    assignFailed: 'スプールの割り当てに失敗しました',
+    selectSpool: 'このスロットに割り当てるスプールを選択',
+    assigned: '割り当て済み',
+    assigning: '割り当て中...',
+    searchSpools: 'スプールを検索...',
+    allMaterials: 'すべての素材',
+    filterByBrand: 'ブランドで絞り込み...',
+    showArchived: 'アーカイブ済みを表示',
+    spoolCreated: 'スプールを作成しました',
+    spoolUpdated: 'スプールを更新しました',
+    spoolDeleted: 'スプールを削除しました',
+    spoolArchived: 'スプールをアーカイブしました',
+    spoolRestored: 'スプールを復元しました',
+    deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
+    advancedSettings: '詳細設定',
+    filamentInfoTab: 'フィラメント情報',
+    paProfileTab: 'PAプロファイル',
+    filamentInfo: 'フィラメント',
+    additional: '追加情報',
+    loadingPresets: 'クラウドプリセットを読み込み中...',
+    cloudConnected: 'クラウド接続済み',
+    cloudNotConnected: 'クラウド未接続(デフォルト使用)',
+    recentColors: '最近',
+    searchColors: '色を検索...',
+    searchResults: '検索結果',
+    allColors: 'すべての色',
+    commonColors: '一般的な色',
+    showLess: '少なく表示',
+    showAll: 'すべて表示',
+    noColorsFound: '一致する色がありません',
+    noResults: '結果なし',
+    selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
+    noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',
+    matchingFilter: 'フィルター',
+    anyBrand: 'すべてのブランド',
+    anyVariant: 'すべてのバリアント',
+    autoSelect: '自動選択',
+    matches: '件一致',
+    match: '件一致',
+    noMatches: '一致なし',
+    connected: '接続済み',
+    offline: 'オフライン',
+    printerOffline: 'プリンターがオフラインです。接続してキャリブレーションプロファイルを表示してください。',
+    noKProfilesMatch: '選択したフィラメントに一致するKプロファイルがありません。',
+    leftNozzle: '左ノズル',
+    rightNozzle: '右ノズル',
+    profilesSelected: 'キャリブレーションプロファイル選択済み',
+    totalInventory: '在庫合計',
+    totalConsumed: '総消費量',
+    byMaterial: '素材別',
+    inPrinter: 'プリンター内',
+    lowStock: '残量少',
+    sinceTracking: '追跡開始以降',
+    loadedInAms: 'AMS/Extに装填中',
+    remaining: '残り',
+    lowStockThreshold: '残り20%未満',
+    search: 'スプールを検索...',
+    showing: '表示',
+    to: '〜',
+    of: '/',
+    show: '表示',
+    spools: 'スプール',
+    spool: 'スプール',
+    page: 'ページ',
+    noSpoolsMatch: '結果なし',
+    noSpoolsMatchDesc: '検索やフィルターを調整してみてください。',
+    active: 'アクティブ',
+    archived: 'アーカイブ済み',
+    all: 'すべて',
+    used: '使用済み',
+    new: '新規',
+    clearFilters: 'フィルターをクリア',
+    table: 'テーブル',
+    cards: 'カード',
+    net: '正味',
+    columns: '列',
+    configureColumns: '列の設定',
+    configureColumnsDesc: 'ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。',
+    visible: '表示中',
+    reset: 'リセット',
+    cancel: 'キャンセル',
+    applyChanges: '変更を適用',
+    moveUp: '上へ移動',
+    moveDown: '下へ移動',
+    hideColumn: '列を非表示',
+    showColumn: '列を表示',
+    // タグリンク
+    linkToSpool: 'スプールにリンク',
+    tagLinked: 'タグがスプールにリンクされました',
+    tagLinkFailed: 'タグのリンクに失敗しました',
+    tagAlreadyLinked: 'タグは既に別のスプールにリンクされています',
+    unknownTag: '不明なRFIDタグが検出されました',
+    // 使用履歴
+    usageHistory: '使用履歴',
+    noUsageHistory: 'まだ使用記録がありません',
+    printName: 'プリント名',
+    weightConsumed: '消費重量',
+    clearHistory: 'クリア',
+    historyCleared: '使用履歴がクリアされました',
+  },
   timelapse: {
     download: 'ダウンロード',
     preview: 'プレビュー',
@@ -2666,6 +2901,7 @@ export default {
     noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',
     noMatchingPresets: '一致するプリセットが見つかりません。',
     custom: 'カスタム',
+    builtin: '内蔵',
     settingsSentToPrinter: '設定をプリンターに送信しました',
     filamentProfile: 'フィラメントプロファイル',
   },

+ 1055 - 0
frontend/src/pages/InventoryPage.tsx

@@ -0,0 +1,1055 @@
+import { useState, useMemo, type ReactNode } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
+  Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
+  TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { InventorySpool, SpoolAssignment } from '../api/client';
+import { Button } from '../components/Button';
+import { SpoolFormModal } from '../components/SpoolFormModal';
+import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
+import { useToast } from '../contexts/ToastContext';
+
+type ArchiveFilter = 'active' | 'archived';
+type UsageFilter = 'all' | 'used' | 'new';
+type ViewMode = 'table' | 'cards';
+
+// Column definitions for the inventory table
+const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
+
+const DEFAULT_COLUMNS: ColumnConfig[] = [
+  { id: 'id', label: '#', visible: true },
+  { id: 'added_time', label: 'Added', visible: true },
+  { id: 'encode_time', label: 'Encoded', visible: false },
+  { id: 'last_used_time', label: 'Last Used', visible: false },
+  { id: 'rgba', label: 'Color', visible: true },
+  { id: 'material', label: 'Material', visible: true },
+  { id: 'subtype', label: 'Subtype', visible: true },
+  { id: 'color_name', label: 'Color Name', visible: false },
+  { id: 'brand', label: 'Brand', visible: true },
+  { id: 'slicer_filament', label: 'Slicer Filament', visible: false },
+  { id: 'location', label: 'Location', visible: true },
+  { id: 'label_weight', label: 'Label', visible: true },
+  { id: 'net', label: 'Net', visible: true },
+  { id: 'gross', label: 'Gross', visible: false },
+  { id: 'added_full', label: 'Full', visible: false },
+  { id: 'used', label: 'Used', visible: false },
+  { id: 'printed_total', label: 'Printed Total', visible: false },
+  { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
+  { id: 'note', label: 'Note', visible: false },
+  { id: 'pa_k', label: 'PA(K)', visible: true },
+  { id: 'tag_id', label: 'Tag ID', visible: false },
+  { id: 'data_origin', label: 'Data Origin', visible: false },
+  { id: 'tag_type', label: 'Linked Tag Type', visible: false },
+  { id: 'remaining', label: 'Remaining', visible: true },
+];
+
+function loadColumnConfig(): ColumnConfig[] {
+  try {
+    const stored = localStorage.getItem(COLUMN_CONFIG_KEY);
+    if (stored) {
+      const parsed = JSON.parse(stored) as ColumnConfig[];
+      const defaultIds = new Set(DEFAULT_COLUMNS.map((c) => c.id));
+      const storedIds = new Set(parsed.map((c) => c.id));
+      // Keep stored columns that still exist in defaults
+      const validStored = parsed.filter((c) => defaultIds.has(c.id));
+      // Add any new default columns not in stored config
+      const newColumns = DEFAULT_COLUMNS.filter((c) => !storedIds.has(c.id));
+      return [...validStored, ...newColumns];
+    }
+  } catch {
+    // Ignore errors
+  }
+  return DEFAULT_COLUMNS.map((c) => ({ ...c }));
+}
+
+function saveColumnConfig(config: ColumnConfig[]) {
+  try {
+    localStorage.setItem(COLUMN_CONFIG_KEY, JSON.stringify(config));
+  } catch {
+    // Ignore errors
+  }
+}
+
+function formatWeight(g: number, useKg = false): string {
+  if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
+  return `${Math.round(g)}g`;
+}
+
+// Material color mapping for pills
+const MATERIAL_COLORS: Record<string, string> = {
+  PLA: 'bg-green-500/20 text-green-400',
+  ABS: 'bg-red-500/20 text-red-400',
+  PETG: 'bg-blue-500/20 text-blue-400',
+  TPU: 'bg-purple-500/20 text-purple-400',
+  ASA: 'bg-orange-500/20 text-orange-400',
+  PA: 'bg-yellow-500/20 text-yellow-400',
+  PC: 'bg-cyan-500/20 text-cyan-400',
+  PET: 'bg-sky-500/20 text-sky-400',
+};
+
+type TFn = (key: string) => string;
+
+function formatDate(dateStr: string | null): string {
+  if (!dateStr) return '-';
+  const date = new Date(dateStr);
+  return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' });
+}
+
+type CellCtx = {
+  spool: InventorySpool;
+  remaining: number;
+  pct: number;
+  assignmentMap: Record<number, SpoolAssignment>;
+};
+
+// Column header labels (25 columns — matching SpoolBuddy exactly)
+const columnHeaders: Record<string, (t: TFn) => string> = {
+  id: () => '#',
+  added_time: () => 'Added',
+  encode_time: () => 'Encoded',
+  last_used_time: () => 'Last Used',
+  rgba: (t) => t('inventory.color'),
+  material: (t) => t('inventory.material'),
+  subtype: (t) => t('inventory.subtype'),
+  color_name: (t) => t('inventory.colorName'),
+  brand: (t) => t('inventory.brand'),
+  slicer_filament: (t) => t('inventory.slicerFilament'),
+  location: () => 'Location',
+  label_weight: (t) => t('inventory.labelWeight'),
+  net: (t) => t('inventory.net'),
+  gross: () => 'Gross',
+  added_full: () => 'Full',
+  used: (t) => t('inventory.weightUsed'),
+  printed_total: () => 'Printed Total',
+  printed_since_weight: () => 'Printed Since Weight',
+  note: (t) => t('inventory.note'),
+  pa_k: () => 'PA(K)',
+  tag_id: () => 'Tag ID',
+  data_origin: () => 'Data Origin',
+  tag_type: () => 'Linked Tag Type',
+  remaining: (t) => t('inventory.remaining'),
+};
+
+// Column cell renderers (25 columns — matching SpoolBuddy exactly)
+const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
+  id: ({ spool }) => (
+    <span className="text-sm font-medium text-white">{spool.id}</span>
+  ),
+  added_time: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{formatDate(spool.created_at)}</span>
+  ),
+  encode_time: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{formatDate(spool.encode_time)}</span>
+  ),
+  last_used_time: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.last_used ? formatDate(spool.last_used) : 'Never'}</span>
+  ),
+  rgba: ({ spool }) => (
+    <div className="flex items-center gap-2">
+      <span
+        className="w-5 h-5 rounded-full border border-white/20 flex-shrink-0"
+        style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
+      />
+      <span className="text-sm text-white">{spool.color_name || '-'}</span>
+    </div>
+  ),
+  material: ({ spool }) => (
+    <span className="text-sm text-white">{spool.material}</span>
+  ),
+  subtype: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.subtype || '-'}</span>
+  ),
+  color_name: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.color_name || '-'}</span>
+  ),
+  brand: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.brand || '-'}</span>
+  ),
+  slicer_filament: ({ spool }) => (
+    <span className="text-sm text-bambu-gray" title={spool.slicer_filament || undefined}>
+      {spool.slicer_filament_name || spool.slicer_filament || '-'}
+    </span>
+  ),
+  location: ({ spool, assignmentMap }) => {
+    const assignment = assignmentMap[spool.id];
+    if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
+    return (
+      <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
+        AMS {assignment.ams_id} T{assignment.tray_id}
+      </span>
+    );
+  },
+  label_weight: ({ spool }) => (
+    <span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
+  ),
+  net: ({ remaining }) => (
+    <span className="text-sm text-white">{formatWeight(remaining)}</span>
+  ),
+  gross: ({ spool, remaining }) => (
+    <span className="text-sm text-bambu-gray">{formatWeight(remaining + spool.core_weight)}</span>
+  ),
+  added_full: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.added_full == null ? '-' : spool.added_full ? 'Yes' : 'No'}</span>
+  ),
+  used: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
+  ),
+  printed_total: () => (
+    <span className="text-sm text-bambu-gray/50">-</span>
+  ),
+  printed_since_weight: () => (
+    <span className="text-sm text-bambu-gray/50">-</span>
+  ),
+  note: ({ spool }) => (
+    <span className="text-sm text-bambu-gray max-w-[150px] truncate block" title={spool.note || undefined}>{spool.note || '-'}</span>
+  ),
+  pa_k: ({ spool }) => {
+    const count = spool.k_profiles?.length ?? 0;
+    if (count === 0) return <span className="text-sm text-bambu-gray">-</span>;
+    return (
+      <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green">
+        K
+      </span>
+    );
+  },
+  tag_id: ({ spool }) => {
+    const tag = spool.tag_uid || spool.tray_uuid;
+    if (!tag) return <span className="text-sm text-bambu-gray/50">-</span>;
+    return (
+      <span className="text-sm text-bambu-gray font-mono" title={tag}>
+        {tag.length > 12 ? `${tag.slice(0, 6)}...${tag.slice(-4)}` : tag}
+      </span>
+    );
+  },
+  data_origin: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.data_origin || '-'}</span>
+  ),
+  tag_type: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.tag_type || '-'}</span>
+  ),
+  remaining: ({ remaining, pct }) => (
+    <div className="flex items-center gap-2">
+      <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+        <div
+          className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
+          style={{ width: `${Math.min(pct, 100)}%` }}
+        />
+      </div>
+      <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
+    </div>
+  ),
+};
+
+export default function InventoryPage() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
+
+  // Filter state
+  const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
+  const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');
+  const [materialFilter, setMaterialFilter] = useState('');
+  const [brandFilter, setBrandFilter] = useState('');
+  const [search, setSearch] = useState('');
+  const [viewMode, setViewMode] = useState<ViewMode>('table');
+  const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
+  const [showColumnModal, setShowColumnModal] = useState(false);
+
+  // Pagination state
+  const [pageIndex, setPageIndex] = useState(0);
+  const [pageSize, setPageSize] = useState(15);
+
+  const { data: spools, isLoading } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
+  });
+
+  const { data: assignments } = useQuery({
+    queryKey: ['spool-assignments'],
+    queryFn: () => api.getAssignments(),
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteSpool(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      showToast(t('inventory.spoolDeleted'), 'success');
+    },
+  });
+
+  const archiveMutation = useMutation({
+    mutationFn: (id: number) => api.archiveSpool(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      showToast(t('inventory.spoolArchived'), 'success');
+    },
+  });
+
+  const restoreMutation = useMutation({
+    mutationFn: (id: number) => api.restoreSpool(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      showToast(t('inventory.spoolRestored'), 'success');
+    },
+  });
+
+  // Stats calculation (active spools only)
+  const stats = useMemo(() => {
+    if (!spools) return null;
+    let totalWeight = 0;
+    let totalConsumed = 0;
+    let lowStock = 0;
+    let activeCount = 0;
+    const byMaterial: Record<string, { count: number; weight: number }> = {};
+    for (const s of spools) {
+      if (s.archived_at) continue;
+      activeCount++;
+      const remaining = Math.max(0, s.label_weight - s.weight_used);
+      totalWeight += remaining;
+      totalConsumed += s.weight_used;
+      const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
+      if (pct < 20) lowStock++;
+      const mat = s.material || 'Unknown';
+      if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
+      byMaterial[mat].count++;
+      byMaterial[mat].weight += remaining;
+    }
+    return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount };
+  }, [spools]);
+
+  const inPrinterCount = assignments?.length ?? 0;
+
+  // Map spool_id -> assignment for location column
+  const assignmentMap = useMemo(() => {
+    const map: Record<number, SpoolAssignment> = {};
+    for (const a of assignments || []) {
+      map[a.spool_id] = a;
+    }
+    return map;
+  }, [assignments]);
+
+  // Top materials by weight for stat card pills
+  const topMaterials = useMemo(() => {
+    if (!stats) return [];
+    return Object.entries(stats.byMaterial)
+      .sort((a, b) => b[1].weight - a[1].weight)
+      .slice(0, 4);
+  }, [stats]);
+
+  // Filtering pipeline
+  const filteredSpools = useMemo(() => {
+    let filtered = spools || [];
+
+    // Archive filter
+    if (archiveFilter === 'active') {
+      filtered = filtered.filter((s) => !s.archived_at);
+    } else {
+      filtered = filtered.filter((s) => !!s.archived_at);
+    }
+
+    // Usage filter
+    if (usageFilter === 'used') {
+      filtered = filtered.filter((s) => s.weight_used > 0);
+    } else if (usageFilter === 'new') {
+      filtered = filtered.filter((s) => s.weight_used === 0);
+    }
+
+    // Material dropdown
+    if (materialFilter) {
+      filtered = filtered.filter((s) => s.material === materialFilter);
+    }
+
+    // Brand dropdown
+    if (brandFilter) {
+      filtered = filtered.filter((s) => s.brand === brandFilter);
+    }
+
+    // Global search
+    if (search) {
+      const q = search.toLowerCase();
+      filtered = filtered.filter((s) =>
+        s.brand?.toLowerCase().includes(q) ||
+        s.material.toLowerCase().includes(q) ||
+        s.color_name?.toLowerCase().includes(q) ||
+        s.subtype?.toLowerCase().includes(q) ||
+        s.note?.toLowerCase().includes(q) ||
+        s.slicer_filament_name?.toLowerCase().includes(q)
+      );
+    }
+
+    return filtered;
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, search]);
+
+  // Pagination
+  const totalPages = Math.max(1, Math.ceil(filteredSpools.length / pageSize));
+  const safePageIndex = Math.min(pageIndex, totalPages - 1);
+  const pagedSpools = filteredSpools.slice(safePageIndex * pageSize, (safePageIndex + 1) * pageSize);
+
+  // Reset page on filter changes
+  const resetPage = () => setPageIndex(0);
+
+  // Unique values for filter dropdowns
+  const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();
+  const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
+
+  // Check if any filters are non-default
+  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!search;
+
+  const handleColumnConfigSave = (config: ColumnConfig[]) => {
+    setColumnConfig(config);
+    saveColumnConfig(config);
+  };
+
+  // Visible column IDs in order
+  const visibleColumns = useMemo(
+    () => columnConfig.filter((c) => c.visible).map((c) => c.id),
+    [columnConfig]
+  );
+
+  const clearAllFilters = () => {
+    setArchiveFilter('active');
+    setUsageFilter('all');
+    setMaterialFilter('');
+    setBrandFilter('');
+    setSearch('');
+    resetPage();
+  };
+
+  return (
+    <div className="p-4 md:p-6 space-y-6">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div>
+          <div className="flex items-center gap-3">
+            <Package className="w-6 h-6 text-bambu-green" />
+            <h1 className="text-2xl font-bold text-white">{t('inventory.title')}</h1>
+          </div>
+          <p className="text-sm text-bambu-gray mt-1 ml-9">{t('inventory.noSpools').split('.')[0] ? '' : ''}</p>
+        </div>
+        <Button onClick={() => setFormModal({ spool: null })}>
+          <Plus className="w-4 h-4" />
+          {t('inventory.addSpool')}
+        </Button>
+      </div>
+
+      {/* Stats Bar */}
+      {stats && !isLoading && (
+        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
+          {/* Total Inventory */}
+          <div className="bg-bambu-dark-secondary rounded-lg p-4">
+            <div className="flex items-center gap-2 mb-1">
+              <Package className="w-4 h-4 text-bambu-green" />
+              <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalInventory')}</span>
+            </div>
+            <div className="text-xl font-bold text-white">{formatWeight(stats.totalWeight, true)}</div>
+            <div className="text-xs text-bambu-gray mt-1">{stats.totalSpools} {stats.totalSpools !== 1 ? t('inventory.spools') : t('inventory.spool')}</div>
+          </div>
+
+          {/* Total Consumed */}
+          <div className="bg-bambu-dark-secondary rounded-lg p-4">
+            <div className="flex items-center gap-2 mb-1">
+              <TrendingDown className="w-4 h-4 text-blue-400" />
+              <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.totalConsumed')}</span>
+            </div>
+            <div className="text-xl font-bold text-white">{formatWeight(stats.totalConsumed, true)}</div>
+            <div className="text-xs text-bambu-gray mt-1">{t('inventory.sinceTracking')}</div>
+          </div>
+
+          {/* By Material */}
+          <div className="bg-bambu-dark-secondary rounded-lg p-4">
+            <div className="flex items-center gap-2 mb-1">
+              <Layers className="w-4 h-4 text-green-400" />
+              <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.byMaterial')}</span>
+            </div>
+            <div className="flex flex-wrap gap-1.5 mt-1">
+              {topMaterials.map(([mat, data]) => (
+                <span
+                  key={mat}
+                  className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${MATERIAL_COLORS[mat] || 'bg-bambu-dark-tertiary text-bambu-gray'}`}
+                >
+                  {mat} <span className="opacity-70">{formatWeight(data.weight, true)}</span>
+                </span>
+              ))}
+            </div>
+          </div>
+
+          {/* In Printer */}
+          <div className="bg-bambu-dark-secondary rounded-lg p-4">
+            <div className="flex items-center gap-2 mb-1">
+              <Printer className="w-4 h-4 text-purple-400" />
+              <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.inPrinter')}</span>
+            </div>
+            <div className="text-xl font-bold text-white">{inPrinterCount}</div>
+            <div className="text-xs text-bambu-gray mt-1">{t('inventory.loadedInAms')}</div>
+          </div>
+
+          {/* Low Stock */}
+          <div className="bg-bambu-dark-secondary rounded-lg p-4">
+            <div className="flex items-center gap-2 mb-1">
+              <AlertTriangle className="w-4 h-4 text-yellow-400" />
+              <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.lowStock')}</span>
+            </div>
+            <div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>
+            <div className="text-xs text-bambu-gray mt-1">{t('inventory.lowStockThreshold')}</div>
+          </div>
+        </div>
+      )}
+
+      {/* Toolbar: Search + View toggle */}
+      <div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
+        <div className="relative flex-1 max-w-md">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50" />
+          <input
+            type="text"
+            value={search}
+            onChange={(e) => { setSearch(e.target.value); resetPage(); }}
+            placeholder={t('inventory.search')}
+            className="w-full pl-10 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+          />
+          {search && (
+            <button
+              onClick={() => { setSearch(''); resetPage(); }}
+              className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          )}
+        </div>
+
+        <div className="flex items-center gap-2">
+          {/* Columns button */}
+          <button
+            onClick={() => setShowColumnModal(true)}
+            className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+            title={t('inventory.configureColumns')}
+          >
+            <Columns className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('inventory.columns')}</span>
+          </button>
+          {/* Table / Cards toggle */}
+          <div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+            <button
+              onClick={() => setViewMode('table')}
+              className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${
+                viewMode === 'table'
+                  ? 'bg-bambu-green text-white'
+                  : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+              }`}
+            >
+              <TableProperties className="w-4 h-4" />
+              <span className="hidden sm:inline">{t('inventory.table')}</span>
+            </button>
+            <button
+              onClick={() => setViewMode('cards')}
+              className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${
+                viewMode === 'cards'
+                  ? 'bg-bambu-green text-white'
+                  : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+              }`}
+            >
+              <LayoutGrid className="w-4 h-4" />
+              <span className="hidden sm:inline">{t('inventory.cards')}</span>
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {/* Filter chips row */}
+      <div className="flex flex-wrap items-center gap-2">
+        {/* Active / Archived chips */}
+        <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
+          <button
+            onClick={() => { setArchiveFilter('active'); resetPage(); }}
+            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
+              archiveFilter === 'active'
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            <Package className="w-3.5 h-3.5" />
+            {t('inventory.active')}
+          </button>
+          <button
+            onClick={() => { setArchiveFilter('archived'); resetPage(); }}
+            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
+              archiveFilter === 'archived'
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            <Archive className="w-3.5 h-3.5" />
+            {t('inventory.archived')}
+          </button>
+        </div>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary" />
+
+        {/* All / Used / New chips */}
+        <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
+          <button
+            onClick={() => { setUsageFilter('all'); resetPage(); }}
+            className={`px-3 py-1.5 text-xs font-medium transition-colors ${
+              usageFilter === 'all'
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            {t('inventory.all')}
+          </button>
+          <button
+            onClick={() => { setUsageFilter('used'); resetPage(); }}
+            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
+              usageFilter === 'used'
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            <Clock className="w-3.5 h-3.5" />
+            {t('inventory.used')}
+          </button>
+          <button
+            onClick={() => { setUsageFilter('new'); resetPage(); }}
+            className={`px-3 py-1.5 text-xs font-medium transition-colors ${
+              usageFilter === 'new'
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            {t('inventory.new')}
+          </button>
+        </div>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary" />
+
+        {/* Material dropdown chip */}
+        <select
+          value={materialFilter}
+          onChange={(e) => { setMaterialFilter(e.target.value); resetPage(); }}
+          className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+            materialFilter
+              ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
+              : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
+          }`}
+        >
+          <option value="">{t('inventory.material')}</option>
+          {uniqueMaterials.map((m) => (
+            <option key={m} value={m}>{m}</option>
+          ))}
+        </select>
+
+        {/* Brand dropdown chip */}
+        <select
+          value={brandFilter}
+          onChange={(e) => { setBrandFilter(e.target.value); resetPage(); }}
+          className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+            brandFilter
+              ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
+              : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
+          }`}
+        >
+          <option value="">{t('inventory.brand')}</option>
+          {uniqueBrands.map((b) => (
+            <option key={b} value={b}>{b}</option>
+          ))}
+        </select>
+
+        {/* Clear filters */}
+        {hasActiveFilters && (
+          <>
+            <div className="w-px h-5 bg-bambu-dark-tertiary" />
+            <button
+              onClick={clearAllFilters}
+              className="flex items-center gap-1 text-xs text-bambu-gray hover:text-bambu-green transition-colors"
+            >
+              <X className="w-3.5 h-3.5" />
+              {t('inventory.clearFilters')}
+            </button>
+          </>
+        )}
+
+        {/* Results count */}
+        <span className="ml-auto text-xs text-bambu-gray">
+          {filteredSpools.length} {filteredSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
+        </span>
+      </div>
+
+      {/* Content */}
+      {isLoading ? (
+        <div className="flex justify-center py-16">
+          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+        </div>
+      ) : viewMode === 'cards' ? (
+        /* Cards view */
+        pagedSpools.length > 0 ? (
+          <>
+            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
+              {pagedSpools.map((spool) => {
+                const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+                const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
+                const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
+                return (
+                  <div
+                    key={spool.id}
+                    className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
+                    onClick={() => setFormModal({ spool })}
+                  >
+                    {/* Color header */}
+                    <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
+                      <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
+                        {spool.color_name || '-'}
+                      </span>
+                    </div>
+                    {/* Content */}
+                    <div className="p-4 space-y-3">
+                      <div className="flex items-start justify-between gap-2">
+                        <div>
+                          <h3 className="font-semibold text-white">{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}</h3>
+                          <p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
+                        </div>
+                        <span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">#{spool.id}</span>
+                      </div>
+                      {/* Progress */}
+                      <div>
+                        <div className="flex justify-between text-xs text-bambu-gray mb-1">
+                          <span>{t('inventory.remaining')}</span>
+                          <span>{Math.round(pct)}%</span>
+                        </div>
+                        <div className="flex items-center gap-2">
+                          <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+                            <div
+                              className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
+                              style={{ width: `${Math.min(pct, 100)}%` }}
+                            />
+                          </div>
+                          <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
+                        </div>
+                      </div>
+                      {/* Weight info */}
+                      <div className="grid grid-cols-2 gap-2 text-xs">
+                        <div>
+                          <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
+                          <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
+                        </div>
+                        <div>
+                          <span className="text-bambu-gray/60">{t('inventory.weightUsed')}: </span>
+                          <span className="text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
+                        </div>
+                      </div>
+                      {/* Note */}
+                      {spool.note && (
+                        <div className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate" title={spool.note}>
+                          {spool.note}
+                        </div>
+                      )}
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+            {/* Pagination for cards */}
+            <PaginationBar
+              pageIndex={safePageIndex}
+              pageSize={pageSize}
+              totalRows={filteredSpools.length}
+              totalPages={totalPages}
+              onPageChange={setPageIndex}
+              onPageSizeChange={(size) => { setPageSize(size); resetPage(); }}
+              t={t}
+            />
+          </>
+        ) : (
+          <EmptyFilterState
+            hasFilters={hasActiveFilters}
+            onAddSpool={() => setFormModal({ spool: null })}
+            t={t}
+          />
+        )
+      ) : (
+        /* Table view */
+        pagedSpools.length > 0 ? (
+          <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
+            <div className="overflow-x-auto">
+              <table className="w-full">
+                <thead>
+                  <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
+                    {visibleColumns.map((colId) => (
+                      <th
+                        key={colId}
+                        className={`text-left py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide ${colId === 'remaining' ? 'min-w-[150px]' : ''}`}
+                      >
+                        {columnHeaders[colId]?.(t) ?? colId}
+                      </th>
+                    ))}
+                    <th className="text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('common.actions')}</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {pagedSpools.map((spool) => {
+                    const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+                    const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
+                    return (
+                      <tr
+                        key={spool.id}
+                        className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
+                          spool.archived_at ? 'opacity-50' : ''
+                        }`}
+                        onClick={() => setFormModal({ spool })}
+                      >
+                        {visibleColumns.map((colId) => (
+                          <td key={colId} className="py-3 px-4">
+                            {columnCells[colId]?.({ spool, remaining, pct, assignmentMap })}
+                          </td>
+                        ))}
+                        <td className="py-3 px-4">
+                          <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
+                            <button
+                              onClick={() => setFormModal({ spool })}
+                              className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
+                              title={t('inventory.editSpool')}
+                            >
+                              <Edit2 className="w-4 h-4" />
+                            </button>
+                            {spool.archived_at ? (
+                              <button
+                                onClick={() => restoreMutation.mutate(spool.id)}
+                                className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors"
+                                title={t('inventory.restore')}
+                              >
+                                <RotateCcw className="w-4 h-4" />
+                              </button>
+                            ) : (
+                              <button
+                                onClick={() => archiveMutation.mutate(spool.id)}
+                                className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors"
+                                title={t('inventory.archive')}
+                              >
+                                <Archive className="w-4 h-4" />
+                              </button>
+                            )}
+                            <button
+                              onClick={() => {
+                                if (confirm(t('inventory.deleteConfirm'))) {
+                                  deleteMutation.mutate(spool.id);
+                                }
+                              }}
+                              className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors"
+                              title={t('common.delete')}
+                            >
+                              <Trash2 className="w-4 h-4" />
+                            </button>
+                          </div>
+                        </td>
+                      </tr>
+                    );
+                  })}
+                </tbody>
+              </table>
+            </div>
+
+            {/* Pagination inside card footer */}
+            <div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
+              <span className="text-bambu-gray">
+                {t('inventory.showing')} {safePageIndex * pageSize + 1} {t('inventory.to')}{' '}
+                {Math.min((safePageIndex + 1) * pageSize, filteredSpools.length)}{' '}
+                {t('inventory.of')} {filteredSpools.length} {t('inventory.spools')}
+              </span>
+
+              <div className="flex items-center gap-2">
+                <span className="text-bambu-gray">{t('inventory.show')}</span>
+                <select
+                  value={pageSize}
+                  onChange={(e) => { setPageSize(Number(e.target.value)); resetPage(); }}
+                  className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
+                >
+                  {[15, 30, 50, 100].map((n) => (
+                    <option key={n} value={n}>{n}</option>
+                  ))}
+                </select>
+
+                <button
+                  onClick={() => setPageIndex(0)}
+                  disabled={safePageIndex === 0}
+                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                  title="First page"
+                >
+                  <ChevronsLeft className="w-4 h-4" />
+                </button>
+                <button
+                  onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
+                  disabled={safePageIndex === 0}
+                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                >
+                  <ChevronLeft className="w-4 h-4" />
+                </button>
+                <span className="text-bambu-gray px-2 whitespace-nowrap">
+                  {t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}
+                </span>
+                <button
+                  onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
+                  disabled={safePageIndex >= totalPages - 1}
+                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                >
+                  <ChevronRight className="w-4 h-4" />
+                </button>
+                <button
+                  onClick={() => setPageIndex(totalPages - 1)}
+                  disabled={safePageIndex >= totalPages - 1}
+                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                  title="Last page"
+                >
+                  <ChevronsRight className="w-4 h-4" />
+                </button>
+              </div>
+            </div>
+          </div>
+        ) : (
+          <EmptyFilterState
+            hasFilters={hasActiveFilters}
+            onAddSpool={() => setFormModal({ spool: null })}
+            t={t}
+          />
+        )
+      )}
+
+      {/* Spool Form Modal */}
+      {formModal !== null && (
+        <SpoolFormModal
+          isOpen={true}
+          onClose={() => setFormModal(null)}
+          spool={formModal.spool}
+        />
+      )}
+
+      {/* Column Config Modal */}
+      <ColumnConfigModal
+        isOpen={showColumnModal}
+        onClose={() => setShowColumnModal(false)}
+        columns={columnConfig}
+        defaultColumns={DEFAULT_COLUMNS}
+        onSave={handleColumnConfigSave}
+      />
+    </div>
+  );
+}
+
+/* Pagination bar (reused for cards view) */
+function PaginationBar({
+  pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t,
+}: {
+  pageIndex: number;
+  pageSize: number;
+  totalRows: number;
+  totalPages: number;
+  onPageChange: (page: number) => void;
+  onPageSizeChange: (size: number) => void;
+  t: (key: string) => string;
+}) {
+  if (totalPages <= 1) return null;
+  return (
+    <div className="flex items-center justify-between pt-2 text-sm">
+      <span className="text-bambu-gray">
+        {t('inventory.showing')} {pageIndex * pageSize + 1} {t('inventory.to')}{' '}
+        {Math.min((pageIndex + 1) * pageSize, totalRows)}{' '}
+        {t('inventory.of')} {totalRows} {t('inventory.spools')}
+      </span>
+      <div className="flex items-center gap-2">
+        <span className="text-bambu-gray">{t('inventory.show')}</span>
+        <select
+          value={pageSize}
+          onChange={(e) => onPageSizeChange(Number(e.target.value))}
+          className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
+        >
+          {[15, 30, 50, 100].map((n) => (
+            <option key={n} value={n}>{n}</option>
+          ))}
+        </select>
+        <button
+          onClick={() => onPageChange(0)}
+          disabled={pageIndex === 0}
+          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+        >
+          <ChevronsLeft className="w-4 h-4" />
+        </button>
+        <button
+          onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
+          disabled={pageIndex === 0}
+          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+        >
+          <ChevronLeft className="w-4 h-4" />
+        </button>
+        <span className="text-bambu-gray px-2 whitespace-nowrap">
+          {t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}
+        </span>
+        <button
+          onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
+          disabled={pageIndex >= totalPages - 1}
+          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+        >
+          <ChevronRight className="w-4 h-4" />
+        </button>
+        <button
+          onClick={() => onPageChange(totalPages - 1)}
+          disabled={pageIndex >= totalPages - 1}
+          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+        >
+          <ChevronsRight className="w-4 h-4" />
+        </button>
+      </div>
+    </div>
+  );
+}
+
+/* Empty state matching SpoolBuddy's design */
+function EmptyFilterState({
+  hasFilters,
+  onAddSpool,
+  t,
+}: {
+  hasFilters: boolean;
+  onAddSpool: () => void;
+  t: (key: string) => string;
+}) {
+  return (
+    <div className="flex flex-col items-center justify-center py-16 px-4">
+      <div className="relative mb-6">
+        <div className="absolute inset-0 -m-4 bg-bambu-green/5 rounded-full blur-2xl" />
+        <div className="relative flex items-center justify-center w-24 h-24 rounded-2xl bg-gradient-to-br from-bambu-dark-secondary to-bambu-dark-tertiary border border-bambu-dark-tertiary shadow-lg">
+          <div className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-bambu-green/30" />
+          <div className="absolute -bottom-2 -left-2 w-2 h-2 rounded-full bg-bambu-green/20" />
+          {hasFilters ? (
+            <Search className="w-10 h-10 text-bambu-gray/40" strokeWidth={1.5} />
+          ) : (
+            <div className="relative">
+              <div className="w-14 h-14 rounded-full border-4 border-bambu-gray/20 flex items-center justify-center">
+                <div className="w-6 h-6 rounded-full bg-bambu-gray/10 border-2 border-bambu-gray/20" />
+              </div>
+              <div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-bambu-green flex items-center justify-center shadow-md">
+                <span className="text-white text-lg font-bold leading-none">+</span>
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+      <h3 className="text-lg font-semibold text-white mb-2 text-center">
+        {hasFilters ? t('inventory.noSpoolsMatch') : t('inventory.noSpools').split('.')[0]}
+      </h3>
+      <p className="text-sm text-bambu-gray text-center max-w-sm mb-6">
+        {hasFilters
+          ? t('inventory.noSpoolsMatchDesc')
+          : t('inventory.noSpools')
+        }
+      </p>
+      {!hasFilters && (
+        <Button onClick={onAddSpool}>
+          <Package className="w-4 h-4" />
+          {t('inventory.addSpool')}
+        </Button>
+      )}
+    </div>
+  );
+}

+ 133 - 11
frontend/src/pages/PrintersPage.tsx

@@ -47,7 +47,7 @@ import {
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { formatDateOnly } from '../utils/date';
-import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo } from '../api/client';
+import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -59,6 +59,7 @@ import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
+import { AssignSpoolModal } from '../components/AssignSpoolModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
@@ -1339,6 +1340,8 @@ function PrinterCard({
   hasUnlinkedSpools = false,
   linkedSpools,
   spoolmanUrl,
+  onGetAssignment,
+  onUnassignSpool,
   timeFormat = 'system',
   cameraViewMode = 'window',
   onOpenEmbeddedCamera,
@@ -1359,6 +1362,9 @@ function PrinterCard({
   hasUnlinkedSpools?: boolean;
   linkedSpools?: Record<string, LinkedSpoolInfo>;
   spoolmanUrl?: string | null;
+  spoolAssignments?: SpoolAssignment[];
+  onGetAssignment?: (printerId: number, amsId: number, trayId: number) => SpoolAssignment | undefined;
+  onUnassignSpool?: (printerId: number, amsId: number, trayId: number) => void;
   timeFormat?: 'system' | '12h' | '24h';
   cameraViewMode?: 'window' | 'embedded';
   onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
@@ -1388,7 +1394,16 @@ function PrinterCard({
     mode: 'humidity' | 'temperature';
   } | null>(null);
   const [linkSpoolModal, setLinkSpoolModal] = useState<{
+    tagUid: string;
     trayUuid: string;
+    printerId: number;
+    amsId: number;
+    trayId: number;
+  } | null>(null);
+  const [assignSpoolModal, setAssignSpoolModal] = useState<{
+    printerId: number;
+    amsId: number;
+    trayId: number;
     trayInfo: { type: string; color: string; location: string };
   } | null>(null);
   const [configureSlotModal, setConfigureSlotModal] = useState<{
@@ -2691,6 +2706,7 @@ function PrinterCard({
                                   kFactor: formatKValue(tray.k),
                                   fillLevel: effectiveFill,
                                   trayUuid: tray.tray_uuid || null,
+                                  tagUid: tray.tag_uid || null,
                                   fillSource,
                                 } : null;
 
@@ -2791,15 +2807,36 @@ function PrinterCard({
                                           spoolmanUrl,
                                           onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                             setLinkSpoolModal({
+                                              tagUid: filamentData.tagUid || '',
                                               trayUuid: uuid,
+                                              printerId: printer.id,
+                                              amsId: ams.id,
+                                              trayId: slotIdx,
+                                            });
+                                          } : undefined,
+                                        }}
+                                        inventory={(() => {
+                                          const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
+                                          return {
+                                            assignedSpool: assignment?.spool ? {
+                                              id: assignment.spool.id,
+                                              material: assignment.spool.material,
+                                              brand: assignment.spool.brand,
+                                              color_name: assignment.spool.color_name,
+                                            } : null,
+                                            onAssignSpool: () => setAssignSpoolModal({
+                                              printerId: printer.id,
+                                              amsId: ams.id,
+                                              trayId: slotIdx,
                                               trayInfo: {
                                                 type: filamentData.profile,
                                                 color: filamentData.colorHex || '',
                                                 location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
                                               },
-                                            });
-                                          } : undefined,
-                                        }}
+                                            }),
+                                            onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
+                                          };
+                                        })()}
                                         configureSlot={{
                                           enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
@@ -2879,6 +2916,7 @@ function PrinterCard({
                           kFactor: formatKValue(tray.k),
                           fillLevel: htEffectiveFill,
                           trayUuid: tray.tray_uuid || null,
+                          tagUid: tray.tag_uid || null,
                           fillSource: htFillSource,
                         } : null;
 
@@ -2992,15 +3030,36 @@ function PrinterCard({
                                       spoolmanUrl,
                                       onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                         setLinkSpoolModal({
+                                          tagUid: filamentData.tagUid || '',
                                           trayUuid: uuid,
+                                          printerId: printer.id,
+                                          amsId: ams.id,
+                                          trayId: htSlotId,
+                                        });
+                                      } : undefined,
+                                    }}
+                                    inventory={(() => {
+                                      const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
+                                      return {
+                                        assignedSpool: assignment?.spool ? {
+                                          id: assignment.spool.id,
+                                          material: assignment.spool.material,
+                                          brand: assignment.spool.brand,
+                                          color_name: assignment.spool.color_name,
+                                        } : null,
+                                        onAssignSpool: () => setAssignSpoolModal({
+                                          printerId: printer.id,
+                                          amsId: ams.id,
+                                          trayId: htSlotId,
                                           trayInfo: {
                                             type: filamentData.profile,
                                             color: filamentData.colorHex || '',
                                             location: getAmsLabel(ams.id, ams.tray.length),
                                           },
-                                        });
-                                      } : undefined,
-                                    }}
+                                        }),
+                                        onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
+                                      };
+                                    })()}
                                     configureSlot={{
                                       enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
@@ -3090,6 +3149,7 @@ function PrinterCard({
                           kFactor: formatKValue(extTray.k),
                           fillLevel: extSpoolmanFill, // Use Spoolman data if available
                           trayUuid: extTray.tray_uuid || null,
+                          tagUid: extTray.tag_uid || null,
                           fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
                         };
 
@@ -3138,15 +3198,36 @@ function PrinterCard({
                                 spoolmanUrl,
                                 onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
                                   setLinkSpoolModal({
+                                    tagUid: extFilamentData.tagUid || '',
                                     trayUuid: uuid,
+                                    printerId: printer.id,
+                                    amsId: 255,
+                                    trayId: 0,
+                                  });
+                                } : undefined,
+                              }}
+                              inventory={(() => {
+                                const assignment = onGetAssignment?.(printer.id, 255, 0);
+                                return {
+                                  assignedSpool: assignment?.spool ? {
+                                    id: assignment.spool.id,
+                                    material: assignment.spool.material,
+                                    brand: assignment.spool.brand,
+                                    color_name: assignment.spool.color_name,
+                                  } : null,
+                                  onAssignSpool: () => setAssignSpoolModal({
+                                    printerId: printer.id,
+                                    amsId: 255,
+                                    trayId: 0,
                                     trayInfo: {
                                       type: extFilamentData.profile,
                                       color: extFilamentData.colorHex || '',
                                       location: 'External Spool',
                                     },
-                                  });
-                                } : undefined,
-                              }}
+                                  }),
+                                  onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
+                                };
+                              })()}
                               configureSlot={{
                                 enabled: hasPermission('printers:control'),
                                 onConfigure: () => setConfigureSlotModal({
@@ -3776,8 +3857,23 @@ function PrinterCard({
         <LinkSpoolModal
           isOpen={!!linkSpoolModal}
           onClose={() => setLinkSpoolModal(null)}
+          tagUid={linkSpoolModal.tagUid}
           trayUuid={linkSpoolModal.trayUuid}
-          trayInfo={linkSpoolModal.trayInfo}
+          printerId={linkSpoolModal.printerId}
+          amsId={linkSpoolModal.amsId}
+          trayId={linkSpoolModal.trayId}
+        />
+      )}
+
+      {/* Assign Spool Modal */}
+      {assignSpoolModal && (
+        <AssignSpoolModal
+          isOpen={!!assignSpoolModal}
+          onClose={() => setAssignSpoolModal(null)}
+          printerId={assignSpoolModal.printerId}
+          amsId={assignSpoolModal.amsId}
+          trayId={assignSpoolModal.trayId}
+          trayInfo={assignSpoolModal.trayInfo}
         />
       )}
 
@@ -4804,6 +4900,28 @@ export function PrintersPage() {
   });
   const linkedSpools = linkedSpoolsData?.linked;
 
+  // Fetch spool assignments for inventory feature
+  const { data: spoolAssignments } = useQuery({
+    queryKey: ['spool-assignments'],
+    queryFn: () => api.getAssignments(),
+    staleTime: 30 * 1000,
+  });
+
+  const unassignMutation = useMutation({
+    mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>
+      api.unassignSpool(printerId, amsId, trayId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+    },
+  });
+
+  // Helper to find assignment for a specific slot
+  const getAssignment = (printerId: number, amsId: number, trayId: number): SpoolAssignment | undefined => {
+    return spoolAssignments?.find(
+      (a) => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId
+    );
+  };
+
   // Create a map of printer_id -> maintenance info for quick lookup
   const maintenanceByPrinter = maintenanceOverview?.reduce(
     (acc, overview) => {
@@ -5112,6 +5230,8 @@ export function PrintersPage() {
                     hasUnlinkedSpools={hasUnlinkedSpools}
                     linkedSpools={linkedSpools}
                     spoolmanUrl={spoolmanStatus?.url}
+                    onGetAssignment={getAssignment}
+                    onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
                     timeFormat={settings?.time_format || 'system'}
                     cameraViewMode={settings?.camera_view_mode || 'window'}
                     onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
@@ -5137,6 +5257,8 @@ export function PrintersPage() {
               hasUnlinkedSpools={hasUnlinkedSpools}
               linkedSpools={linkedSpools}
               spoolmanUrl={spoolmanStatus?.url}
+              onGetAssignment={getAssignment}
+              onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
               amsThresholds={settings ? {
                 humidityGood: Number(settings.ams_humidity_good) || 40,
                 humidityFair: Number(settings.ams_humidity_fair) || 60,

+ 12 - 5
frontend/src/pages/SettingsPage.tsx

@@ -18,6 +18,8 @@ import { NotificationLogViewer } from '../components/NotificationLogViewer';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
+import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
+import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
@@ -3145,9 +3147,12 @@ export function SettingsPage() {
 
       {/* Filament Tab */}
       {activeTab === 'filament' && localSettings && (
+        <>
         <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
-          {/* Left Column - AMS Display Thresholds */}
-          <div className="flex-1 lg:max-w-xl">
+          {/* Left Column (1/3) - Mode Selector + AMS Thresholds */}
+          <div className="lg:w-1/3 space-y-6">
+            <SpoolmanSettings />
+
             <Card>
               <CardHeader>
                 <h2 className="text-lg font-semibold text-white">{t('settings.amsDisplayThresholds')}</h2>
@@ -3306,11 +3311,13 @@ export function SettingsPage() {
             </Card>
           </div>
 
-          {/* Right Column - Spoolman Integration */}
-          <div className="flex-1 lg:max-w-xl">
-            <SpoolmanSettings />
+          {/* Right Column (2/3) - Spool Catalog + Color Catalog */}
+          <div className="lg:w-2/3 space-y-6">
+            <SpoolCatalogSettings />
+            <ColorCatalogSettings />
           </div>
         </div>
+        </>
       )}
 
       {/* Delete API Key Confirmation */}

Plik diff jest za duży
+ 0 - 0
static/assets/index-8p-dzNdz.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-B4os6TlG.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-C8xaQF5N.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-DLgJjh2G.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-8p-dzNdz.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DLgJjh2G.css">
+    <script type="module" crossorigin src="/assets/index-B4os6TlG.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C8xaQF5N.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików