Browse Source

feat(spoolbuddy): plate-clear pills on kiosk dashboard + toast UX polish

  Adds a finger-friendly amber pill row under the printer status badges
  on the SpoolBuddy kiosk dashboard. When any printer reports
  awaiting_plate_clear=true, a compact pill appears showing the printer
  name plus a "Clear" action; tapping it calls POST /printers/{id}/clear-plate
  and optimistically removes the pill before the WebSocket round-trip
  lands. Multiple pending printers wrap inline via flex-wrap so the
  dashboard stays compact when several finish at once. Pill dimensions
  match the existing online/offline printer badges (px-2.5 py-1, text-xs).

  The kiosk auth path (X-API-Key) already passes the printers:clear_plate
  permission gate via the existing _APIKEY_DENIED_PERMISSIONS denylist
  (the permission is intentionally not denied — clear-plate is an
  inventory-flow operation, not an admin one), so no auth wiring changes
  were needed.

  Two adjacent fixes shipped in the same change:

  - SpoolBuddyLayout suppresses the global toast viewport while mounted.
    The global ToastProvider in App.tsx wraps both the main app and the
    kiosk routes, which meant the background-dispatch progress overlay
    was rendering on the kiosk display alongside any in-flight prints.
    Added setViewportSuppressed(bool) on the toast context; the layout
    flips it via useEffect and restores on unmount. State machine and
    dispatch-event subscription are untouched — only the visible
    viewport is hidden.

  - Dispatch toast no longer reads as "frozen at 100%" for fast uploads.
    Small files complete FTP in <500ms but the bar would sit at 100%
    while the printer's MQTT confirmation landed. When uploadProgressPct
    >= 99.9 and status is still 'processing', the byte counter is
    replaced with "Awaiting printer..." and the bar gets animate-pulse.
maziggy 1 month ago
parent
commit
d92f48e3c6

File diff suppressed because it is too large
+ 3 - 0
CHANGELOG.md


+ 44 - 12
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -9,6 +9,7 @@ import React from 'react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { SpoolBuddyLayout } from '../../../components/spoolbuddy/SpoolBuddyLayout';
 import { SpoolBuddyLayout } from '../../../components/spoolbuddy/SpoolBuddyLayout';
+import { ToastProvider } from '../../../contexts/ToastContext';
 
 
 vi.mock('react-i18next', () => ({
 vi.mock('react-i18next', () => ({
   useTranslation: () => ({
   useTranslation: () => ({
@@ -32,9 +33,21 @@ vi.mock('../../../utils/date', () => ({
   formatTimeOnly: () => '12:00',
   formatTimeOnly: () => '12:00',
 }));
 }));
 
 
-vi.mock('lucide-react', () => ({
-  WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
-}));
+vi.mock('lucide-react', () => {
+  const Stub = (props: Record<string, unknown>) => <span {...props} />;
+  return {
+    WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
+    // ToastProvider, brought in by SpoolBuddyLayout's useToast(), imports these.
+    AlertCircle: Stub,
+    CheckCircle: Stub,
+    ChevronDown: Stub,
+    ChevronUp: Stub,
+    Info: Stub,
+    Loader2: Stub,
+    X: Stub,
+    XCircle: Stub,
+  };
+});
 
 
 vi.mock('../../../components/VirtualKeyboard', () => ({
 vi.mock('../../../components/VirtualKeyboard', () => ({
   VirtualKeyboard: () => <div data-testid="virtual-keyboard" />,
   VirtualKeyboard: () => <div data-testid="virtual-keyboard" />,
@@ -43,15 +56,17 @@ vi.mock('../../../components/VirtualKeyboard', () => ({
 function renderLayout() {
 function renderLayout() {
   const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
   const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
   return render(
   return render(
-    <QueryClientProvider client={qc}>
-      <MemoryRouter initialEntries={['/spoolbuddy']}>
-        <Routes>
-          <Route path="spoolbuddy" element={<SpoolBuddyLayout />}>
-            <Route index element={<div data-testid="child-page">Child</div>} />
-          </Route>
-        </Routes>
-      </MemoryRouter>
-    </QueryClientProvider>
+    <ToastProvider>
+      <QueryClientProvider client={qc}>
+        <MemoryRouter initialEntries={['/spoolbuddy']}>
+          <Routes>
+            <Route path="spoolbuddy" element={<SpoolBuddyLayout />}>
+              <Route index element={<div data-testid="child-page">Child</div>} />
+            </Route>
+          </Routes>
+        </MemoryRouter>
+      </QueryClientProvider>
+    </ToastProvider>
   );
   );
 }
 }
 
 
@@ -86,4 +101,21 @@ describe('SpoolBuddyLayout', () => {
     const child = document.querySelector('[data-testid="child-page"]');
     const child = document.querySelector('[data-testid="child-page"]');
     expect(child).not.toBeNull();
     expect(child).not.toBeNull();
   });
   });
+
+  it('suppresses the global toast viewport while mounted', () => {
+    const { unmount } = renderLayout();
+    // Visible viewport gets `hidden` class while the kiosk is up.
+    const viewport = document.querySelector('div.fixed.bottom-4.right-20');
+    expect(viewport?.className).toContain('hidden');
+
+    // Cleanup restores the viewport when the kiosk unmounts (e.g. user
+    // navigates back to the main app).
+    unmount();
+    const viewportAfter = document.querySelector('div.fixed.bottom-4.right-20');
+    // After unmount the toast container is gone with the provider; the
+    // important guarantee is the suppression flag was untoggled, which the
+    // ToastContext tests pin directly. Here we only assert no crash on
+    // unmount during cleanup.
+    expect(viewportAfter).toBeNull();
+  });
 });
 });

+ 128 - 1
frontend/src/__tests__/contexts/ToastContext.test.tsx

@@ -13,7 +13,7 @@
  */
  */
 
 
 import { describe, it, expect, beforeEach, vi } from 'vitest';
 import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { act, renderHook } from '@testing-library/react';
+import { act, render, renderHook } from '@testing-library/react';
 import { type ReactNode } from 'react';
 import { type ReactNode } from 'react';
 import { ToastProvider, useToast } from '../../contexts/ToastContext';
 import { ToastProvider, useToast } from '../../contexts/ToastContext';
 
 
@@ -94,3 +94,130 @@ describe('ToastContext post-unmount safety', () => {
     vi.useRealTimers();
     vi.useRealTimers();
   });
   });
 });
 });
+
+describe('ToastContext background dispatch — upload-done UX', () => {
+  // Small fast files reach 100% upload before the printer's MQTT confirmation
+  // arrives, leaving the bar parked at 100% for what feels like "stuck". When
+  // status is still 'processing' but uploadProgressPct >= 99.9 the byte-count
+  // line should switch to "Awaiting printer..." and the bar gets a pulse.
+  function dispatchBackgroundEvent(detail: Record<string, unknown>) {
+    window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
+  }
+
+  it('shows "Awaiting printer..." once upload is complete but printer has not confirmed', () => {
+    const { container } = render(
+      <ToastProvider>
+        <div />
+      </ToastProvider>
+    );
+
+    act(() => {
+      dispatchBackgroundEvent({
+        total: 1,
+        dispatched: 0,
+        processing: 1,
+        completed: 0,
+        failed: 0,
+        active_jobs: [
+          {
+            job_id: 42,
+            printer_name: 'X1C-2',
+            source_name: 'Benchy.3mf',
+            upload_bytes: 102400,
+            upload_total_bytes: 102400,
+            upload_progress_pct: 100.0,
+          },
+        ],
+      });
+    });
+
+    // The byte-count line should be replaced with the awaiting-printer text.
+    expect(container.textContent).toContain('Awaiting printer');
+    // And the original bytes-progressed format must not be visible at the
+    // same time — that is the "stuck at 100%" symptom we are fixing.
+    expect(container.textContent).not.toContain('100.0%');
+
+    // Bar gets the pulse class when in this state.
+    const bar = container.querySelector('.animate-pulse');
+    expect(bar).not.toBeNull();
+  });
+
+  it('still shows the byte/percent counter while upload is mid-flight', () => {
+    const { container } = render(
+      <ToastProvider>
+        <div />
+      </ToastProvider>
+    );
+
+    act(() => {
+      dispatchBackgroundEvent({
+        total: 1,
+        dispatched: 0,
+        processing: 1,
+        completed: 0,
+        failed: 0,
+        active_jobs: [
+          {
+            job_id: 7,
+            printer_name: 'X1C-2',
+            source_name: 'Benchy.3mf',
+            upload_bytes: 51200,
+            upload_total_bytes: 102400,
+            upload_progress_pct: 50.0,
+          },
+        ],
+      });
+    });
+
+    expect(container.textContent).toContain('50.0%');
+    expect(container.textContent).not.toContain('Awaiting printer');
+    expect(container.querySelector('.animate-pulse')).toBeNull();
+  });
+});
+
+describe('ToastContext viewport suppression', () => {
+  // The kiosk layout flips setViewportSuppressed(true) on mount so the
+  // SpoolBuddy display stays free of main-app toasts (background dispatch
+  // progress, login flows, etc.). Verify the gate hides the visible viewport
+  // without affecting the underlying state machine.
+  function ViewportProbe() {
+    const { showToast, setViewportSuppressed } = useToast();
+    return (
+      <>
+        <button data-testid="show-toast" onClick={() => showToast('hello', 'success')} />
+        <button data-testid="suppress-on" onClick={() => setViewportSuppressed(true)} />
+        <button data-testid="suppress-off" onClick={() => setViewportSuppressed(false)} />
+      </>
+    );
+  }
+
+  it('hides the visible toast viewport when suppressed but keeps state alive', () => {
+    const { container, getByTestId } = render(
+      <ToastProvider>
+        <ViewportProbe />
+      </ToastProvider>
+    );
+
+    // Toast viewport is the fixed-position container with bottom-4 right-20.
+    const findViewport = () => container.querySelector('div.fixed.bottom-4.right-20');
+    expect(findViewport()?.className).not.toContain('hidden');
+
+    act(() => {
+      getByTestId('suppress-on').click();
+    });
+    expect(findViewport()?.className).toContain('hidden');
+
+    // State is unaffected — emitting a toast while suppressed is fine; the
+    // state container exists, just hidden.
+    act(() => {
+      getByTestId('show-toast').click();
+    });
+    expect(findViewport()?.className).toContain('hidden');
+
+    // Restore on unmount of the kiosk layout (or via the setter directly).
+    act(() => {
+      getByTestId('suppress-off').click();
+    });
+    expect(findViewport()?.className).not.toContain('hidden');
+  });
+});

+ 110 - 2
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -7,7 +7,7 @@
  */
  */
 
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
 import React from 'react';
 import React from 'react';
 import { render } from '@testing-library/react';
 import { render } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -26,6 +26,7 @@ vi.mock('../../api/client', () => ({
     getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
     getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
     linkTagToSpool: vi.fn().mockResolvedValue({}),
     linkTagToSpool: vi.fn().mockResolvedValue({}),
     createSpool: vi.fn().mockResolvedValue({ id: 4 }),
     createSpool: vi.fn().mockResolvedValue({ id: 4 }),
+    clearPlate: vi.fn().mockResolvedValue({}),
   },
   },
   spoolbuddyApi: {
   spoolbuddyApi: {
     getDevices: vi.fn().mockResolvedValue([]),
     getDevices: vi.fn().mockResolvedValue([]),
@@ -34,7 +35,12 @@ vi.mock('../../api/client', () => ({
 
 
 vi.mock('react-i18next', () => ({
 vi.mock('react-i18next', () => ({
   useTranslation: () => ({
   useTranslation: () => ({
-    t: (_key: string, fallback: string) => fallback,
+    // Mirrors i18next's (key, defaultValue, options) signature with simple
+    // {{var}} interpolation so tests can assert on the rendered text.
+    t: (_key: string, fallback: string, options?: Record<string, unknown>) => {
+      if (!options) return fallback;
+      return fallback.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
+    },
     i18n: { language: 'en', changeLanguage: vi.fn() },
     i18n: { language: 'en', changeLanguage: vi.fn() },
   }),
   }),
 }));
 }));
@@ -137,4 +143,106 @@ describe('SpoolBuddyDashboard', () => {
       expect(screen.getByText('Current Spool')).toBeDefined();
       expect(screen.getByText('Current Spool')).toBeDefined();
     });
     });
   });
   });
+
+  describe('plate-clear row', () => {
+    // We re-mock api.getPrinters / getPrinterStatus per test so each scenario
+    // controls exactly which printers report awaiting_plate_clear.
+    it('does not render the plate-clear button when no printer needs it', async () => {
+      const { api } = await import('../../api/client');
+      (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
+        { id: 1, name: 'X1C' },
+      ]);
+      (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
+        connected: true,
+        awaiting_plate_clear: false,
+      });
+      renderPage();
+      await waitFor(() => {
+        expect(screen.getByText('X1C')).toBeDefined();
+      });
+      expect(screen.queryByTestId('plate-clear-section')).toBeNull();
+    });
+
+    it('renders a plate-clear pill only for printers with awaiting_plate_clear=true', async () => {
+      const { api } = await import('../../api/client');
+      (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
+        { id: 1, name: 'X1C' },
+        { id: 2, name: 'P1S' },
+      ]);
+      (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockImplementation((printerId: number) =>
+        Promise.resolve({
+          connected: true,
+          awaiting_plate_clear: printerId === 2,
+        })
+      );
+      renderPage();
+      await waitFor(() => {
+        expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
+      });
+      expect(screen.queryByTestId('plate-clear-button-1')).toBeNull();
+      // Pill content: printer name + "Clear" label, plus full "Plate ready: P1S" in title attr.
+      const pill = screen.getByTestId('plate-clear-button-2');
+      expect(pill.getAttribute('title')).toBe('Plate ready: P1S');
+      expect(pill.textContent).toContain('P1S');
+      expect(pill.textContent).toContain('Clear');
+    });
+
+    it('renders multiple plate-clear pills inline when several printers are pending', async () => {
+      const { api } = await import('../../api/client');
+      (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
+        { id: 1, name: 'A' },
+        { id: 2, name: 'B' },
+        { id: 3, name: 'C' },
+      ]);
+      (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
+        connected: true,
+        awaiting_plate_clear: true,
+      });
+      renderPage();
+      await waitFor(() => {
+        expect(screen.getByTestId('plate-clear-button-1')).toBeDefined();
+        expect(screen.getByTestId('plate-clear-button-2')).toBeDefined();
+        expect(screen.getByTestId('plate-clear-button-3')).toBeDefined();
+      });
+      // Pills sit in the same flex-wrap container so they flow inline.
+      const section = screen.getByTestId('plate-clear-section');
+      expect(section.className).toContain('flex-wrap');
+    });
+
+    it('calls api.clearPlate with the printer id when clicked', async () => {
+      const { api } = await import('../../api/client');
+      (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
+        { id: 7, name: 'H2D' },
+      ]);
+      (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
+        connected: true,
+        awaiting_plate_clear: true,
+      });
+      renderPage();
+      const btn = await waitFor(() => screen.getByTestId('plate-clear-button-7'));
+      fireEvent.click(btn);
+      await waitFor(() => {
+        expect(api.clearPlate).toHaveBeenCalledWith(7);
+      });
+    });
+
+    it('hides the row optimistically after a successful click without a refetch', async () => {
+      const { api } = await import('../../api/client');
+      (api.getPrinters as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
+        { id: 9, name: 'X1E' },
+      ]);
+      // Stable resolve — even if refetch happens it would still report pending,
+      // so a disappearing row proves the optimistic cache write worked.
+      (api.getPrinterStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
+        connected: true,
+        awaiting_plate_clear: true,
+      });
+      renderPage();
+      const btn = await waitFor(() => screen.getByTestId('plate-clear-button-9'));
+      fireEvent.click(btn);
+      await waitFor(() => {
+        expect(screen.queryByTestId('plate-clear-button-9')).toBeNull();
+      });
+    });
+  });
 });
 });

+ 10 - 0
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -10,6 +10,7 @@ import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 import { VirtualKeyboard } from '../VirtualKeyboard';
+import { useToast } from '../../contexts/ToastContext';
 
 
 export function SpoolBuddyLayout() {
 export function SpoolBuddyLayout() {
   // Cascade a re-render into all SpoolBuddy pages when the color catalog
   // Cascade a re-render into all SpoolBuddy pages when the color catalog
@@ -24,6 +25,15 @@ export function SpoolBuddyLayout() {
   const location = useLocation();
   const location = useLocation();
   const sbState = useSpoolBuddyState();
   const sbState = useSpoolBuddyState();
 
 
+  // Hide the global toast viewport (background-dispatch progress, etc.) on the
+  // kiosk display. Restore on unmount so navigating back to the main app sees
+  // its toasts again.
+  const { setViewportSuppressed } = useToast();
+  useEffect(() => {
+    setViewportSuppressed(true);
+    return () => setViewportSuppressed(false);
+  }, [setViewportSuppressed]);
+
   // Sync language from backend settings (kiosk has its own browser with empty localStorage)
   // Sync language from backend settings (kiosk has its own browser with empty localStorage)
   const { data: appSettings } = useQuery({
   const { data: appSettings } = useQuery({
     queryKey: ['settings'],
     queryKey: ['settings'],

+ 34 - 9
frontend/src/contexts/ToastContext.tsx

@@ -42,6 +42,13 @@ interface ToastContextType {
   showToast: (message: string, type?: ToastType) => void;
   showToast: (message: string, type?: ToastType) => void;
   showPersistentToast: ShowPersistentToast;
   showPersistentToast: ShowPersistentToast;
   dismissToast: (id: string) => void;
   dismissToast: (id: string) => void;
+  /**
+   * Suppress the visible toast viewport while keeping the state machine alive.
+   * Used by the SpoolBuddy kiosk layout to keep the kiosk display free of
+   * main-app notifications (background dispatch progress, etc.) without
+   * tearing down the dispatch-job subscription that other tabs rely on.
+   */
+  setViewportSuppressed: (suppressed: boolean) => void;
 }
 }
 
 
 const ToastContext = createContext<ToastContextType | undefined>(undefined);
 const ToastContext = createContext<ToastContextType | undefined>(undefined);
@@ -74,6 +81,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [toasts, setToasts] = useState<Toast[]>([]);
   const [toasts, setToasts] = useState<Toast[]>([]);
   const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
   const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
+  const [viewportSuppressed, setViewportSuppressed] = useState(false);
   const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
   const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
   const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
   const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
   const dispatchToastId = 'background-dispatch';
   const dispatchToastId = 'background-dispatch';
@@ -479,11 +487,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
 
 
 
 
   return (
   return (
-    <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
+    <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
       {children}
       {children}
 
 
-      {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12) */}
-      <div className="fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2">
+      {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
+          The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
+          free of main-app notifications. */}
+      <div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
         {toasts.map((toast) => (
         {toasts.map((toast) => (
           <div
           <div
             key={toast.id}
             key={toast.id}
@@ -540,6 +550,15 @@ export function ToastProvider({ children }: { children: ReactNode }) {
                         failed: 100,
                         failed: 100,
                         cancelled: 100,
                         cancelled: 100,
                       };
                       };
+                      // Upload byte count reached the total — the printer hasn't yet
+                      // confirmed it received the file (state is still 'processing').
+                      // Without distinguishing this we show a frozen 100% bar that
+                      // reads as "stuck" on small files where the upload completed
+                      // in <500ms.
+                      const uploadDoneAwaitingPrinter =
+                        job.status === 'processing' &&
+                        typeof job.uploadProgressPct === 'number' &&
+                        job.uploadProgressPct >= 99.9;
                       const barColorByStatus: Record<DispatchJobStatus, string> = {
                       const barColorByStatus: Record<DispatchJobStatus, string> = {
                         dispatched: 'bg-bambu-gray/60',
                         dispatched: 'bg-bambu-gray/60',
                         processing: 'bg-bambu-green',
                         processing: 'bg-bambu-green',
@@ -579,15 +598,21 @@ export function ToastProvider({ children }: { children: ReactNode }) {
                               {job.message}
                               {job.message}
                             </div>
                             </div>
                           )}
                           )}
-                          {job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
-                            <div className="text-[11px] text-bambu-gray truncate">
-                              {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
-                              {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
-                            </div>
+                          {job.status === 'processing' && (
+                            uploadDoneAwaitingPrinter ? (
+                              <div className="text-[11px] text-bambu-gray truncate">
+                                {t('backgroundDispatch.awaitingPrinter')}
+                              </div>
+                            ) : typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 ? (
+                              <div className="text-[11px] text-bambu-gray truncate">
+                                {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
+                                {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
+                              </div>
+                            ) : null
                           )}
                           )}
                           <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
                           <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
                             <div
                             <div
-                              className={`h-full ${barColorByStatus[job.status]} transition-all duration-300`}
+                              className={`h-full ${barColorByStatus[job.status]} transition-all duration-300 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
                               style={{
                               style={{
                                 width: `${
                                 width: `${
                                   job.status === 'processing' && typeof job.uploadProgressPct === 'number'
                                   job.status === 'processing' && typeof job.uploadProgressPct === 'number'

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

@@ -1100,6 +1100,7 @@ export default {
     cancelDispatchJob: 'Dispatch-Job abbrechen',
     cancelDispatchJob: 'Dispatch-Job abbrechen',
     cancel: 'Abbrechen',
     cancel: 'Abbrechen',
     cancelling: 'Wird abgebrochen…',
     cancelling: 'Wird abgebrochen…',
+    awaitingPrinter: 'Warte auf Drucker…',
     status: {
     status: {
       dispatched: 'Geplant',
       dispatched: 'Geplant',
       processing: 'In Bearbeitung',
       processing: 'In Bearbeitung',
@@ -4885,6 +4886,11 @@ export default {
       spoolSize: 'Spulengröße',
       spoolSize: 'Spulengröße',
       close: 'Schließen',
       close: 'Schließen',
       currentSpool: 'Aktuelle Spule',
       currentSpool: 'Aktuelle Spule',
+      plateReady: 'Druckplatte bereit: {{name}}',
+      plateReadyLabel: 'Bereit-zum-Quittieren-Liste',
+      plateClearAction: 'Frei',
+      plateClearedToast: 'Druckplatte als geleert markiert',
+      plateClearFailed: 'Druckplatte konnte nicht als geleert markiert werden',
     },
     },
     modal: {
     modal: {
       spoolDetected: 'Spule erkannt',
       spoolDetected: 'Spule erkannt',

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

@@ -1100,6 +1100,7 @@ export default {
     cancelDispatchJob: 'Cancel dispatch job',
     cancelDispatchJob: 'Cancel dispatch job',
     cancel: 'Cancel',
     cancel: 'Cancel',
     cancelling: 'Cancelling…',
     cancelling: 'Cancelling…',
+    awaitingPrinter: 'Awaiting printer…',
     status: {
     status: {
       dispatched: 'Dispatched',
       dispatched: 'Dispatched',
       processing: 'Processing',
       processing: 'Processing',
@@ -4894,6 +4895,11 @@ export default {
       spoolSize: 'Spool size',
       spoolSize: 'Spool size',
       close: 'Close',
       close: 'Close',
       currentSpool: 'Current Spool',
       currentSpool: 'Current Spool',
+      plateReady: 'Plate ready: {{name}}',
+      plateReadyLabel: 'Plates ready to clear',
+      plateClearAction: 'Clear',
+      plateClearedToast: 'Plate marked as cleared',
+      plateClearFailed: 'Could not mark plate as cleared',
     },
     },
     modal: {
     modal: {
       spoolDetected: 'Spool Detected',
       spoolDetected: 'Spool Detected',

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

@@ -1093,6 +1093,7 @@ export default {
     cancelDispatchJob: 'Cancel dispatch job',
     cancelDispatchJob: 'Cancel dispatch job',
     cancel: 'Cancel',
     cancel: 'Cancel',
     cancelling: 'Cancelling…',
     cancelling: 'Cancelling…',
+    awaitingPrinter: 'En attente de l\'imprimante…',
     status: {
     status: {
       dispatched: 'Dispatched',
       dispatched: 'Dispatched',
       processing: 'Processing',
       processing: 'Processing',
@@ -4811,6 +4812,11 @@ export default {
       spoolSize: 'Taille bobine',
       spoolSize: 'Taille bobine',
       close: 'Fermer',
       close: 'Fermer',
       currentSpool: 'Bobine actuelle',
       currentSpool: 'Bobine actuelle',
+      plateReady: 'Plateau prêt : {{name}}',
+      plateReadyLabel: 'Plateaux à libérer',
+      plateClearAction: 'Libérer',
+      plateClearedToast: 'Plateau marqué comme libéré',
+      plateClearFailed: 'Impossible de marquer le plateau comme libéré',
     },
     },
     modal: {
     modal: {
       spoolDetected: 'Bobine détectée',
       spoolDetected: 'Bobine détectée',

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

@@ -1093,6 +1093,7 @@ export default {
     cancelDispatchJob: 'Annulla job dispatch',
     cancelDispatchJob: 'Annulla job dispatch',
     cancel: 'Annulla',
     cancel: 'Annulla',
     cancelling: 'Annullamento…',
     cancelling: 'Annullamento…',
+    awaitingPrinter: 'In attesa della stampante…',
     status: {
     status: {
       dispatched: 'Inviato',
       dispatched: 'Inviato',
       processing: 'In elaborazione',
       processing: 'In elaborazione',
@@ -4810,6 +4811,11 @@ export default {
       spoolSize: 'Dimensione bobina',
       spoolSize: 'Dimensione bobina',
       close: 'Chiudi',
       close: 'Chiudi',
       currentSpool: 'Bobina attuale',
       currentSpool: 'Bobina attuale',
+      plateReady: 'Piatto pronto: {{name}}',
+      plateReadyLabel: 'Piatti da liberare',
+      plateClearAction: 'Libera',
+      plateClearedToast: 'Piatto contrassegnato come liberato',
+      plateClearFailed: 'Impossibile contrassegnare il piatto come liberato',
     },
     },
     modal: {
     modal: {
       spoolDetected: 'Bobina rilevata',
       spoolDetected: 'Bobina rilevata',

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

@@ -1092,6 +1092,7 @@ export default {
     cancelDispatchJob: '配信ジョブをキャンセル',
     cancelDispatchJob: '配信ジョブをキャンセル',
     cancel: 'キャンセル',
     cancel: 'キャンセル',
     cancelling: 'キャンセル中…',
     cancelling: 'キャンセル中…',
+    awaitingPrinter: 'プリンターを待機中…',
     status: {
     status: {
       dispatched: '配信済み',
       dispatched: '配信済み',
       processing: '処理中',
       processing: '処理中',
@@ -4849,6 +4850,11 @@ export default {
       spoolSize: 'スプールサイズ',
       spoolSize: 'スプールサイズ',
       close: '閉じる',
       close: '閉じる',
       currentSpool: '現在のスプール',
       currentSpool: '現在のスプール',
+      plateReady: 'プレート準備完了: {{name}}',
+      plateReadyLabel: '片付け待ちプレート',
+      plateClearAction: '片付け',
+      plateClearedToast: 'プレートを片付け済みにしました',
+      plateClearFailed: 'プレートを片付け済みにできませんでした',
     },
     },
     modal: {
     modal: {
       spoolDetected: 'スプール検出',
       spoolDetected: 'スプール検出',

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

@@ -1093,6 +1093,7 @@ export default {
     cancelDispatchJob: 'Cancel dispatch job',
     cancelDispatchJob: 'Cancel dispatch job',
     cancel: 'Cancel',
     cancel: 'Cancel',
     cancelling: 'Cancelling…',
     cancelling: 'Cancelling…',
+    awaitingPrinter: 'Aguardando impressora…',
     status: {
     status: {
       dispatched: 'Dispatched',
       dispatched: 'Dispatched',
       processing: 'Processing',
       processing: 'Processing',
@@ -4824,6 +4825,11 @@ export default {
       spoolSize: 'Tamanho do carretel',
       spoolSize: 'Tamanho do carretel',
       close: 'Fechar',
       close: 'Fechar',
       currentSpool: 'Carretel Atual',
       currentSpool: 'Carretel Atual',
+      plateReady: 'Mesa pronta: {{name}}',
+      plateReadyLabel: 'Mesas para liberar',
+      plateClearAction: 'Liberar',
+      plateClearedToast: 'Mesa marcada como limpa',
+      plateClearFailed: 'Não foi possível marcar a mesa como limpa',
     },
     },
     modal: {
     modal: {
       spoolDetected: 'Carretel Detectado',
       spoolDetected: 'Carretel Detectado',

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

@@ -1100,6 +1100,7 @@ export default {
     cancelDispatchJob: '取消分发任务',
     cancelDispatchJob: '取消分发任务',
     cancel: '取消',
     cancel: '取消',
     cancelling: '取消中…',
     cancelling: '取消中…',
+    awaitingPrinter: '等待打印机…',
     status: {
     status: {
       dispatched: '已分发',
       dispatched: '已分发',
       processing: '处理中',
       processing: '处理中',
@@ -4875,6 +4876,11 @@ export default {
       spoolSize: '耗材盘尺寸',
       spoolSize: '耗材盘尺寸',
       close: '关闭',
       close: '关闭',
       currentSpool: '当前耗材',
       currentSpool: '当前耗材',
+      plateReady: '热床就绪: {{name}}',
+      plateReadyLabel: '待清理的热床',
+      plateClearAction: '清理',
+      plateClearedToast: '已将热床标记为已清理',
+      plateClearFailed: '无法将热床标记为已清理',
     },
     },
     modal: {
     modal: {
       spoolDetected: '检测到耗材',
       spoolDetected: '检测到耗材',

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

@@ -1100,6 +1100,7 @@ export default {
     cancelDispatchJob: '取消分發任務',
     cancelDispatchJob: '取消分發任務',
     cancel: '取消',
     cancel: '取消',
     cancelling: '取消中…',
     cancelling: '取消中…',
+    awaitingPrinter: '等待印表機…',
     status: {
     status: {
       dispatched: '已分發',
       dispatched: '已分發',
       processing: '處理中',
       processing: '處理中',
@@ -4875,6 +4876,11 @@ export default {
       spoolSize: '耗材盤尺寸',
       spoolSize: '耗材盤尺寸',
       close: '關閉',
       close: '關閉',
       currentSpool: '目前耗材',
       currentSpool: '目前耗材',
+      plateReady: '熱床就緒: {{name}}',
+      plateReadyLabel: '待清理的熱床',
+      plateClearAction: '清理',
+      plateClearedToast: '已將熱床標記為已清理',
+      plateClearFailed: '無法將熱床標記為已清理',
     },
     },
     modal: {
     modal: {
       spoolDetected: '偵測到耗材',
       spoolDetected: '偵測到耗材',

+ 66 - 2
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo, useRef } from 'react';
 import { useState, useEffect, useMemo, useRef } from 'react';
 import { useOutletContext } from 'react-router-dom';
 import { useOutletContext } from 'react-router-dom';
-import { useQuery, useQueries } from '@tanstack/react-query';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
 import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
@@ -157,10 +157,40 @@ export function SpoolBuddyDashboard() {
       queryKey: ['printerStatus', printer.id],
       queryKey: ['printerStatus', printer.id],
       queryFn: () => api.getPrinterStatus(printer.id),
       queryFn: () => api.getPrinterStatus(printer.id),
       refetchInterval: 10000,
       refetchInterval: 10000,
-      select: (data: PrinterStatus) => ({ connected: data?.connected }),
+      select: (data: PrinterStatus) => ({
+        connected: data?.connected,
+        awaiting_plate_clear: data?.awaiting_plate_clear === true,
+      }),
     })),
     })),
   });
   });
 
 
+  // Plate-clear: collect printers that are waiting for the operator to confirm.
+  // The kiosk's API key passes the printers:clear_plate gate (not in the
+  // _APIKEY_DENIED_PERMISSIONS set), so no extra perm wiring is needed here.
+  const platesPending = printers
+    .map((printer: Printer, i: number) => ({
+      printer,
+      pending: statusQueries[i]?.data?.awaiting_plate_clear === true,
+    }))
+    .filter((row: { pending: boolean }) => row.pending);
+
+  const queryClient = useQueryClient();
+  const clearPlateMutation = useMutation({
+    mutationFn: (printerId: number) => api.clearPlate(printerId),
+    onSuccess: (_data, printerId) => {
+      // Optimistically clear the flag so the row vanishes immediately; the
+      // backend already broadcasts a printer_status WS event after clearing,
+      // but we don't want the user to see the row linger while that round-trips.
+      queryClient.setQueryData(['printerStatus', printerId], (old: PrinterStatus | undefined) =>
+        old ? { ...old, awaiting_plate_clear: false } : old
+      );
+      showToast(t('spoolbuddy.dashboard.plateClearedToast', 'Plate marked as cleared'), 'success');
+    },
+    onError: () => {
+      showToast(t('spoolbuddy.dashboard.plateClearFailed', 'Could not mark plate as cleared'), 'error');
+    },
+  });
+
   // Current Spool card state - persists until user closes or new tag detected
   // Current Spool card state - persists until user closes or new tag detected
   const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
   const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
   const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
   const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
@@ -389,6 +419,40 @@ export function SpoolBuddyDashboard() {
                   );
                   );
                 })}
                 })}
               </div>
               </div>
+
+              {/* Plate-ready pills — same compact size as the printer badges above so
+                  the row stays scannable when multiple printers finish at once.
+                  Wraps via flex-wrap. Each pill is independently tappable. */}
+              {platesPending.length > 0 && (
+                <div
+                  className="mt-2 flex flex-wrap gap-2"
+                  data-testid="plate-clear-section"
+                  aria-label={t('spoolbuddy.dashboard.plateReadyLabel', 'Plates ready to clear')}
+                >
+                  {platesPending.map(({ printer }: { printer: Printer }) => (
+                    <button
+                      key={printer.id}
+                      type="button"
+                      onClick={() => clearPlateMutation.mutate(printer.id)}
+                      disabled={clearPlateMutation.isPending}
+                      data-testid={`plate-clear-button-${printer.id}`}
+                      title={t('spoolbuddy.dashboard.plateReady', 'Plate ready: {{name}}', { name: printer.name })}
+                      className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 active:bg-amber-500/30 border border-amber-500/30 text-amber-200 transition-colors disabled:opacity-60 disabled:cursor-wait"
+                    >
+                      <svg className="w-3 h-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+                        <path d="M12 9v4" />
+                        <path d="M12 17h.01" />
+                        <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
+                      </svg>
+                      <span className="text-xs truncate max-w-[100px]">{printer.name}</span>
+                      <span className="text-xs opacity-70" aria-hidden="true">·</span>
+                      <span className="text-xs font-medium">
+                        {t('spoolbuddy.dashboard.plateClearAction', 'Clear')}
+                      </span>
+                    </button>
+                  ))}
+                </div>
+              )}
             </div>
             </div>
           )}
           )}
         </div>
         </div>

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


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


+ 2 - 2
static/index.html

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

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