Browse Source

Material number as a first-class spool field (#2994)

maziggy 3 days ago
parent
commit
d30e0e685b
78 changed files with 5770 additions and 699 deletions
  1. 3 0
      CHANGELOG.md
  2. 10 0
      backend/app/api/routes/_spoolman_helpers.py
  3. 91 0
      backend/app/api/routes/inventory.py
  4. 99 51
      backend/app/api/routes/library.py
  5. 6 0
      backend/app/core/database.py
  6. 31 9
      backend/app/main.py
  7. 9 0
      backend/app/models/spool.py
  8. 50 0
      backend/app/schemas/spool.py
  9. 10 0
      backend/app/services/github_backup.py
  10. 11 0
      backend/app/services/github_restore.py
  11. 75 0
      backend/app/services/material_number.py
  12. 13 3
      backend/app/services/spool_csv.py
  13. 13 0
      backend/app/services/spool_tag_matcher.py
  14. 25 0
      backend/tests/integration/test_library_api.py
  15. 104 1
      backend/tests/integration/test_library_pdf_thumbnail.py
  16. 154 0
      backend/tests/integration/test_library_preview_thumbnail_api.py
  17. 528 0
      backend/tests/integration/test_material_number_api.py
  18. 52 29
      backend/tests/integration/test_security_headers.py
  19. 33 0
      backend/tests/unit/services/test_spool_tag_matcher.py
  20. 47 0
      backend/tests/unit/test_github_backup_spool_fields.py
  21. 56 0
      backend/tests/unit/test_github_restore.py
  22. 94 0
      backend/tests/unit/test_material_number_migration.py
  23. 16 0
      backend/tests/unit/test_spoolman_inventory_helpers.py
  24. 31 6
      frontend/scripts/check-browser-baseline.mjs
  25. 14 0
      frontend/src/__tests__/components/AdditionalSection.test.tsx
  26. 69 0
      frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx
  27. 235 0
      frontend/src/__tests__/components/ImagePreviewModal.test.tsx
  28. 101 0
      frontend/src/__tests__/components/MaterialNumberStats.test.tsx
  29. 210 0
      frontend/src/__tests__/components/ModelViewerControls.test.tsx
  30. 45 1
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  31. 347 7
      frontend/src/__tests__/components/PdfPreviewModal.test.tsx
  32. 140 0
      frontend/src/__tests__/components/PreviewModalShell.test.tsx
  33. 125 1
      frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx
  34. 127 0
      frontend/src/__tests__/hooks/useElementFullscreen.test.tsx
  35. 2 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  36. 80 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  37. 335 0
      frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx
  38. 141 0
      frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx
  39. 56 0
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  40. 186 0
      frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx
  41. 21 0
      frontend/src/api/client.ts
  42. 29 7
      frontend/src/components/BulkEditSpoolsModal.tsx
  43. 1 0
      frontend/src/components/ForecastPanel.tsx
  44. 306 0
      frontend/src/components/ImagePreviewModal.tsx
  45. 79 0
      frontend/src/components/MaterialNumberStats.tsx
  46. 49 11
      frontend/src/components/ModelViewer.tsx
  47. 299 318
      frontend/src/components/ModelViewerModal.tsx
  48. 303 86
      frontend/src/components/PdfPreviewModal.tsx
  49. 101 0
      frontend/src/components/PreviewModalShell.tsx
  50. 14 0
      frontend/src/components/SpoolFormModal.tsx
  51. 118 109
      frontend/src/components/SpreadsheetPreviewModal.tsx
  52. 29 0
      frontend/src/components/spool-form/AdditionalSection.tsx
  53. 11 0
      frontend/src/components/spool-form/types.ts
  54. 72 0
      frontend/src/hooks/useElementFullscreen.ts
  55. 22 0
      frontend/src/hooks/usePreviewFullscreen.ts
  56. 2 0
      frontend/src/hooks/useWebSocket.ts
  57. 18 0
      frontend/src/i18n/locales/de.ts
  58. 18 0
      frontend/src/i18n/locales/en.ts
  59. 18 0
      frontend/src/i18n/locales/es.ts
  60. 18 0
      frontend/src/i18n/locales/fr.ts
  61. 18 0
      frontend/src/i18n/locales/it.ts
  62. 18 0
      frontend/src/i18n/locales/ja.ts
  63. 18 0
      frontend/src/i18n/locales/ko.ts
  64. 18 0
      frontend/src/i18n/locales/nl.ts
  65. 18 0
      frontend/src/i18n/locales/pt-BR.ts
  66. 18 0
      frontend/src/i18n/locales/ru.ts
  67. 18 0
      frontend/src/i18n/locales/sv.ts
  68. 18 0
      frontend/src/i18n/locales/tr.ts
  69. 18 0
      frontend/src/i18n/locales/uk.ts
  70. 18 0
      frontend/src/i18n/locales/zh-CN.ts
  71. 18 0
      frontend/src/i18n/locales/zh-TW.ts
  72. 146 56
      frontend/src/pages/FileManagerPage.tsx
  73. 64 3
      frontend/src/pages/InventoryPage.tsx
  74. 10 0
      frontend/src/pages/StatsPage.tsx
  75. 1 0
      frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
  76. 5 0
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  77. 1 0
      frontend/src/utils/inventorySearch.ts
  78. 43 1
      frontend/vite.config.ts

+ 3 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **File Manager previews open larger, zoom, go fullscreen and cover images (#2976, requested and contributed by @Thomansky in #2990)** — PDF, spreadsheet and 3D previews now open in the same large window, with a fullscreen button; double-click the preview to toggle fullscreen. PDFs zoom with Ctrl/⌘ + wheel, a trackpad or touch pinch, or the `+`, `-` and `0` keys. In the 3D viewer the wheel no longer zooms past the model into empty space, and the new zoom and reset buttons return to the framed view. PNG, JPG, GIF, WebP and BMP files get a preview with zoom and pan; TIFF keeps its thumbnail but no preview, because most browsers cannot show it. Double-click a file in the grid or list to open its preview, or select one file and use **Preview** in the toolbar. PDFs get **Generate Thumbnail** in the card menu and list row, like STLs. PDFs with Japanese, Chinese or Korean text, JPEG2000 images or ICC colour now render correctly; they came out blank or wrong before, because pdf.js could not load its font maps and image decoders. The page's security policy no longer allows WebAssembly; only the two preview workers that need it do. Very large zooms on iPhone and iPad stop gaining sharpness instead of showing a blank page. All previews run in the browser, not on the Bambuddy server.
 - **Find a spool by its label number, and assign it to a printer slot from the spool itself (#2978, requested and contributed by @pd81 in #2998)** — Typing `#3` in the inventory search now finds only spool 3. A plain `3` still matches anything containing it, such as #13 or #30, so existing searches behave as before. The spool pickers show each spool's `#ID`, so identical spools can be told apart. A spool with no slot now has an **Assign Spool** button in its editor, which is also where scanning a spool's QR code lands. It opens one dialog to pick the printer, then the AMS, AMS-HT or external slot. Nothing changes until you confirm, and a material mismatch still asks first. The SpoolBuddy kiosk offers the same printer choice when no printer is selected, instead of reporting it disconnected. On dual-nozzle printers the left external slot was labelled "Ext-R" in the assignment confirmation; it now reads "Ext-L". The AMS slots in the dialog can be picked with the keyboard, and screen readers announce each slot by name.
 - **Ambient drying can wait until the humidity stays high, so opening the AMS lid no longer buys a drying cycle (#2518, requested by @ryansouza, contributed by @M2ABRAMSTANK in #2895)** — Ambient auto-drying started on the first humidity reading above the threshold. Opening the AMS lid to swap a spool lets room air reach the sensor for a few minutes, and that brief spike was enough to start a cycle of up to 12 hours. A new **Require sustained humidity** switch in the **Queue Auto-Drying** card under Settings → Workflow, shown while **Ambient drying** is on, sets how long the humidity has to stay above the threshold before a cycle starts: 5 to 240 minutes, 15 when first switched on. It is off by default, so nothing changes until it is turned on. The 15 minutes come from a measured trace on an H2D: after the lid had been open for one to five minutes, every unit was back at or below 25% within about 12 minutes of opening, and the wait only starts counting at the first high reading. One reading at or below the threshold starts the wait over, and so does a gap of more than two minutes without a reading (a disconnect, or a print on a printer that isn't drying while printing), which is logged with the measured gap. A missing reading changes nothing. A printer with a scheduled print pending still starts at once, for every AMS unit on it, because that drying has a deadline. The wait also applies to a printer that is printing with **Continue drying while printing** on, but not while ambient drying itself is off. It runs alongside the cooldown after a cycle instead of being added to it, never interrupts a running or manual cycle, and does not log a wait for a unit the printer refuses to dry. Translated in all locales.
 - **Other applications can send messages through your notification channels** — A new switch per notification provider, **Messages from connected apps** (off by default), lets an application such as Bambuddy Orders send its own messages ("3 orders need you") to Telegram, ntfy, email and the other channels. The app calls `POST /api/v1/notifications/app-message` with an API key that has the new **Send Notifications** permission (off by default; the key's owner also needs `notifications:update`). Messages go through quiet hours, the daily digest and the notification log like Bambuddy's own. Text is plain, links must be http(s), and each key is limited to 20 messages a minute. `GET /api/v1/notifications/app-message/channels` tells the app which channels take its messages. The check behind the electricity-price door and this one is now one shared helper.
@@ -46,6 +47,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Generate Thumbnails works for users who can edit only their own files, and external folder scans no longer wait on PDF thumbnails** — The toolbar's **Generate Thumbnails** button and the file menu's **Generate Thumbnail** entry were offered to users who may edit only their own library files, but the server refused them. For those users both now cover the files they uploaded themselves; users who may edit all files still get everyone's. Scanning an external folder rendered each new PDF's thumbnail before the scan finished, which on a Raspberry Pi with a share full of PDFs kept the scan waiting for a long time. PDF thumbnails are now made in the background after the scan, like STL thumbnails already were.
+- **The Low Filament notification now fires (#2913, reported and contributed by @ojimpo in #2940)** — The event had a toggle on every notification provider, but nothing in Bambuddy ever sent it, so switching it on did nothing. It now alerts when a spool assigned to an AMS, AMS-HT or external slot drops below its low-stock threshold. That is the same global percentage (default 20 %) and per-spool override that drive the Low Stock count on the Inventory page, so the alert and the count agree. Remaining filament comes from the spool's weight, not the AMS remain percentage, which can be far off. It works with the built-in inventory and with Spoolman; with Spoolman only the global threshold applies. Each spool alerts once per slot and can alert again after it goes back above the threshold, for example when a fresh one is loaded. Archived spools and slots with no assigned spool never alert. The check runs every 30 seconds and does nothing unless a provider has the event switched on. `{color}` is now filled in for custom templates. If you switched Low Filament on in the past, expect one alert for each assigned spool that is already low after updating, and again after each restart while it stays low.
 - **A live camera view no longer stops for good after about half an hour on X1, H2 and P2 printers** — These printers' camera streams come over RTSP, and the printer ends each session after a while; a stock X1 Carbon ends every one after exactly a minute. Bambuddy reconnects straight away, so viewers never notice, but every reconnect counted against a limit of 30 for the life of the stream. Half an hour into a print the camera stopped and did not come back until the page was reopened. The limit now counts failed attempts in a row, and a session that delivered video resets it. A printer that refuses the camera for a while, for example because another app is on it, used to be given up on after 30 attempts in nine seconds. Bambuddy now waits longer between failed attempts, up to five seconds each, so it gets about two minutes. External cameras had the same problem with a limit of three: a camera server that ends its sessions now and then stopped the stream on the fourth drop. External streams now always reconnect after a session that delivered video. Found while investigating #3189.
 - **A slot that reads empty for a moment no longer loses its spool assignment (#3186, reported by @Sawtaytoes)** — An idle X1 Carbon sent one status update that showed a whole AMS unit as empty, with no colour or material in any slot, and Bambuddy deleted all four spool assignments on it at once. The spools never moved, and the next update reported them again. Nothing brought the assignments back, and for a non-RFID spool the assignment is the only record of which spool is in the slot. The #3100 fix covered a blank slot the AMS still reported as occupied, but not one briefly reported as empty. A slot that looks empty, or that drops out of the AMS data, now keeps its assignment for two minutes and loses it only if it is still empty then. Bambuddy checks again by itself when the two minutes are up, so a spool that was really taken out does not wait for the next AMS change. A different spool the AMS can identify, by its RFID tag or its colour and material, still releases the old assignment immediately. A spool it cannot read that goes in during those two minutes releases it once they are up, rather than inheriting the old spool's assignment. Assigning a spool to the slot yourself cancels the wait. Spoolman mode's slot links follow the same rules.
 - **Number fields can be cleared and retyped (#3182, reported by @Carter3DP)** — Every number field corrected its value on each keystroke, so erasing the "1" in the print dialog's Quantity snapped straight back to 1, and getting to 6 meant typing 16 and deleting the 1. It was worse where the minimum is above 1: typing the "6" of 60 into the AMS drying temperature turned it into 45, so the field could only be set with the arrows. Fields now keep what you type while you edit, use it once it is a number in range, and settle on leaving the field: out-of-range numbers are pulled into range, and an empty field goes back to its default. This covers the 38 number fields across the print dialog, per-plate quantities, scheduling, drying, Settings, smart plugs, backups, spools, projects and SpoolBuddy. Two fields could not be set to 0 even though 0 is allowed, the smart plug's delay after drying and AMS humidity; they can now. The drying presets now also keep to their own limits, 30–65 °C on an AMS 2 Pro and 30–85 °C on an AMS HT.

+ 10 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -55,6 +55,10 @@ class MappedSpoolFields(TypedDict):
     created_at: str | None  # None when Spoolman spool has no registered timestamp
     updated_at: str | None
     cost_per_kg: float | None
+    # Spoolman's native filament.article_number, surfaced as the internal
+    # material number (#2870). Read-only in Spoolman mode — the number is
+    # filament-level there and maintained in Spoolman itself.
+    material_number: str | None
     storage_location: str | None
     location_id: int | None
     k_profiles: list[Any]
@@ -414,6 +418,12 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         # Spoolman has no updated_at field; use registered timestamp as best available proxy
         "updated_at": created_at,
         "cost_per_kg": _safe_optional_float(spool.get("price")),
+        # Spoolman's filament.article_number maps 1:1 onto the internal
+        # material number (#2870): both identify the purchasable product.
+        # Trimmed for the same reason the schema validator trims the internal
+        # one — the filter chip builds its options from trimmed values and
+        # matches exactly, so a padded number would list and match nothing.
+        "material_number": ((filament.get("article_number") or "").strip() or None),
         "storage_location": spool.get("location") or None,
         "location_id": None,
         "k_profiles": [],

+ 91 - 0
backend/app/api/routes/inventory.py

@@ -33,6 +33,7 @@ from backend.app.models.supplier import SpoolmanSpoolSupplier, SpoolSupplier, Su
 from backend.app.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
 from backend.app.schemas.spool import (
+    MaterialNumberStats,
     SpoolAssignmentCreate,
     SpoolAssignmentResponse,
     SpoolBulkCreate,
@@ -67,6 +68,7 @@ from backend.app.services.location_service import (
     prepare_internal_spool_payload,
     rename_location as rename_location_record,
 )
+from backend.app.services.material_number import apply_material_number_inheritance
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_csv import (
@@ -1467,6 +1469,10 @@ async def import_spools_csv(
     created = 0
     for row in preview.rows:
         if row.status == "valid" and row.spool is not None:
+            # Deliberately no material-number inheritance here (#2870), unlike
+            # the other create paths: the file is authoritative. A CSV that
+            # leaves the column blank is stating "no number", not asking for
+            # one to be guessed from whatever else is in the inventory.
             spool = Spool(**row.spool)
             db.add(spool)
             # Supplier assignments resolved by name during parsing (#2988).
@@ -1563,6 +1569,8 @@ async def create_spool(
         payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
     except ValueError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    # A new spool of an already-numbered product inherits its material number (#2870).
+    payload = await apply_material_number_inheritance(db, payload)
     spool = Spool(**payload)
     db.add(spool)
     await db.flush()
@@ -1589,6 +1597,8 @@ async def bulk_create_spools(
         payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
     except ValueError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    # A new spool of an already-numbered product inherits its material number (#2870).
+    payload = await apply_material_number_inheritance(db, payload)
     for _ in range(data.quantity):
         spool = Spool(**payload)
         db.add(spool)
@@ -2597,6 +2607,87 @@ async def get_supplier_stats(
     return sorted(stats.values(), key=lambda s: (-s.consumed_g, supplier_name_key(s.supplier_name)))
 
 
+@router.get("/stats/material-numbers", response_model=list[MaterialNumberStats])
+async def get_material_number_stats(
+    date_from: date | None = Query(None),
+    date_to: date | None = Query(None),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Aggregate the inventory by material number (#2870).
+
+    The material number is the internal purchasing identifier shared by all
+    spools of a product, so this is the grouping the business actually costs
+    by — unlike brand+material+colour. Two queries: active-spool counts and
+    remaining weight from the spool table, consumption and cost from the
+    recorded usage history (archived spools included — their consumption
+    happened).
+
+    ``date_from``/``date_to`` narrow the usage half only, so the widget can
+    follow the dashboard timeframe the rest of the stats page uses. Stock is
+    point-in-time by nature and stays unfiltered — "how much do I hold" has
+    no date range. Sorted by consumption, heaviest first, then by number so
+    a range where nothing was consumed still lists in a stable order.
+    """
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+    # material_number is normalised to NULL-or-non-empty by the schema
+    # validator, so NULL is the only "unset" state to exclude here.
+    has_number = Spool.material_number.is_not(None)
+
+    usage_filters = [has_number]
+    if date_from:
+        usage_filters.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc))
+    if date_to:
+        usage_filters.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc))
+
+    # Clamped PER SPOOL, like every other remaining-weight computation in the
+    # codebase: a spool whose weight_used overshot its label_weight holds 0 g,
+    # it does not subtract from the other spools sharing the number.
+    per_spool_remaining = func.coalesce(Spool.label_weight, 0) - func.coalesce(Spool.weight_used, 0)
+    inventory_rows = await db.execute(
+        select(
+            Spool.material_number,
+            func.count(Spool.id),
+            func.sum(case((per_spool_remaining > 0, per_spool_remaining), else_=0.0)),
+        )
+        .where(has_number, Spool.archived_at.is_(None))
+        .group_by(Spool.material_number)
+    )
+
+    usage_rows = await db.execute(
+        select(
+            Spool.material_number,
+            func.sum(SpoolUsageHistory.weight_used),
+            func.sum(SpoolUsageHistory.cost),
+        )
+        .join(Spool, SpoolUsageHistory.spool_id == Spool.id)
+        .where(*usage_filters)
+        .group_by(Spool.material_number)
+    )
+
+    stats: dict[str, MaterialNumberStats] = {}
+    for number, count, remaining in inventory_rows.all():
+        stats[number] = MaterialNumberStats(
+            material_number=number,
+            spool_count=count,
+            remaining_g=float(remaining or 0),
+            consumed_g=0.0,
+            cost=0.0,
+        )
+    for number, consumed, cost in usage_rows.all():
+        entry = stats.get(number)
+        if entry is None:
+            entry = MaterialNumberStats(
+                material_number=number, spool_count=0, remaining_g=0.0, consumed_g=0.0, cost=0.0
+            )
+            stats[number] = entry
+        entry.consumed_g = float(consumed or 0)
+        entry.cost = float(cost or 0)
+
+    return sorted(stats.values(), key=lambda s: (-s.consumed_g, s.material_number))
+
+
 @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
 async def get_all_usage_history(
     limit: int = 100,

+ 99 - 51
backend/app/api/routes/library.py

@@ -17,7 +17,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
-from sqlalchemy import distinct, func, select
+from sqlalchemy import distinct, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -813,11 +813,12 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
 IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
 
 # File types whose thumbnails are rendered client-side and uploaded back
-# (#2976). The server has no renderer for these formats — STEP would need
-# OpenCascade, PDF a rasteriser — so the browser posts its first preview
-# render to POST /files/{id}/preview-thumbnail instead. Kept to exactly
-# these types so the endpoint can never overwrite a server-generated
-# STL/3MF/G-code/image thumbnail.
+# (#2976). The server has no renderer for STEP (that would need OpenCascade)
+# or the spreadsheet types, so the browser posts its first preview render to
+# POST /files/{id}/preview-thumbnail instead. PDF is rendered server-side with
+# PDFium when it lands and stays here for a PDF that renderer cannot read.
+# Kept to exactly these types so the endpoint can never overwrite a
+# server-generated STL/3MF/G-code/image thumbnail.
 CLIENT_THUMBNAIL_TYPES = {"step", "stp", "pdf", "csv", "xlsx", "ods"}
 
 # Photos of the printed result (#3077): same allowlist and naming as the
@@ -835,9 +836,18 @@ MAX_PHOTO_BYTES = 10 * 1024 * 1024
 # 256px PNG (a few tens of KB); anything near this limit is not a thumbnail.
 MAX_CLIENT_THUMBNAIL_BYTES = 2 * 1024 * 1024
 
+# Upper bound on the *decoded* size, checked against the header before any
+# pixels are allocated: a few-KB PNG can declare 12000x7000 and still be under
+# PIL's own decompression-bomb limit, which would be ~340 MB of RGBA.
+MAX_CLIENT_THUMBNAIL_EDGE = 2048
 
-async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
-    """Generate STL thumbnails for an external folder tree in the background.
+# What the endpoint stores. The grid renders at ~256px, so anything larger is
+# downscaled rather than kept.
+STORED_CLIENT_THUMBNAIL_EDGE = 512
+
+
+async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
+    """Generate STL and PDF thumbnails for an external folder tree in the background.
 
     Spawned via ``asyncio.create_task`` from ``scan_external_folder`` so the
     HTTP request can return as soon as the filesystem walk + folder/file rows
@@ -845,7 +855,9 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
     the request open for many minutes (each file triggers a ``trimesh.load``
     + matplotlib render, ~1-5s each) and the FE modal times out before the
     final ``db.commit()`` runs — causing the original symptom in #1299 where
-    subdirectories never showed up because nothing got committed.
+    subdirectories never showed up because nothing got committed. PDFs are
+    faster (a PDFium page render) but a share holding hundreds of them would
+    still hold the request open, so they are rendered here too.
 
     Opens its own session because the request session is closed by the time
     this task starts running. Commits per-file so a worker restart mid-run
@@ -859,22 +871,30 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
         result = await db.execute(
             LibraryFile.active().where(
                 LibraryFile.folder_id.in_(folder_ids),
-                LibraryFile.file_type == "stl",
+                LibraryFile.file_type.in_(("stl", "pdf")),
                 LibraryFile.thumbnail_path.is_(None),
             )
         )
-        stl_files = result.scalars().all()
-        if not stl_files:
+        target_files = result.scalars().all()
+        if not target_files:
             return
         logger.info(
-            "Backfilling STL thumbnails: %d file(s) across %d folder(s)",
-            len(stl_files),
+            "Backfilling STL/PDF thumbnails: %d file(s) across %d folder(s)",
+            len(target_files),
             len(folder_ids),
         )
-        for stl_file in stl_files:
-            abs_path = to_absolute_path(stl_file.file_path)
+        for target_file in target_files:
+            abs_path = to_absolute_path(target_file.file_path)
             if not abs_path or not abs_path.exists():
                 continue
+            if target_file.file_type == "pdf":
+                # generate_pdf_thumbnail never raises; an unreadable PDF
+                # returns None and keeps the browser-preview fallback.
+                thumb_path = await asyncio.to_thread(generate_pdf_thumbnail, abs_path, thumbnails_dir)
+                if thumb_path:
+                    target_file.thumbnail_path = to_relative_path(Path(thumb_path))
+                    await db.commit()
+                continue
             # Pre-skip files too small to contain even a single triangle.
             # Bulk-uploaded ZIPs of stub STLs would otherwise trigger one
             # trimesh.load() call + one debug log line per stub.
@@ -889,7 +909,7 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
                 logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
                 continue
             if thumb_path:
-                stl_file.thumbnail_path = to_relative_path(Path(thumb_path))
+                target_file.thumbnail_path = to_relative_path(Path(thumb_path))
                 await db.commit()
 
 
@@ -1989,8 +2009,8 @@ async def scan_external_folder(
                 except Exception as e:
                     logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
 
-            # STL thumbnails are deferred to a background task spawned after
-            # the scan's db.commit() — see _backfill_external_stl_thumbnails.
+            # STL and PDF thumbnails are deferred to a background task spawned
+            # after the scan's db.commit() — see _backfill_external_thumbnails.
             # Doing them inline would block the HTTP request for minutes on a
             # large NAS mount (#1299).
 
@@ -2010,14 +2030,6 @@ async def scan_external_folder(
                 if thumbnail_path_str:
                     thumbnail_path = to_relative_path(Path(thumbnail_path_str))
 
-            # Render page one of a PDF so it has a thumbnail before anyone opens it
-            if file_type == "pdf" and thumbnail_path is None:
-                thumbnail_path_str = await asyncio.to_thread(
-                    generate_pdf_thumbnail, filepath, get_library_thumbnails_dir()
-                )
-                if thumbnail_path_str:
-                    thumbnail_path = to_relative_path(Path(thumbnail_path_str))
-
             db_file = LibraryFile(
                 folder_id=target_folder_id,
                 is_external=True,
@@ -2096,17 +2108,17 @@ async def scan_external_folder(
 
     await db.commit()
 
-    # Spawn STL thumbnail backfill in the background — the scan endpoint
+    # Spawn STL/PDF thumbnail backfill in the background — the scan endpoint
     # returns immediately so the FE modal closes and subdirectories are
     # visible right away; thumbnails fill in over the following seconds /
-    # minutes as the task processes each STL file. Survives FE refresh —
+    # minutes as the task processes each file. Survives FE refresh —
     # the task lives in the FastAPI event loop, not the request scope.
     # folder_cache.values() covers the root + every pre-existing subfolder
     # + every subfolder created during this scan. all_folder_ids on its own
     # would miss the newly-created ones (it's snapshotted before the walk).
     spawn_background_task(
-        _backfill_external_stl_thumbnails(list(set(folder_cache.values()))),
-        name=f"stl-backfill-folder-{folder_id}",
+        _backfill_external_thumbnails(list(set(folder_cache.values()))),
+        name=f"thumbnail-backfill-folder-{folder_id}",
     )
 
     return {"status": "success", "added": added, "removed": removed}
@@ -2762,12 +2774,20 @@ async def extract_zip_file(
 async def batch_generate_stl_thumbnails(
     request: BatchThumbnailRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
 ):
     """Generate thumbnails for STL and PDF files in batch.
 
-    Note: Requires library:update_all permission since this is a batch operation
-    that may affect files owned by different users.
+    With library:update_all this covers every matching file; with only
+    library:update_own it is narrowed to the caller's own files, the same
+    rule as update_file. The File Manager offers the toolbar button and the
+    per-file "Generate Thumbnail" entry to update_own users, and both land
+    here.
 
     PDFs are included so the ones added before server-side PDF thumbnails
     existed can be backfilled without opening each preview. The route keeps
@@ -2784,6 +2804,10 @@ async def batch_generate_stl_thumbnails(
     # Build query based on request
     query = LibraryFile.active().where(LibraryFile.file_type.in_(("stl", "pdf")))
 
+    user, can_modify_all = auth_result
+    if not can_modify_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+
     if request.file_ids:
         # Specific files
         query = query.where(LibraryFile.id.in_(request.file_ids))
@@ -5421,9 +5445,11 @@ async def upload_preview_thumbnail(
 
     STEP, PDF and spreadsheet previews are rendered in the browser; the FE
     posts its first render here so the grid gets a thumbnail without the
-    server needing OpenCascade or a PDF rasteriser. Only file types in
-    ``CLIENT_THUMBNAIL_TYPES`` are accepted, and only while the file has no
-    thumbnail yet — a stored thumbnail is never replaced by this route.
+    server needing OpenCascade. A PDF normally has its PDFium thumbnail from
+    upload already, so for PDFs this only fills the gap for a file PDFium
+    could not read. Only file types in ``CLIENT_THUMBNAIL_TYPES`` are
+    accepted, and only while the file has no thumbnail yet — a stored
+    thumbnail is never replaced by this route.
     """
     user, can_modify_all = auth_result
 
@@ -5455,26 +5481,48 @@ async def upload_preview_thumbnail(
     from PIL import Image, UnidentifiedImageError
 
     try:
-        with Image.open(io.BytesIO(content)) as img:
-            img.load()
-            if img.format != "PNG":
+        # Image.open() reads the header only. Both checks below happen before
+        # load(), so a declared-but-never-delivered canvas is refused rather
+        # than allocated. DecompressionBombError derives straight from
+        # Exception, so it has to be named explicitly — open() itself raises
+        # it once the declared size passes PIL's own limit.
+        with Image.open(io.BytesIO(content)) as source:
+            if source.format != "PNG":
                 raise HTTPException(status_code=400, detail="Thumbnail must be a PNG image")
-            if img.mode not in ("RGB", "RGBA"):
-                img = img.convert("RGBA")
-            # The grid renders at ~256px; cap outliers instead of storing them.
-            if img.width > 512 or img.height > 512:
-                img.thumbnail((512, 512), Image.Resampling.LANCZOS)
-            thumbnails_dir = get_library_thumbnails_dir()
-            thumb_filename = f"{uuid.uuid4().hex}.png"
-            thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
-            img.save(thumb_path, "PNG", optimize=True)
+            if max(source.size) > MAX_CLIENT_THUMBNAIL_EDGE:
+                raise HTTPException(status_code=400, detail="Thumbnail image dimensions too large")
+            source.load()
+            img = source.convert("RGBA") if source.mode not in ("RGB", "RGBA") else source.copy()
     except HTTPException:
         raise
-    except (UnidentifiedImageError, OSError, ValueError) as e:
+    except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError) as e:
         raise HTTPException(status_code=400, detail="Invalid thumbnail image") from e
 
-    file.thumbnail_path = to_relative_path(thumb_path)
+    if max(img.size) > STORED_CLIENT_THUMBNAIL_EDGE:
+        img.thumbnail((STORED_CLIENT_THUMBNAIL_EDGE, STORED_CLIENT_THUMBNAIL_EDGE), Image.Resampling.LANCZOS)
+
+    thumbnails_dir = get_library_thumbnails_dir()
+    thumb_filename = f"{uuid.uuid4().hex}.png"
+    thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
+    # Outside the decode guard on purpose: a full disk or an unwritable
+    # thumbnail directory is ours, not "Invalid thumbnail image".
+    try:
+        img.save(thumb_path, "PNG", optimize=True)
+    except OSError as e:
+        logger.error("Failed to store preview thumbnail for file %s: %s", file_id, e)
+        raise HTTPException(status_code=500, detail="Failed to store thumbnail") from e
+
+    # Two previews of the same file can reach this point together; the loser
+    # of the UPDATE takes its PNG back off disk instead of orphaning it.
+    result = await db.execute(
+        update(LibraryFile)
+        .where(LibraryFile.id == file_id, LibraryFile.thumbnail_path.is_(None))
+        .values(thumbnail_path=to_relative_path(thumb_path))
+    )
     await db.commit()
+    if result.rowcount == 0:
+        thumb_path.unlink(missing_ok=True)
+        return ClientThumbnailResponse(updated=False)
 
     return ClientThumbnailResponse(updated=True)
 

+ 6 - 0
backend/app/core/database.py

@@ -5080,6 +5080,12 @@ async def run_migrations(conn):
     # create_all() covers fresh installs; this covers upgrades.
     await _migrate_create_supplier_tables(conn)
 
+    # Migration: Add material_number to spool (#2870). Nullable free text —
+    # the internal purchasing/article number a business costs by, shared by
+    # all spools of the same product. VARCHAR(64) is spelled identically on
+    # SQLite and Postgres.
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN material_number VARCHAR(64)")
+
     # Migration: repair the tare of spools the RFID auto-add gave the wrong
     # Bambu spool row (#2909). Runs last so the spool catalogue it reads is
     # whatever this database actually holds.

+ 31 - 9
backend/app/main.py

@@ -10163,10 +10163,18 @@ def _frame_ancestors(default_value: str) -> str:
     return f"frame-ancestors {default_value};"
 
 
-# The Vite-emitted STEP preview worker chunk (#2976): src/workers/
-# stepPreview.worker.ts becomes /assets/stepPreview.worker-<hash>.js. Matched
-# exactly so the eval-relaxed CSP below can never apply to any other asset.
+# The two Vite-emitted worker assets that compile WebAssembly (#2976). Both
+# patterns are anchored on the exact emitted name so the relaxed policies
+# below can never apply to any other asset.
+#   src/workers/stepPreview.worker.ts -> /assets/stepPreview.worker-<hash>.js
+#   pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url
+#                                     -> /assets/pdf.worker.min-<hash>.js
+# Vite also emits a one-line chunk under the second name that only exports the
+# worker's URL. The page imports it as a module, and a module script is run
+# under the importing document's policy, never its own response's, so it
+# matching as well changes nothing.
 _STEP_WORKER_ASSET_RE = re.compile(r"^/assets/stepPreview\.worker-[\w-]+\.js$")
+_PDF_WORKER_ASSET_RE = re.compile(r"^/assets/pdf\.worker\.min-[\w-]+\.js$")
 
 
 @app.middleware("http")
@@ -10236,6 +10244,20 @@ async def security_headers_middleware(request, call_next):
             "object-src 'none'; "
             "base-uri 'self'; " + _frame_ancestors("'none'")
         )
+    elif _PDF_WORKER_ASSET_RE.match(request.url.path):
+        # pdf.js decodes JPEG2000/JBIG2 images and ICC colour with WebAssembly
+        # and fetches those modules from /assets/pdfjs/wasm/ (#2976). Same CSP3
+        # rule as the STEP worker above: the policy that governs a dedicated
+        # worker is the one delivered with its own script, so the wasm compile
+        # has to be permitted here rather than on the document. Unlike the STEP
+        # worker this one needs no JS eval, so it gets 'wasm-unsafe-eval' only.
+        response.headers["Content-Security-Policy"] = (
+            "default-src 'self'; "
+            "script-src 'self' 'wasm-unsafe-eval'; "
+            "connect-src 'self'; "
+            "object-src 'none'; "
+            "base-uri 'self'; " + _frame_ancestors("'none'")
+        )
     else:
         # The streaming overlay is embedded same-origin by the URL builder's
         # preview in Settings (#1422), so this branch allows 'self'.
@@ -10257,14 +10279,14 @@ async def security_headers_middleware(request, call_next):
         # sidebar link's site included -- still cannot frame the consent
         # screen to bait a click.
         embeddable_same_origin = request.url.path.startswith("/overlay/") or request.url.path == "/connect/authorize"
-        # 'wasm-unsafe-eval' permits WebAssembly compilation ONLY — it does
-        # not allow eval()/Function() for JS, unlike 'unsafe-eval'. Needed by
-        # the STEP preview, which triangulates in the browser via OpenCascade
-        # compiled to WASM (#2976). Browsers that predate the keyword ignore
-        # it and simply keep blocking wasm, so this never widens JS execution.
+        # No 'wasm-unsafe-eval' here: nothing compiles WebAssembly on the main
+        # thread. Both wasm consumers — the STEP preview and pdf.js's image
+        # decoders (#2976) — run in dedicated workers, which CSP3 governs by
+        # the policy served with their own script, so each gets it in its own
+        # branch above and the document policy stays as strict as it was.
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
-            f"script-src 'self' 'wasm-unsafe-eval' 'nonce-{csp_nonce}'; "
+            f"script-src 'self' 'nonce-{csp_nonce}'; "
             "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "

+ 9 - 0
backend/app/models/spool.py

@@ -57,6 +57,15 @@ class Spool(Base):
     # spools with a lower one without changing the global default.
     low_stock_threshold_pct: Mapped[int | None] = mapped_column(Integer)
 
+    # Internal material / article number (#2870): the identifier a business
+    # purchases and costs by (e.g. "15" = Bambu Lab PLA Basic), distinct from
+    # `category` (production grouping) and `note` (free text). Free text, no
+    # uniqueness — several spools of the same product share the number, which
+    # is exactly what makes it a sort/filter/statistics key. New spools of a
+    # matching product inherit it on creation (services/material_number.py,
+    # applied by the spool create routes and the RFID auto-add).
+    material_number: Mapped[str | None] = mapped_column(String(64))
+
     # Cost tracking
     cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram
 

+ 50 - 0
backend/app/schemas/spool.py

@@ -65,6 +65,19 @@ def normalize_extra_colors(value: str | None) -> str | None:
     return ",".join(tokens)
 
 
+def normalize_material_number(value: str | None) -> str | None:
+    """Trim the material number and treat a blank one as unset (#2870).
+
+    Every write path lands here (form, bulk edit, CSV import, direct API), so
+    "15" and "15 " can never become two groups in the statistics aggregate or
+    two entries in the inventory filter. Blank collapses to NULL rather than
+    "", which keeps "has no number" a single state to query for.
+    """
+    if value is None:
+        return None
+    return value.strip() or None
+
+
 def normalize_effect_type(value: str | None) -> str | None:
     if value is None:
         return None
@@ -123,6 +136,17 @@ class SpoolBase(BaseModel):
     # User-defined category + per-spool low-stock threshold override (#729).
     category: str | None = Field(default=None, max_length=50)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
+    # Internal material / article number (#2870) — the purchasing identifier,
+    # shared by all spools of the same product. Free text, no uniqueness.
+    material_number: str | None = Field(default=None, max_length=64)
+
+    # mode="before": trim first, so a padded value is held to the 64
+    # characters it will store, not to the length it arrived with.
+    @field_validator("material_number", mode="before")
+    @classmethod
+    def _validate_material_number(cls, v):
+        return normalize_material_number(v) if isinstance(v, str) else v
+
     # Free-text storage location, distinct from `location` (AMS slot
     # assignment). Column has lived on the ORM since the inventory rework
     # but was missing from this schema, so writes were silently dropped (#1291).
@@ -176,6 +200,16 @@ class SpoolUpdate(BaseModel):
     # User-defined category + per-spool low-stock threshold override (#729).
     category: str | None = Field(default=None, max_length=50)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
+    # Internal material / article number (#2870).
+    material_number: str | None = Field(default=None, max_length=64)
+
+    # mode="before": trim first, so a padded value is held to the 64
+    # characters it will store, not to the length it arrived with.
+    @field_validator("material_number", mode="before")
+    @classmethod
+    def _validate_material_number(cls, v):
+        return normalize_material_number(v) if isinstance(v, str) else v
+
     storage_location: str | None = Field(default=None, max_length=255)
     location_id: int | None = Field(default=None, gt=0)
 
@@ -254,6 +288,22 @@ class SpoolResponse(SpoolBase):
         populate_by_name = True
 
 
+class MaterialNumberStats(BaseModel):
+    """Per-material-number inventory aggregate (#2870).
+
+    ``spool_count`` and ``remaining_g`` cover active (non-archived) spools;
+    ``consumed_g`` and ``cost`` sum the recorded usage history of every spool
+    carrying the number, archived included — consumption doesn't disappear
+    when a spool is archived.
+    """
+
+    material_number: str
+    spool_count: int
+    remaining_g: float
+    consumed_g: float
+    cost: float
+
+
 class SpoolAssignmentCreate(BaseModel):
     spool_id: int
     printer_id: int

+ 10 - 0
backend/app/services/github_backup.py

@@ -778,6 +778,16 @@ class GitHubBackupService:
                 "nozzle_temp_max": s.nozzle_temp_max,
                 "note": s.note,
                 "cost_per_kg": s.cost_per_kg,
+                # The user's own bookkeeping on the spool: purchasing number
+                # (#2870), category and low-stock override (#729), free-text
+                # storage. All four were missing from this whitelist, so a
+                # restore silently dropped them. `location_id` stays out —
+                # the locations table itself is not in the backup, so the ID
+                # would point at whatever happens to own it on the target.
+                "material_number": s.material_number,
+                "category": s.category,
+                "low_stock_threshold_pct": s.low_stock_threshold_pct,
+                "storage_location": s.storage_location,
                 "tag_uid": s.tag_uid,
                 "tray_uuid": s.tray_uuid,
                 "data_origin": s.data_origin,

+ 11 - 0
backend/app/services/github_restore.py

@@ -1313,6 +1313,17 @@ class GitHubRestoreService:
                 "archived_at": _parse_dt(entry.get("archived_at")),
             }
 
+            # The user's own bookkeeping on the spool: purchasing number
+            # (#2870), category and low-stock override (#729), free-text
+            # storage. Added to the backup format after the fields above, so
+            # they are applied only when the file actually carries them —
+            # an older backup must not wipe what the live row holds.
+            # `location_id` is deliberately absent: the locations table is
+            # not backed up, so the ID would point at whatever owns it here.
+            for late_field in ("material_number", "category", "low_stock_threshold_pct", "storage_location"):
+                if late_field in entry:
+                    fields[late_field] = entry[late_field]
+
             if existing is not None:
                 if old_id is not None:
                     spool_id_map[old_id] = existing.id

+ 75 - 0
backend/app/services/material_number.py

@@ -0,0 +1,75 @@
+"""Material-number inheritance for newly created spools (#2870).
+
+The material number is the internal purchasing/article identifier a business
+costs by (e.g. "15" = Bambu Lab PLA Basic). All spools of the same product
+share it, so a new spool of an already-numbered product should arrive with
+the number filled instead of blank — whether it is created manually, via the
+API, or by the RFID auto-add.
+"""
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+async def find_material_number_for_product(
+    db: AsyncSession,
+    *,
+    material: str | None,
+    subtype: str | None,
+    brand: str | None,
+    color_name: str | None,
+) -> str | None:
+    """Return the material number an existing spool of this product carries.
+
+    Product identity is the (material, subtype, brand, color_name) string
+    tuple — the same key FilamentSkuSettings groups by. The most recently
+    updated match wins, newest row on a tie. `updated_at` moves on every
+    write, usage included, so this is "most recently touched", not "most
+    recently numbered": when a product's spools disagree, bulk-edit them to
+    one number rather than relying on which one wins. Archived spools count:
+    a product being out of stock doesn't change its number.
+    """
+    if not material:
+        return None
+
+    def _same(column, value):
+        return column.is_(None) if value is None else column == value
+
+    result = await db.execute(
+        select(Spool.material_number)
+        .where(
+            # Normalised to NULL-or-non-empty by the schema validator, so
+            # NULL is the only "unset" state left to exclude.
+            Spool.material_number.is_not(None),
+            Spool.material == material,
+            _same(Spool.subtype, subtype),
+            _same(Spool.brand, brand),
+            _same(Spool.color_name, color_name),
+        )
+        .order_by(Spool.updated_at.desc(), Spool.id.desc())
+        .limit(1)
+    )
+    return result.scalars().first()
+
+
+async def apply_material_number_inheritance(db: AsyncSession, payload: dict) -> dict:
+    """Fill ``payload["material_number"]`` from a matching existing spool.
+
+    No-op when the caller already supplied a non-empty number. Only used on
+    the create paths — editing a spool never overwrites what the user set.
+    """
+    if payload.get("material_number"):
+        return payload
+    number = await find_material_number_for_product(
+        db,
+        material=payload.get("material"),
+        subtype=payload.get("subtype"),
+        brand=payload.get("brand"),
+        color_name=payload.get("color_name"),
+    )
+    if number:
+        payload = dict(payload)
+        payload["material_number"] = number
+    return payload

+ 13 - 3
backend/app/services/spool_csv.py

@@ -34,8 +34,9 @@ from backend.app.schemas.spool import SpoolCreate
 # import — `weight_used` is the source of truth, and accepting both would let
 # them contradict. `last_used` is a timestamp the model carries but SpoolCreate
 # does not, so import applies it to the ORM object directly (see persist path).
-# `storage_location`, `category` and `low_stock_threshold_pct` are SpoolCreate
-# fields included so a round-trip preserves them (they'd otherwise be lost).
+# `storage_location`, `category`, `low_stock_threshold_pct` and
+# `material_number` (#2870) are SpoolCreate fields included so a round-trip
+# preserves them (they'd otherwise be lost).
 CSV_COLUMNS = [
     "material",
     "brand",
@@ -55,6 +56,7 @@ CSV_COLUMNS = [
     "storage_location",
     "category",
     "low_stock_threshold_pct",
+    "material_number",
     # Supplier assignments (#2988): `suppliers` is the "; "-joined names of
     # all assigned suppliers, `purchase_supplier` the one this spool was
     # actually bought from (or empty). Import matches names against the
@@ -382,7 +384,15 @@ async def parse_and_validate(raw_bytes: bytes, db: AsyncSession) -> ImportPrevie
         row_error: str | None = None
 
         # Plain text passthrough columns.
-        for field in ("subtype", "effect_type", "extra_colors", "note", "storage_location", "category"):
+        for field in (
+            "subtype",
+            "effect_type",
+            "extra_colors",
+            "note",
+            "storage_location",
+            "category",
+            "material_number",
+        ):
             value = cell(raw_row, field)
             if value:
                 data[field] = value

+ 13 - 0
backend/app/services/spool_tag_matcher.py

@@ -227,6 +227,18 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         remain_pct = 100  # Unknown → assume full
     weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)
 
+    # A new spool of an already-numbered product inherits its material number
+    # (#2870) — an RFID-scanned refill arrives costed, not blank.
+    from backend.app.services.material_number import find_material_number_for_product
+
+    material_number = await find_material_number_for_product(
+        db,
+        material=material,
+        subtype=subtype,
+        brand="Bambu Lab",
+        color_name=color_name,
+    )
+
     spool = Spool(
         material=material,
         subtype=subtype,
@@ -235,6 +247,7 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         extra_colors=extra_colors,
         effect_type=effect_type,
         brand="Bambu Lab",
+        material_number=material_number,
         label_weight=label_weight,
         core_weight=core_weight,
         core_weight_catalog_id=core_weight_catalog_id,

+ 25 - 0
backend/tests/integration/test_library_api.py

@@ -1190,6 +1190,31 @@ endsolid cube"""
         # No thumbnail should be generated when disabled
         assert result["thumbnail_path"] is None
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_image_stores_a_thumbnail(self, async_client: AsyncClient, db_session):
+        """An uploaded image gets its own grid thumbnail, so PNGs are not blank cards (#2976)."""
+        from PIL import Image
+
+        buffer = io.BytesIO()
+        Image.new("RGB", (640, 480), (0, 174, 66)).save(buffer, format="PNG")
+
+        files = {"file": ("photo.png", buffer.getvalue(), "image/png")}
+        response = await async_client.post("/api/v1/library/files", files=files)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["file_type"] == "png"
+        assert result["thumbnail_path"]
+
+        from backend.app.api.routes.library import to_absolute_path
+
+        thumb = to_absolute_path(result["thumbnail_path"])
+        assert thumb is not None and thumb.exists()
+        with Image.open(thumb) as thumbnail:
+            assert thumbnail.format == "PNG"
+            assert max(thumbnail.size) <= 256
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):

+ 104 - 1
backend/tests/integration/test_library_pdf_thumbnail.py

@@ -3,7 +3,8 @@
 A PDF gets its grid thumbnail when it enters the library - upload, ZIP
 extraction, external-folder scan - instead of only after somebody has opened
 the browser preview. The "Generate Thumbnails" batch backfills PDFs added
-before that.
+before that. An external scan renders them in its background backfill, not
+inside the scan request.
 """
 
 import io
@@ -15,9 +16,12 @@ from PIL import Image
 from reportlab.lib.pagesizes import A4
 from reportlab.pdfgen import canvas
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
 
+from backend.app.api.routes import library as library_routes
 from backend.app.core.config import settings as app_settings
 from backend.app.models.library import LibraryFile
+from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
 
 
 def _pdf_bytes() -> bytes:
@@ -106,6 +110,17 @@ async def test_external_scan_renders_pdf_thumbnail(
     share.mkdir()
     (share / "manual.pdf").write_bytes(_pdf_bytes())
 
+    # The scan hands thumbnails to a background task. Capture it instead of
+    # letting it run on its own, and give it the test database: the route
+    # module holds its own reference to the real async_session.
+    spawned = []
+    monkeypatch.setattr(library_routes, "spawn_background_task", lambda coro, **_: spawned.append(coro))
+    monkeypatch.setattr(
+        library_routes,
+        "async_session",
+        async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False),
+    )
+
     created = await async_client.post(
         "/api/v1/library/folders/external",
         json={"name": "NAS", "external_path": str(share), "readonly": True, "show_hidden": False},
@@ -118,6 +133,13 @@ async def test_external_scan_renders_pdf_thumbnail(
     files = (await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")).json()
     pdf = next(f for f in files if f["filename"] == "manual.pdf")
     row = await db_session.get(LibraryFile, pdf["id"])
+    # The scan request itself leaves the rendering to the backfill...
+    assert row.thumbnail_path is None
+    assert len(spawned) == 1
+
+    # ...which fills it in.
+    await spawned[0]
+    await db_session.refresh(row)
     _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
 
 
@@ -155,3 +177,84 @@ async def test_batch_generate_backfills_pdf_without_thumbnail(async_client: Asyn
     await db_session.refresh(sheet)
     _assert_png_thumbnail(isolated_storage, old.thumbnail_path)
     assert sheet.thumbnail_path is None
+
+
+class TestBatchThumbnailOwnership(TestOwnershipPermissionsSetup):
+    """Operators hold library:update_own only. The File Manager offers them the
+    toolbar button and the per-file "Generate Thumbnail" entry, so the batch
+    route must serve them - narrowed to their own files."""
+
+    @staticmethod
+    async def _pdf_row(db_session, base_dir, name, owner_id):
+        pdf_path = base_dir / "archive" / "library" / "files" / name
+        pdf_path.parent.mkdir(parents=True, exist_ok=True)
+        pdf_path.write_bytes(_pdf_bytes())
+        row = LibraryFile(
+            filename=name,
+            file_path=f"archive/library/files/{name}",
+            file_type="pdf",
+            file_size=pdf_path.stat().st_size,
+            created_by_id=owner_id,
+        )
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+        return row
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_all_missing_covers_only_their_own_files(
+        self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
+    ):
+        mine = await self._pdf_row(db_session, isolated_storage, "mine.pdf", auth_setup["operator_user"]["id"])
+        theirs = await self._pdf_row(db_session, isolated_storage, "theirs.pdf", auth_setup["operator2_user"]["id"])
+        ownerless = await self._pdf_row(db_session, isolated_storage, "ownerless.pdf", None)
+
+        response = await async_client.post(
+            "/api/v1/library/generate-stl-thumbnails",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"all_missing": True},
+        )
+
+        assert response.status_code == 200, response.text
+        assert [r["file_id"] for r in response.json()["results"]] == [mine.id]
+        for row in (mine, theirs, ownerless):
+            await db_session.refresh(row)
+        _assert_png_thumbnail(isolated_storage, mine.thumbnail_path)
+        assert theirs.thumbnail_path is None
+        assert ownerless.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_generate_for_someone_elses_file(
+        self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
+    ):
+        theirs = await self._pdf_row(db_session, isolated_storage, "theirs.pdf", auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            "/api/v1/library/generate-stl-thumbnails",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [theirs.id]},
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["processed"] == 0
+        await db_session.refresh(theirs)
+        assert theirs.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_all_missing_covers_everyone(
+        self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
+    ):
+        mine = await self._pdf_row(db_session, isolated_storage, "mine.pdf", auth_setup["operator_user"]["id"])
+        ownerless = await self._pdf_row(db_session, isolated_storage, "ownerless.pdf", None)
+
+        response = await async_client.post(
+            "/api/v1/library/generate-stl-thumbnails",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={"all_missing": True},
+        )
+
+        assert response.status_code == 200, response.text
+        assert sorted(r["file_id"] for r in response.json()["results"]) == sorted([mine.id, ownerless.id])

+ 154 - 0
backend/tests/integration/test_library_preview_thumbnail_api.py

@@ -7,6 +7,7 @@ types, and never replacing an existing thumbnail.
 """
 
 import io
+import zlib
 
 import pytest
 from httpx import AsyncClient
@@ -14,6 +15,7 @@ from PIL import Image
 
 from backend.app.core.config import settings as app_settings
 from backend.app.models.library import LibraryFile
+from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
 
 
 def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
@@ -22,6 +24,22 @@ def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
     return buf.getvalue()
 
 
+def _png_claiming(width: int, height: int) -> bytes:
+    """A ~70-byte PNG whose IHDR declares a canvas it never delivers.
+
+    This is the shape that costs memory: the header is what a decoder sizes
+    its buffer from, and the payload stays small enough to pass any upload cap.
+    """
+    raw = bytearray(_png_bytes(size=(1, 1)))
+    # 8-byte signature, then IHDR: length(4) type(4) data(13) crc(4).
+    ihdr = raw[8:33]
+    ihdr[8:12] = width.to_bytes(4, "big")
+    ihdr[12:16] = height.to_bytes(4, "big")
+    ihdr[21:25] = zlib.crc32(bytes(ihdr[4:21])).to_bytes(4, "big")
+    raw[8:33] = ihdr
+    return bytes(raw)
+
+
 @pytest.fixture
 def isolated_storage(monkeypatch, tmp_path):
     """Point thumbnail storage at a throwaway directory."""
@@ -169,3 +187,139 @@ class TestPreviewThumbnailUpload:
             files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
         )
         assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_a_canvas_it_would_have_to_allocate(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        """12000x7000 is under PIL's bomb limit and would decode for real.
+
+        A few KB of upload turns into ~340 MB of pixels, so the declared size
+        has to be refused from the header, before load() is ever reached.
+        """
+        library_file = await file_factory(file_type="step")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_claiming(12000, 7000), "image/png")},
+        )
+
+        assert response.status_code == 400
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_a_decompression_bomb_header(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        """PIL raises DecompressionBombError straight off Exception.
+
+        It is neither an OSError nor a ValueError, so it escaped the decode
+        guard and surfaced as a 500 — it is a bad request like any other.
+        """
+        library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_claiming(20000, 20000), "image/png")},
+        )
+
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_storage_failure_is_not_reported_as_a_bad_image(
+        self, async_client: AsyncClient, monkeypatch, file_factory, isolated_storage
+    ):
+        """A full disk is ours to own, not "Invalid thumbnail image"."""
+
+        payload = _png_bytes()
+        library_file = await file_factory(file_type="xlsx")
+
+        def _no_space(*args, **kwargs):
+            raise OSError(28, "No space left on device")
+
+        monkeypatch.setattr(Image.Image, "save", _no_space)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", payload, "image/png")},
+        )
+
+        assert response.status_code == 500
+        assert response.json()["detail"] != "Invalid thumbnail image"
+
+
+class TestPreviewThumbnailOwnership(TestOwnershipPermissionsSetup):
+    """The ownership branch of the upload route (#2976).
+
+    Reuses the shared auth setup: Operators hold library:update_own only, so
+    they are the group that can tell the two branches apart.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_upload_for_their_own_file(
+        self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 200
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_upload_for_someone_elses_file(
+        self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 403
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_upload_for_an_ownerless_file(
+        self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
+    ):
+        """created_by_id is NULL — an *_own permission owns nothing here."""
+        library_file = await file_factory(file_type="step", created_by_id=None)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_upload_for_an_ownerless_file(
+        self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step", created_by_id=None)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+
+        assert response.status_code == 200

+ 528 - 0
backend/tests/integration/test_material_number_api.py

@@ -0,0 +1,528 @@
+"""API coverage for the spool material number (#2870).
+
+The material number is the internal purchasing identifier shared by all
+spools of a product. Pinned here: CRUD round-trip, server-side normalisation,
+inheritance on the create paths, the per-number statistics aggregate and its
+dashboard timeframe, and the CSV round-trip.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Bambu Lab",
+            "color_name": "Jade White",
+            "rgba": "FFFFFFFF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "weight_used": 0,
+            "weight_used_baseline": 0,
+            "weight_locked": False,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+class TestMaterialNumberCrud:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_persists_and_lists_material_number(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={"material": "PLA", "material_number": "15"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "15"
+
+        listing = await async_client.get("/api/v1/inventory/spools")
+        assert listing.status_code == 200
+        assert [s["material_number"] for s in listing.json()] == ["15"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_updates_material_number(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(material_number="15")
+
+        resp = await async_client.patch(
+            f"/api/v1/inventory/spools/{spool.id}",
+            json={"material_number": "16"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "16"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_material_number_longer_than_64_chars_is_rejected(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={"material": "PLA", "material_number": "x" * 65},
+        )
+        assert resp.status_code == 422
+
+
+class TestMaterialNumberNormalisation:
+    """One validator on the schema, so every write path normalises (#2870).
+
+    Without it "15" and "15 " are two groups in the statistics aggregate and
+    two entries in the inventory filter chip, and the chip's exact match
+    never finds the padded one.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_strips_surrounding_whitespace(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={"material": "PLA", "material_number": "  15 "},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "15"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_maps_blank_to_none(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={"material": "PLA", "material_number": "   "},
+        )
+        assert resp.status_code == 200
+        # NULL, not "" — "has no number" stays a single state to query for.
+        assert resp.json()["material_number"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(material_number="15")
+
+        resp = await async_client.patch(
+            f"/api/v1/inventory/spools/{spool.id}",
+            json={"material_number": " 16 "},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "16"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_edit_strips_surrounding_whitespace(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [spool.id], "update": {"material_number": " 15 "}},
+        )
+        assert resp.status_code == 200
+
+        listing = await async_client.get("/api/v1/inventory/spools")
+        assert [s["material_number"] for s in listing.json()] == ["15"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_csv_import_strips_surrounding_whitespace(self, async_client: AsyncClient):
+        csv = "material,brand,material_number\nPLA,Bambu Lab, 15 \n"
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/import",
+            files={"file": ("spools.csv", csv.encode("utf-8"), "text/csv")},
+        )
+        assert resp.status_code == 200, resp.text
+
+        listing = await async_client.get("/api/v1/inventory/spools")
+        assert [s["material_number"] for s in listing.json()] == ["15"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_padding_does_not_count_against_the_length_cap(self, async_client: AsyncClient, spool_factory):
+        """The cap applies to what is stored: 63 characters with padding
+        around them fit, on every path that goes through the schema."""
+        padded = "  " + "x" * 63
+        created = await async_client.post(
+            "/api/v1/inventory/spools", json={"material": "PLA", "material_number": padded}
+        )
+        assert created.status_code == 200, created.text
+        assert created.json()["material_number"] == "x" * 63
+
+        spool = await spool_factory()
+        patched = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": padded})
+        assert patched.status_code == 200, patched.text
+        assert patched.json()["material_number"] == "x" * 63
+
+        bulk = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [spool.id], "update": {"material_number": " " + "y" * 64 + " "}},
+        )
+        assert bulk.status_code == 200, bulk.text
+
+        too_long = await async_client.patch(f"/api/v1/inventory/spools/{spool.id}", json={"material_number": "x" * 65})
+        assert too_long.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_padded_duplicate_does_not_become_a_second_group(self, async_client: AsyncClient, spool_factory):
+        await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15"})
+        await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "15 "})
+
+        resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+        assert [r["material_number"] for r in resp.json()] == ["15"]
+        assert resp.json()[0]["spool_count"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_blank_number_is_not_offered_as_a_group(self, async_client: AsyncClient):
+        await async_client.post("/api/v1/inventory/spools", json={"material": "PLA", "material_number": "  "})
+
+        resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+        assert resp.json() == []
+
+
+class TestMaterialNumberInheritance:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_new_spool_of_same_product_inherits_number(self, async_client: AsyncClient, spool_factory):
+        await spool_factory(material_number="15")
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={
+                "material": "PLA",
+                "subtype": "Basic",
+                "brand": "Bambu Lab",
+                "color_name": "Jade White",
+            },
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "15"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_different_product_does_not_inherit(self, async_client: AsyncClient, spool_factory):
+        await spool_factory(material_number="15")
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={
+                "material": "PLA",
+                "subtype": "Basic",
+                "brand": "Bambu Lab",
+                "color_name": "Black",
+            },
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_explicit_number_wins_over_inheritance(self, async_client: AsyncClient, spool_factory):
+        await spool_factory(material_number="15")
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={
+                "material": "PLA",
+                "subtype": "Basic",
+                "brand": "Bambu Lab",
+                "color_name": "Jade White",
+                "material_number": "99",
+            },
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_create_inherits_number(self, async_client: AsyncClient, spool_factory):
+        await spool_factory(material_number="15")
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk",
+            json={
+                "spool": {
+                    "material": "PLA",
+                    "subtype": "Basic",
+                    "brand": "Bambu Lab",
+                    "color_name": "Jade White",
+                },
+                "quantity": 3,
+            },
+        )
+        assert resp.status_code == 200
+        assert [s["material_number"] for s in resp.json()] == ["15", "15", "15"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archived_spool_still_provides_the_number(self, async_client: AsyncClient, spool_factory):
+        from datetime import datetime, timezone
+
+        await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={
+                "material": "PLA",
+                "subtype": "Basic",
+                "brand": "Bambu Lab",
+                "color_name": "Jade White",
+            },
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "15"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_timestamp_tie_goes_to_the_newest_spool(
+        self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+    ):
+        """Two donors touched in the same second must not pick one at random."""
+        from datetime import datetime
+
+        same_moment = datetime(2026, 1, 1, 12, 0, 0)
+        older = await spool_factory(material_number="15")
+        newer = await spool_factory(material_number="16")
+        for spool in (older, newer):
+            spool.updated_at = same_moment
+        await db_session.commit()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools",
+            json={
+                "material": "PLA",
+                "subtype": "Basic",
+                "brand": "Bambu Lab",
+                "color_name": "Jade White",
+            },
+        )
+        assert resp.status_code == 200
+        assert resp.json()["material_number"] == "16"
+
+
+class TestMaterialNumberStats:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stats_group_by_number(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession):
+        a = await spool_factory(material_number="15", label_weight=1000, weight_used=200)
+        b = await spool_factory(material_number="15", label_weight=1000, weight_used=0)
+        c = await spool_factory(material_number="16", color_name="Black", label_weight=1000, weight_used=500)
+        await spool_factory(material_number=None, color_name="Gray")
+
+        db_session.add_all(
+            [
+                SpoolUsageHistory(spool_id=a.id, weight_used=120, percent_used=12, status="completed", cost=2.4),
+                SpoolUsageHistory(spool_id=b.id, weight_used=80, percent_used=8, status="completed", cost=1.6),
+                SpoolUsageHistory(spool_id=c.id, weight_used=500, percent_used=50, status="failed", cost=15.0),
+            ]
+        )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+        assert resp.status_code == 200
+        rows = {r["material_number"]: r for r in resp.json()}
+
+        assert set(rows) == {"15", "16"}
+        assert rows["15"]["spool_count"] == 2
+        assert rows["15"]["remaining_g"] == pytest.approx(1800)
+        assert rows["15"]["consumed_g"] == pytest.approx(200)
+        assert rows["15"]["cost"] == pytest.approx(4.0)
+        assert rows["16"]["consumed_g"] == pytest.approx(500)
+        assert rows["16"]["cost"] == pytest.approx(15.0)
+        # Heaviest consumption first.
+        assert [r["material_number"] for r in resp.json()] == ["16", "15"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_over_consumed_spool_does_not_eat_its_siblings_stock(
+        self, async_client: AsyncClient, spool_factory
+    ):
+        """Remaining stock is clamped per spool, not once over the group.
+
+        weight_used above label_weight is reachable (a scale reading, an AMS
+        sync, or a plain PATCH), and every other remaining-weight computation
+        in the codebase clamps each spool at 0. Summing the raw difference
+        first would subtract the overshoot from the other spools of the same
+        number and report less stock than the inventory list does.
+        """
+        await spool_factory(material_number="15", label_weight=1000, weight_used=0)
+        await spool_factory(material_number="15", color_name="Black", label_weight=1000, weight_used=1200)
+
+        resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+        assert resp.status_code == 200
+        row = resp.json()[0]
+        assert row["spool_count"] == 2
+        assert row["remaining_g"] == pytest.approx(1000)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archived_spools_keep_their_recorded_consumption(
+        self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+    ):
+        from datetime import datetime, timezone
+
+        archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
+        db_session.add(
+            SpoolUsageHistory(spool_id=archived.id, weight_used=300, percent_used=30, status="completed", cost=6.0)
+        )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+        assert resp.status_code == 200
+        rows = {r["material_number"]: r for r in resp.json()}
+        # No active spools carry the number, but the consumption is still there.
+        assert rows["15"]["spool_count"] == 0
+        assert rows["15"]["remaining_g"] == 0
+        assert rows["15"]["consumed_g"] == pytest.approx(300)
+
+
+class TestMaterialNumberStatsTimeframe:
+    """The widget sits in the stats dashboard, so it follows its timeframe.
+
+    Usage history is the per-period half; stock is point-in-time and stays
+    whole — "how much do I hold" has no date range.
+    """
+
+    @staticmethod
+    async def _usage(db_session, spool_id, *, days_ago, grams, cost):
+        from datetime import datetime, timedelta, timezone
+
+        row = SpoolUsageHistory(
+            spool_id=spool_id, weight_used=grams, percent_used=grams / 10, status="completed", cost=cost
+        )
+        # created_at is a server default, so set it explicitly to age the row.
+        row.created_at = (datetime.now(timezone.utc) - timedelta(days=days_ago)).replace(tzinfo=None)
+        db_session.add(row)
+        await db_session.commit()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_date_from_excludes_older_consumption(
+        self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+    ):
+        from datetime import datetime, timedelta, timezone
+
+        spool = await spool_factory(material_number="15", label_weight=1000, weight_used=400)
+        await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
+        await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
+
+        since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
+        resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}")
+        assert resp.status_code == 200
+        row = resp.json()[0]
+        assert row["consumed_g"] == pytest.approx(10)
+        assert row["cost"] == pytest.approx(0.2)
+        # Stock is point-in-time: unaffected by the range.
+        assert row["spool_count"] == 1
+        assert row["remaining_g"] == pytest.approx(600)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_date_to_excludes_newer_consumption(
+        self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+    ):
+        from datetime import datetime, timedelta, timezone
+
+        spool = await spool_factory(material_number="15")
+        await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
+        await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
+
+        until = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
+        resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_to={until}")
+        assert resp.json()[0]["consumed_g"] == pytest.approx(1000)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_range_still_reports_lifetime_totals(
+        self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+    ):
+        spool = await spool_factory(material_number="15")
+        await self._usage(db_session, spool.id, days_ago=200, grams=1000, cost=20.0)
+        await self._usage(db_session, spool.id, days_ago=2, grams=10, cost=0.2)
+
+        resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+        assert resp.json()[0]["consumed_g"] == pytest.approx(1010)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_number_with_no_usage_in_range_still_lists_its_stock(
+        self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+    ):
+        from datetime import datetime, timedelta, timezone
+
+        spool = await spool_factory(material_number="15", label_weight=1000, weight_used=250)
+        await self._usage(db_session, spool.id, days_ago=200, grams=250, cost=5.0)
+
+        since = (datetime.now(timezone.utc) - timedelta(days=30)).date().isoformat()
+        resp = await async_client.get(f"/api/v1/inventory/stats/material-numbers?date_from={since}")
+        row = resp.json()[0]
+        assert row["consumed_g"] == 0
+        assert row["remaining_g"] == pytest.approx(750)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ties_sort_by_number_so_the_order_is_stable(
+        self, async_client: AsyncClient, spool_factory, db_session: AsyncSession
+    ):
+        """Equal consumption has to fall back to the number, not to row order.
+
+        The response is assembled in two passes — active spools first, then
+        the numbers that only appear in usage history — so "16" (which has a
+        live spool) is seeded before "15" (archived, usage only). Without the
+        number tie-break the endpoint hands that seeding order straight back.
+        """
+        from datetime import datetime, timezone
+
+        live = await spool_factory(material_number="16", color_name="Black")
+        archived = await spool_factory(material_number="15", archived_at=datetime.now(timezone.utc))
+        db_session.add_all(
+            [
+                SpoolUsageHistory(spool_id=live.id, weight_used=100, percent_used=10, status="completed", cost=2.0),
+                SpoolUsageHistory(spool_id=archived.id, weight_used=100, percent_used=10, status="completed", cost=2.0),
+            ]
+        )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/inventory/stats/material-numbers")
+        rows = resp.json()
+        assert [r["consumed_g"] for r in rows] == [pytest.approx(100), pytest.approx(100)]
+        assert [r["material_number"] for r in rows] == ["15", "16"]
+
+
+class TestMaterialNumberCsv:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_import_round_trip(self, async_client: AsyncClient, spool_factory, db_session: AsyncSession):
+        await spool_factory(material_number="15")
+
+        export = await async_client.get("/api/v1/inventory/spools/export")
+        assert export.status_code == 200
+        text = export.text
+        header = text.splitlines()[0]
+        assert "material_number" in header.split(",")
+        assert ",15" in text.splitlines()[1] or text.splitlines()[1].endswith("15")
+
+        # Wipe and re-import: the number must survive the round trip.
+        from sqlalchemy import delete
+
+        await db_session.execute(delete(Spool))
+        await db_session.commit()
+
+        imported = await async_client.post(
+            "/api/v1/inventory/spools/import",
+            files={"file": ("spools.csv", text.encode("utf-8"), "text/csv")},
+        )
+        assert imported.status_code == 200, imported.text
+        assert imported.json()["created"] == 1
+
+        listing = await async_client.get("/api/v1/inventory/spools")
+        assert [s["material_number"] for s in listing.json()] == ["15"]

+ 52 - 29
backend/tests/integration/test_security_headers.py

@@ -316,31 +316,39 @@ async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
     assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
 
 
-# ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ─────────
+# ─── #2976: WebAssembly is confined to the two preview workers ───────────
 
 
-@pytest.mark.asyncio
-@pytest.mark.integration
-async def test_spa_csp_allows_wasm_but_not_eval(async_client: AsyncClient):
-    """script-src must carry 'wasm-unsafe-eval' but never 'unsafe-eval' (#2976).
-
-    The STEP preview triangulates in the browser via OpenCascade compiled to
-    WASM; without 'wasm-unsafe-eval' the nonce-based CSP blocks
-    WebAssembly.instantiate() and the preview dies with a CompileError.
-    'wasm-unsafe-eval' permits wasm compilation only — JS eval()/Function()
-    stay blocked, which is what the second assertion pins.
+def _script_src_tokens(resp) -> list[str]:
+    """The script-src directive of a response's CSP, split into whole tokens.
+
+    Whole tokens, because 'wasm-unsafe-eval' contains the text "unsafe-eval"
+    and a substring check would read as a pass either way.
     """
-    resp = await async_client.get("/api/v1/auth/status")
     csp = resp.headers.get("Content-Security-Policy", "")
-    script_src = next(
+    directive = next(
         (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
         "",
     )
-    assert "'wasm-unsafe-eval'" in script_src, f"script-src must allow wasm compilation: {script_src!r}"
-    # Substring check must not be fooled by 'wasm-unsafe-eval' containing
-    # "unsafe-eval" — compare whole tokens.
-    tokens = script_src.split()
-    assert "'unsafe-eval'" not in tokens, f"script-src must not allow JS eval: {script_src!r}"
+    return directive.split()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_permits_no_kind_of_eval(async_client: AsyncClient):
+    """The document policy allows neither wasm compilation nor JS eval (#2976).
+
+    Nothing on the main thread compiles WebAssembly: the STEP preview and
+    pdf.js's image decoders both run in dedicated workers, which get their own
+    policies below. So the SPA document stays exactly as strict as it was
+    before the previews landed.
+    """
+    resp = await async_client.get("/api/v1/auth/status")
+    tokens = _script_src_tokens(resp)
+
+    assert tokens, "the SPA response must carry a script-src directive"
+    assert "'wasm-unsafe-eval'" not in tokens, f"document must not compile wasm: {tokens!r}"
+    assert "'unsafe-eval'" not in tokens, f"document must not allow JS eval: {tokens!r}"
 
 
 @pytest.mark.asyncio
@@ -355,20 +363,35 @@ async def test_step_worker_asset_csp_relaxes_eval_only_for_that_file(async_clien
     SPA document itself — must stay nonce-strict. Both requests 404 in the
     test checkout; the security middleware stamps headers regardless.
     """
-
-    def script_src_tokens(resp) -> list[str]:
-        csp = resp.headers.get("Content-Security-Policy", "")
-        directive = next(
-            (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
-            "",
-        )
-        return directive.split()
-
     worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
-    assert "'unsafe-eval'" in script_src_tokens(worker), "step worker script must be allowed to eval"
+    assert "'unsafe-eval'" in _script_src_tokens(worker), "step worker script must be allowed to eval"
 
     other = await async_client.get("/assets/index-Ck9aB12c.js")
-    assert "'unsafe-eval'" not in script_src_tokens(other), "ordinary assets must stay eval-free"
+    assert "'unsafe-eval'" not in _script_src_tokens(other), "ordinary assets must stay eval-free"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_pdf_worker_asset_csp_allows_wasm_without_eval(async_client: AsyncClient):
+    """pdf.js's worker may compile wasm, and nothing more (#2976).
+
+    Its JPEG2000/JBIG2/ICC decoders are WebAssembly fetched from
+    /assets/pdfjs/wasm/; without 'wasm-unsafe-eval' on the worker script's own
+    response they fail to compile and those images and colour spaces silently
+    drop out. JS eval stays blocked — unlike the STEP worker, pdf.js needs
+    none.
+    """
+    worker = await async_client.get("/assets/pdf.worker.min-Ck9aB12c.js")
+    tokens = _script_src_tokens(worker)
+
+    assert "'wasm-unsafe-eval'" in tokens, f"pdf worker must be allowed to compile wasm: {tokens!r}"
+    assert "'unsafe-eval'" not in tokens, f"pdf worker must not be allowed to eval JS: {tokens!r}"
+
+    # Only the worker: pdf.js's own chunk and the modal that loads it run on
+    # the page, whose policy stays wasm-free.
+    for path in ("/assets/pdf-Ck9aB12c.js", "/assets/PdfPreviewModal-Ck9aB12c.js"):
+        other = await async_client.get(path)
+        assert "'wasm-unsafe-eval'" not in _script_src_tokens(other), f"{path} must not get the worker's policy"
 
 
 # ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───

+ 33 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -96,6 +96,39 @@ async def test_create_spool_from_tray_weight_from_remain(db_session):
     assert spool.weight_used == 200.0
 
 
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_inherits_material_number(db_session):
+    """A scanned refill of an already-numbered product arrives costed (#2870).
+
+    This is the one create path nobody watches: it fires from the AMS, not
+    from a request, and it hardcodes brand="Bambu Lab" in the lookup.
+    """
+    first = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+    await db_session.commit()
+    assert first.material_number is None
+
+    first.material_number = "15"
+    await db_session.commit()
+
+    second = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+    await db_session.commit()
+
+    assert second.material_number == "15"
+
+
+@pytest.mark.asyncio
+async def test_create_spool_from_tray_does_not_inherit_across_products(db_session):
+    """The number follows the product, not the brand (#2870)."""
+    donor = await create_spool_from_tray(db_session, SAMPLE_TRAY)
+    donor.material_number = "15"
+    await db_session.commit()
+
+    other = await create_spool_from_tray(db_session, {**SAMPLE_TRAY, "tray_type": "PETG"})
+    await db_session.commit()
+
+    assert other.material_number is None
+
+
 @pytest.mark.asyncio
 async def test_create_spool_from_tray_relationships_loaded(db_session):
     """Both k_profiles and assignments must be eagerly initialized.

+ 47 - 0
backend/tests/unit/test_github_backup_spool_fields.py

@@ -0,0 +1,47 @@
+"""The spool collector's field whitelist for Git backup (#2870).
+
+``_collect_spools`` builds an explicit dict per spool, so anything missing
+from it is silently absent from the backup and therefore lost on restore.
+The owner's own bookkeeping — purchasing number (#2870), category and
+low-stock override (#729), free-text storage — belongs in the file.
+"""
+
+import pytest
+
+from backend.app.models.spool import Spool
+from backend.app.services.github_backup import GitHubBackupService
+
+
+@pytest.mark.asyncio
+async def test_collects_the_owners_own_bookkeeping_fields(db_session):
+    db_session.add(
+        Spool(
+            material="PLA",
+            material_number="15",
+            category="Production",
+            low_stock_threshold_pct=40,
+            storage_location="Shelf B",
+        )
+    )
+    await db_session.commit()
+
+    files: dict = {}
+    await GitHubBackupService()._collect_spools(db_session, files)
+
+    entry = files["spools/inventory.json"]["spools"][0]
+    assert entry["material_number"] == "15"
+    assert entry["category"] == "Production"
+    assert entry["low_stock_threshold_pct"] == 40
+    assert entry["storage_location"] == "Shelf B"
+
+
+@pytest.mark.asyncio
+async def test_location_id_is_left_out(db_session):
+    """The locations table is not backed up, so the ID has nothing to mean."""
+    db_session.add(Spool(material="PLA"))
+    await db_session.commit()
+
+    files: dict = {}
+    await GitHubBackupService()._collect_spools(db_session, files)
+
+    assert "location_id" not in files["spools/inventory.json"]["spools"][0]

+ 56 - 0
backend/tests/unit/test_github_restore.py

@@ -931,6 +931,62 @@ class TestRestoreSpools:
         assert len(spools) == 1
         assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
 
+    @pytest.mark.asyncio
+    async def test_restores_the_owners_own_bookkeeping_fields(self, db_session):
+        """#2870 + #729: purchasing number, category, threshold, storage."""
+        tally = _CategoryTally()
+        entry = self._spool_entry(
+            material_number="15",
+            category="Production",
+            low_stock_threshold_pct=40,
+            storage_location="Shelf B",
+        )
+
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert row.material_number == "15"
+        assert row.category == "Production"
+        assert row.low_stock_threshold_pct == 40
+        assert row.storage_location == "Shelf B"
+
+    @pytest.mark.asyncio
+    async def test_a_backup_predating_those_fields_does_not_wipe_them(self, db_session):
+        """An old file has no such keys — overwrite must leave the live values."""
+        db_session.add(
+            Spool(
+                material="PLA",
+                tag_uid="AABBCCDD",
+                material_number="15",
+                category="Production",
+                low_stock_threshold_pct=40,
+                storage_location="Shelf B",
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert row.material_number == "15"
+        assert row.category == "Production"
+        assert row.low_stock_threshold_pct == 40
+        assert row.storage_location == "Shelf B"
+
+    @pytest.mark.asyncio
+    async def test_location_id_is_never_restored(self, db_session):
+        """The locations table is not in the backup, so the ID is meaningless."""
+        tally = _CategoryTally()
+        entry = self._spool_entry(location_id=99)
+
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert (await db_session.execute(select(Spool))).scalar_one().location_id is None
+
     @pytest.mark.asyncio
     async def test_usage_history_spool_id_is_remapped(self, db_session):
         """Usage rows must point at the new local spool id, not the backup's."""

+ 94 - 0
backend/tests/unit/test_material_number_migration.py

@@ -0,0 +1,94 @@
+"""Migration tests for the spool material_number column (#2870).
+
+A legacy database whose spool table predates the column must gain it on
+upgrade, existing rows must read back as NULL, and re-running the migration
+must be a no-op (idempotent _safe_execute).
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    import backend.app.models  # noqa: F401
+    from backend.app.models import (  # noqa: F401
+        external_link,
+        location,
+        print_log,
+        print_queue,
+        project_bom,
+        slot_preset,
+        spoolman_k_profile,
+        spoolman_slot_assignment,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine_with_legacy_spool_table():
+    """create_all builds the current schema; dropping the column afterwards
+    reproduces a database from a Bambuddy version that predates #2870."""
+    from backend.app.core.database import Base
+
+    _register_all_models()
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+        await conn.execute(text("ALTER TABLE spool DROP COLUMN material_number"))
+        await conn.execute(
+            text(
+                """
+                INSERT INTO spool (
+                    material, label_weight, core_weight,
+                    weight_used, weight_used_baseline, weight_locked
+                )
+                VALUES ('PLA', 1000, 250, 0, 0, 0)
+                """
+            )
+        )
+    yield engine
+    await engine.dispose()
+
+
+async def test_migration_adds_material_number_column(engine_with_legacy_spool_table):
+    async with engine_with_legacy_spool_table.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_legacy_spool_table.connect() as conn:
+        rows = (await conn.execute(text("SELECT id, material, material_number FROM spool"))).all()
+
+    assert len(rows) == 1
+    # Pre-existing rows read back with NULL, not an error or a default.
+    assert rows[0].material_number is None
+
+
+async def test_migration_is_idempotent(engine_with_legacy_spool_table):
+    async with engine_with_legacy_spool_table.begin() as conn:
+        await run_migrations(conn)
+    # A value written after the first run must survive the second run — the
+    # duplicate ALTER TABLE is swallowed, not applied destructively.
+    async with engine_with_legacy_spool_table.begin() as conn:
+        await conn.execute(text("UPDATE spool SET material_number = '15'"))
+    async with engine_with_legacy_spool_table.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_legacy_spool_table.connect() as conn:
+        value = (await conn.execute(text("SELECT material_number FROM spool"))).scalar_one()
+
+    assert value == "15"

+ 16 - 0
backend/tests/unit/test_spoolman_inventory_helpers.py

@@ -107,6 +107,22 @@ class TestMapSpoolmanSpool:
         assert result["weight_used_baseline"] == pytest.approx(0.0)
         assert result["data_origin"] == "spoolman"
 
+    def test_article_number_maps_to_material_number(self):
+        """Spoolman's filament.article_number is the material number (#2870)."""
+        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": "15"}}
+        assert _map_spoolman_spool(spool)["material_number"] == "15"
+
+    def test_missing_or_blank_article_number_maps_to_none(self):
+        assert _map_spoolman_spool(MINIMAL_SPOOL)["material_number"] is None
+        for value in ("", "   "):
+            blank = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": value}}
+            assert _map_spoolman_spool(blank)["material_number"] is None
+
+    def test_padded_article_number_is_trimmed(self):
+        """The filter chip matches exactly against trimmed options (#2870)."""
+        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "article_number": " 15 "}}
+        assert _map_spoolman_spool(spool)["material_number"] == "15"
+
     def test_remaining_weight_drives_synthetic_used_for_parity(self):
         """When remaining_weight is set, weight_used = label - remaining and
         the baseline absorbs the used_weight delta. This mirrors the internal

+ 31 - 6
frontend/scripts/check-browser-baseline.mjs

@@ -28,11 +28,37 @@
  */
 
 import { readdirSync, readFileSync } from 'node:fs';
-import { join, dirname, resolve } from 'node:path';
+import { join, dirname, relative, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
 
 const ASSETS = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'assets');
 
+/**
+ * Every extension the build can emit executable JavaScript under. `.mjs`
+ * because a dependency's file imported with `?url` is copied verbatim under
+ * its own extension and bypasses `build.target` - the pdf.js worker shipped a
+ * class static block that way (#2976).
+ */
+const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs'];
+
+/**
+ * Every script under `dir`, recursively, as paths relative to ASSETS. The
+ * subdirectories matter: pdf.js's decoder fallbacks are published verbatim
+ * under assets/pdfjs/ (vite.config.ts) and run in its worker (#2976).
+ */
+function collectScripts(dir) {
+  const found = [];
+  for (const entry of readdirSync(dir, { withFileTypes: true })) {
+    const full = join(dir, entry.name);
+    if (entry.isDirectory()) {
+      found.push(...collectScripts(full));
+    } else if (SCRIPT_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
+      found.push(relative(ASSETS, full));
+    }
+  }
+  return found;
+}
+
 /**
  * Each pattern must match only real occurrences of the feature. Anything that
  * needs context to tell a false positive from a real hit (regex flags, for
@@ -62,17 +88,16 @@ const FORBIDDEN = [
 
 let bundles;
 try {
-  // .mjs too: a dependency's worker imported with `?url` is copied verbatim
-  // under its own extension, bypasses `build.target`, and was invisible here -
-  // the pdf.js worker shipped a class static block that way (#2976).
-  bundles = readdirSync(ASSETS).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
+  bundles = collectScripts(ASSETS);
 } catch {
   console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
   process.exit(1);
 }
 
 if (bundles.length === 0) {
-  console.error(`check-browser-baseline: no .js or .mjs files in ${ASSETS} - did the build succeed?`);
+  console.error(
+    `check-browser-baseline: no ${SCRIPT_EXTENSIONS.join('/')} files in ${ASSETS} - did the build succeed?`,
+  );
   process.exit(1);
 }
 

+ 14 - 0
frontend/src/__tests__/components/AdditionalSection.test.tsx

@@ -17,6 +17,7 @@ const baseProps = {
   spoolCatalog: [],
   currencySymbol: '$',
   availableCategories: [],
+  availableMaterialNumbers: [],
   globalLowStockThreshold: 20,
 };
 
@@ -26,4 +27,17 @@ describe('AdditionalSection', () => {
     // SpoolWeightPicker renders the 'inventory.coreWeight' label
     expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
   });
+
+  it('renders the material number field in internal mode (#2870)', () => {
+    render(<AdditionalSection {...baseProps} spoolmanMode={false} />);
+    expect(screen.getByText('inventory.materialNumber')).toBeTruthy();
+  });
+
+  it('hides the material number field in Spoolman mode (#2870)', () => {
+    // In Spoolman mode the number is the filament-level article_number,
+    // maintained in Spoolman itself — the form must not offer an input
+    // whose value would be silently dropped.
+    render(<AdditionalSection {...baseProps} spoolmanMode={true} />);
+    expect(screen.queryByText('inventory.materialNumber')).toBeNull();
+  });
 });

+ 69 - 0
frontend/src/__tests__/components/BulkEditSpoolsModal.test.tsx

@@ -0,0 +1,69 @@
+/**
+ * Bulk edit: the field list must only offer fields the active inventory
+ * backend can actually store (#2870).
+ *
+ * In Spoolman mode a spool has no material number, category or low-stock
+ * override of its own — SpoolmanInventoryUpdate has no such fields, so the
+ * payload dumps to {} and the route answers 400 "update must include at
+ * least one field". The user ticks a box, types a value, clicks Apply and
+ * gets an error. Filtering the list is the fix.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { BulkEditSpoolsModal } from '../../components/BulkEditSpoolsModal';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}));
+
+const baseProps = {
+  isOpen: true,
+  selectedCount: 3,
+  isPending: false,
+  availableLocations: [],
+  availableMaterials: [],
+  availableSubtypes: [],
+  availableBrands: [],
+  availableCategories: [],
+  availableMaterialNumbers: [],
+  availableSlicerFilaments: [],
+  availableSlicerFilamentNames: [],
+  onClose: vi.fn(),
+  onApply: vi.fn(),
+};
+
+const INTERNAL_ONLY = [
+  'inventory.materialNumber',
+  'inventory.category',
+  'inventory.lowStockThresholdOverride',
+];
+
+describe('BulkEditSpoolsModal field list', () => {
+  it('offers the internal-only fields in internal mode', () => {
+    render(<BulkEditSpoolsModal {...baseProps} spoolmanMode={false} />);
+    for (const key of INTERNAL_ONLY) {
+      expect(screen.getByText(key)).toBeTruthy();
+    }
+  });
+
+  it('hides every field Spoolman cannot store in Spoolman mode', () => {
+    render(<BulkEditSpoolsModal {...baseProps} spoolmanMode={true} />);
+    for (const key of INTERNAL_ONLY) {
+      expect(screen.queryByText(key)).toBeNull();
+    }
+    // The fields Spoolman does accept stay.
+    expect(screen.getByText('inventory.material')).toBeTruthy();
+    expect(screen.getByText('inventory.note')).toBeTruthy();
+    expect(screen.getByText('inventory.costPerKg')).toBeTruthy();
+  });
+
+  it('defaults to internal mode when the prop is omitted', () => {
+    render(<BulkEditSpoolsModal {...baseProps} />);
+    expect(screen.getByText('inventory.materialNumber')).toBeTruthy();
+  });
+});

+ 235 - 0
frontend/src/__tests__/components/ImagePreviewModal.test.tsx

@@ -0,0 +1,235 @@
+/**
+ * Tests for ImagePreviewModal (#2976).
+ *
+ * jsdom decodes nothing and lays nothing out, so the tests cover the modal's
+ * own logic — the authenticated fetch, the size guard, zoom input and the
+ * fullscreen wiring — rather than what the image looks like.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ImagePreviewModal, IMAGE_PREVIEW_MAX_BYTES } from '../../components/ImagePreviewModal';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
+  },
+  getAuthToken: () => 'token-123',
+}));
+
+const mockOnClose = vi.fn();
+
+function renderModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
+  return render(
+    <ImagePreviewModal
+      libraryFileId={11}
+      filename="plate.png"
+      fileSize={2048}
+      onClose={mockOnClose}
+      {...props}
+    />,
+  );
+}
+
+async function renderLoadedModal(props: Partial<Parameters<typeof ImagePreviewModal>[0]> = {}) {
+  const utils = renderModal(props);
+  const image = (await screen.findByAltText('plate.png')) as HTMLImageElement;
+  fireEvent.load(image);
+  return { ...utils, image, content: screen.getByTestId('image-preview-content') };
+}
+
+function scaleOf(image: HTMLImageElement): number {
+  const match = /scale\(([\d.]+)\)/.exec(image.style.transform);
+  return match ? Number(match[1]) : NaN;
+}
+
+describe('ImagePreviewModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 })),
+    );
+    vi.stubGlobal('URL', {
+      ...URL,
+      createObjectURL: vi.fn(() => 'blob:image-preview'),
+      revokeObjectURL: vi.fn(),
+    });
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('loads the file through the authenticated download URL', async () => {
+    await renderLoadedModal();
+
+    expect(fetch).toHaveBeenCalledWith('http://test/library/files/11/download', {
+      headers: { Authorization: 'Bearer token-123' },
+    });
+    expect(screen.getByAltText('plate.png')).toHaveAttribute('src', 'blob:image-preview');
+    expect(screen.getByText('plate.png')).toBeInTheDocument();
+  });
+
+  it('refuses a file too large to hold in memory', async () => {
+    renderModal({ fileSize: IMAGE_PREVIEW_MAX_BYTES + 1 });
+
+    expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
+    expect(fetch).not.toHaveBeenCalled();
+  });
+
+  it('shows a message when the download fails', async () => {
+    vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 404 })));
+    vi.spyOn(console, 'error').mockImplementation(() => {});
+
+    renderModal();
+
+    expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
+  });
+
+  it('starts fitted to the window and zooms from the buttons', async () => {
+    const user = userEvent.setup();
+    const { image } = await renderLoadedModal();
+
+    expect(scaleOf(image)).toBe(1);
+
+    await user.click(screen.getByRole('button', { name: 'Zoom in' }));
+    expect(scaleOf(image)).toBeCloseTo(1.25, 5);
+
+    await user.click(screen.getByRole('button', { name: 'Zoom out' }));
+    expect(scaleOf(image)).toBeCloseTo(1, 5);
+  });
+
+  it('zooms with the wheel around the pointer', async () => {
+    const { image, content } = await renderLoadedModal();
+
+    fireEvent.wheel(content, { deltaY: -100, clientX: 40, clientY: 30 });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+    fireEvent.wheel(content, { deltaY: 100, clientX: 40, clientY: 30 });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1, 5));
+  });
+
+  it('zooms from the keyboard and resets with 0', async () => {
+    const { image } = await renderLoadedModal();
+
+    fireEvent.keyDown(window, { key: '+' });
+    fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.5625, 4));
+
+    fireEvent.keyDown(window, { key: '0' });
+    await waitFor(() => expect(scaleOf(image)).toBe(1));
+  });
+
+  it('stops at the zoom limits', async () => {
+    const { image } = await renderLoadedModal();
+
+    for (let i = 0; i < 20; i++) fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(scaleOf(image)).toBe(8));
+
+    for (let i = 0; i < 30; i++) fireEvent.keyDown(window, { key: '-' });
+    await waitFor(() => expect(scaleOf(image)).toBe(0.4));
+  });
+
+  it('offers grab-to-pan only once the image is bigger than the window', async () => {
+    const { content } = await renderLoadedModal();
+
+    expect(content.className).not.toContain('cursor-grab');
+
+    fireEvent.keyDown(window, { key: '+' });
+    await waitFor(() => expect(content.className).toContain('cursor-grab'));
+  });
+
+  describe('a pointer that never reports its release', () => {
+    // jsdom has no PointerEvent, and testing-library then falls back to a plain
+    // Event without pointerId / pointerType / buttons.
+    class FakePointerEvent extends MouseEvent {
+      pointerId: number;
+      pointerType: string;
+      constructor(type: string, init: PointerEventInit = {}) {
+        super(type, init);
+        this.pointerId = init.pointerId ?? 0;
+        this.pointerType = init.pointerType ?? '';
+      }
+    }
+
+    /** jsdom lays nothing out, so the pan clamp needs real box sizes. */
+    function giveBoxes(content: HTMLElement, image: HTMLImageElement) {
+      Object.defineProperty(content, 'clientWidth', { configurable: true, value: 200 });
+      Object.defineProperty(content, 'clientHeight', { configurable: true, value: 200 });
+      Object.defineProperty(image, 'offsetWidth', { configurable: true, value: 400 });
+      Object.defineProperty(image, 'offsetHeight', { configurable: true, value: 400 });
+      content.setPointerCapture = vi.fn();
+    }
+
+    function translateOf(image: HTMLImageElement): string {
+      return /translate\(([^)]*)\)/.exec(image.style.transform)?.[1] ?? '';
+    }
+
+    beforeEach(() => {
+      Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
+    });
+
+    afterEach(() => {
+      delete (window as { PointerEvent?: unknown }).PointerEvent;
+    });
+
+    it('drops it when it leaves the image area, instead of panning on hover', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      // Press at zoom 1 — no pointer capture is taken — then release somewhere
+      // else, so only pointerleave arrives.
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.pointerLeave(content, { pointerId: 1, pointerType: 'mouse' });
+
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
+
+      expect(translateOf(image)).toBe('0px, 0px');
+    });
+
+    it('drops it on the first buttonless move, so a later touch is not read as a pinch', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      // The mouse moves back over the image with nothing held down.
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 0, clientX: 160, clientY: 140 });
+      expect(translateOf(image)).toBe('0px, 0px');
+
+      // One finger now drags alone: it pans, it does not pinch-zoom.
+      fireEvent.pointerDown(content, { pointerId: 2, pointerType: 'touch', clientX: 100, clientY: 100 });
+      fireEvent.pointerMove(content, { pointerId: 2, pointerType: 'touch', clientX: 130, clientY: 100 });
+
+      expect(scaleOf(image)).toBeCloseTo(1.25, 5);
+      expect(translateOf(image)).toBe('30px, 0px');
+    });
+
+    it('still pans while the button is held', async () => {
+      const { image, content } = await renderLoadedModal();
+      giveBoxes(content, image);
+
+      fireEvent.keyDown(window, { key: '+' });
+      await waitFor(() => expect(scaleOf(image)).toBeCloseTo(1.25, 5));
+
+      fireEvent.pointerDown(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 100, clientY: 100 });
+      fireEvent.pointerMove(content, { pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 120, clientY: 110 });
+
+      expect(translateOf(image)).toBe('20px, 10px');
+    });
+  });
+
+  it('closes on Escape', async () => {
+    await renderLoadedModal();
+
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 101 - 0
frontend/src/__tests__/components/MaterialNumberStats.test.tsx

@@ -0,0 +1,101 @@
+/**
+ * Tests for the MaterialNumberStats widget (#2870).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MaterialNumberStats } from '../../components/MaterialNumberStats';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getMaterialNumberStats: vi.fn(),
+  },
+}));
+
+function renderWidget(props: { dateFrom?: string; dateTo?: string } = {}) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <MaterialNumberStats currency="EUR" {...props} />
+    </QueryClientProvider>,
+  );
+}
+
+describe('MaterialNumberStats', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('renders one row per material number with weights and cost', async () => {
+    (api.getMaterialNumberStats as ReturnType<typeof vi.fn>).mockResolvedValue([
+      { material_number: '16', spool_count: 1, remaining_g: 500, consumed_g: 1500, cost: 45 },
+      { material_number: '15', spool_count: 12, remaining_g: 9500, consumed_g: 250, cost: 5 },
+    ]);
+    renderWidget();
+
+    expect(await screen.findByText('16')).toBeInTheDocument();
+    expect(screen.getByText('15')).toBeInTheDocument();
+    expect(screen.getByText('12')).toBeInTheDocument();
+    // >= 1 kg renders as kilograms, below stays in grams.
+    expect(screen.getByText('9.50 kg')).toBeInTheDocument();
+    expect(screen.getByText('500 g')).toBeInTheDocument();
+    expect(screen.getByText('EUR 45.00')).toBeInTheDocument();
+  });
+
+  it('shows the empty hint when no numbers are assigned', async () => {
+    (api.getMaterialNumberStats as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    renderWidget();
+
+    expect(await screen.findByText(/No material numbers assigned yet/)).toBeInTheDocument();
+  });
+
+  // A 403 from a missing INVENTORY_READ, a 500 or a dropped connection are
+  // not "you have not numbered your spools" — the two states must read
+  // differently or the user goes looking for a problem that isn't there.
+  it('reports an API failure as a failure, not as an empty inventory', async () => {
+    (api.getMaterialNumberStats as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('403'));
+    renderWidget();
+
+    expect(await screen.findByText(/Could not load the material number statistics/)).toBeInTheDocument();
+    expect(screen.queryByText(/No material numbers assigned yet/)).not.toBeInTheDocument();
+  });
+
+  it('passes the dashboard timeframe to the endpoint', async () => {
+    (api.getMaterialNumberStats as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    renderWidget({ dateFrom: '2026-08-01', dateTo: '2026-08-31' });
+
+    await screen.findByText(/No material numbers assigned yet/);
+    expect(api.getMaterialNumberStats).toHaveBeenCalledWith({
+      dateFrom: '2026-08-01',
+      dateTo: '2026-08-31',
+    });
+  });
+
+  // The range is part of the query key, so the cache cannot serve a
+  // 30-day answer when the dashboard has moved to 90.
+  it('refetches on the same client when the timeframe changes', async () => {
+    (api.getMaterialNumberStats as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    const { rerender } = render(
+      <QueryClientProvider client={client}>
+        <MaterialNumberStats currency="EUR" dateFrom="2026-08-01" />
+      </QueryClientProvider>,
+    );
+    await screen.findByText(/No material numbers assigned yet/);
+    expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(1);
+
+    rerender(
+      <QueryClientProvider client={client}>
+        <MaterialNumberStats currency="EUR" dateFrom="2026-09-01" />
+      </QueryClientProvider>,
+    );
+
+    await vi.waitFor(() => expect(api.getMaterialNumberStats).toHaveBeenCalledTimes(2));
+    expect(api.getMaterialNumberStats).toHaveBeenLastCalledWith({
+      dateFrom: '2026-09-01',
+      dateTo: undefined,
+    });
+  });
+});

+ 210 - 0
frontend/src/__tests__/components/ModelViewerControls.test.tsx

@@ -0,0 +1,210 @@
+/**
+ * Zoom and resize behaviour of the 3D model viewer (#2976).
+ *
+ * Only what jsdom cannot provide is replaced: the WebGL renderer, the PMREM
+ * environment it would bake, and OrbitControls. Scene, camera and the STL
+ * loader are real, so the tests exercise the component's own framing maths.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, waitFor } from '@testing-library/react';
+import * as THREE from 'three';
+
+const mocks = vi.hoisted(() => ({
+  setSize: vi.fn(),
+  wheelSeen: vi.fn(),
+  controlsInstances: [] as Array<{ object: THREE.Camera; domElement: HTMLElement; target: THREE.Vector3; minDistance: number; maxDistance: number }>,
+}));
+
+vi.mock('three', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('three')>();
+  class FakeWebGLRenderer {
+    domElement = document.createElement('canvas');
+    shadowMap = { enabled: false, type: 0 };
+    toneMapping = 0;
+    toneMappingExposure = 1;
+    setSize(width: number, height: number) {
+      mocks.setSize(width, height);
+      this.domElement.style.width = `${width}px`;
+      this.domElement.style.height = `${height}px`;
+    }
+    setPixelRatio() {}
+    render() {}
+    dispose() {}
+  }
+  class FakePMREMGenerator {
+    fromScene() {
+      return { texture: { dispose() {} } };
+    }
+    dispose() {}
+  }
+  return { ...actual, WebGLRenderer: FakeWebGLRenderer, PMREMGenerator: FakePMREMGenerator };
+});
+
+vi.mock('three/examples/jsm/controls/OrbitControls.js', () => ({
+  OrbitControls: class {
+    object: THREE.Camera;
+    domElement: HTMLElement;
+    target = new THREE.Vector3();
+    enableDamping = false;
+    dampingFactor = 0;
+    minDistance = 0;
+    maxDistance = Infinity;
+    constructor(object: THREE.Camera, domElement: HTMLElement) {
+      this.object = object;
+      this.domElement = domElement;
+      // The real controls listen for wheel on the element they are handed;
+      // the fake does the same so the test can tell where events end up.
+      domElement.addEventListener('wheel', mocks.wheelSeen);
+      mocks.controlsInstances.push(this);
+    }
+    update() {}
+    dispose() {
+      this.domElement.removeEventListener('wheel', mocks.wheelSeen);
+    }
+  },
+}));
+
+vi.mock('../../api/client', () => ({
+  getAuthToken: () => null,
+}));
+
+import { ModelViewer } from '../../components/ModelViewer';
+
+// Binary STL of one triangle, enough for a bounding box.
+function tinyStl(): ArrayBuffer {
+  const buffer = new ArrayBuffer(84 + 50);
+  const view = new DataView(buffer);
+  view.setUint32(80, 1, true);
+  const floats = [0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 20, 10];
+  floats.forEach((value, i) => view.setFloat32(84 + i * 4, value, true));
+  return buffer;
+}
+
+function setClientSize(element: HTMLElement, width: number, height: number) {
+  Object.defineProperty(element, 'clientWidth', { configurable: true, value: width });
+  Object.defineProperty(element, 'clientHeight', { configurable: true, value: height });
+}
+
+describe('ModelViewer controls', () => {
+  beforeEach(() => {
+    mocks.setSize.mockClear();
+    mocks.wheelSeen.mockClear();
+    mocks.controlsInstances.length = 0;
+    // jsdom lays nothing out; give every element a panel-sized box so the
+    // camera gets a real aspect ratio to frame against.
+    Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 800 });
+    Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 600 });
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response(tinyStl(), { status: 200 })),
+    );
+    vi.stubGlobal(
+      'ResizeObserver',
+      class {
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth;
+    delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight;
+  });
+
+  async function renderLoaded() {
+    const utils = render(<ModelViewer url="/api/v1/library/files/1/download" fileType="stl" />);
+    const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+    // Loaded once the zoom / reset buttons replace the spinner.
+    await waitFor(() => expect(document.querySelector('.lucide-zoom-in')).not.toBeNull());
+    return { ...utils, canvas, controls: mocks.controlsInstances[0] };
+  }
+
+  it('hands the canvas that is in the DOM to OrbitControls, so wheel events reach it', async () => {
+    const { canvas, controls } = await renderLoaded();
+
+    expect(controls.domElement).toBe(canvas);
+    expect(document.body.contains(canvas)).toBe(true);
+
+    fireEvent.wheel(canvas, { deltaY: -100 });
+    expect(mocks.wheelSeen).toHaveBeenCalledTimes(1);
+  });
+
+  it('bounds the dolly range and keeps the far plane beyond it', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+
+    const framed = camera.position.distanceTo(controls.target);
+    expect(controls.maxDistance).toBeGreaterThan(framed);
+    expect(Number.isFinite(controls.maxDistance)).toBe(true);
+    expect(controls.minDistance).toBeGreaterThan(0);
+    // Zooming all the way out must never push the model past the far plane.
+    expect(camera.far).toBeGreaterThan(controls.maxDistance);
+  });
+
+  it('zoom buttons dolly along the view axis toward the orbit target', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const before = camera.position.clone();
+    const distanceBefore = before.distanceTo(controls.target);
+    const directionBefore = before.clone().sub(controls.target).normalize();
+
+    fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
+
+    const distanceAfter = camera.position.distanceTo(controls.target);
+    expect(distanceAfter).toBeCloseTo(distanceBefore * 0.8, 5);
+    const directionAfter = camera.position.clone().sub(controls.target).normalize();
+    expect(directionAfter.distanceTo(directionBefore)).toBeLessThan(1e-6);
+
+    fireEvent.click(document.querySelector('.lucide-zoom-out')!.closest('button')!);
+    expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distanceBefore, 5);
+  });
+
+  it('resets to the framed view, not a fixed pose', async () => {
+    const { controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const framedPosition = camera.position.clone();
+    const framedTarget = controls.target.clone();
+
+    fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
+    fireEvent.click(document.querySelector('.lucide-rotate-ccw')!.closest('button')!);
+
+    expect(camera.position.distanceTo(framedPosition)).toBeLessThan(1e-6);
+    expect(controls.target.distanceTo(framedTarget)).toBeLessThan(1e-6);
+  });
+
+  it('resizes the canvas and camera when fullscreen changes', async () => {
+    const { canvas, controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const container = canvas.parentElement as HTMLElement;
+    mocks.setSize.mockClear();
+
+    setClientSize(container, 1920, 1080);
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+
+    expect(mocks.setSize).toHaveBeenCalledWith(1920, 1080);
+    expect(camera.aspect).toBeCloseTo(1920 / 1080, 5);
+    expect(canvas.style.width).toBe('1920px');
+  });
+
+  it('ignores a resize to zero (hidden container) instead of collapsing the camera', async () => {
+    const { canvas, controls } = await renderLoaded();
+    const camera = controls.object as THREE.PerspectiveCamera;
+    const container = canvas.parentElement as HTMLElement;
+    const aspect = camera.aspect;
+    mocks.setSize.mockClear();
+
+    setClientSize(container, 0, 0);
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+
+    expect(mocks.setSize).not.toHaveBeenCalled();
+    expect(camera.aspect).toBe(aspect);
+  });
+});

+ 45 - 1
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -202,11 +202,55 @@ describe('ModelViewerModal', () => {
         // Look for the maximize icon button
         const buttons = screen.getAllByRole('button');
         const fullscreenButton = buttons.find(
-          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Enter fullscreen'
+          (btn) => btn.querySelector('.lucide-maximize-2') || btn.title === 'Fullscreen'
         );
         expect(fullscreenButton).toBeInTheDocument();
       });
     });
+
+    it('double-click on the viewer requests fullscreen for the panel (#2976)', async () => {
+      const requestFullscreen = vi.fn().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+      try {
+        render(
+          <ModelViewerModal
+            archiveId={1}
+            title="Test Model"
+            onClose={mockOnClose}
+          />
+        );
+        const viewer = await screen.findByTestId('model-viewer-area');
+
+        fireEvent.doubleClick(viewer);
+
+        expect(requestFullscreen).toHaveBeenCalledTimes(1);
+        const panel = screen.getByText('Test Model').closest('.flex-col');
+        expect(requestFullscreen.mock.instances[0]).toBe(panel);
+      } finally {
+        delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+        delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+      }
+    });
+
+    it('fills the viewport as a fallback where the Fullscreen API is missing', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+      const viewer = await screen.findByTestId('model-viewer-area');
+      const panel = screen.getByText('Test Model').closest('.flex-col') as HTMLElement;
+      // Windowed: the size every preview shares (#2976), not a per-modal max-w.
+      expect(panel.className).toContain('w-[min(1800px,96vw)]');
+
+      fireEvent.doubleClick(viewer);
+
+      expect(panel.className).toContain('max-w-none');
+      expect(screen.getByTitle('Exit fullscreen')).toBeInTheDocument();
+    });
   });
 
   describe('object count', () => {

+ 347 - 7
frontend/src/__tests__/components/PdfPreviewModal.test.tsx

@@ -3,25 +3,24 @@
  *
  * pdf.js cannot rasterise inside jsdom (no real canvas), so the library is
  * mocked at the module boundary; the tests cover the modal's own logic —
- * loading, page navigation, and error/size fallbacks.
+ * loading, page navigation, error/size fallbacks, zoom input and fullscreen.
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { PdfPreviewModal } from '../../components/PdfPreviewModal';
 
 const pdfjsMocks = vi.hoisted(() => {
   const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
-  const getPage = vi.fn(async () => ({
-    getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
-    render,
-  }));
+  const defaultViewport = ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale });
+  const getViewport = vi.fn(defaultViewport);
+  const getPage = vi.fn(async () => ({ getViewport, render }));
   const getDocument = vi.fn(() => ({
     promise: Promise.resolve({ numPages: 3, getPage }),
     destroy: vi.fn(),
   }));
-  return { render, getPage, getDocument };
+  return { render, defaultViewport, getViewport, getPage, getDocument };
 });
 
 vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
@@ -52,13 +51,49 @@ function renderModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {})
   );
 }
 
+// jsdom lays nothing out, so the fit scale bottoms out at the 0.1 floor and
+// the 600pt page is shown 60px wide at zoom 1.
+const CSS_WIDTH_AT_ZOOM_1 = 60;
+
+async function renderLoadedModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
+  const utils = renderModal(props);
+  await screen.findByText('Page 1 of 3');
+  await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+  const page = screen.getByTestId('pdf-preview-page');
+  const canvas = page.querySelector('canvas') as HTMLCanvasElement;
+  await waitFor(() => expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`));
+  return { ...utils, page, canvas };
+}
+
+function lastRenderScale(): number {
+  const calls = pdfjsMocks.getViewport.mock.calls;
+  return calls[calls.length - 1][0].scale;
+}
+
+// A wheel event with ctrlKey, as Ctrl+wheel and a trackpad pinch both arrive.
+function wheelWithCtrl(target: Element, deltaY: number) {
+  return fireEvent.wheel(target, { deltaY, ctrlKey: true, clientX: 30, clientY: 40 });
+}
+
 describe('PdfPreviewModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    // clearAllMocks keeps implementations, but a test that swaps the page
+    // geometry would otherwise leak it into every test after it.
+    pdfjsMocks.getViewport.mockImplementation(pdfjsMocks.defaultViewport);
     vi.stubGlobal(
       'fetch',
       vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
     );
+    // The setup-file stub is wiped by unstubAllGlobals below; keep one here.
+    vi.stubGlobal(
+      'ResizeObserver',
+      class {
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
   });
 
   afterEach(() => {
@@ -92,6 +127,40 @@ describe('PdfPreviewModal', () => {
     expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
   });
 
+  it('points pdf.js at the resources it fetches at runtime', async () => {
+    renderModal();
+    await screen.findByText('Page 1 of 3');
+
+    // Unset, CJK text has no CMaps, non-embedded fonts no font data, and the
+    // JPEG2000/JBIG2/ICC decoders no wasm — all of which fail silently.
+    expect(pdfjsMocks.getDocument).toHaveBeenCalledWith(
+      expect.objectContaining({
+        cMapUrl: '/assets/pdfjs/cmaps/',
+        iccUrl: '/assets/pdfjs/iccs/',
+        standardFontDataUrl: '/assets/pdfjs/standard_fonts/',
+        wasmUrl: '/assets/pdfjs/wasm/',
+      }),
+    );
+  });
+
+  it('destroys a loading task that resolved after the modal closed', async () => {
+    // Closing during the fetch/import window used to leave cleanup holding a
+    // null task, and the pdf.js worker it later started ran on forever.
+    let deliver: (response: Response) => void = () => {};
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(() => new Promise<Response>((resolve) => (deliver = resolve))),
+    );
+    const destroy = vi.fn();
+    pdfjsMocks.getDocument.mockReturnValueOnce({ promise: new Promise(() => {}), destroy } as never);
+
+    const { unmount } = renderModal();
+    unmount();
+    deliver(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
+
+    await waitFor(() => expect(destroy).toHaveBeenCalledTimes(1));
+  });
+
   it('refuses oversized files without fetching them', async () => {
     const fetchSpy = vi.fn();
     vi.stubGlobal('fetch', fetchSpy);
@@ -100,4 +169,275 @@ describe('PdfPreviewModal', () => {
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(fetchSpy).not.toHaveBeenCalled();
   });
+
+  describe('zoom', () => {
+    it('zooms on Ctrl+wheel and keeps the browser from zooming the page', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      const scaleBefore = lastRenderScale();
+
+      const notPrevented = wheelWithCtrl(page, -100);
+
+      expect(notPrevented).toBe(false);
+      // Immediate feedback: the raster is scaled by CSS by exactly one step.
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+      // Then the page is re-rasterised at the new scale once the wheel settles.
+      await waitFor(() => expect(lastRenderScale()).toBeCloseTo(scaleBefore * 1.25, 5));
+      expect(pdfjsMocks.render).toHaveBeenCalledTimes(2);
+    });
+
+    it('zooms out on Ctrl+wheel down', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      wheelWithCtrl(page, 100);
+
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
+    });
+
+    it('turns a pinch (many small ctrlKey deltas) into a smooth ramp', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      for (let i = 0; i < 10; i++) wheelWithCtrl(page, -3);
+
+      const width = parseFloat(canvas.style.width);
+      expect(width).toBeGreaterThan(CSS_WIDTH_AT_ZOOM_1);
+      // Ten pixels of pinch is one wheel notch (30px clamp → one step), not ten.
+      expect(width).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+    });
+
+    it('clamps to the zoom range', async () => {
+      const { page, canvas } = await renderLoadedModal();
+
+      for (let i = 0; i < 40; i++) wheelWithCtrl(page, -100);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 4, 5);
+
+      for (let i = 0; i < 40; i++) wheelWithCtrl(page, 100);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 0.4, 5);
+    });
+
+    it('zooms on a plain wheel while the page fits the viewport', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      // jsdom reports every scroll metric as 0: nothing to scroll.
+      const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
+
+      expect(notPrevented).toBe(false);
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+    });
+
+    it('lets a plain wheel scroll once the page overflows the viewport', async () => {
+      const { page, canvas } = await renderLoadedModal();
+      Object.defineProperty(page, 'scrollHeight', { configurable: true, value: 2000 });
+      Object.defineProperty(page, 'clientHeight', { configurable: true, value: 500 });
+
+      const notPrevented = fireEvent.wheel(page, { deltaY: -100 });
+
+      expect(notPrevented).toBe(true);
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('zooms with the keyboard: + and - step, 0 resets', async () => {
+      const { canvas } = await renderLoadedModal();
+
+      fireEvent.keyDown(window, { key: '+' });
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+
+      fireEvent.keyDown(window, { key: '-' });
+      fireEvent.keyDown(window, { key: '-' });
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 / 1.25, 5);
+
+      fireEvent.keyDown(window, { key: '0' });
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('leaves Ctrl+plus / Ctrl+0 to the browser', async () => {
+      const { canvas } = await renderLoadedModal();
+
+      fireEvent.keyDown(window, { key: '+', ctrlKey: true });
+      fireEvent.keyDown(window, { key: '0', metaKey: true });
+
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+
+    it('pinch-zooms with two touch pointers', async () => {
+      // jsdom has no PointerEvent, and testing-library then falls back to a
+      // plain Event without pointerId / pointerType.
+      class FakePointerEvent extends MouseEvent {
+        pointerId: number;
+        pointerType: string;
+        constructor(type: string, init: PointerEventInit = {}) {
+          super(type, init);
+          this.pointerId = init.pointerId ?? 0;
+          this.pointerType = init.pointerType ?? '';
+        }
+      }
+      Object.defineProperty(window, 'PointerEvent', { configurable: true, value: FakePointerEvent });
+      const { page, canvas } = await renderLoadedModal();
+
+      fireEvent.pointerDown(page, { pointerId: 1, pointerType: 'touch', clientX: 100, clientY: 100 });
+      fireEvent.pointerDown(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
+      // First move sets the reference distance, the second one spreads it.
+      fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 200, clientY: 100 });
+      fireEvent.pointerMove(page, { pointerId: 2, pointerType: 'touch', clientX: 300, clientY: 100 });
+
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 2, 5);
+
+      fireEvent.pointerUp(page, { pointerId: 2, pointerType: 'touch' });
+      fireEvent.pointerUp(page, { pointerId: 1, pointerType: 'touch' });
+      delete (window as { PointerEvent?: unknown }).PointerEvent;
+    });
+
+    it('still offers the zoom buttons', async () => {
+      const user = userEvent.setup();
+      const { canvas } = await renderLoadedModal();
+
+      await user.click(screen.getByRole('button', { name: 'Zoom in' }));
+      expect(parseFloat(canvas.style.width)).toBeCloseTo(CSS_WIDTH_AT_ZOOM_1 * 1.25, 5);
+
+      await user.click(screen.getByRole('button', { name: 'Zoom out' }));
+      expect(canvas.style.width).toBe(`${CSS_WIDTH_AT_ZOOM_1}px`);
+    });
+  });
+
+  describe('fullscreen', () => {
+    const requestFullscreen = vi.fn();
+    const exitFullscreen = vi.fn();
+
+    beforeEach(() => {
+      requestFullscreen.mockReset().mockResolvedValue(undefined);
+      exitFullscreen.mockReset().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+      Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+    });
+
+    afterEach(() => {
+      delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+      delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+      delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+      delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+    });
+
+    // The browser flips fullscreenElement and fires fullscreenchange; the
+    // mocks do neither, so the test plays the browser's part.
+    function enterFullscreen(panel: Element) {
+      (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+      act(() => {
+        document.dispatchEvent(new Event('fullscreenchange'));
+      });
+    }
+
+    it('double-click on the page toggles fullscreen', async () => {
+      const { page } = await renderLoadedModal();
+      const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(page);
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+      expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+      enterFullscreen(panel);
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      fireEvent.doubleClick(page);
+      expect(exitFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('offers a fullscreen button in the header', async () => {
+      const user = userEvent.setup();
+      await renderLoadedModal();
+
+      await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('re-syncs when the browser leaves fullscreen on Esc', async () => {
+      const { page } = await renderLoadedModal();
+      const panel = screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(page);
+      enterFullscreen(panel);
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      // Esc while fullscreen is the browser's: the modal must not close.
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).not.toHaveBeenCalled();
+
+      enterFullscreen(null as unknown as Element);
+      expect(screen.getByRole('button', { name: 'Fullscreen' })).toBeInTheDocument();
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('grid thumbnail snapshot', () => {
+    // jsdom has no 2D backend: the snapshot helper draws into an offscreen
+    // canvas and hands the result to toBlob, so both have to be stood in for.
+    function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
+      const context = { fillStyle: '', fillRect: vi.fn(), drawImage: vi.fn() };
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
+        context as unknown as CanvasRenderingContext2D,
+      );
+      vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
+      return context;
+    }
+
+    afterEach(() => {
+      vi.restoreAllMocks();
+    });
+
+    it('hands the first page to onSnapshot as a PNG', async () => {
+      stubCanvas2d();
+      const onSnapshot = vi.fn();
+
+      await renderLoadedModal({ onSnapshot });
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+      const blob = onSnapshot.mock.calls[0][0] as Blob;
+      expect(blob.type).toBe('image/png');
+    });
+
+    it('sends it once, not on every re-render of page 1', async () => {
+      stubCanvas2d();
+      const user = userEvent.setup();
+      const onSnapshot = vi.fn();
+      await renderLoadedModal({ onSnapshot });
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+
+      await user.click(screen.getByRole('button', { name: 'Next page' }));
+      await screen.findByText('Page 2 of 3');
+      await user.click(screen.getByRole('button', { name: 'Previous page' }));
+      await screen.findByText('Page 1 of 3');
+
+      expect(onSnapshot).toHaveBeenCalledTimes(1);
+    });
+
+    it('stays quiet when the canvas yields no blob', async () => {
+      stubCanvas2d(null);
+      const onSnapshot = vi.fn();
+
+      await renderLoadedModal({ onSnapshot });
+      await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+
+      expect(onSnapshot).not.toHaveBeenCalled();
+    });
+  });
+
+  it('keeps the raster inside the canvas area iOS Safari will back', async () => {
+    // Past roughly 16.7M pixels iOS Safari hands back a blank canvas instead
+    // of failing, so resolution is what gives way, not the page.
+    vi.stubGlobal('devicePixelRatio', 2);
+    pdfjsMocks.getViewport.mockImplementation(({ scale }: { scale: number }) => ({
+      width: 30000 * scale,
+      height: 30000 * scale,
+    }));
+
+    renderModal();
+    await screen.findByText('Page 1 of 3');
+    await waitFor(() => expect(pdfjsMocks.render).toHaveBeenCalled());
+
+    // Unclamped this would be 0.1 (fit floor) x 2 (dpr) = 6000 x 6000 px.
+    const scale = lastRenderScale();
+    expect(scale).toBeLessThan(0.2);
+    expect(30000 * scale * (30000 * scale)).toBeLessThanOrEqual(16 * 1024 * 1024);
+  });
 });

+ 140 - 0
frontend/src/__tests__/components/PreviewModalShell.test.tsx

@@ -0,0 +1,140 @@
+/**
+ * Tests for PreviewModalShell (#2976) — the backdrop, panel size and header
+ * every file preview shares.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { PreviewModalShell } from '../../components/PreviewModalShell';
+import { usePreviewFullscreen } from '../../hooks/usePreviewFullscreen';
+
+const mockOnClose = vi.fn();
+
+function Harness({ closeOnBackdropClick }: { closeOnBackdropClick?: boolean }) {
+  const fullscreen = usePreviewFullscreen();
+  return (
+    <PreviewModalShell
+      title="drawing.pdf"
+      fullscreen={fullscreen}
+      onClose={mockOnClose}
+      closeOnBackdropClick={closeOnBackdropClick}
+      actions={<button type="button">Zoom in</button>}
+    >
+      <div data-testid="preview-body" onDoubleClick={fullscreen.toggleFullscreen}>
+        body
+      </div>
+    </PreviewModalShell>
+  );
+}
+
+function panelOf(): HTMLElement {
+  return screen.getByText('drawing.pdf').closest('.flex-col') as HTMLElement;
+}
+
+describe('PreviewModalShell', () => {
+  const requestFullscreen = vi.fn();
+  const exitFullscreen = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    requestFullscreen.mockResolvedValue(undefined);
+    exitFullscreen.mockResolvedValue(undefined);
+    Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+    Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+    Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+    Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+  });
+
+  afterEach(() => {
+    delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+    delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+    delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+    delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+  });
+
+  // The browser flips fullscreenElement and fires fullscreenchange; the mocks
+  // do neither, so the test plays the browser's part.
+  function browserEntersFullscreen(panel: Element | null) {
+    (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+    act(() => {
+      document.dispatchEvent(new Event('fullscreenchange'));
+    });
+  }
+
+  it('gives the panel the size every preview shares', () => {
+    render(<Harness />);
+    const panel = panelOf();
+
+    expect(panel.className).toContain('w-[min(1800px,96vw)]');
+    expect(panel.className).toContain('h-[94vh]');
+    expect(panel.className).toContain('rounded-lg');
+  });
+
+  it('renders the title, the preview actions and the close button', async () => {
+    const user = userEvent.setup();
+    render(<Harness />);
+
+    expect(screen.getByText('drawing.pdf')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Zoom in' })).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'Close' }));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('goes fullscreen on a double-click in the body and fills the screen', () => {
+    render(<Harness />);
+    const panel = panelOf();
+
+    fireEvent.doubleClick(screen.getByTestId('preview-body'));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+    browserEntersFullscreen(panel);
+    expect(panel.className).toContain('max-w-none');
+    expect(panel.className).not.toContain('w-[min(1800px,96vw)]');
+    expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+  });
+
+  it('toggles fullscreen from the header button', async () => {
+    const user = userEvent.setup();
+    render(<Harness />);
+
+    await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+
+    browserEntersFullscreen(panelOf());
+    await user.click(screen.getByRole('button', { name: 'Exit fullscreen' }));
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+  });
+
+  it('closes on Escape, but leaves it to the browser while fullscreen', () => {
+    render(<Harness />);
+
+    browserEntersFullscreen(panelOf());
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).not.toHaveBeenCalled();
+
+    browserEntersFullscreen(null);
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('closes on a backdrop click only where the preview asks for it', async () => {
+    const user = userEvent.setup();
+    const { unmount } = render(<Harness />);
+    const backdrop = panelOf().parentElement as HTMLElement;
+
+    await user.click(backdrop);
+    expect(mockOnClose).not.toHaveBeenCalled();
+    unmount();
+
+    render(<Harness closeOnBackdropClick />);
+    await user.click(panelOf().parentElement as HTMLElement);
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+
+    // A click inside the panel is not a backdrop click.
+    await user.click(screen.getByTestId('preview-body'));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+});

+ 125 - 1
frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx

@@ -7,7 +7,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as XLSX from 'xlsx';
 import { SpreadsheetPreviewModal } from '../../components/SpreadsheetPreviewModal';
@@ -111,4 +111,128 @@ describe('SpreadsheetPreviewModal', () => {
     expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
     expect(fetchSpy).not.toHaveBeenCalled();
   });
+
+  describe('fullscreen', () => {
+    const requestFullscreen = vi.fn();
+    const exitFullscreen = vi.fn();
+
+    beforeEach(() => {
+      requestFullscreen.mockReset().mockResolvedValue(undefined);
+      exitFullscreen.mockReset().mockResolvedValue(undefined);
+      Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+      Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+      Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+      Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+    });
+
+    afterEach(() => {
+      delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+      delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+      delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+      delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+    });
+
+    it('double-click on the sheet toggles fullscreen', async () => {
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      renderModal();
+      await screen.findByText('M3 screw');
+      const content = screen.getByTestId('spreadsheet-preview-content');
+      const panel = screen.getByText('parts.csv').closest('.flex-col') as HTMLElement;
+
+      fireEvent.doubleClick(content);
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+      expect(requestFullscreen.mock.instances[0]).toBe(panel);
+
+      // The mocks do not flip fullscreenElement or fire fullscreenchange;
+      // play the browser's part.
+      (document as { fullscreenElement: Element | null }).fullscreenElement = panel;
+      act(() => {
+        document.dispatchEvent(new Event('fullscreenchange'));
+      });
+      expect(screen.getByRole('button', { name: 'Exit fullscreen' })).toBeInTheDocument();
+
+      fireEvent.doubleClick(content);
+      expect(exitFullscreen).toHaveBeenCalledTimes(1);
+    });
+
+    it('offers a fullscreen button in the header', async () => {
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      const user = userEvent.setup();
+      renderModal();
+      await screen.findByText('M3 screw');
+
+      await user.click(screen.getByRole('button', { name: 'Fullscreen' }));
+      expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('grid thumbnail snapshot', () => {
+    // The snapshot is drawn into an offscreen canvas, which jsdom cannot
+    // back: both the 2D context and toBlob are stood in for.
+    function stubCanvas2d(blob: Blob | null = new Blob(['png'], { type: 'image/png' })) {
+      const context = {
+        fillStyle: '',
+        strokeStyle: '',
+        lineWidth: 0,
+        font: '',
+        textBaseline: '',
+        fillRect: vi.fn(),
+        beginPath: vi.fn(),
+        moveTo: vi.fn(),
+        lineTo: vi.fn(),
+        stroke: vi.fn(),
+        fillText: vi.fn(),
+      };
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
+        context as unknown as CanvasRenderingContext2D,
+      );
+      vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => callback(blob));
+      return context;
+    }
+
+    afterEach(() => {
+      vi.restoreAllMocks();
+    });
+
+    it('hands the first sheet to onSnapshot as a PNG', async () => {
+      const context = stubCanvas2d();
+      stubFetchWith('Article,Qty\nM3 screw,12\n');
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot });
+      await screen.findByText('M3 screw');
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+      expect((onSnapshot.mock.calls[0][0] as Blob).type).toBe('image/png');
+      // The mini table is what makes it recognisable in the grid.
+      expect(context.fillText).toHaveBeenCalledWith('M3 screw', expect.any(Number), expect.any(Number), expect.any(Number));
+    });
+
+    it('draws the first sheet that has rows, not an empty leading one', async () => {
+      stubCanvas2d();
+      const workbook = XLSX.utils.book_new();
+      XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([]), 'Cover');
+      XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['Part', 'Qty'], ['Hinge', '2']]), 'Bom');
+      stubFetchWith(XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer);
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot, filename: 'bom.xlsx', fileType: 'xlsx' });
+      // The empty Cover sheet opens first, so the tab strip is what says the
+      // workbook has parsed.
+      await screen.findByRole('button', { name: 'Bom' });
+
+      await waitFor(() => expect(onSnapshot).toHaveBeenCalledTimes(1));
+    });
+
+    it('stays quiet for a sheet with no rows at all', async () => {
+      stubCanvas2d();
+      stubFetchWith('');
+      const onSnapshot = vi.fn();
+
+      renderModal({ onSnapshot });
+      await screen.findByText('This sheet is empty');
+
+      expect(onSnapshot).not.toHaveBeenCalled();
+    });
+  });
 });

+ 127 - 0
frontend/src/__tests__/hooks/useElementFullscreen.test.tsx

@@ -0,0 +1,127 @@
+/**
+ * useElementFullscreen (#2976): Fullscreen API where the browser grants it,
+ * the viewport-filling fallback everywhere else.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useRef } from 'react';
+import { useElementFullscreen } from '../../hooks/useElementFullscreen';
+
+function Panel() {
+  const ref = useRef<HTMLDivElement>(null);
+  const { isFullscreen, toggleFullscreen, apiAvailable } = useElementFullscreen(ref);
+  return (
+    <div ref={ref} data-testid="panel" data-fullscreen={String(isFullscreen)} data-api={String(apiAvailable)}>
+      <button onClick={toggleFullscreen}>toggle</button>
+    </div>
+  );
+}
+
+const requestFullscreen = vi.fn();
+const exitFullscreen = vi.fn();
+
+function installApi() {
+  Object.defineProperty(document, 'fullscreenEnabled', { configurable: true, value: true });
+  Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null, writable: true });
+  Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exitFullscreen });
+  Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', { configurable: true, value: requestFullscreen });
+}
+
+function removeApi() {
+  delete (document as { fullscreenEnabled?: boolean }).fullscreenEnabled;
+  delete (document as { fullscreenElement?: Element | null }).fullscreenElement;
+  delete (document as { exitFullscreen?: () => Promise<void> }).exitFullscreen;
+  delete (HTMLElement.prototype as { requestFullscreen?: () => Promise<void> }).requestFullscreen;
+}
+
+// The mocks neither flip fullscreenElement nor fire fullscreenchange, so the
+// tests play the browser's part.
+function browserSetsFullscreen(element: Element | null) {
+  (document as { fullscreenElement: Element | null }).fullscreenElement = element;
+  act(() => {
+    document.dispatchEvent(new Event('fullscreenchange'));
+  });
+}
+
+describe('useElementFullscreen', () => {
+  beforeEach(() => {
+    requestFullscreen.mockReset().mockResolvedValue(undefined);
+    exitFullscreen.mockReset().mockResolvedValue(undefined);
+  });
+
+  afterEach(() => {
+    removeApi();
+  });
+
+  it('requests fullscreen on the element and follows fullscreenchange', async () => {
+    installApi();
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+    expect(panel.dataset.api).toBe('true');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(requestFullscreen).toHaveBeenCalledTimes(1);
+    expect(requestFullscreen.mock.instances[0]).toBe(panel);
+    // Nothing changes until the browser confirms.
+    expect(panel.dataset.fullscreen).toBe('false');
+
+    browserSetsFullscreen(panel);
+    expect(panel.dataset.fullscreen).toBe('true');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+
+    browserSetsFullscreen(null);
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('ignores another element going fullscreen', () => {
+    installApi();
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    browserSetsFullscreen(document.createElement('video'));
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('falls back to the viewport-filling layout when the request is refused', async () => {
+    installApi();
+    requestFullscreen.mockRejectedValue(new TypeError('Permissions check failed'));
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    await act(async () => {
+      fireEvent.click(screen.getByText('toggle'));
+    });
+    expect(panel.dataset.fullscreen).toBe('true');
+
+    // Leaving the fallback never calls the API.
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('false');
+    expect(exitFullscreen).not.toHaveBeenCalled();
+  });
+
+  it('falls back where the Fullscreen API is missing', () => {
+    render(<Panel />);
+    const panel = screen.getByTestId('panel');
+    expect(panel.dataset.api).toBe('false');
+
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('true');
+    fireEvent.click(screen.getByText('toggle'));
+    expect(panel.dataset.fullscreen).toBe('false');
+  });
+
+  it('leaves fullscreen when the panel unmounts while fullscreen', () => {
+    installApi();
+    const { unmount } = render(<Panel />);
+    const panel = screen.getByTestId('panel');
+
+    fireEvent.click(screen.getByText('toggle'));
+    browserSetsFullscreen(panel);
+
+    unmount();
+    expect(exitFullscreen).toHaveBeenCalledTimes(1);
+  });
+});

+ 2 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -468,6 +468,8 @@ describe('useWebSocket hook', () => {
       // #2988: without this key the supplier broadcast reached nothing, so a
       // supplier created in one tab never showed up in another.
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-suppliers'] });
+      // The per-material-number aggregate is derived from the same rows (#2870).
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['material-number-stats'] });
 
       vi.useRealTimers();
       vi.unstubAllGlobals();

+ 80 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -600,6 +600,86 @@ describe('FileManagerPage', () => {
         expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
       });
     });
+
+    // Since #2976 the server renders PDF thumbnails too, so the per-file
+    // action is offered for PDFs and stays hidden for types it cannot render.
+    describe('per-file action for PDFs', () => {
+      const pdfFile = {
+        id: 40,
+        filename: 'drawing.pdf',
+        file_path: '/library/drawing.pdf',
+        file_size: 4096,
+        file_type: 'pdf',
+        folder_id: null,
+        thumbnail_path: null,
+        print_name: null,
+        print_time_seconds: null,
+        print_count: 0,
+        duplicate_count: 0,
+        created_at: '2024-01-04T00:00:00Z',
+      };
+      const stepFile = { ...pdfFile, id: 41, filename: 'part.step', file_path: '/library/part.step', file_type: 'step' };
+
+      beforeEach(() => {
+        server.use(
+          http.get('/api/v1/library/files', () => HttpResponse.json([...mockFiles, pdfFile, stepFile])),
+        );
+      });
+
+      const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
+        const card = screen.getByText(filename).closest('.group') as HTMLElement;
+        const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+        await user.click(kebab);
+        return card;
+      };
+
+      it('offers Generate Thumbnail in the card menu of a PDF', async () => {
+        const user = userEvent.setup();
+        server.use(
+          http.post('/api/v1/library/generate-stl-thumbnails', async ({ request }) => {
+            const body = (await request.json()) as { file_ids?: number[] };
+            return HttpResponse.json({
+              processed: 1,
+              succeeded: 1,
+              failed: 0,
+              results: [{ file_id: body.file_ids?.[0], success: true }],
+            });
+          })
+        );
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
+
+        const card = await openMenu(user, 'drawing.pdf');
+        await user.click(within(card).getByText('Generate Thumbnail'));
+
+        expect(await screen.findByText('Thumbnail generated')).toBeInTheDocument();
+      });
+
+      it('does not offer it for STEP, which only the browser can render', async () => {
+        const user = userEvent.setup();
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('part.step')).toBeInTheDocument());
+
+        const card = await openMenu(user, 'part.step');
+        expect(within(card).queryByText('Generate Thumbnail')).not.toBeInTheDocument();
+      });
+
+      it('offers the action in the list view strip of a PDF', async () => {
+        const user = userEvent.setup();
+        render(<FileManagerPage />);
+        await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
+
+        await user.click(screen.getByRole('button', { name: /list/i }));
+
+        // List rows are CSS grids; the row is the nearest grid ancestor.
+        await waitFor(() => {
+          const row = screen.getByText('drawing.pdf').closest('.grid') as HTMLElement;
+          expect(within(row).getByTitle('Generate Thumbnail')).toBeInTheDocument();
+        });
+        const stepRow = screen.getByText('part.step').closest('.grid') as HTMLElement;
+        expect(within(stepRow).queryByTitle('Generate Thumbnail')).not.toBeInTheDocument();
+      });
+    });
   });
 
   describe('upload modal (FileUploadModal)', () => {

+ 335 - 0
frontend/src/__tests__/pages/FileManagerPreviewOpening.test.tsx

@@ -0,0 +1,335 @@
+/**
+ * How a preview is opened from the File Manager (#2976): double-click in the
+ * grid and the list, the toolbar's Preview button, and the per-file menu.
+ *
+ * The preview modals themselves are stubbed — pdf.js, SheetJS and three.js
+ * have their own tests and none of them render in jsdom.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { server } from '../mocks/server';
+
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('react-router-dom')>()),
+  useNavigate: () => mockNavigate,
+}));
+
+vi.mock('../../components/ModelViewerModal', () => ({
+  ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
+}));
+vi.mock('../../components/PdfPreviewModal', () => ({
+  PdfPreviewModal: ({ filename }: { filename: string }) => <div data-testid="pdf-preview-modal">{filename}</div>,
+}));
+vi.mock('../../components/SpreadsheetPreviewModal', () => ({
+  SpreadsheetPreviewModal: ({ filename }: { filename: string }) => (
+    <div data-testid="sheet-preview-modal">{filename}</div>
+  ),
+}));
+vi.mock('../../components/ImagePreviewModal', () => ({
+  ImagePreviewModal: ({ filename }: { filename: string }) => <div data-testid="image-preview-modal">{filename}</div>,
+}));
+vi.mock('../../components/LibraryFileDetailsModal', () => ({
+  LibraryFileDetailsModal: () => <div data-testid="details-modal" />,
+}));
+
+function libraryFile(overrides: Record<string, unknown>) {
+  return {
+    file_path: '/library/file',
+    file_size: 4096,
+    folder_id: null,
+    thumbnail_path: null,
+    print_name: null,
+    print_time_seconds: null,
+    print_count: 0,
+    duplicate_count: 0,
+    created_at: '2026-01-01T00:00:00Z',
+    ...overrides,
+  };
+}
+
+const mockFiles = [
+  libraryFile({ id: 1, filename: 'benchy.gcode.3mf', file_type: 'gcode.3mf' }),
+  libraryFile({ id: 2, filename: 'bracket.stl', file_type: 'stl' }),
+  libraryFile({ id: 3, filename: 'drawing.pdf', file_type: 'pdf' }),
+  libraryFile({ id: 4, filename: 'parts.csv', file_type: 'csv' }),
+  libraryFile({
+    id: 5,
+    filename: 'photo.png',
+    file_type: 'png',
+    tags: [{ id: 21, name: 'reference', color: '#00ae42' }],
+    has_notes: true,
+  }),
+  libraryFile({ id: 6, filename: 'notes.md', file_type: 'md' }),
+  libraryFile({ id: 7, filename: 'scan.tif', file_type: 'tif' }),
+];
+
+function card(name: string): HTMLElement {
+  return screen.getByText(name).closest('div.group') as HTMLElement;
+}
+
+function row(name: string): HTMLElement {
+  return screen.getByText(name).closest('div[class*="grid-cols-"]') as HTMLElement;
+}
+
+/**
+ * The preview modals load lazily, so one opened by mistake renders a moment
+ * after the event that opened it: a few hundred milliseconds for the first
+ * one in a run. Absence only counts once it has had findBy's full second to
+ * appear; checking straight away passed whenever no earlier test had loaded
+ * that modal yet.
+ */
+async function expectNoPreviewOpened() {
+  await expect(screen.findByTestId(/^(model-viewer|pdf-preview|sheet-preview|image-preview)-modal$/)).rejects.toThrow();
+}
+
+describe('FileManagerPage preview opening', () => {
+  beforeEach(() => {
+    mockNavigate.mockClear();
+    // localStorage is a module-global vi.fn mock (see __tests__/setup.ts), so
+    // the view mode is programmed rather than written.
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json([])),
+      http.get('/api/v1/library/files', () => HttpResponse.json(mockFiles)),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: mockFiles.length,
+          total_folders: 0,
+          total_size_bytes: 1024,
+          disk_free_bytes: 1024 * 1024,
+          disk_total_bytes: 2048 * 1024,
+        }),
+      ),
+      http.get('/api/v1/settings/', () => HttpResponse.json({ check_updates: false })),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/', () => HttpResponse.json([])),
+    );
+  });
+
+  describe('double-click in the grid', () => {
+    it('opens the image preview for a PNG and leaves the selection alone', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.dblClick(card('photo.png'));
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+      // The two clicks of a double-click toggle the selection twice.
+      expect(screen.queryByText('1 selected')).not.toBeInTheDocument();
+    });
+
+    it('opens the 3D viewer for an STL', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('bracket.stl');
+
+      await user.dblClick(card('bracket.stl'));
+
+      expect(await screen.findByTestId('model-viewer-modal')).toHaveTextContent('bracket.stl');
+    });
+
+    it('sends a sliced file to the gcode viewer route', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('benchy.gcode.3mf');
+
+      await user.dblClick(card('benchy.gcode.3mf'));
+
+      expect(mockNavigate).toHaveBeenCalledWith('/gcode-viewer?library_file=1');
+    });
+
+    it('does nothing for a file with no preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('notes.md');
+
+      await user.dblClick(card('notes.md'));
+
+      await expectNoPreviewOpened();
+      expect(mockNavigate).not.toHaveBeenCalled();
+    });
+
+    // The card's own controls are not "the row": stopping their click is not
+    // enough, because dblclick is a separate native event (#2976).
+    it('ignores a double-click on the card menu button', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      const imageCard = card('photo.png');
+      const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.dblClick(kebab);
+
+      await expectNoPreviewOpened();
+    });
+
+    // The chip's own click toggles the tag filter and re-renders the list, so
+    // the dblclick is fired directly: what is under test is whether it bubbles
+    // to the card, not what the two clicks before it did.
+    it('ignores a double-click on a tag chip', async () => {
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      fireEvent.doubleClick(within(card('photo.png')).getByTitle('reference'));
+
+      await expectNoPreviewOpened();
+    });
+
+    // The notes and photos indicators open the file's details; a double-click
+    // on one must not open the preview on top of them.
+    it('ignores a double-click on the notes indicator', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.dblClick(within(card('photo.png')).getByTitle('Has notes'));
+
+      expect(await screen.findByTestId('details-modal')).toBeInTheDocument();
+      await expectNoPreviewOpened();
+    });
+  });
+
+  describe('the list view', () => {
+    beforeEach(() => {
+      (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+        key === 'library-view-mode' ? 'list' : null,
+      );
+    });
+
+    it('opens the document preview for a PDF', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('drawing.pdf');
+
+      await user.dblClick(row('drawing.pdf'));
+
+      expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
+    });
+
+    it('opens the spreadsheet preview for a CSV', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('parts.csv');
+
+      await user.dblClick(row('parts.csv'));
+
+      expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
+    });
+
+    it('offers an image file the same action-strip preview button as a document', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(within(row('photo.png')).getByTitle('Preview'));
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+    });
+
+    it('ignores a double-click on the row action strip', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      // An impatient double-tap on Rename must not also open the preview.
+      await user.dblClick(within(row('photo.png')).getByTitle('Rename'));
+
+      await expectNoPreviewOpened();
+    });
+
+    it('ignores a double-click on the row tag cell', async () => {
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      fireEvent.doubleClick(within(row('photo.png')).getByTitle('reference'));
+
+      await expectNoPreviewOpened();
+    });
+  });
+
+  describe('the toolbar Preview button', () => {
+    it('appears for a single previewable selection and opens the preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(card('photo.png'));
+      const preview = await screen.findByRole('button', { name: 'Preview' });
+      await user.click(preview);
+
+      expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+    });
+
+    it('stays away for a multi-file selection', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('photo.png');
+
+      await user.click(card('photo.png'));
+      expect(await screen.findByRole('button', { name: 'Preview' })).toBeInTheDocument();
+
+      await user.click(card('drawing.pdf'));
+      await waitFor(() => expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument());
+    });
+
+    it('stays away for a file with no preview', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('notes.md');
+
+      await user.click(card('notes.md'));
+
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
+    });
+  });
+
+  // The server thumbnails TIFF (PIL), but an <img> only decodes it on Safari,
+  // so the preview is not offered rather than downloading 50 MB to fail (#2976).
+  describe('a TIFF file', () => {
+    it('gets no Preview entry in the card menu and no toolbar button', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('scan.tif');
+
+      await user.click(card('scan.tif'));
+      expect(await screen.findByText('1 selected')).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument();
+
+      const kebab = card('scan.tif').querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.click(kebab);
+      expect(within(card('scan.tif')).queryByText('Preview')).not.toBeInTheDocument();
+    });
+
+    it('does nothing on double-click', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+      await screen.findByText('scan.tif');
+
+      await user.dblClick(card('scan.tif'));
+
+      await expectNoPreviewOpened();
+      expect(mockNavigate).not.toHaveBeenCalled();
+    });
+  });
+
+  it('offers an image file a Preview entry in the card menu', async () => {
+    const user = userEvent.setup();
+    render(<FileManagerPage />);
+    await screen.findByText('photo.png');
+
+    const imageCard = card('photo.png');
+    const kebab = imageCard.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+    await user.click(kebab);
+    await user.click(within(imageCard).getByText('Preview'));
+
+    expect(await screen.findByTestId('image-preview-modal')).toHaveTextContent('photo.png');
+  });
+});

+ 141 - 0
frontend/src/__tests__/pages/InventoryPageMaterialNumberFilter.test.tsx

@@ -0,0 +1,141 @@
+/**
+ * The material-number filter chip (#2870).
+ *
+ * The chip needs one slot for "no number assigned". The other chips spell
+ * that '__none__', but a material number is free text, so '__none__' can be
+ * a real value — and then picking it filtered for the spools that have no
+ * number at all. The sentinel is now longer than the column's 64-character
+ * cap, so no spool can collide with it.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const BASE = {
+  material: 'PLA',
+  subtype: 'Basic',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  nozzle_temp_min: 220,
+  nozzle_temp_max: 240,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  storage_location: null,
+  category: null,
+  low_stock_threshold_pct: null,
+  weight_locked: false,
+};
+
+// One spool whose material number is literally the old sentinel, one with no
+// number at all. The two must never be confused for each other.
+const SPOOLS = [
+  { ...BASE, id: 1, brand: 'AlphaBrand', material_number: '__none__' },
+  { ...BASE, id: 2, brand: 'BetaBrand', material_number: null },
+];
+
+function setupHandlers() {
+  server.use(
+    http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD', low_stock_threshold: 20.0, language: 'en' })),
+    http.get('/api/v1/settings/spoolman', () =>
+      HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' })
+    ),
+    http.get('/api/v1/inventory/spools', () => HttpResponse.json(SPOOLS)),
+    http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/locations', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+  );
+}
+
+async function materialNumberSelect(): Promise<HTMLSelectElement> {
+  return waitFor(() => {
+    const found = screen
+      .getAllByRole('combobox')
+      .find((el) => el.querySelector('option[value=""]')?.textContent === 'Material No.');
+    if (!found) throw new Error('material number chip not rendered');
+    return found as HTMLSelectElement;
+  });
+}
+
+/** Brand names shown in the table body — the chips list brands too. */
+function rowBrands(): string[] {
+  return Array.from(document.querySelectorAll('tbody tr'))
+    .map((row) => row.textContent ?? '')
+    .flatMap((text) => ['AlphaBrand', 'BetaBrand'].filter((b) => text.includes(b)));
+}
+
+describe('InventoryPage material-number filter', () => {
+  beforeEach(() => {
+    setupHandlers();
+    vi.mocked(localStorage.getItem).mockReturnValue(null);
+  });
+
+  it('filters for the literal value "__none__" rather than for unnumbered spools', async () => {
+    render(<InventoryPageRouter />);
+    const select = await materialNumberSelect();
+    await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand', 'BetaBrand']));
+
+    fireEvent.change(select, { target: { value: '__none__' } });
+
+    // The spool whose number IS '__none__', not the one without a number.
+    await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand']));
+  });
+
+  it('still offers a slot that finds the spools with no number', async () => {
+    render(<InventoryPageRouter />);
+    const select = await materialNumberSelect();
+    await waitFor(() => expect(rowBrands()).toEqual(['AlphaBrand', 'BetaBrand']));
+
+    const noneOption = Array.from(select.options).find((o) => o.textContent === 'No material number');
+    expect(noneOption).toBeTruthy();
+    // The sentinel outruns SpoolBase's 64-character cap, so it is a value no
+    // spool can carry.
+    expect(noneOption!.value.length).toBeGreaterThan(64);
+
+    fireEvent.change(select, { target: { value: noneOption!.value } });
+
+    await waitFor(() => expect(rowBrands()).toEqual(['BetaBrand']));
+  });
+
+  it('sorts the Material No. column numerically, "2" before "15"', async () => {
+    server.use(
+      http.get('/api/v1/inventory/spools', () =>
+        HttpResponse.json([
+          { ...BASE, id: 1, brand: 'AlphaBrand', material_number: '15' },
+          { ...BASE, id: 2, brand: 'BetaBrand', material_number: '2' },
+        ])
+      ),
+    );
+    vi.mocked(localStorage.getItem).mockImplementation((key) =>
+      key === 'bambuddy-inventory-sort' ? '{"column":"material_number","direction":"asc"}' : null,
+    );
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => expect(rowBrands()).toEqual(['BetaBrand', 'AlphaBrand']));
+  });
+});

+ 56 - 0
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -357,4 +357,60 @@ describe('SpoolBuddyWriteTagPage', () => {
       expect(payload).not.toHaveProperty('core_weight_catalog_id');
     });
   });
+
+  // #2870: the form rendered a Material No. input whose value never made it
+  // into the create payload, so the spool was saved with an inherited number
+  // or none at all — silently, with no hint that the typed one was dropped.
+  async function openFullNewSpoolForm() {
+    const rendered = renderPage();
+    fireEvent.click(screen.getByText('New Spool'));
+    await waitFor(() => {
+      expect(screen.getByText('Material')).toBeDefined();
+    });
+    // The simple view's material select is the cheapest way to satisfy the
+    // only field validateForm insists on.
+    fireEvent.change(screen.getByRole('combobox'), { target: { value: 'PLA' } });
+    fireEvent.click(screen.getByText('Full'));
+    await waitFor(() => {
+      expect(screen.getByText('Quick Add')).toBeDefined();
+    });
+    return rendered;
+  }
+
+  it('sends the material number the operator typed (#2870)', async () => {
+    const { container } = await openFullNewSpoolForm();
+
+    // Quick Add so material is the only required field on this form.
+    const quickAddToggle = screen.getByText('Quick Add').parentElement?.querySelector('button');
+    expect(quickAddToggle).toBeTruthy();
+    fireEvent.click(quickAddToggle as HTMLElement);
+
+    const numberInput = container.querySelector('#spool-material-number') as HTMLInputElement;
+    expect(numberInput).toBeTruthy();
+    fireEvent.change(numberInput, { target: { value: '77' } });
+
+    fireEvent.click(screen.getByText('Create Spool'));
+
+    await waitFor(() => {
+      expect(vi.mocked(mockedApi.createSpool)).toHaveBeenCalled();
+    });
+    expect(vi.mocked(mockedApi.createSpool).mock.calls[0][0]).toEqual(
+      expect.objectContaining({ material_number: '77' }),
+    );
+  });
+
+  it('does not offer the material number in Spoolman mode (#2870)', async () => {
+    // There the number is Spoolman's filament-level article_number; an input
+    // the internal create path cannot store must not be shown.
+    vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({
+      spoolman_enabled: 'true',
+      spoolman_url: 'http://spoolman.test',
+      spoolman_sync_mode: '',
+      spoolman_disable_weight_sync: '',
+      spoolman_report_partial_usage: '',
+    });
+    const { container } = await openFullNewSpoolForm();
+
+    expect(container.querySelector('#spool-material-number')).toBeNull();
+  });
 });

+ 186 - 0
frontend/src/__tests__/pages/StatsPageMaterialNumbers.test.tsx

@@ -0,0 +1,186 @@
+/**
+ * The "By Material Number" widget on the stats dashboard (#2870).
+ *
+ * It sits in the same grid as the widgets that follow the dashboard
+ * timeframe, so its usage half has to follow it too — otherwise it shows
+ * lifetime totals next to cards headed "Last 30 days".
+ *
+ * In Spoolman mode it aggregates the internal spool table, which is empty
+ * there, while the inventory list does show numbers mapped from Spoolman's
+ * filament.article_number. A permanently empty card saying "no material
+ * numbers assigned yet" next to an inventory full of them is worse than no
+ * card, so the widget is dropped in that mode.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { StatsPage } from '../../pages/StatsPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const EMPTY_STATS = {
+  total_prints: 0,
+  successful_prints: 0,
+  failed_prints: 0,
+  cancelled_prints: 0,
+  total_print_time_hours: 0,
+  total_filament_grams: 0,
+  total_cost: 0,
+  prints_by_filament_type: {},
+  prints_by_printer: {},
+  average_time_accuracy: 0,
+  time_accuracy_by_printer: {},
+  total_energy_kwh: 0,
+  total_energy_cost: 0,
+};
+
+let materialNumberRequests: URL[] = [];
+
+function setupHandlers(spoolmanEnabled: boolean, spoolmanGate?: Promise<void>) {
+  server.use(
+    http.get('/api/v1/archives/stats', () => HttpResponse.json(EMPTY_STATS)),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+    http.get('/api/v1/archives/slim', () => HttpResponse.json([])),
+    http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD' })),
+    http.get('/api/v1/settings/spoolman', async () => {
+      // A gate lets a test hold the settings response back while the rest of
+      // the dashboard renders, which is the ordering the widget has to survive.
+      if (spoolmanGate) await spoolmanGate;
+      return HttpResponse.json({
+        spoolman_enabled: spoolmanEnabled ? 'true' : 'false',
+        spoolman_url: spoolmanEnabled ? 'http://spoolman.local' : '',
+      });
+    }),
+    http.get('/api/v1/archives/analysis/failures', () =>
+      HttpResponse.json({
+        period_days: 30,
+        total_prints: 0,
+        failed_prints: 0,
+        failure_rate: 0,
+        failures_by_reason: {},
+        failures_by_filament: {},
+        failures_by_printer: {},
+        failures_by_hour: {},
+        recent_failures: [],
+        trend: [],
+      })
+    ),
+    http.get('/api/v1/inventory/stats/material-numbers', ({ request }) => {
+      materialNumberRequests.push(new URL(request.url));
+      return HttpResponse.json([]);
+    })
+  );
+}
+
+/** The suite stubs localStorage with bare mocks, so feed the timeframe in. */
+function withTimeframe(preset: string) {
+  (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+    key === 'bambusy-stats-timeframe' ? JSON.stringify({ preset }) : null
+  );
+}
+
+describe('StatsPage material-number widget', () => {
+  beforeEach(() => {
+    materialNumberRequests = [];
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
+  });
+
+  afterEach(() => {
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
+  });
+
+  it('asks the endpoint for the dashboard timeframe', async () => {
+    withTimeframe('last-30');
+    setupHandlers(false);
+    render(<StatsPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('By Material Number')).toBeInTheDocument();
+    });
+    await waitFor(() => {
+      expect(materialNumberRequests.length).toBeGreaterThan(0);
+    });
+
+    const today = new Date();
+    const from = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 29));
+    expect(materialNumberRequests[0].searchParams.get('date_from')).toBe(from.toISOString().split('T')[0]);
+    expect(materialNumberRequests[0].searchParams.get('date_to')).toBe(today.toISOString().split('T')[0]);
+  });
+
+  it('asks for lifetime totals when the timeframe is all time', async () => {
+    withTimeframe('all-time');
+    setupHandlers(false);
+    render(<StatsPage />);
+
+    await waitFor(() => {
+      expect(materialNumberRequests.length).toBeGreaterThan(0);
+    });
+    expect(materialNumberRequests[0].searchParams.get('date_from')).toBeNull();
+    expect(materialNumberRequests[0].searchParams.get('date_to')).toBeNull();
+  });
+
+  it('drops the widget entirely in Spoolman mode', async () => {
+    setupHandlers(true);
+    render(<StatsPage />);
+
+    // Wait for the dashboard to be up before asserting on an absence.
+    await waitFor(() => {
+      expect(screen.getByText('Statistics')).toBeInTheDocument();
+    });
+    await waitFor(() => {
+      expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+    });
+
+    expect(screen.queryByText('By Material Number')).toBeNull();
+    expect(materialNumberRequests).toHaveLength(0);
+  });
+
+  // #2870: the mode decision has to wait for the settings response. Deriving
+  // it from `undefined` treats "not loaded yet" as "internal mode", so a
+  // Spoolman install flashed the card and fired the aggregate request before
+  // the setting arrived.
+  it('holds the widget back until the Spoolman setting has resolved', async () => {
+    let openGate = () => {};
+    const gate = new Promise<void>((resolve) => {
+      openGate = resolve;
+    });
+    setupHandlers(true, gate);
+    render(<StatsPage />);
+
+    // The dashboard is fully up on the archive response alone.
+    await waitFor(() => {
+      expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('By Material Number')).toBeNull();
+    expect(materialNumberRequests).toHaveLength(0);
+
+    openGate();
+    await waitFor(() => {
+      expect(screen.queryByText('By Material Number')).toBeNull();
+    });
+    expect(materialNumberRequests).toHaveLength(0);
+  });
+
+  it('shows the widget once the setting says this is not Spoolman mode', async () => {
+    let openGate = () => {};
+    const gate = new Promise<void>((resolve) => {
+      openGate = resolve;
+    });
+    setupHandlers(false, gate);
+    render(<StatsPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Filament Trends')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('By Material Number')).toBeNull();
+
+    openGate();
+    await waitFor(() => {
+      expect(screen.getByText('By Material Number')).toBeInTheDocument();
+    });
+    await waitFor(() => {
+      expect(materialNumberRequests.length).toBeGreaterThan(0);
+    });
+  });
+});

+ 21 - 0
frontend/src/api/client.ts

@@ -3658,6 +3658,9 @@ export interface InventorySpool {
   // User-defined category + per-spool low-stock threshold override (#729).
   category: string | null;
   low_stock_threshold_pct: number | null;
+  // Internal material / article number (#2870) — the purchasing identifier
+  // shared by all spools of the same product.
+  material_number: string | null;
   k_profiles?: SpoolKProfile[];
   storage_location?: string | null;
   location_id?: number | null;
@@ -3718,6 +3721,15 @@ export interface SupplierStats {
   cost: number;
 }
 
+/** Per-material-number inventory aggregate (#2870). */
+export interface MaterialNumberStats {
+  material_number: string;
+  spool_count: number;
+  remaining_g: number;
+  consumed_g: number;
+  cost: number;
+}
+
 export interface SpoolmanBulkCreateResult {
   created: InventorySpool[];
   requested_count: number;
@@ -6798,6 +6810,15 @@ export const api = {
       method: 'PATCH',
       body: JSON.stringify(data),
     }),
+  // Per-material-number inventory aggregate (#2870). The date range narrows
+  // the usage half only — stock is point-in-time.
+  getMaterialNumberStats: (options?: { dateFrom?: string; dateTo?: string }) => {
+    const params = new URLSearchParams();
+    if (options?.dateFrom) params.set('date_from', options.dateFrom);
+    if (options?.dateTo) params.set('date_to', options.dateTo);
+    const qs = params.toString();
+    return request<MaterialNumberStats[]>(`/inventory/stats/material-numbers${qs ? `?${qs}` : ''}`);
+  },
   getSpoolUsageHistory: (spoolId: number, limit = 50) =>
     request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
   getAllUsageHistory: (limit = 100, printerId?: number) =>

+ 29 - 7
frontend/src/components/BulkEditSpoolsModal.tsx

@@ -26,7 +26,8 @@ type EditableField =
   | 'label_weight'
   | 'core_weight'
   | 'category'
-  | 'low_stock_threshold_pct';
+  | 'low_stock_threshold_pct'
+  | 'material_number';
 
 type FieldSpec = {
   id: EditableField;
@@ -43,6 +44,11 @@ type FieldSpec = {
   step?: number;
   /** Hex pattern for the rgba field. */
   pattern?: string;
+  /** Lives on the internal spool row only. A Spoolman spool has no such
+   *  field, so SpoolmanInventoryUpdate drops it, the payload dumps to {} and
+   *  the route answers 400 "update must include at least one field". Offering
+   *  it in Spoolman mode is offering a button that cannot work. */
+  internalOnly?: boolean;
 };
 
 const FIELDS: FieldSpec[] = [
@@ -58,8 +64,11 @@ const FIELDS: FieldSpec[] = [
   { id: 'note', type: 'textarea', labelKey: 'inventory.note' },
   { id: 'label_weight', type: 'number', labelKey: 'inventory.labelWeight', min: 1, step: 1 },
   { id: 'core_weight', type: 'number', labelKey: 'inventory.coreWeight', min: 0, step: 1 },
-  { id: 'category', type: 'searchable', labelKey: 'inventory.category' },
-  { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1 },
+  { id: 'category', type: 'searchable', labelKey: 'inventory.category', internalOnly: true },
+  { id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1, internalOnly: true },
+  // Internal material / article number (#2870) — bulk-assigning it is the
+  // main way existing inventories get numbered.
+  { id: 'material_number', type: 'searchable', labelKey: 'inventory.materialNumber', internalOnly: true },
 ];
 
 export interface BulkEditSpoolsModalProps {
@@ -72,8 +81,11 @@ export interface BulkEditSpoolsModalProps {
   availableSubtypes: string[];
   availableBrands: string[];
   availableCategories: string[];
+  availableMaterialNumbers: string[];
   availableSlicerFilaments: string[];
   availableSlicerFilamentNames: string[];
+  /** Spoolman-backed inventory: hides the fields Spoolman cannot store. */
+  spoolmanMode?: boolean;
   onClose: () => void;
   onApply: (patch: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) => void;
 }
@@ -210,10 +222,15 @@ function combineUnique(...lists: string[][]): string[] {
 export function BulkEditSpoolsModal({
   isOpen, selectedCount, isPending,
   availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories,
-  availableSlicerFilaments, availableSlicerFilamentNames,
+  availableMaterialNumbers, availableSlicerFilaments, availableSlicerFilamentNames,
+  spoolmanMode = false,
   onClose, onApply,
 }: BulkEditSpoolsModalProps) {
   const { t } = useTranslation();
+  const fields = useMemo(
+    () => FIELDS.filter((f) => !(spoolmanMode && f.internalOnly)),
+    [spoolmanMode],
+  );
 
   // Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca
   // + local + built-in). Gated on `isOpen` so closed modal doesn't fetch.
@@ -280,6 +297,10 @@ export function BulkEditSpoolsModal({
     () => combineUnique(availableCategories).map((m) => ({ value: m, label: m })),
     [availableCategories],
   );
+  const materialNumberOptions: Option[] = useMemo(
+    () => combineUnique(availableMaterialNumbers).map((m) => ({ value: m, label: m })),
+    [availableMaterialNumbers],
+  );
   const slicerFilamentOptions: Option[] = useMemo(() => {
     // value = preset code (what goes into spool.slicer_filament),
     // label = display name so the user can find it by name.
@@ -317,7 +338,7 @@ export function BulkEditSpoolsModal({
 
   const buildPatch = (): Record<string, string | number> => {
     const patch: Record<string, string | number> = {};
-    for (const f of FIELDS) {
+    for (const f of fields) {
       const raw = values[f.id];
       if (raw === undefined) continue;
       const trimmed = typeof raw === 'string' ? raw.trim() : raw;
@@ -345,7 +366,7 @@ export function BulkEditSpoolsModal({
   // would be silently dropped from the patch — e.g. a malformed rgba hex.
   // Without this guard the user clicks Apply, the field is dropped, and the
   // success toast still fires for the OTHER fields.
-  const hasDroppedTickedField = FIELDS.some((f) => {
+  const hasDroppedTickedField = fields.some((f) => {
     const raw = values[f.id];
     if (raw === undefined) return false;
     if (raw.trim() === '') return false;
@@ -357,6 +378,7 @@ export function BulkEditSpoolsModal({
     if (id === 'subtype') return subtypeOptions;
     if (id === 'brand') return brandOptions;
     if (id === 'category') return categoryOptions;
+    if (id === 'material_number') return materialNumberOptions;
     if (id === 'slicer_filament') return slicerFilamentOptions;
     if (id === 'slicer_filament_name') return slicerFilamentNameOptions;
     if (id === 'location_id') return locationOptions;
@@ -469,7 +491,7 @@ export function BulkEditSpoolsModal({
           {t('inventory.bulk.editHint')}
         </p>
         <div className="flex-1 overflow-y-auto p-5 space-y-3">
-          {FIELDS.map((f) => {
+          {fields.map((f) => {
             const enabled = values[f.id] !== undefined;
             return (
               <div key={f.id} className={`flex items-start gap-3 rounded-md p-2 transition-colors ${enabled ? 'bg-bambu-green/5 border border-bambu-green/30' : 'border border-transparent'}`}>

+ 1 - 0
frontend/src/components/ForecastPanel.tsx

@@ -1281,6 +1281,7 @@ function ShoppingListPanel({
           added_full: null, last_used: null, encode_time: null,
           category: 'Stock',
           low_stock_threshold_pct: null,
+          material_number: null,
         };
         await api.bulkCreateSpools(spoolBase, item.quantity_spools);
         await api.removeFromShoppingList(id);

+ 306 - 0
frontend/src/components/ImagePreviewModal.tsx

@@ -0,0 +1,306 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Image as ImageIcon, Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
+import { api, getAuthToken } from '../api/client';
+import { formatFileSize } from '../utils/file';
+import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
+
+// The whole file is fetched into memory before it is shown, so a multi-hundred
+// megabyte scan gets the same notice the other previews give instead of a tab
+// that stops responding (#2976).
+export const IMAGE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
+
+const MIN_ZOOM = 0.4;
+// Higher than the PDF's ceiling: a photo or a scan is worth inspecting at the
+// pixel, and unlike a PDF page there is no sharper raster to fall back on.
+const MAX_ZOOM = 8;
+// One button press, one key press, one mouse-wheel notch.
+const ZOOM_STEP = 1.25;
+// A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
+// ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
+// and the pinch a smooth ramp.
+const WHEEL_DELTA_CLAMP = 30;
+const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
+
+const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+
+interface Point {
+  x: number;
+  y: number;
+}
+
+const ORIGIN: Point = { x: 0, y: 0 };
+
+interface ImagePreviewModalProps {
+  libraryFileId: number;
+  filename: string;
+  fileSize: number;
+  onClose: () => void;
+}
+
+// deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
+function wheelDeltaPixels(e: WheelEvent): number {
+  if (e.deltaMode === 1) return e.deltaY * 16;
+  if (e.deltaMode === 2) return e.deltaY * 100;
+  return e.deltaY;
+}
+
+export function ImagePreviewModal({ libraryFileId, filename, fileSize, onClose }: ImagePreviewModalProps) {
+  const { t } = useTranslation();
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
+  const containerRef = useRef<HTMLDivElement>(null);
+  const imageRef = useRef<HTMLImageElement>(null);
+
+  const [src, setSrc] = useState<string | null>(null);
+  const [loaded, setLoaded] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  // The image is laid out fitted to the panel; zoom and pan are a transform on
+  // top of that, so zoom 1 is always "fits the window".
+  const [zoom, setZoom] = useState(1);
+  const [offset, setOffset] = useState<Point>(ORIGIN);
+  const [dragging, setDragging] = useState(false);
+
+  const zoomRef = useRef(1);
+  const offsetRef = useRef<Point>(ORIGIN);
+  const pointersRef = useRef<Map<number, Point>>(new Map());
+  const pinchDistanceRef = useRef<number | null>(null);
+
+  // Panning past the edges would strand the image off-screen, so the offset is
+  // bounded by however much of it the zoom pushes outside the viewport.
+  const clampOffset = useCallback((next: Point, atZoom: number): Point => {
+    const container = containerRef.current;
+    const image = imageRef.current;
+    if (!container || !image) return next;
+    const maxX = Math.max(0, (image.offsetWidth * atZoom - container.clientWidth) / 2);
+    const maxY = Math.max(0, (image.offsetHeight * atZoom - container.clientHeight) / 2);
+    return {
+      x: Math.min(maxX, Math.max(-maxX, next.x)),
+      y: Math.min(maxY, Math.max(-maxY, next.y)),
+    };
+  }, []);
+
+  const setView = useCallback((nextZoom: number, nextOffset: Point) => {
+    zoomRef.current = nextZoom;
+    offsetRef.current = nextOffset;
+    setZoom(nextZoom);
+    setOffset(nextOffset);
+  }, []);
+
+  /** Zoom by `factor`, keeping the point under the pointer where it is. */
+  const zoomAt = useCallback(
+    (factor: number, clientX?: number, clientY?: number) => {
+      const current = zoomRef.current;
+      const next = clampZoom(current * factor);
+      if (next === current) return;
+      const container = containerRef.current;
+      const previous = offsetRef.current;
+      let nextOffset = { x: previous.x * (next / current), y: previous.y * (next / current) };
+      if (container) {
+        const rect = container.getBoundingClientRect();
+        // The transform grows the image around the container's centre, so an
+        // anchored point is expressed relative to that centre.
+        const ax = (clientX == null ? rect.left + rect.width / 2 : clientX) - (rect.left + rect.width / 2);
+        const ay = (clientY == null ? rect.top + rect.height / 2 : clientY) - (rect.top + rect.height / 2);
+        const k = next / current;
+        nextOffset = { x: ax - (ax - previous.x) * k, y: ay - (ay - previous.y) * k };
+      }
+      setView(next, clampOffset(nextOffset, next));
+    },
+    [clampOffset, setView]
+  );
+
+  const resetView = useCallback(() => setView(1, ORIGIN), [setView]);
+
+  useEffect(() => {
+    let cancelled = false;
+    let objectUrl: string | null = null;
+    setSrc(null);
+    setLoaded(false);
+    setError(null);
+    setView(1, ORIGIN);
+
+    if (fileSize > IMAGE_PREVIEW_MAX_BYTES) {
+      setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
+      return;
+    }
+
+    const headers: HeadersInit = {};
+    const token = getAuthToken();
+    if (token) headers['Authorization'] = `Bearer ${token}`;
+
+    (async () => {
+      // Fetched rather than pointed at with <img src>: the download endpoint
+      // wants the bearer token, which an <img> cannot send.
+      const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      const blob = await res.blob();
+      if (cancelled) return;
+      objectUrl = URL.createObjectURL(blob);
+      setSrc(objectUrl);
+    })().catch((err: unknown) => {
+      console.error('[image-preview] load failed', err);
+      if (!cancelled) setError(t('fileManager.preview.error'));
+    });
+
+    return () => {
+      cancelled = true;
+      if (objectUrl) URL.revokeObjectURL(objectUrl);
+    };
+  }, [libraryFileId, fileSize, t, setView]);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
+      if (e.ctrlKey || e.metaKey || e.altKey) return;
+      if (e.key === '+' || e.key === '=') {
+        e.preventDefault();
+        zoomAt(ZOOM_STEP);
+      } else if (e.key === '-') {
+        e.preventDefault();
+        zoomAt(1 / ZOOM_STEP);
+      } else if (e.key === '0') {
+        e.preventDefault();
+        resetView();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [resetView, zoomAt]);
+
+  // Native listener: React registers wheel as passive, so preventDefault —
+  // which keeps Ctrl+wheel from zooming the whole page — would be ignored.
+  // Nothing scrolls here, so a plain wheel zooms as well.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+    const handleWheel = (e: WheelEvent) => {
+      e.preventDefault();
+      const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
+      zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
+    };
+    container.addEventListener('wheel', handleWheel, { passive: false });
+    return () => container.removeEventListener('wheel', handleWheel);
+  }, [zoomAt, src]);
+
+  // One pointer drags the image once it is bigger than the window, two fingers
+  // pinch-zoom around their midpoint. `touch-action: none` on the container
+  // hands both gestures here instead of to the browser's scroll / page zoom.
+  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
+    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
+    pinchDistanceRef.current = null;
+    if (pointersRef.current.size === 1 && zoomRef.current > 1) {
+      e.currentTarget.setPointerCapture(e.pointerId);
+      setDragging(true);
+    }
+  };
+
+  const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
+    pointersRef.current.delete(e.pointerId);
+    pinchDistanceRef.current = null;
+    if (pointersRef.current.size === 0) setDragging(false);
+  };
+
+  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
+    const pointers = pointersRef.current;
+    const previous = pointers.get(e.pointerId);
+    if (!previous) return;
+    // A button released outside this container never reaches onPointerUp (no
+    // capture is taken below zoom 1), and the stale entry would then pan the
+    // image under a bare cursor — or count as a second finger on a hybrid
+    // device, turning the next one-finger drag into a pinch (#2976).
+    if (e.pointerType === 'mouse' && e.buttons === 0) {
+      handlePointerEnd(e);
+      return;
+    }
+    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+    if (pointers.size === 1) {
+      if (zoomRef.current <= 1) return;
+      const moved = { x: offsetRef.current.x + (e.clientX - previous.x), y: offsetRef.current.y + (e.clientY - previous.y) };
+      setView(zoomRef.current, clampOffset(moved, zoomRef.current));
+      return;
+    }
+    if (pointers.size !== 2) return;
+    const [a, b] = Array.from(pointers.values());
+    const distance = Math.hypot(a.x - b.x, a.y - b.y);
+    const last = pinchDistanceRef.current;
+    pinchDistanceRef.current = distance;
+    if (last != null && last > 0 && distance > 0) {
+      zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
+    }
+  };
+
+  const canPan = zoom > 1;
+
+  return (
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<ImageIcon className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+      actions={
+        src ? (
+          <>
+            <button
+              onClick={() => zoomAt(1 / ZOOM_STEP)}
+              className={previewIconButtonClass}
+              aria-label={t('fileManager.preview.zoomOut')}
+            >
+              <ZoomOut className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => zoomAt(ZOOM_STEP)}
+              className={previewIconButtonClass}
+              aria-label={t('fileManager.preview.zoomIn')}
+            >
+              <ZoomIn className="w-4 h-4" />
+            </button>
+            <button onClick={resetView} className={previewIconButtonClass} aria-label={t('fileManager.preview.resetZoom')}>
+              <RotateCcw className="w-4 h-4" />
+            </button>
+          </>
+        ) : undefined
+      }
+    >
+      <div
+        ref={containerRef}
+        data-testid="image-preview-content"
+        className={`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${
+          isFullscreen ? '' : 'rounded-b-lg'
+        } ${canPan ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
+        onDoubleClick={toggleFullscreen}
+        onPointerDown={handlePointerDown}
+        onPointerMove={handlePointerMove}
+        onPointerUp={handlePointerEnd}
+        onPointerCancel={handlePointerEnd}
+        onPointerLeave={handlePointerEnd}
+      >
+        {error ? (
+          <p className="text-bambu-gray text-center p-6">{error}</p>
+        ) : (
+          <>
+            {src && (
+              <img
+                ref={imageRef}
+                src={src}
+                alt={filename}
+                draggable={false}
+                onLoad={() => setLoaded(true)}
+                onError={() => setError(t('fileManager.preview.error'))}
+                style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})` }}
+                className="max-w-full max-h-full object-contain select-none"
+              />
+            )}
+            {!loaded && (
+              <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+                <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+              </div>
+            )}
+          </>
+        )}
+      </div>
+    </PreviewModalShell>
+  );
+}

+ 79 - 0
frontend/src/components/MaterialNumberStats.tsx

@@ -0,0 +1,79 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+
+// Consumption, cost and stock grouped by the internal material number
+// (#2870) — the identifier the business actually purchases and costs by,
+// unlike brand+material+colour. Data comes from the dedicated aggregate
+// endpoint so archived spools' recorded usage still counts.
+
+interface MaterialNumberStatsProps {
+  currency: string;
+  // Dashboard timeframe. Narrows the consumption/cost columns only — the
+  // spool count and remaining weight are point-in-time stock.
+  dateFrom?: string;
+  dateTo?: string;
+}
+
+function formatGrams(g: number): string {
+  if (Math.abs(g) >= 1000) return `${(g / 1000).toFixed(2)} kg`;
+  return `${Math.round(g)} g`;
+}
+
+export function MaterialNumberStats({ currency, dateFrom, dateTo }: MaterialNumberStatsProps) {
+  const { t } = useTranslation();
+  const { data, isLoading, isError } = useQuery({
+    queryKey: ['material-number-stats', dateFrom ?? null, dateTo ?? null],
+    queryFn: () => api.getMaterialNumberStats({ dateFrom, dateTo }),
+  });
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-8">
+        <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  // A failed request is not an empty inventory: telling someone who has
+  // numbered their spools to go and number them (because of a 403 from a
+  // missing INVENTORY_READ, or a dropped connection) sends them looking for
+  // a problem that isn't there.
+  if (isError) {
+    return <p className="text-sm text-red-700 dark:text-red-400 py-4">{t('stats.materialNumbers.loadFailed')}</p>;
+  }
+
+  if (!data || data.length === 0) {
+    return <p className="text-sm text-bambu-gray py-4">{t('stats.materialNumbers.empty')}</p>;
+  }
+
+  return (
+    <div className="overflow-x-auto">
+      <table className="w-full text-sm">
+        <thead>
+          <tr className="text-left text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
+            <th className="py-2 pr-4 font-medium">{t('inventory.materialNumber')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.spools')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.remaining')}</th>
+            <th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.consumed')}</th>
+            <th className="py-2 font-medium text-right">{t('stats.materialNumbers.cost')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {data.map((row) => (
+            <tr key={row.material_number} className="border-b border-bambu-dark-tertiary/50 last:border-b-0">
+              <td className="py-2 pr-4 text-white font-medium">{row.material_number}</td>
+              <td className="py-2 pr-4 text-bambu-gray text-right">{row.spool_count}</td>
+              <td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.remaining_g)}</td>
+              <td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.consumed_g)}</td>
+              <td className="py-2 text-bambu-gray text-right">
+                {currency} {row.cost.toFixed(2)}
+              </td>
+            </tr>
+          ))}
+        </tbody>
+      </table>
+    </div>
+  );
+}

+ 49 - 11
frontend/src/components/ModelViewer.tsx

@@ -64,6 +64,9 @@ function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
   });
 }
 
+// How far the user may dolly out, as a multiple of the framed distance.
+const MAX_ZOOM_OUT = 8;
+
 /**
  * Frame the camera on a bounding box.
  *
@@ -78,6 +81,7 @@ function fitCameraToBox(
   camera: THREE.PerspectiveCamera,
   controls: OrbitControls,
   box: THREE.Box3,
+  plateDiagonal: number,
   padding = 1.15,
 ): void {
   const size = box.getSize(new THREE.Vector3());
@@ -92,10 +96,18 @@ function fitCameraToBox(
   // Keep the established three-quarter view; only the distance changes.
   const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
   camera.position.copy(center).addScaledVector(direction, distance);
+
+  // Zoom range relative to the framed distance. Without the upper bound the
+  // wheel dollied straight through the far plane: five or six notches out and
+  // the model, plate and grid all vanished, which reads as "zoom is broken".
+  controls.minDistance = Math.max(radius * 0.05, 0.05);
+  controls.maxDistance = distance * MAX_ZOOM_OUT;
+
   // Clip planes scaled to the subject, so a small model doesn't z-fight and a
-  // large one isn't sliced by the far plane.
+  // large one isn't sliced by the far plane. The far plane has to cover the
+  // whole dolly range plus the plate, whose diagonal dwarfs a small model.
   camera.near = Math.max(distance / 1000, 0.01);
-  camera.far = distance + radius * 4;
+  camera.far = controls.maxDistance + radius * 4 + plateDiagonal;
   camera.updateProjectionMatrix();
 
   controls.target.copy(center);
@@ -108,6 +120,11 @@ interface BuildVolume {
   z: number;
 }
 
+// Module-level so the default keeps its identity: an inline default object is
+// new on every render and, as an effect dependency, would rebuild the whole
+// scene each time the component re-renders.
+const DEFAULT_BUILD_VOLUME: BuildVolume = { x: 256, y: 256, z: 256 };
+
 interface ModelViewerProps {
   url: string;
   fileType?: string;
@@ -765,7 +782,7 @@ function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THRE
 export function ModelViewer({
   url,
   fileType,
-  buildVolume = { x: 256, y: 256, z: 256 },
+  buildVolume = DEFAULT_BUILD_VOLUME,
   filamentColors,
   selectedPlateId = null,
   className = '',
@@ -786,6 +803,9 @@ export function ModelViewer({
   const modelGroupRef = useRef<THREE.Group | null>(null);
   const plateRef = useRef<THREE.Mesh | null>(null);
   const gridRef = useRef<THREE.GridHelper | null>(null);
+  // The last framed bounds, so Reset returns to the framed view rather than
+  // a fixed camera pose that ignores where the model was placed.
+  const fitBoxRef = useRef<THREE.Box3 | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
@@ -1023,6 +1043,10 @@ export function ModelViewer({
       renderer.setSize(w, h);
     };
     window.addEventListener('resize', handleResize);
+    // Entering or leaving fullscreen resizes the container too; the observer
+    // catches that, but only after layout — resizing on the event itself
+    // avoids one frame drawn at the old aspect.
+    document.addEventListener('fullscreenchange', handleResize);
     const resizeObserver = new ResizeObserver(() => {
       handleResize();
     });
@@ -1030,6 +1054,7 @@ export function ModelViewer({
 
     return () => {
       window.removeEventListener('resize', handleResize);
+      document.removeEventListener('fullscreenchange', handleResize);
       resizeObserver.disconnect();
       cancelAnimationFrame(animationId);
       controls.dispose();
@@ -1148,7 +1173,8 @@ export function ModelViewer({
     const finalBox = new THREE.Box3().setFromObject(group);
 
     // Adjust camera to fit model
-    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
+    fitBoxRef.current = finalBox;
+    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox, Math.hypot(buildVolume.x, buildVolume.y));
 
     setLoading(false);
 
@@ -1184,17 +1210,29 @@ export function ModelViewer({
   }, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
 
   const resetView = () => {
-    if (cameraRef.current && controlsRef.current) {
-      cameraRef.current.position.set(150, 150, 150);
-      controlsRef.current.target.set(0, 50, 0);
-      controlsRef.current.update();
+    const camera = cameraRef.current;
+    const controls = controlsRef.current;
+    if (!camera || !controls) return;
+    if (fitBoxRef.current) {
+      fitCameraToBox(camera, controls, fitBoxRef.current, Math.hypot(buildVolume.x, buildVolume.y));
+    } else {
+      camera.position.set(150, 150, 150);
+      controls.target.set(0, 50, 0);
+      controls.update();
     }
   };
 
+  // Dolly along the view axis, like the wheel. Scaling the camera position
+  // itself measured from the world origin, not from the orbit target at the
+  // plate centre, so each press also slid the model sideways.
   const zoom = (factor: number) => {
-    if (cameraRef.current) {
-      cameraRef.current.position.multiplyScalar(factor);
-    }
+    const camera = cameraRef.current;
+    const controls = controlsRef.current;
+    if (!camera || !controls) return;
+    const offset = camera.position.clone().sub(controls.target);
+    const distance = Math.min(controls.maxDistance, Math.max(controls.minDistance, offset.length() * factor));
+    camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
+    controls.update();
   };
 
   return (

+ 299 - 318
frontend/src/components/ModelViewerModal.tsx

@@ -1,9 +1,11 @@
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
+import { ExternalLink, Box, Cog, Loader2, Layers, Check, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { Button } from './Button';
+import { PreviewModalShell } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 import { api, withMediaToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
@@ -156,26 +158,20 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   const [platesLoading, setPlatesLoading] = useState(false);
   const [selectedPlateId, setSelectedPlateId] = useState<number | null>(null);
   const [platePage, setPlatePage] = useState(0);
-  const [isFullscreen, setIsFullscreen] = useState(false);
   const [platePanelHeight, setPlatePanelHeight] = useState<number | null>(null);
   const [isDraggingDivider, setIsDraggingDivider] = useState(false);
   const [hasCustomSplit, setHasCustomSplit] = useState(false);
   const splitContainerRef = useRef<HTMLDivElement>(null);
   const platesPanelRef = useRef<HTMLDivElement>(null);
+  // Real fullscreen where the browser offers it, the viewport-filling layout
+  // below otherwise (#2976).
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
   const dividerHeight = 10;
   const minPlateHeight = 160;
   const minViewerPx = 240;
   const minViewerRatio = 0.35;
 
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
   useEffect(() => {
     setLoading(true);
 
@@ -454,339 +450,324 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   }));
 
   return (
-    <div
-      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
-      onClick={onClose}
-    >
-      <div
-        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary w-full flex flex-col ${
-          isFullscreen ? 'h-full max-w-none rounded-none' : 'h-[80vh] max-w-4xl rounded-xl'
-        }`}
-        onClick={(e) => e.stopPropagation()}
-      >
-        {/* Header */}
-        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-3 min-w-0 flex-1 mr-4">
-            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
-            {hasObjectCount && (
-              <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
-                {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
-              </span>
-            )}
-          </div>
-          <div className="flex items-center gap-2">
-            {useBambuddySlicer ? (
-              <SlicerSplitButton
-                icon={<Cog className="w-4 h-4" />}
-                label={t('slice.action')}
-                dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => onSliceWithBambuddy?.()}
-                items={slicerDropdownItems}
-              />
-            ) : canOpenInSlicer ? (
-              <SlicerSplitButton
-                icon={<ExternalLink className="w-4 h-4" />}
-                // Name the slicer when it is not the configured one. That happens
-                // when the configured slicer cannot take this format — an STL with
-                // Bambu Studio selected — and silently handing the file to the
-                // other one without saying so would be worse than the failure it
-                // replaces.
-                label={
-                  usableSlicers[0] === preferredSlicer
-                    ? t('modelViewer.openInSlicer')
-                    : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
-                }
-                dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
-                items={slicerDropdownItems}
-              />
-            ) : (
-              <Button variant="secondary" size="sm" disabled>
-                <ExternalLink className="w-4 h-4" />
-                {t('modelViewer.openInSlicer')}
-              </Button>
-            )}
-            <Button
-              variant="secondary"
-              size="sm"
-              onClick={() => setIsFullscreen((prev) => !prev)}
-              title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
-            >
-              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
-            </Button>
-            <Button variant="ghost" size="sm" onClick={onClose}>
-              <X className="w-5 h-5" />
+    <PreviewModalShell
+      title={title}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      closeOnBackdropClick
+      titleExtra={
+        hasObjectCount ? (
+          <span className="text-xs text-bambu-gray bg-bambu-dark-tertiary/70 px-2 py-1 rounded whitespace-nowrap">
+            {objectCountLabel}: {t('modelViewer.objectCount', { count: selectedObjectCount })}
+          </span>
+        ) : undefined
+      }
+      actions={
+        <>
+          {useBambuddySlicer ? (
+            <SlicerSplitButton
+              icon={<Cog className="w-4 h-4" />}
+              label={t('slice.action')}
+              dropdownLabel={t('modelViewer.moreSlicerOptions')}
+              onPrimary={() => onSliceWithBambuddy?.()}
+              items={slicerDropdownItems}
+            />
+          ) : canOpenInSlicer ? (
+            <SlicerSplitButton
+              icon={<ExternalLink className="w-4 h-4" />}
+              // Name the slicer when it is not the configured one. That happens
+              // when the configured slicer cannot take this format — an STL with
+              // Bambu Studio selected — and silently handing the file to the
+              // other one without saying so would be worse than the failure it
+              // replaces.
+              label={
+                usableSlicers[0] === preferredSlicer
+                  ? t('modelViewer.openInSlicer')
+                  : t('modelViewer.openInSlicerWith', { slicer: slicerName(usableSlicers[0]) })
+              }
+              dropdownLabel={t('modelViewer.moreSlicerOptions')}
+              onPrimary={() => handleOpenInSlicer(usableSlicers[0])}
+              items={slicerDropdownItems}
+            />
+          ) : (
+            <Button variant="secondary" size="sm" disabled>
+              <ExternalLink className="w-4 h-4" />
+              {t('modelViewer.openInSlicer')}
             </Button>
-          </div>
+          )}
+        </>
+      }
+    >
+      {/* Tabs - only show if we have capabilities */}
+      {capabilities && (
+        <div className="flex border-b border-bambu-dark-tertiary">
+          <button
+            onClick={() => capabilities.has_model && setActiveTab('3d')}
+            disabled={!capabilities.has_model}
+            className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
+              activeTab === '3d'
+                ? 'text-bambu-green border-b-2 border-bambu-green'
+                : capabilities.has_model
+                  ? 'text-bambu-gray hover:text-white'
+                  : 'text-bambu-gray/30 cursor-not-allowed'
+            }`}
+          >
+            <Box className="w-4 h-4" />
+            {t('modelViewer.tabs.model')}
+            {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
+          </button>
         </div>
+      )}
 
-        {/* Tabs - only show if we have capabilities */}
-        {capabilities && (
-          <div className="flex border-b border-bambu-dark-tertiary">
-            <button
-              onClick={() => capabilities.has_model && setActiveTab('3d')}
-              disabled={!capabilities.has_model}
-              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
-                activeTab === '3d'
-                  ? 'text-bambu-green border-b-2 border-bambu-green'
-                  : capabilities.has_model
-                    ? 'text-bambu-gray hover:text-white'
-                    : 'text-bambu-gray/30 cursor-not-allowed'
-              }`}
-            >
-              <Box className="w-4 h-4" />
-              {t('modelViewer.tabs.model')}
-              {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
-            </button>
+      {/* Viewer */}
+      <div className="flex-1 overflow-hidden p-4">
+        {loading ? (
+          <div className="w-full h-full flex items-center justify-center">
+            <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
           </div>
-        )}
-
-        {/* Viewer */}
-        <div className="flex-1 overflow-hidden p-4">
-          {loading ? (
-            <div className="w-full h-full flex items-center justify-center">
-              <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
-            </div>
-          ) : activeTab === '3d' && capabilities ? (
-            <div
-              ref={splitContainerRef}
-              className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
-            >
-              {hasMultiplePlates && (
-                <div
-                  ref={platesPanelRef}
-                  style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
-                  className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
-                >
-                  <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
-                    <Layers className="w-4 h-4" />
-                    {t('modelViewer.plates')}
-                    {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
-                  </div>
-                  <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
-                      <div
-                        ref={platesViewportRef}
-                        className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
-                      >
-                      <div
-                        ref={platesGridRef}
-                        className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
-                        style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
+        ) : activeTab === '3d' && capabilities ? (
+          <div
+            ref={splitContainerRef}
+            className={`w-full h-full flex flex-col ${splitFullscreen ? 'gap-0 min-h-0' : 'gap-3'}`}
+          >
+            {hasMultiplePlates && (
+              <div
+                ref={platesPanelRef}
+                style={splitFullscreen && platePanelHeight != null ? { height: platePanelHeight } : undefined}
+                className={`rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-3 ${splitFullscreen ? 'flex flex-col shrink-0' : ''}`}
+              >
+                <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+                  <Layers className="w-4 h-4" />
+                  {t('modelViewer.plates')}
+                  {platesLoading && <Loader2 className="w-3 h-3 animate-spin" />}
+                </div>
+                <div className={splitFullscreen ? 'flex flex-col min-h-0 flex-1' : undefined}>
+                    <div
+                      ref={platesViewportRef}
+                      className={splitFullscreen ? 'min-h-0 overflow-hidden pr-1 flex-1' : undefined}
+                    >
+                    <div
+                      ref={platesGridRef}
+                      className={splitFullscreen ? 'grid gap-2' : 'grid grid-cols-2 md:grid-cols-3 gap-2'}
+                      style={splitFullscreen ? { gridTemplateColumns: `repeat(${plateColumns}, minmax(0, 1fr))` } : undefined}
+                    >
+                      <button
+                        type="button"
+                        onClick={() => setSelectedPlateId(null)}
+                        className={`flex items-center rounded-lg border text-left transition-colors ${
+                          splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
+                        } ${
+                          selectedPlateId == null
+                            ? 'border-bambu-green bg-bambu-green/10'
+                            : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
+                        }`}
                       >
+                        <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                          splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                        }`}>
+                          <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                        </div>
+                        <div className="min-w-0 flex-1">
+                          <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
+                          <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
+                            {t('modelViewer.plateCount', { count: plates.length })}
+                          </p>
+                        </div>
+                        {selectedPlateId == null && (
+                          <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
+                        )}
+                      </button>
+                      {pagedPlates.map((plate) => (
                         <button
+                          key={plate.index}
                           type="button"
-                          onClick={() => setSelectedPlateId(null)}
+                          onClick={() => setSelectedPlateId(plate.index)}
                           className={`flex items-center rounded-lg border text-left transition-colors ${
                             splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
                           } ${
-                            selectedPlateId == null
+                            selectedPlateId === plate.index
                               ? 'border-bambu-green bg-bambu-green/10'
                               : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
                           }`}
                         >
-                          <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
-                            splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
-                          }`}>
-                            <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
-                          </div>
+                          {plate.has_thumbnail && plate.thumbnail_url ? (
+                            <img
+                              src={withMediaToken(plate.thumbnail_url)}
+                              alt={`Plate ${plate.index}`}
+                              className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
+                            />
+                          ) : (
+                            <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
+                              splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
+                            }`}>
+                              <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
+                            </div>
+                          )}
                           <div className="min-w-0 flex-1">
-                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>{t('modelViewer.allPlates')}</p>
+                            <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
+                              {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
+                            </p>
                             <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
-                              {t('modelViewer.plateCount', { count: plates.length })}
+                              {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
                             </p>
                           </div>
-                          {selectedPlateId == null && (
+                          {selectedPlateId === plate.index && (
                             <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
                           )}
                         </button>
-                        {pagedPlates.map((plate) => (
-                          <button
-                            key={plate.index}
-                            type="button"
-                            onClick={() => setSelectedPlateId(plate.index)}
-                            className={`flex items-center rounded-lg border text-left transition-colors ${
-                              splitFullscreen ? 'gap-1.5 p-1.5 w-full' : 'gap-2 p-2'
-                            } ${
-                              selectedPlateId === plate.index
-                                ? 'border-bambu-green bg-bambu-green/10'
-                                : 'border-bambu-dark-tertiary bg-bambu-dark-secondary hover:border-bambu-gray'
-                            }`}
-                          >
-                            {plate.has_thumbnail && plate.thumbnail_url ? (
-                              <img
-                                src={withMediaToken(plate.thumbnail_url)}
-                                alt={`Plate ${plate.index}`}
-                                className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
-                              />
-                            ) : (
-                              <div className={`rounded bg-bambu-dark-tertiary flex items-center justify-center ${
-                                splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'
-                              }`}>
-                                <Layers className={`${splitFullscreen ? 'w-4 h-4' : 'w-5 h-5'} text-bambu-gray`} />
-                              </div>
-                            )}
-                            <div className="min-w-0 flex-1">
-                              <p className={`${splitFullscreen ? 'text-xs' : 'text-sm'} text-white font-medium truncate`}>
-                                {plate.name || t('modelViewer.plateNumber', { number: plate.index })}
-                              </p>
-                              <p className={`${splitFullscreen ? 'text-[10px]' : 'text-xs'} text-bambu-gray truncate`}>
-                                {t('modelViewer.objectCount', { count: plate.object_count ?? plate.objects?.length ?? 0 })}
-                              </p>
-                            </div>
-                            {selectedPlateId === plate.index && (
-                              <Check className={`${splitFullscreen ? 'w-3.5 h-3.5' : 'w-4 h-4'} text-bambu-green flex-shrink-0`} />
-                            )}
-                          </button>
-                        ))}
-                      </div>
+                      ))}
                     </div>
-                    {(selectedPlate || shouldPaginatePlates) && (
-                      <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
-                        {selectedPlate && (
-                          <div className="flex items-center gap-3 whitespace-nowrap">
-                            <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
-                            {selectedPlate.print_time_seconds != null && (
-                              <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
-                            )}
-                            {selectedPlate.filament_used_grams != null && (
-                              <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
-                            )}
-                            {selectedPlate.filaments.length > 0 && (
-                              <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
-                            )}
-                          </div>
-                        )}
-                        {shouldPaginatePlates && (
-                          <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
-                            <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
-                            <div className="flex items-center gap-1">
-                              <button
-                                type="button"
-                                onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
-                                disabled={platePage === 0}
-                                className={`px-2 py-1 rounded border text-xs ${
-                                  platePage === 0
-                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
-                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                }`}
-                              >
-                                {t('modelViewer.pagination.prev')}
-                              </button>
-                              {(() => {
-                                const maxVisible = 5;
-                                let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
-                                const end = Math.min(totalPlatePages, start + maxVisible);
-                                if (end - start < maxVisible) {
-                                  start = Math.max(0, end - maxVisible);
-                                }
-                                const pages = Array.from({ length: end - start }, (_, i) => start + i);
-
-                                return (
-                                  <>
-                                    {start > 0 && (
-                                      <button
-                                        type="button"
-                                        onClick={() => setPlatePage(0)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === 0
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        1
-                                      </button>
-                                    )}
-                                    {start > 1 && <span className="px-1">…</span>}
-                                    {pages.map((pageNumber) => (
-                                      <button
-                                        key={pageNumber}
-                                        type="button"
-                                        onClick={() => setPlatePage(pageNumber)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === pageNumber
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        {pageNumber + 1}
-                                      </button>
-                                    ))}
-                                    {end < totalPlatePages - 1 && <span className="px-1">…</span>}
-                                    {end < totalPlatePages && (
-                                      <button
-                                        type="button"
-                                        onClick={() => setPlatePage(totalPlatePages - 1)}
-                                        className={`px-2 py-1 rounded border text-xs ${
-                                          platePage === totalPlatePages - 1
-                                            ? 'border-bambu-green text-bambu-green'
-                                            : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                        }`}
-                                      >
-                                        {totalPlatePages}
-                                      </button>
-                                    )}
-                                  </>
-                                );
-                              })()}
-                              <button
-                                type="button"
-                                onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
-                                disabled={platePage >= totalPlatePages - 1}
-                                className={`px-2 py-1 rounded border text-xs ${
-                                  platePage >= totalPlatePages - 1
-                                    ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
-                                    : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
-                                }`}
-                              >
-                                {t('modelViewer.pagination.next')}
-                              </button>
-                            </div>
-                          </div>
-                        )}
-                      </div>
-                    )}
                   </div>
+                  {(selectedPlate || shouldPaginatePlates) && (
+                    <div className="mt-auto pt-3 flex items-center gap-4 text-xs text-bambu-gray overflow-x-auto">
+                      {selectedPlate && (
+                        <div className="flex items-center gap-3 whitespace-nowrap">
+                          <span>{t('modelViewer.plateNumber', { number: selectedPlate.index })}</span>
+                          {selectedPlate.print_time_seconds != null && (
+                            <span>{t('modelViewer.eta', { minutes: Math.round(selectedPlate.print_time_seconds / 60) })}</span>
+                          )}
+                          {selectedPlate.filament_used_grams != null && (
+                            <span>{selectedPlate.filament_used_grams.toFixed(1)} g</span>
+                          )}
+                          {selectedPlate.filaments.length > 0 && (
+                            <span>{t('modelViewer.filamentCount', { count: selectedPlate.filaments.length })}</span>
+                          )}
+                        </div>
+                      )}
+                      {shouldPaginatePlates && (
+                        <div className={`flex items-center gap-2 whitespace-nowrap ${selectedPlate ? 'ml-auto' : ''}`}>
+                          <span>{t('modelViewer.pagination.pageOf', { current: platePage + 1, total: totalPlatePages })}</span>
+                          <div className="flex items-center gap-1">
+                            <button
+                              type="button"
+                              onClick={() => setPlatePage((prev) => Math.max(prev - 1, 0))}
+                              disabled={platePage === 0}
+                              className={`px-2 py-1 rounded border text-xs ${
+                                platePage === 0
+                                  ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                              }`}
+                            >
+                              {t('modelViewer.pagination.prev')}
+                            </button>
+                            {(() => {
+                              const maxVisible = 5;
+                              let start = Math.max(0, platePage - Math.floor(maxVisible / 2));
+                              const end = Math.min(totalPlatePages, start + maxVisible);
+                              if (end - start < maxVisible) {
+                                start = Math.max(0, end - maxVisible);
+                              }
+                              const pages = Array.from({ length: end - start }, (_, i) => start + i);
+
+                              return (
+                                <>
+                                  {start > 0 && (
+                                    <button
+                                      type="button"
+                                      onClick={() => setPlatePage(0)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === 0
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      1
+                                    </button>
+                                  )}
+                                  {start > 1 && <span className="px-1">…</span>}
+                                  {pages.map((pageNumber) => (
+                                    <button
+                                      key={pageNumber}
+                                      type="button"
+                                      onClick={() => setPlatePage(pageNumber)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === pageNumber
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      {pageNumber + 1}
+                                    </button>
+                                  ))}
+                                  {end < totalPlatePages - 1 && <span className="px-1">…</span>}
+                                  {end < totalPlatePages && (
+                                    <button
+                                      type="button"
+                                      onClick={() => setPlatePage(totalPlatePages - 1)}
+                                      className={`px-2 py-1 rounded border text-xs ${
+                                        platePage === totalPlatePages - 1
+                                          ? 'border-bambu-green text-bambu-green'
+                                          : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                                      }`}
+                                    >
+                                      {totalPlatePages}
+                                    </button>
+                                  )}
+                                </>
+                              );
+                            })()}
+                            <button
+                              type="button"
+                              onClick={() => setPlatePage((prev) => Math.min(prev + 1, totalPlatePages - 1))}
+                              disabled={platePage >= totalPlatePages - 1}
+                              className={`px-2 py-1 rounded border text-xs ${
+                                platePage >= totalPlatePages - 1
+                                  ? 'border-bambu-dark-tertiary text-bambu-gray/40 cursor-not-allowed'
+                                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                              }`}
+                            >
+                              {t('modelViewer.pagination.next')}
+                            </button>
+                          </div>
+                        </div>
+                      )}
+                    </div>
+                  )}
                 </div>
-              )}
-              {splitFullscreen && (
-                <div
-                  role="separator"
-                  aria-orientation="horizontal"
-                  onMouseDown={(event) => {
-                    event.preventDefault();
-                    setIsDraggingDivider(true);
-                    setHasCustomSplit(true);
-                  }}
-                  className={`h-2 cursor-row-resize flex items-center justify-center ${
-                    isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
-                  }`}
-                >
-                  <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
-                </div>
-              )}
-              <div className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}>
-                  <ModelViewer
-                    url={isLibrary
-                      ? api.getLibraryFileDownloadUrl(libraryFileId!)
-                      : (capabilities.has_source
-                        ? api.getSource3mfDownloadUrl(archiveId!)
-                        : api.getArchiveDownload(archiveId!))}
-                    fileType={fileType}
-                    buildVolume={capabilities.build_volume}
-                    filamentColors={capabilities.filament_colors}
-                    selectedPlateId={selectedPlateId}
-                    className="w-full h-full"
-                    onSnapshot={onSnapshot}
-                  />
               </div>
+            )}
+            {splitFullscreen && (
+              <div
+                role="separator"
+                aria-orientation="horizontal"
+                onMouseDown={(event) => {
+                  event.preventDefault();
+                  setIsDraggingDivider(true);
+                  setHasCustomSplit(true);
+                }}
+                className={`h-2 cursor-row-resize flex items-center justify-center ${
+                  isDraggingDivider ? 'bg-bambu-dark-tertiary' : 'bg-bambu-dark-secondary/60 hover:bg-bambu-dark-tertiary'
+                }`}
+              >
+                <div className="w-12 h-1 rounded-full bg-bambu-gray/50" />
+              </div>
+            )}
+            <div
+              className={`flex-1 ${splitFullscreen ? 'min-h-0' : ''}`}
+              data-testid="model-viewer-area"
+              onDoubleClick={toggleFullscreen}
+            >
+                <ModelViewer
+                  url={isLibrary
+                    ? api.getLibraryFileDownloadUrl(libraryFileId!)
+                    : (capabilities.has_source
+                      ? api.getSource3mfDownloadUrl(archiveId!)
+                      : api.getArchiveDownload(archiveId!))}
+                  fileType={fileType}
+                  buildVolume={capabilities.build_volume}
+                  filamentColors={capabilities.filament_colors}
+                  selectedPlateId={selectedPlateId}
+                  className="w-full h-full"
+                  onSnapshot={onSnapshot}
+                />
             </div>
-          ) : (
-            <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-              {t('modelViewer.noPreview')}
-            </div>
-          )}
-        </div>
+          </div>
+        ) : (
+          <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+            {t('modelViewer.noPreview')}
+          </div>
+        )}
       </div>
-    </div>
+    </PreviewModalShell>
   );
 }

+ 303 - 86
frontend/src/components/PdfPreviewModal.tsx

@@ -1,14 +1,40 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react';
+import { ChevronLeft, ChevronRight, FileText, Loader2, ZoomIn, ZoomOut } from 'lucide-react';
 import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist/legacy/build/pdf.mjs';
 import { api, getAuthToken } from '../api/client';
 import { formatFileSize } from '../utils/file';
+import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 
 // Fetching and parsing happen fully in the browser; beyond this size the
 // preview shows a notice instead of stalling the tab on a giant download.
 export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
 
+const MIN_ZOOM = 0.4;
+const MAX_ZOOM = 4;
+// One button press, one key press, one mouse-wheel notch.
+const ZOOM_STEP = 1.25;
+// A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
+// ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
+// and the pinch a smooth ramp.
+const WHEEL_DELTA_CLAMP = 30;
+const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
+// Wheel zoom changes the displayed size at once (CSS) and re-rasterises after
+// the gesture settles, so a scroll burst costs one pdf.js render, not twenty.
+const RERENDER_DEBOUNCE_MS = 150;
+// iOS Safari refuses to back a canvas past roughly 16.7M pixels and hands back
+// a blank one instead of failing; zoom 4 on a dpr-2 screen crosses that on any
+// ordinary page. Past the cap the raster stops getting sharper, which costs
+// detail rather than the whole page.
+const MAX_CANVAS_PIXELS = 16 * 1024 * 1024;
+// pdf.js fetches its CMaps, ICC profiles, standard fonts and wasm decoders at
+// runtime instead of bundling them; vite.config.ts publishes them here. Left
+// unset, CJK text, JPEG2000/JBIG2 images and ICC colour silently fail (#2976).
+const PDFJS_ASSET_BASE = `${import.meta.env.BASE_URL}assets/pdfjs/`;
+
+const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+
 interface PdfPreviewModalProps {
   libraryFileId: number;
   filename: string;
@@ -18,6 +44,20 @@ interface PdfPreviewModalProps {
   onSnapshot?: (blob: Blob) => void;
 }
 
+// Where a zoom step should keep the page still: a point on the canvas
+// (fractions) pinned to a point in the scroll viewport (pixels).
+interface ZoomAnchor {
+  fx: number;
+  fy: number;
+  px: number;
+  py: number;
+}
+
+interface ActivePointer {
+  x: number;
+  y: number;
+}
+
 // Square 256px crop of the rendered page, white-backed like a paper page.
 function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
   const size = 256;
@@ -35,8 +75,17 @@ function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
   return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
 }
 
+// deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
+function wheelDeltaPixels(e: WheelEvent): number {
+  if (e.deltaMode === 1) return e.deltaY * 16;
+  if (e.deltaMode === 2) return e.deltaY * 100;
+  return e.deltaY;
+}
+
 export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
   const { t } = useTranslation();
+  const fullscreen = usePreviewFullscreen();
+  const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
   const containerRef = useRef<HTMLDivElement>(null);
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const renderTaskRef = useRef<RenderTask | null>(null);
@@ -48,17 +97,160 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
 
   const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
   const [pageNum, setPageNum] = useState(1);
+  // `zoom` is what the user sees (applied as CSS size immediately);
+  // `renderZoom` trails it and drives the pdf.js raster.
   const [zoom, setZoom] = useState(1);
+  const [renderZoom, setRenderZoom] = useState(1);
+  const [layoutVersion, setLayoutVersion] = useState(0);
   const [error, setError] = useState<string | null>(null);
   const [rendering, setRendering] = useState(true);
 
+  const zoomRef = useRef(zoom);
+  // CSS size of the page at zoom 1, known once a page has been rendered.
+  const baseCssSizeRef = useRef<{ width: number; height: number } | null>(null);
+  const anchorRef = useRef<ZoomAnchor | null>(null);
+  const pointersRef = useRef<Map<number, ActivePointer>>(new Map());
+  const pinchDistanceRef = useRef<number | null>(null);
+
+  const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
+    const container = containerRef.current;
+    const canvas = canvasRef.current;
+    if (container && canvas) {
+      const viewport = container.getBoundingClientRect();
+      const page = canvas.getBoundingClientRect();
+      // Without a pointer, keep whatever is in the middle of the viewport.
+      const px = clientX == null ? viewport.width / 2 : clientX - viewport.left;
+      const py = clientY == null ? viewport.height / 2 : clientY - viewport.top;
+      anchorRef.current =
+        page.width > 0 && page.height > 0
+          ? {
+              fx: (px + viewport.left - page.left) / page.width,
+              fy: (py + viewport.top - page.top) / page.height,
+              px,
+              py,
+            }
+          : null;
+    }
+    setZoom((current) => clampZoom(current * factor));
+  }, []);
+
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
+      // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
+      if (e.ctrlKey || e.metaKey || e.altKey) return;
+      if (e.key === '+' || e.key === '=') {
+        e.preventDefault();
+        zoomAt(ZOOM_STEP);
+      } else if (e.key === '-') {
+        e.preventDefault();
+        zoomAt(1 / ZOOM_STEP);
+      } else if (e.key === '0') {
+        e.preventDefault();
+        anchorRef.current = null;
+        setZoom(1);
+      }
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
+  }, [zoomAt]);
+
+  // Wheel: Ctrl/⌘ (which is also how a trackpad pinch arrives) always zooms;
+  // a plain wheel zooms only while the page fits the viewport, because then
+  // there is nothing to scroll. Native listener: React registers wheel as
+  // passive, so preventDefault — needed to keep the browser from zooming the
+  // whole page — would be ignored there.
+  useEffect(() => {
+    const container = containerRef.current;
+    if (!container) return;
+    const handleWheel = (e: WheelEvent) => {
+      if (!canvasRef.current || !baseCssSizeRef.current) return;
+      const modified = e.ctrlKey || e.metaKey;
+      if (!modified) {
+        const fits =
+          container.scrollHeight <= container.clientHeight && container.scrollWidth <= container.clientWidth;
+        if (!fits) return;
+      }
+      e.preventDefault();
+      const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
+      zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
+    };
+    container.addEventListener('wheel', handleWheel, { passive: false });
+    return () => container.removeEventListener('wheel', handleWheel);
+  }, [zoomAt]);
+
+  // Touch: one finger pans the page, two fingers pinch-zoom around their
+  // midpoint. `touch-action: none` on the container hands both gestures to
+  // these handlers instead of the browser's scroll / page-zoom.
+  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
+    pinchDistanceRef.current = null;
+  };
+
+  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    const pointers = pointersRef.current;
+    const previous = pointers.get(e.pointerId);
+    if (!previous) return;
+    pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+    if (pointers.size === 1) {
+      const container = containerRef.current;
+      if (container) {
+        container.scrollLeft -= e.clientX - previous.x;
+        container.scrollTop -= e.clientY - previous.y;
+      }
+      return;
+    }
+    if (pointers.size !== 2) return;
+    const [a, b] = Array.from(pointers.values());
+    const distance = Math.hypot(a.x - b.x, a.y - b.y);
+    const last = pinchDistanceRef.current;
+    pinchDistanceRef.current = distance;
+    if (last != null && last > 0 && distance > 0) {
+      zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
+    }
+  };
+
+  const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
+    if (e.pointerType !== 'touch') return;
+    pointersRef.current.delete(e.pointerId);
+    pinchDistanceRef.current = null;
+  };
+
+  // Show the new zoom right away by resizing the existing raster, and keep
+  // the anchored point under the pointer. The sharp re-render follows.
+  useLayoutEffect(() => {
+    zoomRef.current = zoom;
+    const canvas = canvasRef.current;
+    const container = containerRef.current;
+    const base = baseCssSizeRef.current;
+    if (!canvas || !container || !base) return;
+    canvas.style.width = `${base.width * zoom}px`;
+    canvas.style.height = `${base.height * zoom}px`;
+    const anchor = anchorRef.current;
+    anchorRef.current = null;
+    if (!anchor) return;
+    container.scrollLeft = canvas.offsetLeft + anchor.fx * canvas.offsetWidth - anchor.px;
+    container.scrollTop = canvas.offsetTop + anchor.fy * canvas.offsetHeight - anchor.py;
+  }, [zoom]);
+
+  useEffect(() => {
+    if (zoom === renderZoom) return;
+    const id = window.setTimeout(() => setRenderZoom(zoom), RERENDER_DEBOUNCE_MS);
+    return () => window.clearTimeout(id);
+  }, [zoom, renderZoom]);
+
+  // The page is fitted to the panel width, so a panel resize (fullscreen,
+  // window resize) needs a fresh fit. Watching the panel rather than the
+  // scroll container keeps a scrollbar appearing from re-fitting the page.
+  useEffect(() => {
+    const panel = panelRef.current;
+    if (!panel) return;
+    const observer = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
+    observer.observe(panel);
+    return () => observer.disconnect();
+  }, [panelRef]);
 
   // Load the document. pdf.js is imported on demand so the viewer and its
   // worker stay out of the main bundle.
@@ -98,14 +290,32 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
           await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url')
         ).default;
       }
-      loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
+      loadingTask = pdfjs.getDocument({
+        data: new Uint8Array(buffer),
+        cMapUrl: `${PDFJS_ASSET_BASE}cmaps/`,
+        iccUrl: `${PDFJS_ASSET_BASE}iccs/`,
+        standardFontDataUrl: `${PDFJS_ASSET_BASE}standard_fonts/`,
+        wasmUrl: `${PDFJS_ASSET_BASE}wasm/`,
+      });
+      if (cancelled) {
+        // The modal closed during the fetch/import above, so cleanup ran while
+        // `loadingTask` was still null and left this task — and its worker —
+        // running. Nothing else will destroy it.
+        loadingTask.destroy();
+        return;
+      }
       const loaded = await loadingTask.promise;
       if (cancelled) {
-        // Cleanup below already ran; destroying the task tears down the doc.
+        // Cleanup ran after the assignment above, so it destroyed the task
+        // already — and with it the document.
         return;
       }
       setDoc(loaded);
-    })().catch(() => {
+    })().catch((err: unknown) => {
+      // The reason never reaches the UI beyond a generic line, so leave it in
+      // the console: an HTTP status, a refused worker or a parser failure each
+      // need a different fix, and "cannot be previewed" hides which one it was.
+      console.error('[pdf-preview] load failed', err);
       if (!cancelled) {
         setError(t('fileManager.preview.error'));
         setRendering(false);
@@ -137,11 +347,16 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
       // Fit the page width to the panel at zoom 1; render at device pixels.
       const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
       const dpr = Math.min(window.devicePixelRatio || 1, 2);
-      const viewport = page.getViewport({ scale: fitScale * zoom * dpr });
+      const wanted = fitScale * renderZoom * dpr;
+      const pixels = baseViewport.width * wanted * (baseViewport.height * wanted);
+      const scale = pixels > MAX_CANVAS_PIXELS ? wanted * Math.sqrt(MAX_CANVAS_PIXELS / pixels) : wanted;
+      const viewport = page.getViewport({ scale });
       canvas.width = viewport.width;
       canvas.height = viewport.height;
-      canvas.style.width = `${viewport.width / dpr}px`;
-      canvas.style.height = `${viewport.height / dpr}px`;
+      baseCssSizeRef.current = { width: baseViewport.width * fitScale, height: baseViewport.height * fitScale };
+      // The displayed zoom may already be ahead of this raster.
+      canvas.style.width = `${baseCssSizeRef.current.width * zoomRef.current}px`;
+      canvas.style.height = `${baseCssSizeRef.current.height * zoomRef.current}px`;
 
       renderTaskRef.current?.cancel();
       const task = page.render({ canvas, viewport });
@@ -158,6 +373,7 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
     })().catch((err: unknown) => {
       // A cancelled render throws RenderingCancelledException — not an error.
       if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
+        console.error('[pdf-preview] render failed', err);
         setError(t('fileManager.preview.error'));
         setRendering(false);
       }
@@ -166,89 +382,90 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
     return () => {
       cancelled = true;
     };
-  }, [doc, pageNum, zoom, t]);
+  }, [doc, pageNum, renderZoom, layoutVersion, t]);
 
   const pageCount = doc?.numPages ?? 0;
+  const iconButtonClass = previewIconButtonClass;
 
   return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
-        {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 min-w-0">
-            <FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
-            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
-          </div>
-          <div className="flex items-center gap-2 flex-shrink-0">
-            {doc && pageCount > 1 && (
-              <div className="flex items-center gap-1 mr-2">
-                <button
-                  onClick={() => setPageNum((p) => Math.max(1, p - 1))}
-                  disabled={pageNum <= 1}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
-                  aria-label={t('fileManager.preview.prevPage')}
-                >
-                  <ChevronLeft className="w-4 h-4" />
-                </button>
-                <span className="text-sm text-bambu-gray whitespace-nowrap">
-                  {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
-                </span>
-                <button
-                  onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
-                  disabled={pageNum >= pageCount}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
-                  aria-label={t('fileManager.preview.nextPage')}
-                >
-                  <ChevronRight className="w-4 h-4" />
-                </button>
-              </div>
-            )}
-            {doc && (
-              <>
-                <button
-                  onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                  aria-label={t('fileManager.preview.zoomOut')}
-                >
-                  <ZoomOut className="w-4 h-4" />
-                </button>
-                <button
-                  onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
-                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                  aria-label={t('fileManager.preview.zoomIn')}
-                >
-                  <ZoomIn className="w-4 h-4" />
-                </button>
-              </>
-            )}
-            <button
-              onClick={onClose}
-              className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-              aria-label={t('common.close')}
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-        </div>
-
-        {/* Page */}
-        <div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
-          {error ? (
-            <div className="h-full flex items-center justify-center">
-              <p className="text-bambu-gray text-center">{error}</p>
-            </div>
-          ) : (
-            <div className="flex justify-center min-w-fit">
-              <canvas ref={canvasRef} className="shadow-lg" />
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+      actions={
+        <>
+          {doc && pageCount > 1 && (
+            <div className="flex items-center gap-1 mr-2">
+              <button
+                onClick={() => setPageNum((p) => Math.max(1, p - 1))}
+                disabled={pageNum <= 1}
+                className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
+                aria-label={t('fileManager.preview.prevPage')}
+              >
+                <ChevronLeft className="w-4 h-4" />
+              </button>
+              <span className="text-sm text-bambu-gray whitespace-nowrap">
+                {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
+              </span>
+              <button
+                onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
+                disabled={pageNum >= pageCount}
+                className={`${iconButtonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
+                aria-label={t('fileManager.preview.nextPage')}
+              >
+                <ChevronRight className="w-4 h-4" />
+              </button>
             </div>
           )}
-          {!error && rendering && (
-            <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
+          {doc && (
+            <>
+              <button
+                onClick={() => zoomAt(1 / ZOOM_STEP)}
+                className={iconButtonClass}
+                aria-label={t('fileManager.preview.zoomOut')}
+              >
+                <ZoomOut className="w-4 h-4" />
+              </button>
+              <button
+                onClick={() => zoomAt(ZOOM_STEP)}
+                className={iconButtonClass}
+                aria-label={t('fileManager.preview.zoomIn')}
+              >
+                <ZoomIn className="w-4 h-4" />
+              </button>
+            </>
           )}
-        </div>
+        </>
+      }
+    >
+      {/* Page */}
+      <div
+        ref={containerRef}
+        data-testid="pdf-preview-page"
+        className={`relative flex-1 min-h-0 overflow-auto bg-bambu-dark p-4 touch-none ${isFullscreen ? '' : 'rounded-b-lg'}`}
+        onDoubleClick={toggleFullscreen}
+        onPointerDown={handlePointerDown}
+        onPointerMove={handlePointerMove}
+        onPointerUp={handlePointerEnd}
+        onPointerCancel={handlePointerEnd}
+        onPointerLeave={handlePointerEnd}
+      >
+        {error ? (
+          <div className="h-full flex items-center justify-center">
+            <p className="text-bambu-gray text-center">{error}</p>
+          </div>
+        ) : (
+          <div className="flex justify-center min-w-fit">
+            <canvas ref={canvasRef} className="shadow-lg" />
+          </div>
+        )}
+        {!error && rendering && (
+          <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+            <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+          </div>
+        )}
       </div>
-    </div>
+    </PreviewModalShell>
   );
 }

+ 101 - 0
frontend/src/components/PreviewModalShell.tsx

@@ -0,0 +1,101 @@
+import { useEffect, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Maximize2, Minimize2, X } from 'lucide-react';
+import type { PreviewFullscreen } from '../hooks/usePreviewFullscreen';
+
+// One size for every file preview (#2976). Each modal used to carry its own
+// max-width (4xl/5xl/6xl at 80-85vh), which left most of a wide screen empty
+// for a window the user opened precisely to look at something closely.
+// min() keeps the panel off the edges on a laptop and stops it stretching to
+// a wall of pixels on an ultrawide.
+export const PREVIEW_PANEL_SIZE_CLASS = 'w-[min(1800px,96vw)] h-[94vh]';
+
+// Header icon buttons of every preview.
+export const previewIconButtonClass =
+  'p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors';
+
+interface PreviewModalShellProps {
+  title: string;
+  fullscreen: PreviewFullscreen;
+  onClose: () => void;
+  /** Type icon left of the title. */
+  icon?: ReactNode;
+  /** Rendered after the title, e.g. the 3D viewer's object-count badge. */
+  titleExtra?: ReactNode;
+  /** This preview's own header buttons, left of the fullscreen/close pair. */
+  actions?: ReactNode;
+  /** The 3D viewer closes on a backdrop click; the document previews do not. */
+  closeOnBackdropClick?: boolean;
+  /** Panel content: tabs, body and footers, as direct flex children. */
+  children: ReactNode;
+}
+
+/**
+ * Backdrop, panel sizing and header row shared by every file preview (#2976).
+ *
+ * Esc closes the modal, except while the browser owns it: in fullscreen Esc
+ * leaves fullscreen and the preview stays open. Double-click-to-fullscreen is
+ * wired by each modal on its own content area, because only the modal knows
+ * which part of the panel is the preview.
+ */
+export function PreviewModalShell({
+  title,
+  fullscreen,
+  onClose,
+  icon,
+  titleExtra,
+  actions,
+  closeOnBackdropClick,
+  children,
+}: PreviewModalShellProps) {
+  const { t } = useTranslation();
+  const { panelRef, isFullscreen, toggleFullscreen } = fullscreen;
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !document.fullscreenElement) onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div
+      className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-2 sm:p-4'}`}
+      onClick={closeOnBackdropClick ? onClose : undefined}
+    >
+      <div
+        ref={panelRef}
+        className={`bg-bambu-dark-secondary border border-bambu-dark-tertiary flex flex-col ${
+          isFullscreen
+            ? 'w-full h-full max-w-none rounded-none'
+            : `${PREVIEW_PANEL_SIZE_CLASS} max-w-full max-h-full rounded-lg`
+        }`}
+        onClick={closeOnBackdropClick ? (e) => e.stopPropagation() : undefined}
+      >
+        <div className="flex items-center justify-between gap-4 p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+          <div className="flex items-center gap-2 min-w-0">
+            {icon}
+            <h2 className="text-lg font-semibold text-white truncate">{title}</h2>
+            {titleExtra}
+          </div>
+          <div className="flex items-center gap-2 flex-shrink-0">
+            {actions}
+            <button
+              onClick={toggleFullscreen}
+              className={previewIconButtonClass}
+              aria-label={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
+              title={isFullscreen ? t('fileManager.preview.exitFullscreen') : t('fileManager.preview.fullscreen')}
+            >
+              {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
+            </button>
+            <button onClick={onClose} className={previewIconButtonClass} aria-label={t('common.close')}>
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+        </div>
+        {children}
+      </div>
+    </div>
+  );
+}

+ 14 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -428,6 +428,7 @@ export function SpoolFormModal({
           cost_per_kg: spool.cost_per_kg ?? null,
           category: spool.category || '',
           low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
+          material_number: spool.material_number || '',
           location_id: spool.location_id ?? null,
           spoolman_filament_id: null,
         });
@@ -786,6 +787,16 @@ export function SpoolFormModal({
     }
     return Array.from(set).sort((a, b) => a.localeCompare(b));
   })();
+  // Autocomplete for the internal material number (#2870), mirroring the
+  // category datalist above.
+  const availableMaterialNumbers = (() => {
+    const set = new Set<string>();
+    for (const s of allSpools ?? []) {
+      const n = s.material_number?.trim();
+      if (n) set.add(n);
+    }
+    return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+  })();
   const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
 
   const unassignMutation = useMutation({
@@ -968,6 +979,7 @@ export function SpoolFormModal({
       cost_per_kg: formData.cost_per_kg,
       category: formData.category.trim() || null,
       low_stock_threshold_pct: formData.low_stock_threshold_pct,
+      material_number: formData.material_number.trim() || null,
       ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
     };
 
@@ -1174,6 +1186,7 @@ export function SpoolFormModal({
                   spoolCatalog={spoolCatalog}
                   currencySymbol={currencySymbol}
                   availableCategories={availableCategories}
+                  availableMaterialNumbers={availableMaterialNumbers}
                   availableLocations={storageLocations}
                   onCreateLocation={async (name) => {
                     try {
@@ -1192,6 +1205,7 @@ export function SpoolFormModal({
                     }
                   }}
                   globalLowStockThreshold={globalLowStockThreshold}
+                  spoolmanMode={spoolmanMode}
                 />
               </div>
 

+ 118 - 109
frontend/src/components/SpreadsheetPreviewModal.tsx

@@ -1,8 +1,10 @@
 import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { FileSpreadsheet, Loader2, X } from 'lucide-react';
+import { FileSpreadsheet, Loader2 } from 'lucide-react';
 import { api, getAuthToken } from '../api/client';
 import { formatFileSize } from '../utils/file';
+import { PreviewModalShell } from './PreviewModalShell';
+import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
 
 // Parsing an arbitrarily large workbook would freeze the tab — anything over
 // this size (or beyond the row/column caps) falls back to a truncation notice.
@@ -101,14 +103,8 @@ export function SpreadsheetPreviewModal({
   useEffect(() => {
     onSnapshotRef.current = onSnapshot;
   });
-
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
+  const fullscreen = usePreviewFullscreen();
+  const { isFullscreen, toggleFullscreen } = fullscreen;
 
   useEffect(() => {
     let cancelled = false;
@@ -135,15 +131,33 @@ export function SpreadsheetPreviewModal({
         // papaparse is loaded on demand so it stays out of the main bundle.
         const Papa = (await import('papaparse')).default;
         const text = new TextDecoder().decode(buffer);
-        const result = Papa.parse<string[]>(text, { skipEmptyLines: false });
-        const all = result.data.filter((row) => Array.isArray(row));
-        // A trailing newline parses as one empty row — drop trailing blanks.
-        while (all.length > 0 && all[all.length - 1].every((cell) => !cell)) {
-          all.pop();
-        }
-        const totalRows = all.length;
-        const totalCols = all.reduce((max, row) => Math.max(max, row.length), 0);
-        const rows = all.slice(0, MAX_ROWS).map((row) => row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
+        const rows: string[][] = [];
+        let totalRows = 0;
+        let totalCols = 0;
+        // A trailing newline parses as one empty row, and only a *trailing*
+        // run of blanks is dropped — so blanks are held back until a later
+        // row proves they were interior.
+        let pendingBlanks: string[][] = [];
+        // Counted and capped row by row, so the whole file is never
+        // materialised as rows the preview then throws away.
+        const take = (row: string[]) => {
+          totalRows += 1;
+          totalCols = Math.max(totalCols, row.length);
+          if (rows.length < MAX_ROWS) rows.push(row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
+        };
+        Papa.parse<string[]>(text, {
+          skipEmptyLines: false,
+          step: ({ data }) => {
+            if (!Array.isArray(data)) return;
+            if (data.every((cell) => !cell)) {
+              pendingBlanks.push(data);
+              return;
+            }
+            pendingBlanks.forEach(take);
+            pendingBlanks = [];
+            take(data);
+          },
+        });
         parsed = [{ name: filename, rows, totalRows, totalCols }];
       } else {
         // SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
@@ -186,7 +200,8 @@ export function SpreadsheetPreviewModal({
           if (blob && !cancelled) onSnapshotRef.current?.(blob);
         }
       }
-    })().catch(() => {
+    })().catch((err: unknown) => {
+      console.error('[sheet-preview] load failed', err);
       if (!cancelled) setError(t('fileManager.preview.error'));
     });
 
@@ -201,105 +216,99 @@ export function SpreadsheetPreviewModal({
   const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
 
   return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
-      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-6xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
-        {/* Header */}
-        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-          <div className="flex items-center gap-2 min-w-0">
-            <FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />
-            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
-          </div>
-          <button
-            onClick={onClose}
-            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-            aria-label={t('common.close')}
-          >
-            <X className="w-5 h-5" />
-          </button>
+    <PreviewModalShell
+      title={filename}
+      fullscreen={fullscreen}
+      onClose={onClose}
+      icon={<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />}
+    >
+      {/* Sheet tabs */}
+      {sheets && sheets.length > 1 && (
+        <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
+          {sheets.map((s, index) => (
+            <button
+              key={`${s.name}-${index}`}
+              onClick={() => setActiveSheet(index)}
+              className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
+                index === activeSheet
+                  ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
+                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
+              }`}
+            >
+              {s.name}
+            </button>
+          ))}
         </div>
+      )}
 
-        {/* Sheet tabs */}
-        {sheets && sheets.length > 1 && (
-          <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
-            {sheets.map((s, index) => (
-              <button
-                key={`${s.name}-${index}`}
-                onClick={() => setActiveSheet(index)}
-                className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
-                  index === activeSheet
-                    ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
-                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
-                }`}
-              >
-                {s.name}
-              </button>
-            ))}
+      {/* Content */}
+      <div
+        data-testid="spreadsheet-preview-content"
+        className={`flex-1 min-h-0 overflow-auto bg-bambu-dark ${isFullscreen ? '' : 'rounded-b-lg'}`}
+        onDoubleClick={toggleFullscreen}
+      >
+        {error ? (
+          <div className="h-full flex items-center justify-center p-6">
+            <p className="text-bambu-gray text-center">{error}</p>
           </div>
-        )}
-
-        {/* Content */}
-        <div className="flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg">
-          {error ? (
-            <div className="h-full flex items-center justify-center p-6">
-              <p className="text-bambu-gray text-center">{error}</p>
-            </div>
-          ) : !sheets ? (
-            <div className="h-full flex items-center justify-center">
-              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-            </div>
-          ) : !sheet || sheet.rows.length === 0 ? (
-            <div className="h-full flex items-center justify-center p-6">
-              <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
-            </div>
-          ) : (
-            <table className="border-collapse text-xs">
-              <thead>
-                <tr>
-                  <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
+        ) : !sheets ? (
+          <div className="h-full flex items-center justify-center">
+            <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+          </div>
+        ) : !sheet || sheet.rows.length === 0 ? (
+          <div className="h-full flex items-center justify-center p-6">
+            <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
+          </div>
+        ) : (
+          // min-w-full so a narrow sheet spreads across the wider panel
+          // instead of huddling in its top-left corner (#2976).
+          <table className="border-collapse text-xs min-w-full">
+            <thead>
+              <tr>
+                <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
+                {Array.from({ length: shownCols }, (_, c) => (
+                  <th
+                    key={c}
+                    className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
+                  >
+                    {columnLabel(c)}
+                  </th>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {sheet.rows.map((row, r) => (
+                <tr key={r}>
+                  <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
+                    {r + 1}
+                  </td>
                   {Array.from({ length: shownCols }, (_, c) => (
-                    <th
+                    <td
                       key={c}
-                      className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
+                      className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
+                      title={row[c] || undefined}
                     >
-                      {columnLabel(c)}
-                    </th>
+                      {row[c] ?? ''}
+                    </td>
                   ))}
                 </tr>
-              </thead>
-              <tbody>
-                {sheet.rows.map((row, r) => (
-                  <tr key={r}>
-                    <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
-                      {r + 1}
-                    </td>
-                    {Array.from({ length: shownCols }, (_, c) => (
-                      <td
-                        key={c}
-                        className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
-                        title={row[c] || undefined}
-                      >
-                        {row[c] ?? ''}
-                      </td>
-                    ))}
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          )}
-        </div>
-
-        {/* Truncation notices */}
-        {(rowsTruncated || colsTruncated) && sheet && (
-          <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
-            {rowsTruncated && (
-              <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
-            )}
-            {colsTruncated && (
-              <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
-            )}
-          </div>
+              ))}
+            </tbody>
+          </table>
         )}
       </div>
-    </div>
+
+      {/* Truncation notices */}
+      {(rowsTruncated || colsTruncated) && sheet && (
+        <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
+          {rowsTruncated && (
+            <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
+          )}
+          {colsTruncated && (
+            <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
+          )}
+        </div>
+      )}
+    </PreviewModalShell>
   );
 }

+ 29 - 0
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -174,9 +174,11 @@ export function AdditionalSection({
   spoolCatalog,
   currencySymbol,
   availableCategories,
+  availableMaterialNumbers,
   availableLocations = [],
   onCreateLocation,
   globalLowStockThreshold,
+  spoolmanMode = false,
 }: AdditionalSectionProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
@@ -318,6 +320,33 @@ export function AdditionalSection({
         </div>
       </div>
 
+      {/* Material number (#2870). Hidden in Spoolman mode: there the number
+          is Spoolman's filament-level article_number, maintained in Spoolman
+          itself and surfaced read-only in the list. */}
+      {!spoolmanMode && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-material-number">
+            {t('inventory.materialNumber')}
+          </label>
+          <input
+            id="spool-material-number"
+            type="text"
+            list="spool-material-number-options"
+            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.materialNumberPlaceholder')}
+            value={formData.material_number}
+            maxLength={64}
+            onChange={(e) => updateField('material_number', e.target.value)}
+          />
+          {availableMaterialNumbers.length > 0 && (
+            <datalist id="spool-material-number-options">
+              {availableMaterialNumbers.map((n) => <option key={n} value={n} />)}
+            </datalist>
+          )}
+          <p className="text-xs text-bambu-gray mt-1">{t('inventory.materialNumberHelp')}</p>
+        </div>
+      )}
+
       {/* Category (#729) */}
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-category">

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

@@ -41,6 +41,9 @@ export interface SpoolFormData {
   // User-defined category + per-spool low-stock threshold override (#729).
   category: string;
   low_stock_threshold_pct: number | null;
+  // Internal material / article number (#2870) — the purchasing identifier
+  // shared by all spools of the same product. Free text.
+  material_number: string;
   location_id: number | null;
   // When set the spool is linked to a specific Spoolman filament catalog entry;
   // the backend skips find_or_create_filament() and uses this ID directly.
@@ -64,6 +67,7 @@ export const defaultFormData: SpoolFormData = {
   cost_per_kg: null,
   category: '',
   low_stock_threshold_pct: null,
+  material_number: '',
   location_id: null,
   spoolman_filament_id: null,
 };
@@ -207,11 +211,18 @@ export interface AdditionalSectionProps extends SectionProps {
   // datalist so users naturally re-use existing names instead of creating
   // near-duplicates ("Production" vs "production" vs "prod"). #729
   availableCategories: string[];
+  // Material numbers already used on other spools — same autocomplete idea
+  // as availableCategories, for the internal article number (#2870).
+  availableMaterialNumbers: string[];
   // Global low-stock threshold (%); shown as placeholder on the per-spool
   // override input so users see what they're overriding. #729
   globalLowStockThreshold: number;
   availableLocations?: { id: number; name: string }[];
   onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
+  // When true the material number input is hidden: in Spoolman mode the
+  // number is Spoolman's filament-level article_number, maintained in
+  // Spoolman itself and shown read-only in the list (#2870).
+  spoolmanMode?: boolean;
 }
 
 // PA Profile section props

+ 72 - 0
frontend/src/hooks/useElementFullscreen.ts

@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
+
+// True where the unprefixed Fullscreen API can be used on an arbitrary
+// element. iPhone Safari only offers it for <video>, and an <iframe> without
+// allowfullscreen reports fullscreenEnabled === false.
+export function fullscreenApiAvailable(): boolean {
+  return (
+    typeof document !== 'undefined' &&
+    document.fullscreenEnabled === true &&
+    typeof document.documentElement.requestFullscreen === 'function'
+  );
+}
+
+/**
+ * Fullscreen for a preview panel (#2976).
+ *
+ * Uses the Fullscreen API on the element behind `ref` where the browser has
+ * it, and otherwise a viewport-filling layout driven by the same
+ * `isFullscreen` flag, so the toggle keeps working on iPhone Safari and in
+ * embedded views that refuse the request (a webview without the fullscreen
+ * permission rejects it even though `fullscreenEnabled` says yes). In API
+ * mode the flag follows `fullscreenchange`: Esc is handled by the browser and
+ * simply shows up as a change, and the panel leaves fullscreen on unmount so
+ * closing the modal never strands the document in fullscreen.
+ */
+export function useElementFullscreen(ref: RefObject<HTMLElement | null>) {
+  const apiAvailable = fullscreenApiAvailable();
+  const [isFullscreen, setIsFullscreen] = useState(false);
+  // True while the viewport-filling fallback is what the user sees.
+  const fallbackActiveRef = useRef(false);
+
+  useEffect(() => {
+    if (!apiAvailable) return;
+    const element = ref.current;
+    const sync = () => {
+      if (fallbackActiveRef.current) return;
+      setIsFullscreen(document.fullscreenElement != null && document.fullscreenElement === ref.current);
+    };
+    document.addEventListener('fullscreenchange', sync);
+    return () => {
+      document.removeEventListener('fullscreenchange', sync);
+      if (element && document.fullscreenElement === element) {
+        Promise.resolve(document.exitFullscreen()).catch(() => {});
+      }
+    };
+  }, [apiAvailable, ref]);
+
+  const toggleFullscreen = useCallback(() => {
+    if (fallbackActiveRef.current) {
+      fallbackActiveRef.current = false;
+      setIsFullscreen(false);
+      return;
+    }
+    if (apiAvailable && document.fullscreenElement) {
+      Promise.resolve(document.exitFullscreen()).catch(() => {});
+      return;
+    }
+    const element = ref.current;
+    if (!element) return;
+    const enterFallback = () => {
+      fallbackActiveRef.current = true;
+      setIsFullscreen(true);
+    };
+    if (!apiAvailable) {
+      enterFallback();
+      return;
+    }
+    Promise.resolve(element.requestFullscreen()).catch(enterFallback);
+  }, [apiAvailable, ref]);
+
+  return { isFullscreen, toggleFullscreen, apiAvailable };
+}

+ 22 - 0
frontend/src/hooks/usePreviewFullscreen.ts

@@ -0,0 +1,22 @@
+import { useRef, type RefObject } from 'react';
+import { useElementFullscreen } from './useElementFullscreen';
+
+export interface PreviewFullscreen {
+  panelRef: RefObject<HTMLDivElement | null>;
+  isFullscreen: boolean;
+  toggleFullscreen: () => void;
+}
+
+/**
+ * Fullscreen state for a file preview panel (#2976).
+ *
+ * Held by the modal rather than by `PreviewModalShell`, which renders the
+ * toggle: the 3D viewer drives layout effects off `isFullscreen` — in
+ * fullscreen the plate list splits off the canvas — so the flag has to be
+ * readable outside the shell's own subtree.
+ */
+export function usePreviewFullscreen(): PreviewFullscreen {
+  const panelRef = useRef<HTMLDivElement>(null);
+  const { isFullscreen, toggleFullscreen } = useElementFullscreen(panelRef);
+  return { panelRef, isFullscreen, toggleFullscreen };
+}

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

@@ -409,6 +409,8 @@ export function useWebSocket() {
         debouncedInvalidate('spoolman-inventory-spools');
         debouncedInvalidate(inventoryLocationsQueryKey[0]);
         debouncedInvalidate(inventorySuppliersQueryKey[0]);
+        // The per-material-number aggregate is derived from the same rows (#2870).
+        debouncedInvalidate('material-number-stats');
         break;
 
       case 'spool_assignment_changed':

+ 18 - 0
frontend/src/i18n/locales/de.ts

@@ -1671,6 +1671,16 @@ export default {
     printActivity: 'Druckaktivität',
     filamentTypes: 'Filamenttypen',
     filamentTrends: 'Filamenttrends',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Nach Materialnummer',
+      empty: 'Noch keine Materialnummern vergeben. Weise sie Spulen im Bestand zu, um Verbrauch und Kosten hier zu gruppieren.',
+      loadFailed: 'Die Materialnummern-Statistik konnte nicht geladen werden.',
+      spools: 'Spulen',
+      remaining: 'Verbleibend',
+      consumed: 'Verbraucht',
+      cost: 'Kosten',
+    },
     // Consumption/cost grouped by purchase-source supplier (#2988)
     suppliers: {
       title: 'Nach Lieferant',
@@ -4054,6 +4064,9 @@ export default {
       nextPage: 'Nächste Seite',
       zoomIn: 'Vergrößern',
       zoomOut: 'Verkleinern',
+      resetZoom: 'Zoom zurücksetzen',
+      fullscreen: 'Vollbild',
+      exitFullscreen: 'Vollbild beenden',
       emptySheet: 'Dieses Tabellenblatt ist leer',
       truncatedRows: 'Die ersten {{shown}} von {{total}} Zeilen werden angezeigt',
       truncatedCols: 'Die ersten {{shown}} von {{total}} Spalten werden angezeigt',
@@ -4826,6 +4839,11 @@ export default {
     storageLocationNone: 'Kein Lagerort',
     lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
     lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
+    // Internal material / article number (#2870)
+    materialNumber: 'Material-Nr.',
+    materialNumberPlaceholder: 'z. B. 15',
+    materialNumberHelp: 'Interne Einkaufsnummer - wird von allen Spulen dieses Produkts geteilt. Neue Spulen desselben Produkts übernehmen sie.',
+    materialNumberNone: 'Keine Materialnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
       label: 'Lieferanten',

+ 18 - 0
frontend/src/i18n/locales/en.ts

@@ -1688,6 +1688,16 @@ export default {
     printActivity: 'Print Activity',
     filamentTypes: 'Filament Types',
     filamentTrends: 'Filament Trends',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'By Material Number',
+      empty: 'No material numbers assigned yet. Add them to spools in the inventory to group consumption and costs here.',
+      loadFailed: 'Could not load the material number statistics.',
+      spools: 'Spools',
+      remaining: 'Remaining',
+      consumed: 'Consumed',
+      cost: 'Cost',
+    },
     // Consumption/cost grouped by purchase-source supplier (#2988).
     suppliers: {
       title: 'By Supplier',
@@ -4084,6 +4094,9 @@ export default {
       nextPage: 'Next page',
       zoomIn: 'Zoom in',
       zoomOut: 'Zoom out',
+      resetZoom: 'Reset zoom',
+      fullscreen: 'Fullscreen',
+      exitFullscreen: 'Exit fullscreen',
       emptySheet: 'This sheet is empty',
       truncatedRows: 'Showing the first {{shown}} of {{total}} rows',
       truncatedCols: 'Showing the first {{shown}} of {{total}} columns',
@@ -4866,6 +4879,11 @@ export default {
     storageLocationNone: 'No location set',
     lowStockThresholdOverride: 'Low-stock threshold (this spool)',
     lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
+    // Internal material / article number (#2870)
+    materialNumber: 'Material No.',
+    materialNumberPlaceholder: 'e.g. 15',
+    materialNumberHelp: 'Internal purchasing number — shared by all spools of this product. New spools of the same product inherit it.',
+    materialNumberNone: 'No material number',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
       label: 'Suppliers',

+ 18 - 0
frontend/src/i18n/locales/es.ts

@@ -1671,6 +1671,16 @@ export default {
     printActivity: 'Actividad de impresión',
     filamentTypes: 'Tipos de filamento',
     filamentTrends: 'Tendencias del filamento',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Por número de material',
+      empty: 'Aún no hay números de material asignados. Añádelos a las bobinas en el inventario para agrupar aquí el consumo y los costes.',
+      loadFailed: 'No se han podido cargar las estadísticas por número de material.',
+      spools: 'Bobinas',
+      remaining: 'Restante',
+      consumed: 'Consumido',
+      cost: 'Coste',
+    },
     // Consumption/cost grouped by purchase-source supplier (#2988).
     suppliers: {
       title: 'Por proveedor',
@@ -4056,6 +4066,9 @@ export default {
       nextPage: 'Página siguiente',
       zoomIn: 'Acercar',
       zoomOut: 'Alejar',
+      resetZoom: 'Restablecer zoom',
+      fullscreen: 'Pantalla completa',
+      exitFullscreen: 'Salir de la pantalla completa',
       emptySheet: 'Esta hoja está vacía',
       truncatedRows: 'Mostrando las primeras {{shown}} de {{total}} filas',
       truncatedCols: 'Mostrando las primeras {{shown}} de {{total}} columnas',
@@ -4829,6 +4842,11 @@ export default {
     storageLocationNone: 'Sin ubicación establecida',
     lowStockThresholdOverride: 'Umbral de existencias bajas (esta bobina)',
     lowStockThresholdOverrideHelp: 'Déjelo en blanco para usar el umbral global ({{global}}%).',
+    // Internal material / article number (#2870)
+    materialNumber: 'N.º de material',
+    materialNumberPlaceholder: 'p. ej. 15',
+    materialNumberHelp: 'Número interno de compras: compartido por todas las bobinas de este producto. Las bobinas nuevas del mismo producto lo heredan.',
+    materialNumberNone: 'Sin número de material',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
       label: 'Proveedores',

+ 18 - 0
frontend/src/i18n/locales/fr.ts

@@ -1671,6 +1671,16 @@ export default {
     printActivity: 'Activité d\'impression',
     filamentTypes: 'Types de filament',
     filamentTrends: 'Tendances filament',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Par numéro de matière',
+      empty: 'Aucun numéro de matière attribué pour l\'instant. Ajoutez-en aux bobines dans l\'inventaire pour regrouper ici la consommation et les coûts.',
+      loadFailed: 'Impossible de charger les statistiques par numéro de matière.',
+      spools: 'Bobines',
+      remaining: 'Restant',
+      consumed: 'Consommé',
+      cost: 'Coût',
+    },
     suppliers: {
       title: 'Par fournisseur',
       empty: 'Aucun achat enregistré pour le moment. Indiquez sur une bobine auprès de quel fournisseur elle a été achetée pour regrouper ici la consommation et les coûts.',
@@ -4042,6 +4052,9 @@ export default {
       nextPage: 'Page suivante',
       zoomIn: 'Zoom avant',
       zoomOut: 'Zoom arrière',
+      resetZoom: 'Réinitialiser le zoom',
+      fullscreen: 'Plein écran',
+      exitFullscreen: 'Quitter plein écran',
       emptySheet: 'Cette feuille est vide',
       truncatedRows: 'Affichage des {{shown}} premières lignes sur {{total}}',
       truncatedCols: 'Affichage des {{shown}} premières colonnes sur {{total}}',
@@ -4814,6 +4827,11 @@ export default {
     storageLocationNone: 'Aucun emplacement défini',
     lowStockThresholdOverride: 'Seuil bas (cette bobine)',
     lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
+    // Internal material / article number (#2870)
+    materialNumber: 'N° matière',
+    materialNumberPlaceholder: 'ex. 15',
+    materialNumberHelp: 'Numéro d\'achat interne - partagé par toutes les bobines de ce produit. Les nouvelles bobines du même produit en héritent.',
+    materialNumberNone: 'Aucun numéro de matière',
     suppliers: {
       label: 'Fournisseurs',
       none: 'Aucun fournisseur',

+ 18 - 0
frontend/src/i18n/locales/it.ts

@@ -1671,6 +1671,16 @@ export default {
     printActivity: 'Attivita di stampa',
     filamentTypes: 'Tipi di filamento',
     filamentTrends: 'Trend filamento',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Per numero materiale',
+      empty: 'Nessun numero materiale assegnato. Aggiungili alle bobine nell\'inventario per raggruppare qui consumi e costi.',
+      loadFailed: 'Impossibile caricare le statistiche per numero materiale.',
+      spools: 'Bobine',
+      remaining: 'Rimanente',
+      consumed: 'Consumato',
+      cost: 'Costo',
+    },
     suppliers: {
       title: 'Per fornitore',
       empty: 'Nessun acquisto registrato. Indica su una bobina da quale fornitore è stata acquistata per raggruppare qui consumi e costi.',
@@ -4041,6 +4051,9 @@ export default {
       nextPage: 'Pagina successiva',
       zoomIn: 'Ingrandisci',
       zoomOut: 'Riduci',
+      resetZoom: 'Reimposta zoom',
+      fullscreen: 'Schermo intero',
+      exitFullscreen: 'Esci da schermo intero',
       emptySheet: 'Questo foglio è vuoto',
       truncatedRows: 'Visualizzate le prime {{shown}} righe di {{total}}',
       truncatedCols: 'Visualizzate le prime {{shown}} colonne di {{total}}',
@@ -4813,6 +4826,11 @@ export default {
     storageLocationNone: 'Nessuna posizione impostata',
     lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
     lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
+    // Internal material / article number (#2870)
+    materialNumber: 'N. materiale',
+    materialNumberPlaceholder: 'es. 15',
+    materialNumberHelp: 'Numero interno di acquisto - condiviso da tutte le bobine di questo prodotto. Le nuove bobine dello stesso prodotto lo ereditano.',
+    materialNumberNone: 'Nessun numero materiale',
     suppliers: {
       label: 'Fornitori',
       none: 'Nessun fornitore',

+ 18 - 0
frontend/src/i18n/locales/ja.ts

@@ -1670,6 +1670,16 @@ export default {
     printActivity: '印刷アクティビティ',
     filamentTypes: 'フィラメントタイプ',
     filamentTrends: 'フィラメントトレンド',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: '資材番号別',
+      empty: 'まだ資材番号が割り当てられていません。在庫のスプールに資材番号を追加すると、ここで消費量とコストをまとめて確認できます。',
+      loadFailed: '資材番号の統計を読み込めませんでした。',
+      spools: 'スプール',
+      remaining: '残量',
+      consumed: '消費量',
+      cost: 'コスト',
+    },
     // Consumption/cost grouped by purchase-source supplier (#2988).
     suppliers: {
       title: 'サプライヤー別',
@@ -4054,6 +4064,9 @@ export default {
       nextPage: '次のページ',
       zoomIn: '拡大',
       zoomOut: '縮小',
+      resetZoom: 'ズームをリセット',
+      fullscreen: 'フルスクリーン',
+      exitFullscreen: 'フルスクリーンを終了',
       emptySheet: 'このシートは空です',
       truncatedRows: '全{{total}}行のうち最初の{{shown}}行を表示しています',
       truncatedCols: '全{{total}}列のうち最初の{{shown}}列を表示しています',
@@ -4826,6 +4839,11 @@ export default {
     storageLocationNone: '保管場所未設定',
     lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
     lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
+    // Internal material / article number (#2870)
+    materialNumber: '資材番号',
+    materialNumberPlaceholder: '例:15',
+    materialNumberHelp: '社内の購買番号です。同じ製品のすべてのスプールで共有され、同じ製品の新しいスプールに自動的に引き継がれます。',
+    materialNumberNone: '資材番号なし',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
       label: 'サプライヤー',

+ 18 - 0
frontend/src/i18n/locales/ko.ts

@@ -1604,6 +1604,16 @@ export default {
     printActivity: '인쇄 활동',
     filamentTypes: '필라멘트 종류',
     filamentTrends: '필라멘트 추세',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: '자재 번호별',
+      empty: '아직 지정된 자재 번호가 없습니다. 인벤토리에서 스풀에 자재 번호를 추가하면 여기에서 소비량과 비용을 그룹화할 수 있습니다.',
+      loadFailed: '자재 번호 통계를 불러오지 못했습니다.',
+      spools: '스풀',
+      remaining: '남은 양',
+      consumed: '소비량',
+      cost: '비용',
+    },
     suppliers: {
       title: '공급업체별',
       empty: '아직 기록된 구매가 없습니다. 스풀에 구매한 공급업체를 표시하면 여기에서 소비량과 비용이 그룹화됩니다.',
@@ -3863,6 +3873,9 @@ export default {
       nextPage: '다음 페이지',
       zoomIn: '확대',
       zoomOut: '축소',
+      resetZoom: '확대/축소 초기화',
+      fullscreen: '전체 화면',
+      exitFullscreen: '전체 화면 종료',
       emptySheet: '이 시트는 비어 있습니다',
       truncatedRows: '전체 {{total}}개 행 중 처음 {{shown}}개 표시 중',
       truncatedCols: '전체 {{total}}개 열 중 처음 {{shown}}개 표시 중',
@@ -4610,6 +4623,11 @@ export default {
     categoryNone: '미분류',
     lowStockThresholdOverride: '재고 부족 임계값 (이 스풀)',
     lowStockThresholdOverrideHelp: '전역 임계값({{global}}%)을 사용하려면 비워두세요.',
+    // Internal material / article number (#2870)
+    materialNumber: '자재 번호',
+    materialNumberPlaceholder: '예: 15',
+    materialNumberHelp: '내부 구매 번호 - 이 제품의 모든 스풀이 공유합니다. 같은 제품의 새 스풀은 이 번호를 이어받습니다.',
+    materialNumberNone: '자재 번호 없음',
     suppliers: {
       label: '공급업체',
       none: '공급업체 없음',

+ 18 - 0
frontend/src/i18n/locales/nl.ts

@@ -1688,6 +1688,16 @@ export default {
     printActivity: 'Afdrukactiviteit',
     filamentTypes: 'Filamenttypen',
     filamentTrends: 'Filamenttrends',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Per materiaalnummer',
+      empty: 'Nog geen materiaalnummers toegewezen. Voeg ze toe aan spoelen in de voorraad om verbruik en kosten hier te groeperen.',
+      loadFailed: 'De statistieken per materiaalnummer konden niet worden geladen.',
+      spools: 'Spoelen',
+      remaining: 'Resterend',
+      consumed: 'Verbruikt',
+      cost: 'Kosten',
+    },
     // Consumption/cost grouped by purchase-source supplier (#2988).
     suppliers: {
       title: 'Per leverancier',
@@ -4084,6 +4094,9 @@ export default {
       nextPage: 'Volgende pagina',
       zoomIn: 'Inzoomen',
       zoomOut: 'Uitzoomen',
+      resetZoom: 'Zoom herstellen',
+      fullscreen: 'Volledig scherm',
+      exitFullscreen: 'Volledig scherm afsluiten',
       emptySheet: 'Dit werkblad is leeg',
       truncatedRows: 'Eerste {{shown}} van {{total}} rijen worden getoond',
       truncatedCols: 'Eerste {{shown}} van {{total}} kolommen worden getoond',
@@ -4866,6 +4879,11 @@ export default {
     storageLocationNone: 'Geen locatie ingesteld',
     lowStockThresholdOverride: 'Drempel lage voorraad (deze spoel)',
     lowStockThresholdOverrideHelp: 'Laat leeg om de globale drempel ({{global}}%) te gebruiken.',
+    // Internal material / article number (#2870)
+    materialNumber: 'Materiaalnr.',
+    materialNumberPlaceholder: 'bijv. 15',
+    materialNumberHelp: 'Intern inkoopnummer - gedeeld door alle spoelen van dit product. Nieuwe spoelen van hetzelfde product nemen het over.',
+    materialNumberNone: 'Geen materiaalnummer',
     // Suppliers (#2988): the master list and the per-spool assignments.
     suppliers: {
       label: 'Leveranciers',

+ 18 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1671,6 +1671,16 @@ export default {
     printActivity: 'Atividade de Impressão',
     filamentTypes: 'Tipos de Filamento',
     filamentTrends: 'Tendências de Filamento',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Por Número de Material',
+      empty: 'Nenhum número de material atribuído ainda. Adicione-os aos carretéis no inventário para agrupar consumo e custos aqui.',
+      loadFailed: 'Não foi possível carregar as estatísticas por número de material.',
+      spools: 'Carretéis',
+      remaining: 'Restante',
+      consumed: 'Consumido',
+      cost: 'Custo',
+    },
     suppliers: {
       title: 'Por Fornecedor',
       empty: 'Nenhuma compra registrada ainda. Marque em um carretel de qual fornecedor ele foi comprado para agrupar consumo e custos aqui.',
@@ -4041,6 +4051,9 @@ export default {
       nextPage: 'Próxima página',
       zoomIn: 'Aumentar zoom',
       zoomOut: 'Diminuir zoom',
+      resetZoom: 'Redefinir zoom',
+      fullscreen: 'Tela Cheia',
+      exitFullscreen: 'Sair da Tela Cheia',
       emptySheet: 'Esta planilha está vazia',
       truncatedRows: 'Mostrando as primeiras {{shown}} de {{total}} linhas',
       truncatedCols: 'Mostrando as primeiras {{shown}} de {{total}} colunas',
@@ -4813,6 +4826,11 @@ export default {
     storageLocationNone: 'Sem local definido',
     lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
     lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
+    // Internal material / article number (#2870)
+    materialNumber: 'Nº do Material',
+    materialNumberPlaceholder: 'ex. 15',
+    materialNumberHelp: 'Número interno de compra - compartilhado por todos os carretéis deste produto. Novos carretéis do mesmo produto o herdam.',
+    materialNumberNone: 'Sem número de material',
     suppliers: {
       label: 'Fornecedores',
       none: 'Sem fornecedor',

+ 18 - 0
frontend/src/i18n/locales/ru.ts

@@ -1601,6 +1601,16 @@ export default {
     printActivity: "Активность печати",
     filamentTypes: "Типы филамента",
     filamentTrends: "Расход филамента",
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'По артикулам',
+      empty: 'Артикулы пока не назначены. Добавьте их катушкам в инвентаре, чтобы группировать здесь расход и затраты.',
+      loadFailed: 'Не удалось загрузить статистику по артикулам.',
+      spools: 'Катушки',
+      remaining: 'Остаток',
+      consumed: 'Израсходовано',
+      cost: 'Стоимость',
+    },
     suppliers: {
       title: "По поставщикам",
       empty: "Покупки пока не зарегистрированы. Отметьте на катушке, у какого поставщика она была куплена, чтобы сгруппировать здесь расход и затраты.",
@@ -3855,6 +3865,9 @@ export default {
       nextPage: 'Следующая страница',
       zoomIn: 'Увеличить',
       zoomOut: 'Уменьшить',
+      resetZoom: 'Сбросить масштаб',
+      fullscreen: 'На весь экран',
+      exitFullscreen: 'Выйти из полноэкранного режима',
       emptySheet: 'Этот лист пуст',
       truncatedRows: 'Показаны первые {{shown}} из {{total}} строк',
       truncatedCols: 'Показаны первые {{shown}} из {{total}} столбцов',
@@ -4601,6 +4614,11 @@ export default {
     storageLocationNone: "Место не указано",
     lowStockThresholdOverride: "Порог малого остатка (эта катушка)",
     lowStockThresholdOverrideHelp: "Оставьте пустым, чтобы использовать общий порог ({{global}}%).",
+    // Internal material / article number (#2870)
+    materialNumber: 'Артикул',
+    materialNumberPlaceholder: 'напр. 15',
+    materialNumberHelp: 'Внутренний закупочный номер — общий для всех катушек этого товара. Новые катушки того же товара наследуют его.',
+    materialNumberNone: 'Без артикула',
     suppliers: {
       label: "Поставщики",
       none: "Без поставщика",

+ 18 - 0
frontend/src/i18n/locales/sv.ts

@@ -1688,6 +1688,16 @@ export default {
     printActivity: 'Utskriftsaktivitet',
     filamentTypes: 'Filamenttyper',
     filamentTrends: 'Filamenttrender',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Efter materialnummer',
+      empty: 'Inga materialnummer tilldelade än. Lägg till dem på rullar i inventariet för att gruppera förbrukning och kostnader här.',
+      loadFailed: 'Kunde inte ladda materialnummerstatistiken.',
+      spools: 'Rullar',
+      remaining: 'Återstående',
+      consumed: 'Förbrukat',
+      cost: 'Kostnad',
+    },
     suppliers: {
       title: 'Per leverantör',
       empty: 'Inga inköp registrerade ännu. Ange på en rulle vilken leverantör den köptes från för att gruppera förbrukning och kostnader här.',
@@ -4083,6 +4093,9 @@ errors: {
       nextPage: 'Nästa sida',
       zoomIn: 'Zooma in',
       zoomOut: 'Zooma ut',
+      resetZoom: 'Återställ zoom',
+      fullscreen: 'Helskärm',
+      exitFullscreen: 'Avsluta helskärm',
       emptySheet: 'Det här bladet är tomt',
       truncatedRows: 'Visar de första {{shown}} av {{total}} raderna',
       truncatedCols: 'Visar de första {{shown}} av {{total}} kolumnerna',
@@ -4865,6 +4878,11 @@ errors: {
     storageLocationNone: 'Ingen lagringsplats inställd',
     lowStockThresholdOverride: 'Lågt lagertröskelvärde (denna rulle)',
     lowStockThresholdOverrideHelp: 'Lämna tomt för att använda det globala tröskelvärdet ({{global}}%).',
+    // Internal material / article number (#2870)
+    materialNumber: 'Materialnr',
+    materialNumberPlaceholder: 't.ex. 15',
+    materialNumberHelp: 'Internt inköpsnummer — delas av alla rullar av denna produkt. Nya rullar av samma produkt ärver det.',
+    materialNumberNone: 'Inget materialnummer',
     suppliers: {
       label: 'Leverantörer',
       none: 'Ingen leverantör',

+ 18 - 0
frontend/src/i18n/locales/tr.ts

@@ -1672,6 +1672,16 @@ export default {
     printActivity: 'Baskı Etkinliği',
     filamentTypes: 'Filament Türleri',
     filamentTrends: 'Filament Trendleri',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'Malzeme Numarasına Göre',
+      empty: 'Henüz malzeme numarası atanmadı. Tüketim ve maliyetleri burada gruplamak için envanterdeki makaralara malzeme numarası ekleyin.',
+      loadFailed: 'Malzeme numarası istatistikleri yüklenemedi.',
+      spools: 'Makaralar',
+      remaining: 'Kalan',
+      consumed: 'Tüketilen',
+      cost: 'Maliyet',
+    },
     suppliers: {
       title: 'Tedarikçiye Göre',
       empty: 'Henüz satın alma kaydı yok. Tüketim ve maliyetleri burada gruplamak için bir makarada hangi tedarikçiden alındığını işaretleyin.',
@@ -4048,6 +4058,9 @@ export default {
       nextPage: 'Sonraki sayfa',
       zoomIn: 'Yakınlaştır',
       zoomOut: 'Uzaklaştır',
+      resetZoom: 'Yakınlaştırmayı sıfırla',
+      fullscreen: 'Tam ekran',
+      exitFullscreen: 'Tam ekrandan çık',
       emptySheet: 'Bu sayfa boş',
       truncatedRows: '{{total}} satırdan ilk {{shown}} tanesi gösteriliyor',
       truncatedCols: '{{total}} sütundan ilk {{shown}} tanesi gösteriliyor',
@@ -4813,6 +4826,11 @@ export default {
     storageLocationNone: 'Konum ayarlanmamış',
     lowStockThresholdOverride: 'Düşük stok eşiği (bu makara)',
     lowStockThresholdOverrideHelp: 'Global eşiği kullanmak için boş bırakın (%{{global}}).',
+    // Internal material / article number (#2870)
+    materialNumber: 'Malzeme No.',
+    materialNumberPlaceholder: 'örn. 15',
+    materialNumberHelp: 'Dahili satın alma numarası - bu ürünün tüm makaraları tarafından paylaşılır. Aynı ürünün yeni makaraları bu numarayı devralır.',
+    materialNumberNone: 'Malzeme numarası yok',
     suppliers: {
       label: 'Tedarikçiler',
       none: 'Tedarikçi yok',

+ 18 - 0
frontend/src/i18n/locales/uk.ts

@@ -1687,6 +1687,16 @@ export default {
     printActivity: "Активність друку",
     filamentTypes: "Типи філаментів",
     filamentTrends: "Тенденції філаменту",
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: 'За номером матеріалу',
+      empty: 'Номери матеріалів ще не призначено. Додайте їх до котушок в інвентарі, щоб групувати тут споживання та витрати.',
+      loadFailed: 'Не вдалося завантажити статистику за номерами матеріалів.',
+      spools: 'Котушки',
+      remaining: 'Залишок',
+      consumed: 'Спожито',
+      cost: 'Вартість',
+    },
     suppliers: {
       title: "За постачальником",
       empty: "Покупок ще не зафіксовано. Позначте на котушці, у якого постачальника її придбано, щоб згрупувати тут споживання та витрати.",
@@ -4081,6 +4091,9 @@ export default {
       nextPage: 'Наступна сторінка',
       zoomIn: 'Збільшити',
       zoomOut: 'Зменшити',
+      resetZoom: 'Скинути масштаб',
+      fullscreen: 'Повний екран',
+      exitFullscreen: 'Вийти з повноекранного режиму',
       emptySheet: 'Цей аркуш порожній',
       truncatedRows: 'Показано перші {{shown}} з {{total}} рядків',
       truncatedCols: 'Показано перші {{shown}} з {{total}} стовпців',
@@ -4862,6 +4875,11 @@ export default {
     storageLocationNone: "Місцезнаходження не встановлено",
     lowStockThresholdOverride: "Поріг низького запасу (ця котушка)",
     lowStockThresholdOverrideHelp: "Залиште поле порожнім, щоб використовувати глобальне порогове значення ({{global}}%).",
+    // Internal material / article number (#2870)
+    materialNumber: 'Мат. №',
+    materialNumberPlaceholder: 'напр. 15',
+    materialNumberHelp: 'Внутрішній закупівельний номер — спільний для всіх котушок цього продукту. Нові котушки того самого продукту успадковують його.',
+    materialNumberNone: 'Без номера матеріалу',
     suppliers: {
       label: "Постачальники",
       none: "Без постачальника",

+ 18 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1671,6 +1671,16 @@ export default {
     printActivity: '打印活动',
     filamentTypes: '耗材类型',
     filamentTrends: '耗材趋势',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: '按物料号',
+      empty: '尚未分配物料号。在库存中为料盘添加物料号,即可在此按其汇总消耗量和成本。',
+      loadFailed: '无法加载物料号统计数据。',
+      spools: '料盘',
+      remaining: '剩余',
+      consumed: '已消耗',
+      cost: '成本',
+    },
     suppliers: {
       title: '按供应商',
       empty: '暂无购买记录。在料盘上标记其购买来源供应商后,即可在此按供应商统计消耗和成本。',
@@ -4041,6 +4051,9 @@ export default {
       nextPage: '下一页',
       zoomIn: '放大',
       zoomOut: '缩小',
+      resetZoom: '重置缩放',
+      fullscreen: '全屏',
+      exitFullscreen: '退出全屏',
       emptySheet: '此工作表为空',
       truncatedRows: '仅显示前 {{shown}} 行,共 {{total}} 行',
       truncatedCols: '仅显示前 {{shown}} 列,共 {{total}} 列',
@@ -4813,6 +4826,11 @@ export default {
     storageLocationNone: '未设置位置',
     lowStockThresholdOverride: '低库存阈值(此料盘)',
     lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
+    // Internal material / article number (#2870)
+    materialNumber: '物料号',
+    materialNumberPlaceholder: '例如 15',
+    materialNumberHelp: '内部采购编号 — 同一产品的所有料盘共用。同一产品的新料盘会自动继承。',
+    materialNumberNone: '无物料号',
     suppliers: {
       label: '供应商',
       none: '无供应商',

+ 18 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1671,6 +1671,16 @@ export default {
     printActivity: '列印活動',
     filamentTypes: '耗材類型',
     filamentTrends: '耗材趨勢',
+    // Consumption/cost grouped by the internal material number (#2870).
+    materialNumbers: {
+      title: '依料號統計',
+      empty: '尚未指定任何料號。請在庫存中為料盤新增料號,即可在此依料號統整消耗量與成本。',
+      loadFailed: '無法載入料號統計資料。',
+      spools: '料盤',
+      remaining: '剩餘',
+      consumed: '已消耗',
+      cost: '成本',
+    },
     suppliers: {
       title: '依供應商',
       empty: '尚未記錄任何購買。在料盤上標記其購買來源的供應商,即可在此依供應商彙整消耗量與成本。',
@@ -4041,6 +4051,9 @@ export default {
       nextPage: '下一頁',
       zoomIn: '放大',
       zoomOut: '縮小',
+      resetZoom: '重設縮放',
+      fullscreen: '全螢幕',
+      exitFullscreen: '離開全螢幕',
       emptySheet: '此工作表是空的',
       truncatedRows: '顯示前 {{shown}} 列,共 {{total}} 列',
       truncatedCols: '顯示前 {{shown}} 欄,共 {{total}} 欄',
@@ -4813,6 +4826,11 @@ export default {
     storageLocationNone: '未設定位置',
     lowStockThresholdOverride: '低庫存閾值(此料盤)',
     lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
+    // Internal material / article number (#2870)
+    materialNumber: '料號',
+    materialNumberPlaceholder: '例如:15',
+    materialNumberHelp: '內部採購編號 — 同一產品的所有料盤共用。同一產品的新料盤會自動繼承。',
+    materialNumberNone: '無料號',
     suppliers: {
       label: '供應商',
       none: '無供應商',

+ 146 - 56
frontend/src/pages/FileManagerPage.tsx

@@ -51,6 +51,7 @@ import {
   Globe,
   StickyNote,
   Camera,
+  Eye,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -97,6 +98,9 @@ const PdfPreviewModal = lazy(() =>
 const SpreadsheetPreviewModal = lazy(() =>
   import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
 );
+const ImagePreviewModal = lazy(() =>
+  import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal }))
+);
 
 function isSpreadsheetType(fileType: string): boolean {
   return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
@@ -106,6 +110,68 @@ function isStepType(fileType: string): boolean {
   return fileType === 'step' || fileType === 'stp';
 }
 
+// Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both
+// stores and renders a thumbnail for, and so the ones that get an image icon.
+const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']);
+
+// The subset ImagePreviewModal can actually show: it hands the bytes to an
+// <img>, and outside Safari no browser decodes TIFF. Offering the preview
+// would download up to 50 MB only to report "cannot be previewed", so TIFF
+// keeps its server-rendered thumbnail and no preview (#2976).
+const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
+
+function isImageType(fileType: string): boolean {
+  return IMAGE_TYPES.has(fileType.toLowerCase());
+}
+
+function isPreviewableImageType(fileType: string): boolean {
+  return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase());
+}
+
+// Which files have a preview at all: what a double-click opens, and what the
+// toolbar's Preview button appears for (#2976). Sliced files go to the
+// full-page gcode viewer, everything else to a modal.
+function isPreviewableLibraryFile(file: LibraryFileListItem): boolean {
+  const type = file.file_type;
+  return (
+    isSlicedLibraryFile(file) ||
+    type === '3mf' ||
+    type === 'stl' ||
+    isStepType(type) ||
+    type === 'pdf' ||
+    isSpreadsheetType(type) ||
+    isPreviewableImageType(type)
+  );
+}
+
+// Spread onto a card/row subtree that is not "the row": its own controls must
+// neither toggle the selection nor open the preview. `dblclick` is a separate
+// native event from `click`, so stopping the click alone still lets the second
+// click of a double-click reach the row's onDoubleClick (#2976).
+const stopRowActivation = {
+  onClick: (e: React.MouseEvent) => e.stopPropagation(),
+  onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
+};
+
+// Whether the preview is the 3D one, which has its own menu label.
+function isModelPreview(file: LibraryFileListItem): boolean {
+  return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type);
+}
+
+function documentPreviewIcon(fileType: string) {
+  if (fileType === 'pdf') return <FileText className="w-4 h-4" />;
+  if (isSpreadsheetType(fileType)) return <FileSpreadsheet className="w-4 h-4" />;
+  return <Image className="w-4 h-4" />;
+}
+
+// Types the server renders a thumbnail for on request, STL through trimesh
+// and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action
+// applies to them. Mirrors the file types batch_generate_stl_thumbnails in
+// routes/library.py selects.
+function hasServerThumbnail(fileType: string): boolean {
+  return fileType === 'stl' || fileType === 'pdf';
+}
+
 // New Folder Modal
 interface NewFolderModalProps {
   parentId: number | null;
@@ -784,8 +850,7 @@ interface FileCardProps {
   // (#3029), so offering one there would only ever fail.
   desktopSlicer: SlicerType;
   canSlice?: boolean;
-  onPreview3d?: (file: LibraryFileListItem) => void;
-  onPreviewDocument?: (file: LibraryFileListItem) => void;
+  onPreview?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onDetails?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -798,7 +863,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onPreviewDocument, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -840,20 +905,12 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
     });
   }
-  if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf' || isStepType(file.file_type))) {
-    menuItems.push({
-      label: t('fileManager.preview3d'),
-      icon: <Box className="w-4 h-4" />,
-      onClick: () => onPreview3d(file),
-      disabled: !canPreview3d,
-      title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
-    });
-  }
-  if (onPreviewDocument && (file.file_type === 'pdf' || isSpreadsheetType(file.file_type))) {
+  if (onPreview && isPreviewableLibraryFile(file)) {
+    const modelPreview = isModelPreview(file);
     menuItems.push({
-      label: t('fileManager.preview.open'),
-      icon: file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />,
-      onClick: () => onPreviewDocument(file),
+      label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'),
+      icon: modelPreview ? <Box className="w-4 h-4" /> : documentPreviewIcon(file.file_type),
+      onClick: () => onPreview(file),
       disabled: !canPreview3d,
       title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
     });
@@ -892,7 +949,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
     });
   }
-  if (onGenerateThumbnail && file.file_type === 'stl') {
+  if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) {
     menuItems.push({
       label: t('fileManager.generateThumbnail'),
       icon: <Image className="w-4 h-4" />,
@@ -918,6 +975,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
       }`}
       onClick={() => onSelect(file.id)}
+      // Double-click opens the preview (#2976). The two clicks that precede it
+      // toggle the selection twice, so the selection is left as it was.
+      onDoubleClick={() => onPreview?.(file)}
     >
       {/* Thumbnail */}
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
@@ -931,6 +991,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           <FileText className="w-12 h-12 text-bambu-gray/30" />
         ) : isSpreadsheetType(file.file_type) ? (
           <FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
+        ) : isImageType(file.file_type) ? (
+          <Image className="w-12 h-12 text-bambu-gray/30" />
         ) : (
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
         )}
@@ -986,7 +1048,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
         {/* Metadata indicators (#3077): link, notes, photos. The link opens in
             a new tab like the archive card's globe; the others open Details. */}
         {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
-          <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" onClick={(e) => e.stopPropagation()}>
+          <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" {...stopRowActivation}>
             {file.external_url && (
               <a
                 href={file.external_url}
@@ -1039,7 +1101,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           </div>
         )}
         {(file.tags?.length ?? 0) > 0 && (
-          <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
+          <div className="mt-2 flex flex-wrap gap-1" {...stopRowActivation}>
             {file.tags!.map((tg) => (
               <button
                 key={tg.id}
@@ -1057,7 +1119,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       </div>
 
       {/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
-      <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" onClick={(e) => e.stopPropagation()}>
+      <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" {...stopRowActivation}>
         <button
           onClick={(e) => {
             // No open/close toggle: the menu's own outside-mousedown handler
@@ -1130,6 +1192,7 @@ export function FileManagerPage() {
   const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
   const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
+  const [imagePreviewFile, setImagePreviewFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -1676,6 +1739,34 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
+  // The one way into a preview (#2976): the kebab entry, the action-strip
+  // icon, a double-click on the card or row, and the toolbar button all end
+  // up here. Sliced files open the full-page gcode viewer the archive card
+  // uses; everything else opens the modal for its type. A file with no
+  // preview does nothing.
+  const openPreview = useCallback((file: LibraryFileListItem) => {
+    if (!hasPermission('library:read')) return;
+    if (isSlicedLibraryFile(file)) {
+      navigate(`/gcode-viewer?library_file=${file.id}`);
+    } else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) {
+      setViewerFile(file);
+    } else if (file.file_type === 'pdf') {
+      setPdfPreviewFile(file);
+    } else if (isSpreadsheetType(file.file_type)) {
+      setSheetPreviewFile(file);
+    } else if (isPreviewableImageType(file.file_type)) {
+      setImagePreviewFile(file);
+    }
+  }, [hasPermission, navigate]);
+
+  // The toolbar's Preview button acts on one file, so it is offered only for
+  // a single previewable selection.
+  const previewSelection = useMemo(() => {
+    if (!files || selectedFiles.length !== 1) return null;
+    const file = files.find((f) => f.id === selectedFiles[0]);
+    return file && isPreviewableLibraryFile(file) ? file : null;
+  }, [files, selectedFiles]);
+
   // Get sliced files from selection
   const selectedSlicedFiles = useMemo(() => {
     if (!files) return [];
@@ -2411,6 +2502,18 @@ export function FileManagerPage() {
                   </span>
                   <div className="hidden sm:block flex-1" />
                   <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
+                    {previewSelection && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => openPreview(previewSelection)}
+                        disabled={!hasPermission('library:read')}
+                        title={!hasPermission('library:read') ? t('fileManager.noPermissionPreview') : undefined}
+                      >
+                        <Eye className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{t('fileManager.preview.open')}</span>
+                      </Button>
+                    )}
                     {/* Print used to disappear the moment a second sliced file was
                         selected. Selecting several is now how you say "same job,
                         different printers" (#671) — one queue item, whichever
@@ -2564,21 +2667,7 @@ export function FileManagerPage() {
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     canSlice={canSlice()}
-                    onPreview3d={(f) => {
-                      // Sliced files (.gcode / .gcode.3mf) open the same
-                      // full-page gcode viewer the archive card uses, so
-                      // the two paths feel consistent. STL / source 3MF
-                      // continue to use the in-app 3D model viewer modal.
-                      if (isSlicedLibraryFile(f)) {
-                        navigate(`/gcode-viewer?library_file=${f.id}`);
-                      } else {
-                        setViewerFile(f);
-                      }
-                    }}
-                    onPreviewDocument={(f) => {
-                      if (f.file_type === 'pdf') setPdfPreviewFile(f);
-                      else setSheetPreviewFile(f);
-                    }}
+                    onPreview={openPreview}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onDetails={setDetailsFile}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
@@ -2624,6 +2713,8 @@ export function FileManagerPage() {
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     onClick={() => handleFileSelect(file.id)}
+                    // Double-click opens the preview (#2976), as in the grid.
+                    onDoubleClick={() => openPreview(file)}
                   >
                     {/* Checkbox */}
                     <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
@@ -2649,6 +2740,8 @@ export function FileManagerPage() {
                                 <FileText className="w-5 h-5 text-bambu-gray/50" />
                               ) : isSpreadsheetType(file.file_type) ? (
                                 <FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
+                              ) : isImageType(file.file_type) ? (
+                                <Image className="w-5 h-5 text-bambu-gray/50" />
                               ) : (
                                 <FileBox className="w-5 h-5 text-bambu-gray/50" />
                               )}
@@ -2677,7 +2770,7 @@ export function FileManagerPage() {
                               href={file.external_url}
                               target="_blank"
                               rel="noopener noreferrer"
-                              onClick={(e) => e.stopPropagation()}
+                              {...stopRowActivation}
                               className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
                               title={t('fileManager.details.openLink')}
                               aria-label={t('fileManager.details.openLink')}
@@ -2742,7 +2835,7 @@ export function FileManagerPage() {
                         filter; minmax(0,200px) on the column lets the cell
                         shrink/wrap on narrow viewports without pushing the
                         Actions cell off-screen. */}
-                    <div className="min-w-0" onClick={(e) => e.stopPropagation()}>
+                    <div className="min-w-0" {...stopRowActivation}>
                       {!file.tags || file.tags.length === 0 ? (
                         <span className="text-xs text-bambu-gray/50">-</span>
                       ) : (
@@ -2763,7 +2856,7 @@ export function FileManagerPage() {
                       )}
                     </div>
                     {/* Actions */}
-                    <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
+                    <div className="flex items-center gap-1" {...stopRowActivation}>
                       {isSlicedLibraryFile(file) && (
                         <>
                           <button
@@ -2811,16 +2904,9 @@ export function FileManagerPage() {
                           <Play className="w-4 h-4" />
                         </button>
                       )}
-                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl' || isStepType(file.file_type)) && (
+                      {isModelPreview(file) && (
                         <button
-                          onClick={() => {
-                            if (!hasPermission('library:read')) return;
-                            if (isSlicedLibraryFile(file)) {
-                              navigate(`/gcode-viewer?library_file=${file.id}`);
-                            } else {
-                              setViewerFile(file);
-                            }
-                          }}
+                          onClick={() => openPreview(file)}
                           className={`p-1.5 rounded transition-colors ${
                             hasPermission('library:read')
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
@@ -2832,13 +2918,9 @@ export function FileManagerPage() {
                           <Box className="w-4 h-4" />
                         </button>
                       )}
-                      {(file.file_type === 'pdf' || isSpreadsheetType(file.file_type)) && (
+                      {!isModelPreview(file) && isPreviewableLibraryFile(file) && (
                         <button
-                          onClick={() => {
-                            if (!hasPermission('library:read')) return;
-                            if (file.file_type === 'pdf') setPdfPreviewFile(file);
-                            else setSheetPreviewFile(file);
-                          }}
+                          onClick={() => openPreview(file)}
                           className={`p-1.5 rounded transition-colors ${
                             hasPermission('library:read')
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
@@ -2847,7 +2929,7 @@ export function FileManagerPage() {
                           title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
                           disabled={!hasPermission('library:read')}
                         >
-                          {file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />}
+                          {documentPreviewIcon(file.file_type)}
                         </button>
                       )}
                       <button
@@ -2886,7 +2968,7 @@ export function FileManagerPage() {
                       >
                         <Pencil className="w-4 h-4" />
                       </button>
-                      {file.file_type === 'stl' && (
+                      {hasServerThumbnail(file.file_type) && (
                         <button
                           onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
                           className={`p-1.5 rounded transition-colors ${
@@ -3087,7 +3169,7 @@ export function FileManagerPage() {
         />
       )}
 
-      {(pdfPreviewFile || sheetPreviewFile) && (
+      {(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && (
         <Suspense fallback={null}>
           {pdfPreviewFile && (
             <PdfPreviewModal
@@ -3108,6 +3190,14 @@ export function FileManagerPage() {
               onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
             />
           )}
+          {imagePreviewFile && (
+            <ImagePreviewModal
+              libraryFileId={imagePreviewFile.id}
+              filename={imagePreviewFile.print_name || imagePreviewFile.filename}
+              fileSize={imagePreviewFile.file_size}
+              onClose={() => setImagePreviewFile(null)}
+            />
+          )}
         </Suspense>
       )}
 

+ 64 - 3
frontend/src/pages/InventoryPage.tsx

@@ -75,6 +75,20 @@ function spoolGroupKey(s: InventorySpool): string {
 // Column definitions for the inventory table
 const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
 
+// Sentinel for the "no material number" slot in the filter dropdown (#2870).
+// The other chips use a readable '__none__', but a material number is free
+// text and could literally be '__none__'. SpoolBase caps the column at 64
+// characters, so a 65-character sentinel is one no spool can ever carry.
+const MATERIAL_NUMBER_NONE = 'none'.padStart(65, '_');
+
+// Sort key for the material-number column (#2870). The table compares sort
+// values with plain < / >, which puts "15" before "2"; padding every digit run
+// to the column's 64-character cap makes that comparison numeric-aware, so the
+// column orders the same way as the filter chip and the dialog suggestions.
+function materialNumberSortKey(value: string | null): string {
+  return (value || '').toLowerCase().replace(/\d+/g, (digits) => digits.padStart(64, '0'));
+}
+
 const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'id', label: '#', visible: true },
   { id: 'added_time', label: 'Added', visible: true },
@@ -99,6 +113,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { 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: 'material_number', label: 'Material No.', visible: false },
   { id: 'suppliers', label: 'Suppliers', visible: false },
   { id: 'pa_k', label: 'PA(K)', visible: true },
   { id: 'tag_id', label: 'Tag ID', visible: false },
@@ -229,6 +244,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   printed_total: () => 'Printed Total',
   printed_since_weight: () => 'Printed Since Weight',
   note: (t) => t('inventory.note'),
+  material_number: (t) => t('inventory.materialNumber'),
   suppliers: (t) => t('inventory.suppliers.label'),
   pa_k: () => 'PA(K)',
   tag_id: () => 'Tag ID',
@@ -370,6 +386,9 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   note: ({ spool }) => (
     <span className="text-sm text-bambu-gray max-w-[150px] truncate block" title={spool.note || undefined}>{spool.note || '-'}</span>
   ),
+  material_number: ({ spool }) => (
+    <span className="text-sm text-bambu-gray">{spool.material_number || '-'}</span>
+  ),
   // Supplier chips (#2988): purchase source first and highlighted; the
   // others read as alternative sources. Tooltip carries the supplier's
   // article number when set.
@@ -541,6 +560,7 @@ const columnSortValues: Record<
   used: (s) => s.weight_used,
   remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
   note: (s) => (s.note || '').toLowerCase(),
+  material_number: (s) => materialNumberSortKey(s.material_number),
   // Sorts on the purchase-source supplier, falling back to the first
   // assignment — a spool has to sit in exactly one place in the list.
   suppliers: (s) => {
@@ -638,6 +658,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [materialFilter, setMaterialFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
   const [categoryFilter, setCategoryFilter] = useState('');
+  // Filter on the internal material number (#2870), same shape as category.
+  const [materialNumberFilter, setMaterialNumberFilter] = useState('');
   // Filter on an assigned supplier (#2988), same shape as category.
   const [supplierFilter, setSupplierFilter] = useState('');
   const [spoolFilter, setSpoolFilter] = useState('');
@@ -674,7 +696,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   // honest vs. what the user is actually looking at.
   useEffect(() => {
     setSelectedIds(new Set());
-  }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, search]);
+  }, [archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, materialNumberFilter, supplierFilter, spoolFilter, stockFilter, search]);
 
   // Pagination state (pageSize persisted to localStorage)
   const [pageIndex, setPageIndex] = useState(0);
@@ -1312,6 +1334,16 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
       }
     }
 
+    // Material number dropdown (#2870). The sentinel finds spools that have
+    // no number assigned yet.
+    if (materialNumberFilter) {
+      if (materialNumberFilter === MATERIAL_NUMBER_NONE) {
+        filtered = filtered.filter((s) => !s.material_number?.trim());
+      } else {
+        filtered = filtered.filter((s) => s.material_number === materialNumberFilter);
+      }
+    }
+
     // Supplier dropdown (#2988): "everything from supplier X" matches ANY
     // assignment, purchase source or alternative; `__none__` finds spools
     // without supplier assignments.
@@ -1359,7 +1391,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, supplierFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, materialNumberFilter, supplierFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -1380,6 +1412,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
   const uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort();
   const hasUncategorized = (spools ?? []).some((s) => !s.category);
+  // #2870: distinct material numbers, numeric-aware sort ("2" before "15").
+  const uniqueMaterialNumbers = [...new Set(spools?.map((s) => s.material_number?.trim()).filter(Boolean) as string[] || [])]
+    .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+  const hasUnnumbered = (spools ?? []).some((s) => !s.material_number?.trim());
   // #2988: suppliers seen across the inventory, for the filter dropdown.
   const uniqueSuppliers = useMemo(() => {
     const byId = new Map<number, string>();
@@ -1401,7 +1437,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.location_id && !s.storage_location?.trim());
 
   // Check if any filters are non-default
-  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!supplierFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
+  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!materialNumberFilter || !!supplierFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search;
 
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
     setColumnConfig(config);
@@ -1523,6 +1559,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     setMaterialFilter('');
     setBrandFilter('');
     setCategoryFilter('');
+    setMaterialNumberFilter('');
     setSupplierFilter('');
     setSpoolFilter('');
     setStockFilter('all');
@@ -1988,6 +2025,28 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           </select>
         )}
 
+        {/* Material number dropdown chip (#2870) — same render rule as the
+            category chip: hidden until at least one spool carries a number. */}
+        {(uniqueMaterialNumbers.length > 0 || materialNumberFilter) && (
+          <select
+            value={materialNumberFilter}
+            onChange={(e) => { setMaterialNumberFilter(e.target.value); resetPage(); }}
+            className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+              materialNumberFilter
+                ? '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.materialNumber')}</option>
+            {uniqueMaterialNumbers.map((n) => (
+              <option key={n} value={n}>{n}</option>
+            ))}
+            {hasUnnumbered && (
+              <option value={MATERIAL_NUMBER_NONE}>{t('inventory.materialNumberNone')}</option>
+            )}
+          </select>
+        )}
+
         {/* Supplier dropdown chip (#2988) — same render rule as the category
             chip: hidden until at least one spool carries an assignment. */}
         {(uniqueSuppliers.length > 0 || supplierFilter) && (
@@ -2550,8 +2609,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         availableSubtypes={dedupeAndSort((spools ?? []).map((s) => s.subtype))}
         availableBrands={dedupeAndSort((spools ?? []).map((s) => s.brand))}
         availableCategories={dedupeAndSort((spools ?? []).map((s) => s.category))}
+        availableMaterialNumbers={dedupeAndSort((spools ?? []).map((s) => s.material_number))}
         availableSlicerFilaments={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament))}
         availableSlicerFilamentNames={dedupeAndSort((spools ?? []).map((s) => s.slicer_filament_name))}
+        spoolmanMode={spoolmanMode}
         onClose={() => setBulkEditOpen(false)}
         onApply={(patch) => bulkUpdateMutation.mutate({ ids: [...selectedIds], update: patch })}
       />

+ 10 - 0
frontend/src/pages/StatsPage.tsx

@@ -39,6 +39,7 @@ import { api, type ArchiveSlim } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
 import { SupplierStats } from '../components/SupplierStats';
+import { MaterialNumberStats } from '../components/MaterialNumberStats';
 import { Dashboard, type DashboardWidget } from '../components/Dashboard';
 import { getCurrencySymbol } from '../utils/currency';
 import { formatWeight } from '../utils/weight';
@@ -1071,6 +1072,9 @@ export function StatsPage() {
   // in Spoolman mode — there the assignments live in the Spoolman twin table.
   // Rather than show a permanently empty card next to an inventory that does
   // display supplier chips, drop it (#2988).
+  // The material-number widget shares this gate for the same reason: in
+  // Spoolman mode the number is Spoolman's filament-level article_number and
+  // lives in Spoolman itself, not in the internal spool table (#2870).
   const { data: spoolmanSettings, isPending: spoolmanSettingsPending } = useQuery({
     queryKey: ['spoolman-settings'],
     queryFn: api.getSpoolmanSettings,
@@ -1213,6 +1217,12 @@ export function StatsPage() {
       component: <SupplierStats currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
       defaultSize: 2,
     }] as DashboardWidget[])),
+    ...(!spoolmanModeReady || spoolmanMode ? [] : ([{
+      id: 'material-numbers',
+      title: t('stats.materialNumbers.title'),
+      component: <MaterialNumberStats currency={currency} dateFrom={effectiveDateRange.dateFrom} dateTo={effectiveDateRange.dateTo} />,
+      defaultSize: 2,
+    }] as DashboardWidget[])),
   ];
 
   return (

+ 1 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -480,6 +480,7 @@ export function SpoolBuddyDashboard() {
           last_weighed_at: weight !== null ? new Date().toISOString() : null,
           category: null,
           low_stock_threshold_pct: null,
+          material_number: null,
         });
       }
     } catch (e) {

+ 5 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -780,6 +780,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
       last_weighed_at: null,
       category: formData.category.trim() || null,
       low_stock_threshold_pct: formData.low_stock_threshold_pct,
+      material_number: formData.material_number.trim() || null,
     };
 
     setCreating(true);
@@ -1001,7 +1002,11 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
               availableCategories={Array.from(new Set(
                 allSpoolsForForm.map((s) => s.category?.trim()).filter((c): c is string => !!c),
               )).sort((a, b) => a.localeCompare(b))}
+              availableMaterialNumbers={Array.from(new Set(
+                allSpoolsForForm.map((s) => s.material_number?.trim()).filter((n): n is string => !!n),
+              )).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))}
               globalLowStockThreshold={settingsForForm?.low_stock_threshold ?? 20}
+              spoolmanMode={spoolmanMode}
             />
           </div>
         ) : (

+ 1 - 0
frontend/src/utils/inventorySearch.ts

@@ -35,6 +35,7 @@ export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean
     (spool.note?.toLowerCase().includes(q) ?? false) ||
     (spool.slicer_filament_name?.toLowerCase().includes(q) ?? false) ||
     (spool.storage_location?.toLowerCase().includes(q) ?? false) ||
+    (spool.material_number?.toLowerCase().includes(q) ?? false) ||
     (spool.suppliers?.some(
       (link) =>
         link.supplier_name.toLowerCase().includes(q) ||

+ 43 - 1
frontend/vite.config.ts

@@ -1,4 +1,7 @@
+import { createRequire } from 'node:module'
+import { readdirSync, readFileSync } from 'node:fs'
 import { defineConfig } from 'vite'
+import type { Plugin } from 'vite'
 import react from '@vitejs/plugin-react'
 import path from 'path'
 
@@ -6,6 +9,45 @@ import path from 'path'
 const backendPort = process.env.BACKEND_PORT || '8000'
 const backendUrl = `http://localhost:${backendPort}`
 
+// pdf.js keeps these out of its bundle and fetches them at runtime (#2976):
+// CJK text needs the CMaps, non-embedded fonts the standard font files, and
+// JPEG2000/JBIG2 images and ICC colour the wasm decoders. Published under the
+// bundle's own assets directory so the existing /assets mount serves them —
+// PdfPreviewModal builds the matching URLs from its PDFJS_ASSET_BASE.
+const PDFJS_RUNTIME_DIRS = ['cmaps', 'iccs', 'standard_fonts', 'wasm']
+const PDFJS_RUNTIME_PREFIX = 'assets/pdfjs'
+
+/** Publish pdf.js's runtime data directories next to the bundle. */
+function pdfjsRuntimeAssets(): Plugin {
+  const require = createRequire(__filename)
+  const packageDir = path.dirname(require.resolve('pdfjs-dist/package.json'))
+  // Published path -> file on disk. Built once, and used as an allowlist by
+  // the dev-server handler so no request can escape the package directory.
+  const files = new Map<string, string>()
+  for (const dir of PDFJS_RUNTIME_DIRS) {
+    for (const entry of readdirSync(path.join(packageDir, dir), { withFileTypes: true })) {
+      if (!entry.isFile()) continue
+      files.set(`${PDFJS_RUNTIME_PREFIX}/${dir}/${entry.name}`, path.join(packageDir, dir, entry.name))
+    }
+  }
+
+  return {
+    name: 'bambuddy:pdfjs-runtime-assets',
+    generateBundle() {
+      for (const [fileName, source] of files) {
+        this.emitFile({ type: 'asset', fileName, source: readFileSync(source) })
+      }
+    },
+    configureServer(server) {
+      server.middlewares.use((req, res, next) => {
+        const source = files.get((req.url ?? '').split('?')[0].replace(/^\//, ''))
+        if (!source) return next()
+        res.setHeader('Content-Type', source.endsWith('.wasm') ? 'application/wasm' : 'application/octet-stream')
+        res.end(readFileSync(source))
+      })
+    },
+  }
+}
 
 export default defineConfig({
   // Default base ('/') emits absolute asset URLs (/assets/...). Required so
@@ -17,7 +59,7 @@ export default defineConfig({
   // fix for subpath reverse proxies (#1195, wontfix) is reverted — that
   // audience uses NPM + Cloudflare Tunnel at a real domain per the
   // documented workaround, which doesn't depend on this setting.
-  plugins: [react()],
+  plugins: [react(), pdfjsRuntimeAssets()],
   build: {
     outDir: '../static',
     emptyOutDir: true,