Browse Source

Enhance plate-clear tracking and visibility in printer cards (#939)

* implement plate clear button, add plate status indicator, enable hide on setting change

* added plate cleared icon

* added smaller plate cleared button on "small" printers view

* tighten layout slightly

* fix(printers): restore plate-clear card controls
Ed 4 tháng trước cách đây
mục cha
commit
b046c2cac4

+ 195 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -45,6 +45,7 @@ const mockPrinters = [
 const mockPrinterStatus = {
 const mockPrinterStatus = {
   connected: true,
   connected: true,
   state: 'IDLE',
   state: 'IDLE',
+  awaiting_plate_clear: false,
   progress: 0,
   progress: 0,
   layer_num: 0,
   layer_num: 0,
   total_layers: 0,
   total_layers: 0,
@@ -61,6 +62,8 @@ const mockPrinterStatus = {
 
 
 describe('PrintersPage', () => {
 describe('PrintersPage', () => {
   beforeEach(() => {
   beforeEach(() => {
+    localStorage.removeItem('printerCardSize');
+
     server.use(
     server.use(
       http.get('/api/v1/printers/', () => {
       http.get('/api/v1/printers/', () => {
         return HttpResponse.json(mockPrinters);
         return HttpResponse.json(mockPrinters);
@@ -68,6 +71,23 @@ describe('PrintersPage', () => {
       http.get('/api/v1/printers/:id/status', () => {
       http.get('/api/v1/printers/:id/status', () => {
         return HttpResponse.json(mockPrinterStatus);
         return HttpResponse.json(mockPrinterStatus);
       }),
       }),
+      http.post('/api/v1/printers/:id/clear-plate', () => {
+        return HttpResponse.json({ success: true, message: 'Plate cleared' });
+      }),
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json({
+          auto_archive: true,
+          save_thumbnails: true,
+          capture_finish_photo: true,
+          default_filament_cost: 25.0,
+          currency: 'USD',
+          ams_humidity_good: 40,
+          ams_humidity_fair: 60,
+          ams_temp_good: 30,
+          ams_temp_fair: 35,
+          require_plate_clear: true,
+        });
+      }),
       http.get('/api/v1/queue/', () => {
       http.get('/api/v1/queue/', () => {
         return HttpResponse.json([]);
         return HttpResponse.json([]);
       })
       })
@@ -173,6 +193,181 @@ describe('PrintersPage', () => {
       const buttons = screen.getAllByRole('button');
       const buttons = screen.getAllByRole('button');
       expect(buttons.length).toBeGreaterThan(0);
       expect(buttons.length).toBeGreaterThan(0);
     });
     });
+
+    it('shows plate clear status and action on finished printers when not cleared', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
+    });
+
+    it('shows plate clear status and action on failed printers when not cleared', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FAILED', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
+    });
+
+    it('keeps the clear action available when an idle printer is still awaiting acknowledgment', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'IDLE', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.getAllByRole('button', { name: 'Mark plate as cleared' }).length).toBeGreaterThan(0);
+    });
+
+    it('updates the plate clear status after using the printer card action', async () => {
+      let awaitingPlateClear = true;
+
+      server.use(
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([mockPrinters[0]]);
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });
+        }),
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          awaitingPlateClear = false;
+          return HttpResponse.json({ success: true, message: 'Plate cleared' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate not Clear').length).toBeGreaterThan(0);
+      });
+
+      fireEvent.click(screen.getAllByRole('button', { name: 'Mark plate as cleared' })[0]);
+
+      await waitFor(() => {
+        expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();
+      });
+
+      expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);
+    });
+
+    it('shows an icon-only plate clear action in small card view', async () => {
+      let awaitingPlateClear = true;
+
+      server.use(
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([mockPrinters[0]]);
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: awaitingPlateClear });
+        }),
+        http.post('/api/v1/printers/:id/clear-plate', () => {
+          awaitingPlateClear = false;
+          return HttpResponse.json({ success: true, message: 'Plate cleared' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'S' }));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Mark plate as cleared')).not.toBeInTheDocument();
+      });
+
+      const clearButton = screen.getByRole('button', { name: 'Mark plate as cleared' });
+
+      fireEvent.click(clearButton);
+
+      await waitFor(() => {
+        expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows plate clear status but no action while idle', async () => {
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate Clear').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+    });
+
+    it('shows plate in use status while printing and hides the clear action', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'RUNNING', awaiting_plate_clear: false });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Plate in Use').length).toBeGreaterThan(0);
+      });
+
+      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+    });
+
+    it('hides plate status and action when plate-clear confirmation is disabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({
+            auto_archive: true,
+            save_thumbnails: true,
+            capture_finish_photo: true,
+            default_filament_cost: 25.0,
+            currency: 'USD',
+            ams_humidity_good: 40,
+            ams_humidity_fair: 60,
+            ams_temp_good: 30,
+            ams_temp_fair: 35,
+            require_plate_clear: false,
+          });
+        }),
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrinterStatus, state: 'FINISH', awaiting_plate_clear: true });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('Plate not Clear')).not.toBeInTheDocument();
+      expect(screen.queryByText('Plate Clear')).not.toBeInTheDocument();
+      expect(screen.queryByText('Plate in Use')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Mark plate as cleared' })).not.toBeInTheDocument();
+    });
   });
   });
 
 
   describe('disabled printer', () => {
   describe('disabled printer', () => {

+ 51 - 0
frontend/src/components/icons/PlateClearedIcon.tsx

@@ -0,0 +1,51 @@
+interface PlateClearedIconProps {
+  className?: string;
+}
+
+export function PlateClearedIcon({ className = "w-4 h-4" }: PlateClearedIconProps) {
+  return (
+    <svg
+      viewBox="0 0 1945 1370"
+      fill="none"
+      className={className}
+      aria-hidden="true"
+    >
+      <g transform="translate(-754.293 -471.685)">
+        <g transform="translate(0.18191 255.976)">
+          <g transform="matrix(1.05469 0 0 0.241063 -153.484 1120.2)">
+            <rect
+              x="922.048"
+              y="1195.15"
+              width="1721.5"
+              height="470.135"
+              stroke="currentColor"
+              strokeOpacity="0.99"
+              strokeWidth="168.84"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </g>
+          <g transform="matrix(0.983656 0 0 1.0767 -62.2035 141.539)">
+            <path
+              d="M2741.42,1175.93L895.832,1175.93L1125.16,621.902L2512.09,621.902L2741.42,1175.93Z"
+              fill="currentColor"
+              fillOpacity="0.05"
+              stroke="currentColor"
+              strokeOpacity="0.99"
+              strokeWidth="125.26"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            />
+          </g>
+        </g>
+        <g transform="translate(21.1916 0.684817)">
+          <path
+            d="M1981.31,567.518C1954.86,567.518 1933.39,546.047 1933.39,519.601C1933.39,493.156 1954.86,471.685 1981.31,471.685L2146.61,471.685C2173.07,471.685 2194.53,493.138 2194.53,519.601L2194.53,688.741C2194.53,715.187 2173.05,736.658 2146.61,736.658C2120.16,736.658 2098.69,715.187 2098.69,688.741L2098.69,567.518L1981.31,567.518ZM2098.69,1252.54C2098.69,1226.1 2120.16,1204.62 2146.61,1204.62C2173.05,1204.62 2194.53,1226.1 2194.53,1252.54L2194.53,1421.68C2194.53,1448.14 2173.07,1469.6 2146.61,1469.6L1981.31,1469.6C1954.86,1469.6 1933.39,1448.13 1933.39,1421.68C1933.39,1395.24 1954.86,1373.76 1981.31,1373.76L2098.69,1373.76L2098.69,1252.54ZM1430.29,1373.76C1456.74,1373.76 1478.21,1395.24 1478.21,1421.68C1478.21,1448.13 1456.74,1469.6 1430.29,1469.6L1264.99,1469.6C1238.53,1469.6 1217.07,1448.14 1217.07,1421.68L1217.07,1252.54C1217.07,1226.1 1238.55,1204.62 1264.99,1204.62C1291.44,1204.62 1312.91,1226.1 1312.91,1252.54L1312.91,1373.76L1430.29,1373.76ZM1312.91,688.741C1312.91,715.187 1291.44,736.658 1264.99,736.658C1238.55,736.658 1217.07,715.187 1217.07,688.741L1217.07,519.601C1217.07,493.138 1238.53,471.685 1264.99,471.685L1430.29,471.685C1456.74,471.685 1478.21,493.156 1478.21,519.601C1478.21,546.047 1456.74,567.518 1430.29,567.518L1312.91,567.518L1312.91,688.741Z"
+            fill="currentColor"
+            fillOpacity="0.99"
+          />
+        </g>
+      </g>
+    </svg>
+  );
+}

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

@@ -316,6 +316,12 @@ export default {
       connected: 'Verbunden',
       connected: 'Verbunden',
       offline: 'Offline',
       offline: 'Offline',
     },
     },
+    plateStatus: {
+      markCleared: 'Platte als freigegeben markieren',
+      cleared: 'Platte freigegeben',
+      notCleared: 'Platte nicht freigegeben',
+      inUse: 'Platte in Benutzung',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: '{{count}} Druck in Warteschlange',
       inQueue: '{{count}} Druck in Warteschlange',
@@ -1738,7 +1744,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Druckplatte-Bestätigung',
     plateClear: 'Druckplatte-Bestätigung',
     requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
     requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
-    requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatte-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Deaktivieren Sie dies für Farm-Workflows, bei denen die Platten physisch überprüft werden.',
+    requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatten-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Wenn dies deaktiviert ist, werden auch das Druckplatten-Status-Badge und die Schaltfläche "Druckplatte als freigegeben markieren" auf den Druckerkarten ausgeblendet.',
     gcodeInjection: 'G-code Injection',
     gcodeInjection: 'G-code Injection',
     gcodeInjectionDescription: 'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn "G-code einfügen" bei einem Warteschlangen-Element aktiviert ist.',
     gcodeInjectionDescription: 'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn "G-code einfügen" bei einem Warteschlangen-Element aktiviert ist.',
     gcodeInjectionNoPrinters: 'Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.',
     gcodeInjectionNoPrinters: 'Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.',

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

@@ -316,6 +316,12 @@ export default {
       connected: 'Connected',
       connected: 'Connected',
       offline: 'Offline',
       offline: 'Offline',
     },
     },
+    plateStatus: {
+      markCleared: 'Mark plate as cleared',
+      cleared: 'Plate Clear',
+      notCleared: 'Plate not Clear',
+      inUse: 'Plate in Use',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: '{{count}} print in queue',
       inQueue: '{{count}} print in queue',
@@ -1741,7 +1747,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Plate-Clear Confirmation',
     plateClear: 'Plate-Clear Confirmation',
     requirePlateClear: 'Require plate-clear confirmation',
     requirePlateClear: 'Require plate-clear confirmation',
-    requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disable for farm workflows where plates are verified physically.',
+    requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.',
     gcodeInjection: 'G-code Injection',
     gcodeInjection: 'G-code Injection',
     gcodeInjectionDescription: 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.',
     gcodeInjectionDescription: 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.',
     gcodeInjectionNoPrinters: 'No printers found. Add printers to configure G-code snippets.',
     gcodeInjectionNoPrinters: 'No printers found. Add printers to configure G-code snippets.',

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

@@ -316,6 +316,12 @@ export default {
       connected: 'Connecté',
       connected: 'Connecté',
       offline: 'Hors ligne',
       offline: 'Hors ligne',
     },
     },
+    plateStatus: {
+      markCleared: 'Marquer le plateau comme dégagé',
+      cleared: 'Plateau dégagé',
+      notCleared: 'Plateau non dégagé',
+      inUse: 'Plateau en cours d\'utilisation',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: '{{count}} impression en file',
       inQueue: '{{count}} impression en file',
@@ -1687,7 +1693,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Confirmation de plateau libre',
     plateClear: 'Confirmation de plateau libre',
     requirePlateClear: 'Exiger la confirmation de plateau libre',
     requirePlateClear: 'Exiger la confirmation de plateau libre',
-    requirePlateClearDescription: 'Lorsque activé, le planificateur attend la confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. Désactivez pour les workflows de ferme où les plateaux sont vérifiés physiquement.',
+    requirePlateClearDescription: 'Lorsque cette option est activée, le planificateur attend une confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. La désactiver masque également le badge d\'état du plateau et le bouton « Marquer le plateau comme dégagé » sur les cartes d\'imprimante.',
     gcodeInjection: 'Injection de G-code',
     gcodeInjection: 'Injection de G-code',
     gcodeInjectionDescription: 'Configurez du G-code personnalisé à injecter au début et/ou à la fin des impressions pour les systèmes d\'auto-impression comme Farmloop, SwapMod, AutoClear et Printflow 3D. Les snippets sont configurés par modèle d\'imprimante et appliqués lorsque « Injecter le G-code » est activé sur un élément de file d\'attente.',
     gcodeInjectionDescription: 'Configurez du G-code personnalisé à injecter au début et/ou à la fin des impressions pour les systèmes d\'auto-impression comme Farmloop, SwapMod, AutoClear et Printflow 3D. Les snippets sont configurés par modèle d\'imprimante et appliqués lorsque « Injecter le G-code » est activé sur un élément de file d\'attente.',
     gcodeInjectionNoPrinters: 'Aucune imprimante trouvée. Ajoutez des imprimantes pour configurer les snippets G-code.',
     gcodeInjectionNoPrinters: 'Aucune imprimante trouvée. Ajoutez des imprimantes pour configurer les snippets G-code.',

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

@@ -316,6 +316,12 @@ export default {
       connected: 'Connesso',
       connected: 'Connesso',
       offline: 'Offline',
       offline: 'Offline',
     },
     },
+    plateStatus: {
+      markCleared: 'Segna il piatto come liberato',
+      cleared: 'Piatto libero',
+      notCleared: 'Piatto non libero',
+      inUse: 'Piatto in uso',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: '{{count}} stampa in coda',
       inQueue: '{{count}} stampa in coda',
@@ -1687,7 +1693,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Conferma piatto libero',
     plateClear: 'Conferma piatto libero',
     requirePlateClear: 'Richiedi conferma piatto libero',
     requirePlateClear: 'Richiedi conferma piatto libero',
-    requirePlateClearDescription: 'Quando abilitato, lo scheduler attende la conferma per stampante che il piatto è libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitare per flussi di lavoro in farm dove i piatti vengono verificati fisicamente.',
+    requirePlateClearDescription: 'Quando questa opzione è abilitata, lo scheduler attende una conferma per stampante che il piatto sia libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitandola vengono nascosti anche il badge di stato del piatto e il pulsante "Segna il piatto come liberato" sulle schede stampante.',
     gcodeInjection: 'Iniezione G-code',
     gcodeInjection: 'Iniezione G-code',
     gcodeInjectionDescription: 'Configura G-code personalizzato da iniettare all\'inizio e/o alla fine delle stampe per sistemi di stampa automatica come Farmloop, SwapMod, AutoClear e Printflow 3D. Gli snippet sono configurati per modello di stampante e applicati quando "Inietta G-code" è abilitato su un elemento della coda.',
     gcodeInjectionDescription: 'Configura G-code personalizzato da iniettare all\'inizio e/o alla fine delle stampe per sistemi di stampa automatica come Farmloop, SwapMod, AutoClear e Printflow 3D. Gli snippet sono configurati per modello di stampante e applicati quando "Inietta G-code" è abilitato su un elemento della coda.',
     gcodeInjectionNoPrinters: 'Nessuna stampante trovata. Aggiungi stampanti per configurare gli snippet G-code.',
     gcodeInjectionNoPrinters: 'Nessuna stampante trovata. Aggiungi stampanti per configurare gli snippet G-code.',

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

@@ -315,6 +315,12 @@ export default {
       connected: '接続中',
       connected: '接続中',
       offline: 'オフライン',
       offline: 'オフライン',
     },
     },
+    plateStatus: {
+      markCleared: 'プレートをクリア済みにする',
+      cleared: 'プレートクリア済み',
+      notCleared: 'プレート未クリア',
+      inUse: 'プレート使用中',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: 'キュー内',
       inQueue: 'キュー内',
@@ -1712,7 +1718,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'プレートクリア確認',
     plateClear: 'プレートクリア確認',
     requirePlateClear: 'プレートクリア確認を必須にする',
     requirePlateClear: 'プレートクリア確認を必須にする',
-    requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。プレートを物理的に確認するファームワークフローでは無効にしてください。',
+    requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。無効にすると、プリンターカード上のプレート状態バッジと「プレートをクリア済みにする」ボタンも非表示になります。',
     gcodeInjection: 'G-codeインジェクション',
     gcodeInjection: 'G-codeインジェクション',
     gcodeInjectionDescription: 'Farmloop、SwapMod、AutoClear、Printflow 3Dなどの自動印刷システム用に、印刷の開始と終了時にカスタムG-codeを挿入します。スニペットはプリンターモデルごとに設定し、キューアイテム��「G-codeを挿入」を有効にすると適用されます。',
     gcodeInjectionDescription: 'Farmloop、SwapMod、AutoClear、Printflow 3Dなどの自動印刷システム用に、印刷の開始と終了時にカスタムG-codeを挿入します。スニペットはプリンターモデルごとに設定し、キューアイテム��「G-codeを挿入」を有効にすると適用されます。',
     gcodeInjectionNoPrinters: 'プリンターが見つかりません。G-codeスニペットを設定するにはプリンターを追加してください。',
     gcodeInjectionNoPrinters: 'プリンターが見つかりません。G-codeスニペットを設定するにはプリンターを追加してください。',

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

@@ -316,6 +316,12 @@ export default {
       connected: 'Conectado',
       connected: 'Conectado',
       offline: 'Offline',
       offline: 'Offline',
     },
     },
+    plateStatus: {
+      markCleared: 'Marcar placa como liberada',
+      cleared: 'Placa liberada',
+      notCleared: 'Placa não liberada',
+      inUse: 'Placa em uso',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: '{{count}} impressão na fila',
       inQueue: '{{count}} impressão na fila',
@@ -1687,7 +1693,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: 'Confirmação de placa livre',
     plateClear: 'Confirmação de placa livre',
     requirePlateClear: 'Exigir confirmação de placa livre',
     requirePlateClear: 'Exigir confirmação de placa livre',
-    requirePlateClearDescription: 'Quando ativado, o agendador aguarda a confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desative para fluxos de trabalho de fazenda onde as placas são verificadas fisicamente.',
+    requirePlateClearDescription: 'Quando ativado, o agendador aguarda uma confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desativar isso também oculta o indicador de status da placa e o botão "Marcar placa como liberada" nos cartões das impressoras.',
     gcodeInjection: 'Injeção de G-code',
     gcodeInjection: 'Injeção de G-code',
     gcodeInjectionDescription: 'Configure G-code personalizado para injetar no início e/ou no final das impressões para sistemas de impressão automática como Farmloop, SwapMod, AutoClear e Printflow 3D. Os snippets são configurados por modelo de impressora e aplicados quando "Injetar G-code" está ativado em um item da fila.',
     gcodeInjectionDescription: 'Configure G-code personalizado para injetar no início e/ou no final das impressões para sistemas de impressão automática como Farmloop, SwapMod, AutoClear e Printflow 3D. Os snippets são configurados por modelo de impressora e aplicados quando "Injetar G-code" está ativado em um item da fila.',
     gcodeInjectionNoPrinters: 'Nenhuma impressora encontrada. Adicione impressoras para configurar snippets de G-code.',
     gcodeInjectionNoPrinters: 'Nenhuma impressora encontrada. Adicione impressoras para configurar snippets de G-code.',

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

@@ -316,6 +316,12 @@ export default {
       connected: '已连接',
       connected: '已连接',
       offline: '离线',
       offline: '离线',
     },
     },
+    plateStatus: {
+      markCleared: '将打印板标记为已清理',
+      cleared: '打印板已清理',
+      notCleared: '打印板未清理',
+      inUse: '打印板使用中',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: '队列中有 {{count}} 个打印任务',
       inQueue: '队列中有 {{count}} 个打印任务',
@@ -1739,7 +1745,7 @@ export default {
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
     plateClear: '热床清空确认',
     plateClear: '热床清空确认',
     requirePlateClear: '需要热床清空确认',
     requirePlateClear: '需要热床清空确认',
-    requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。对于物理验证热床的农场工作流,请禁用此选项。',
+    requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。禁用后,也会隐藏打印机卡片上的打印板状态标记和“将打印板标记为已清理”按钮。',
     gcodeInjection: 'G-code注入',
     gcodeInjection: 'G-code注入',
     gcodeInjectionDescription: '为Farmloop、SwapMod、AutoClear和Printflow 3D等自动打印系统配置自定义G-code,在打印开始和/或结束时注入。代码片段按打印机型号配置,在队列项目上启用"注入G-code"时应用。',
     gcodeInjectionDescription: '为Farmloop、SwapMod、AutoClear和Printflow 3D等自动打印系统配置自定义G-code,在打印开始和/或结束时注入。代码片段按打印机型号配置,在队列项目上启用"注入G-code"时应用。',
     gcodeInjectionNoPrinters: '未找到打印机。添加打印机以配置G-code代码片段。',
     gcodeInjectionNoPrinters: '未找到打印机。添加打印机以配置G-code代码片段。',

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

@@ -316,6 +316,12 @@ export default {
       connected: '已連線',
       connected: '已連線',
       offline: '離線',
       offline: '離線',
     },
     },
+    plateStatus: {
+      markCleared: '將列印板標記為已清理',
+      cleared: '列印板已清理',
+      notCleared: '列印板未清理',
+      inUse: '列印板使用中',
+    },
     // Queue info
     // Queue info
     queue: {
     queue: {
       inQueue: '佇列中有 {{count}} 個列印任務',
       inQueue: '佇列中有 {{count}} 個列印任務',
@@ -1739,7 +1745,7 @@ export default {
     staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',
     staggeredStartDescription: '多台印表機批次啟動時的預設群組大小與間隔。可在列印對話框中逐批覆寫。',
     plateClear: '熱床清空確認',
     plateClear: '熱床清空確認',
     requirePlateClear: '需要熱床清空確認',
     requirePlateClear: '需要熱床清空確認',
-    requirePlateClearDescription: '啟用後,排程器會在已完成列印的印表機上啟動佇列列印之前,等待每臺印表機的熱床清空確認。對於物理驗證熱床的農場工作流,請停用此選項。',
+    requirePlateClearDescription: '啟用後,排程器會在已完成列印的印表機上啟動佇列列印之前,等待每臺印表機的熱床清空確認。停用後,也會隱藏印表機卡片上的列印板狀態標記和「將列印板標記為已清理」按鈕。',
     gcodeInjection: 'G-code注入',
     gcodeInjection: 'G-code注入',
     gcodeInjectionDescription: '為Farmloop、SwapMod、AutoClear和Printflow 3D等自動列印系統設定自訂G-code,在列印開始和/或結束時注入。程式碼片段按印表機型號設定,在佇列項目上啟用"注入G-code"時套用。',
     gcodeInjectionDescription: '為Farmloop、SwapMod、AutoClear和Printflow 3D等自動列印系統設定自訂G-code,在列印開始和/或結束時注入。程式碼片段按印表機型號設定,在佇列項目上啟用"注入G-code"時套用。',
     gcodeInjectionNoPrinters: '未找到印表機。新增印表機以設定G-code程式碼片段。',
     gcodeInjectionNoPrinters: '未找到印表機。新增印表機以設定G-code程式碼片段。',

+ 95 - 7
frontend/src/pages/PrintersPage.tsx

@@ -59,7 +59,7 @@ import {
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi, withStreamToken } from '../api/client';
 import { api, discoveryApi, firmwareApi, withStreamToken } from '../api/client';
 import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
 import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
-import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError } from '../api/client';
+import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -76,6 +76,7 @@ import { AssignSpoolModal } from '../components/AssignSpoolModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { ChamberLight } from '../components/icons/ChamberLight';
+import { PlateClearedIcon } from '../components/icons/PlateClearedIcon';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
@@ -1644,6 +1645,33 @@ function PrinterCard({
     enabled: status?.connected && status?.state !== 'RUNNING',
     enabled: status?.connected && status?.state !== 'RUNNING',
   });
   });
   const lastPrint = lastPrints?.[0];
   const lastPrint = lastPrints?.[0];
+  const isPrintingOrPaused = status?.state === 'RUNNING' || status?.state === 'PAUSE';
+  const needsPlateClear = requirePlateClear && status?.awaiting_plate_clear === true;
+  const showClearPlateButton = status?.connected && needsPlateClear && !isPrintingOrPaused;
+  const plateStatus = (() => {
+    if (!requirePlateClear || !status?.connected) return null;
+    if (isPrintingOrPaused) {
+      return {
+        label: t('printers.plateStatus.inUse'),
+        className: 'bg-blue-500/20 text-blue-400',
+      };
+    }
+    if (status.awaiting_plate_clear) {
+      return {
+        label: t('printers.plateStatus.notCleared'),
+        className: 'bg-yellow-500/20 text-yellow-400',
+      };
+    }
+    return {
+      label: t('printers.plateStatus.cleared'),
+      className: 'bg-status-ok/20 text-status-ok',
+    };
+  })();
+  const plateStatusPill = plateStatus ? (
+    <span className={`inline-flex flex-shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${plateStatus.className}`}>
+      {plateStatus.label}
+    </span>
+  ) : null;
 
 
   // Determine if this card should be hidden (use cached connected state to prevent flicker)
   // Determine if this card should be hidden (use cached connected state to prevent flicker)
   const shouldHide = hideIfDisconnected && isConnected === false;
   const shouldHide = hideIfDisconnected && isConnected === false;
@@ -1762,6 +1790,19 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToResumePrint'), 'error'),
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToResumePrint'), 'error'),
   });
   });
 
 
+  const clearPlateMutation = useMutation({
+    mutationFn: () => api.clearPlate(printer.id),
+    onSuccess: () => {
+      showToast(t('queue.clearPlateSuccess'));
+      queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) =>
+        old ? { ...old, awaiting_plate_clear: false } : old
+      );
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+      queryClient.invalidateQueries({ queryKey: ['queue', printer.id] });
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
   // Chamber light mutation with optimistic update
   // Chamber light mutation with optimistic update
   const chamberLightMutation = useMutation({
   const chamberLightMutation = useMutation({
     mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
     mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
@@ -2604,10 +2645,34 @@ function PrinterCard({
                         style={{ width: `${status.progress || 0}%` }}
                         style={{ width: `${status.progress || 0}%` }}
                       />
                       />
                     </div>
                     </div>
-                    <span className="text-xs text-white">{Math.round(status.progress || 0)}%</span>
+                    <div className="flex flex-shrink-0 items-center gap-1.5">
+                      <span className="text-xs text-white">{Math.round(status.progress || 0)}%</span>
+                      {plateStatusPill}
+                    </div>
                   </div>
                   </div>
                 ) : (
                 ) : (
-                  <p className="text-xs text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                  <div className="flex items-center justify-between gap-2">
+                    <div className="min-w-0 flex-1 flex items-center gap-1.5">
+                      <p className="min-w-0 truncate text-xs text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                      {plateStatusPill}
+                    </div>
+                    {showClearPlateButton && (
+                      <button
+                        type="button"
+                        onClick={() => clearPlateMutation.mutate()}
+                        disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
+                        aria-label={t('printers.plateStatus.markCleared')}
+                        className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors disabled:opacity-50"
+                        title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
+                      >
+                        {clearPlateMutation.isPending ? (
+                          <Loader2 className="w-3 h-3 animate-spin" />
+                        ) : (
+                          <PlateClearedIcon className="w-3 h-3" />
+                        )}
+                      </button>
+                    )}
+                  </div>
                 )}
                 )}
               </div>
               </div>
             ) : (
             ) : (
@@ -2652,7 +2717,10 @@ function PrinterCard({
                     <div className="flex-1 min-w-0">
                     <div className="flex-1 min-w-0">
                       {status.current_print && (status.state === 'RUNNING' || status.state === 'PAUSE') ? (
                       {status.current_print && (status.state === 'RUNNING' || status.state === 'PAUSE') ? (
                         <>
                         <>
-                          <p className="text-sm text-bambu-gray mb-1">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                          <div className="mb-1 flex items-center gap-2">
+                            <p className="text-sm text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
+                            {plateStatusPill}
+                          </div>
                           <p className="text-white text-sm mb-2 truncate">
                           <p className="text-white text-sm mb-2 truncate">
                             {formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t)}
                             {formatPrintName(status.subtask_name || status.current_print || null, status.gcode_file, t)}
                           </p>
                           </p>
@@ -2694,9 +2762,12 @@ function PrinterCard({
                       ) : (
                       ) : (
                         <>
                         <>
                           <p className="text-sm text-bambu-gray mb-1">{t('printers.sort.status')}</p>
                           <p className="text-sm text-bambu-gray mb-1">{t('printers.sort.status')}</p>
-                          <p className="text-white text-sm mb-2">
-                            {getStatusDisplay(status.state, status.stg_cur_name)}
-                          </p>
+                          <div className="mb-2 flex items-center gap-2">
+                            <p className="text-white text-sm">
+                              {getStatusDisplay(status.state, status.stg_cur_name)}
+                            </p>
+                            {plateStatusPill}
+                          </div>
                           <div className="flex items-center justify-between text-sm">
                           <div className="flex items-center justify-between text-sm">
                             <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
                             <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
                               <div className="bg-bambu-dark-tertiary h-2 rounded-full" />
                               <div className="bg-bambu-dark-tertiary h-2 rounded-full" />
@@ -2818,6 +2889,23 @@ function PrinterCard({
               );
               );
             })()}
             })()}
 
 
+            {viewMode === 'expanded' && showClearPlateButton && (
+              <button
+                type="button"
+                onClick={() => clearPlateMutation.mutate()}
+                disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
+                className="mt-2 w-full inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-500/20 border border-yellow-400/40 text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs font-medium disabled:opacity-50"
+                title={!hasPermission('printers:clear_plate') ? t('printers.permission.noControl') : t('printers.plateStatus.markCleared')}
+              >
+                {clearPlateMutation.isPending ? (
+                  <Loader2 className="w-3 h-3 animate-spin" />
+                ) : (
+                  <PlateClearedIcon className="w-4 h-4" />
+                )}
+                {t('printers.plateStatus.markCleared')}
+              </button>
+            )}
+
             {/* Controls - Fans + Print Buttons */}
             {/* Controls - Fans + Print Buttons */}
             {viewMode === 'expanded' && (() => {
             {viewMode === 'expanded' && (() => {
               // Determine print state for control buttons
               // Determine print state for control buttons

+ 1 - 1
frontend/src/pages/SettingsPage.tsx

@@ -3724,7 +3724,7 @@ export function SettingsPage() {
                     {t('settings.requirePlateClear', 'Require plate-clear confirmation')}
                     {t('settings.requirePlateClear', 'Require plate-clear confirmation')}
                   </p>
                   </p>
                   <p className="text-xs text-bambu-gray mt-1">
                   <p className="text-xs text-bambu-gray mt-1">
-                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disable for farm workflows where plates are verified physically.')}
+                    {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.')}
                   </p>
                   </p>
                 </div>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                 <label className="relative inline-flex items-center cursor-pointer">