Procházet zdrojové kódy

Multi-printer selection for print modal

Added ability to send prints or queue items to multiple printers at once:
- Checkbox selection for multiple printers in reprint and add-to-queue modes
- "Select all" / "Clear" buttons for quick printer selection
- Same filament slot mapping applies to all selected printers
- Progress indicator during multi-printer submission (e.g., "Sending 2/3...")
- Info message: "Slot mapping below applies to all N printers"
- Ideal for print farms with identical filament configurations

Implementation:
- Added multi-select props to PrinterSelector component
- Added computeAmsMapping() utility function for imperative AMS mapping
- Updated PrintModal to handle array of selected printers
- Created unified PrintModal.test.tsx replacing three separate test files

Closes #104
maziggy před 4 měsíci
rodič
revize
ec66e0a665

+ 22 - 0
CHANGELOG.md

@@ -2,6 +2,28 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b11] - 2026-01-20
+
+### New Features
+- **Unified Print Modal** - Consolidated three separate modals into one unified component:
+  - Single modal handles reprint, add-to-queue, and edit-queue-item operations
+  - Consistent UI/UX across all print operations
+  - Reduced code duplication (~1300 LOC removed)
+- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
+  - Checkbox selection for multiple printers in reprint and add-to-queue modes
+  - "Select all" / "Clear" buttons for quick selection
+  - Same filament slot mapping applies to all selected printers
+  - Progress indicator during multi-printer submission
+  - Ideal for print farms with identical filament configurations
+- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
+  - Configure all print settings upfront instead of editing afterward
+  - Filament mapping with manual override capability
+
+### Fixed
+- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
+  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
+  - Folder navigation now works correctly without resetting
+
 ## [0.1.6b10] - 2026-01-20
 
 ### New Features

+ 1 - 0
README.md

@@ -70,6 +70,7 @@
 
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
+- Multi-printer selection (send to multiple printers at once)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota, Home Assistant)

+ 0 - 196
frontend/src/__tests__/components/AddToQueueModal.test.tsx

@@ -1,196 +0,0 @@
-/**
- * Tests for the AddToQueueModal component.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { AddToQueueModal } from '../../components/AddToQueueModal';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-const mockPrinters = [
-  {
-    id: 1,
-    name: 'X1 Carbon',
-    ip_address: '192.168.1.100',
-    model: 'X1C',
-    enabled: true,
-  },
-  {
-    id: 2,
-    name: 'P1S',
-    ip_address: '192.168.1.101',
-    model: 'P1S',
-    enabled: true,
-  },
-];
-
-const mockPlates = [
-  { id: 1, plate_number: 1, name: 'Plate 1' },
-  { id: 2, plate_number: 2, name: 'Plate 2' },
-];
-
-describe('AddToQueueModal', () => {
-  const mockOnClose = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-    server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json(mockPrinters);
-      }),
-      http.get('/api/v1/archives/:id/plates', () => {
-        return HttpResponse.json(mockPlates);
-      }),
-      http.get('/api/v1/archives/:id/filament-requirements', () => {
-        return HttpResponse.json([]);
-      }),
-      http.post('/api/v1/queue/', () => {
-        return HttpResponse.json({ id: 1, status: 'pending' });
-      })
-    );
-  });
-
-  describe('rendering', () => {
-    it('renders the modal title', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText('Schedule Print')).toBeInTheDocument();
-    });
-
-    it('shows archive name', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText('Test Print')).toBeInTheDocument();
-    });
-
-    it('shows printer selector', async () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('Printer')).toBeInTheDocument();
-      });
-    });
-
-    it('shows add button', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
-    });
-
-    it('shows cancel button', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
-    });
-  });
-
-  describe('queue options', () => {
-    it('shows Queue Only option', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText('Queue Only')).toBeInTheDocument();
-    });
-
-    it('shows power off option', () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      expect(screen.getByText(/power off/i)).toBeInTheDocument();
-    });
-  });
-
-  describe('print options', () => {
-    it('has print configuration options', async () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      // Modal should render and have configuration options
-      await waitFor(() => {
-        expect(screen.getByText('Schedule Print')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('actions', () => {
-    it('calls onClose when cancel is clicked', async () => {
-      const user = userEvent.setup();
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      await user.click(screen.getByRole('button', { name: /cancel/i }));
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-  });
-
-  describe('plate selection', () => {
-    it('shows plate selector when plates exist', async () => {
-      render(
-        <AddToQueueModal
-          archiveId={1}
-          archiveName="Test Print"
-          onClose={mockOnClose}
-        />
-      );
-
-      // Modal should render - plate selector may be conditional
-      await waitFor(() => {
-        expect(screen.getByText('Schedule Print')).toBeInTheDocument();
-      });
-    });
-  });
-});

+ 0 - 257
frontend/src/__tests__/components/EditQueueItemModal.test.tsx

@@ -1,257 +0,0 @@
-/**
- * Tests for the EditQueueItemModal component.
- *
- * These tests focus on:
- * - Basic rendering and modal controls
- * - Print options (bed levelling, flow calibration, etc.)
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { EditQueueItemModal } from '../../components/EditQueueItemModal';
-import type { PrintQueueItem, Printer } from '../../api/client';
-
-// Mock the API client to prevent actual API calls
-vi.mock('../../api/client', async () => {
-  const actual = await vi.importActual('../../api/client');
-  return {
-    ...actual,
-    fetchArchivePlates: vi.fn().mockResolvedValue([]),
-    fetchFilamentRequirements: vi.fn().mockResolvedValue([]),
-  };
-});
-
-// Mock data
-const createMockPrinter = (overrides: Partial<Printer> = {}): Printer => ({
-  id: 1,
-  name: 'Test Printer',
-  ip_address: '192.168.1.100',
-  serial_number: 'TESTSERIAL0001',
-  access_code: '12345678',
-  model: 'X1C',
-  enabled: true,
-  created_at: '2024-01-01T00:00:00Z',
-  ...overrides,
-});
-
-const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
-  id: 1,
-  printer_id: 1,
-  archive_id: 1,
-  position: 1,
-  scheduled_time: null,
-  require_previous_success: false,
-  auto_off_after: false,
-  manual_start: false,
-  ams_mapping: null,
-  plate_id: null,
-  bed_levelling: true,
-  flow_cali: false,
-  vibration_cali: true,
-  layer_inspect: false,
-  timelapse: false,
-  use_ams: true,
-  status: 'pending',
-  started_at: null,
-  completed_at: null,
-  error_message: null,
-  created_at: '2024-01-01T00:00:00Z',
-  archive_name: 'Test Print',
-  archive_thumbnail: null,
-  printer_name: 'Test Printer',
-  print_time_seconds: 3600,
-  ...overrides,
-});
-
-describe('EditQueueItemModal', () => {
-  const mockOnClose = vi.fn();
-  const mockOnSave = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-  });
-
-  describe('rendering', () => {
-    it('renders the modal with title', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
-    });
-
-    it('shows printer selector label', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter({ name: 'My Printer' })];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      // The printer label should be present
-      expect(screen.getByText('Printer')).toBeInTheDocument();
-    });
-
-    it('shows print options toggle', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText('Print Options')).toBeInTheDocument();
-    });
-  });
-
-  describe('print options', () => {
-    it('has print options toggle button', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      // Print Options toggle should be present
-      expect(screen.getByText('Print Options')).toBeInTheDocument();
-    });
-
-    it('print options toggle is clickable', async () => {
-      const user = userEvent.setup();
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      // Click should not throw an error
-      const printOptionsButton = screen.getByText('Print Options');
-      await user.click(printOptionsButton);
-
-      // The button should still be in the document after clicking
-      expect(screen.getByText('Print Options')).toBeInTheDocument();
-    });
-  });
-
-  describe('modal controls', () => {
-    it('has save button', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      const saveButton = screen.getByRole('button', { name: /save/i });
-      expect(saveButton).toBeInTheDocument();
-    });
-
-    it('has cancel button', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      const cancelButton = screen.getByRole('button', { name: /cancel/i });
-      expect(cancelButton).toBeInTheDocument();
-    });
-
-    it('calls onClose when cancel button is clicked', async () => {
-      const user = userEvent.setup();
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      const cancelButton = screen.getByRole('button', { name: /cancel/i });
-      await user.click(cancelButton);
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-  });
-
-  describe('queue options', () => {
-    it('shows queue only option', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText('Queue Only')).toBeInTheDocument();
-    });
-
-    it('shows power off option', () => {
-      const item = createMockQueueItem();
-      const printers = [createMockPrinter()];
-
-      render(
-        <EditQueueItemModal
-          item={item}
-          printers={printers}
-          onClose={mockOnClose}
-          onSave={mockOnSave}
-        />
-      );
-
-      expect(screen.getByText(/power off/i)).toBeInTheDocument();
-    });
-  });
-});

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

@@ -0,0 +1,536 @@
+/**
+ * Tests for the unified PrintModal component.
+ *
+ * The PrintModal supports three modes:
+ * - 'reprint': Immediate print from archive (multi-printer support)
+ * - 'add-to-queue': Schedule print to queue (multi-printer support)
+ * - 'edit-queue-item': Edit existing queue item (single printer)
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintModal } from '../../components/PrintModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import type { PrintQueueItem } from '../../api/client';
+
+const mockPrinters = [
+  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
+  { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
+];
+
+const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
+  id: 1,
+  printer_id: 1,
+  archive_id: 1,
+  position: 1,
+  scheduled_time: null,
+  require_previous_success: false,
+  auto_off_after: false,
+  manual_start: false,
+  ams_mapping: null,
+  plate_id: null,
+  bed_levelling: true,
+  flow_cali: false,
+  vibration_cali: true,
+  layer_inspect: false,
+  timelapse: false,
+  use_ams: true,
+  status: 'pending',
+  started_at: null,
+  completed_at: null,
+  error_message: null,
+  created_at: '2024-01-01T00:00:00Z',
+  archive_name: 'Test Print',
+  archive_thumbnail: null,
+  printer_name: 'Test Printer',
+  print_time_seconds: 3600,
+  ...overrides,
+});
+
+describe('PrintModal', () => {
+  const mockOnClose = vi.fn();
+  const mockOnSuccess = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.get('/api/v1/archives/:id/plates', () => {
+        return HttpResponse.json({ is_multi_plate: false, plates: [] });
+      }),
+      http.get('/api/v1/archives/:id/filament-requirements', () => {
+        return HttpResponse.json({ filaments: [] });
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
+      }),
+      http.post('/api/v1/archives/:id/reprint', () => {
+        return HttpResponse.json({ success: true });
+      }),
+      http.post('/api/v1/queue/', () => {
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+      http.patch('/api/v1/queue/:id', () => {
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      })
+    );
+  });
+
+  describe('reprint mode', () => {
+    it('renders the modal title', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByText('Re-print')).toBeInTheDocument();
+    });
+
+    it('shows archive name', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByText('Benchy')).toBeInTheDocument();
+    });
+
+    it('shows printer selection with checkboxes for multi-select', async () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+        expect(screen.getByText('P1S')).toBeInTheDocument();
+      });
+    });
+
+    it('has print button', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Get the submit button specifically (not printer selection buttons)
+      const submitButton = screen.getByRole('button', { name: /^print$/i });
+      expect(submitButton).toBeInTheDocument();
+    });
+
+    it('has cancel button', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+    });
+
+    it('calls onClose when cancel is clicked', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    it('print button is disabled until printer is selected', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Get the submit button specifically (not printer selection buttons)
+      const printButton = screen.getByRole('button', { name: /^print$/i });
+      expect(printButton).toBeDisabled();
+    });
+
+    it('shows no printers message when none active', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () => {
+          return HttpResponse.json([]);
+        })
+      );
+
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('No active printers available')).toBeInTheDocument();
+      });
+    });
+
+    it('shows print options toggle', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByText('Print Options')).toBeInTheDocument();
+    });
+  });
+
+  describe('add-to-queue mode', () => {
+    it('renders the modal title', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Schedule Print')).toBeInTheDocument();
+    });
+
+    it('shows archive name', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Test Print')).toBeInTheDocument();
+    });
+
+    it('shows add button', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
+    });
+
+    it('shows cancel button', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+    });
+
+    it('shows Queue Only option', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Queue Only')).toBeInTheDocument();
+    });
+
+    it('shows power off option', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText(/power off/i)).toBeInTheDocument();
+    });
+
+    it('shows schedule options', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('ASAP')).toBeInTheDocument();
+      expect(screen.getByText('Scheduled')).toBeInTheDocument();
+    });
+
+    it('calls onClose when cancel is clicked', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+  });
+
+  describe('edit-queue-item mode', () => {
+    it('renders the modal title', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
+    });
+
+    it('shows save button', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+    });
+
+    it('shows cancel button', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+    });
+
+    it('shows print options toggle', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Print Options')).toBeInTheDocument();
+    });
+
+    it('shows Queue Only option', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText('Queue Only')).toBeInTheDocument();
+    });
+
+    it('shows power off option', () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(screen.getByText(/power off/i)).toBeInTheDocument();
+    });
+
+    it('calls onClose when cancel button is clicked', async () => {
+      const user = userEvent.setup();
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      const cancelButton = screen.getByRole('button', { name: /cancel/i });
+      await user.click(cancelButton);
+
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    it('shows printer dropdown for single selection', async () => {
+      const item = createMockQueueItem();
+
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={item}
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('multi-printer selection', () => {
+    it('shows select all button when multiple printers available', async () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+    });
+
+    it('shows info message when multiple printers selected', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText(/Slot mapping below applies to all/)).toBeInTheDocument();
+      });
+    });
+
+    it('updates button text when multiple printers selected', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /print to 2 printers/i })).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 0 - 184
frontend/src/__tests__/components/ReprintModal.test.tsx

@@ -1,184 +0,0 @@
-/**
- * Tests for the ReprintModal component.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { ReprintModal } from '../../components/ReprintModal';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-const mockPrinters = [
-  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
-  { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
-];
-
-describe('ReprintModal', () => {
-  const mockOnClose = vi.fn();
-  const mockOnSuccess = vi.fn();
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-    server.use(
-      http.get('/api/v1/printers/', () => {
-        return HttpResponse.json(mockPrinters);
-      }),
-      http.get('/api/v1/archives/:id/plates', () => {
-        return HttpResponse.json({ is_multi_plate: false, plates: [] });
-      }),
-      http.get('/api/v1/archives/:id/filament-requirements', () => {
-        return HttpResponse.json({ filaments: [] });
-      }),
-      http.get('/api/v1/printers/:id/status', () => {
-        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
-      }),
-      http.post('/api/v1/archives/:id/reprint', () => {
-        return HttpResponse.json({ success: true });
-      })
-    );
-  });
-
-  describe('rendering', () => {
-    it('renders the modal title', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByText('Re-print')).toBeInTheDocument();
-    });
-
-    it('shows archive name', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByText('Benchy')).toBeInTheDocument();
-    });
-
-    it('shows printer selection buttons', async () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
-        expect(screen.getByText('P1S')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('printer selection', () => {
-    it('shows active printers as buttons', async () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await waitFor(() => {
-        // Printer buttons should be present
-        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
-      });
-    });
-
-    it('shows no printers message when none active', async () => {
-      server.use(
-        http.get('/api/v1/printers/', () => {
-          return HttpResponse.json([]);
-        })
-      );
-
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('No active printers available')).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('actions', () => {
-    it('has print button', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
-    });
-
-    it('has cancel button', () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
-    });
-
-    it('calls onClose when cancel is clicked', async () => {
-      const user = userEvent.setup();
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      await user.click(screen.getByRole('button', { name: /cancel/i }));
-
-      expect(mockOnClose).toHaveBeenCalled();
-    });
-
-    it('print button is disabled until printer is selected', async () => {
-      render(
-        <ReprintModal
-          archiveId={1}
-          archiveName="Benchy"
-          onClose={mockOnClose}
-          onSuccess={mockOnSuccess}
-        />
-      );
-
-      // Print button should be disabled initially (no printer selected)
-      const printButton = screen.getByRole('button', { name: /print/i });
-      expect(printButton).toBeDisabled();
-    });
-  });
-});

+ 89 - 8
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -1,17 +1,20 @@
-import { Printer as PrinterIcon, Loader2, AlertCircle } from 'lucide-react';
+import { Printer as PrinterIcon, Loader2, AlertCircle, Check } from 'lucide-react';
 import type { PrinterSelectorProps } from './types';
 
 /**
- * Printer selection component with two modes:
- * - Grid mode (default): Shows printers as selectable cards
+ * Printer selection component with multiple modes:
+ * - Grid mode (default): Shows printers as selectable cards (single or multi-select)
  * - Dropdown mode: Shows printers in a select dropdown (used when allowUnassigned is true)
  */
 export function PrinterSelector({
   printers,
   selectedPrinterId,
+  selectedPrinterIds = [],
   onSelect,
+  onMultiSelect,
   isLoading = false,
   allowUnassigned = false,
+  allowMultiple = false,
 }: PrinterSelectorProps) {
   const activePrinters = printers.filter((p) => p.is_active);
 
@@ -68,36 +71,114 @@ export function PrinterSelector({
     );
   }
 
+  const handlePrinterClick = (printerId: number) => {
+    if (allowMultiple && onMultiSelect) {
+      // Multi-select mode: toggle printer in selection
+      if (selectedPrinterIds.includes(printerId)) {
+        onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
+      } else {
+        onMultiSelect([...selectedPrinterIds, printerId]);
+      }
+    } else {
+      // Single-select mode
+      onSelect(printerId);
+    }
+  };
+
+  const handleSelectAll = () => {
+    if (onMultiSelect) {
+      onMultiSelect(activePrinters.map((p) => p.id));
+    }
+  };
+
+  const handleDeselectAll = () => {
+    if (onMultiSelect) {
+      onMultiSelect([]);
+    }
+  };
+
+  const isSelected = (printerId: number) => {
+    if (allowMultiple) {
+      return selectedPrinterIds.includes(printerId);
+    }
+    return selectedPrinterId === printerId;
+  };
+
+  const selectedCount = allowMultiple ? selectedPrinterIds.length : (selectedPrinterId ? 1 : 0);
+
   return (
     <div className="space-y-2 mb-6">
+      {/* Multi-select header */}
+      {allowMultiple && activePrinters.length > 1 && (
+        <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
+          <span>
+            {selectedCount === 0
+              ? 'Select printers'
+              : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
+          </span>
+          <div className="flex gap-2">
+            {selectedCount < activePrinters.length && (
+              <button
+                type="button"
+                onClick={handleSelectAll}
+                className="text-bambu-green hover:text-bambu-green/80 transition-colors"
+              >
+                Select all
+              </button>
+            )}
+            {selectedCount > 0 && (
+              <button
+                type="button"
+                onClick={handleDeselectAll}
+                className="text-bambu-gray hover:text-white transition-colors"
+              >
+                Clear
+              </button>
+            )}
+          </div>
+        </div>
+      )}
+
       {activePrinters.map((printer) => (
         <button
           key={printer.id}
           type="button"
-          onClick={() => onSelect(printer.id)}
+          onClick={() => handlePrinterClick(printer.id)}
           className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
-            selectedPrinterId === printer.id
+            isSelected(printer.id)
               ? 'border-bambu-green bg-bambu-green/10'
               : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
           }`}
         >
           <div
             className={`p-2 rounded-lg ${
-              selectedPrinterId === printer.id ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
+              isSelected(printer.id) ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
             }`}
           >
             <PrinterIcon
               className={`w-5 h-5 ${
-                selectedPrinterId === printer.id ? 'text-bambu-green' : 'text-bambu-gray'
+                isSelected(printer.id) ? 'text-bambu-green' : 'text-bambu-gray'
               }`}
             />
           </div>
-          <div className="text-left">
+          <div className="text-left flex-1">
             <p className="text-white font-medium">{printer.name}</p>
             <p className="text-xs text-bambu-gray">
               {printer.model || 'Unknown model'} • {printer.ip_address}
             </p>
           </div>
+          {/* Checkbox indicator for multi-select */}
+          {allowMultiple && (
+            <div
+              className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
+                isSelected(printer.id)
+                  ? 'bg-bambu-green border-bambu-green'
+                  : 'border-bambu-gray/50'
+              }`}
+            >
+              {isSelected(printer.id) && <Check className="w-3 h-3 text-white" />}
+            </div>
+          )}
         </button>
       ))}
     </div>

+ 181 - 116
frontend/src/components/PrintModal/index.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { X, Printer, Loader2, Calendar, Pencil } from 'lucide-react';
+import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
 import { api } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import { Card, CardContent } from '../Card';
@@ -23,9 +23,9 @@ import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 
 /**
  * Unified PrintModal component that handles three modes:
- * - 'reprint': Immediate print from archive
- * - 'add-to-queue': Schedule print to queue
- * - 'edit-queue-item': Edit existing queue item
+ * - 'reprint': Immediate print from archive (supports multi-printer)
+ * - 'add-to-queue': Schedule print to queue (supports multi-printer)
+ * - 'edit-queue-item': Edit existing queue item (single printer only)
  */
 export function PrintModal({
   mode,
@@ -38,7 +38,7 @@ export function PrintModal({
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
-  // Initialize state based on mode
+  // Single printer selection (for edit mode and backward compatibility)
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
       return queueItem.printer_id;
@@ -46,6 +46,9 @@ export function PrintModal({
     return null;
   });
 
+  // Multiple printer selection (for reprint and add-to-queue modes)
+  const [selectedPrinters, setSelectedPrinters] = useState<number[]>([]);
+
   const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
       return queueItem.plate_id;
@@ -68,7 +71,6 @@ export function PrintModal({
 
   const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
-      // Determine schedule type from queue item
       let scheduleType: ScheduleType = 'asap';
       if (queueItem.manual_start) {
         scheduleType = 'manual';
@@ -76,7 +78,6 @@ export function PrintModal({
         scheduleType = 'scheduled';
       }
 
-      // Convert scheduled time to local datetime-local format
       let scheduledTime = '';
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
         const date = new Date(queueItem.scheduled_time);
@@ -111,6 +112,18 @@ export function PrintModal({
   const [initialPrinterId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.printer_id : null));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
 
+  // Submission state for multi-printer
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
+
+  // Determine if we're in multi-printer mode
+  const isMultiPrinterMode = mode !== 'edit-queue-item';
+  const effectivePrinterCount = isMultiPrinterMode ? selectedPrinters.length : (selectedPrinter ? 1 : 0);
+  // For filament mapping, use first selected printer (mapping applies to all)
+  const effectivePrinterId = isMultiPrinterMode
+    ? (selectedPrinters.length > 0 ? selectedPrinters[0] : null)
+    : selectedPrinter;
+
   // Queries
   const { data: printers, isLoading: loadingPrinters } = useQuery({
     queryKey: ['printers'],
@@ -128,13 +141,14 @@ export function PrintModal({
     enabled: selectedPlate !== null || !platesData?.is_multi_plate,
   });
 
+  // Only fetch printer status when single printer selected (for filament mapping)
   const { data: printerStatus } = useQuery({
-    queryKey: ['printer-status', selectedPrinter],
-    queryFn: () => api.getPrinterStatus(selectedPrinter!),
-    enabled: !!selectedPrinter,
+    queryKey: ['printer-status', effectivePrinterId],
+    queryFn: () => api.getPrinterStatus(effectivePrinterId!),
+    enabled: !!effectivePrinterId,
   });
 
-  // Get AMS mapping from hook
+  // Get AMS mapping from hook (only when single printer selected)
   const { amsMapping } = useFilamentMapping(filamentReqs, printerStatus, manualMappings);
 
   // Auto-select first plate for single-plate files
@@ -144,70 +158,41 @@ export function PrintModal({
     }
   }, [platesData, selectedPlate]);
 
-  // Auto-select first printer when only one available (add-to-queue mode)
+  // Auto-select first printer when only one available (non-multi mode)
   useEffect(() => {
-    if (mode === 'add-to-queue' && printers?.length === 1 && !selectedPrinter) {
-      setSelectedPrinter(printers[0].id);
+    if (mode === 'edit-queue-item') return;
+    const activePrinters = printers?.filter(p => p.is_active) || [];
+    if (activePrinters.length === 1 && selectedPrinters.length === 0) {
+      setSelectedPrinters([activePrinters[0].id]);
     }
-  }, [mode, printers, selectedPrinter]);
+  }, [mode, printers, selectedPrinters.length]);
 
   // Clear manual mappings when printer or plate changes
   useEffect(() => {
     if (mode === 'edit-queue-item') {
-      // Only clear if changed from initial values
       if (selectedPrinter !== initialPrinterId || selectedPlate !== initialPlateId) {
         setManualMappings({});
       }
     } else {
-      // Always clear on change for non-edit modes
       setManualMappings({});
     }
-  }, [mode, selectedPrinter, selectedPlate, initialPrinterId, initialPlateId]);
+  }, [mode, selectedPrinter, selectedPrinters, selectedPlate, initialPrinterId, initialPlateId]);
 
   // Close on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
+      if (e.key === 'Escape' && !isSubmitting) onClose();
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
+  }, [onClose, isSubmitting]);
 
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const plates = platesData?.plates ?? [];
 
-  // Reprint mutation
-  const reprintMutation = useMutation({
-    mutationFn: () => {
-      if (!selectedPrinter) throw new Error('No printer selected');
-      return api.reprintArchive(archiveId, selectedPrinter, {
-        plate_id: selectedPlate ?? undefined,
-        ams_mapping: amsMapping,
-        ...printOptions,
-      });
-    },
-    onSuccess: () => {
-      showToast('Print started');
-      onSuccess?.();
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(error.message || 'Failed to start print', 'error');
-    },
-  });
-
-  // Add to queue mutation
+  // Add to queue mutation (single printer)
   const addToQueueMutation = useMutation({
     mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Added to print queue');
-      onSuccess?.();
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(error.message || 'Failed to add to queue', 'error');
-    },
   });
 
   // Update queue item mutation
@@ -224,38 +209,11 @@ export function PrintModal({
     },
   });
 
-  const handleSubmit = (e?: React.FormEvent) => {
+  const handleSubmit = async (e?: React.FormEvent) => {
     e?.preventDefault();
 
-    if (mode === 'reprint') {
-      if (!selectedPrinter) {
-        showToast('Please select a printer', 'error');
-        return;
-      }
-      reprintMutation.mutate();
-    } else if (mode === 'add-to-queue') {
-      if (!selectedPrinter) {
-        showToast('Please select a printer', 'error');
-        return;
-      }
-
-      const data: PrintQueueItemCreate = {
-        printer_id: selectedPrinter,
-        archive_id: archiveId,
-        require_previous_success: scheduleOptions.requirePreviousSuccess,
-        auto_off_after: scheduleOptions.autoOffAfter,
-        manual_start: scheduleOptions.scheduleType === 'manual',
-        ams_mapping: amsMapping,
-        plate_id: selectedPlate,
-        ...printOptions,
-      };
-
-      if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
-        data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
-      }
-
-      addToQueueMutation.mutate(data);
-    } else if (mode === 'edit-queue-item') {
+    if (mode === 'edit-queue-item') {
+      // Edit mode - single printer update
       const data: PrintQueueItemUpdate = {
         printer_id: selectedPrinter,
         require_previous_success: scheduleOptions.requirePreviousSuccess,
@@ -273,56 +231,150 @@ export function PrintModal({
       }
 
       updateQueueMutation.mutate(data);
+      return;
+    }
+
+    // Multi-printer modes (reprint or add-to-queue)
+    if (selectedPrinters.length === 0) {
+      showToast('Please select at least one printer', 'error');
+      return;
+    }
+
+    setIsSubmitting(true);
+    setSubmitProgress({ current: 0, total: selectedPrinters.length });
+
+    const results: { success: number; failed: number; errors: string[] } = {
+      success: 0,
+      failed: 0,
+      errors: [],
+    };
+
+    for (let i = 0; i < selectedPrinters.length; i++) {
+      const printerId = selectedPrinters[i];
+      setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
+
+      try {
+        // Use the same AMS mapping for all printers (configured via UI based on first printer)
+        // This assumes all printers have the same filament configuration
+        if (mode === 'reprint') {
+          await api.reprintArchive(archiveId, printerId, {
+            plate_id: selectedPlate ?? undefined,
+            ams_mapping: amsMapping,
+            ...printOptions,
+          });
+        } else {
+          // add-to-queue mode
+          const data: PrintQueueItemCreate = {
+            printer_id: printerId,
+            archive_id: archiveId,
+            require_previous_success: scheduleOptions.requirePreviousSuccess,
+            auto_off_after: scheduleOptions.autoOffAfter,
+            manual_start: scheduleOptions.scheduleType === 'manual',
+            ams_mapping: amsMapping,
+            plate_id: selectedPlate,
+            ...printOptions,
+          };
+
+          if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
+            data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
+          }
+
+          await addToQueueMutation.mutateAsync(data);
+        }
+        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}`);
+      }
+    }
+
+    setIsSubmitting(false);
+
+    // Show result toast
+    if (results.failed === 0) {
+      const action = mode === 'reprint' ? 'sent to' : 'queued for';
+      if (results.success === 1) {
+        showToast(`Print ${action} printer`);
+      } else {
+        showToast(`Print ${action} ${results.success} printers`);
+      }
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      onSuccess?.();
+      onClose();
+    } else if (results.success === 0) {
+      showToast(`Failed: ${results.errors[0]}`, 'error');
+    } else {
+      showToast(`${results.success} succeeded, ${results.failed} failed`, 'error');
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
     }
   };
 
-  const isPending =
-    reprintMutation.isPending || addToQueueMutation.isPending || updateQueueMutation.isPending;
+  const isPending = isSubmitting || updateQueueMutation.isPending;
 
   const canSubmit = useMemo(() => {
+    if (isPending) return false;
+
     // For edit mode, printer can be null (unassigned)
     if (mode === 'edit-queue-item') {
-      return !isPending && (printers?.length ?? 0) > 0;
+      return (printers?.length ?? 0) > 0;
     }
-    // For reprint and add-to-queue, need a selected printer
-    if (!selectedPrinter) return false;
+
+    // For reprint and add-to-queue, need at least one selected printer
+    if (selectedPrinters.length === 0) return false;
+
     // For multi-plate files, need a selected plate
     if (isMultiPlate && !selectedPlate) return false;
-    return !isPending;
-  }, [mode, selectedPrinter, isMultiPlate, selectedPlate, isPending, printers]);
+
+    return true;
+  }, [mode, selectedPrinters.length, isMultiPlate, selectedPlate, isPending, printers]);
 
   // Modal title and action button text based on mode
-  const modalConfig = {
-    reprint: {
-      title: 'Re-print',
-      icon: Printer,
-      submitText: 'Print',
-      submitIcon: Printer,
-      loadingText: 'Sending...',
-    },
-    'add-to-queue': {
-      title: 'Schedule Print',
-      icon: Calendar,
-      submitText: 'Add to Queue',
-      submitIcon: Calendar,
-      loadingText: 'Adding...',
-    },
-    'edit-queue-item': {
+  const getModalConfig = () => {
+    const printerCount = isMultiPrinterMode ? selectedPrinters.length : 1;
+
+    if (mode === 'reprint') {
+      return {
+        title: 'Re-print',
+        icon: Printer,
+        submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
+        submitIcon: Printer,
+        loadingText: submitProgress.total > 1
+          ? `Sending ${submitProgress.current}/${submitProgress.total}...`
+          : 'Sending...',
+      };
+    }
+    if (mode === 'add-to-queue') {
+      return {
+        title: 'Schedule Print',
+        icon: Calendar,
+        submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
+        submitIcon: Calendar,
+        loadingText: submitProgress.total > 1
+          ? `Adding ${submitProgress.current}/${submitProgress.total}...`
+          : 'Adding...',
+      };
+    }
+    return {
       title: 'Edit Queue Item',
       icon: Pencil,
       submitText: 'Save Changes',
       submitIcon: Pencil,
       loadingText: 'Saving...',
-    },
-  }[mode];
+    };
+  };
 
+  const modalConfig = getModalConfig();
   const TitleIcon = modalConfig.icon;
   const SubmitIcon = modalConfig.submitIcon;
 
+  // Show filament mapping only when single printer selected
+  const showFilamentMapping = effectivePrinterId && (isMultiPlate ? selectedPlate !== null : true);
+
   return (
     <div
       className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
-      onClick={onClose}
+      onClick={isSubmitting ? undefined : onClose}
     >
       <Card
         className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
@@ -339,7 +391,7 @@ export function PrintModal({
               <TitleIcon className="w-5 h-5 text-bambu-green" />
               <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
             </div>
-            <Button variant="ghost" size="sm" onClick={onClose}>
+            <Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
               <X className="w-5 h-5" />
             </Button>
           </div>
@@ -349,7 +401,8 @@ export function PrintModal({
             <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
               {mode === 'reprint' ? (
                 <>
-                  Send <span className="text-white">{archiveName}</span> to a printer
+                  Send <span className="text-white">{archiveName}</span> to{' '}
+                  {isMultiPrinterMode ? 'printer(s)' : 'a printer'}
                 </>
               ) : (
                 <>
@@ -363,11 +416,24 @@ export function PrintModal({
             <PrinterSelector
               printers={printers || []}
               selectedPrinterId={selectedPrinter}
+              selectedPrinterIds={selectedPrinters}
               onSelect={setSelectedPrinter}
+              onMultiSelect={setSelectedPrinters}
               isLoading={loadingPrinters}
               allowUnassigned={mode === 'edit-queue-item'}
+              allowMultiple={isMultiPrinterMode}
             />
 
+            {/* Multi-printer filament mapping note */}
+            {isMultiPrinterMode && selectedPrinters.length > 1 && (
+              <div className="flex items-start gap-2 p-3 mb-2 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm">
+                <AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
+                <p className="text-blue-400">
+                  Slot mapping below applies to all {selectedPrinters.length} printers. Ensure they have matching filament configurations.
+                </p>
+              </div>
+            )}
+
             {/* Plate selection */}
             <PlateSelector
               plates={plates}
@@ -376,10 +442,10 @@ export function PrintModal({
               onSelect={setSelectedPlate}
             />
 
-            {/* Filament mapping - show when printer selected and plate ready */}
-            {selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && (
+            {/* Filament mapping - show when single printer selected and plate ready */}
+            {showFilamentMapping && (
               <FilamentMapping
-                printerId={selectedPrinter}
+                printerId={effectivePrinterId!}
                 archiveId={archiveId}
                 selectedPlate={selectedPlate}
                 isMultiPlate={isMultiPlate}
@@ -389,7 +455,7 @@ export function PrintModal({
             )}
 
             {/* Print options */}
-            {(mode === 'reprint' || selectedPrinter) && (
+            {(mode === 'reprint' || effectivePrinterCount > 0) && (
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
             )}
 
@@ -399,16 +465,15 @@ export function PrintModal({
             )}
 
             {/* Error message */}
-            {(reprintMutation.isError || addToQueueMutation.isError || updateQueueMutation.isError) && (
+            {updateQueueMutation.isError && (
               <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
-                {((reprintMutation.error || addToQueueMutation.error || updateQueueMutation.error) as Error)?.message ||
-                  'Failed to complete operation'}
+                {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
               </div>
             )}
 
             {/* Actions */}
             <div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
-              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+              <Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
                 Cancel
               </Button>
               <Button

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

@@ -104,9 +104,12 @@ export interface PlatesResponse {
 export interface PrinterSelectorProps {
   printers: Printer[];
   selectedPrinterId: number | null;
+  selectedPrinterIds?: number[];
   onSelect: (printerId: number | null) => void;
+  onMultiSelect?: (printerIds: number[]) => void;
   isLoading?: boolean;
   allowUnassigned?: boolean;
+  allowMultiple?: boolean;
 }
 
 /**

+ 121 - 0
frontend/src/hooks/useFilamentMapping.ts

@@ -9,6 +9,127 @@ import {
 } from '../utils/amsHelpers';
 import type { PrinterStatus } from '../api/client';
 
+/**
+ * Build loaded filaments list from printer status (non-hook version).
+ * Extracts filaments from all AMS units (regular and HT) and external spool.
+ */
+export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
+  const filaments: LoadedFilament[] = [];
+
+  // Add filaments from all AMS units (regular and HT)
+  printerStatus?.ams?.forEach((amsUnit) => {
+    const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
+    amsUnit.tray.forEach((tray) => {
+      if (tray.tray_type) {
+        const color = normalizeColor(tray.tray_color);
+        filaments.push({
+          type: tray.tray_type,
+          color,
+          colorName: getColorName(color),
+          amsId: amsUnit.id,
+          trayId: tray.id,
+          isHt,
+          isExternal: false,
+          label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+          globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+        });
+      }
+    });
+  });
+
+  // Add external spool if loaded
+  if (printerStatus?.vt_tray?.tray_type) {
+    const color = normalizeColor(printerStatus.vt_tray.tray_color);
+    filaments.push({
+      type: printerStatus.vt_tray.tray_type,
+      color,
+      colorName: getColorName(color),
+      amsId: -1,
+      trayId: 0,
+      isHt: false,
+      isExternal: true,
+      label: 'External',
+      globalTrayId: 254,
+    });
+  }
+
+  return filaments;
+}
+
+/**
+ * Compute AMS mapping for a printer given filament requirements and printer status.
+ * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
+ *
+ * @param filamentReqs - Required filaments from the 3MF file
+ * @param printerStatus - Current printer status with AMS information
+ * @returns AMS mapping array or undefined if no mapping needed
+ */
+export function computeAmsMapping(
+  filamentReqs: { filaments: FilamentRequirement[] } | undefined,
+  printerStatus: PrinterStatus | undefined
+): number[] | undefined {
+  if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
+
+  const loadedFilaments = buildLoadedFilaments(printerStatus);
+  if (loadedFilaments.length === 0) return undefined;
+
+  // Track which trays have been assigned to avoid duplicates
+  const usedTrayIds = new Set<number>();
+
+  const comparisons = filamentReqs.filaments.map((req) => {
+    // Auto-match: Find a loaded filament that matches by TYPE
+    // Priority: exact color match > similar color match > type-only match
+    const exactMatch = loadedFilaments.find(
+      (f) =>
+        !usedTrayIds.has(f.globalTrayId) &&
+        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+    );
+    const similarMatch =
+      !exactMatch &&
+      loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          colorsAreSimilar(f.color, req.color)
+      );
+    const typeOnlyMatch =
+      !exactMatch &&
+      !similarMatch &&
+      loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+      );
+    const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
+    // Mark this tray as used so it won't be assigned to another slot
+    if (loaded) {
+      usedTrayIds.add(loaded.globalTrayId);
+    }
+
+    return {
+      slot_id: req.slot_id,
+      globalTrayId: loaded?.globalTrayId ?? -1,
+    };
+  });
+
+  // Find the max slot_id to determine array size
+  const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
+  if (maxSlotId <= 0) return undefined;
+
+  // Create array with -1 for all positions
+  const mapping = new Array(maxSlotId).fill(-1);
+
+  // Fill in tray IDs at correct positions (slot_id - 1)
+  comparisons.forEach((f) => {
+    if (f.slot_id && f.slot_id > 0) {
+      mapping[f.slot_id - 1] = f.globalTrayId;
+    }
+  });
+
+  return mapping;
+}
+
 /**
  * Represents a loaded filament in the printer's AMS/HT/External spool holder.
  */

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-DE9m9l_f.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-qwbvIwuo.js"></script>
+    <script type="module" crossorigin src="/assets/index-DE9m9l_f.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-t4RhRNeD.css">
   </head>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů