Browse Source

Add clear plate confirmation between queued prints

The print scheduler previously treated FINISH/FAILED as idle states
and would auto-start the next queued print before the user cleared
the build plate. Now requires explicit user confirmation via a
"Clear Plate & Start Next" button on the printer card. Uses an
in-memory plate-cleared flag — no MQTT command needed since the
scheduler's start_print overrides the printer state.
maziggy 3 months ago
parent
commit
aa87e5598b

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### New Features
 - **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
+- **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
 
 ### Improved
 - **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.

+ 1 - 0
README.md

@@ -103,6 +103,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
+- Clear plate confirmation between queued prints
 - Smart plug integration (Tasmota, Home Assistant, MQTT)
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)

+ 31 - 0
backend/app/api/routes/printers.py

@@ -1821,6 +1821,37 @@ async def stop_print(
     return {"success": True, "message": "Print stop command sent"}
 
 
+@router.post("/{printer_id}/clear-plate")
+async def clear_plate(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Acknowledge that the build plate has been cleared after a finished/failed print.
+
+    Sets a plate-cleared flag so the scheduler can start the next queued print.
+    No MQTT command is sent to the printer — the scheduler's start_print command
+    will override the FINISH/FAILED state when it sends the next job.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    if not printer_manager.is_connected(printer_id):
+        raise HTTPException(400, "Printer not connected")
+
+    state = printer_manager.get_status(printer_id)
+    if not state or state.state not in ("FINISH", "FAILED"):
+        raise HTTPException(
+            400, f"Printer is not in FINISH or FAILED state (current: {state.state if state else 'unknown'})"
+        )
+
+    printer_manager.set_plate_cleared(printer_id)
+
+    return {"success": True, "message": "Plate cleared, next print will start shortly"}
+
+
 @router.post("/{printer_id}/print/pause")
 async def pause_print(
     printer_id: int,

+ 8 - 3
backend/app/services/print_scheduler.py

@@ -694,9 +694,11 @@ class PrintScheduler:
         if not state:
             return False
 
-        # Printer is idle if state is IDLE, FINISH, FAILED, or unknown
-        # FAILED means previous print failed, printer is ready for new print
-        return state.state in ("IDLE", "FINISH", "FAILED", "unknown")
+        # IDLE = ready for next print
+        # FINISH/FAILED = ready only if user confirmed plate is cleared
+        return state.state == "IDLE" or (
+            state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
+        )
 
     async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
         """Get the smart plug associated with a printer."""
@@ -1034,6 +1036,9 @@ class PrintScheduler:
         item.status = "printing"
         item.started_at = datetime.utcnow()
         await db.commit()
+
+        # Consume the plate-cleared flag now that we're starting a print
+        printer_manager.consume_plate_cleared(item.printer_id)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
 
         # Start the print with AMS mapping, plate_id and print options

+ 14 - 0
backend/app/services/printer_manager.py

@@ -100,6 +100,8 @@ class PrinterManager:
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
+        # Track plate-cleared acknowledgments for queue flow
+        self._plate_cleared: set[int] = set()  # printer_ids where user confirmed plate is cleared
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
         """Get printer info by ID."""
@@ -117,6 +119,18 @@ class PrinterManager:
         """Clear the current print user when print completes (Issue #206)."""
         self._current_print_user.pop(printer_id, None)
 
+    def set_plate_cleared(self, printer_id: int):
+        """Mark that user has cleared the build plate for this printer."""
+        self._plate_cleared.add(printer_id)
+
+    def is_plate_cleared(self, printer_id: int) -> bool:
+        """Check if user has confirmed the plate is cleared."""
+        return printer_id in self._plate_cleared
+
+    def consume_plate_cleared(self, printer_id: int):
+        """Clear the plate-cleared flag (called when scheduler starts next print)."""
+        self._plate_cleared.discard(printer_id)
+
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""
         self._loop = loop

+ 122 - 0
backend/tests/unit/test_scheduler_clear_plate.py

@@ -0,0 +1,122 @@
+"""Tests for the clear plate queue flow in the print scheduler."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.services.printer_manager import PrinterManager
+
+
+class TestPrinterManagerPlateCleared:
+    """Test the plate-cleared flag management in PrinterManager."""
+
+    @pytest.fixture
+    def manager(self):
+        return PrinterManager()
+
+    def test_plate_cleared_initially_false(self, manager):
+        """No printers should have plate cleared by default."""
+        assert not manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(999)
+
+    def test_set_plate_cleared(self, manager):
+        """Setting plate cleared should make is_plate_cleared return True."""
+        manager.set_plate_cleared(1)
+        assert manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(2)
+
+    def test_consume_plate_cleared(self, manager):
+        """Consuming plate cleared should reset the flag."""
+        manager.set_plate_cleared(1)
+        assert manager.is_plate_cleared(1)
+        manager.consume_plate_cleared(1)
+        assert not manager.is_plate_cleared(1)
+
+    def test_consume_plate_cleared_idempotent(self, manager):
+        """Consuming when not set should not raise."""
+        manager.consume_plate_cleared(1)  # Should not raise
+        assert not manager.is_plate_cleared(1)
+
+    def test_set_plate_cleared_multiple_printers(self, manager):
+        """Plate cleared should be tracked per printer."""
+        manager.set_plate_cleared(1)
+        manager.set_plate_cleared(3)
+        assert manager.is_plate_cleared(1)
+        assert not manager.is_plate_cleared(2)
+        assert manager.is_plate_cleared(3)
+
+    def test_consume_only_affects_target_printer(self, manager):
+        """Consuming plate cleared for one printer should not affect others."""
+        manager.set_plate_cleared(1)
+        manager.set_plate_cleared(2)
+        manager.consume_plate_cleared(1)
+        assert not manager.is_plate_cleared(1)
+        assert manager.is_plate_cleared(2)
+
+
+class TestSchedulerIdleCheckWithPlateCleared:
+    """Test _is_printer_idle with plate-cleared flag interactions."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_idle_state_is_idle(self, mock_pm, scheduler):
+        """Printer in IDLE state should be considered idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="IDLE")
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_running_state_not_idle(self, mock_pm, scheduler):
+        """Printer in RUNNING state should not be idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FINISH state should NOT be idle without plate cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_finish_state_idle_with_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FINISH state should be idle when plate is cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_plate_cleared.return_value = True
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_failed_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FAILED state should NOT be idle without plate cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FAILED")
+        mock_pm.is_plate_cleared.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_failed_state_idle_with_plate_cleared(self, mock_pm, scheduler):
+        """Printer in FAILED state should be idle when plate is cleared."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FAILED")
+        mock_pm.is_plate_cleared.return_value = True
+        assert scheduler._is_printer_idle(1) is True
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_disconnected_printer_not_idle(self, mock_pm, scheduler):
+        """Disconnected printer should never be idle."""
+        mock_pm.is_connected.return_value = False
+        assert scheduler._is_printer_idle(1) is False
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_status_not_idle(self, mock_pm, scheduler):
+        """Printer with no status should not be idle."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = None
+        assert scheduler._is_printer_idle(1) is False

+ 177 - 0
frontend/src/__tests__/components/PrinterQueueWidgetClearPlate.test.tsx

@@ -0,0 +1,177 @@
+/**
+ * Tests for the PrinterQueueWidget clear plate behavior.
+ *
+ * When the printer is in FINISH or FAILED state and has pending queue items,
+ * the widget shows a "Clear Plate & Start Next" button instead of the
+ * passive queue link. After clicking, it shows a confirmation state.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrinterQueueWidget } from '../../components/PrinterQueueWidget';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockQueueItems = [
+  {
+    id: 1,
+    printer_id: 1,
+    archive_id: 1,
+    position: 1,
+    status: 'pending',
+    archive_name: 'First Print',
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 3600,
+    scheduled_time: null,
+  },
+  {
+    id: 2,
+    printer_id: 1,
+    archive_id: 2,
+    position: 2,
+    status: 'pending',
+    archive_name: 'Second Print',
+    printer_name: 'X1 Carbon',
+    print_time_seconds: 7200,
+    scheduled_time: null,
+  },
+];
+
+describe('PrinterQueueWidget - Clear Plate', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/queue/', ({ request }) => {
+        const url = new URL(request.url);
+        const printerId = url.searchParams.get('printer_id');
+        if (printerId === '1') {
+          return HttpResponse.json(mockQueueItems);
+        }
+        return HttpResponse.json([]);
+      }),
+      http.post('/api/v1/printers/:id/clear-plate', () => {
+        return HttpResponse.json({ success: true, message: 'Plate cleared' });
+      })
+    );
+  });
+
+  describe('clear plate button visibility', () => {
+    it('shows clear plate button when printer state is FINISH', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows clear plate button when printer state is FAILED', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+
+    it('shows passive link when printer state is IDLE', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="IDLE" />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+
+      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
+    });
+
+    it('shows passive link when printer state is RUNNING', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="RUNNING" />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+    });
+
+    it('shows passive link when printerState is not provided', async () => {
+      render(<PrinterQueueWidget printerId={1} />);
+
+      await waitFor(() => {
+        const link = screen.getByRole('link');
+        expect(link).toHaveAttribute('href', '/queue');
+      });
+    });
+  });
+
+  describe('clear plate button shows queue info', () => {
+    it('shows next item name in clear plate mode', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('First Print')).toBeInTheDocument();
+      });
+    });
+
+    it('shows additional items badge in clear plate mode', async () => {
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('+1')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('clear plate action', () => {
+    it('shows confirmation state after clicking clear plate', async () => {
+      const user = userEvent.setup();
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Clear Plate & Start Next'));
+
+      await waitFor(() => {
+        // Both the widget confirmation and the toast show this text
+        const elements = screen.getAllByText('Plate cleared — ready for next print');
+        expect(elements.length).toBeGreaterThanOrEqual(1);
+      });
+    });
+
+    it('shows error toast on API failure', async () => {
+      server.use(
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          return HttpResponse.json(
+            { detail: 'Printer not connected' },
+            { status: 400 }
+          );
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Clear Plate & Start Next'));
+
+      // Button should remain visible (not transition to success state)
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('empty queue', () => {
+    it('renders nothing in FINISH state with no queue items', async () => {
+      const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(container.querySelector('button')).not.toBeInTheDocument();
+      });
+    });
+  });
+});

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

@@ -2170,6 +2170,10 @@ export const api = {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
       method: 'POST',
     }),
+  clearPlate: (printerId: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {
+      method: 'POST',
+    }),
 
   // Get current print user (for reprint tracking - Issue #206)
   getCurrentPrintUser: (printerId: number) =>

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

@@ -375,7 +375,7 @@ export function Layout() {
           break;
       }
     }
-  }, [navigate, orderedSidebarIds, navItemsMap]);
+  }, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
 
   useEffect(() => {
     document.addEventListener('keydown', handleKeyDown);

+ 66 - 4
frontend/src/components/PrinterQueueWidget.tsx

@@ -1,11 +1,15 @@
-import { useQuery } from '@tanstack/react-query';
-import { Clock, Calendar, ChevronRight } from 'lucide-react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Clock, Calendar, ChevronRight, Loader2, CircleCheck } from 'lucide-react';
 import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
 import { parseUTCDate } from '../utils/date';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
+  printerState?: string | null;
 }
 
 function formatRelativeTime(dateString: string | null): string {
@@ -22,13 +26,29 @@ function formatRelativeTime(dateString: string | null): string {
   return date.toLocaleDateString();
 }
 
-export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidgetProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending'],
     queryFn: () => api.getQueue(printerId, 'pending'),
     refetchInterval: 30000,
   });
 
+  const clearPlateMutation = useMutation({
+    mutationFn: () => api.clearPlate(printerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue', printerId] });
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
+      showToast(t('queue.clearPlateSuccess'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message, 'error');
+    },
+  });
+
   const nextItem = queue?.[0];
   const totalPending = queue?.length || 0;
 
@@ -36,6 +56,48 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
     return null;
   }
 
+  const needsClearPlate = printerState === 'FINISH' || printerState === 'FAILED';
+
+  if (needsClearPlate) {
+    return (
+      <div className="mb-3 p-3 bg-bambu-dark rounded-lg border border-yellow-400/30">
+        <div className="flex items-center gap-3 mb-2">
+          <Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
+          <div className="min-w-0 flex-1">
+            <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
+            <p className="text-sm text-white truncate">
+              {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
+            </p>
+          </div>
+          {totalPending > 1 && (
+            <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded flex-shrink-0">
+              +{totalPending - 1}
+            </span>
+          )}
+        </div>
+        {clearPlateMutation.isSuccess ? (
+          <div className="w-full py-2 px-3 rounded-lg bg-bambu-green/10 border border-bambu-green/20 text-bambu-green text-sm flex items-center justify-center gap-2">
+            <CircleCheck className="w-4 h-4" />
+            {t('queue.plateReady')}
+          </div>
+        ) : (
+          <button
+            onClick={() => clearPlateMutation.mutate()}
+            disabled={clearPlateMutation.isPending || !hasPermission('printers:control')}
+            className="w-full py-2 px-3 rounded-lg bg-bambu-green/20 border border-bambu-green/40 text-bambu-green hover:bg-bambu-green/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50"
+          >
+            {clearPlateMutation.isPending ? (
+              <Loader2 className="w-4 h-4 animate-spin" />
+            ) : (
+              <CircleCheck className="w-4 h-4" />
+            )}
+            {t('queue.clearPlate')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
   return (
     <Link
       to="/queue"
@@ -45,7 +107,7 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
         <div className="flex items-center gap-3 min-w-0 flex-1">
           <Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
           <div className="min-w-0 flex-1">
-            <p className="text-xs text-bambu-gray">Next in queue</p>
+            <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-sm text-white truncate">
               {nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
             </p>

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

@@ -697,6 +697,10 @@ export default {
     dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',
     reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',
     addedBy: 'Hinzugefügt von {{name}}',
+    nextInQueue: 'Nächster in der Warteschlange',
+    clearPlate: 'Druckplatte freigeben & Nächsten starten',
+    clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
+    plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
     // Sections
     sections: {
       currentlyPrinting: 'Aktuell druckend',

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

@@ -697,6 +697,10 @@ export default {
     dragToReorder: 'Drag to reorder (ASAP only)',
     reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',
     addedBy: 'Added by {{name}}',
+    nextInQueue: 'Next in queue',
+    clearPlate: 'Clear Plate & Start Next',
+    clearPlateSuccess: 'Plate cleared — ready for next print',
+    plateReady: 'Plate cleared — ready for next print',
     // Sections
     sections: {
       currentlyPrinting: 'Currently Printing',

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

@@ -769,6 +769,10 @@ export default {
     itemCount: '{{count}}件',
     dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
     addedBy: '{{username}}が追加',
+    nextInQueue: '次のキュー',
+    clearPlate: 'プレートをクリアして次を開始',
+    clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
+    plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     sections: {
       currentlyPrinting: '印刷中',
       queued: 'キュー中',

+ 2 - 4
frontend/src/pages/PrintersPage.tsx

@@ -2399,10 +2399,8 @@ function PrinterCard({
                   </div>
                 </div>
 
-                {/* Queue Widget - shows next scheduled print */}
-                {status.state !== 'RUNNING' && (
-                  <PrinterQueueWidget printerId={printer.id} />
-                )}
+                {/* Queue Widget - always visible when there are pending items */}
+                <PrinterQueueWidget printerId={printer.id} printerState={status.state} />
               </>
             )}
 

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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CK9fZPad.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-COjr2ipw.css


+ 2 - 2
static/index.html

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

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