Просмотр исходного кода

Add queue-all-plates and fix clear plate prompt for staged items (#530)

  Multi-plate 3MF files can now be queued in one action via a "Queue All
  Plates" toggle in the plate selector (add-to-queue mode only). Each
  plate becomes a separate queue entry, individually editable.

  Fix: "Clear Plate & Start Next" no longer appears when all pending
  queue items are staged (manual_start). The prompt only shows when
  there are auto-dispatchable items the scheduler will actually start.
maziggy 2 месяцев назад
Родитель
Сommit
6b12c685aa

+ 4 - 0
CHANGELOG.md

@@ -6,6 +6,10 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### New Features
 - **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.
+- **Queue All Plates** ([#530](https://github.com/maziggy/bambuddy/issues/530)) — Multi-plate 3MF files can now be queued in one action. When adding a multi-plate file to the queue, a "Queue All N Plates" toggle appears in the plate selector. When activated, every plate is added as a separate queue entry (one per plate × per selected printer), each individually editable from the queue page. The toggle is only available in add-to-queue mode (not reprint or edit). Requested by @Dendrowen.
+
+### Fixed
+- **Clear Plate Prompt Shown for Staged Queue Items** — The "Clear Plate & Start Next" button on the printer card appeared when all pending queue items were staged (`manual_start`/Queue Only), even though the scheduler won't auto-start them. The clear plate prompt now only appears when there are auto-dispatchable items that the scheduler will actually start after the plate is cleared.
 
 ## [0.2.2b3] - Unreleased
 

+ 122 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -712,4 +712,126 @@ describe('PrintModal', () => {
       });
     });
   });
+
+  describe('queue all plates', () => {
+    const multiPlateResponse = {
+      is_multi_plate: true,
+      plates: [
+        { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['Part A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
+        { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['Part B'], filaments: [{ type: 'PLA', color: '#00FF00' }], print_time_seconds: 2400, filament_used_grams: 60 },
+        { index: 3, name: 'Plate 3', has_thumbnail: false, thumbnail_url: null, objects: ['Part C'], filaments: [{ type: 'PETG', color: '#0000FF' }], print_time_seconds: 3000, filament_used_grams: 70 },
+      ],
+    };
+
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/archives/:id/plates', () => {
+          return HttpResponse.json(multiPlateResponse);
+        }),
+      );
+    });
+
+    it('shows "Queue All" button only in add-to-queue mode', async () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="MultiPlate.3mf"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show "Queue All" button in reprint mode', async () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="MultiPlate.3mf"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Plate 1')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Queue All 3 Plates')).not.toBeInTheDocument();
+    });
+
+    it('highlights all plates when "Queue All" is clicked', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="MultiPlate.3mf"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Queue All 3 Plates'));
+
+      // All plates should show check marks
+      await waitFor(() => {
+        const checks = document.querySelectorAll('.text-bambu-green.flex-shrink-0');
+        expect(checks.length).toBe(3);
+      });
+    });
+
+    it('creates one queue item per plate when submitting with queue-all', async () => {
+      const queueRequests: unknown[] = [];
+      server.use(
+        http.post('/api/v1/queue/', async ({ request }) => {
+          const body = await request.json();
+          queueRequests.push(body);
+          return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="MultiPlate.3mf"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Wait for plates and select a printer
+      await waitFor(() => {
+        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Select printer
+      await user.click(screen.getByText('X1 Carbon'));
+
+      // Click queue all
+      await user.click(screen.getByText('Queue All 3 Plates'));
+
+      // Find the submit button (type="submit") — distinct from the toggle button (type="button")
+      const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
+      await user.click(submitButton);
+
+      await waitFor(() => {
+        expect(queueRequests.length).toBe(3);
+      });
+
+      // Verify each request has the correct plate_id
+      expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
+      expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(2);
+      expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
+    });
+  });
 });

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

@@ -528,4 +528,41 @@ describe('PrinterQueueWidget - Clear Plate', () => {
       });
     });
   });
+
+  describe('staged (manual_start) items', () => {
+    const stagedItems = [
+      { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print 1', manual_start: true, scheduled_time: null },
+      { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Staged Print 2', manual_start: true, scheduled_time: null },
+    ];
+
+    it('does not show clear plate button when all items are staged', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(stagedItems)),
+      );
+
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      // Should show the passive link (not the clear plate button)
+      await waitFor(() => {
+        expect(screen.getByText('Staged Print 1')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
+    });
+
+    it('shows clear plate button when mix of staged and auto-dispatch items', async () => {
+      const mixedItems = [
+        { id: 10, printer_id: 1, archive_id: 1, position: 1, status: 'pending', archive_name: 'Staged Print', manual_start: true, scheduled_time: null },
+        { id: 11, printer_id: 1, archive_id: 2, position: 2, status: 'pending', archive_name: 'Auto Print', manual_start: false, scheduled_time: null },
+      ];
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(mixedItems)),
+      );
+
+      render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
+      });
+    });
+  });
 });

+ 27 - 4
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -1,4 +1,5 @@
 import { Layers, Check, AlertTriangle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import type { PlateSelectorProps } from './types';
 import { formatDuration } from '../../utils/date';
 
@@ -11,7 +12,11 @@ export function PlateSelector({
   isMultiPlate,
   selectedPlate,
   onSelect,
+  queueAll,
+  onQueueAllChange,
 }: PlateSelectorProps) {
+  const { t } = useTranslation();
+
   // Only show for multi-plate files with multiple plates
   if (!isMultiPlate || plates.length <= 1) {
     return null;
@@ -22,21 +27,39 @@ export function PlateSelector({
       <div className="flex items-center gap-2 mb-2">
         <Layers className="w-4 h-4 text-bambu-gray" />
         <span className="text-sm text-bambu-gray">Select Plate to Print</span>
-        {!selectedPlate && (
+        {!selectedPlate && !queueAll && (
           <span className="text-xs text-orange-400 flex items-center gap-1">
             <AlertTriangle className="w-3 h-3" />
             Selection required
           </span>
         )}
+        {onQueueAllChange && (
+          <button
+            type="button"
+            onClick={() => onQueueAllChange(!queueAll)}
+            className={`ml-auto text-xs px-2 py-0.5 rounded-full border transition-colors ${
+              queueAll
+                ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
+            }`}
+          >
+            {t('queue.queueAllPlates', { count: plates.length })}
+          </button>
+        )}
       </div>
       <div className="grid grid-cols-2 gap-2">
         {plates.map((plate) => (
           <button
             key={plate.index}
             type="button"
-            onClick={() => onSelect(plate.index)}
+            onClick={() => {
+              if (queueAll && onQueueAllChange) {
+                onQueueAllChange(false);
+              }
+              onSelect(plate.index);
+            }}
             className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
-              selectedPlate === plate.index
+              queueAll || selectedPlate === plate.index
                 ? 'border-bambu-green bg-bambu-green/10'
                 : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
             }`}
@@ -64,7 +87,7 @@ export function PlateSelector({
                 {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
               </p>
             </div>
-            {selectedPlate === plate.index && (
+            {(queueAll || selectedPlate === plate.index) && (
               <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
             )}
           </button>

+ 113 - 87
frontend/src/components/PrintModal/index.tsx

@@ -74,6 +74,9 @@ export function PrintModal({
     return null;
   });
 
+  // Queue all plates at once (only for add-to-queue mode with multi-plate files)
+  const [queueAllPlates, setQueueAllPlates] = useState(false);
+
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
       return {
@@ -412,8 +415,11 @@ export function PrintModal({
     }
 
     setIsSubmitting(true);
-    // For model-based assignment, we just make one API call
-    const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
+    // Calculate total API calls: plates × printers (or 1 for model-based)
+    const platesToQueue = queueAllPlates ? plates : [null];
+    const totalCount = assignmentMode === 'model'
+      ? platesToQueue.length
+      : selectedPrinters.length * platesToQueue.length;
     setSubmitProgress({ current: 0, total: totalCount });
 
     const results: { success: number; failed: number; errors: string[] } = {
@@ -444,7 +450,7 @@ export function PrintModal({
       : undefined;
 
     // Common queue data for add-to-queue and edit modes
-    const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
+    const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
       target_location: assignmentMode === 'model' ? targetLocation : null,
@@ -456,86 +462,40 @@ export function PrintModal({
       auto_off_after: scheduleOptions.autoOffAfter,
       manual_start: scheduleOptions.scheduleType === 'manual',
       ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
-      plate_id: selectedPlate,
+      plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
         ? new Date(scheduleOptions.scheduledTime).toISOString()
         : undefined,
       ...printOptions,
     });
 
-    // Model-based assignment: single API call
+    // Model-based assignment
     if (assignmentMode === 'model') {
-      setSubmitProgress({ current: 1, total: 1 });
-      try {
-        if (mode === 'reprint') {
-          // Model-based reprint not supported (need specific printer for immediate print)
-          showToast('Model-based assignment only works with queue mode', 'error');
-          setIsSubmitting(false);
-          return;
-        } else if (mode === 'edit-queue-item') {
-          // Edit mode - update with target_model
-          const updateData: PrintQueueItemUpdate = {
-            printer_id: null,
-            target_model: targetModel,
-            target_location: targetLocation,
-            filament_overrides: filamentOverridesArray || null,
-            require_previous_success: scheduleOptions.requirePreviousSuccess,
-            auto_off_after: scheduleOptions.autoOffAfter,
-            manual_start: scheduleOptions.scheduleType === 'manual',
-            ams_mapping: undefined,
-            plate_id: selectedPlate,
-            scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
-              ? new Date(scheduleOptions.scheduledTime).toISOString()
-              : null,
-            ...printOptions,
-          };
-          await updateQueueMutation.mutateAsync(updateData);
-        } else {
-          // Add-to-queue mode with model-based assignment
-          await addToQueueMutation.mutateAsync(getQueueData(null));
-        }
-        results.success++;
-      } catch (error) {
-        results.failed++;
-        results.errors.push((error as Error).message);
+      if (mode === 'reprint') {
+        showToast('Model-based assignment only works with queue mode', 'error');
+        setIsSubmitting(false);
+        return;
       }
-    } else {
-      // Printer-based assignment: loop through selected printers
-      for (let i = 0; i < selectedPrinters.length; i++) {
-        const printerId = selectedPrinters[i];
-        setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
+
+      let progressCounter = 0;
+      for (const plate of platesToQueue) {
+        progressCounter++;
+        setSubmitProgress({ current: progressCounter, total: totalCount });
+        const plateId = plate ? plate.index : selectedPlate;
 
         try {
-          if (mode === 'reprint') {
-            // Reprint mode - start print immediately
-            const printerMapping = getMappingForPrinter(printerId);
-            if (isLibraryFile) {
-              await api.printLibraryFile(libraryFileId!, printerId, {
-                plate_id: selectedPlate ?? undefined,
-                plate_name: selectedPlateName,
-                ams_mapping: printerMapping,
-                ...printOptions,
-              });
-            } else {
-              await api.reprintArchive(archiveId!, printerId, {
-                plate_id: selectedPlate ?? undefined,
-                plate_name: selectedPlateName,
-                ams_mapping: printerMapping,
-                ...printOptions,
-              });
-            }
-          } else if (mode === 'edit-queue-item' && i === 0) {
-            // Edit mode - update the original queue item for the first printer
-            const printerMapping = getMappingForPrinter(printerId);
+          if (mode === 'edit-queue-item' && !plate) {
+            // Edit mode - update with target_model (only for single plate)
             const updateData: PrintQueueItemUpdate = {
-              printer_id: printerId,
-              target_model: null,
-              target_location: null,
+              printer_id: null,
+              target_model: targetModel,
+              target_location: targetLocation,
+              filament_overrides: filamentOverridesArray || null,
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
               manual_start: scheduleOptions.scheduleType === 'manual',
-              ams_mapping: printerMapping,
-              plate_id: selectedPlate,
+              ams_mapping: undefined,
+              plate_id: plateId,
               scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
                 ? new Date(scheduleOptions.scheduledTime).toISOString()
                 : null,
@@ -543,14 +503,76 @@ export function PrintModal({
             };
             await updateQueueMutation.mutateAsync(updateData);
           } else {
-            // Add-to-queue mode OR edit mode with additional printers
-            await addToQueueMutation.mutateAsync(getQueueData(printerId));
+            // Add-to-queue mode with model-based assignment
+            await addToQueueMutation.mutateAsync(getQueueData(null, plateId));
           }
           results.success++;
         } catch (error) {
           results.failed++;
-          const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
-          results.errors.push(`${printerName}: ${(error as Error).message}`);
+          const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';
+          results.errors.push(plateName ? `${plateName}: ${(error as Error).message}` : (error as Error).message);
+        }
+      }
+    } else {
+      // Printer-based assignment: loop through plates × printers
+      let progressCounter = 0;
+      for (const plate of platesToQueue) {
+        const plateId = plate ? plate.index : selectedPlate;
+
+        for (let i = 0; i < selectedPrinters.length; i++) {
+          const printerId = selectedPrinters[i];
+          progressCounter++;
+          setSubmitProgress({ current: progressCounter, total: totalCount });
+
+          try {
+            if (mode === 'reprint') {
+              // Reprint mode - start print immediately (single plate only, queueAllPlates not available)
+              const printerMapping = getMappingForPrinter(printerId);
+              if (isLibraryFile) {
+                await api.printLibraryFile(libraryFileId!, printerId, {
+                  plate_id: selectedPlate ?? undefined,
+                  plate_name: selectedPlateName,
+                  ams_mapping: printerMapping,
+                  ...printOptions,
+                });
+              } else {
+                await api.reprintArchive(archiveId!, printerId, {
+                  plate_id: selectedPlate ?? undefined,
+                  plate_name: selectedPlateName,
+                  ams_mapping: printerMapping,
+                  ...printOptions,
+                });
+              }
+            } else if (mode === 'edit-queue-item' && progressCounter === 1) {
+              // Edit mode - update the original queue item for the first entry
+              const printerMapping = getMappingForPrinter(printerId);
+              const updateData: PrintQueueItemUpdate = {
+                printer_id: printerId,
+                target_model: null,
+                target_location: null,
+                require_previous_success: scheduleOptions.requirePreviousSuccess,
+                auto_off_after: scheduleOptions.autoOffAfter,
+                manual_start: scheduleOptions.scheduleType === 'manual',
+                ams_mapping: printerMapping,
+                plate_id: plateId,
+                scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+                  ? new Date(scheduleOptions.scheduledTime).toISOString()
+                  : null,
+                ...printOptions,
+              };
+              await updateQueueMutation.mutateAsync(updateData);
+            } else {
+              // Add-to-queue mode OR edit mode with additional entries
+              await addToQueueMutation.mutateAsync(getQueueData(printerId, plateId));
+            }
+            results.success++;
+          } catch (error) {
+            results.failed++;
+            const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
+            const plateName = plate ? (plate.name || `Plate ${plate.index}`) : '';
+            const label = plateName ? `${printerName} (${plateName})` : printerName;
+            results.errors.push(`${label}: ${(error as Error).message}`);
+          }
         }
       }
     }
@@ -560,16 +582,12 @@ export function PrintModal({
     // Show result toast (skip for reprint mode — the dispatch toast handles it)
     if (results.failed === 0) {
       if (mode !== 'reprint') {
-        if (assignmentMode === 'model') {
-          showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
+        if (mode === 'edit-queue-item') {
+          showToast('Queue item updated');
+        } else if (results.success === 1) {
+          showToast(assignmentMode === 'model' ? `Queued for any ${targetModel}` : t('queue.printQueued'));
         } else {
-          if (mode === 'edit-queue-item') {
-            showToast('Queue item updated');
-          } else if (results.success === 1) {
-            showToast('Print queued for printer');
-          } else {
-            showToast(`Print queued for ${results.success} printers`);
-          }
+          showToast(t('queue.itemsQueued', { count: results.success }));
         }
       }
       queryClient.invalidateQueries({ queryKey: ['queue'] });
@@ -595,11 +613,11 @@ export function PrintModal({
     // Model-based assignment only works in queue modes (not immediate reprint)
     if (assignmentMode === 'model' && mode === 'reprint') return false;
 
-    // For multi-plate files, need a selected plate
-    if (isMultiPlate && !selectedPlate) return false;
+    // For multi-plate files, need a selected plate (or queue-all)
+    if (isMultiPlate && !selectedPlate && !queueAllPlates) return false;
 
     return true;
-  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending]);
+  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, queueAllPlates, isPending]);
 
   // Modal title and action button text based on mode
   const getModalConfig = () => {
@@ -617,10 +635,16 @@ export function PrintModal({
       };
     }
     if (mode === 'add-to-queue') {
+      let submitText = t('queue.addToQueue');
+      if (queueAllPlates && plates.length > 1) {
+        submitText = t('queue.queueAllPlates', { count: plates.length });
+      } else if (printerCount > 1) {
+        submitText = t('queue.queueToPrinters', { count: printerCount });
+      }
       return {
         title: t('queue.schedulePrint'),
         icon: Calendar,
-        submitText: printerCount > 1 ? t('queue.queueToPrinters', { count: printerCount }) : t('queue.addToQueue'),
+        submitText,
         submitIcon: Calendar,
         loadingText: submitProgress.total > 1
           ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
@@ -647,7 +671,7 @@ export function PrintModal({
   // - Single printer selected
   // - For archives: plate is selected (for multi-plate) or not required (single-plate)
   // - For library files: always show (no plate selection)
-  const showFilamentMapping = effectivePrinterId && (
+  const showFilamentMapping = effectivePrinterId && !queueAllPlates && (
     isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
   );
 
@@ -700,6 +724,8 @@ export function PrintModal({
               isMultiPlate={isMultiPlate}
               selectedPlate={selectedPlate}
               onSelect={setSelectedPlate}
+              queueAll={queueAllPlates}
+              onQueueAllChange={mode === 'add-to-queue' ? setQueueAllPlates : undefined}
             />
 
             {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}

+ 4 - 0
frontend/src/components/PrintModal/types.ts

@@ -150,6 +150,10 @@ export interface PlateSelectorProps {
   isMultiPlate: boolean;
   selectedPlate: number | null;
   onSelect: (plateIndex: number) => void;
+  /** Whether "queue all plates" is active */
+  queueAll?: boolean;
+  /** Callback to toggle queue-all mode (only shown in add-to-queue mode) */
+  onQueueAllChange?: (queueAll: boolean) => void;
 }
 
 /**

+ 8 - 3
frontend/src/components/PrinterQueueWidget.tsx

@@ -60,16 +60,21 @@ export function PrinterQueueWidget({ printerId, printerModel, printerState, plat
     return true;
   });
 
-  const nextItem = compatibleQueue?.[0];
+  // Split into auto-dispatchable vs staged (manual_start) items
+  const autoDispatchQueue = compatibleQueue?.filter(item => !item.manual_start) ?? [];
   const totalPending = compatibleQueue?.length || 0;
 
   if (totalPending === 0) {
     return null;
   }
 
-  const needsClearPlate = (printerState === 'FINISH' || printerState === 'FAILED') && !plateCleared;
+  const nextAutoItem = autoDispatchQueue[0];
+  const nextItem = compatibleQueue?.[0];
+  // Only prompt "Clear Plate & Start Next" when there are auto-dispatchable items
+  const needsClearPlate = (printerState === 'FINISH' || printerState === 'FAILED') && !plateCleared && autoDispatchQueue.length > 0;
 
   if (needsClearPlate) {
+    const displayItem = nextAutoItem || nextItem;
     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">
@@ -77,7 +82,7 @@ export function PrinterQueueWidget({ printerId, printerModel, printerState, plat
           <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 || nextItem?.library_file_name || `File #${nextItem?.archive_id || nextItem?.library_file_id}`}
+              {displayItem?.archive_name || displayItem?.library_file_name || `File #${displayItem?.archive_id || displayItem?.library_file_id}`}
             </p>
           </div>
           {totalPending > 1 && (

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

@@ -757,6 +757,9 @@ export default {
     editQueueItem: 'Warteschlangeneintrag bearbeiten',
     printToPrinters: 'Auf {{count}} Druckern drucken',
     queueToPrinters: 'Zu {{count}} Druckern hinzufügen',
+    queueAllPlates: 'Alle {{count}} Platten in die Warteschlange',
+    printQueued: 'Druck in Warteschlange',
+    itemsQueued: '{{count}} Einträge in Warteschlange',
     sending: 'Wird gesendet...',
     sendingProgress: 'Sende {{current}}/{{total}}...',
     adding: 'Wird hinzugefügt...',

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

@@ -757,6 +757,9 @@ export default {
     editQueueItem: 'Edit Queue Item',
     printToPrinters: 'Print to {{count}} Printers',
     queueToPrinters: 'Queue to {{count}} Printers',
+    queueAllPlates: 'Queue All {{count}} Plates',
+    printQueued: 'Print queued',
+    itemsQueued: '{{count}} items queued',
     sending: 'Sending...',
     sendingProgress: 'Sending {{current}}/{{total}}...',
     adding: 'Adding...',

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

@@ -757,6 +757,9 @@ export default {
     editQueueItem: 'Modifier l\'élément',
     printToPrinters: 'Imprimer sur {{count}} imprimantes',
     queueToPrinters: 'Ajouter à la file pour {{count}} imprimantes',
+    queueAllPlates: 'Ajouter les {{count}} plaques à la file',
+    printQueued: 'Impression ajoutée à la file',
+    itemsQueued: '{{count}} éléments ajoutés à la file',
     sending: 'Envoi...',
     sendingProgress: 'Envoi {{current}}/{{total}}...',
     adding: 'Ajout...',

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

@@ -757,6 +757,9 @@ export default {
     editQueueItem: 'Modifica elemento coda',
     printToPrinters: 'Stampa su {{count}} Stampanti',
     queueToPrinters: 'Metti in coda su {{count}} Stampanti',
+    queueAllPlates: 'Metti in coda tutte le {{count}} piastre',
+    printQueued: 'Stampa in coda',
+    itemsQueued: '{{count}} elementi in coda',
     sending: 'Invio...',
     sendingProgress: 'Invio {{current}}/{{total}}...',
     adding: 'Aggiunta...',

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

@@ -757,6 +757,9 @@ export default {
     editQueueItem: 'キューアイテムを編集',
     printToPrinters: '{{count}}台のプリンターで印刷',
     queueToPrinters: '{{count}}台のプリンターでキュー追加',
+    queueAllPlates: '全{{count}}プレートをキューに追加',
+    printQueued: 'キューに追加しました',
+    itemsQueued: '{{count}}件をキューに追加しました',
     sending: '送信中...',
     sendingProgress: '送信中 {{current}}/{{total}}...',
     adding: '追加中...',

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

@@ -757,6 +757,9 @@ export default {
     editQueueItem: 'Editar Item da Fila',
     printToPrinters: 'Imprimir para {{count}} Impressoras',
     queueToPrinters: 'Adicionar à Fila para {{count}} Impressoras',
+    queueAllPlates: 'Adicionar todas as {{count}} placas à fila',
+    printQueued: 'Impressão adicionada à fila',
+    itemsQueued: '{{count}} itens adicionados à fila',
     sending: 'Enviando...',
     sendingProgress: 'Enviando {{current}}/{{total}}...',
     adding: 'Adicionando...',

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

@@ -757,6 +757,9 @@ export default {
     editQueueItem: '编辑队列项目',
     printToPrinters: '打印到 {{count}} 台打印机',
     queueToPrinters: '排队到 {{count}} 台打印机',
+    queueAllPlates: '将全部 {{count}} 个热床加入队列',
+    printQueued: '已加入打印队列',
+    itemsQueued: '{{count}} 个任务已加入队列',
     sending: '发送中...',
     sendingProgress: '发送中 {{current}}/{{total}}...',
     adding: '添加中...',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C43DmYxN.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DNkwGxkk.js"></script>
+    <script type="module" crossorigin src="/assets/index-C43DmYxN.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DfcIVNpM.css">
   </head>
   <body>

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