maziggy 2 месяцев назад
Родитель
Сommit
37d5dfe25a

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 124 - 2
backend/app/api/routes/print_queue.py

@@ -25,7 +25,9 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
+    PrintBatchCreate,
     PrintBatchResponse,
+    PrintBatchUngroupResponse,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
@@ -482,10 +484,30 @@ async def add_to_queue(
     # Validate quantity
     quantity = max(1, data.quantity)
 
-    # Create batch if quantity > 1
+    # Validate batch_id if provided. Client passes batch_id when adding items
+    # into a pre-created batch (multi-plate auto-batch or "Group as batch" flow).
+    # 404 keeps the existing-id leak surface low.
     batch = None
     batch_id = None
-    if quantity > 1:
+    if data.batch_id is not None:
+        result = await db.execute(select(PrintBatch).where(PrintBatch.id == data.batch_id))
+        existing_batch = result.scalar_one_or_none()
+        if not existing_batch:
+            raise HTTPException(404, "Batch not found")
+        if existing_batch.status != "active":
+            raise HTTPException(400, "Cannot add items to a non-active batch")
+        if (
+            current_user is not None
+            and existing_batch.created_by_id is not None
+            and existing_batch.created_by_id != current_user.id
+            and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+        ):
+            raise HTTPException(404, "Batch not found")
+        batch = existing_batch
+        batch_id = existing_batch.id
+
+    # Create batch if quantity > 1 and no batch_id provided
+    if batch_id is None and quantity > 1:
         # Derive batch name from source file
         batch_name_base = "Batch"
         if archive:
@@ -708,6 +730,106 @@ async def bulk_update_queue_items(
 # --- Batch endpoints ---
 
 
+@router.post("/batches", response_model=PrintBatchResponse)
+async def create_batch(
+    data: PrintBatchCreate,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+):
+    """Create a batch.
+
+    Two modes:
+    * ``item_ids`` provided: assign the listed pending queue items to a new
+      batch ("Group as batch" UI action).
+    * ``item_ids`` omitted/empty: create an empty batch so the client can
+      pass the returned ``id`` on subsequent ``POST /queue/`` calls. Used by
+      the multi-plate auto-batch flow in PrintModal.
+    """
+    if not data.name or not data.name.strip():
+        raise HTTPException(400, "Batch name is required")
+
+    batch = PrintBatch(
+        name=data.name.strip()[:255],
+        archive_id=data.archive_id,
+        library_file_id=data.library_file_id,
+        quantity=len(data.item_ids) if data.item_ids else 1,
+        status="active",
+        created_by_id=current_user.id if current_user else None,
+    )
+    db.add(batch)
+    await db.flush()  # Need batch.id before assigning to items
+
+    assigned = 0
+    if data.item_ids:
+        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
+        items = result.scalars().all()
+        for item in items:
+            if item.status != "pending":
+                continue
+            if item.batch_id is not None:
+                continue
+            if (
+                current_user is not None
+                and item.created_by_id != current_user.id
+                and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+            ):
+                continue
+            item.batch_id = batch.id
+            assigned += 1
+        batch.quantity = max(assigned, 1)
+
+    await db.commit()
+    await db.refresh(batch)
+
+    logger.info("Created batch %s '%s' with %s assigned items", batch.id, batch.name, assigned)
+    return await _build_batch_response(db, batch)
+
+
+@router.post("/batches/{batch_id}/ungroup", response_model=PrintBatchUngroupResponse)
+async def ungroup_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+):
+    """Disband a batch: clear batch_id from all members and delete the batch row.
+
+    Items stay in the queue. Only ungroups items the caller owns (unless they
+    hold QUEUE_UPDATE_ALL). A batch with all members ungrouped is deleted.
+    """
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+
+    can_modify_all = current_user is None or current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+    if not can_modify_all and batch.created_by_id != (current_user.id if current_user else None):
+        raise HTTPException(404, "Batch not found")
+
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id))
+    items = result.scalars().all()
+    ungrouped = 0
+    remaining = 0
+    for item in items:
+        if not can_modify_all and item.created_by_id != (current_user.id if current_user else None):
+            remaining += 1
+            continue
+        item.batch_id = None
+        ungrouped += 1
+
+    # Delete the batch row only when all members were ungrouped — otherwise it
+    # still owns the items the caller couldn't touch.
+    if remaining == 0:
+        await db.delete(batch)
+
+    await db.commit()
+
+    logger.info("Ungrouped batch %s (%s items)", batch_id, ungrouped)
+    return PrintBatchUngroupResponse(
+        ungrouped_count=ungrouped,
+        message=f"Ungrouped {ungrouped} item(s)",
+    )
+
+
 @router.get("/batches", response_model=list[PrintBatchResponse])
 async def list_batches(
     status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),

+ 24 - 0
backend/app/schemas/print_queue.py

@@ -52,6 +52,10 @@ class PrintQueueItemCreate(BaseModel):
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
     quantity: int = 1
+    # Existing batch to add this item into. When set, the item's batch_id is
+    # populated on insert so the queue UI groups it with its siblings. Used by
+    # the multi-plate auto-batch flow and by the "Group as batch" action.
+    batch_id: int | None = None
     # Project to associate the resulting archive with
     project_id: int | None = None
 
@@ -202,6 +206,26 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     message: str
 
 
+class PrintBatchCreate(BaseModel):
+    """Create a batch, either empty (multi-plate pre-batch flow) or by
+    assigning existing pending queue items into it (manual "Group as batch")."""
+
+    name: str
+    archive_id: int | None = None
+    library_file_id: int | None = None
+    # Existing pending queue items to assign to this batch. None / empty for
+    # the empty-batch flow (client passes the returned id on subsequent
+    # addToQueue calls).
+    item_ids: list[int] | None = None
+
+
+class PrintBatchUngroupResponse(BaseModel):
+    """Response after ungrouping a batch."""
+
+    ungrouped_count: int
+    message: str
+
+
 class PrintBatchResponse(BaseModel):
     """Response for a print batch with progress stats."""
 

+ 146 - 0
backend/tests/integration/test_print_queue_api.py

@@ -2002,6 +2002,152 @@ class TestAbortedStatusNormalisation:
         response = await async_client.get("/api/v1/queue/batches/9999")
         assert response.status_code == 404
 
+    # ========================================================================
+    # Queue redesign: create-empty + group-existing + ungroup
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_empty_batch_for_client_side_grouping(
+        self, async_client: AsyncClient, printer_factory, archive_factory
+    ):
+        """Verify POST /queue/batches without item_ids creates an empty batch
+        whose id can be passed on subsequent /queue/ POSTs (the multi-plate
+        auto-batch flow). Subsequent items must end up with the same batch_id."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # 1. Pre-create batch
+        batch_resp = await async_client.post(
+            "/api/v1/queue/batches",
+            json={"name": "Plates · 2 plates", "archive_id": archive.id},
+        )
+        assert batch_resp.status_code == 200
+        batch = batch_resp.json()
+        assert batch["status"] == "active"
+        batch_id = batch["id"]
+
+        # 2. Add two items referencing that batch
+        for plate_id in (1, 2):
+            item_resp = await async_client.post(
+                "/api/v1/queue/",
+                json={
+                    "printer_id": printer.id,
+                    "archive_id": archive.id,
+                    "plate_id": plate_id,
+                    "batch_id": batch_id,
+                },
+            )
+            assert item_resp.status_code == 200
+            assert item_resp.json()["batch_id"] == batch_id
+
+        # 3. Verify batch now has 2 pending children
+        list_resp = await async_client.get("/api/v1/queue/")
+        siblings = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert len(siblings) == 2
+        assert {i["plate_id"] for i in siblings} == {1, 2}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_group_existing_items_as_batch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
+    ):
+        """Verify POST /queue/batches with item_ids assigns batch_id to
+        existing pending items (the 'Group as batch' UI action)."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        item_a = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+        item_b = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+
+        resp = await async_client.post(
+            "/api/v1/queue/batches",
+            json={"name": "Manual group", "item_ids": [item_a.id, item_b.id]},
+        )
+        assert resp.status_code == 200
+        batch_id = resp.json()["id"]
+
+        list_resp = await async_client.get("/api/v1/queue/")
+        grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert {i["id"] for i in grouped} == {item_a.id, item_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_group_skips_non_pending_items(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
+    ):
+        """Verify grouping doesn't pull in already-completed/cancelled items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+        completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
+
+        resp = await async_client.post(
+            "/api/v1/queue/batches",
+            json={"name": "Mixed", "item_ids": [pending.id, completed.id]},
+        )
+        assert resp.status_code == 200
+        batch_id = resp.json()["id"]
+
+        list_resp = await async_client.get("/api/v1/queue/")
+        grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert {i["id"] for i in grouped} == {pending.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_batch_requires_name(self, async_client: AsyncClient):
+        """Verify empty / whitespace-only name is rejected with 400."""
+        resp = await async_client.post("/api/v1/queue/batches", json={"name": "   "})
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ungroup_batch_clears_batch_id_and_deletes_row(
+        self, async_client: AsyncClient, printer_factory, archive_factory
+    ):
+        """Verify POST /queue/batches/{id}/ungroup clears batch_id from all
+        members and deletes the batch row when nothing remains assigned."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # Create batch with two items via the existing quantity flow
+        add_resp = await async_client.post(
+            "/api/v1/queue/",
+            json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2},
+        )
+        batch_id = add_resp.json()["batch_id"]
+
+        # Ungroup
+        ungroup_resp = await async_client.post(f"/api/v1/queue/batches/{batch_id}/ungroup")
+        assert ungroup_resp.status_code == 200
+        assert ungroup_resp.json()["ungrouped_count"] == 2
+
+        # Verify items still exist but no longer batched
+        list_resp = await async_client.get("/api/v1/queue/")
+        ex_members = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert ex_members == []
+
+        # Batch row was deleted
+        get_resp = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
+        assert get_resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_unknown_batch_id_404(
+        self, async_client: AsyncClient, printer_factory, archive_factory
+    ):
+        """Verify addToQueue with a non-existent batch_id is rejected."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        resp = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "batch_id": 99999,
+            },
+        )
+        assert resp.status_code == 404
+
     # ========================================================================
     # Soft-deleted archive handling (#1348 follow-up)
     # ========================================================================

+ 13 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -198,8 +198,12 @@ describe('QueuePage', () => {
     });
 
     it('shows completed items in history', async () => {
+      const user = userEvent.setup();
       render(<QueuePage />);
 
+      // The History tab now owns the completed/cancelled/failed list.
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
       await waitFor(() => {
         expect(screen.getByText('Completed Print')).toBeInTheDocument();
       });
@@ -329,8 +333,11 @@ describe('QueuePage', () => {
     });
 
     it('shows re-queue button for history items', async () => {
+      const user = userEvent.setup();
       render(<QueuePage />);
 
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
       await waitFor(() => {
         expect(screen.getByText('Completed Print')).toBeInTheDocument();
       });
@@ -342,8 +349,12 @@ describe('QueuePage', () => {
 
   describe('clear history', () => {
     it('shows clear history button when history exists', async () => {
+      const user = userEvent.setup();
       render(<QueuePage />);
 
+      // Clear History only renders inside the History tab now.
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
       await waitFor(() => {
         expect(screen.getByText('Clear History')).toBeInTheDocument();
       });
@@ -353,6 +364,8 @@ describe('QueuePage', () => {
       const user = userEvent.setup();
       render(<QueuePage />);
 
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
       await waitFor(() => {
         expect(screen.getByText('Clear History')).toBeInTheDocument();
       });

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

@@ -1905,6 +1905,8 @@ export interface PrintQueueItem {
   printer_name?: string | null;
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
   filament_used_grams?: number | null;  // Estimated print weight from archive or library file
+  filament_type?: string | null;  // e.g. "PLA", "PETG"
+  filament_color?: string | null;  // Hex RGBA from the slicer
   bed_type?: string | null;  // Build plate type for this print (per-plate accurate, #1281)
   // User tracking (Issue #206)
   created_by_id?: number | null;
@@ -1963,10 +1965,22 @@ export interface PrintQueueItemCreate {
   gcode_injection?: boolean;
   // Batch: create multiple copies (creates a batch if > 1)
   quantity?: number;
+  // Existing batch to add this item into (multi-plate auto-batch flow).
+  batch_id?: number | null;
   // Project to associate the resulting archive with
   project_id?: number;
 }
 
+export interface PrintBatchCreate {
+  name: string;
+  archive_id?: number | null;
+  library_file_id?: number | null;
+  /** When set, the listed pending items are assigned to the new batch
+   *  (manual "Group as batch"). When omitted/empty, an empty batch is
+   *  returned so the client can pass batch_id on subsequent addToQueue calls. */
+  item_ids?: number[];
+}
+
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
@@ -4674,6 +4688,16 @@ export const api = {
   getBatch: (id: number) => request<PrintBatch>(`/queue/batches/${id}`),
   cancelBatch: (id: number) =>
     request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }),
+  createBatch: (data: PrintBatchCreate) =>
+    request<PrintBatch>('/queue/batches', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  ungroupBatch: (id: number) =>
+    request<{ ungrouped_count: number; message: string }>(
+      `/queue/batches/${id}/ungroup`,
+      { method: 'POST' },
+    ),
 
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>

+ 129 - 57
frontend/src/components/CompactHistoryRow.tsx

@@ -2,12 +2,15 @@ import {
   CheckCircle,
   XCircle,
   SkipForward,
-  X,
+  Ban,
   RefreshCw,
   Trash2,
   Printer,
   Timer,
   Layers,
+  User,
+  AlertCircle,
+  Weight,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
@@ -18,9 +21,20 @@ const STATUS_CONFIG = {
   completed: { icon: CheckCircle, color: 'text-emerald-400', border: 'border-l-emerald-500' },
   failed: { icon: XCircle, color: 'text-red-400', border: 'border-l-red-500' },
   skipped: { icon: SkipForward, color: 'text-orange-400', border: 'border-l-gray-500' },
-  cancelled: { icon: X, color: 'text-gray-400', border: 'border-l-gray-500' },
+  cancelled: { icon: Ban, color: 'text-gray-400', border: 'border-l-gray-500' },
 } as const;
 
+/** Bambu encodes "no filament" as transparent/zeroed RGBA. The slicer's
+ *  filament_color is a hex string like "RRGGBBAA" or "RRGGBB"; treat all-zero
+ *  / unparsable as no swatch. */
+function normalizeFilamentColor(raw: string | null | undefined): string | null {
+  if (!raw) return null;
+  const clean = raw.startsWith('#') ? raw.slice(1) : raw;
+  if (/^0{6,8}$/.test(clean)) return null;
+  if (!/^[0-9a-fA-F]{6,8}$/.test(clean)) return null;
+  return `#${clean.slice(0, 6)}`;
+}
+
 export function CompactHistoryRow({
   item,
   onRequeue,
@@ -49,72 +63,130 @@ export function CompactHistoryRow({
       : null;
 
   const completedTime = item.completed_at || item.created_at;
+  const filamentColor = normalizeFilamentColor(item.filament_color);
+  const filamentMass = item.filament_used_grams ? Math.round(item.filament_used_grams) : null;
+  // Failed and skipped prints carry the diagnostic in error_message; surface
+  // it inline so the user doesn't have to reopen the row to see why.
+  const showErrorMessage = !!item.error_message
+    && (item.status === 'failed' || item.status === 'skipped');
 
   return (
-    <div className={`flex items-center gap-2 sm:gap-3 px-3 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary border-l-[3px] ${config.border}`}>
-      {/* Status icon */}
-      <StatusIcon className={`w-4 h-4 shrink-0 ${config.color}`} />
+    <div className={`px-3 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary border-l-[3px] ${config.border}`}>
+      {/* Top row — status / thumb / name / time / actions */}
+      <div className="flex items-center gap-2 sm:gap-3">
+        <StatusIcon className={`w-4 h-4 shrink-0 ${config.color}`} />
 
-      {/* Thumbnail */}
-      <div className="w-8 h-8 shrink-0 bg-bambu-dark rounded overflow-hidden">
-        {thumbnailUrl ? (
-          <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
-        ) : (
-          <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-            <Layers className="w-4 h-4" />
+        <div className="relative shrink-0 history-thumb-hover">
+          <div className="w-8 h-8 bg-bambu-dark rounded overflow-hidden">
+            {thumbnailUrl ? (
+              <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
+            ) : (
+              <div className="w-full h-full flex items-center justify-center text-bambu-gray">
+                <Layers className="w-4 h-4" />
+              </div>
+            )}
           </div>
-        )}
-      </div>
-
-      {/* File name */}
-      <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
-        {displayName}
-      </span>
+          {/* Hover preview — desktop only via CSS @media. Positioned to the
+              right of the thumbnail; the parent card has no overflow:hidden
+              so the popup escapes its rounded border. pointer-events-none so
+              it doesn't interfere with clicks on rows below. */}
+          {thumbnailUrl && (
+            <div className="history-thumb-preview absolute z-[60] left-full top-0 ml-2 w-48 h-48 pointer-events-none opacity-0 transition-opacity">
+              <img
+                src={thumbnailUrl}
+                alt=""
+                className="w-full h-full object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark"
+              />
+            </div>
+          )}
+        </div>
 
-      {/* Printer */}
-      {item.printer_name && (
-        <span className="hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0">
-          <Printer className="w-3 h-3" />
-          <span className="truncate max-w-[100px]">{item.printer_name}</span>
+        <span className="text-sm text-white font-medium truncate min-w-0 flex-1">
+          {displayName}
         </span>
-      )}
 
-      {/* Duration */}
-      {item.print_time_seconds && (
-        <span className="hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0">
-          <Timer className="w-3 h-3" />
-          {formatDuration(item.print_time_seconds)}
+        <span
+          className="text-xs text-bambu-gray shrink-0"
+          title={completedTime ?? undefined}
+        >
+          {formatRelativeTime(completedTime, timeFormat, t)}
         </span>
-      )}
 
-      {/* Completed time */}
-      <span className="text-xs text-bambu-gray shrink-0">
-        {formatRelativeTime(completedTime, timeFormat, t)}
-      </span>
+        <div className="flex items-center gap-0.5 shrink-0">
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={onRequeue}
+            disabled={!hasPermission('queue:create')}
+            title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
+            className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5"
+          >
+            <RefreshCw className="w-3.5 h-3.5" />
+          </Button>
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={onRemove}
+            disabled={!canModify('queue', 'delete', item.created_by_id)}
+            title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
+            className="p-1.5"
+          >
+            <Trash2 className="w-3.5 h-3.5" />
+          </Button>
+        </div>
+      </div>
 
-      {/* Actions */}
-      <div className="flex items-center gap-0.5 shrink-0">
-        <Button
-          variant="ghost"
-          size="sm"
-          onClick={onRequeue}
-          disabled={!hasPermission('queue:create')}
-          title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
-          className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5"
-        >
-          <RefreshCw className="w-3.5 h-3.5" />
-        </Button>
-        <Button
-          variant="ghost"
-          size="sm"
-          onClick={onRemove}
-          disabled={!canModify('queue', 'delete', item.created_by_id)}
-          title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
-          className="p-1.5"
-        >
-          <Trash2 className="w-3.5 h-3.5" />
-        </Button>
+      {/* Meta row — printer / filament / duration / user. Indented under the
+          thumbnail so it lines up with the name. */}
+      <div className="mt-1 ml-[3.25rem] flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-bambu-gray">
+        {item.printer_name && (
+          <span className="flex items-center gap-1 shrink-0">
+            <Printer className="w-3 h-3" />
+            <span className="truncate max-w-[100px] sm:max-w-[140px]">{item.printer_name}</span>
+          </span>
+        )}
+        {(filamentColor || filamentMass) && (
+          <span className="flex items-center gap-1 shrink-0">
+            {filamentColor ? (
+              <span
+                className="inline-block w-2.5 h-2.5 rounded-full border border-white/15"
+                style={{ backgroundColor: filamentColor }}
+                aria-hidden
+              />
+            ) : (
+              <Weight className="w-3 h-3" />
+            )}
+            <span className="truncate">
+              {filamentMass ? `${filamentMass}g` : null}
+              {filamentMass && item.filament_type ? ` ${item.filament_type}` : null}
+              {!filamentMass && item.filament_type ? item.filament_type : null}
+            </span>
+          </span>
+        )}
+        {item.print_time_seconds && (
+          <span className="flex items-center gap-1 shrink-0">
+            <Timer className="w-3 h-3" />
+            {formatDuration(item.print_time_seconds)}
+          </span>
+        )}
+        {item.created_by_username && (
+          <span
+            className="flex items-center gap-1 shrink-0"
+            title={t('queue.addedBy', { name: item.created_by_username })}
+          >
+            <User className="w-3 h-3" />
+            <span className="truncate max-w-[120px]">{item.created_by_username}</span>
+          </span>
+        )}
       </div>
+
+      {/* Error message — only rendered on failed/skipped rows. */}
+      {showErrorMessage && (
+        <div className="mt-1 ml-[3.25rem] flex items-start gap-1 text-xs text-red-400">
+          <AlertCircle className="w-3 h-3 mt-0.5 shrink-0" />
+          <span className="break-words">{item.error_message}</span>
+        </div>
+      )}
     </div>
   );
 }

+ 27 - 0
frontend/src/components/PrintModal/index.tsx

@@ -640,6 +640,32 @@ export function PrintModal({
 
     const filamentOverridesArray = buildFilamentOverridesArray();
 
+    // Multi-plate auto-batch: when the user adds 2+ plates from one source in
+    // a single add-to-queue submission, pre-create a PrintBatch and pass its
+    // id to each subsequent addToQueue call so the queue UI groups them as a
+    // collapsible batch. Only triggered for single-target submissions —
+    // multi-printer fan-out keeps the old per-item shape.
+    const shouldAutoBatch =
+      mode === 'add-to-queue'
+      && platesToQueue.length > 1
+      && (assignmentMode === 'model' || selectedPrinters.length === 1);
+    let autoBatchId: number | null = null;
+    if (shouldAutoBatch) {
+      try {
+        const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
+        const batchName = `${baseName || 'Batch'} · ${platesToQueue.length} plates`;
+        const batch = await api.createBatch({
+          name: batchName,
+          archive_id: isLibraryFile ? undefined : archiveId,
+          library_file_id: isLibraryFile ? libraryFileId : undefined,
+        });
+        autoBatchId = batch.id;
+      } catch {
+        // Non-fatal: fall back to ungrouped items so the queue still works.
+        autoBatchId = null;
+      }
+    }
+
     // Common queue data for add-to-queue and edit modes
     const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({
       printer_id: assignmentMode === 'printer' ? printerId : null,
@@ -664,6 +690,7 @@ export function PrintModal({
         : undefined,
       ...printOptions,
       project_id: projectId ?? undefined,
+      batch_id: autoBatchId ?? undefined,
     });
 
     // Model-based assignment

+ 363 - 267
frontend/src/components/QueueTimelineView.tsx

@@ -1,172 +1,113 @@
-import { useState, useMemo, useEffect } from 'react';
+import { useState, useMemo, useEffect, useRef } from 'react';
 import { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react';
 import { formatDuration, parseUTCDate } from '../utils/date';
-import type { PrintQueueItem } from '../api/client';
+import type { PrintQueueItem, Printer } from '../api/client';
 import { api } from '../api/client';
 import { Button } from './Button';
 
-type FilterMode = 'all' | 'printing' | 'queued';
+/** Gantt-style 24h-rolling timeline. One horizontal swimlane per printer
+ *  (plus one per active target_model and one for unassigned items). Each
+ *  pending or printing job is rendered as a colored bar positioned by its
+ *  predicted start time, width = predicted duration. A vertical NOW line
+ *  marks current time. Hover a bar for details, click to edit/stop. */
+
+const HOUR_MS = 60 * 60 * 1000;
+const RANGE_HOURS = 24;
+const RANGE_MS = RANGE_HOURS * HOUR_MS;
+// Minimum bar width — short prints (a few minutes) would otherwise render as
+// 1-2px slivers and be unclickable. 32px keeps them at thumb size.
+const MIN_BAR_PX = 32;
+// Lane height for the bar row + label.
+const LANE_BAR_HEIGHT_PX = 40;
 
 interface ScheduleEvent {
   item: PrintQueueItem;
-  estimatedEnd: Date;
   estimatedStart: Date;
+  estimatedEnd: Date;
   progress?: number;
   type: 'printing' | 'queued';
 }
 
 interface QueueTimelineViewProps {
   queueItems: PrintQueueItem[];
+  printers: Printer[];
   printerStatuses: Record<number, { progress?: number; remaining_time?: number; state?: string }>;
   onItemClick: (item: PrintQueueItem) => void;
   t: (key: string, options?: Record<string, unknown>) => string;
 }
 
-function getStartOfDay(date: Date): Date {
-  const d = new Date(date);
-  d.setHours(0, 0, 0, 0);
-  return d;
+interface LaneDescriptor {
+  key: string;
+  label: string;
+  /** null for model-based / unassigned lanes. */
+  printerId: number | null;
+  /** Set for model-based lanes (`Any X1C`). */
+  targetModel: string | null;
 }
 
-function formatDateLabel(date: Date): string {
-  return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
+function formatHour(date: Date): string {
+  return date.toLocaleTimeString(undefined, { hour: 'numeric' });
 }
 
-function formatTimeOnly(date: Date): string {
+function formatTooltipTime(date: Date): string {
   return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
 }
 
-function formatTimeLeft(ms: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
-  if (ms <= 0) return t('queue.timeline.time.anyMoment');
-  const totalMin = Math.round(ms / 60000);
-  if (totalMin < 60) return t('queue.timeline.time.minutesLeft', { minutes: totalMin });
-  const hours = Math.floor(totalMin / 60);
-  const mins = totalMin % 60;
-  if (mins === 0) return t('queue.timeline.time.hoursLeft', { hours });
-  return t('queue.timeline.time.hoursMinutesLeft', { hours, minutes: mins });
-}
-
-function getHourLabel(hour: number): string {
-  const date = new Date();
-  date.setHours(hour, 0, 0, 0);
-  return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
-}
-
-function ScheduleCard({
-  event,
-  now,
-  onItemClick,
-  t,
-}: {
-  event: ScheduleEvent;
-  now: Date;
-  onItemClick: (item: PrintQueueItem) => void;
-  t: (key: string, opts?: Record<string, unknown>) => string;
-}) {
-  const item = event.item;
-  const displayName = item.archive_name || item.library_file_name || t('common.unknown');
-  const printerName = item.printer_name || (item.target_model ? `${t('queue.filter.any')} ${item.target_model}` : t('queue.timeline.unassigned'));
-  const isPrinting = event.type === 'printing';
-  const timeLeft = event.estimatedEnd.getTime() - now.getTime();
-
-  const thumbnailUrl = item.archive_thumbnail
-    ? api.getArchiveThumbnail(item.archive_id!)
-    : item.library_file_thumbnail
-      ? api.getLibraryFileThumbnailUrl(item.library_file_id!)
-      : null;
-
-  return (
-    <div
-      className={`flex items-center gap-3 px-3 sm:px-4 py-3 bg-bambu-dark-secondary rounded-xl border cursor-pointer transition-all hover:border-bambu-green/40
-        ${isPrinting ? 'border-blue-500/30' : 'border-bambu-dark-tertiary'}`}
-      onClick={() => onItemClick(item)}
-    >
-      {/* Left accent */}
-      <div className={`w-1 self-stretch rounded-full shrink-0 ${isPrinting ? 'bg-blue-500' : 'bg-bambu-green/40'}`} />
-
-      {/* Thumbnail */}
-      <div className="w-10 h-10 shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
-        {thumbnailUrl ? (
-          <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
-        ) : (
-          <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-            <Layers className="w-5 h-5" />
-          </div>
-        )}
-      </div>
-
-      {/* Info */}
-      <div className="flex-1 min-w-0">
-        <p className="text-sm text-white font-medium truncate">{displayName}</p>
-        <div className="flex items-center gap-2 mt-0.5">
-          <span className="flex items-center gap-1 text-xs text-bambu-gray">
-            <PrinterIcon className="w-3 h-3" />
-            <span className="truncate max-w-[120px] sm:max-w-none">{printerName}</span>
-          </span>
-          {item.print_time_seconds && (
-            <span className="hidden sm:inline text-xs text-bambu-gray">
-              {formatDuration(item.print_time_seconds)}
-            </span>
-          )}
-        </div>
-
-        {/* Progress bar for active prints */}
-        {isPrinting && event.progress != null && (
-          <div className="flex items-center gap-2 mt-1.5">
-            <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5">
-              <div
-                className="bg-blue-500 h-1.5 rounded-full transition-all"
-                style={{ width: `${event.progress}%` }}
-              />
-            </div>
-            <span className="text-xs text-blue-400 shrink-0">{Math.round(event.progress)}%</span>
-          </div>
-        )}
-      </div>
-
-      {/* Time info */}
-      <div className="text-right shrink-0">
-        <p className="text-sm text-white font-medium">{formatTimeOnly(event.estimatedEnd)}</p>
-        <p className={`text-xs mt-0.5 ${isPrinting ? 'text-blue-400' : 'text-bambu-gray'}`}>
-          {formatTimeLeft(timeLeft, t)}
-        </p>
-      </div>
-    </div>
-  );
-}
-
 export function QueueTimelineView({
   queueItems,
+  printers,
   printerStatuses,
   onItemClick,
   t,
 }: QueueTimelineViewProps) {
-  const [viewDate, setViewDate] = useState(() => getStartOfDay(new Date()));
+  // Tick "now" every minute so the NOW line and ETA labels stay live.
   const [now, setNow] = useState(() => new Date());
-  const [filter, setFilter] = useState<FilterMode>('all');
-
-  // Update "now" every 60 seconds
   useEffect(() => {
-    const interval = setInterval(() => setNow(new Date()), 60000);
+    const interval = setInterval(() => setNow(new Date()), 60_000);
     return () => clearInterval(interval);
   }, []);
 
+  // User can shift the window forward/back in 12h steps. Default = current
+  // time, so the timeline reads "next 24h from now."
+  const [windowOffsetMs, setWindowOffsetMs] = useState(0);
+
+  // Round the window start down to the previous full hour so the axis ticks
+  // land on whole hours.
+  const rangeStartMs = useMemo(() => {
+    const target = now.getTime() + windowOffsetMs;
+    return Math.floor(target / HOUR_MS) * HOUR_MS;
+  }, [now, windowOffsetMs]);
+  const rangeEndMs = rangeStartMs + RANGE_MS;
   const nowMs = now.getTime();
-  const isToday = getStartOfDay(new Date()).getTime() === getStartOfDay(viewDate).getTime();
 
-  // Build schedule events with ETA chaining
-  const events = useMemo(() => {
+  // Build schedule events. Only committed schedules are rendered:
+  //  • currently printing → always
+  //  • pending with explicit scheduled_time → at that time
+  //  • pending ASAP that chain behind a print actually running on the same
+  //    lane → forecast
+  // Staged (manual_start) and waiting (waiting_reason) items are not on the
+  // timeline because they won't auto-dispatch — they'd be misleading bars.
+  // Idle-printer ASAP queues also stay off until something starts on them.
+  const events = useMemo<ScheduleEvent[]>(() => {
     const result: ScheduleEvent[] = [];
-
-    // Group pending items by printer for chaining
-    const pendingByPrinter = new Map<number | null, PrintQueueItem[]>();
+    const pendingByLaneKey = new Map<string, PrintQueueItem[]>();
+    // Lanes that have an active print right now — only these qualify for
+    // ASAP chain forecasting.
+    const lanesWithActive = new Set<string>();
+    // Chain-end timestamp per lane (where the next pending item's bar starts).
+    const chainEndByLane = new Map<string, number>();
+
+    const laneKeyOf = (item: PrintQueueItem): string => {
+      if (item.printer_id != null) return `printer:${item.printer_id}`;
+      if (item.target_model) return `model:${item.target_model}`;
+      return 'unassigned';
+    };
 
     for (const item of queueItems) {
       if (item.status === 'printing') {
         const status = item.printer_id != null ? printerStatuses[item.printer_id] : undefined;
         const start = parseUTCDate(item.started_at) || new Date();
         let endTime: Date;
-
         if (status?.remaining_time != null && status.remaining_time > 0) {
           endTime = new Date(nowMs + status.remaining_time * 60 * 1000);
         } else if (item.print_time_seconds) {
@@ -174,9 +115,8 @@ export function QueueTimelineView({
           const remainingFraction = Math.max(0, 1 - progress / 100);
           endTime = new Date(nowMs + item.print_time_seconds * remainingFraction * 1000);
         } else {
-          endTime = new Date(nowMs + 3600000);
+          endTime = new Date(nowMs + HOUR_MS);
         }
-
         result.push({
           item,
           estimatedStart: start,
@@ -184,203 +124,359 @@ export function QueueTimelineView({
           progress: status?.progress ?? undefined,
           type: 'printing',
         });
+        const lk = laneKeyOf(item);
+        lanesWithActive.add(lk);
+        chainEndByLane.set(lk, Math.max(chainEndByLane.get(lk) ?? nowMs, endTime.getTime()));
       } else if (item.status === 'pending') {
-        const pid = item.printer_id;
-        if (!pendingByPrinter.has(pid)) pendingByPrinter.set(pid, []);
-        pendingByPrinter.get(pid)!.push(item);
+        // Skip un-committed pending shapes — staged items and waiting items
+        // won't auto-dispatch, so a bar would lie.
+        if (item.manual_start) continue;
+        if (item.waiting_reason) continue;
+        const lk = laneKeyOf(item);
+        if (!pendingByLaneKey.has(lk)) pendingByLaneKey.set(lk, []);
+        pendingByLaneKey.get(lk)!.push(item);
       }
     }
 
-    // Chain pending items per printer
-    for (const [printerId, items] of pendingByPrinter) {
+    const sixMonthsFromNow = Date.now() + 180 * 24 * HOUR_MS;
+    for (const [lk, items] of pendingByLaneKey) {
       items.sort((a, b) => a.position - b.position);
-
-      // Find when the current active print on this printer ends
-      let chainEnd = nowMs;
-      for (const ev of result) {
-        if (ev.item.printer_id === printerId && ev.type === 'printing') {
-          chainEnd = Math.max(chainEnd, ev.estimatedEnd.getTime());
-        }
-      }
-
+      const hasActive = lanesWithActive.has(lk);
+      // A lane is timelineable when EITHER it has an active print (chain
+      // forecast off its end) OR its first pending item is scheduled (a
+      // committed anchor exists). Otherwise every chained ASAP item is just
+      // a guess — drop the whole lane to keep the view honest.
+      const firstScheduled = items[0] ? parseUTCDate(items[0].scheduled_time) : null;
+      const firstScheduledOk = firstScheduled && firstScheduled.getTime() <= sixMonthsFromNow;
+      if (!hasActive && !firstScheduledOk) continue;
+
+      let chainEnd = chainEndByLane.get(lk) ?? nowMs;
       for (const item of items) {
-        // Respect scheduled_time
-        const scheduledTime = parseUTCDate(item.scheduled_time);
-        if (scheduledTime) {
-          const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
-          if (scheduledTime.getTime() <= sixMonthsFromNow) {
-            chainEnd = Math.max(chainEnd, scheduledTime.getTime());
-          }
+        const scheduled = parseUTCDate(item.scheduled_time);
+        if (scheduled && scheduled.getTime() <= sixMonthsFromNow) {
+          chainEnd = Math.max(chainEnd, scheduled.getTime());
         }
-
         const duration = (item.print_time_seconds || 3600) * 1000;
-        const startTime = new Date(chainEnd);
-        const endTime = new Date(chainEnd + duration);
-
         result.push({
           item,
-          estimatedStart: startTime,
-          estimatedEnd: endTime,
+          estimatedStart: new Date(chainEnd),
+          estimatedEnd: new Date(chainEnd + duration),
           type: 'queued',
         });
-
-        chainEnd = endTime.getTime();
+        chainEnd += duration;
       }
     }
-
-    // Sort by estimated end time
-    result.sort((a, b) => a.estimatedEnd.getTime() - b.estimatedEnd.getTime());
-
     return result;
   }, [queueItems, printerStatuses, nowMs]);
 
-  // Filter events for the selected day
-  const viewDayStart = getStartOfDay(viewDate).getTime();
-  const viewDayEnd = viewDayStart + 24 * 60 * 60 * 1000 - 1;
-
-  const filteredEvents = useMemo(() => {
-    return events.filter(ev => {
-      // Event finishes within the viewed day
-      const endMs = ev.estimatedEnd.getTime();
-      if (endMs < viewDayStart || endMs > viewDayEnd) return false;
+  // Lanes: every printer + every distinct target_model with queue activity
+  // + an "unassigned" lane if needed. Printers that have NO events queued
+  // still get a lane so users see idle capacity.
+  const lanes = useMemo<LaneDescriptor[]>(() => {
+    const list: LaneDescriptor[] = [];
+    for (const p of printers) {
+      list.push({
+        key: `printer:${p.id}`,
+        label: p.name,
+        printerId: p.id,
+        targetModel: null,
+      });
+    }
+    const modelLanesAdded = new Set<string>();
+    let hasUnassigned = false;
+    for (const ev of events) {
+      if (ev.item.printer_id != null) continue;
+      if (ev.item.target_model) {
+        const k = `model:${ev.item.target_model}`;
+        if (!modelLanesAdded.has(k)) {
+          modelLanesAdded.add(k);
+          list.push({
+            key: k,
+            label: `${t('queue.filter.any')} ${ev.item.target_model}`,
+            printerId: null,
+            targetModel: ev.item.target_model,
+          });
+        }
+      } else {
+        hasUnassigned = true;
+      }
+    }
+    if (hasUnassigned) {
+      list.push({
+        key: 'unassigned',
+        label: t('queue.filter.unassigned'),
+        printerId: null,
+        targetModel: null,
+      });
+    }
+    return list;
+  }, [printers, events, t]);
 
-      // Filter by type
-      if (filter === 'printing') return ev.type === 'printing';
-      if (filter === 'queued') return ev.type === 'queued';
-      return true;
-    });
-  }, [events, viewDayStart, viewDayEnd, filter]);
+  const eventsByLane = useMemo(() => {
+    const map = new Map<string, ScheduleEvent[]>();
+    for (const ev of events) {
+      let key: string;
+      if (ev.item.printer_id != null) key = `printer:${ev.item.printer_id}`;
+      else if (ev.item.target_model) key = `model:${ev.item.target_model}`;
+      else key = 'unassigned';
+      if (!map.has(key)) map.set(key, []);
+      map.get(key)!.push(ev);
+    }
+    return map;
+  }, [events]);
 
-  // Group events by hour for time markers
-  const groupedByHour = useMemo(() => {
-    const groups: Map<number, ScheduleEvent[]> = new Map();
-    for (const ev of filteredEvents) {
-      const hour = ev.estimatedEnd.getHours();
-      if (!groups.has(hour)) groups.set(hour, []);
-      groups.get(hour)!.push(ev);
+  const hourTicks = useMemo(() => {
+    const ticks: { ms: number; pct: number; label: string }[] = [];
+    for (let h = 0; h <= RANGE_HOURS; h += 2) {
+      const ms = rangeStartMs + h * HOUR_MS;
+      ticks.push({
+        ms,
+        pct: (h / RANGE_HOURS) * 100,
+        label: formatHour(new Date(ms)),
+      });
     }
-    // Sort by hour
-    return Array.from(groups.entries()).sort(([a], [b]) => a - b);
-  }, [filteredEvents]);
+    return ticks;
+  }, [rangeStartMs]);
 
-  // Counts for filter tabs
-  const printingCount = events.filter(ev => ev.type === 'printing' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length;
-  const queuedCount = events.filter(ev => ev.type === 'queued' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length;
+  const nowPct = ((nowMs - rangeStartMs) / RANGE_MS) * 100;
+  const nowInView = nowPct >= 0 && nowPct <= 100;
 
-  // Overall completion estimate
+  // Aggregated "all done by" across the entire (un-windowed) event set.
   const allDoneBy = useMemo(() => {
     let latest = 0;
-    for (const ev of events) {
-      latest = Math.max(latest, ev.estimatedEnd.getTime());
-    }
+    for (const ev of events) latest = Math.max(latest, ev.estimatedEnd.getTime());
     return latest > 0 ? new Date(latest) : null;
   }, [events]);
 
-  const goToday = () => setViewDate(getStartOfDay(new Date()));
-  const goPrev = () => {
-    const d = new Date(viewDate);
-    d.setDate(d.getDate() - 1);
-    setViewDate(d);
-  };
-  const goNext = () => {
-    const d = new Date(viewDate);
-    d.setDate(d.getDate() + 1);
-    setViewDate(d);
-  };
-
-  const filterTabs: { key: FilterMode; label: string; count: number }[] = [
-    { key: 'all', label: t('queue.timeline.filterAll'), count: printingCount + queuedCount },
-    { key: 'printing', label: t('queue.timeline.filterPrinting'), count: printingCount },
-    { key: 'queued', label: t('queue.timeline.filterQueued'), count: queuedCount },
-  ];
+  const trackRef = useRef<HTMLDivElement | null>(null);
 
   return (
     <div>
-      {/* Header */}
+      {/* Window controls */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
-        {/* Day navigation */}
         <div className="flex items-center gap-2">
-          <Button variant="ghost" size="sm" onClick={goPrev} className="p-1.5">
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={() => setWindowOffsetMs((v) => v - 12 * HOUR_MS)}
+            className="p-1.5"
+            title={t('queue.timeline.window.back12h')}
+          >
             <ChevronLeft className="w-4 h-4" />
           </Button>
-          <span className="text-sm font-medium text-white min-w-[140px] text-center">
-            {formatDateLabel(viewDate)}
+          <span className="text-sm font-medium text-white min-w-[180px] text-center">
+            {new Date(rangeStartMs).toLocaleString(undefined, {
+              weekday: 'short',
+              month: 'short',
+              day: 'numeric',
+              hour: '2-digit',
+              minute: '2-digit',
+            })}
+            {' → '}
+            {new Date(rangeEndMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
           </span>
-          <Button variant="ghost" size="sm" onClick={goNext} className="p-1.5">
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={() => setWindowOffsetMs((v) => v + 12 * HOUR_MS)}
+            className="p-1.5"
+            title={t('queue.timeline.window.forward12h')}
+          >
             <ChevronRight className="w-4 h-4" />
           </Button>
-          {!isToday && (
-            <Button variant="ghost" size="sm" onClick={goToday} className="text-xs text-bambu-green">
-              {t('queue.timeline.day.today')}
+          {windowOffsetMs !== 0 && (
+            <Button
+              variant="ghost"
+              size="sm"
+              onClick={() => setWindowOffsetMs(0)}
+              className="text-xs text-bambu-green"
+            >
+              {t('queue.timeline.window.now')}
             </Button>
           )}
         </div>
-
         {allDoneBy && (
           <span className="text-xs text-bambu-gray flex items-center gap-1.5">
             <Clock className="w-3.5 h-3.5" />
             {t('queue.timeline.allDoneBy', {
-              time: allDoneBy.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }),
+              time: allDoneBy.toLocaleString(undefined, {
+                weekday: 'short',
+                hour: '2-digit',
+                minute: '2-digit',
+              }),
             })}
           </span>
         )}
       </div>
 
-      {/* Filter tabs */}
-      <div className="flex gap-2 mb-5">
-        {filterTabs.map((tab) => (
-          <button
-            key={tab.key}
-            onClick={() => setFilter(tab.key)}
-            className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
-              filter === tab.key
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-dark-secondary border border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-            }`}
-          >
-            {tab.label}
-            {tab.count > 0 && (
-              <span className={`ml-1.5 text-xs ${filter === tab.key ? 'text-white/70' : 'text-bambu-gray'}`}>
-                {tab.count}
-              </span>
-            )}
-          </button>
-        ))}
-      </div>
-
-      {/* Schedule feed */}
-      {groupedByHour.length > 0 ? (
-        <div className="space-y-6">
-          {groupedByHour.map(([hour, hourEvents]) => (
-            <div key={hour}>
-              {/* Hour marker */}
-              <div className="flex items-center gap-3 mb-3">
-                <span className="text-xs font-medium text-bambu-gray w-14 shrink-0">
-                  {getHourLabel(hour)}
-                </span>
-                <div className="flex-1 h-px bg-bambu-dark-tertiary" />
-              </div>
-
-              {/* Events in this hour */}
-              <div className="space-y-2 sm:ml-[68px]">
-                {hourEvents.map((event) => (
-                  <ScheduleCard
-                    key={event.item.id}
-                    event={event}
-                    now={now}
-                    onItemClick={onItemClick}
-                    t={t}
-                  />
-                ))}
-              </div>
-            </div>
-          ))}
+      {/* Empty-state notice when the fleet is idle and no queued item is
+          committed (no scheduled_time / no active print to chain off).
+          Without this, users see striped lanes with no bars and assume the
+          timeline is broken — common confusion from the GHSA-r2qv era. */}
+      {lanes.length > 0 && events.length === 0 && (
+        <div className="mb-4 p-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 text-xs text-bambu-gray">
+          {t('queue.timeline.nothingCommitted')}
         </div>
-      ) : (
+      )}
+
+      {lanes.length === 0 ? (
         <div className="flex flex-col items-center justify-center py-16 text-bambu-gray">
           <Layers className="w-12 h-12 mb-3 opacity-30" />
           <p className="text-sm">{t('queue.timeline.noData')}</p>
         </div>
+      ) : (
+        <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden">
+          {/* Hour axis */}
+          <div className="flex border-b border-bambu-dark-tertiary">
+            <div className="w-32 sm:w-40 shrink-0 px-3 py-2 text-xs font-medium text-bambu-gray border-r border-bambu-dark-tertiary">
+              {t('queue.timeline.printerColumnHeader')}
+            </div>
+            <div className="relative flex-1 h-9">
+              {hourTicks.map((tick) => (
+                <div
+                  key={tick.ms}
+                  className="absolute top-0 bottom-0 border-l border-bambu-dark-tertiary/40 text-[10px] sm:text-xs text-bambu-gray pl-1 flex items-center"
+                  style={{ left: `${tick.pct}%` }}
+                >
+                  {tick.label}
+                </div>
+              ))}
+            </div>
+          </div>
+
+          {/* Lanes */}
+          <div ref={trackRef} className="relative">
+            {lanes.map((lane) => {
+              const laneEvents = eventsByLane.get(lane.key) ?? [];
+              return (
+                <div key={lane.key} className="flex border-b border-bambu-dark-tertiary/40 last:border-b-0">
+                  <div className="w-32 sm:w-40 shrink-0 px-3 py-3 border-r border-bambu-dark-tertiary flex items-center gap-2">
+                    <PrinterIcon className={`w-3.5 h-3.5 shrink-0 ${
+                      lane.printerId == null && lane.targetModel == null
+                        ? 'text-orange-400'
+                        : lane.targetModel
+                          ? 'text-blue-400'
+                          : 'text-bambu-green'
+                    }`} />
+                    <span className="text-sm text-white truncate">{lane.label}</span>
+                  </div>
+                  <div
+                    className="relative flex-1"
+                    style={{ height: LANE_BAR_HEIGHT_PX + 16 }}
+                  >
+                    {/* Hour grid lines */}
+                    {hourTicks.map((tick) => (
+                      <div
+                        key={tick.ms}
+                        className="absolute top-0 bottom-0 border-l border-bambu-dark-tertiary/30"
+                        style={{ left: `${tick.pct}%` }}
+                      />
+                    ))}
+
+                    {/* Idle background — diagonal stripes hint that the lane
+                        is sittable. Rendered behind bars so they overlay it. */}
+                    <div
+                      className="absolute inset-0 opacity-30"
+                      style={{
+                        backgroundImage:
+                          'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(255,255,255,0.04) 6px, rgba(255,255,255,0.04) 12px)',
+                      }}
+                      aria-hidden
+                    />
+
+                    {/* Job bars */}
+                    {laneEvents.map((ev) => {
+                      // Clip to the visible window.
+                      const startMs = Math.max(rangeStartMs, ev.estimatedStart.getTime());
+                      const endMs = Math.min(rangeEndMs, ev.estimatedEnd.getTime());
+                      if (endMs <= rangeStartMs || startMs >= rangeEndMs) return null;
+                      const leftPct = ((startMs - rangeStartMs) / RANGE_MS) * 100;
+                      const widthPct = ((endMs - startMs) / RANGE_MS) * 100;
+                      const displayName = ev.item.archive_name
+                        || ev.item.library_file_name
+                        || `#${ev.item.id}`;
+                      const thumbnailUrl = ev.item.archive_thumbnail
+                        ? api.getArchiveThumbnail(ev.item.archive_id!)
+                        : ev.item.library_file_thumbnail
+                          ? api.getLibraryFileThumbnailUrl(ev.item.library_file_id!)
+                          : null;
+                      const isPrinting = ev.type === 'printing';
+                      const isBatched = ev.item.batch_id != null;
+                      const tooltipParts = [
+                        displayName,
+                        `${formatTooltipTime(ev.estimatedStart)} → ${formatTooltipTime(ev.estimatedEnd)}`,
+                        ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : null,
+                        isPrinting && ev.progress != null ? `${Math.round(ev.progress)}%` : null,
+                        ev.item.batch_name ? `batch: ${ev.item.batch_name}` : null,
+                      ].filter(Boolean).join(' · ');
+                      return (
+                        <button
+                          key={ev.item.id}
+                          onClick={() => onItemClick(ev.item)}
+                          title={tooltipParts}
+                          className={`absolute rounded-md transition-all hover:brightness-110 hover:z-10 overflow-hidden flex items-center gap-1.5 px-1.5 text-left ${
+                            isPrinting
+                              ? 'bg-blue-500/30 border border-blue-400/60'
+                              : isBatched
+                                ? 'bg-cyan-500/20 border border-cyan-400/50'
+                                : 'bg-bambu-green/20 border border-bambu-green/40'
+                          }`}
+                          style={{
+                            left: `${leftPct}%`,
+                            width: `max(${MIN_BAR_PX}px, ${widthPct}%)`,
+                            top: 8,
+                            height: LANE_BAR_HEIGHT_PX,
+                          }}
+                        >
+                          {thumbnailUrl && (
+                            <img
+                              src={thumbnailUrl}
+                              alt=""
+                              className="w-7 h-7 rounded object-cover shrink-0 bg-bambu-dark"
+                            />
+                          )}
+                          <div className="min-w-0 flex-1">
+                            <div className="text-xs text-white font-medium truncate leading-tight">
+                              {displayName}
+                            </div>
+                            <div className="text-[10px] text-bambu-gray truncate leading-tight">
+                              {ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : ''}
+                              {isPrinting && ev.progress != null ? ` · ${Math.round(ev.progress)}%` : ''}
+                            </div>
+                          </div>
+                          {isPrinting && ev.progress != null && (
+                            <div
+                              className="absolute bottom-0 left-0 h-0.5 bg-blue-300"
+                              style={{ width: `${ev.progress}%` }}
+                              aria-hidden
+                            />
+                          )}
+                        </button>
+                      );
+                    })}
+                  </div>
+                </div>
+              );
+            })}
+
+            {/* NOW line — drawn on top of all lanes. Use the same
+                label-column offset (w-32 sm:w-40) as the lanes so the line
+                aligns exactly with the time track. */}
+            {nowInView && (
+              <div className="absolute top-0 bottom-0 pointer-events-none z-20 flex inset-x-0">
+                <div className="w-32 sm:w-40 shrink-0" />
+                <div className="relative flex-1">
+                  <div
+                    className="absolute top-0 bottom-0 w-0.5 bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.6)]"
+                    style={{ left: `${nowPct}%` }}
+                  >
+                    <div className="absolute -top-1 -left-1 w-2.5 h-2.5 bg-red-400 rounded-full" />
+                  </div>
+                </div>
+              </div>
+            )}
+          </div>
+        </div>
       )}
     </div>
   );

+ 45 - 1
frontend/src/i18n/locales/de.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: 'Verbleibende Stapeleinträge abgebrochen',
     cancelBatchConfirmTitle: 'Stapel abbrechen',
     cancelBatchConfirmMessage: 'Alle verbleibenden ausstehenden Einträge in diesem Stapel abbrechen?',
-    batch: 'Stapel',
+    batch: {
+      defaultName: 'Stapel',
+      label: '{{count}} Eintrag',
+      label_plural: '{{count}} Einträge',
+      pendingCount: '{{count}} ausstehend',
+      pendingCount_plural: '{{count}} ausstehend',
+      expand: 'Stapel ausklappen',
+      collapse: 'Stapel einklappen',
+      groupAsBatch: 'Als Stapel gruppieren…',
+      groupAsBatchDescription: 'Fasse die {{count}} ausgewählten Einträge zu einem einklappbaren Stapel zusammen.',
+      nameLabel: 'Stapelname',
+      namePlaceholder: 'z. B. Freitagsgeschenke',
+      create: 'Stapel erstellen',
+      ungroup: 'Gruppierung aufheben',
+      ungroupConfirmTitle: 'Stapel auflösen?',
+      ungroupConfirmMessage: 'Die Einträge bleiben in der Warteschlange, sind aber nicht mehr gruppiert.',
+    },
+    tabs: {
+      queue: 'Warteschlange',
+      history: 'Verlauf',
+      timeline: 'Zeitachse',
+    },
+    layout: {
+      flatList: 'Liste',
+      byPrinter: 'Nach Drucker',
+      groupByPrinter: 'Nach Drucker gruppieren',
+    },
+    history: {
+      emptyTitle: 'Noch kein Verlauf',
+      emptyDescription: 'Abgeschlossene, abgebrochene und fehlgeschlagene Drucke erscheinen hier.',
+    },
+    dragGhost: {
+      multiCount: '{{count}} Einträge',
+    },
     // Sections
     sections: {
       currentlyPrinting: 'Aktuell druckend',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: 'Elemente konnten nicht aktualisiert werden',
       bulkCancelled: '{{count}} Element(e) abgebrochen',
       bulkCancelFailed: 'Elemente konnten nicht abgebrochen werden',
+      batchCreated: 'Stapel „{{name}}“ erstellt',
+      batchCreateFailed: 'Stapel konnte nicht erstellt werden',
+      batchUngrouped: '{{count}} Eintrag/Einträge aus Stapel gelöst',
+      batchUngroupFailed: 'Stapel konnte nicht aufgelöst werden',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: 'Zeitstrahl',
       unassigned: 'Nicht zugewiesen',
       noData: 'Keine geplanten Drucke für diesen Tag',
+      nothingCommitted: 'Keine festgelegten Pläne in diesem Zeitfenster. Vorgemerkte Einträge, wartende Einträge und ASAP-Aufträge auf inaktiven Druckern werden nicht angezeigt — leg eine geplante Zeit fest oder gib einen vorgemerkten Eintrag frei, damit er hier erscheint.',
       allDoneBy: 'Alle Drucke voraussichtlich fertig um {{time}}',
       staged: 'Bereitgestellt',
       filterAll: 'Alle anzeigen',
@@ -1167,6 +1205,12 @@ export default {
         next: 'Nächster Tag',
         today: 'Heute',
       },
+      window: {
+        back12h: '12 Stunden zurück',
+        forward12h: '12 Stunden vor',
+        now: 'Jetzt',
+      },
+      printerColumnHeader: 'Drucker',
     },
     // Permissions
     permissions: {

+ 52 - 1
frontend/src/i18n/locales/en.ts

@@ -1016,7 +1016,46 @@ export default {
     batchCancelled: 'Remaining batch items cancelled',
     cancelBatchConfirmTitle: 'Cancel Batch',
     cancelBatchConfirmMessage: 'Cancel all remaining pending items in this batch?',
-    batch: 'Batch',
+    batch: {
+      defaultName: 'Batch',
+      label: '{{count}} item',
+      label_plural: '{{count}} items',
+      pendingCount: '{{count}} pending',
+      pendingCount_plural: '{{count}} pending',
+      expand: 'Expand batch',
+      collapse: 'Collapse batch',
+      groupAsBatch: 'Group as batch…',
+      groupAsBatchDescription: 'Combine the {{count}} selected items into a single collapsible batch.',
+      nameLabel: 'Batch name',
+      namePlaceholder: 'e.g. Friday gifts',
+      create: 'Create batch',
+      ungroup: 'Ungroup',
+      ungroupConfirmTitle: 'Ungroup batch?',
+      ungroupConfirmMessage: 'The items will stay in the queue but no longer be grouped together.',
+    },
+    // Tabs
+    tabs: {
+      queue: 'Queue',
+      history: 'History',
+      timeline: 'Timeline',
+    },
+    // Layout toggle on the Queue tab — distinct from the sort dropdown
+    // (those control order; these control whether items render as one flat
+    // list or grouped under per-printer section headers).
+    layout: {
+      flatList: 'List',
+      byPrinter: 'By Printer',
+      groupByPrinter: 'Group by Printer',
+    },
+    // History tab empty state
+    history: {
+      emptyTitle: 'No history yet',
+      emptyDescription: 'Completed, cancelled, and failed prints will appear here.',
+    },
+    // Drag ghost label when multi-dragging
+    dragGhost: {
+      multiCount: '{{count}} items',
+    },
     // Sections
     sections: {
       currentlyPrinting: 'Currently Printing',
@@ -1144,6 +1183,10 @@ export default {
       updateFailed: 'Failed to update items',
       bulkCancelled: 'Cancelled {{count}} item(s)',
       bulkCancelFailed: 'Failed to cancel items',
+      batchCreated: 'Batch "{{name}}" created',
+      batchCreateFailed: 'Failed to create batch',
+      batchUngrouped: 'Ungrouped {{count}} item(s)',
+      batchUngroupFailed: 'Failed to ungroup batch',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1194,7 @@ export default {
       timelineView: 'Timeline',
       unassigned: 'Unassigned',
       noData: 'No scheduled prints for this day',
+      nothingCommitted: 'No committed schedules in this window. Staged items, waiting items, and ASAP jobs on idle printers are not shown — set a scheduled time or release a staged item to see it here.',
       allDoneBy: 'All prints estimated done by {{time}}',
       staged: 'Staged',
       filterAll: 'Show All',
@@ -1167,6 +1211,13 @@ export default {
         next: 'Next day',
         today: 'Today',
       },
+      // Rolling-24h Gantt window
+      window: {
+        back12h: 'Back 12 hours',
+        forward12h: 'Forward 12 hours',
+        now: 'Now',
+      },
+      printerColumnHeader: 'Printer',
     },
     // Permissions
     permissions: {

+ 45 - 1
frontend/src/i18n/locales/es.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: 'Elementos restantes del lote cancelados',
     cancelBatchConfirmTitle: 'Cancelar lote',
     cancelBatchConfirmMessage: '¿Cancelar todos los elementos pendientes restantes de este lote?',
-    batch: 'Lote',
+    batch: {
+      defaultName: 'Lote',
+      label: '{{count}} elemento',
+      label_plural: '{{count}} elementos',
+      pendingCount: '{{count}} pendiente',
+      pendingCount_plural: '{{count}} pendientes',
+      expand: 'Expandir lote',
+      collapse: 'Contraer lote',
+      groupAsBatch: 'Agrupar como lote…',
+      groupAsBatchDescription: 'Combinar los {{count}} elementos seleccionados en un único lote contraíble.',
+      nameLabel: 'Nombre del lote',
+      namePlaceholder: 'p. ej., Regalos del viernes',
+      create: 'Crear lote',
+      ungroup: 'Desagrupar',
+      ungroupConfirmTitle: '¿Desagrupar lote?',
+      ungroupConfirmMessage: 'Los elementos permanecerán en la cola pero ya no estarán agrupados.',
+    },
+    tabs: {
+      queue: 'Cola',
+      history: 'Historial',
+      timeline: 'Cronología',
+    },
+    layout: {
+      flatList: 'Lista',
+      byPrinter: 'Por impresora',
+      groupByPrinter: 'Agrupar por impresora',
+    },
+    history: {
+      emptyTitle: 'Sin historial todavía',
+      emptyDescription: 'Las impresiones completadas, canceladas y fallidas aparecerán aquí.',
+    },
+    dragGhost: {
+      multiCount: '{{count}} elementos',
+    },
     // Sections
     sections: {
       currentlyPrinting: 'Imprimiendo actualmente',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: 'Error al actualizar los elementos',
       bulkCancelled: 'Se cancelaron {{count}} elemento(s)',
       bulkCancelFailed: 'Error al cancelar los elementos',
+      batchCreated: 'Lote "{{name}}" creado',
+      batchCreateFailed: 'Error al crear el lote',
+      batchUngrouped: '{{count}} elemento(s) desagrupado(s)',
+      batchUngroupFailed: 'Error al desagrupar el lote',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: 'Cronología',
       unassigned: 'Sin asignar',
       noData: 'No hay impresiones programadas para este día',
+      nothingCommitted: 'No hay programaciones comprometidas en esta ventana. Los elementos preparados, los elementos en espera y los trabajos ASAP en impresoras inactivas no se muestran — establece una hora programada o libera un elemento preparado para verlo aquí.',
       allDoneBy: 'Todas las impresiones estimadas para las {{time}}',
       staged: 'Preparado',
       filterAll: 'Mostrar todo',
@@ -1167,6 +1205,12 @@ export default {
         next: 'Día siguiente',
         today: 'Hoy',
       },
+      window: {
+        back12h: '12 horas atrás',
+        forward12h: '12 horas adelante',
+        now: 'Ahora',
+      },
+      printerColumnHeader: 'Impresora',
     },
     // Permissions
     permissions: {

+ 45 - 1
frontend/src/i18n/locales/fr.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: 'Éléments restants du lot annulés',
     cancelBatchConfirmTitle: 'Annuler le lot',
     cancelBatchConfirmMessage: 'Annuler tous les éléments en attente restants dans ce lot ?',
-    batch: 'Lot',
+    batch: {
+      defaultName: 'Lot',
+      label: '{{count}} élément',
+      label_plural: '{{count}} éléments',
+      pendingCount: '{{count}} en attente',
+      pendingCount_plural: '{{count}} en attente',
+      expand: 'Développer le lot',
+      collapse: 'Réduire le lot',
+      groupAsBatch: 'Grouper en lot…',
+      groupAsBatchDescription: 'Combiner les {{count}} éléments sélectionnés en un seul lot repliable.',
+      nameLabel: 'Nom du lot',
+      namePlaceholder: 'p. ex. Cadeaux du vendredi',
+      create: 'Créer le lot',
+      ungroup: 'Dégrouper',
+      ungroupConfirmTitle: 'Dégrouper le lot ?',
+      ungroupConfirmMessage: 'Les éléments resteront dans la file mais ne seront plus groupés.',
+    },
+    tabs: {
+      queue: 'File',
+      history: 'Historique',
+      timeline: 'Chronologie',
+    },
+    layout: {
+      flatList: 'Liste',
+      byPrinter: 'Par imprimante',
+      groupByPrinter: 'Grouper par imprimante',
+    },
+    history: {
+      emptyTitle: 'Aucun historique',
+      emptyDescription: 'Les impressions terminées, annulées et échouées apparaîtront ici.',
+    },
+    dragGhost: {
+      multiCount: '{{count}} éléments',
+    },
     // Sections
     sections: {
       currentlyPrinting: 'En cours',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: 'Échec mise à jour',
       bulkCancelled: '{{count}} éléments annulés',
       bulkCancelFailed: 'Échec annulation',
+      batchCreated: 'Lot « {{name}} » créé',
+      batchCreateFailed: 'Échec de la création du lot',
+      batchUngrouped: '{{count}} élément(s) dégroupé(s)',
+      batchUngroupFailed: 'Échec du dégroupement du lot',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: 'Chronologie',
       unassigned: 'Non attribué',
       noData: 'Aucune impression planifiée pour ce jour',
+      nothingCommitted: 'Aucun horaire confirmé dans cette fenêtre. Les éléments en attente d’action, les éléments bloqués et les jobs ASAP sur des imprimantes inactives ne sont pas affichés — définissez une heure planifiée ou libérez un élément en attente pour qu’il apparaisse ici.',
       allDoneBy: 'Toutes les impressions terminées vers {{time}}',
       staged: 'En attente',
       filterAll: 'Tout afficher',
@@ -1167,6 +1205,12 @@ export default {
         next: 'Jour suivant',
         today: 'Aujourd\'hui',
       },
+      window: {
+        back12h: 'Reculer de 12 heures',
+        forward12h: 'Avancer de 12 heures',
+        now: 'Maintenant',
+      },
+      printerColumnHeader: 'Imprimante',
     },
     // Permissions
     permissions: {

+ 45 - 1
frontend/src/i18n/locales/it.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: 'Elementi rimanenti del lotto annullati',
     cancelBatchConfirmTitle: 'Annulla lotto',
     cancelBatchConfirmMessage: 'Annullare tutti gli elementi in sospeso rimanenti in questo lotto?',
-    batch: 'Lotto',
+    batch: {
+      defaultName: 'Lotto',
+      label: '{{count}} elemento',
+      label_plural: '{{count}} elementi',
+      pendingCount: '{{count}} in attesa',
+      pendingCount_plural: '{{count}} in attesa',
+      expand: 'Espandi lotto',
+      collapse: 'Comprimi lotto',
+      groupAsBatch: 'Raggruppa come lotto…',
+      groupAsBatchDescription: 'Combina gli {{count}} elementi selezionati in un unico lotto comprimibile.',
+      nameLabel: 'Nome lotto',
+      namePlaceholder: 'es. Regali del venerdì',
+      create: 'Crea lotto',
+      ungroup: 'Separa',
+      ungroupConfirmTitle: 'Separare il lotto?',
+      ungroupConfirmMessage: 'Gli elementi resteranno in coda ma non saranno più raggruppati.',
+    },
+    tabs: {
+      queue: 'Coda',
+      history: 'Cronologia',
+      timeline: 'Linea temporale',
+    },
+    layout: {
+      flatList: 'Elenco',
+      byPrinter: 'Per stampante',
+      groupByPrinter: 'Raggruppa per stampante',
+    },
+    history: {
+      emptyTitle: 'Nessuna cronologia',
+      emptyDescription: 'Le stampe completate, annullate e fallite appariranno qui.',
+    },
+    dragGhost: {
+      multiCount: '{{count}} elementi',
+    },
     // Sections
     sections: {
       currentlyPrinting: 'In stampa',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: 'Aggiornamento elementi non riuscito',
       bulkCancelled: 'Annullati {{count}} elementi',
       bulkCancelFailed: 'Annullamento elementi non riuscito',
+      batchCreated: 'Lotto «{{name}}» creato',
+      batchCreateFailed: 'Creazione lotto non riuscita',
+      batchUngrouped: '{{count}} elemento/i separato/i',
+      batchUngroupFailed: 'Separazione del lotto non riuscita',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: 'Cronologia',
       unassigned: 'Non assegnato',
       noData: 'Nessuna stampa programmata per questo giorno',
+      nothingCommitted: 'Nessuna pianificazione confermata in questa finestra. Elementi preparati, in attesa e ASAP su stampanti inattive non sono mostrati — imposta un orario pianificato o rilascia un elemento preparato per vederlo qui.',
       allDoneBy: 'Tutte le stampe completate entro le {{time}}',
       staged: 'In attesa',
       filterAll: 'Mostra tutto',
@@ -1167,6 +1205,12 @@ export default {
         next: 'Giorno successivo',
         today: 'Oggi',
       },
+      window: {
+        back12h: 'Indietro di 12 ore',
+        forward12h: 'Avanti di 12 ore',
+        now: 'Adesso',
+      },
+      printerColumnHeader: 'Stampante',
     },
     // Permissions
     permissions: {

+ 45 - 1
frontend/src/i18n/locales/ja.ts

@@ -1015,7 +1015,40 @@ export default {
     batchCancelled: '残りのバッチアイテムをキャンセルしました',
     cancelBatchConfirmTitle: 'バッチをキャンセル',
     cancelBatchConfirmMessage: 'このバッチの残りの保留中アイテムをすべてキャンセルしますか?',
-    batch: 'バッチ',
+    batch: {
+      defaultName: 'バッチ',
+      label: '{{count}}件',
+      label_plural: '{{count}}件',
+      pendingCount: '{{count}}件待機中',
+      pendingCount_plural: '{{count}}件待機中',
+      expand: 'バッチを展開',
+      collapse: 'バッチを折りたたむ',
+      groupAsBatch: 'バッチとしてグループ化…',
+      groupAsBatchDescription: '選択した{{count}}件をひとつの折りたたみ可能なバッチにまとめます。',
+      nameLabel: 'バッチ名',
+      namePlaceholder: '例:金曜のプレゼント',
+      create: 'バッチを作成',
+      ungroup: 'グループ解除',
+      ungroupConfirmTitle: 'バッチのグループを解除しますか?',
+      ungroupConfirmMessage: 'アイテムはキューに残りますが、グループ化されなくなります。',
+    },
+    tabs: {
+      queue: 'キュー',
+      history: '履歴',
+      timeline: 'タイムライン',
+    },
+    layout: {
+      flatList: 'リスト',
+      byPrinter: 'プリンター別',
+      groupByPrinter: 'プリンター別にグループ化',
+    },
+    history: {
+      emptyTitle: '履歴はまだありません',
+      emptyDescription: '完了・キャンセル・失敗した印刷がここに表示されます。',
+    },
+    dragGhost: {
+      multiCount: '{{count}}件',
+    },
     // Sections
     sections: {
       currentlyPrinting: '印刷中',
@@ -1143,6 +1176,10 @@ export default {
       updateFailed: 'アイテムの更新に失敗しました',
       bulkCancelled: '{{count}}件のアイテムをキャンセルしました',
       bulkCancelFailed: 'アイテムのキャンセルに失敗しました',
+      batchCreated: 'バッチ「{{name}}」を作成しました',
+      batchCreateFailed: 'バッチの作成に失敗しました',
+      batchUngrouped: '{{count}}件のグループを解除しました',
+      batchUngroupFailed: 'バッチのグループ解除に失敗しました',
     },
     // Timeline view
     timeline: {
@@ -1150,6 +1187,7 @@ export default {
       timelineView: 'タイムライン',
       unassigned: '未割当',
       noData: 'この日の予定された印刷はありません',
+      nothingCommitted: 'この時間枠に確定したスケジュールはありません。手動開始のステージ済み項目、待機中の項目、アイドル状態のプリンターのASAPジョブは表示されません — 予定時刻を設定するか、ステージ済み項目をリリースすると表示されます。',
       allDoneBy: 'すべての印刷は {{time}} までに完了予定',
       staged: 'ステージング',
       filterAll: 'すべて表示',
@@ -1166,6 +1204,12 @@ export default {
         next: '翌日',
         today: '今日',
       },
+      window: {
+        back12h: '12時間戻る',
+        forward12h: '12時間進む',
+        now: '現在',
+      },
+      printerColumnHeader: 'プリンター',
     },
     // Permissions
     permissions: {

+ 47 - 3
frontend/src/i18n/locales/ko.ts

@@ -960,7 +960,40 @@ export default {
     batchCancelled: '남은 배치 항목이 취소되었습니다',
     cancelBatchConfirmTitle: '배치 취소',
     cancelBatchConfirmMessage: '이 배치의 남은 대기 항목을 모두 취소하시겠습니까?',
-    batch: '배치',
+    batch: {
+      defaultName: '배치',
+      label: '{{count}}개 항목',
+      label_plural: '{{count}}개 항목',
+      pendingCount: '{{count}}개 대기 중',
+      pendingCount_plural: '{{count}}개 대기 중',
+      expand: '배치 펼치기',
+      collapse: '배치 접기',
+      groupAsBatch: '배치로 묶기…',
+      groupAsBatchDescription: '선택한 {{count}}개 항목을 접을 수 있는 하나의 배치로 묶습니다.',
+      nameLabel: '배치 이름',
+      namePlaceholder: '예: 금요일 선물',
+      create: '배치 만들기',
+      ungroup: '그룹 해제',
+      ungroupConfirmTitle: '배치 그룹을 해제하시겠습니까?',
+      ungroupConfirmMessage: '항목은 큐에 남아 있지만 더 이상 함께 그룹화되지 않습니다.',
+    },
+    tabs: {
+      queue: '큐',
+      history: '기록',
+      timeline: '타임라인',
+    },
+    layout: {
+      flatList: '목록',
+      byPrinter: '프린터별',
+      groupByPrinter: '프린터별로 묶기',
+    },
+    history: {
+      emptyTitle: '아직 기록이 없습니다',
+      emptyDescription: '완료·취소·실패한 인쇄가 여기에 표시됩니다.',
+    },
+    dragGhost: {
+      multiCount: '{{count}}개 항목',
+    },
     sections: {
       currentlyPrinting: '현재 인쇄 중',
       queued: '대기 중',
@@ -1075,13 +1108,18 @@ export default {
       clearHistoryFailed: '기록 지우기 실패',
       updateFailed: '항목 업데이트 실패',
       bulkCancelled: '{{count}}개 항목이 취소되었습니다',
-      bulkCancelFailed: '항목 취소 실패'
+      bulkCancelFailed: '항목 취소 실패',
+      batchCreated: '"{{name}}" 배치를 만들었습니다',
+      batchCreateFailed: '배치 만들기에 실패했습니다',
+      batchUngrouped: '{{count}}개 항목의 그룹을 해제했습니다',
+      batchUngroupFailed: '배치 그룹 해제에 실패했습니다',
     },
     timeline: {
       listView: '목록',
       timelineView: '타임라인',
       unassigned: '미할당',
       noData: '이 날에 예약된 인쇄 없음',
+      nothingCommitted: '이 시간 창에 확정된 일정이 없습니다. 대기 중인 항목, 보류된 항목, 유휴 프린터의 ASAP 작업은 표시되지 않습니다 — 예약 시간을 설정하거나 대기 항목을 릴리스하면 여기에 표시됩니다.',
       allDoneBy: '모든 인쇄 예상 완료 시간: {{time}}',
       staged: '준비됨',
       filterAll: '모두 표시',
@@ -1097,7 +1135,13 @@ export default {
         previous: '이전 날',
         next: '다음 날',
         today: '오늘'
-      }
+      },
+      window: {
+        back12h: '12시간 이전',
+        forward12h: '12시간 이후',
+        now: '지금',
+      },
+      printerColumnHeader: '프린터',
     },
     permissions: {
       noStopPrint: '인쇄를 정지할 권한이 없습니다',

+ 45 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: 'Itens restantes do lote cancelados',
     cancelBatchConfirmTitle: 'Cancelar lote',
     cancelBatchConfirmMessage: 'Cancelar todos os itens pendentes restantes neste lote?',
-    batch: 'Lote',
+    batch: {
+      defaultName: 'Lote',
+      label: '{{count}} item',
+      label_plural: '{{count}} itens',
+      pendingCount: '{{count}} pendente',
+      pendingCount_plural: '{{count}} pendentes',
+      expand: 'Expandir lote',
+      collapse: 'Recolher lote',
+      groupAsBatch: 'Agrupar como lote…',
+      groupAsBatchDescription: 'Combine os {{count}} itens selecionados em um único lote recolhível.',
+      nameLabel: 'Nome do lote',
+      namePlaceholder: 'ex. Presentes de sexta',
+      create: 'Criar lote',
+      ungroup: 'Desagrupar',
+      ungroupConfirmTitle: 'Desagrupar lote?',
+      ungroupConfirmMessage: 'Os itens permanecerão na fila mas não estarão mais agrupados.',
+    },
+    tabs: {
+      queue: 'Fila',
+      history: 'Histórico',
+      timeline: 'Linha do tempo',
+    },
+    layout: {
+      flatList: 'Lista',
+      byPrinter: 'Por impressora',
+      groupByPrinter: 'Agrupar por impressora',
+    },
+    history: {
+      emptyTitle: 'Sem histórico ainda',
+      emptyDescription: 'Impressões concluídas, canceladas e com falha aparecerão aqui.',
+    },
+    dragGhost: {
+      multiCount: '{{count}} itens',
+    },
     // Sections
     sections: {
       currentlyPrinting: 'Imprimindo Atualmente',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: 'Falha ao atualizar itens',
       bulkCancelled: 'Cancelado {{count}} item(s)',
       bulkCancelFailed: 'Falha ao cancelar itens',
+      batchCreated: 'Lote "{{name}}" criado',
+      batchCreateFailed: 'Falha ao criar o lote',
+      batchUngrouped: '{{count}} item(ns) desagrupado(s)',
+      batchUngroupFailed: 'Falha ao desagrupar o lote',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: 'Linha do tempo',
       unassigned: 'Não atribuído',
       noData: 'Nenhuma impressão agendada para este dia',
+      nothingCommitted: 'Sem programações confirmadas nesta janela. Itens preparados, itens em espera e trabalhos ASAP em impressoras ociosas não são mostrados — defina um horário agendado ou libere um item preparado para vê-lo aqui.',
       allDoneBy: 'Todas as impressões concluídas até {{time}}',
       staged: 'Preparado',
       filterAll: 'Mostrar tudo',
@@ -1167,6 +1205,12 @@ export default {
         next: 'Próximo dia',
         today: 'Hoje',
       },
+      window: {
+        back12h: 'Voltar 12 horas',
+        forward12h: 'Avançar 12 horas',
+        now: 'Agora',
+      },
+      printerColumnHeader: 'Impressora',
     },
     // Permissions
     permissions: {

+ 45 - 1
frontend/src/i18n/locales/tr.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: 'Kalan yığın öğeleri iptal edildi',
     cancelBatchConfirmTitle: 'Yığını İptal Et',
     cancelBatchConfirmMessage: 'Bu yığındaki tüm bekleyen öğeler iptal edilsin mi?',
-    batch: 'Yığın',
+    batch: {
+      defaultName: 'Yığın',
+      label: '{{count}} öğe',
+      label_plural: '{{count}} öğe',
+      pendingCount: '{{count}} bekleyen',
+      pendingCount_plural: '{{count}} bekleyen',
+      expand: 'Yığını genişlet',
+      collapse: 'Yığını daralt',
+      groupAsBatch: 'Yığın olarak grupla…',
+      groupAsBatchDescription: 'Seçilen {{count}} öğeyi tek bir daraltılabilir yığında birleştirin.',
+      nameLabel: 'Yığın adı',
+      namePlaceholder: 'örn. Cuma hediyeleri',
+      create: 'Yığın oluştur',
+      ungroup: 'Gruptan çıkar',
+      ungroupConfirmTitle: 'Yığını gruptan çıkar?',
+      ungroupConfirmMessage: 'Öğeler kuyrukta kalacak ancak artık birlikte gruplanmayacak.',
+    },
+    tabs: {
+      queue: 'Kuyruk',
+      history: 'Geçmiş',
+      timeline: 'Zaman çizelgesi',
+    },
+    layout: {
+      flatList: 'Liste',
+      byPrinter: 'Yazıcıya göre',
+      groupByPrinter: 'Yazıcıya göre grupla',
+    },
+    history: {
+      emptyTitle: 'Henüz geçmiş yok',
+      emptyDescription: 'Tamamlanan, iptal edilen ve başarısız baskılar burada görünür.',
+    },
+    dragGhost: {
+      multiCount: '{{count}} öğe',
+    },
     // Bölümler
     sections: {
       currentlyPrinting: 'Şu Anda Yazdırılan',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: 'Öğeler güncellenemedi',
       bulkCancelled: '{{count}} öğe iptal edildi',
       bulkCancelFailed: 'Öğeler iptal edilemedi',
+      batchCreated: '"{{name}}" yığını oluşturuldu',
+      batchCreateFailed: 'Yığın oluşturma başarısız',
+      batchUngrouped: '{{count}} öğe gruptan çıkarıldı',
+      batchUngroupFailed: 'Yığını gruptan çıkarma başarısız',
     },
     // Zaman çizelgesi görünümü
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: 'Zaman Çizelgesi',
       unassigned: 'Atanmamış',
       noData: 'Bu gün için zamanlanmış baskı yok',
+      nothingCommitted: 'Bu pencerede onaylanmış programlar yok. Hazırlanmış öğeler, beklemedeki öğeler ve boştaki yazıcılardaki ASAP işleri gösterilmez — burada görmek için zamanlanmış bir saat belirleyin veya hazırlanmış bir öğeyi serbest bırakın.',
       allDoneBy: 'Tüm baskıların {{time}}\'e kadar bitmesi tahmin ediliyor',
       staged: 'Hazırlandı',
       filterAll: 'Tümünü Göster',
@@ -1167,6 +1205,12 @@ export default {
         next: 'Sonraki gün',
         today: 'Bugün',
       },
+      window: {
+        back12h: '12 saat geri',
+        forward12h: '12 saat ileri',
+        now: 'Şimdi',
+      },
+      printerColumnHeader: 'Yazıcı',
     },
     // İzinler
     permissions: {

+ 45 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: '已取消剩余批次项目',
     cancelBatchConfirmTitle: '取消批次',
     cancelBatchConfirmMessage: '取消此批次中所有剩余的待处理项目?',
-    batch: '批次',
+    batch: {
+      defaultName: '批次',
+      label: '{{count}} 项',
+      label_plural: '{{count}} 项',
+      pendingCount: '{{count}} 待处理',
+      pendingCount_plural: '{{count}} 待处理',
+      expand: '展开批次',
+      collapse: '折叠批次',
+      groupAsBatch: '组合为批次…',
+      groupAsBatchDescription: '将选中的 {{count}} 项组合为一个可折叠的批次。',
+      nameLabel: '批次名称',
+      namePlaceholder: '例如:周五礼物',
+      create: '创建批次',
+      ungroup: '取消分组',
+      ungroupConfirmTitle: '取消批次分组?',
+      ungroupConfirmMessage: '项目将保留在队列中,但不再分组在一起。',
+    },
+    tabs: {
+      queue: '队列',
+      history: '历史',
+      timeline: '时间线',
+    },
+    layout: {
+      flatList: '列表',
+      byPrinter: '按打印机',
+      groupByPrinter: '按打印机分组',
+    },
+    history: {
+      emptyTitle: '暂无历史',
+      emptyDescription: '已完成、已取消和失败的打印将在此显示。',
+    },
+    dragGhost: {
+      multiCount: '{{count}} 项',
+    },
     // Sections
     sections: {
       currentlyPrinting: '正在打印',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: '更新项目失败',
       bulkCancelled: '已取消 {{count}} 个项目',
       bulkCancelFailed: '批量取消项目失败',
+      batchCreated: '已创建批次"{{name}}"',
+      batchCreateFailed: '创建批次失败',
+      batchUngrouped: '已取消分组 {{count}} 项',
+      batchUngroupFailed: '取消批次分组失败',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: '时间线',
       unassigned: '未分配',
       noData: '当天没有计划的打印任务',
+      nothingCommitted: '此时间窗口内没有已确定的计划。暂存项目、等待项目以及空闲打印机上的 ASAP 任务不会显示 — 设置计划时间或释放暂存项目以在此处查看。',
       allDoneBy: '所有打印预计在 {{time}} 前完成',
       staged: '暂存',
       filterAll: '全部显示',
@@ -1167,6 +1205,12 @@ export default {
         next: '后一天',
         today: '今天',
       },
+      window: {
+        back12h: '后退 12 小时',
+        forward12h: '前进 12 小时',
+        now: '现在',
+      },
+      printerColumnHeader: '打印机',
     },
     // Permissions
     permissions: {

+ 45 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -1016,7 +1016,40 @@ export default {
     batchCancelled: '已取消剩餘批次項目',
     cancelBatchConfirmTitle: '取消批次',
     cancelBatchConfirmMessage: '取消此批次中所有剩餘的待處理項目?',
-    batch: '批次',
+    batch: {
+      defaultName: '批次',
+      label: '{{count}} 項',
+      label_plural: '{{count}} 項',
+      pendingCount: '{{count}} 待處理',
+      pendingCount_plural: '{{count}} 待處理',
+      expand: '展開批次',
+      collapse: '收合批次',
+      groupAsBatch: '組合為批次…',
+      groupAsBatchDescription: '將選取的 {{count}} 項組合為一個可收合的批次。',
+      nameLabel: '批次名稱',
+      namePlaceholder: '例如:週五禮物',
+      create: '建立批次',
+      ungroup: '取消分組',
+      ungroupConfirmTitle: '取消批次分組?',
+      ungroupConfirmMessage: '項目將保留在佇列中,但不再分組在一起。',
+    },
+    tabs: {
+      queue: '佇列',
+      history: '歷史',
+      timeline: '時間軸',
+    },
+    layout: {
+      flatList: '清單',
+      byPrinter: '依印表機',
+      groupByPrinter: '依印表機分組',
+    },
+    history: {
+      emptyTitle: '目前沒有歷史記錄',
+      emptyDescription: '已完成、已取消與失敗的列印將顯示於此。',
+    },
+    dragGhost: {
+      multiCount: '{{count}} 項',
+    },
     // Sections
     sections: {
       currentlyPrinting: '正在列印',
@@ -1144,6 +1177,10 @@ export default {
       updateFailed: '更新項目失敗',
       bulkCancelled: '已取消 {{count}} 個項目',
       bulkCancelFailed: '批次取消項目失敗',
+      batchCreated: '已建立批次「{{name}}」',
+      batchCreateFailed: '建立批次失敗',
+      batchUngrouped: '已取消分組 {{count}} 項',
+      batchUngroupFailed: '取消批次分組失敗',
     },
     // Timeline view
     timeline: {
@@ -1151,6 +1188,7 @@ export default {
       timelineView: '時間線',
       unassigned: '未分配',
       noData: '當天沒有計畫的列印任務',
+      nothingCommitted: '此時間視窗內沒有已確定的排程。暫存項目、等待項目以及閒置印表機上的 ASAP 任務不會顯示 — 設定排程時間或釋放暫存項目即可在此處查看。',
       allDoneBy: '所有列印預計在 {{time}} 前完成',
       staged: '暫存',
       filterAll: '全部顯示',
@@ -1167,6 +1205,12 @@ export default {
         next: '後一天',
         today: '今天',
       },
+      window: {
+        back12h: '後退 12 小時',
+        forward12h: '前進 12 小時',
+        now: '現在',
+      },
+      printerColumnHeader: '印表機',
     },
     // Permissions
     permissions: {

+ 7 - 0
frontend/src/index.css

@@ -523,3 +523,10 @@ body {
 .calendar-scroll::-webkit-scrollbar-thumb:hover {
   background-color: color-mix(in srgb, var(--text-muted) 80%, transparent);
 }
+
+/* History row thumbnail hover-to-enlarge — desktop only (no hover on touch). */
+@media (hover: hover) and (pointer: fine) {
+  .history-thumb-hover:hover .history-thumb-preview {
+    opacity: 1;
+  }
+}

Разница между файлами не показана из-за своего большого размера
+ 810 - 133
frontend/src/pages/QueuePage.tsx


Некоторые файлы не были показаны из-за большого количества измененных файлов