Browse Source

Archives: a page for each archive, calendar and filters by print (archive redesign P4)

- /archives/<id>: the file (estimate, plates, note, actions) and every
  print of it with its own outcome, verdict, figures, note, video and
  photos; a card, list row or "N prints" badge opens it, a merged id
  opens the archive it went into
- GET /archives/{id}/runs/{run_id}/timelapse plays one print's video,
  PATCH /print-log/{id} takes the print's note, the runs list carries
  each print's own photos, video and note
- The calendar shows each print on the day it ended, read from the new
  GET /archives/prints (the archive list's visibility, id order), and
  opens the archive page at that print; its labels are translated
- The printer filter (list and search) finds archives with any print on
  it and counts that printer's prints; Failed Prints finds archives any
  print of which failed or aborted; aborted counts as failed
- Print Log names link to their archive's page, not for deleted ones
maziggy 21 hours ago
parent
commit
d43bf57cfc
100 changed files with 2569 additions and 121 deletions
  1. 6 0
      CHANGELOG.md
  2. 117 10
      backend/app/api/routes/archives.py
  3. 16 0
      backend/app/api/routes/print_log.py
  4. 14 0
      backend/app/schemas/print_log.py
  5. 19 1
      backend/app/services/archive.py
  6. 191 0
      backend/tests/integration/test_archive_filters_by_print.py
  7. 124 0
      backend/tests/integration/test_archive_page_runs.py
  8. 7 0
      backend/tests/unit/test_print_log.py
  9. 2 0
      frontend/src/App.tsx
  10. 100 0
      frontend/src/__tests__/components/CalendarView.test.tsx
  11. 200 0
      frontend/src/__tests__/pages/ArchiveDetailPage.test.tsx
  12. 52 1
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  13. 20 0
      frontend/src/__tests__/pages/ArchivesPagePrintLogColumns.test.tsx
  14. 13 1
      frontend/src/__tests__/utils/projectQueries.test.ts
  15. 24 1
      frontend/src/api/client.ts
  16. 126 66
      frontend/src/components/CalendarView.tsx
  17. 2 0
      frontend/src/components/ConfirmOutcomeDialog.tsx
  18. 6 6
      frontend/src/components/EditArchiveModal.tsx
  19. 1 1
      frontend/src/components/MergeDuplicatesModal.tsx
  20. 6 0
      frontend/src/hooks/useWebSocket.ts
  21. 48 0
      frontend/src/i18n/locales/de.ts
  22. 48 0
      frontend/src/i18n/locales/en.ts
  23. 48 0
      frontend/src/i18n/locales/es.ts
  24. 48 0
      frontend/src/i18n/locales/fr.ts
  25. 48 0
      frontend/src/i18n/locales/it.ts
  26. 48 0
      frontend/src/i18n/locales/ja.ts
  27. 49 1
      frontend/src/i18n/locales/ko.ts
  28. 48 0
      frontend/src/i18n/locales/nl.ts
  29. 48 0
      frontend/src/i18n/locales/pt-BR.ts
  30. 48 0
      frontend/src/i18n/locales/ru.ts
  31. 48 0
      frontend/src/i18n/locales/sv.ts
  32. 48 0
      frontend/src/i18n/locales/tr.ts
  33. 48 0
      frontend/src/i18n/locales/uk.ts
  34. 48 0
      frontend/src/i18n/locales/zh-CN.ts
  35. 48 0
      frontend/src/i18n/locales/zh-TW.ts
  36. 704 0
      frontend/src/pages/ArchiveDetailPage.tsx
  37. 84 26
      frontend/src/pages/ArchivesPage.tsx
  38. 9 1
      frontend/src/utils/projectQueries.ts
  39. 0 0
      static/assets/AdditionalSection-BOTq9mjN.js
  40. 0 0
      static/assets/AmsUnitCard-JWKwEuej.js
  41. 0 0
      static/assets/ArchiveDetailPage-BYgLfUI5.js
  42. 0 0
      static/assets/ArchivesPage-BfhEWJ1i.js
  43. 0 0
      static/assets/ArchivesPage-CMEzXCAy.js
  44. 0 0
      static/assets/AreaChart-BX0y1y2t.js
  45. 0 0
      static/assets/AssignToAmsModal-C421iyiz.js
  46. 0 0
      static/assets/AuthContext-DQrtirY_.js
  47. 1 1
      static/assets/Button-B9_qjR81.js
  48. 0 0
      static/assets/CamWallPage-DY3tmBJ4.js
  49. 0 0
      static/assets/CameraDiagnoseModal-DMpI5Ekl.js
  50. 0 0
      static/assets/CameraPage-B8IFsAGd.js
  51. 0 0
      static/assets/CameraWall-CGoAbJwx.js
  52. 1 1
      static/assets/Card-CjvOwXIN.js
  53. 0 0
      static/assets/CartesianChart-BX_I_DZv.js
  54. 0 0
      static/assets/ColumnConfigModal-BPEGc8K-.js
  55. 0 0
      static/assets/ConfigureAmsSlotModal-2aj0GtSK.js
  56. 0 0
      static/assets/ConfirmModal-fFY-Pxek.js
  57. 0 0
      static/assets/ConfirmOutcomeDialog-DJy_CUup.js
  58. 0 0
      static/assets/ConfirmOutcomeDialog-aLxq9HvU.js
  59. 0 0
      static/assets/ConnectAuthorizePage-BP4EAAcE.js
  60. 0 0
      static/assets/ConnectionDiagnostic-BaFc8Rjv.js
  61. 0 0
      static/assets/ContextMenu-BymVycFy.js
  62. 0 0
      static/assets/CopyButton-Cf9XUMu_.js
  63. 0 0
      static/assets/EmbeddedCameraViewer-DZln3y9W.js
  64. 0 0
      static/assets/ErrorBarContext-mNPmZ5iD.js
  65. 0 0
      static/assets/ExternalLinkPage-Bymiesua.js
  66. 1 1
      static/assets/FilamentSwatch-DU9bOgNP.js
  67. 0 1
      static/assets/FileManagerPage-CNtOPtw7.js
  68. 0 0
      static/assets/FileUploadModal-BbcWN0EN.js
  69. 0 0
      static/assets/FinancePage-DtQlDAOC.js
  70. 0 0
      static/assets/GCodeViewerPage-C3YVfkN_.js
  71. 0 0
      static/assets/GcodeToolpathViewer-C6tlJsLY.js
  72. 0 0
      static/assets/GroupEditPage-C9Vu8eR2.js
  73. 0 0
      static/assets/IconPicker-BimhUo0f.js
  74. 0 0
      static/assets/IconPicker-gCJJi2Ou.js
  75. 0 0
      static/assets/ImagePreviewModal-CDQVn7ek.js
  76. 0 0
      static/assets/InventoryPage-CT6XBWR_.js
  77. 0 0
      static/assets/Layout-KcUdYMIG.js
  78. 0 0
      static/assets/LibraryTrashPage-BcEJFagm.js
  79. 0 0
      static/assets/LoginPage-Cyd6braG.js
  80. 0 0
      static/assets/MaintenancePage-qUKJf-sM.js
  81. 0 0
      static/assets/MergeDuplicatesModal-AGh6LPz7.js
  82. 0 0
      static/assets/MergeDuplicatesModal-Cx2alSPc.js
  83. 0 0
      static/assets/ModelSourcesPage-BokWlcLP.js
  84. 0 0
      static/assets/NotificationsPage-CrvJ9KH5.js
  85. 1 1
      static/assets/NumberInput-CuACXpY4.js
  86. 1 1
      static/assets/OverlayFrame-hnkLBxV8.js
  87. 0 0
      static/assets/PdfPreviewModal-D6ppezZ6.js
  88. 0 0
      static/assets/PhotoGalleryModal-7bggvmMY.js
  89. 0 0
      static/assets/PhotoGalleryModal-CdHJisSV.js
  90. 0 0
      static/assets/PrintModal-BObYTfX7.js
  91. 0 0
      static/assets/PrinterLocationsPage-BD0N0q1Y.js
  92. 0 0
      static/assets/PrintersPage-D2yvw3Pn.js
  93. 0 0
      static/assets/PrintersPage-D6FUiK0m.js
  94. 0 0
      static/assets/ProfilesPage-joCuD-oz.js
  95. 0 0
      static/assets/ProjectDetailPage-BBxTl-Eh.js
  96. 0 0
      static/assets/ProjectsPage-C9W_giGb.js
  97. 0 0
      static/assets/QueryClientProvider-C3eWKLmF.js
  98. 0 0
      static/assets/QueuePage-LQ7r9MrS.js
  99. 0 0
      static/assets/ReferenceLine-CDP7AXLB.js
  100. 0 0
      static/assets/RichTextEditor-Dk7fzPGY.js

+ 6 - 0
CHANGELOG.md

@@ -5,6 +5,12 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.1b1] - Unreleased
 
 ### Added
+- **A page for each archive** — An archive is one file, but everything about its prints sat on one card: its latest print's outcome, one video, one note. A click on a card (or its name in the list) now opens the archive's page at `/archives/<id>`:
+  - **The file:** thumbnail, the slicer's estimate for the whole file, layers, how often it was printed, tags, the archive's note, each plate of a multi-plate file with its own estimate and print count, and **Reprint**, **Edit**, **3D preview**, **Download** and **Delete**.
+  - **Every print of it,** newest first: its outcome and failure reason, printer, plate, duration, filament, cost and energy, its own verdict (**Set outcome** answers for that print only), its own note, and its own timelapse video and photos. Each plate keeps its own video.
+  - **Calendar by print:** the calendar shows each print on the day it ended, so a file printed three times this month is there three times, and a click opens the archive's page at that print. With a printer picked it shows only the prints on it. Its labels are now translated.
+  - **Print Log:** a print's name links to its archive's page at that print, unless the archive was deleted. The orange **N prints** badge opens the page too.
+  - **Filters by print:** the printer filter (in search too) finds archives with any print on that printer, not only the latest, and their print counts are of the prints on it; **Failed Prints** finds archives any print of which failed or was aborted. Aborted prints now count as failed in an archive's print counts. A link to a merged archive opens the archive it went into. Wiki updated.
 - **Slice dialog: sorted presets, search, stock presets listed once** - Preset groups are sorted (decimals by value), every preset dropdown has a search, and a Bambu stock preset that Bambu Cloud and the bundled Standard profiles both list appears once, as the Bambu Cloud copy. Pipelines and saved AMS slot presets that name the hidden Standard copy use the cloud one. Requested in #3386, built by @pleasantone in #3393.
 - **SpoolBuddy: add a scanned Bambu spool in one tap** - When an unknown Bambu Lab spool is placed on the SpoolBuddy reader, the card now shows the material, colour and weight decoded from its RFID tag, and Quick Add creates the spool with them (tray UUID and tare included) instead of a blank PLA spool. A failed re-read of the same tag keeps the data from the good read. Requested by @BenBBSLA in #2685, built by @hibikipr in #3390.
 - **`DB_POOL_PRE_PING` to turn off the PostgreSQL connection check (#3396, requested and contributed by @oh-forf in #3413)** — Every pooled PostgreSQL session first checks its connection, and SQLAlchemy wraps that check in an empty `BEGIN; ROLLBACK;`. On a small install that made `pg_stat_database` show around 80% rolled-back transactions and set off rollback-ratio alerts in monitoring. `DB_POOL_PRE_PING=false` skips the check and roughly halves the rollback count. The default stays on: with it off, a connection the server dropped fails the first request that uses it, so turn it off only for a database on the same host or LAN.

+ 117 - 10
backend/app/api/routes/archives.py

@@ -43,7 +43,7 @@ from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate
 from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
-from backend.app.services.archive import ArchiveService
+from backend.app.services.archive import ArchiveService, printed_on_clause
 from backend.app.services.archive_scope import (
     ScopedView,
     ensure_whole_archive_in_scope,
@@ -366,7 +366,7 @@ def compute_time_accuracy(archive: PrintArchive, run_aggregate: dict | None = No
 
 
 async def _load_run_aggregates(
-    db: AsyncSession, archive_ids: list[int], printer_scope: PrinterScope | None = None
+    db: AsyncSession, archive_ids: list[int], printer_scope: PrinterScope | None = None, printer_id: int | None = None
 ) -> dict[int, dict]:
     """Batch-load per-archive run aggregates from PrintLogEntry.
 
@@ -375,7 +375,9 @@ async def _load_run_aggregates(
     absent from the map; callers should treat that as zero/none.
 
     With ``printer_scope``, only the runs on its printers count: a caller
-    limited to some printers sees only their prints (archive redesign).
+    limited to some printers sees only their prints (archive redesign). With
+    ``printer_id`` (the list's printer filter) the counts are of the prints
+    on that printer; ``latest_run_id`` stays the archive's latest.
     """
     from backend.app.models.print_log import PrintLogEntry
 
@@ -388,7 +390,7 @@ async def _load_run_aggregates(
             func.max(PrintLogEntry.started_at).label("last_run_at"),
             func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
             func.sum(case((PrintLogEntry.status == "completed", 1), else_=0)).label("successful"),
-            func.sum(case((PrintLogEntry.status == "failed", 1), else_=0)).label("failed"),
+            func.sum(case((PrintLogEntry.status.in_(("failed", "aborted")), 1), else_=0)).label("failed"),
             func.sum(case((PrintLogEntry.duration_unmeasured.is_(True), 1), else_=0)).label("estimated"),
         )
         .where(
@@ -398,6 +400,7 @@ async def _load_run_aggregates(
                 if printer_scope is not None and (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None
                 else []
             ),
+            *([PrintLogEntry.printer_id == printer_id] if printer_id is not None else []),
         )
         .group_by(PrintLogEntry.archive_id)
     )
@@ -690,7 +693,7 @@ async def list_archives(
             for sequence, (archive_id, _) in enumerate(group):
                 duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))
 
-    run_aggregates = await _load_run_aggregates(db, [a.id for a in archives], printer_scope)
+    run_aggregates = await _load_run_aggregates(db, [a.id for a in archives], printer_scope, printer_id)
     views = await scoped_views(db, archives, printer_scope)
 
     # Build response with duplicate sequence and original archive ID pre-computed
@@ -1129,6 +1132,57 @@ async def list_archives_slim(
     ]
 
 
+@router.get("/prints", response_model=PrintLogResponse)
+async def list_archive_prints(
+    date_from: date = Query(...),
+    date_to: date = Query(...),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
+    printer_id: int | None = None,
+    limit: int = Query(default=500, ge=1, le=1000),
+    offset: int = Query(default=0, ge=0),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """The prints of the archives the caller sees that ended in a date range
+    (archive redesign P4): the archives calendar. Visibility as the archive
+    list: archives they own (or all, with read-all), and of those only the
+    prints on their printers. Ordered by id, so a print logged while the
+    pages are read lands on the last one instead of shifting the others.
+    """
+    user, can_read_all = auth_result
+    if printer_id is not None and not printer_scope.allows(printer_id):
+        return PrintLogResponse(items=[], total=0)
+    zone = day_zone(tz)
+    ended = func.coalesce(PrintLogEntry.completed_at, PrintLogEntry.created_at)
+    visible = select(PrintArchive.id).where(
+        PrintArchive.deleted_at.is_(None), *_visible_archive_clauses(user, can_read_all, printer_scope)
+    )
+    conditions = [
+        PrintLogEntry.archive_id.in_(visible),
+        ended >= local_date_start(date_from, zone),
+        ended <= local_date_end(date_to, zone),
+    ]
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        conditions.append(clause)
+    if printer_id is not None:
+        conditions.append(PrintLogEntry.printer_id == printer_id)
+    total = await db.scalar(select(func.count(PrintLogEntry.id)).where(*conditions)) or 0
+    rows = await db.scalars(
+        select(PrintLogEntry).where(*conditions).order_by(PrintLogEntry.id).offset(offset).limit(limit)
+    )
+    from backend.app.schemas.print_log import PrintLogEntrySchema
+
+    return PrintLogResponse(items=[PrintLogEntrySchema.model_validate(r) for r in rows.all()], total=total)
+
+
 @router.get("/search", response_model=list[ArchiveResponse])
 async def search_archives(
     q: str = Query(..., min_length=2, description="Search query"),
@@ -1159,6 +1213,9 @@ async def search_archives(
     user, can_read_all = auth_result
     own_only = user is not None and not can_read_all
     search_term = q.strip()
+    # A printer outside the caller's scope finds nothing, as in the list.
+    if printer_id is not None and not printer_scope.allows(printer_id):
+        return []
 
     # Build dialect-specific full-text search query
     if is_sqlite():
@@ -1213,7 +1270,7 @@ async def search_archives(
         )
 
         if printer_id:
-            query = query.where(PrintArchive.printer_id == printer_id)
+            query = query.where(printed_on_clause(printer_id))
         if project_id:
             query = query.where(PrintArchive.project_id == project_id)
         if status:
@@ -1228,7 +1285,7 @@ async def search_archives(
         archives = result.scalars().all()
         # Load run aggregates so multi-run archives' time/accuracy badge is
         # suppressed consistently with the main list endpoint (#1608).
-        run_aggregates = await _load_run_aggregates(db, [a.id for a in archives], printer_scope)
+        run_aggregates = await _load_run_aggregates(db, [a.id for a in archives], printer_scope, printer_id)
         views = await scoped_views(db, archives, printer_scope)
         return [
             _scoped(archive_to_response(a, run_aggregate=run_aggregates.get(a.id)), views.get(a.id)) for a in archives
@@ -1250,7 +1307,7 @@ async def search_archives(
 
     # Apply additional filters
     if printer_id:
-        query = query.where(PrintArchive.printer_id == printer_id)
+        query = query.where(printed_on_clause(printer_id))
     if project_id:
         query = query.where(PrintArchive.project_id == project_id)
     if status:
@@ -1265,7 +1322,7 @@ async def search_archives(
 
     # Load run aggregates so multi-run archives' time/accuracy badge is
     # suppressed consistently with the main list endpoint (#1608).
-    run_aggregates = await _load_run_aggregates(db, [a.id for a in paginated], printer_scope)
+    run_aggregates = await _load_run_aggregates(db, [a.id for a in paginated], printer_scope, printer_id)
     views = await scoped_views(db, paginated, printer_scope)
     return [_scoped(archive_to_response(a, run_aggregate=run_aggregates.get(a.id)), views.get(a.id)) for a in paginated]
 
@@ -2188,7 +2245,7 @@ async def list_archive_runs(
     from backend.app.schemas.print_log import PrintLogEntrySchema
 
     user, can_read_all = auth_result
-    await _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all, printer_scope)
+    archive = await _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all, printer_scope)
 
     # Only the prints on the caller's printers (#1727), as the print log.
     scope_clause = printer_scope.where(PrintLogEntry.printer_id)
@@ -2199,9 +2256,59 @@ async def list_archive_runs(
     )
     entries = list(rows.scalars().all())
     items = [PrintLogEntrySchema.model_validate(e, from_attributes=True) for e in entries]
+    # A print's photos as the archive's photo route serves them: one removed
+    # from the archive is gone, whatever a run still lists.
+    archive_photos = set(archive.photos or [])
+    for item in items:
+        if item.photos is not None:
+            item.photos = [p for p in item.photos if p in archive_photos] or None
     return PrintLogResponse(items=items, total=len(items))
 
 
+@router.get("/{archive_id}/runs/{run_id}/timelapse")
+async def get_run_timelapse(
+    archive_id: int,
+    run_id: int,
+    db: AsyncSession = Depends(get_db, scope="function"),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
+):
+    """One print's timelapse video (archive redesign): plates of one archive
+    each keep their own, while the archive's timelapse route serves only the
+    latest print's. 404 for a print on a printer outside the caller's scope.
+    """
+    from backend.app.models.print_log import PrintLogEntry
+
+    user, can_read_all = auth_result
+    archive = await _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all, printer_scope)
+    run = await db.get(PrintLogEntry, run_id)
+    if run is None or run.archive_id != archive.id or not printer_scope.allows(run.printer_id):
+        raise HTTPException(404, "Run not found")
+    if not run.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+
+    base = settings.base_dir.resolve()
+    timelapse_path = (settings.base_dir / run.timelapse_path).resolve()
+    if not timelapse_path.is_relative_to(base) or not timelapse_path.is_file():
+        raise HTTPException(404, "Timelapse file not found")
+
+    mtime = int(timelapse_path.stat().st_mtime)
+    suffix = timelapse_path.suffix.lower()
+    media_type = {".mp4": "video/mp4", ".avi": "video/x-msvideo", ".mkv": "video/x-matroska"}.get(suffix, "video/mp4")
+    ext = suffix if suffix in (".mp4", ".avi", ".mkv") else ".mp4"
+    return FileResponse(
+        path=timelapse_path,
+        media_type=media_type,
+        filename=f"{archive.print_name or 'timelapse'}{ext}",
+        headers={"Cache-Control": "no-cache, must-revalidate", "ETag": f'"{mtime}"'},
+    )
+
+
 @router.get("/{archive_id}/similar")
 async def find_similar_archives(
     archive_id: int,

+ 16 - 0
backend/app/api/routes/print_log.py

@@ -136,9 +136,22 @@ async def get_print_log(
     # It lost failure_reason that way (#1687 part 4), then cost / energy_kwh /
     # energy_cost, which were written to the table but never sent — so the
     # Print Log's cost and energy columns read empty for every run (#2636).
+    # Archives of these prints that were deleted (soft, #1343: their prints
+    # stay logged), so the log doesn't link to an archive page that is gone.
+    from backend.app.models.archive import PrintArchive
+
+    archive_ids = {e.archive_id for e in entries if e.archive_id is not None}
+    deleted = (
+        await db.scalars(
+            select(PrintArchive.id).where(PrintArchive.id.in_(archive_ids), PrintArchive.deleted_at.is_not(None))
+        )
+        if archive_ids
+        else None
+    )
     return PrintLogResponse(
         items=[PrintLogEntrySchema.model_validate(e) for e in entries],
         total=total,
+        deleted_archive_ids=sorted(deleted.all()) if deleted is not None else [],
     )
 
 
@@ -337,6 +350,9 @@ async def update_print_log_entry(
             raise HTTPException(400, f"Unknown status: {new_status!r}")
         entry.status = new_status
 
+    if "notes" in payload:
+        entry.notes = (payload["notes"] or "").strip() or None
+
     if entry.archive_id is not None:
         from backend.app.services.print_log import mirror_latest_run
 

+ 14 - 0
backend/app/schemas/print_log.py

@@ -33,6 +33,15 @@ class PrintLogEntrySchema(BaseModel):
     wear_cost: float | None = None  # #694
     failure_reason: str | None = None
     user_verdict: str | None = None  # Post-print quality verdict (#1898)
+    user_verdict_source: str | None = None
+    confirm_requested: bool | None = None
+    duration_unmeasured: bool | None = None
+    quantity: int | None = None
+    # This print's own (archive redesign): its note, its finish photos (served
+    # by the archive's photo route) and its video (the run's timelapse route).
+    notes: str | None = None
+    photos: list[str] | None = None
+    timelapse_path: str | None = None
     thumbnail_path: str | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
@@ -42,6 +51,9 @@ class PrintLogEntrySchema(BaseModel):
 class PrintLogResponse(BaseModel):
     items: list[PrintLogEntrySchema]
     total: int
+    # The print log only: archives of these prints that were deleted, whose
+    # prints stay logged (#1343) but have no archive page to open.
+    deleted_archive_ids: list[int] = []
 
 
 class PrintLogEntryUpdate(IgnoresNullForRequiredColumns):
@@ -57,3 +69,5 @@ class PrintLogEntryUpdate(IgnoresNullForRequiredColumns):
 
     failure_reason: str | None = None
     status: str | None = None
+    # The print's own note, beside the archive's (the file's) note.
+    notes: str | None = None

+ 19 - 1
backend/app/services/archive.py

@@ -1124,6 +1124,18 @@ async def _count_related_queue_items(db: AsyncSession, archive_id: int) -> tuple
     return int(total or 0), int(printing or 0)
 
 
+def printed_on_clause(printer_id: int):
+    """Archives printed on ``printer_id``, as a WHERE clause: the archive's
+    own printer (its latest print's) or any print's, a print running now
+    included (archive redesign P4)."""
+    from backend.app.models.print_log import PrintLogEntry
+
+    # The table, not the entity: the ORM hides open runs from every read.
+    runs = PrintLogEntry.__table__
+    printed_on = select(runs.c.archive_id).where(runs.c.archive_id.is_not(None), runs.c.printer_id == printer_id)
+    return or_(PrintArchive.printer_id == printer_id, PrintArchive.id.in_(printed_on))
+
+
 class ArchiveService:
     """Service for archiving print jobs."""
 
@@ -1680,7 +1692,13 @@ class ArchiveService:
         )
 
         if printer_id:
-            query = query.where(PrintArchive.printer_id == printer_id)
+            # Printed on it: the archive's own printer (its latest print's) or
+            # any print's, a print running now included (archive redesign). A
+            # printer outside the caller's scope finds nothing: its prints are
+            # not theirs to see.
+            if printer_scope is not None and not printer_scope.allows(printer_id):
+                return []
+            query = query.where(printed_on_clause(printer_id))
 
         if project_id:
             query = query.where(PrintArchive.project_id == project_id)

+ 191 - 0
backend/tests/integration/test_archive_filters_by_print.py

@@ -0,0 +1,191 @@
+"""The archive list's filters look at every print (archive redesign P4).
+
+An archive is a file printed maybe several times, maybe on several printers:
+the printer filter finds it on each printer one of its prints ran on, and its
+failed prints count the aborted ones too.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import RUN_OPEN, PrintLogEntry
+from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+T1 = datetime(2026, 10, 1, 9)
+T2 = datetime(2026, 10, 20, 9)
+
+
+@pytest.fixture
+async def printed(db_session, printer_factory):
+    a, b, c = await printer_factory(name="A"), await printer_factory(name="B"), await printer_factory(name="C")
+    planter = PrintArchive(
+        printer_id=b.id,
+        filename="planter.3mf",
+        file_path="archives/t/planter.3mf",
+        file_size=1,
+        print_name="Planter",
+        status="completed",
+    )
+    hook = PrintArchive(
+        printer_id=c.id, filename="hook.3mf", file_path="archives/t/hook.3mf", file_size=1, print_name="Hook"
+    )
+    db_session.add_all([planter, hook])
+    await db_session.flush()
+    db_session.add_all(
+        [
+            PrintLogEntry(archive_id=planter.id, printer_id=a.id, status="aborted", started_at=T1, created_at=T1),
+            PrintLogEntry(archive_id=planter.id, printer_id=b.id, status="completed", started_at=T2, created_at=T2),
+        ]
+    )
+    await db_session.commit()
+    return {"a": a.id, "b": b.id, "c": c.id, "planter": planter.id, "hook": hook.id}
+
+
+async def _names(async_client, headers=None, **params) -> list[str]:
+    response = await async_client.get("/api/v1/archives/", params=params, headers=headers or {})
+    assert response.status_code == 200, response.text
+    return sorted(r["print_name"] for r in response.json())
+
+
+async def test_an_archive_is_found_on_each_printer_it_printed_on(async_client, printed):
+    assert await _names(async_client, printer_id=printed["a"]) == ["Planter"]
+    assert await _names(async_client, printer_id=printed["b"]) == ["Planter"]
+    assert await _names(async_client, printer_id=printed["c"]) == ["Hook"]
+
+
+async def test_a_print_running_now_counts(async_client, db_session, printed):
+    db_session.add(PrintLogEntry(archive_id=printed["hook"], printer_id=printed["a"], status=RUN_OPEN, started_at=T2))
+    await db_session.commit()
+
+    assert await _names(async_client, printer_id=printed["a"]) == ["Hook", "Planter"]
+
+
+async def test_a_printer_outside_the_callers_finds_nothing(async_client, printed):
+    """Planter is theirs to see by its print on A; that it printed on B is not."""
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(
+        async_client, admin, "of_a", ["archives:read_all", "printers:read"], printer_ids=[printed["a"]]
+    )
+
+    assert await _names(async_client, _auth(jwt), printer_id=printed["a"]) == ["Planter"]
+    assert await _names(async_client, _auth(jwt), printer_id=printed["b"]) == []
+
+
+async def test_an_aborted_print_is_a_failed_one(async_client, printed):
+    response = await async_client.get("/api/v1/archives/")
+
+    [planter] = [r for r in response.json() if r["id"] == printed["planter"]]
+    assert (planter["status"], planter["failed_run_count"]) == ("completed", 1)
+
+
+async def test_with_a_printer_picked_the_counts_are_its_prints(async_client, printed):
+    """Failed Prints under printer B: Planter's aborted print was on A."""
+    response = await async_client.get("/api/v1/archives/", params={"printer_id": printed["b"]})
+
+    [planter] = response.json()
+    assert (planter["run_count"], planter["failed_run_count"]) == (1, 0)
+
+
+async def test_search_finds_an_archive_on_each_printer_it_printed_on(async_client, printed):
+    response = await async_client.get("/api/v1/archives/search", params={"q": "Planter", "printer_id": printed["a"]})
+
+    assert response.status_code == 200, response.text
+    assert [r["print_name"] for r in response.json()] == ["Planter"]
+
+
+async def test_search_under_a_printer_outside_the_callers_finds_nothing(async_client, printed):
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(
+        async_client, admin, "of_a", ["archives:read_all", "printers:read"], printer_ids=[printed["a"]]
+    )
+
+    response = await async_client.get(
+        "/api/v1/archives/search", params={"q": "Planter", "printer_id": printed["b"]}, headers=_auth(jwt)
+    )
+
+    assert response.json() == []
+
+
+# The calendar: the prints of the archives the caller sees, by when they ended.
+
+
+async def _prints(async_client, headers=None, **params) -> list[int]:
+    response = await async_client.get(
+        "/api/v1/archives/prints",
+        params={"date_from": "2026-10-01", "date_to": "2026-10-31", "tz": "UTC", **params},
+        headers=headers or {},
+    )
+    assert response.status_code == 200, response.text
+    return [r["id"] for r in response.json()["items"]]
+
+
+async def test_the_calendar_gets_the_prints_that_ended_in_the_month(async_client, db_session, printed):
+    early = PrintLogEntry(
+        archive_id=printed["hook"],
+        printer_id=printed["c"],
+        status="completed",
+        started_at=datetime(2026, 9, 29, 9),
+        completed_at=datetime(2026, 10, 1, 1),
+    )
+    later = PrintLogEntry(
+        archive_id=printed["hook"], printer_id=printed["c"], status="completed", completed_at=datetime(2026, 11, 1, 1)
+    )
+    db_session.add_all([early, later])
+    await db_session.commit()
+    runs = await async_client.get(f"/api/v1/archives/{printed['planter']}/runs")
+    planter = sorted(r["id"] for r in runs.json()["items"])
+
+    # Each print in id order; the Planter prints carry no end time and count
+    # by when they were logged.
+    ids = await _prints(async_client)
+    assert early.id in ids and later.id not in ids
+    assert ids == sorted(ids)
+    assert set(planter) <= set(ids)
+    assert await _prints(async_client, printer_id=printed["c"]) == [early.id]
+
+
+async def test_the_calendar_shows_an_owners_archive_whoever_printed_it(async_client, db_session, printed):
+    """read_own goes by who owns the archive, as the archive list: a print
+    of it started by no one (from the printer) is still theirs to see."""
+    admin = await _admin_token(async_client)
+    jwt, user_id = await _user(async_client, admin, "owner", ["archives:read_own"])
+    planter = await db_session.get(PrintArchive, printed["planter"])
+    planter.created_by_id = user_id
+    # Hook isn't theirs, though they started its print.
+    db_session.add(
+        PrintLogEntry(
+            archive_id=printed["hook"],
+            printer_id=printed["c"],
+            status="completed",
+            created_by_id=user_id,
+            created_at=T2,
+        )
+    )
+    await db_session.commit()
+
+    ids = await _prints(async_client, _auth(jwt))
+
+    runs = await async_client.get(f"/api/v1/archives/{printed['planter']}/runs", headers=_auth(admin))
+    assert len(ids) == 2
+    assert sorted(ids) == sorted(r["id"] for r in runs.json()["items"])
+
+
+async def test_the_calendar_leaves_out_deleted_archives_and_other_printers(async_client, db_session, printed):
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(
+        async_client, admin, "of_a", ["archives:read_all", "printers:read"], printer_ids=[printed["a"]]
+    )
+    on_a = await _prints(async_client, _auth(jwt))
+    assert len(on_a) == 1
+    assert await _prints(async_client, _auth(jwt), printer_id=printed["b"]) == []
+
+    planter = await db_session.get(PrintArchive, printed["planter"])
+    planter.deleted_at = T2
+    await db_session.commit()
+    assert await _prints(async_client, _auth(admin)) == []

+ 124 - 0
backend/tests/integration/test_archive_page_runs.py

@@ -0,0 +1,124 @@
+"""What the archive page shows of each print (archive redesign P4).
+
+The page lists an archive's prints with what is each print's own: its note,
+its finish photos and its video. Plates of one archive keep their own videos,
+so each print's video has a route of its own; a caller limited to some
+printers gets only their prints' media.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.tests.integration.test_archive_scope_per_run import DIR, _url, files, plates  # noqa: F401
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _runs(async_client, plates, headers) -> dict[int, dict]:
+    response = await async_client.get(_url(plates, "/runs"), headers=headers)
+    assert response.status_code == 200, response.text
+    return {r["id"]: r for r in response.json()["items"]}
+
+
+async def test_each_print_carries_its_own_media_and_note(async_client, db_session, plates):
+    first = await db_session.get(PrintLogEntry, plates.first)
+    first.notes = "brim lifted"
+    await db_session.commit()
+
+    runs = await _runs(async_client, plates, plates.admin)
+
+    assert (runs[plates.first]["photos"], runs[plates.first]["timelapse_path"], runs[plates.first]["notes"]) == (
+        ["finish_a.jpg"],
+        f"{DIR}/a.mp4",
+        "brim lifted",
+    )
+    assert runs[plates.second]["photos"] == ["finish_b.jpg"]
+
+
+async def test_a_photo_removed_from_the_archive_is_no_prints_any_more(async_client, db_session, plates):
+    archive = await db_session.get(PrintArchive, plates.archive)
+    archive.photos = ["finish_b.jpg", "0a1b2c3d.jpg"]
+    await db_session.commit()
+
+    runs = await _runs(async_client, plates, plates.admin)
+
+    assert runs[plates.first]["photos"] is None
+    assert runs[plates.second]["photos"] == ["finish_b.jpg"]
+
+
+async def test_each_print_plays_its_own_video(async_client, files, plates):
+    (files / DIR / "a.mp4").write_bytes(b"plate one")
+    (files / DIR / "b.mp4").write_bytes(b"plate two")
+
+    first = await async_client.get(_url(plates, f"/runs/{plates.first}/timelapse"), headers=plates.admin)
+    second = await async_client.get(_url(plates, f"/runs/{plates.second}/timelapse"), headers=plates.admin)
+
+    assert (first.status_code, first.content) == (200, b"plate one")
+    assert (second.status_code, second.content) == (200, b"plate two")
+
+
+async def test_a_print_outside_the_callers_printers_plays_nothing(async_client, plates):
+    assert (
+        await async_client.get(_url(plates, f"/runs/{plates.first}/timelapse"), headers=plates.of_a)
+    ).status_code == 200
+    assert (
+        await async_client.get(_url(plates, f"/runs/{plates.second}/timelapse"), headers=plates.of_a)
+    ).status_code == 404
+
+
+async def test_a_print_of_another_archive_plays_nothing(async_client, db_session, plates):
+    other = PrintArchive(filename="o.3mf", file_path=f"{DIR}/o.3mf", file_size=1, print_name="o")
+    db_session.add(other)
+    await db_session.commit()
+
+    response = await async_client.get(
+        f"/api/v1/archives/{other.id}/runs/{plates.first}/timelapse", headers=plates.admin
+    )
+
+    assert response.status_code == 404
+
+
+async def test_a_video_outside_the_data_dir_is_not_served(async_client, db_session, files, plates):
+    outside = files.parent / "secret.mp4"
+    outside.write_bytes(b"no")
+    first = await db_session.get(PrintLogEntry, plates.first)
+    first.timelapse_path = "../secret.mp4"
+    await db_session.commit()
+
+    response = await async_client.get(_url(plates, f"/runs/{plates.first}/timelapse"), headers=plates.admin)
+
+    assert response.status_code == 404
+
+
+async def test_a_print_gets_its_own_note(async_client, db_session, plates):
+    response = await async_client.patch(
+        f"/api/v1/print-log/{plates.first}", json={"notes": "  brim lifted  "}, headers=plates.admin
+    )
+    assert response.status_code == 200, response.text
+    assert response.json()["notes"] == "brim lifted"
+
+    cleared = await async_client.patch(f"/api/v1/print-log/{plates.first}", json={"notes": ""}, headers=plates.admin)
+    assert cleared.json()["notes"] is None
+    # The file's note is the archive's own, untouched.
+    archive = await db_session.get(PrintArchive, plates.archive)
+    await db_session.refresh(archive)
+    assert archive.notes is None
+
+
+async def test_a_print_outside_the_callers_printers_takes_no_note(async_client, plates):
+    response = await async_client.patch(f"/api/v1/print-log/{plates.second}", json={"notes": "x"}, headers=plates.of_a)
+
+    assert response.status_code == 404
+
+
+async def test_the_print_log_names_the_deleted_archives_of_its_prints(async_client, db_session, plates):
+    archive = await db_session.get(PrintArchive, plates.archive)
+    archive.deleted_at = archive.created_at
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/print-log/", headers=plates.admin)
+
+    assert response.json()["deleted_archive_ids"] == [plates.archive]

+ 7 - 0
backend/tests/unit/test_print_log.py

@@ -181,6 +181,13 @@ class TestSchemaValidatesFromOrmRow:
         # nothing falls back to its default.
         row.failure_reason = "warping"
         row.user_verdict = "good"
+        row.user_verdict_source = "dialog"
+        row.confirm_requested = True
+        row.duration_unmeasured = False
+        row.quantity = 1
+        row.notes = "brim lifted"
+        row.photos = ["finish_1.jpg"]
+        row.timelapse_path = "archives/1/x/video.mp4"
         row.thumbnail_path = "archives/1/x/thumbnail.png"
         row.created_by_id = 2
         row.created_by_username = "martin"

+ 2 - 0
frontend/src/App.tsx

@@ -19,6 +19,7 @@ import { lazyPage } from './utils/lazyPage';
 // Every page loads as its own file when first opened (#3175).
 const PrintersPage = lazyPage(() => import('./pages/PrintersPage'), 'PrintersPage');
 const ArchivesPage = lazyPage(() => import('./pages/ArchivesPage'), 'ArchivesPage');
+const ArchiveDetailPage = lazyPage(() => import('./pages/ArchiveDetailPage'), 'ArchiveDetailPage');
 const QueuePage = lazyPage(() => import('./pages/QueuePage'), 'QueuePage');
 const StatsPage = lazyPage(() => import('./pages/StatsPage'), 'StatsPage');
 const SettingsPage = lazyPage(() => import('./pages/SettingsPage'), 'SettingsPage');
@@ -233,6 +234,7 @@ function App() {
                 <Route element={<ProtectedRoute><WebSocketProvider><EmbeddedCameraProvider><Layout /></EmbeddedCameraProvider></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
+                  <Route path="archives/:id" element={<ArchiveDetailPage />} />
                   <Route path="queue" element={<QueuePage />} />
                   {/* Slicer Pipelines (#1425) — Pipelines tab lives on the
                       Print Queue page (Queue + History + Timeline +

+ 100 - 0
frontend/src/__tests__/components/CalendarView.test.tsx

@@ -0,0 +1,100 @@
+/**
+ * The archives calendar shows prints, not archives (archive redesign P4): a
+ * file printed twice this month is there twice, on the days it ended.
+ */
+
+/// <reference types="@testing-library/jest-dom" />
+
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { CalendarView } from '../../components/CalendarView';
+import type { Archive } from '../../api/client';
+
+const now = new Date();
+const onDay = (day: number) => new Date(now.getFullYear(), now.getMonth(), day, 12).toISOString();
+
+const archive = (overrides: Partial<Archive>) =>
+  ({
+    filename: 'f.3mf',
+    print_name: 'File',
+    status: 'completed',
+    thumbnail_path: null,
+    filament_color: null,
+    run_count: 0,
+    completed_at: null,
+    created_at: onDay(1),
+    ...overrides,
+  }) as Archive;
+
+const run = (id: number, archiveId: number, day: number, status = 'completed') => ({
+  id,
+  archive_id: archiveId,
+  status,
+  completed_at: onDay(day),
+  created_at: onDay(day),
+  filament_color: null,
+});
+
+describe('CalendarView', () => {
+  it('shows each print of an archive on its own day and opens it there', async () => {
+    let asked: URLSearchParams | null = null;
+    server.use(
+      http.get('/api/v1/archives/prints', ({ request }) => {
+        asked = new URL(request.url).searchParams;
+        return HttpResponse.json({
+          items: [run(31, 7, 3, 'failed'), run(32, 7, 5), run(33, 99, 5)],
+          total: 3,
+        });
+      }),
+    );
+    const onPrintClick = vi.fn();
+    render(
+      <CalendarView
+        archives={[
+          archive({ id: 7, print_name: 'Planter', run_count: 2 }),
+          // Printed before prints were logged: stands for its one print.
+          archive({ id: 8, print_name: 'Hook', completed_at: onDay(5) }),
+          archive({ id: 9, print_name: 'Never printed', status: 'archived' }),
+        ]}
+        printerId={2}
+        onPrintClick={onPrintClick}
+      />
+    );
+
+    // Three prints this month; archive 99 is one the page's filters hide.
+    await waitFor(() => expect(screen.getByText('Prints this month').previousSibling?.textContent).toBe('3'));
+    expect(asked!.get('printer_id')).toBe('2');
+    // The month, as the calendar's local days.
+    const last = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
+    const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+    expect([asked!.get('date_from'), asked!.get('date_to')]).toEqual([`${month}-01`, `${month}-${last}`]);
+    expect(screen.getByText('Failed').previousSibling?.textContent).toBe('1');
+
+    fireEvent.click(screen.getByRole('button', { name: /^5/ }));
+    fireEvent.click(await screen.findByText('Hook'));
+    expect(onPrintClick).toHaveBeenLastCalledWith(8, null);
+    fireEvent.click(screen.getByText('Planter'));
+    expect(onPrintClick).toHaveBeenLastCalledWith(7, 32);
+    expect(screen.queryByText('Never printed')).toBeNull();
+  });
+
+  it('reads every page of a busy month', async () => {
+    const offsets: string[] = [];
+    server.use(
+      http.get('/api/v1/archives/prints', ({ request }) => {
+        const offset = new URL(request.url).searchParams.get('offset') ?? '0';
+        offsets.push(offset);
+        const start = Number(offset);
+        const items = Array.from({ length: offset === '0' ? 500 : 1 }, (_, i) => run(start + i + 1, 7, 2));
+        return HttpResponse.json({ items, total: 501 });
+      }),
+    );
+    render(<CalendarView archives={[archive({ id: 7, print_name: 'Planter', run_count: 501 })]} />);
+
+    await waitFor(() => expect(screen.getByText('Prints this month').previousSibling?.textContent).toBe('501'));
+    expect(offsets).toEqual(['0', '500']);
+  });
+});

+ 200 - 0
frontend/src/__tests__/pages/ArchiveDetailPage.test.tsx

@@ -0,0 +1,200 @@
+/**
+ * The archive page (archive redesign P4): the file in the header, every print
+ * of it below with its own outcome, figures, note and media.
+ */
+
+/// <reference types="@testing-library/jest-dom" />
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { ArchiveDetailPage } from '../../pages/ArchiveDetailPage';
+
+const navigate = vi.fn();
+let routeId = '5';
+vi.mock('react-router-dom', async () => {
+  const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
+  return {
+    ...actual,
+    useParams: () => ({ id: routeId }),
+    useNavigate: () => navigate,
+  };
+});
+
+const archive = {
+  id: 5,
+  filename: 'planter.gcode.3mf',
+  print_name: 'Planter',
+  printer_id: 2,
+  status: 'failed',
+  print_time_seconds: 7200,
+  filament_used_grams: 80,
+  total_layers: 240,
+  thumbnail_path: null,
+  timelapse_path: 'archives/p/b.mp4',
+  photos: ['finish_a.jpg', 'finish_b.jpg'],
+  notes: 'Two plates',
+  tags: 'garden',
+  project_id: null,
+  project_name: null,
+  file_path: 'archives/p/planter.gcode.3mf',
+  plate_id: 2,
+  created_by_id: null,
+  run_count: 2,
+  successful_run_count: 1,
+  failed_run_count: 1,
+  created_at: '2026-10-01T09:00:00Z',
+};
+
+const run = (overrides: Record<string, unknown>) => ({
+  archive_id: 5,
+  print_name: 'Planter',
+  printer_name: 'A',
+  printer_id: 1,
+  status: 'completed',
+  started_at: '2026-10-01T09:00:00Z',
+  completed_at: '2026-10-01T10:00:00Z',
+  duration_seconds: 3600,
+  filament_type: 'PLA',
+  filament_color: null,
+  filament_used_grams: 40,
+  cost: 1.2,
+  energy_kwh: null,
+  energy_cost: null,
+  wear_cost: null,
+  failure_reason: null,
+  user_verdict: null,
+  confirm_requested: null,
+  notes: null,
+  photos: null,
+  timelapse_path: null,
+  thumbnail_path: null,
+  created_by_id: null,
+  created_by_username: null,
+  created_at: '2026-10-01T10:00:00Z',
+  ...overrides,
+});
+
+const runs = [
+  run({ id: 12, plate_id: 2, printer_name: 'B', status: 'failed', failure_reason: 'spaghetti', started_at: '2026-10-20T09:00:00Z', timelapse_path: 'archives/p/b.mp4', photos: ['finish_b.jpg'] }),
+  run({ id: 11, plate_id: 1, user_verdict: 'good', notes: 'brim lifted', timelapse_path: 'archives/p/a.mp4', photos: ['finish_a.jpg'] }),
+];
+
+describe('ArchiveDetailPage', () => {
+  let patched: { id: string; body: unknown } | null;
+
+  beforeEach(() => {
+    routeId = '5';
+    navigate.mockReset();
+    patched = null;
+    server.use(
+      http.get('/api/v1/archives/:id', ({ params }) =>
+        Number(params.id) === 5 || Number(params.id) === 4 ? HttpResponse.json(archive) : new HttpResponse(null, { status: 404 })
+      ),
+      http.get('/api/v1/archives/5/runs', () => HttpResponse.json({ items: runs, total: runs.length })),
+      http.patch('/api/v1/print-log/:id', async ({ params, request }) => {
+        patched = { id: String(params.id), body: await request.json() };
+        return HttpResponse.json(run({ id: Number(params.id), notes: 'warped' }));
+      }),
+    );
+  });
+
+  it('shows the file and every print of it, newest first', async () => {
+    render(<ArchiveDetailPage />);
+
+    expect(await screen.findByRole('heading', { name: 'Planter' })).toBeInTheDocument();
+    expect(screen.getByText('Two plates')).toBeInTheDocument();
+    const items = await screen.findAllByTestId(/archive-run-/);
+    expect(items.map((i) => i.dataset.testid)).toEqual(['archive-run-12', 'archive-run-11']);
+    expect(within(items[0]).getByText('B')).toBeInTheDocument();
+    expect(within(items[1]).getByText('brim lifted')).toBeInTheDocument();
+    expect(within(items[1]).getByText('good')).toBeInTheDocument();
+  });
+
+  it("plays each print's own video", async () => {
+    render(<ArchiveDetailPage />);
+    const first = await screen.findByTestId('archive-run-11');
+
+    fireEvent.click(within(first).getByRole('button', { name: /Timelapse video/ }));
+
+    const video = await waitFor(() => {
+      const el = document.querySelector('video');
+      expect(el).not.toBeNull();
+      return el!;
+    });
+    expect(video.getAttribute('src')).toContain('/archives/5/runs/11/timelapse');
+  });
+
+  it("leaves the file's video to the prints that hold it", async () => {
+    render(<ArchiveDetailPage />);
+    await screen.findByTestId('archive-run-12');
+
+    // The archive's video is print 12's: no header button for it.
+    const header = screen.getByRole('heading', { name: 'Planter' }).closest('div.flex-1')!;
+    expect(within(header as HTMLElement).queryByRole('button', { name: /Timelapse video/ })).toBeNull();
+  });
+
+  it('saves a note on one print', async () => {
+    render(<ArchiveDetailPage />);
+    const latest = await screen.findByTestId('archive-run-12');
+
+    fireEvent.click(within(latest).getByRole('button', { name: /Add note/ }));
+    fireEvent.change(within(latest).getByRole('textbox'), { target: { value: 'warped' } });
+    fireEvent.click(within(latest).getByRole('button', { name: 'Save' }));
+
+    await waitFor(() => expect(patched).toEqual({ id: '12', body: { notes: 'warped' } }));
+  });
+
+  it('opens the archive a merged one went into', async () => {
+    routeId = '4';
+    window.history.pushState({}, '', '/archives/4?run=11');
+    render(<ArchiveDetailPage />);
+
+    await waitFor(() => expect(navigate).toHaveBeenCalledWith('/archives/5?run=11', { replace: true }));
+    window.history.pushState({}, '', '/');
+  });
+
+  it('marks the print a link points at', async () => {
+    window.history.pushState({}, '', '/archives/5?run=11');
+    render(<ArchiveDetailPage />);
+
+    const item = await screen.findByTestId('archive-run-11');
+    expect(item.firstElementChild?.className).toContain('ring-2');
+    expect(screen.getByTestId('archive-run-12').firstElementChild?.className ?? '').not.toContain('ring-2');
+    window.history.pushState({}, '', '/');
+  });
+
+  it('offers to try again when the archive could not be loaded', async () => {
+    let calls = 0;
+    server.use(
+      http.get('/api/v1/archives/:id', () => {
+        calls += 1;
+        return calls === 1 ? new HttpResponse(null, { status: 500 }) : HttpResponse.json(archive);
+      }),
+    );
+    render(<ArchiveDetailPage />);
+
+    fireEvent.click(await screen.findByRole('button', { name: 'Try again' }));
+
+    expect(await screen.findByRole('heading', { name: 'Planter' })).toBeInTheDocument();
+  });
+
+  it('names the one print of an archive printed before prints were logged', async () => {
+    server.use(
+      http.get('/api/v1/archives/:id', () => HttpResponse.json({ ...archive, status: 'completed', run_count: 0 })),
+      http.get('/api/v1/archives/5/runs', () => HttpResponse.json({ items: [], total: 0 })),
+    );
+    render(<ArchiveDetailPage />);
+
+    expect(await screen.findByText('Printed before Bambuddy logged each print: Completed.')).toBeInTheDocument();
+  });
+
+  it('says so for an archive it may not see', async () => {
+    routeId = '9';
+    render(<ArchiveDetailPage />);
+
+    expect(await screen.findByText('This archive does not exist or you cannot see it.')).toBeInTheDocument();
+  });
+});

+ 52 - 1
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
 import { render } from '../utils';
 import { ArchivesPage } from '../../pages/ArchivesPage';
 import { http, HttpResponse } from 'msw';
@@ -600,12 +600,49 @@ describe('ArchivesPage', () => {
     });
   });
 
+  // Archive redesign P4: a card opens its archive's page; the controls on it
+  // do what they say and leave the page where it is.
+  describe('opening an archive', () => {
+    beforeEach(() => {
+      window.localStorage.removeItem('archiveCollection');
+      window.history.pushState({}, '', '/archives');
+      server.use(http.get('/api/v1/archives/', () => HttpResponse.json(mockArchives)));
+    });
+
+    it('opens the archive page from a click on its card', async () => {
+      render(<ArchivesPage />);
+      fireEvent.click(await screen.findByText('#2'));
+
+      await waitFor(() => expect(window.location.pathname).toBe('/archives/2'));
+    });
+
+    it('leaves the page alone for a click on a control in the card', async () => {
+      render(<ArchivesPage />);
+      await screen.findByText('#2');
+      fireEvent.click(screen.getAllByTitle(/favorites/i)[0]);
+
+      expect(window.location.pathname).toBe('/archives');
+    });
+
+    it('leaves the page alone for a click in a dialog the card opened', async () => {
+      render(<ArchivesPage />);
+      await screen.findByText('#2');
+      // The card's own Edit button; its dialog renders inside the card.
+      const card = screen.getByText('#2').closest('[data-archive-id]') as HTMLElement;
+      fireEvent.click(within(card).getByTitle('Edit'));
+      fireEvent.click(await screen.findByText('Edit Archive'));
+
+      expect(window.location.pathname).toBe('/archives');
+    });
+  });
+
   describe('Not Printed / Printed collections', () => {
     const mixedStatusArchives = [
       { ...mockArchives[0], id: 100, print_name: 'NeverPrinted', status: 'archived', started_at: null, completed_at: null },
       { ...mockArchives[0], id: 101, print_name: 'WasPrinted', status: 'completed' },
       { ...mockArchives[0], id: 102, print_name: 'WasFailed', status: 'failed' },
       { ...mockArchives[0], id: 103, print_name: 'WasCancelled', status: 'cancelled' },
+      { ...mockArchives[0], id: 104, print_name: 'FailedBefore', status: 'completed', run_count: 2, failed_run_count: 1 },
     ];
 
     beforeEach(() => {
@@ -651,6 +688,20 @@ describe('ArchivesPage', () => {
         expect(screen.getByText('WasCancelled')).toBeInTheDocument();
       });
     });
+
+    // Archive redesign P4: an archive is a file printed maybe several times.
+    it('"Failed Prints" finds archives one print of which failed, not only the latest', async () => {
+      render(<ArchivesPage />);
+      await screen.findByText('FailedBefore');
+
+      fireEvent.change(screen.getByDisplayValue('All Archives'), { target: { value: 'failed' } });
+
+      await waitFor(() => {
+        expect(screen.getByText('WasFailed')).toBeInTheDocument();
+        expect(screen.getByText('FailedBefore')).toBeInTheDocument();
+        expect(screen.queryByText('WasPrinted')).not.toBeInTheDocument();
+      });
+    });
   });
 
   // Archive redesign P2: merging changes and removes whole archives, so the

+ 20 - 0
frontend/src/__tests__/pages/ArchivesPagePrintLogColumns.test.tsx

@@ -125,6 +125,26 @@ describe('Print Log columns', () => {
     mockLog();
   });
 
+  // Archive redesign P4: a print opens its archive's page at that print.
+  it("links a print to its archive's page", async () => {
+    await openLogView();
+
+    const link = within(screen.getByRole('table')).getByRole('link', { name: 'Califlower Calibration' });
+    expect(link.getAttribute('href')).toBe('/archives/10?run=1');
+  });
+
+  it('links no print of a deleted archive: its page is gone', async () => {
+    server.use(
+      http.get('/api/v1/print-log/', () =>
+        HttpResponse.json({ items: LOG_ENTRIES, total: 1, deleted_archive_ids: [10] }),
+      ),
+    );
+    await openLogView();
+
+    const table = screen.getByRole('table');
+    expect(within(table).getByText('Califlower Calibration').closest('a')).toBeNull();
+  });
+
   it('shows how much filament the run used, not just which filament', async () => {
     await openLogView();
 

+ 13 - 1
frontend/src/__tests__/utils/projectQueries.test.ts

@@ -70,6 +70,18 @@ describe('invalidateArchiveAndProjectViews', () => {
 
     await invalidateArchiveAndProjectViews({ invalidateQueries } as unknown as QueryClient);
 
-    expect(settled).toBe(6);
+    expect(settled).toBe(9); // the list, the archive page and calendar (3), the project views (5)
+  });
+});
+
+// Archive redesign P4: an edit or delete also shows on the archive's page
+// (its archive and prints) and in the calendar's prints.
+describe('invalidateArchiveAndProjectViews and the archive page', () => {
+  it("refreshes the archive page's archive and prints and the calendar", async () => {
+    const { client, invalidateQueries } = mockClient();
+
+    await invalidateArchiveAndProjectViews(client);
+
+    expect(keysFrom(invalidateQueries)).toEqual(expect.arrayContaining(['archive', 'archive-runs', 'calendar-runs']));
   });
 });

+ 24 - 1
frontend/src/api/client.ts

@@ -980,6 +980,15 @@ export interface PrintLogEntry {
   energy_cost: number | null;
   wear_cost: number | null;  // Printer wear (#694)
   failure_reason: string | null;
+  user_verdict?: 'good' | 'reject' | null;
+  user_verdict_source?: string | null;
+  confirm_requested?: boolean | null;
+  duration_unmeasured?: boolean | null;
+  quantity?: number | null;
+  // This print's own (archive redesign): note, finish photos, video.
+  notes?: string | null;
+  photos?: string[] | null;
+  timelapse_path?: string | null;
   thumbnail_path: string | null;
   created_by_id: number | null;
   created_by_username: string | null;
@@ -989,6 +998,8 @@ export interface PrintLogEntry {
 export interface PrintLogResponse {
   items: PrintLogEntry[];
   total: number;
+  // The print log only: archives of its prints that were deleted.
+  deleted_archive_ids?: number[];
 }
 
 export interface ArchiveStats {
@@ -5680,6 +5691,16 @@ export const api = {
   getArchive: (id: number, runId?: number | null) =>
     request<Archive>(`/archives/${id}${runId != null ? `?run_id=${runId}` : ''}`),
   getArchiveRuns: (id: number) => request<PrintLogResponse>(`/archives/${id}/runs`),
+  // The prints of the archives the caller sees that ended between two days
+  // (archive redesign P4: the calendar), in id order.
+  getArchivePrints: (params: { dateFrom: string; dateTo: string; printerId?: number; limit?: number; offset?: number }) => {
+    const search = new URLSearchParams({ date_from: params.dateFrom, date_to: params.dateTo });
+    if (params.printerId) search.set('printer_id', String(params.printerId));
+    if (params.limit) search.set('limit', String(params.limit));
+    if (params.offset) search.set('offset', String(params.offset));
+    addDayZone(search);
+    return request<PrintLogResponse>(`/archives/prints?${search}`);
+  },
   /**
    * Pre-flight for the delete-confirm modal (#1734). Returns the number of
    * related queue items that will be removed along with the archive AND how
@@ -5912,6 +5933,8 @@ export const api = {
   },
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchiveTimelapse: (id: number) => withMediaToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),
+  getArchiveRunTimelapse: (id: number, runId: number) =>
+    withMediaToken(`${API_BASE}/archives/${id}/runs/${runId}/timelapse?v=${Date.now()}`),
   downloadArchiveTimelapse: async (id: number, filename: string): Promise<void> => {
     const prepared = await request<{ token: string; filename: string }>(
       `/archives/${id}/media-download-token`,
@@ -6312,7 +6335,7 @@ export const api = {
   // archive to reach through and this is the only path to classify them.
   updatePrintLogEntry: (
     id: number,
-    body: { failure_reason?: string | null; status?: string },
+    body: { failure_reason?: string | null; status?: string; notes?: string | null },
   ) =>
     request<PrintLogEntry>(`/print-log/${id}`, {
       method: 'PATCH',

+ 126 - 66
frontend/src/components/CalendarView.tsx

@@ -1,15 +1,35 @@
 import { useState, useMemo } from 'react';
-import { ChevronLeft, ChevronRight } from 'lucide-react';
-import type { Archive } from '../api/client';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
+import type { Archive, PrintLogEntry } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate } from '../utils/date';
 
 interface CalendarViewProps {
+  // The archives the page's filters let through: their prints are shown.
   archives: Archive[];
-  onArchiveClick?: (archive: Archive) => void;
+  // Only the prints on this printer, as the page's printer filter.
+  printerId?: number | null;
+  onPrintClick?: (archiveId: number, runId: number | null) => void;
   highlightedArchiveId?: number | null;
 }
 
+// One print on the calendar: a run, or an archive printed before runs were
+// logged, which stands for its one print.
+interface CalendarPrint {
+  key: string;
+  archive: Archive;
+  runId: number | null;
+  status: string;
+  date: Date;
+  colors: string | null;
+}
+
+const PRINTED = new Set(['completed', 'failed', 'aborted', 'cancelled', 'stopped']);
+const FAILED = new Set(['failed', 'aborted']);
+const PAGE = 500;
+
 function getDaysInMonth(year: number, month: number): number {
   return new Date(year, month + 1, 0).getDate();
 }
@@ -18,32 +38,82 @@ function getFirstDayOfMonth(year: number, month: number): number {
   return new Date(year, month, 1).getDay();
 }
 
-const MONTH_NAMES = [
-  'January', 'February', 'March', 'April', 'May', 'June',
-  'July', 'August', 'September', 'October', 'November', 'December'
-];
+function dayKey(date: Date): string {
+  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
+}
 
-const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+// Every print that ended in the month, as the archive list's visibility has
+// it, page by page.
+async function fetchMonthRuns(year: number, month: number, printerId?: number | null): Promise<PrintLogEntry[]> {
+  const dateFrom = dayKey(new Date(year, month, 1));
+  const dateTo = dayKey(new Date(year, month + 1, 0));
+  const runs: PrintLogEntry[] = [];
+  for (let offset = 0; ; offset += PAGE) {
+    const page = await api.getArchivePrints({ dateFrom, dateTo, printerId: printerId || undefined, limit: PAGE, offset });
+    runs.push(...page.items);
+    if (page.items.length < PAGE || runs.length >= page.total) return runs;
+  }
+}
 
-export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }: CalendarViewProps) {
+/**
+ * A month of prints (archive redesign P4): each print of an archive on the
+ * day it ended, so a file printed three times shows three times. A click
+ * opens the archive's page at that print.
+ */
+export function CalendarView({ archives, printerId, onPrintClick, highlightedArchiveId }: CalendarViewProps) {
+  const { t, i18n } = useTranslation();
   const today = new Date();
   const [currentMonth, setCurrentMonth] = useState(today.getMonth());
   const [currentYear, setCurrentYear] = useState(today.getFullYear());
   const [selectedDate, setSelectedDate] = useState<string | null>(null);
-  const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
+  const [selectedKey, setSelectedKey] = useState<string | null>(null);
+
+  const { data: runs, isLoading } = useQuery({
+    queryKey: ['calendar-runs', currentYear, currentMonth, printerId ?? null],
+    queryFn: () => fetchMonthRuns(currentYear, currentMonth, printerId),
+  });
 
-  // Group archives by date (using local timezone from UTC timestamps)
-  const archivesByDate = useMemo(() => {
-    const map = new Map<string, Archive[]>();
-    archives.forEach(archive => {
-      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
-      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
-      const existing = map.get(key) || [];
-      existing.push(archive);
-      map.set(key, existing);
-    });
+  const prints = useMemo(() => {
+    const byId = new Map(archives.map((a) => [a.id, a]));
+    const list: CalendarPrint[] = [];
+    for (const run of runs ?? []) {
+      const archive = run.archive_id != null ? byId.get(run.archive_id) : undefined;
+      const date = parseUTCDate(run.completed_at || run.created_at);
+      if (!archive || !date) continue;
+      list.push({
+        key: `run-${run.id}`,
+        archive,
+        runId: run.id,
+        status: run.status,
+        date,
+        colors: run.filament_color || archive.filament_color,
+      });
+    }
+    for (const archive of archives) {
+      if (archive.run_count > 0 || !PRINTED.has(archive.status)) continue;
+      const date = parseUTCDate(archive.completed_at || archive.created_at);
+      if (!date) continue;
+      list.push({ key: `archive-${archive.id}`, archive, runId: null, status: archive.status, date, colors: archive.filament_color });
+    }
+    return list
+      .filter((p) => p.date.getMonth() === currentMonth && p.date.getFullYear() === currentYear)
+      .sort((a, b) => a.date.getTime() - b.date.getTime());
+  }, [archives, runs, currentMonth, currentYear]);
+
+  const printsByDate = useMemo(() => {
+    const map = new Map<string, CalendarPrint[]>();
+    for (const print of prints) {
+      const key = dayKey(print.date);
+      map.set(key, [...(map.get(key) ?? []), print]);
+    }
     return map;
-  }, [archives]);
+  }, [prints]);
+
+  const monthTitle = new Date(currentYear, currentMonth, 1).toLocaleDateString(i18n.language, { month: 'long', year: 'numeric' });
+  // 2023-01-01 was a Sunday: the calendar's weeks start on Sundays.
+  const dayNames = Array.from({ length: 7 }, (_, i) =>
+    new Date(2023, 0, 1 + i).toLocaleDateString(i18n.language, { weekday: 'short' })
+  );
 
   const daysInMonth = getDaysInMonth(currentYear, currentMonth);
   const firstDay = getFirstDayOfMonth(currentYear, currentMonth);
@@ -80,12 +150,11 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
     calendarDays.push(day);
   }
 
-  const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
+  const selectedPrints = selectedDate ? printsByDate.get(selectedDate) || [] : [];
 
-  // Clear selected archive when date changes
   const handleDateSelect = (dateKey: string | null) => {
     if (dateKey !== selectedDate) {
-      setSelectedArchiveId(null);
+      setSelectedKey(null);
     }
     setSelectedDate(dateKey);
   };
@@ -99,23 +168,24 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
           <button
             onClick={prevMonth}
             className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            aria-label={t('archives.calendar.previousMonth')}
           >
             <ChevronLeft className="w-5 h-5 text-bambu-gray" />
           </button>
           <div className="flex items-center gap-3">
-            <h2 className="text-lg font-semibold text-white">
-              {MONTH_NAMES[currentMonth]} {currentYear}
-            </h2>
+            <h2 className="text-lg font-semibold text-white">{monthTitle}</h2>
             <button
               onClick={goToToday}
               className="px-2 py-1 text-xs bg-bambu-dark-tertiary hover:bg-bambu-green/20 text-bambu-gray hover:text-white rounded transition-colors"
             >
-              Today
+              {t('archives.calendar.today')}
             </button>
+            {isLoading && <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />}
           </div>
           <button
             onClick={nextMonth}
             className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            aria-label={t('archives.calendar.nextMonth')}
           >
             <ChevronRight className="w-5 h-5 text-bambu-gray" />
           </button>
@@ -123,7 +193,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
 
         {/* Day headers */}
         <div className="grid grid-cols-7 gap-1 mb-1">
-          {DAY_NAMES.map(day => (
+          {dayNames.map((day) => (
             <div key={day} className="text-center text-xs text-bambu-gray py-2">
               {day}
             </div>
@@ -138,12 +208,12 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
             }
 
             const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
-            const dayArchives = archivesByDate.get(dateKey) || [];
-            const hasArchives = dayArchives.length > 0;
+            const dayPrints = printsByDate.get(dateKey) || [];
+            const hasPrints = dayPrints.length > 0;
             const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear();
             const isSelected = dateKey === selectedDate;
-            const successCount = dayArchives.filter(a => a.status === 'completed').length;
-            const failedCount = dayArchives.filter(a => a.status === 'failed').length;
+            const successCount = dayPrints.filter((p) => p.status === 'completed').length;
+            const failedCount = dayPrints.filter((p) => FAILED.has(p.status)).length;
 
             return (
               <button
@@ -154,7 +224,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
                     ? 'bg-bambu-green text-white'
                     : isToday
                     ? 'bg-bambu-green/20 text-white ring-2 ring-bambu-green'
-                    : hasArchives
+                    : hasPrints
                     ? 'bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white'
                     : 'hover:bg-bambu-dark-tertiary/50 text-bambu-gray'
                 }`}
@@ -162,7 +232,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
                 <span className={`text-sm font-medium ${isToday && !isSelected ? 'text-bambu-green' : ''}`}>
                   {day}
                 </span>
-                {hasArchives && (
+                {hasPrints && (
                   <div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1">
                     <div className={`w-2 h-2 rounded-full ${
                       failedCount > 0 && successCount === 0
@@ -171,7 +241,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
                         ? 'bg-yellow-400'
                         : 'bg-green-400'
                     }`} />
-                    <span className="text-xs font-medium">{dayArchives.length}</span>
+                    <span className="text-xs font-medium">{dayPrints.length}</span>
                   </div>
                 )}
               </button>
@@ -183,31 +253,20 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
         <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
           <div className="grid grid-cols-3 gap-4 text-center">
             <div>
-              <div className="text-2xl font-bold text-white">
-                {archives.filter(a => {
-                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
-                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
-                }).length}
-              </div>
-              <div className="text-xs text-bambu-gray">Prints this month</div>
+              <div className="text-2xl font-bold text-white">{prints.length}</div>
+              <div className="text-xs text-bambu-gray">{t('archives.calendar.printsThisMonth')}</div>
             </div>
             <div>
               <div className="text-2xl font-bold text-green-700 dark:text-green-400">
-                {archives.filter(a => {
-                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
-                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';
-                }).length}
+                {prints.filter((p) => p.status === 'completed').length}
               </div>
-              <div className="text-xs text-bambu-gray">Successful</div>
+              <div className="text-xs text-bambu-gray">{t('archives.calendar.successful')}</div>
             </div>
             <div>
               <div className="text-2xl font-bold text-red-700 dark:text-red-400">
-                {archives.filter(a => {
-                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
-                  return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';
-                }).length}
+                {prints.filter((p) => FAILED.has(p.status)).length}
               </div>
-              <div className="text-xs text-bambu-gray">Failed</div>
+              <div className="text-xs text-bambu-gray">{t('archives.calendar.failed')}</div>
             </div>
           </div>
         </div>
@@ -218,23 +277,24 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
         {selectedDate ? (
           <>
             <h3 className="text-sm font-medium text-bambu-gray mb-3">
-              {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', {
+              {new Date(selectedDate + 'T12:00:00').toLocaleDateString(i18n.language, {
                 weekday: 'long',
                 month: 'long',
                 day: 'numeric',
                 year: 'numeric'
               })}
             </h3>
-            {selectedArchives.length > 0 ? (
+            {selectedPrints.length > 0 ? (
               <div className="calendar-scroll space-y-2 max-h-96 overflow-y-auto">
-                {selectedArchives.map(archive => {
-                  const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
+                {selectedPrints.map((print) => {
+                  const { archive } = print;
+                  const isHighlighted = print.key === selectedKey || archive.id === highlightedArchiveId;
                   return (
                   <button
-                    key={archive.id}
+                    key={print.key}
                     onClick={() => {
-                      setSelectedArchiveId(archive.id);
-                      onArchiveClick?.(archive);
+                      setSelectedKey(print.key);
+                      onPrintClick?.(archive.id, print.runId);
                     }}
                     className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${
                       !isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''
@@ -257,12 +317,12 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
                         {archive.print_name || archive.filename}
                       </p>
                       <div className="flex items-center gap-2 text-xs">
-                        <span className={archive.status === 'failed' ? 'text-red-700 dark:text-red-400' : 'text-green-700 dark:text-green-400'}>
-                          {archive.status === 'failed' ? 'Failed' : 'Completed'}
+                        <span className={FAILED.has(print.status) ? 'text-red-700 dark:text-red-400' : print.status === 'completed' ? 'text-green-700 dark:text-green-400' : 'text-bambu-gray'}>
+                          {t(`archives.runLog.status.${print.status}`, { defaultValue: print.status })}
                         </span>
-                        {archive.filament_color && (
+                        {print.colors && (
                           <div className="flex gap-0.5">
-                            {archive.filament_color.split(',').map((color, i) => (
+                            {print.colors.split(',').map((color, i) => (
                               <div
                                 key={i}
                                 className="w-3 h-3 rounded-full border border-black/20"
@@ -278,12 +338,12 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
                 })}
               </div>
             ) : (
-              <p className="text-sm text-bambu-gray">No prints on this day</p>
+              <p className="text-sm text-bambu-gray">{t('archives.calendar.noPrints')}</p>
             )}
           </>
         ) : (
           <div className="text-center py-8">
-            <p className="text-sm text-bambu-gray">Select a day to see prints</p>
+            <p className="text-sm text-bambu-gray">{t('archives.calendar.selectDay')}</p>
           </div>
         )}
       </div>

+ 2 - 0
frontend/src/components/ConfirmOutcomeDialog.tsx

@@ -50,6 +50,8 @@ export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutco
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archive', archiveId] });
       queryClient.invalidateQueries({ queryKey: ['archive', targetId] });
+      // The archive page lists each print with its verdict.
+      queryClient.invalidateQueries({ queryKey: ['archive-runs', targetId] });
       showToast(
         variables.user_verdict === 'good'
           ? t('confirmOutcome.savedGood')

+ 6 - 6
frontend/src/components/EditArchiveModal.tsx

@@ -202,13 +202,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     onSuccess: () => {
       // This form can change the archive's project, so the project detail
       // views need refreshing too — not just the overview cards (#2731).
+      // That includes ['archive-runs']: some of what this form writes is
+      // mirrored onto the archive's most recent run — status and failure
+      // reason since #1444, filament grams since #1820 — and the Print Log
+      // this modal renders at its top reads the runs through their own
+      // query. Without it the cached pre-edit row stays, so the correction
+      // looks like it did not take.
       invalidateArchiveAndProjectViews(queryClient);
-      // Some of what this form writes is mirrored onto the archive's most
-      // recent run — status and failure reason since #1444, filament grams
-      // since #1820 — and the Print Log this modal renders at its top reads
-      // the runs through their own query. Without this it serves the cached
-      // pre-edit row, so the correction looks like it did not take.
-      queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
       onClose();
     },
   });

+ 1 - 1
frontend/src/components/MergeDuplicatesModal.tsx

@@ -53,7 +53,7 @@ export function MergeDuplicatesModal({ onClose }: MergeDuplicatesModalProps) {
       const merged = result.merged.length;
       const left = result.skipped.length;
       // Runs moved between archives: every list of archives and runs.
-      for (const key of ['archives', 'archive', 'archiveStats', 'archives-stats', 'print-log', 'archive-runs', 'archive-merge-count']) {
+      for (const key of ['archives', 'archive', 'archiveStats', 'archives-stats', 'print-log', 'archive-runs', 'calendar-runs', 'archive-merge-count']) {
         queryClient.invalidateQueries({ queryKey: [key] });
       }
       if (left === 0) {

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

@@ -401,6 +401,10 @@ export function useWebSocket() {
         // The printer_status websocket messages will naturally update the status
         debouncedInvalidate('archives');
         debouncedInvalidate('archiveStats');
+        // A print ended: the archive page's prints and the calendar's.
+        debouncedInvalidate('archive');
+        debouncedInvalidate('archive-runs');
+        debouncedInvalidate('calendar-runs');
         break;
 
       case 'print_confirm_request': {
@@ -435,6 +439,8 @@ export function useWebSocket() {
 
       case 'archive_updated':
         debouncedInvalidate('archives');
+        debouncedInvalidate('archive');
+        debouncedInvalidate('archive-runs');
         break;
 
       case 'pong':

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

@@ -1215,6 +1215,16 @@ export default {
       edit: 'Bearbeiten',
       delete: 'Löschen',
     },
+    calendar: {
+      today: 'Heute',
+      previousMonth: 'Vorheriger Monat',
+      nextMonth: 'Nächster Monat',
+      printsThisMonth: 'Drucke in diesem Monat',
+      successful: 'Erfolgreich',
+      failed: 'Fehlgeschlagen',
+      noPrints: 'Keine Drucke an diesem Tag',
+      selectDay: 'Wählen Sie einen Tag, um seine Drucke zu sehen',
+    },
     runLog: {
       title: 'Druckprotokoll',
       modalTitle: 'Druckprotokoll — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: 'Gestoppt',
         skipped: 'Übersprungen',
         printing: 'Druckt',
+        aborted: 'Abgebrochen',
       },
     },
     modal: {
@@ -5970,6 +5981,43 @@ export default {
     uploadFailed: 'Hochladen fehlgeschlagen',
   },
 
+  archivePage: {
+    loadFailed: 'Das Archiv konnte nicht geladen werden.',
+    retry: 'Erneut versuchen',
+    printedBeforeLog: 'Gedruckt, bevor Bambuddy jeden Druck einzeln protokolliert hat: {{status}}.',
+    notFound: 'Dieses Archiv existiert nicht oder Sie können es nicht sehen.',
+    back: 'Zurück zu den Archiven',
+    estimatedTime: 'Geschätzte Zeit',
+    estimatedFilament: 'Geschätztes Filament',
+    layers: 'Schichten',
+    prints: 'Drucke',
+    printsValue: '{{count}} ({{successful}} gut, {{failed}} fehlgeschlagen)',
+    archivedAt: 'Archiviert {{date}}',
+    reprint: 'Erneut drucken',
+    edit: 'Bearbeiten',
+    preview: '3D-Vorschau',
+    download: 'Herunterladen',
+    downloadFailed: 'Herunterladen fehlgeschlagen',
+    video: 'Video',
+    allPhotos: 'Alle Fotos ({{count}})',
+    photos: 'Fotos ({{count}})',
+    delete: 'Löschen',
+    plates: 'Platten',
+    platePrinted: 'Gedruckt: {{count}}',
+    printsTitle: 'Drucke',
+    noPrints: 'Diese Datei wurde noch nicht gedruckt.',
+    changeOutcome: 'Ergebnis ändern',
+    setOutcome: 'Ergebnis festlegen',
+    noPrinter: 'Drucker entfernt',
+    startedBy: 'von {{name}}',
+    printNote: 'Notiz zu diesem Druck',
+    notePlaceholder: 'Was war an diesem Druck besonders?',
+    noteSaved: 'Notiz gespeichert',
+    noteFailed: 'Notiz konnte nicht gespeichert werden',
+    editNote: 'Notiz bearbeiten',
+    addNote: 'Notiz hinzufügen',
+  },
+
   confirmOutcome: {
     title: 'Wie ist Ihr Druck geworden?',
     good: 'Gut',

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

@@ -1222,6 +1222,16 @@ export default {
       edit: 'Edit',
       delete: 'Delete',
     },
+    calendar: {
+      today: 'Today',
+      previousMonth: 'Previous month',
+      nextMonth: 'Next month',
+      printsThisMonth: 'Prints this month',
+      successful: 'Successful',
+      failed: 'Failed',
+      noPrints: 'No prints on this day',
+      selectDay: 'Select a day to see its prints',
+    },
     runLog: {
       title: 'Print Log',
       modalTitle: 'Print Log — {{name}}',
@@ -1242,6 +1252,7 @@ export default {
         stopped: 'Stopped',
         skipped: 'Skipped',
         printing: 'Printing',
+        aborted: 'Aborted',
       },
     },
     modal: {
@@ -6023,6 +6034,43 @@ export default {
   },
 
   // Post-print outcome confirmation dialog (#1898)
+  archivePage: {
+    loadFailed: 'The archive could not be loaded.',
+    retry: 'Try again',
+    printedBeforeLog: 'Printed before Bambuddy logged each print: {{status}}.',
+    notFound: 'This archive does not exist or you cannot see it.',
+    back: 'Back to the archives',
+    estimatedTime: 'Estimated time',
+    estimatedFilament: 'Estimated filament',
+    layers: 'Layers',
+    prints: 'Printed',
+    printsValue: '{{count}} ({{successful}} good, {{failed}} failed)',
+    archivedAt: 'Archived {{date}}',
+    reprint: 'Reprint',
+    edit: 'Edit',
+    preview: '3D preview',
+    download: 'Download',
+    downloadFailed: 'Download failed',
+    video: 'Timelapse video',
+    allPhotos: 'All photos ({{count}})',
+    photos: 'Photos ({{count}})',
+    delete: 'Delete',
+    plates: 'Plates',
+    platePrinted: 'Printed: {{count}}',
+    printsTitle: 'Print history',
+    noPrints: 'This file has not been printed yet.',
+    changeOutcome: 'Change outcome',
+    setOutcome: 'Set outcome',
+    noPrinter: 'Printer removed',
+    startedBy: 'by {{name}}',
+    printNote: 'Note on this print',
+    notePlaceholder: 'What was special about this print?',
+    noteSaved: 'Note saved',
+    noteFailed: 'Could not save the note',
+    editNote: 'Edit note',
+    addNote: 'Add note',
+  },
+
   confirmOutcome: {
     title: 'How did your print come out?',
     good: 'Good',

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

@@ -1215,6 +1215,16 @@ export default {
       edit: 'Editar',
       delete: 'Eliminar',
     },
+    calendar: {
+      today: 'Hoy',
+      previousMonth: 'Mes anterior',
+      nextMonth: 'Mes siguiente',
+      printsThisMonth: 'Impresiones este mes',
+      successful: 'Correctas',
+      failed: 'Fallidas',
+      noPrints: 'No hay impresiones este día',
+      selectDay: 'Selecciona un día para ver sus impresiones',
+    },
     runLog: {
       title: 'Registro de impresión',
       modalTitle: 'Registro de impresión — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: 'Detenida',
         skipped: 'Omitida',
         printing: 'Imprimiendo',
+        aborted: 'Abortada',
       },
     },
     modal: {
@@ -5977,6 +5988,43 @@ export default {
     uploadFailed: 'Error al subir',
   },
 
+  archivePage: {
+    loadFailed: 'No se pudo cargar el archivo.',
+    retry: 'Reintentar',
+    printedBeforeLog: 'Impreso antes de que Bambuddy registrara cada impresión: {{status}}.',
+    notFound: 'Este archivo no existe o no puedes verlo.',
+    back: 'Volver a los archivos',
+    estimatedTime: 'Tiempo estimado',
+    estimatedFilament: 'Filamento estimado',
+    layers: 'Capas',
+    prints: 'Impresiones',
+    printsValue: '{{count}} ({{successful}} buenas, {{failed}} fallidas)',
+    archivedAt: 'Archivado {{date}}',
+    reprint: 'Reimprimir',
+    edit: 'Editar',
+    preview: 'Vista previa 3D',
+    download: 'Descargar',
+    downloadFailed: 'Error al descargar',
+    video: 'Vídeo',
+    allPhotos: 'Todas las fotos ({{count}})',
+    photos: 'Fotos ({{count}})',
+    delete: 'Eliminar',
+    plates: 'Placas',
+    platePrinted: 'Impreso: {{count}}',
+    printsTitle: 'Impresiones',
+    noPrints: 'Este archivo aún no se ha impreso.',
+    changeOutcome: 'Cambiar resultado',
+    setOutcome: 'Indicar resultado',
+    noPrinter: 'Impresora eliminada',
+    startedBy: 'por {{name}}',
+    printNote: 'Nota sobre esta impresión',
+    notePlaceholder: '¿Qué tuvo de especial esta impresión?',
+    noteSaved: 'Nota guardada',
+    noteFailed: 'No se pudo guardar la nota',
+    editNote: 'Editar nota',
+    addNote: 'Añadir nota',
+  },
+
   confirmOutcome: {
     title: '¿Cómo ha salido su impresión?',
     good: 'Buena',

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

@@ -1215,6 +1215,16 @@ export default {
       edit: 'Modifier',
       delete: 'Supprimer',
     },
+    calendar: {
+      today: 'Aujourd\'hui',
+      previousMonth: 'Mois précédent',
+      nextMonth: 'Mois suivant',
+      printsThisMonth: 'Impressions ce mois-ci',
+      successful: 'Réussies',
+      failed: 'Échouées',
+      noPrints: 'Aucune impression ce jour-là',
+      selectDay: 'Sélectionnez un jour pour voir ses impressions',
+    },
     runLog: {
       title: 'Journal d\'impression',
       modalTitle: 'Journal d\'impression — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: 'Arrêté',
         skipped: 'Ignoré',
         printing: 'Impression en cours',
+        aborted: 'Interrompue',
       },
     },
     modal: {
@@ -5957,6 +5968,43 @@ export default {
     uploadFailed: 'Échec du téléversement',
   },
 
+  archivePage: {
+    loadFailed: 'L\'archive n\'a pas pu être chargée.',
+    retry: 'Réessayer',
+    printedBeforeLog: 'Imprimé avant que Bambuddy n\'enregistre chaque impression : {{status}}.',
+    notFound: 'Cette archive n\'existe pas ou vous ne pouvez pas la voir.',
+    back: 'Retour aux archives',
+    estimatedTime: 'Durée estimée',
+    estimatedFilament: 'Filament estimé',
+    layers: 'Couches',
+    prints: 'Impressions',
+    printsValue: '{{count}} ({{successful}} réussies, {{failed}} échouées)',
+    archivedAt: 'Archivé {{date}}',
+    reprint: 'Réimprimer',
+    edit: 'Modifier',
+    preview: 'Aperçu 3D',
+    download: 'Télécharger',
+    downloadFailed: 'Échec du téléchargement',
+    video: 'Vidéo',
+    allPhotos: 'Toutes les photos ({{count}})',
+    photos: 'Photos de l\'impression ({{count}})',
+    delete: 'Supprimer',
+    plates: 'Plateaux',
+    platePrinted: 'Imprimé : {{count}}',
+    printsTitle: 'Impressions',
+    noPrints: 'Ce fichier n\'a pas encore été imprimé.',
+    changeOutcome: 'Modifier le résultat',
+    setOutcome: 'Indiquer le résultat',
+    noPrinter: 'Imprimante supprimée',
+    startedBy: 'par {{name}}',
+    printNote: 'Note sur cette impression',
+    notePlaceholder: 'Qu\'avait de particulier cette impression ?',
+    noteSaved: 'Note enregistrée',
+    noteFailed: 'Impossible d\'enregistrer la note',
+    editNote: 'Modifier la note',
+    addNote: 'Ajouter une note',
+  },
+
   confirmOutcome: {
     title: 'Comment votre impression est-elle sortie ?',
     good: 'Bonne',

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

@@ -1215,6 +1215,16 @@ export default {
       edit: 'Modifica',
       delete: 'Elimina',
     },
+    calendar: {
+      today: 'Oggi',
+      previousMonth: 'Mese precedente',
+      nextMonth: 'Mese successivo',
+      printsThisMonth: 'Stampe questo mese',
+      successful: 'Riuscite',
+      failed: 'Fallite',
+      noPrints: 'Nessuna stampa in questo giorno',
+      selectDay: 'Seleziona un giorno per vederne le stampe',
+    },
     runLog: {
       title: 'Registro stampe',
       modalTitle: 'Registro stampe — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: 'Interrotta',
         skipped: 'Saltata',
         printing: 'In stampa',
+        aborted: 'Interrotta',
       },
     },
     modal: {
@@ -5956,6 +5967,43 @@ export default {
     uploadFailed: 'Caricamento fallito',
   },
 
+  archivePage: {
+    loadFailed: 'Impossibile caricare l\'archivio.',
+    retry: 'Riprova',
+    printedBeforeLog: 'Stampato prima che Bambuddy registrasse ogni stampa: {{status}}.',
+    notFound: 'Questo archivio non esiste o non puoi vederlo.',
+    back: 'Torna agli archivi',
+    estimatedTime: 'Tempo stimato',
+    estimatedFilament: 'Filamento stimato',
+    layers: 'Strati',
+    prints: 'Stampe',
+    printsValue: '{{count}} ({{successful}} riuscite, {{failed}} fallite)',
+    archivedAt: 'Archiviato {{date}}',
+    reprint: 'Ristampa',
+    edit: 'Modifica',
+    preview: 'Anteprima 3D',
+    download: 'Scarica',
+    downloadFailed: 'Download non riuscito',
+    video: 'Video',
+    allPhotos: 'Tutte le foto ({{count}})',
+    photos: 'Foto ({{count}})',
+    delete: 'Elimina',
+    plates: 'Piatti',
+    platePrinted: 'Stampato: {{count}}',
+    printsTitle: 'Stampe',
+    noPrints: 'Questo file non è ancora stato stampato.',
+    changeOutcome: 'Cambia esito',
+    setOutcome: 'Imposta esito',
+    noPrinter: 'Stampante rimossa',
+    startedBy: 'da {{name}}',
+    printNote: 'Nota su questa stampa',
+    notePlaceholder: 'Cosa ha avuto di particolare questa stampa?',
+    noteSaved: 'Nota salvata',
+    noteFailed: 'Impossibile salvare la nota',
+    editNote: 'Modifica nota',
+    addNote: 'Aggiungi nota',
+  },
+
   confirmOutcome: {
     title: 'Com\'è venuta la tua stampa?',
     good: 'Buona',

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

@@ -1214,6 +1214,16 @@ export default {
       edit: '編集',
       delete: '削除',
     },
+    calendar: {
+      today: '今日',
+      previousMonth: '前の月',
+      nextMonth: '次の月',
+      printsThisMonth: '今月の印刷',
+      successful: '成功',
+      failed: '失敗',
+      noPrints: 'この日の印刷はありません',
+      selectDay: '日を選択すると印刷が表示されます',
+    },
     runLog: {
       title: '印刷ログ',
       modalTitle: '印刷ログ — {{name}}',
@@ -1234,6 +1244,7 @@ export default {
         stopped: '停止',
         skipped: 'スキップ',
         printing: '印刷中',
+        aborted: '中断',
       },
     },
     modal: {
@@ -5970,6 +5981,43 @@ export default {
     uploadFailed: 'アップロード失敗',
   },
 
+  archivePage: {
+    loadFailed: 'アーカイブを読み込めませんでした。',
+    retry: '再試行',
+    printedBeforeLog: 'Bambuddy が印刷ごとに記録する前の印刷: {{status}}。',
+    notFound: 'このアーカイブは存在しないか、表示する権限がありません。',
+    back: 'アーカイブに戻る',
+    estimatedTime: '推定時間',
+    estimatedFilament: '推定フィラメント',
+    layers: 'レイヤー',
+    prints: '印刷',
+    printsValue: '{{count}}(良好 {{successful}}、失敗 {{failed}})',
+    archivedAt: 'アーカイブ日時 {{date}}',
+    reprint: '再印刷',
+    edit: '編集',
+    preview: '3D プレビュー',
+    download: 'ダウンロード',
+    downloadFailed: 'ダウンロードに失敗しました',
+    video: '動画',
+    allPhotos: 'すべての写真({{count}})',
+    photos: '写真({{count}})',
+    delete: '削除',
+    plates: 'プレート',
+    platePrinted: '印刷回数: {{count}}',
+    printsTitle: '印刷',
+    noPrints: 'このファイルはまだ印刷されていません。',
+    changeOutcome: '結果を変更',
+    setOutcome: '結果を設定',
+    noPrinter: 'プリンター削除済み',
+    startedBy: '{{name}} による',
+    printNote: 'この印刷のメモ',
+    notePlaceholder: 'この印刷で特別だったことは?',
+    noteSaved: 'メモを保存しました',
+    noteFailed: 'メモを保存できませんでした',
+    editNote: 'メモを編集',
+    addNote: 'メモを追加',
+  },
+
   confirmOutcome: {
     title: '印刷の仕上がりはいかがでしたか?',
     good: '良品',

+ 49 - 1
frontend/src/i18n/locales/ko.ts

@@ -1271,6 +1271,16 @@ export default {
         skipped: '건너뜀'
       }
     },
+    calendar: {
+      today: '오늘',
+      previousMonth: '이전 달',
+      nextMonth: '다음 달',
+      printsThisMonth: '이번 달 인쇄',
+      successful: '성공',
+      failed: '실패',
+      noPrints: '이 날에는 인쇄가 없습니다',
+      selectDay: '날짜를 선택하면 인쇄가 표시됩니다',
+    },
     runLog: {
       title: '인쇄 기록',
       modalTitle: '인쇄 기록 — {{name}}',
@@ -1290,7 +1300,8 @@ export default {
         cancelled: '취소됨',
         stopped: '중지됨',
         skipped: '건너뜀',
-        printing: '인쇄 중'
+        printing: '인쇄 중',
+        aborted: '중단됨',
       }
     }
   },
@@ -5723,6 +5734,43 @@ export default {
     upload: '업로드',
     uploadFailed: '업로드 실패'
   },
+  archivePage: {
+    loadFailed: '아카이브를 불러올 수 없습니다.',
+    retry: '다시 시도',
+    printedBeforeLog: 'Bambuddy가 인쇄별로 기록하기 전에 인쇄됨: {{status}}.',
+    notFound: '이 아카이브가 없거나 볼 수 있는 권한이 없습니다.',
+    back: '아카이브로 돌아가기',
+    estimatedTime: '예상 시간',
+    estimatedFilament: '예상 필라멘트',
+    layers: '레이어',
+    prints: '인쇄',
+    printsValue: '{{count}} (양호 {{successful}}, 실패 {{failed}})',
+    archivedAt: '보관됨 {{date}}',
+    reprint: '다시 인쇄',
+    edit: '편집',
+    preview: '3D 미리보기',
+    download: '다운로드',
+    downloadFailed: '다운로드 실패',
+    video: '동영상',
+    allPhotos: '모든 사진 ({{count}})',
+    photos: '사진 ({{count}})',
+    delete: '삭제',
+    plates: '플레이트',
+    platePrinted: '인쇄 횟수: {{count}}',
+    printsTitle: '인쇄',
+    noPrints: '이 파일은 아직 인쇄되지 않았습니다.',
+    changeOutcome: '결과 변경',
+    setOutcome: '결과 설정',
+    noPrinter: '프린터 삭제됨',
+    startedBy: '{{name}} 님',
+    printNote: '이 인쇄에 대한 메모',
+    notePlaceholder: '이 인쇄에서 특별했던 점은?',
+    noteSaved: '메모를 저장했습니다',
+    noteFailed: '메모를 저장할 수 없습니다',
+    editNote: '메모 편집',
+    addNote: '메모 추가',
+  },
+
   confirmOutcome: {
     title: '인쇄 결과가 어땠나요?',
     good: '양품',

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

@@ -1220,6 +1220,16 @@ export default {
       edit: 'Bewerken',
       delete: 'Verwijderen',
     },
+    calendar: {
+      today: 'Vandaag',
+      previousMonth: 'Vorige maand',
+      nextMonth: 'Volgende maand',
+      printsThisMonth: 'Prints deze maand',
+      successful: 'Geslaagd',
+      failed: 'Mislukt',
+      noPrints: 'Geen prints op deze dag',
+      selectDay: 'Kies een dag om de prints te zien',
+    },
     runLog: {
       title: 'Afdruklogboek',
       modalTitle: 'Afdruklogboek — {{name}}',
@@ -1240,6 +1250,7 @@ export default {
         stopped: 'Gestopt',
         skipped: 'Overgeslagen',
         printing: 'Bezig met afdrukken',
+        aborted: 'Afgebroken',
       },
     },
     modal: {
@@ -6020,6 +6031,43 @@ export default {
     uploadFailed: 'Upload mislukt',
   },
 
+  archivePage: {
+    loadFailed: 'Het archief kon niet worden geladen.',
+    retry: 'Opnieuw proberen',
+    printedBeforeLog: 'Geprint voordat Bambuddy elke print vastlegde: {{status}}.',
+    notFound: 'Dit archief bestaat niet of je kunt het niet zien.',
+    back: 'Terug naar de archieven',
+    estimatedTime: 'Geschatte tijd',
+    estimatedFilament: 'Geschat filament',
+    layers: 'Lagen',
+    prints: 'Geprint',
+    printsValue: '{{count}} ({{successful}} goed, {{failed}} mislukt)',
+    archivedAt: 'Gearchiveerd {{date}}',
+    reprint: 'Opnieuw printen',
+    edit: 'Bewerken',
+    preview: '3D-voorbeeld',
+    download: 'Downloaden',
+    downloadFailed: 'Downloaden mislukt',
+    video: 'Video',
+    allPhotos: 'Alle foto\'s ({{count}})',
+    photos: 'Foto\'s ({{count}})',
+    delete: 'Verwijderen',
+    plates: 'Platen',
+    platePrinted: 'Geprint: {{count}}',
+    printsTitle: 'Printgeschiedenis',
+    noPrints: 'Dit bestand is nog niet geprint.',
+    changeOutcome: 'Resultaat wijzigen',
+    setOutcome: 'Resultaat instellen',
+    noPrinter: 'Printer verwijderd',
+    startedBy: 'door {{name}}',
+    printNote: 'Notitie bij deze print',
+    notePlaceholder: 'Wat was er bijzonder aan deze print?',
+    noteSaved: 'Notitie opgeslagen',
+    noteFailed: 'Notitie kon niet worden opgeslagen',
+    editNote: 'Notitie bewerken',
+    addNote: 'Notitie toevoegen',
+  },
+
   confirmOutcome: {
     title: 'Hoe is je afdruk gelukt?',
     good: 'Goed',

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

@@ -1215,6 +1215,16 @@ export default {
       edit: 'Editar',
       delete: 'Excluir',
     },
+    calendar: {
+      today: 'Hoje',
+      previousMonth: 'Mês anterior',
+      nextMonth: 'Próximo mês',
+      printsThisMonth: 'Impressões neste mês',
+      successful: 'Bem-sucedidas',
+      failed: 'Com falha',
+      noPrints: 'Nenhuma impressão neste dia',
+      selectDay: 'Selecione um dia para ver as impressões',
+    },
     runLog: {
       title: 'Histórico de impressões',
       modalTitle: 'Histórico de impressões — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: 'Interrompida',
         skipped: 'Ignorada',
         printing: 'Imprimindo',
+        aborted: 'Abortada',
       },
     },
     modal: {
@@ -5956,6 +5967,43 @@ export default {
     uploadFailed: 'Falha no envio',
   },
 
+  archivePage: {
+    loadFailed: 'Não foi possível carregar o arquivo.',
+    retry: 'Tentar novamente',
+    printedBeforeLog: 'Impresso antes de o Bambuddy registrar cada impressão: {{status}}.',
+    notFound: 'Este arquivo não existe ou você não pode vê-lo.',
+    back: 'Voltar aos arquivos',
+    estimatedTime: 'Tempo estimado',
+    estimatedFilament: 'Filamento estimado',
+    layers: 'Camadas',
+    prints: 'Impressões',
+    printsValue: '{{count}} ({{successful}} boas, {{failed}} com falha)',
+    archivedAt: 'Arquivado {{date}}',
+    reprint: 'Reimprimir',
+    edit: 'Editar',
+    preview: 'Pré-visualização 3D',
+    download: 'Baixar',
+    downloadFailed: 'Falha ao baixar',
+    video: 'Vídeo',
+    allPhotos: 'Todas as fotos ({{count}})',
+    photos: 'Fotos ({{count}})',
+    delete: 'Excluir',
+    plates: 'Placas',
+    platePrinted: 'Impresso: {{count}}',
+    printsTitle: 'Impressões',
+    noPrints: 'Este arquivo ainda não foi impresso.',
+    changeOutcome: 'Alterar resultado',
+    setOutcome: 'Definir resultado',
+    noPrinter: 'Impressora removida',
+    startedBy: 'por {{name}}',
+    printNote: 'Nota sobre esta impressão',
+    notePlaceholder: 'O que esta impressão teve de especial?',
+    noteSaved: 'Nota salva',
+    noteFailed: 'Não foi possível salvar a nota',
+    editNote: 'Editar nota',
+    addNote: 'Adicionar nota',
+  },
+
   confirmOutcome: {
     title: 'Como ficou sua impressão?',
     good: 'Boa',

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

@@ -1170,6 +1170,16 @@ export default {
       edit: "Изменить",
       delete: "Удалить",
     },
+    calendar: {
+      today: 'Сегодня',
+      previousMonth: 'Предыдущий месяц',
+      nextMonth: 'Следующий месяц',
+      printsThisMonth: 'Печатей в этом месяце',
+      successful: 'Успешно',
+      failed: 'Неудачно',
+      noPrints: 'В этот день печатей нет',
+      selectDay: 'Выберите день, чтобы увидеть печати',
+    },
     runLog: {
       title: "Журнал печати",
       modalTitle: "Журнал печати — {{name}}",
@@ -1190,6 +1200,7 @@ export default {
         stopped: "Остановлено",
         skipped: "Пропущено",
         printing: "Печать",
+        aborted: 'Прервано',
       },
     },
     modal: {
@@ -5711,6 +5722,43 @@ export default {
     upload: "Загрузить",
     uploadFailed: "Не удалось загрузить",
   },
+  archivePage: {
+    loadFailed: 'Не удалось загрузить архив.',
+    retry: 'Повторить',
+    printedBeforeLog: 'Напечатано до того, как Bambuddy стал записывать каждую печать: {{status}}.',
+    notFound: 'Этот архив не существует или у вас нет доступа к нему.',
+    back: 'Назад к архивам',
+    estimatedTime: 'Расчётное время',
+    estimatedFilament: 'Расчётный филамент',
+    layers: 'Слои',
+    prints: 'Печати',
+    printsValue: '{{count}} (успешно {{successful}}, неудачно {{failed}})',
+    archivedAt: 'Архивировано {{date}}',
+    reprint: 'Перепечатать',
+    edit: 'Изменить',
+    preview: '3D-просмотр',
+    download: 'Скачать',
+    downloadFailed: 'Не удалось скачать',
+    video: 'Видео',
+    allPhotos: 'Все фото ({{count}})',
+    photos: 'Фото ({{count}})',
+    delete: 'Удалить',
+    plates: 'Пластины',
+    platePrinted: 'Напечатано: {{count}}',
+    printsTitle: 'Печати',
+    noPrints: 'Этот файл ещё не печатался.',
+    changeOutcome: 'Изменить результат',
+    setOutcome: 'Указать результат',
+    noPrinter: 'Принтер удалён',
+    startedBy: '{{name}}',
+    printNote: 'Заметка к этой печати',
+    notePlaceholder: 'Чем была особенной эта печать?',
+    noteSaved: 'Заметка сохранена',
+    noteFailed: 'Не удалось сохранить заметку',
+    editNote: 'Изменить заметку',
+    addNote: 'Добавить заметку',
+  },
+
   confirmOutcome: {
     title: "Как получилась печать?",
     good: "Хорошо",

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

@@ -1220,6 +1220,16 @@ export default {
       edit: 'Redigera',
       delete: 'Ta bort',
     },
+    calendar: {
+      today: 'Idag',
+      previousMonth: 'Föregående månad',
+      nextMonth: 'Nästa månad',
+      printsThisMonth: 'Utskrifter denna månad',
+      successful: 'Lyckade',
+      failed: 'Misslyckade',
+      noPrints: 'Inga utskrifter denna dag',
+      selectDay: 'Välj en dag för att se dess utskrifter',
+    },
     runLog: {
       title: 'Utskriftslogg',
       modalTitle: 'Utskriftslogg — {{name}}',
@@ -1240,6 +1250,7 @@ export default {
         stopped: 'Stoppad',
         skipped: 'Hoppad över',
         printing: 'Skriver ut',
+        aborted: 'Avbruten',
       },
     },
     modal: {
@@ -6019,6 +6030,43 @@ errors: {
   },
 
   // Post-print outcome confirmation dialog (#1898)
+  archivePage: {
+    loadFailed: 'Arkivet kunde inte läsas in.',
+    retry: 'Försök igen',
+    printedBeforeLog: 'Utskriven innan Bambuddy loggade varje utskrift: {{status}}.',
+    notFound: 'Det här arkivet finns inte eller så kan du inte se det.',
+    back: 'Tillbaka till arkiven',
+    estimatedTime: 'Beräknad tid',
+    estimatedFilament: 'Beräknat filament',
+    layers: 'Lager',
+    prints: 'Utskrifter',
+    printsValue: '{{count}} ({{successful}} bra, {{failed}} misslyckade)',
+    archivedAt: 'Arkiverad {{date}}',
+    reprint: 'Skriv ut igen',
+    edit: 'Redigera',
+    preview: '3D-förhandsvisning',
+    download: 'Ladda ner',
+    downloadFailed: 'Nedladdningen misslyckades',
+    video: 'Video',
+    allPhotos: 'Alla foton ({{count}})',
+    photos: 'Foton ({{count}})',
+    delete: 'Ta bort',
+    plates: 'Plattor',
+    platePrinted: 'Utskriven: {{count}}',
+    printsTitle: 'Utskrifter',
+    noPrints: 'Den här filen har inte skrivits ut än.',
+    changeOutcome: 'Ändra resultat',
+    setOutcome: 'Ange resultat',
+    noPrinter: 'Skrivare borttagen',
+    startedBy: 'av {{name}}',
+    printNote: 'Anteckning om den här utskriften',
+    notePlaceholder: 'Vad var speciellt med den här utskriften?',
+    noteSaved: 'Anteckningen sparad',
+    noteFailed: 'Anteckningen kunde inte sparas',
+    editNote: 'Redigera anteckning',
+    addNote: 'Lägg till anteckning',
+  },
+
   confirmOutcome: {
     title: 'Hur blev din utskrift?',
     good: 'Godkänd',

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

@@ -1215,6 +1215,16 @@ export default {
       edit: 'Düzenle',
       delete: 'Sil',
     },
+    calendar: {
+      today: 'Bugün',
+      previousMonth: 'Önceki ay',
+      nextMonth: 'Sonraki ay',
+      printsThisMonth: 'Bu ayki baskılar',
+      successful: 'Başarılı',
+      failed: 'Başarısız',
+      noPrints: 'Bu günde baskı yok',
+      selectDay: 'Baskılarını görmek için bir gün seçin',
+    },
     runLog: {
       title: 'Baskı Günlüğü',
       modalTitle: 'Baskı Günlüğü — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: 'Durduruldu',
         skipped: 'Atlandı',
         printing: 'Yazdırılıyor',
+        aborted: 'İptal edildi',
       },
     },
     modal: {
@@ -5944,6 +5955,43 @@ export default {
     uploadFailed: 'Yükleme başarısız',
   },
 
+  archivePage: {
+    loadFailed: 'Arşiv yüklenemedi.',
+    retry: 'Tekrar dene',
+    printedBeforeLog: 'Bambuddy her baskıyı kaydetmeden önce basıldı: {{status}}.',
+    notFound: 'Bu arşiv yok ya da görme izniniz yok.',
+    back: 'Arşivlere dön',
+    estimatedTime: 'Tahmini süre',
+    estimatedFilament: 'Tahmini filament',
+    layers: 'Katmanlar',
+    prints: 'Baskılar',
+    printsValue: '{{count}} ({{successful}} iyi, {{failed}} başarısız)',
+    archivedAt: 'Arşivlendi {{date}}',
+    reprint: 'Yeniden yazdır',
+    edit: 'Düzenle',
+    preview: '3D önizleme',
+    download: 'İndir',
+    downloadFailed: 'İndirme başarısız',
+    video: 'Video',
+    allPhotos: 'Tüm fotoğraflar ({{count}})',
+    photos: 'Fotoğraflar ({{count}})',
+    delete: 'Sil',
+    plates: 'Plakalar',
+    platePrinted: 'Basıldı: {{count}}',
+    printsTitle: 'Baskılar',
+    noPrints: 'Bu dosya henüz basılmadı.',
+    changeOutcome: 'Sonucu değiştir',
+    setOutcome: 'Sonucu belirle',
+    noPrinter: 'Yazıcı kaldırıldı',
+    startedBy: '{{name}} tarafından',
+    printNote: 'Bu baskıya not',
+    notePlaceholder: 'Bu baskıyı özel kılan neydi?',
+    noteSaved: 'Not kaydedildi',
+    noteFailed: 'Not kaydedilemedi',
+    editNote: 'Notu düzenle',
+    addNote: 'Not ekle',
+  },
+
   confirmOutcome: {
     title: 'Baskınız nasıl çıktı?',
     good: 'İyi',

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

@@ -1219,6 +1219,16 @@ export default {
       edit: "Редагувати",
       delete: "Видалити",
     },
+    calendar: {
+      today: 'Сьогодні',
+      previousMonth: 'Попередній місяць',
+      nextMonth: 'Наступний місяць',
+      printsThisMonth: 'Друків цього місяця',
+      successful: 'Успішно',
+      failed: 'Невдало',
+      noPrints: 'Цього дня друків немає',
+      selectDay: 'Виберіть день, щоб побачити друки',
+    },
     runLog: {
       title: "Журнал друку",
       modalTitle: "Журнал друку — {{name}}",
@@ -1239,6 +1249,7 @@ export default {
         stopped: "Зупинено",
         skipped: "Пропущено",
         printing: "Друк",
+        aborted: 'Перервано',
       },
     },
     modal: {
@@ -6010,6 +6021,43 @@ export default {
     uploadFailed: "Не вдалося вивантажити",
   },
 
+  archivePage: {
+    loadFailed: 'Не вдалося завантажити архів.',
+    retry: 'Повторити',
+    printedBeforeLog: 'Надруковано до того, як Bambuddy почав записувати кожен друк: {{status}}.',
+    notFound: 'Цей архів не існує або у вас немає доступу до нього.',
+    back: 'Назад до архівів',
+    estimatedTime: 'Розрахунковий час',
+    estimatedFilament: 'Розрахунковий філамент',
+    layers: 'Шари',
+    prints: 'Друки',
+    printsValue: '{{count}} (успішно {{successful}}, невдало {{failed}})',
+    archivedAt: 'Архівовано {{date}}',
+    reprint: 'Передрукувати',
+    edit: 'Редагувати',
+    preview: '3D-перегляд',
+    download: 'Завантажити',
+    downloadFailed: 'Не вдалося завантажити',
+    video: 'Відео',
+    allPhotos: 'Усі фото ({{count}})',
+    photos: 'Фото ({{count}})',
+    delete: 'Видалити',
+    plates: 'Пластини',
+    platePrinted: 'Надруковано: {{count}}',
+    printsTitle: 'Друки',
+    noPrints: 'Цей файл ще не друкувався.',
+    changeOutcome: 'Змінити результат',
+    setOutcome: 'Вказати результат',
+    noPrinter: 'Принтер видалено',
+    startedBy: '{{name}}',
+    printNote: 'Нотатка до цього друку',
+    notePlaceholder: 'Чим був особливим цей друк?',
+    noteSaved: 'Нотатку збережено',
+    noteFailed: 'Не вдалося зберегти нотатку',
+    editNote: 'Редагувати нотатку',
+    addNote: 'Додати нотатку',
+  },
+
   confirmOutcome: {
     title: "Як вийшов ваш друк?",
     good: "Добре",

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

@@ -1215,6 +1215,16 @@ export default {
       edit: '编辑',
       delete: '删除',
     },
+    calendar: {
+      today: '今天',
+      previousMonth: '上个月',
+      nextMonth: '下个月',
+      printsThisMonth: '本月打印',
+      successful: '成功',
+      failed: '失败',
+      noPrints: '这一天没有打印',
+      selectDay: '选择一天以查看其打印',
+    },
     runLog: {
       title: '打印记录',
       modalTitle: '打印记录 — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: '已停止',
         skipped: '已跳过',
         printing: '打印中',
+        aborted: '已中止',
       },
     },
     modal: {
@@ -5956,6 +5967,43 @@ export default {
     uploadFailed: '上传失败',
   },
 
+  archivePage: {
+    loadFailed: '无法加载此归档。',
+    retry: '重试',
+    printedBeforeLog: '在 Bambuddy 逐次记录打印之前打印:{{status}}。',
+    notFound: '此归档不存在或您无权查看。',
+    back: '返回归档',
+    estimatedTime: '预计时间',
+    estimatedFilament: '预计耗材',
+    layers: '层数',
+    prints: '打印',
+    printsValue: '{{count}}(良好 {{successful}},失败 {{failed}})',
+    archivedAt: '归档于 {{date}}',
+    reprint: '重新打印',
+    edit: '编辑',
+    preview: '3D 预览',
+    download: '下载',
+    downloadFailed: '下载失败',
+    video: '视频',
+    allPhotos: '全部照片({{count}})',
+    photos: '照片({{count}})',
+    delete: '删除',
+    plates: '打印板',
+    platePrinted: '已打印:{{count}}',
+    printsTitle: '打印',
+    noPrints: '此文件尚未打印。',
+    changeOutcome: '更改结果',
+    setOutcome: '设置结果',
+    noPrinter: '打印机已删除',
+    startedBy: '由 {{name}}',
+    printNote: '此次打印的备注',
+    notePlaceholder: '这次打印有什么特别之处?',
+    noteSaved: '备注已保存',
+    noteFailed: '无法保存备注',
+    editNote: '编辑备注',
+    addNote: '添加备注',
+  },
+
   confirmOutcome: {
     title: '这次打印的成品如何?',
     good: '良品',

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

@@ -1215,6 +1215,16 @@ export default {
       edit: '編輯',
       delete: '刪除',
     },
+    calendar: {
+      today: '今天',
+      previousMonth: '上個月',
+      nextMonth: '下個月',
+      printsThisMonth: '本月列印',
+      successful: '成功',
+      failed: '失敗',
+      noPrints: '這一天沒有列印',
+      selectDay: '選擇一天以檢視其列印',
+    },
     runLog: {
       title: '列印記錄',
       modalTitle: '列印記錄 — {{name}}',
@@ -1235,6 +1245,7 @@ export default {
         stopped: '已停止',
         skipped: '已略過',
         printing: '列印中',
+        aborted: '已中止',
       },
     },
     modal: {
@@ -5956,6 +5967,43 @@ export default {
     uploadFailed: '上傳失敗',
   },
 
+  archivePage: {
+    loadFailed: '無法載入此封存。',
+    retry: '重試',
+    printedBeforeLog: '在 Bambuddy 逐次記錄列印之前列印:{{status}}。',
+    notFound: '此封存不存在或您無權檢視。',
+    back: '返回封存',
+    estimatedTime: '預估時間',
+    estimatedFilament: '預估線材',
+    layers: '層數',
+    prints: '列印',
+    printsValue: '{{count}}(良好 {{successful}},失敗 {{failed}})',
+    archivedAt: '封存於 {{date}}',
+    reprint: '重新列印',
+    edit: '編輯',
+    preview: '3D 預覽',
+    download: '下載',
+    downloadFailed: '下載失敗',
+    video: '影片',
+    allPhotos: '全部照片({{count}})',
+    photos: '照片({{count}})',
+    delete: '刪除',
+    plates: '列印板',
+    platePrinted: '已列印:{{count}}',
+    printsTitle: '列印',
+    noPrints: '此檔案尚未列印。',
+    changeOutcome: '變更結果',
+    setOutcome: '設定結果',
+    noPrinter: '印表機已刪除',
+    startedBy: '由 {{name}}',
+    printNote: '此次列印的備註',
+    notePlaceholder: '這次列印有什麼特別之處?',
+    noteSaved: '備註已儲存',
+    noteFailed: '無法儲存備註',
+    editNote: '編輯備註',
+    addNote: '新增備註',
+  },
+
   confirmOutcome: {
     title: '這次列印的成品如何?',
     good: '良品',

+ 704 - 0
frontend/src/pages/ArchiveDetailPage.tsx

@@ -0,0 +1,704 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  ArrowLeft,
+  Box,
+  Camera,
+  ChevronRight,
+  Clock,
+  Coins,
+  Download,
+  Film,
+  Layers,
+  Loader2,
+  Package,
+  Pencil,
+  Printer,
+  StickyNote,
+  ThumbsDown,
+  ThumbsUp,
+  Trash2,
+  Zap,
+} from 'lucide-react';
+import { api, ApiError } from '../api/client';
+import type { PrintLogEntry } from '../api/client';
+import { Button } from '../components/Button';
+import { Card, CardContent } from '../components/Card';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
+import { EditArchiveModal } from '../components/EditArchiveModal';
+import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
+import { PrintModal } from '../components/PrintModal';
+import { TimelapseViewer } from '../components/TimelapseViewer';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { useUiSettings } from '../hooks/useUiSettings';
+import { getCurrencySymbol } from '../utils/currency';
+import { formatDateTime, formatDuration, type TimeFormat } from '../utils/date';
+import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
+import { verdictSourceKey } from '../utils/verdictSource';
+
+const RUN_STATUS_CLASSES: Record<string, string> = {
+  completed: 'bg-bambu-green/20 text-bambu-green',
+  failed: 'bg-red-500/20 text-red-700 dark:text-red-400',
+  aborted: 'bg-red-500/20 text-red-700 dark:text-red-400',
+  printing: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
+};
+
+const LEGACY_PRINTED = new Set(['completed', 'failed', 'aborted', 'cancelled', 'stopped']);
+
+type Media =
+  | { kind: 'video'; src: string; title: string }
+  | { kind: 'photos'; photos: string[]; title: string };
+
+/**
+ * One archive: the file and every print of it (archive redesign P4). The
+ * header holds what belongs to the file (its estimate, plates, tags, note);
+ * each print below holds its own outcome, verdict, figures, note and media.
+ * A caller limited to some printers sees only their prints of it.
+ */
+export function ArchiveDetailPage() {
+  const { t } = useTranslation();
+  const { id } = useParams<{ id: string }>();
+  const archiveId = Number(id);
+  const [searchParams] = useSearchParams();
+  const highlightRun = Number(searchParams.get('run')) || null;
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission, canModify } = useAuth();
+  const { data: settings } = useUiSettings();
+  const timeFormat: TimeFormat = settings?.time_format || 'system';
+  const currency = getCurrencySymbol(settings?.currency || 'USD');
+
+  const [showEdit, setShowEdit] = useState(false);
+  const [showReprint, setShowReprint] = useState(false);
+  const [showDelete, setShowDelete] = useState(false);
+  const [deletePurgeStats, setDeletePurgeStats] = useState(false);
+  const [confirmRun, setConfirmRun] = useState<number | null>(null);
+  const [media, setMedia] = useState<Media | null>(null);
+
+  const archiveQuery = useQuery({
+    queryKey: ['archive', archiveId],
+    queryFn: () => api.getArchive(archiveId),
+    enabled: Number.isFinite(archiveId),
+    retry: false,
+  });
+  const archive = archiveQuery.data;
+  const { data: runsData, isLoading: runsLoading } = useQuery({
+    queryKey: ['archive-runs', archiveId],
+    queryFn: () => api.getArchiveRuns(archiveId),
+    enabled: archive != null && archive.id === archiveId,
+  });
+  const { data: platesData } = useQuery({
+    queryKey: ['archive-plates', archiveId],
+    queryFn: () => api.getArchivePlates(archiveId),
+    enabled: archive != null && archive.id === archiveId,
+    staleTime: 5 * 60 * 1000,
+  });
+  const deleteImpact = useQuery({
+    queryKey: ['archive', archiveId, 'delete-impact'],
+    queryFn: () => api.getArchiveDeleteImpact(archiveId),
+    enabled: showDelete,
+    staleTime: 0,
+  });
+
+  // A merged archive's id opens the archive it went into (archive redesign
+  // P2): the address follows, so its prints and media load from that one.
+  useEffect(() => {
+    if (archive && archive.id !== archiveId) {
+      navigate(`/archives/${archive.id}${highlightRun ? `?run=${highlightRun}` : ''}`, { replace: true });
+    }
+  }, [archive, archiveId, highlightRun, navigate]);
+
+  const runs = useMemo(() => runsData?.items ?? [], [runsData]);
+  const plates = platesData?.is_multi_plate ? platesData.plates : [];
+  const printsPerPlate = useMemo(() => {
+    const counts = new Map<number, number>();
+    for (const run of runs) {
+      if (run.plate_id != null) counts.set(run.plate_id, (counts.get(run.plate_id) ?? 0) + 1);
+    }
+    return counts;
+  }, [runs]);
+
+  const highlightRef = useRef<HTMLLIElement | null>(null);
+  useEffect(() => {
+    if (highlightRun && runs.some((r) => r.id === highlightRun)) {
+      highlightRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
+    }
+  }, [highlightRun, runs]);
+
+  const deleteMutation = useMutation({
+    mutationFn: (purgeStats: boolean) => api.deleteArchive(archiveId, purgeStats),
+    onSuccess: () => {
+      invalidateArchiveAndProjectViews(queryClient);
+      showToast(t('archives.toast.archiveDeleted'));
+      navigate('/archives');
+    },
+    onError: () => showToast(t('archives.toast.failedDeleteArchive'), 'error'),
+  });
+
+  if (archiveQuery.isLoading) {
+    return (
+      <div className="flex justify-center p-16">
+        <Loader2 className="w-8 h-8 text-bambu-gray animate-spin" />
+      </div>
+    );
+  }
+  if (!archive) {
+    // Missing, or not the caller's: the API answers 404 for both. Anything
+    // else (a server error, a lost connection) can be tried again.
+    const missing = archiveQuery.error instanceof ApiError && archiveQuery.error.status === 404;
+    return (
+      <div className="p-4 md:p-8">
+        <Link to="/archives" className="text-sm text-bambu-gray hover:text-white">
+          {t('nav.archives')}
+        </Link>
+        <div className="mt-8 flex flex-col items-center gap-3 text-bambu-gray">
+          <p>{missing || !archiveQuery.error ? t('archivePage.notFound') : t('archivePage.loadFailed')}</p>
+          {!missing && archiveQuery.error && (
+            <Button variant="secondary" onClick={() => archiveQuery.refetch()}>
+              {t('archivePage.retry')}
+            </Button>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+  const name = archive.print_name || archive.filename;
+  const canUpdate = canModify('archives', 'update', archive.created_by_id);
+  const canReprint = !!archive.file_path && hasPermission('queue:create') && canModify('archives', 'reprint', archive.created_by_id);
+  const canDelete = canModify('archives', 'delete', archive.created_by_id);
+  // A video no print of the list holds is of one logged before prints kept
+  // their own: the file's, shown in the header.
+  const fileVideo = archive.timelapse_path && !runs.some((r) => r.timelapse_path === archive.timelapse_path);
+  const tags = archive.tags?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? [];
+
+  return (
+    <div className="p-4 md:p-8 space-y-6 max-w-6xl mx-auto">
+      <div className="flex items-center gap-2 text-sm text-bambu-gray min-w-0">
+        <Link to="/archives" className="hover:text-white transition-colors">
+          {t('nav.archives')}
+        </Link>
+        <ChevronRight className="w-4 h-4 shrink-0" />
+        <span className="text-white truncate">{name}</span>
+      </div>
+
+      <Card>
+        <CardContent className="p-4 md:p-6">
+          <div className="flex flex-col md:flex-row gap-6">
+            <div className="w-full md:w-60 shrink-0">
+              <div className="aspect-square rounded-lg bg-bambu-dark overflow-hidden flex items-center justify-center">
+                {archive.thumbnail_path ? (
+                  <img src={api.getArchiveThumbnail(archive.id)} alt={name} className="w-full h-full object-contain" />
+                ) : (
+                  <Package className="w-16 h-16 text-bambu-gray/50" />
+                )}
+              </div>
+            </div>
+            <div className="flex-1 min-w-0 space-y-4">
+              <div className="flex items-start gap-3">
+                <button
+                  onClick={() => navigate('/archives')}
+                  className="p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors shrink-0"
+                  aria-label={t('archivePage.back')}
+                >
+                  <ArrowLeft className="w-5 h-5 text-bambu-gray" />
+                </button>
+                <div className="min-w-0">
+                  <h1 className="text-2xl font-bold text-white break-words">{name}</h1>
+                  {archive.print_name && archive.filename !== archive.print_name && (
+                    <p className="text-sm text-bambu-gray break-all">{archive.filename}</p>
+                  )}
+                </div>
+              </div>
+
+              {(archive.project_name || tags.length > 0) && (
+                <div className="flex flex-wrap gap-1.5">
+                  {archive.project_name && archive.project_id != null && (
+                    <Link
+                      to={`/projects/${archive.project_id}`}
+                      className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30"
+                    >
+                      {archive.project_name}
+                    </Link>
+                  )}
+                  {tags.map((tag) => (
+                    <span key={tag} className="text-xs px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray-light">
+                      {tag}
+                    </span>
+                  ))}
+                </div>
+              )}
+
+              <dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
+                <Fact icon={Clock} label={t('archivePage.estimatedTime')} value={archive.print_time_seconds ? formatDuration(archive.print_time_seconds) : '—'} />
+                <Fact icon={Package} label={t('archivePage.estimatedFilament')} value={archive.filament_used_grams ? `${archive.filament_used_grams.toFixed(1)} g` : '—'} />
+                <Fact icon={Layers} label={t('archivePage.layers')} value={archive.total_layers ? String(archive.total_layers) : '—'} />
+                <Fact
+                  icon={Printer}
+                  label={t('archivePage.prints')}
+                  value={t('archivePage.printsValue', {
+                    count: archive.run_count,
+                    successful: archive.successful_run_count,
+                    failed: archive.failed_run_count,
+                  })}
+                />
+              </dl>
+              <p className="text-xs text-bambu-gray">
+                {t('archivePage.archivedAt', { date: formatDateTime(archive.created_at, timeFormat) })}
+              </p>
+
+              {archive.notes && (
+                <div className="flex gap-2 text-sm text-bambu-gray-light bg-bambu-dark rounded-lg p-3">
+                  <StickyNote className="w-4 h-4 shrink-0 mt-0.5 text-bambu-gray" />
+                  <p className="whitespace-pre-wrap break-words">{archive.notes}</p>
+                </div>
+              )}
+
+              <div className="flex flex-wrap gap-2">
+                <Button
+                  onClick={() => setShowReprint(true)}
+                  disabled={!canReprint}
+                  title={!canReprint ? t('archives.permission.noReprint') : undefined}
+                >
+                  <Printer className="w-4 h-4" />
+                  {t('archivePage.reprint')}
+                </Button>
+                <Button
+                  variant="secondary"
+                  onClick={() => setShowEdit(true)}
+                  disabled={!canUpdate}
+                  title={!canUpdate ? t('archives.permission.noUpdateArchives') : undefined}
+                >
+                  <Pencil className="w-4 h-4" />
+                  {t('archivePage.edit')}
+                </Button>
+                <Button
+                  variant="secondary"
+                  onClick={() => navigate(`/gcode-viewer?archive=${archive.id}`)}
+                >
+                  <Box className="w-4 h-4" />
+                  {t('archivePage.preview')}
+                </Button>
+                <Button
+                  variant="secondary"
+                  disabled={!hasPermission('archives:read')}
+                  onClick={() => {
+                    api.downloadArchive(archive.id, `${name}.3mf`).catch(() => {
+                      showToast(t('archivePage.downloadFailed'), 'error');
+                    });
+                  }}
+                >
+                  <Download className="w-4 h-4" />
+                  {t('archivePage.download')}
+                </Button>
+                {fileVideo && (
+                  <Button
+                    variant="secondary"
+                    onClick={() =>
+                      setMedia({ kind: 'video', src: api.getArchiveTimelapse(archive.id), title: t('archives.modal.timelapse', { name }) })
+                    }
+                  >
+                    <Film className="w-4 h-4" />
+                    {t('archivePage.video')}
+                  </Button>
+                )}
+                {archive.photos && archive.photos.length > 0 && (
+                  <Button
+                    variant="secondary"
+                    onClick={() => setMedia({ kind: 'photos', photos: archive.photos!, title: name })}
+                  >
+                    <Camera className="w-4 h-4" />
+                    {t('archivePage.allPhotos', { count: archive.photos.length })}
+                  </Button>
+                )}
+                <Button
+                  variant="danger"
+                  onClick={() => setShowDelete(true)}
+                  disabled={!canDelete}
+                  title={!canDelete ? t('archives.permission.noDelete') : undefined}
+                >
+                  <Trash2 className="w-4 h-4" />
+                  {t('archivePage.delete')}
+                </Button>
+              </div>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {plates.length > 1 && (
+        <section className="space-y-3">
+          <h2 className="text-lg font-semibold text-white">{t('archivePage.plates')}</h2>
+          <ul className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
+            {plates.map((plate) => (
+              <li key={plate.index}>
+                <Card>
+                  <CardContent className="p-3 space-y-2">
+                    <div className="aspect-square rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
+                      {plate.has_thumbnail ? (
+                        <img
+                          src={api.getArchivePlateThumbnail(archive.id, plate.index)}
+                          alt={plate.name || t('archives.card.plateNumber', { index: plate.index })}
+                          className="w-full h-full object-contain"
+                        />
+                      ) : (
+                        <Package className="w-8 h-8 text-bambu-gray/50" />
+                      )}
+                    </div>
+                    <p className="text-sm text-white truncate">
+                      {plate.name || t('archives.card.plateNumber', { index: plate.index })}
+                    </p>
+                    <p className="text-xs text-bambu-gray">
+                      {[
+                        plate.print_time_seconds ? formatDuration(plate.print_time_seconds) : null,
+                        plate.filament_used_grams ? `${plate.filament_used_grams.toFixed(1)} g` : null,
+                      ]
+                        .filter(Boolean)
+                        .join(' · ') || '—'}
+                    </p>
+                    <p className="text-xs text-bambu-gray">
+                      {t('archivePage.platePrinted', { count: printsPerPlate.get(plate.index) ?? 0 })}
+                    </p>
+                  </CardContent>
+                </Card>
+              </li>
+            ))}
+          </ul>
+        </section>
+      )}
+
+      <section className="space-y-3">
+        <h2 className="text-lg font-semibold text-white">{t('archivePage.printsTitle')}</h2>
+        {runsLoading ? (
+          <div className="flex justify-center py-6">
+            <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
+          </div>
+        ) : runs.length === 0 ? (
+          <p className="text-sm text-bambu-gray italic">
+            {/* Printed before each print was logged: its one print is the
+                archive's own outcome. */}
+            {LEGACY_PRINTED.has(archive.status)
+              ? t('archivePage.printedBeforeLog', {
+                  status: t(`archives.runLog.status.${archive.status}`, { defaultValue: archive.status }),
+                })
+              : t('archivePage.noPrints')}
+          </p>
+        ) : (
+          <ul className="space-y-3">
+            {runs.map((run) => (
+              <RunItem
+                key={run.id}
+                run={run}
+                archiveId={archive.id}
+                name={name}
+                showPlate={plates.length > 1 || runs.some((r) => r.plate_id != null && r.plate_id > 1)}
+                highlighted={run.id === highlightRun}
+                itemRef={run.id === highlightRun ? highlightRef : undefined}
+                timeFormat={timeFormat}
+                currency={currency}
+                canAnswer={canUpdate}
+                onConfirm={() => setConfirmRun(run.id)}
+                onMedia={setMedia}
+              />
+            ))}
+          </ul>
+        )}
+      </section>
+
+      {showEdit && <EditArchiveModal archive={archive} onClose={() => setShowEdit(false)} />}
+      {showReprint && (
+        <PrintModal
+          mode="create"
+          archiveId={archive.id}
+          archiveName={name}
+          preselectPlateId={archive.plate_id}
+          onClose={() => setShowReprint(false)}
+        />
+      )}
+      {confirmRun != null && (
+        <ConfirmOutcomeDialog
+          archiveId={archive.id}
+          runId={confirmRun}
+          onClose={() => {
+            setConfirmRun(null);
+            queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
+          }}
+        />
+      )}
+      {media?.kind === 'video' && (
+        <TimelapseViewer
+          src={media.src}
+          title={media.title}
+          downloadFilename={`${name}_timelapse.mp4`}
+          onClose={() => setMedia(null)}
+        />
+      )}
+      {media?.kind === 'photos' && (
+        <PhotoGalleryModal
+          archiveId={archive.id}
+          archiveName={media.title}
+          photos={media.photos}
+          onClose={() => setMedia(null)}
+        />
+      )}
+      {showDelete && (
+        <ConfirmModal
+          title={t('archives.modal.deleteArchive')}
+          message={t('archives.modal.deleteConfirm', { name })}
+          confirmText={t('archives.modal.deleteButton')}
+          variant="danger"
+          confirmDisabled={(deleteImpact.data?.currently_printing ?? 0) > 0}
+          onConfirm={() => {
+            deleteMutation.mutate(deletePurgeStats);
+            setShowDelete(false);
+            setDeletePurgeStats(false);
+          }}
+          onCancel={() => {
+            setShowDelete(false);
+            setDeletePurgeStats(false);
+          }}
+        >
+          {(deleteImpact.data?.related_queue_items ?? 0) > 0 && (
+            <div
+              className={
+                (deleteImpact.data?.currently_printing ?? 0) > 0
+                  ? 'text-sm text-red-600 dark:text-red-400 mb-2'
+                  : 'text-sm text-amber-600 dark:text-amber-400 mb-2'
+              }
+            >
+              {(deleteImpact.data?.currently_printing ?? 0) > 0
+                ? t('archives.modal.deleteBlockedByPrinting', { count: deleteImpact.data!.currently_printing })
+                : t('archives.modal.deleteQueueItemsWarning', { count: deleteImpact.data!.related_queue_items })}
+            </div>
+          )}
+          <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">
+            <input
+              type="checkbox"
+              className="mt-0.5 accent-red-500"
+              checked={deletePurgeStats}
+              onChange={(e) => setDeletePurgeStats(e.target.checked)}
+            />
+            <span>{t('archives.modal.deletePurgeStats')}</span>
+          </label>
+        </ConfirmModal>
+      )}
+    </div>
+  );
+}
+
+function Fact({ icon: Icon, label, value }: { icon: React.ElementType; label: string; value: string }) {
+  return (
+    <div className="bg-bambu-dark rounded-lg p-3 min-w-0">
+      <dt className="flex items-center gap-1.5 text-xs text-bambu-gray">
+        <Icon className="w-3.5 h-3.5" />
+        {label}
+      </dt>
+      <dd className="mt-1 text-white font-medium truncate">{value}</dd>
+    </div>
+  );
+}
+
+function RunItem({
+  run,
+  archiveId,
+  name,
+  showPlate,
+  highlighted,
+  itemRef,
+  timeFormat,
+  currency,
+  canAnswer,
+  onConfirm,
+  onMedia,
+}: {
+  run: PrintLogEntry;
+  archiveId: number;
+  name: string;
+  showPlate: boolean;
+  highlighted: boolean;
+  itemRef?: React.Ref<HTMLLIElement>;
+  timeFormat: TimeFormat;
+  currency: string;
+  canAnswer: boolean;
+  onConfirm: () => void;
+  onMedia: (media: Media) => void;
+}) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { canModify } = useAuth();
+  const [editingNote, setEditingNote] = useState(false);
+  const [note, setNote] = useState(run.notes ?? '');
+  const canEditNote = canModify('archives', 'update', run.created_by_id);
+
+  const noteMutation = useMutation({
+    mutationFn: (notes: string) => api.updatePrintLogEntry(run.id, { notes }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archive-runs', archiveId] });
+      queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      setEditingNote(false);
+      showToast(t('archivePage.noteSaved'));
+    },
+    onError: () => showToast(t('archivePage.noteFailed'), 'error'),
+  });
+
+  const verdictHint = verdictSourceKey(run.user_verdict_source);
+  const figures = [
+    run.duration_seconds ? { icon: Clock, text: `${run.duration_unmeasured ? '~' : ''}${formatDuration(run.duration_seconds)}` } : null,
+    run.filament_used_grams != null ? { icon: Package, text: `${run.filament_used_grams.toFixed(1)} g` } : null,
+    run.cost != null ? { icon: Coins, text: `${currency}${run.cost.toFixed(2)}` } : null,
+    run.energy_kwh != null ? { icon: Zap, text: `${run.energy_kwh.toFixed(2)} kWh` } : null,
+  ].filter((f): f is { icon: typeof Clock; text: string } => f !== null);
+  const pending = run.status === 'completed' && run.confirm_requested && run.user_verdict == null;
+
+  return (
+    <li ref={itemRef} data-testid={`archive-run-${run.id}`}>
+      <Card className={highlighted ? 'ring-2 ring-bambu-green' : undefined}>
+        <CardContent className="p-4 space-y-3">
+          <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
+            <span className="text-sm text-white font-medium">
+              {formatDateTime(run.started_at || run.created_at, timeFormat)}
+            </span>
+            <span className={`text-xs px-2 py-0.5 rounded font-medium ${RUN_STATUS_CLASSES[run.status] ?? 'bg-bambu-dark text-bambu-gray'}`}>
+              {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
+            </span>
+            {run.failure_reason && (
+              <span className="text-xs text-red-700 dark:text-red-400">
+                {t(`editArchive.failureReasons.${run.failure_reason}`, { defaultValue: run.failure_reason })}
+              </span>
+            )}
+            {run.user_verdict === 'good' && (
+              <span
+                className="text-xs px-2 py-0.5 rounded bg-bambu-green/80 text-white flex items-center gap-1"
+                title={`${t('archives.card.confirmedGoodTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
+              >
+                <ThumbsUp className="w-3 h-3" />
+                {t('archives.card.confirmedGood')}
+              </span>
+            )}
+            {run.user_verdict === 'reject' && (
+              <span
+                className="text-xs px-2 py-0.5 rounded bg-status-error/80 text-white flex items-center gap-1"
+                title={`${t('archives.card.rejectedTitle')}${verdictHint ? ` — ${t(verdictHint)}` : ''}`}
+              >
+                <ThumbsDown className="w-3 h-3" />
+                {t('archives.card.rejected')}
+              </span>
+            )}
+            {run.status === 'completed' && (
+              <button
+                onClick={onConfirm}
+                disabled={!canAnswer}
+                className={
+                  pending
+                    ? 'text-xs px-2 py-0.5 rounded bg-amber-500/90 enabled:hover:bg-amber-500 text-black flex items-center gap-1'
+                    : 'text-xs text-bambu-gray enabled:hover:text-white underline-offset-2 enabled:hover:underline'
+                }
+                title={!canAnswer ? t('archives.permission.noUpdateArchives') : undefined}
+              >
+                {pending ? (
+                  <>
+                    <ThumbsUp className="w-3 h-3" />
+                    {t('archives.card.confirmPending')}
+                  </>
+                ) : run.user_verdict ? (
+                  t('archivePage.changeOutcome')
+                ) : (
+                  t('archivePage.setOutcome')
+                )}
+              </button>
+            )}
+          </div>
+
+          <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-bambu-gray">
+            <span className="flex items-center gap-1">
+              <Printer className="w-3.5 h-3.5" />
+              {run.printer_name || t('archivePage.noPrinter')}
+            </span>
+            {showPlate && run.plate_id != null && (
+              <span className="flex items-center gap-1">
+                <Layers className="w-3.5 h-3.5" />
+                {t('archives.card.plateNumber', { index: run.plate_id })}
+              </span>
+            )}
+            {figures.map(({ icon: Icon, text }) => (
+              <span key={text} className="flex items-center gap-1">
+                <Icon className="w-3.5 h-3.5" />
+                {text}
+              </span>
+            ))}
+            {run.created_by_username && <span>{t('archivePage.startedBy', { name: run.created_by_username })}</span>}
+          </div>
+
+          {editingNote ? (
+            <div className="space-y-2">
+              <label className="sr-only" htmlFor={`run-note-${run.id}`}>
+                {t('archivePage.printNote')}
+              </label>
+              <textarea
+                id={`run-note-${run.id}`}
+                value={note}
+                onChange={(e) => setNote(e.target.value)}
+                rows={3}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white focus:border-bambu-green focus:outline-none"
+                placeholder={t('archivePage.notePlaceholder')}
+              />
+              <div className="flex gap-2">
+                <Button size="sm" onClick={() => noteMutation.mutate(note)} disabled={noteMutation.isPending}>
+                  {t('common.save')}
+                </Button>
+                <Button
+                  size="sm"
+                  variant="secondary"
+                  onClick={() => {
+                    setNote(run.notes ?? '');
+                    setEditingNote(false);
+                  }}
+                >
+                  {t('common.cancel')}
+                </Button>
+              </div>
+            </div>
+          ) : run.notes ? (
+            <div className="flex gap-2 text-sm text-bambu-gray-light">
+              <StickyNote className="w-4 h-4 shrink-0 mt-0.5 text-bambu-gray" />
+              <p className="whitespace-pre-wrap break-words flex-1">{run.notes}</p>
+            </div>
+          ) : null}
+
+          <div className="flex flex-wrap gap-2">
+            {run.timelapse_path && (
+              <Button
+                size="sm"
+                variant="secondary"
+                onClick={() =>
+                  onMedia({ kind: 'video', src: api.getArchiveRunTimelapse(archiveId, run.id), title: t('archives.modal.timelapse', { name }) })
+                }
+              >
+                <Film className="w-3.5 h-3.5" />
+                {t('archivePage.video')}
+              </Button>
+            )}
+            {run.photos && run.photos.length > 0 && (
+              <Button size="sm" variant="secondary" onClick={() => onMedia({ kind: 'photos', photos: run.photos!, title: name })}>
+                <Camera className="w-3.5 h-3.5" />
+                {t('archivePage.photos', { count: run.photos.length })}
+              </Button>
+            )}
+            {!editingNote && canEditNote && (
+              <Button size="sm" variant="secondary" onClick={() => setEditingNote(true)}>
+                <StickyNote className="w-3.5 h-3.5" />
+                {run.notes ? t('archivePage.editNote') : t('archivePage.addNote')}
+              </Button>
+            )}
+          </div>
+        </CardContent>
+      </Card>
+    </li>
+  );
+}

+ 84 - 26
frontend/src/pages/ArchivesPage.tsx

@@ -239,6 +239,15 @@ function saveLogColumnConfig(config: Array<{ id: string; visible: boolean }>) {
  * metadata — a .3mf with total_layers or print_time is sliced (contains gcode),
  * while a raw source .3mf (CAD export) has neither.
  */
+// A click on an archive's card or row itself, not on a control in it or a
+// dialog it opened: that opens the archive's page (archive redesign P4).
+function isOwnClick(e: React.MouseEvent<Element>): boolean {
+  const target = e.target as HTMLElement;
+  if (!e.currentTarget.contains(target)) return false;
+  if (target.closest('button, a, input, select, textarea, label, video, .fixed')) return false;
+  return !window.getSelection()?.toString();
+}
+
 function isSlicedFile(archive: { filename?: string | null; total_layers?: number | null; print_time_seconds?: number | null }): boolean {
   const filename = archive.filename;
   if (filename) {
@@ -891,13 +900,30 @@ function ArchiveCard({
     },
   ];
 
+  // Only a plate past the first says anything. The queue records a plate
+  // for single-plate files too -- the print dialog auto-selects the only
+  // plate there is -- so an ungated label reads "Plate 1" on ordinary
+  // prints, and eats room from the truncated name (#2796). The plate
+  // carousel is the one place a multi-plate archive printed from plate 1
+  // still identifies itself; it already gates on is_multi_plate, which this
+  // title cannot read without waiting for the hover-lazy plates request.
+  const cardTitle = `${archive.print_name || archive.filename}${
+    archive.plate_id != null && archive.plate_id > 1 ? ` — ${t('printers.plateNumber', { number: archive.plate_id })}` : ''
+  }`;
+
   return (
     <Card
       data-archive-id={archive.id}
-      className={`relative flex flex-col group ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
+      className={`relative flex flex-col group cursor-pointer ${isSelected ? 'ring-2 ring-bambu-green' : ''}`}
       style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
       onContextMenu={handleContextMenu}
-      onClick={selectionMode ? () => onSelect(archive.id) : undefined}
+      onClick={
+        selectionMode
+          ? () => onSelect(archive.id)
+          : (e) => {
+              if (!contextMenu && isOwnClick(e)) navigate(`/archives/${archive.id}`);
+            }
+      }
     >
       {/* Selection checkbox */}
       {selectionMode && (
@@ -1193,16 +1219,13 @@ function ArchiveCard({
         {/* Title */}
         <div className="flex items-center justify-between gap-2 mb-1">
           <h3 className="min-w-0 font-medium text-white truncate">
-            {archive.print_name || archive.filename}
-            {/* Only a plate past the first says anything. The queue records a
-                plate for single-plate files too -- the print dialog auto-selects
-                the only plate there is -- so an ungated label reads "Plate 1" on
-                ordinary prints, and eats room from the truncated name (#2796).
-                The plate carousel is the one place a multi-plate archive printed
-                from plate 1 still identifies itself; it already gates on
-                is_multi_plate, which this title cannot read without waiting for
-                the hover-lazy plates request. */}
-            {archive.plate_id != null && archive.plate_id > 1 && ` — ${t('printers.plateNumber', { number: archive.plate_id })}`}
+            {selectionMode ? (
+              cardTitle
+            ) : (
+              <Link to={`/archives/${archive.id}`} className="hover:underline">
+                {cardTitle}
+              </Link>
+            )}
           </h3>
           <Button
             variant="ghost"
@@ -1264,7 +1287,7 @@ function ArchiveCard({
               className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-bambu-orange/20 text-bambu-orange hover:bg-bambu-orange/30 transition-colors cursor-pointer"
               onClick={(e) => {
                 e.stopPropagation();
-                setShowPrintLog(true);
+                navigate(`/archives/${archive.id}`);
               }}
               title={t('archives.card.runsBadgeTitle', {
                 count: archive.run_count,
@@ -2424,11 +2447,18 @@ function ArchiveListRow({
     <>
       <div
         data-archive-id={archive.id}
-        className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
+        className={`grid grid-cols-12 gap-4 px-4 py-3 items-center cursor-pointer hover:bg-bambu-dark-tertiary/30 ${
           isSelected ? 'bg-bambu-green/10' : ''
         }`}
         style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '-4px' } : undefined}
         onContextMenu={handleContextMenu}
+        onClick={
+          selectionMode
+            ? undefined
+            : (e) => {
+                if (!contextMenu && isOwnClick(e)) navigate(`/archives/${archive.id}`);
+              }
+        }
       >
         <div className="col-span-1 flex items-center gap-2">
           {selectionMode && (
@@ -2455,7 +2485,15 @@ function ArchiveListRow({
         </div>
         <div className="col-span-4">
           <div className="flex items-center gap-2">
-            <p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
+            <p className="text-white text-sm truncate">
+              {selectionMode ? (
+                archive.print_name || archive.filename
+              ) : (
+                <Link to={`/archives/${archive.id}`} className="hover:underline">
+                  {archive.print_name || archive.filename}
+                </Link>
+              )}
+            </p>
             {(archive.status === 'failed' || archive.status === 'aborted') && (
               <span className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0">
                 {archive.status === 'aborted' ? t('archives.card.aborted') : t('archives.card.failed')}
@@ -3019,6 +3057,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
 
 export function ArchivesPage() {
   const { t } = useTranslation();
+  const navigate = useNavigate();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { hasPermission, hasAnyPermission } = useAuth();
@@ -3393,6 +3432,10 @@ export function ArchivesPage() {
     [],
   );
 
+  const deletedLogArchives = useMemo(
+    () => new Set(printLogData?.deleted_archive_ids ?? []),
+    [printLogData],
+  );
   const renderLogCell = useCallback(
     (colId: string, entry: PrintLogEntry) => {
       switch (colId) {
@@ -3419,9 +3462,20 @@ export function ArchivesPage() {
                   onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
                 />
               )}
-              <span className="text-white break-words" title={entry.print_name || ''}>
-                {entry.print_name || '—'}
-              </span>
+              {entry.archive_id != null && !deletedLogArchives.has(entry.archive_id) ? (
+                // Its archive's page, at this print (archive redesign P4)
+                <Link
+                  to={`/archives/${entry.archive_id}?run=${entry.id}`}
+                  className="text-white break-words hover:underline"
+                  title={entry.print_name || ''}
+                >
+                  {entry.print_name || '—'}
+                </Link>
+              ) : (
+                <span className="text-white break-words" title={entry.print_name || ''}>
+                  {entry.print_name || '—'}
+                </span>
+              )}
             </div>
           );
         case 'printer':
@@ -3517,7 +3571,7 @@ export function ArchivesPage() {
           return null;
       }
     },
-    [currency, t, timeFormat],
+    [currency, deletedLogArchives, t, timeFormat],
   );
 
   const bulkDeleteMutation = useMutation({
@@ -3539,6 +3593,8 @@ export function ArchivesPage() {
     mutationFn: () => api.clearPrintLog(),
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      queryClient.invalidateQueries({ queryKey: ['calendar-runs'] });
+      queryClient.invalidateQueries({ queryKey: ['archive-runs'] });
       setLogOffset(0);
       showToast(t('archives.log.cleared', { count: data.deleted }));
     },
@@ -3551,6 +3607,8 @@ export function ArchivesPage() {
     mutationFn: (id: number) => api.deletePrintLogEntry(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      queryClient.invalidateQueries({ queryKey: ['calendar-runs'] });
+      queryClient.invalidateQueries({ queryKey: ['archive-runs'] });
       queryClient.invalidateQueries({ queryKey: ['archives-stats'] });
       showToast(t('archives.log.entryDeleted'));
     },
@@ -3564,6 +3622,8 @@ export function ArchivesPage() {
       api.updatePrintLogEntry(id, body),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      queryClient.invalidateQueries({ queryKey: ['calendar-runs'] });
+      queryClient.invalidateQueries({ queryKey: ['archive-runs'] });
       // Also invalidate /archives/stats — the Failure Analysis widget there
       // groups by PrintLogEntry.failure_reason, so a re-classification needs
       // to flow through (#1687 part 4).
@@ -3738,7 +3798,9 @@ export function ArchivesPage() {
           matchesCollection = (PRINTED_STATUSES as readonly string[]).includes(a.status);
           break;
         case 'failed':
-          matchesCollection = a.status === 'failed' || a.status === 'aborted';
+          // Any print of it failed, not only the latest; one from before
+          // prints were logged by its own status.
+          matchesCollection = a.status === 'failed' || a.status === 'aborted' || a.failed_run_count > 0;
           break;
         case 'duplicates':
           matchesCollection = a.duplicate_count > 0;
@@ -4515,12 +4577,8 @@ export function ArchivesPage() {
         <Card className="p-6">
           <CalendarView
             archives={filteredArchives || []}
-            onArchiveClick={(archive) => {
-              // Switch to grid view and highlight the archive
-              setSearch(''); // Clear search to show all archives
-              setViewMode('grid');
-              setHighlightedArchiveId(archive.id);
-            }}
+            printerId={filterPrinter}
+            onPrintClick={(archiveId, runId) => navigate(`/archives/${archiveId}${runId != null ? `?run=${runId}` : ''}`)}
             highlightedArchiveId={highlightedArchiveId}
           />
         </Card>

+ 9 - 1
frontend/src/utils/projectQueries.ts

@@ -30,10 +30,18 @@ export function invalidateProjectViews(queryClient: QueryClient) {
   );
 }
 
-/** As above, plus the archive list itself — for mutations that change both. */
+/**
+ * Every key read from an archive or its prints, beside the list: the archive
+ * page's archive and print list and the calendar's prints (archive redesign
+ * P4). Prefixes, as above.
+ */
+export const ARCHIVE_VIEW_QUERY_KEYS = [['archive'], ['archive-runs'], ['calendar-runs']] as const;
+
+/** As above, plus the archive list and pages — for mutations that change both. */
 export function invalidateArchiveAndProjectViews(queryClient: QueryClient) {
   return Promise.all([
     queryClient.invalidateQueries({ queryKey: ['archives'] }),
+    ...ARCHIVE_VIEW_QUERY_KEYS.map((queryKey) => queryClient.invalidateQueries({ queryKey: [...queryKey] })),
     invalidateProjectViews(queryClient),
   ]);
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-BOTq9mjN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-JWKwEuej.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchiveDetailPage-BYgLfUI5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-BfhEWJ1i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-CMEzXCAy.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AreaChart-BX0y1y2t.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-C421iyiz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AuthContext-DQrtirY_.js


+ 1 - 1
static/assets/Button-DPTAdE-r.js → static/assets/Button-B9_qjR81.js

@@ -1 +1 @@
-import{w as e}from"./client-CWBrdRQ8.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-Bw3urp1w.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-DY3tmBJ4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-DMpI5Ekl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-B8IFsAGd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-CGoAbJwx.js


+ 1 - 1
static/assets/Card-DSfTTQ2S.js → static/assets/Card-CjvOwXIN.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-CWBrdRQ8.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Bw3urp1w.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CartesianChart-BX_I_DZv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-BPEGc8K-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-2aj0GtSK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfirmModal-fFY-Pxek.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfirmOutcomeDialog-DJy_CUup.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfirmOutcomeDialog-aLxq9HvU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-BP4EAAcE.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectionDiagnostic-BaFc8Rjv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-BymVycFy.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CopyButton-Cf9XUMu_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/EmbeddedCameraViewer-DZln3y9W.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ErrorBarContext-mNPmZ5iD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-Bymiesua.js


+ 1 - 1
static/assets/FilamentSwatch-Dz5HrDb8.js → static/assets/FilamentSwatch-DU9bOgNP.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-CWBrdRQ8.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Bw3urp1w.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-CNtOPtw7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-BbcWN0EN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-DtQlDAOC.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-C3YVfkN_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GcodeToolpathViewer-C6tlJsLY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-C9Vu8eR2.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/IconPicker-BimhUo0f.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/IconPicker-gCJJi2Ou.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-CDQVn7ek.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-CT6XBWR_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/Layout-KcUdYMIG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-BcEJFagm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-Cyd6braG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-qUKJf-sM.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MergeDuplicatesModal-AGh6LPz7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MergeDuplicatesModal-Cx2alSPc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-BokWlcLP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-CrvJ9KH5.js


+ 1 - 1
static/assets/NumberInput-DmWSadae.js → static/assets/NumberInput-CuACXpY4.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-CWBrdRQ8.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Bw3urp1w.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-BURM4Vsq.js → static/assets/OverlayFrame-hnkLBxV8.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-CWBrdRQ8.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-Bw3urp1w.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/PdfPreviewModal-D6ppezZ6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-7bggvmMY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-CdHJisSV.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintModal-BObYTfX7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-BD0N0q1Y.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-D2yvw3Pn.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-D6FUiK0m.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-joCuD-oz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-BBxTl-Eh.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-C9W_giGb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueryClientProvider-C3eWKLmF.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-LQ7r9MrS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ReferenceLine-CDP7AXLB.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-Dk7fzPGY.js


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