From 6fd11ddc77a0919ebdfa78681c2c486366ab947e Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 17 Jan 2026 13:30:00 +0100 Subject: [PATCH] Add virtual printer Queue mode and sidebar badge indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Virtual Printer Changes: - Add new "Queue" mode that archives files and adds them to print queue - Rename modes: "Immediate" → "Archive", "Queue for Review" → "Review" - Three modes now: Archive (immediate), Review (pending), Queue (print queue) - Queue mode creates unassigned queue items (printer_id=null) Print Queue Changes: - Make printer_id nullable in PrintQueueItem model/schema - Add support for unassigned queue items (no printer assigned) - Queue page shows "Unassigned" filter option - Edit modal allows assigning printer to unassigned items - Unassigned items highlighted in orange Sidebar Badge Indicators: - Queue icon shows yellow badge with pending queue item count - Archive icon shows blue badge with pending upload count - Badges auto-update every 5 seconds and on window focus Other: - Update backup/restore to handle nullable printer_id and ams_mapping - Add database migration for nullable printer_id column - Update VirtualPrinterSettings tests for new modes - Update virtual printer API integration tests - Remove speed controls documentation from wiki (not implemented) --- CHANGELOG.md | 21 ++++ backend/app/api/routes/print_queue.py | 45 ++++--- backend/app/api/routes/settings.py | 36 ++++-- backend/app/core/database.py | 44 +++++++ backend/app/models/print_queue.py | 2 +- backend/app/schemas/print_queue.py | 4 +- backend/app/schemas/settings.py | 5 +- .../app/services/virtual_printer/manager.py | 77 +++++++++++- .../integration/test_virtual_printer_api.py | 22 +++- .../VirtualPrinterSettings.test.tsx | 80 ++++++++++--- frontend/src/api/client.ts | 12 +- .../src/components/EditQueueItemModal.tsx | 38 +++--- frontend/src/components/Layout.tsx | 38 +++++- .../src/components/VirtualPrinterSettings.tsx | 47 +++++--- frontend/src/pages/QueuePage.tsx | 14 ++- static/assets/index-DAZKHkJ3.css | 1 - static/assets/index-Dzh7xD3q.css | 1 + .../{index-Br9mMbRU.js => index-Yr6VeTyb.js} | 110 +++++++++--------- static/index.html | 4 +- 19 files changed, 452 insertions(+), 149 deletions(-) delete mode 100644 static/assets/index-DAZKHkJ3.css create mode 100644 static/assets/index-Dzh7xD3q.css rename static/assets/{index-Br9mMbRU.js => index-Yr6VeTyb.js} (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39feeae98..3301e1bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.6b15] - 2026-01-17 + +### Added +- **Virtual Printer "Queue" mode** - New mode that archives files and adds them to the print queue: + - Three modes now available: Archive (immediate), Review (pending list), Queue (print queue) + - Queue mode creates unassigned queue items that can be assigned to a printer later + - Renamed old "Queue for Review" to "Review" to avoid confusion with print queue +- **Unassigned queue items** - Print queue now supports items without an assigned printer: + - Queue items can be created without a printer (printer_id nullable) + - Queue page shows "Unassigned" filter option and highlights unassigned items in orange + - Edit modal allows assigning a printer to unassigned items + - Useful for virtual printer uploads where target printer is decided later +- **Sidebar badge indicators** - Visual indicators on sidebar icons for pending items: + - Queue icon shows yellow badge with count of pending queue items + - Archive icon shows blue badge with count of pending uploads (virtual printer review items) + - Badges auto-update every 5 seconds and on window focus + +### Changed +- Virtual printer mode labels: "Immediate" → "Archive", "Queue for Review" → "Review" +- Queue page printer filter now includes "Unassigned" option + ## [0.1.6b14] - 2026-01-17 ### Fixed diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 4843f0525..154dab49c 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -64,7 +64,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: @router.get("/", response_model=list[PrintQueueItemResponse]) async def list_queue( - printer_id: int | None = Query(None, description="Filter by printer"), + printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"), status: str | None = Query(None, description="Filter by status"), db: AsyncSession = Depends(get_db), ): @@ -72,11 +72,15 @@ async def list_queue( query = ( select(PrintQueueItem) .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer)) - .order_by(PrintQueueItem.printer_id, PrintQueueItem.position) + .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position) ) if printer_id is not None: - query = query.where(PrintQueueItem.printer_id == printer_id) + if printer_id == -1: + # Special value: filter for unassigned items + query = query.where(PrintQueueItem.printer_id.is_(None)) + else: + query = query.where(PrintQueueItem.printer_id == printer_id) if status: query = query.where(PrintQueueItem.status == status) @@ -91,22 +95,31 @@ async def add_to_queue( db: AsyncSession = Depends(get_db), ): """Add an item to the print queue.""" - # Validate printer exists - result = await db.execute(select(Printer).where(Printer.id == data.printer_id)) - if not result.scalar_one_or_none(): - raise HTTPException(400, "Printer not found") + # Validate printer exists (if assigned) + if data.printer_id is not None: + result = await db.execute(select(Printer).where(Printer.id == data.printer_id)) + if not result.scalar_one_or_none(): + raise HTTPException(400, "Printer not found") # Validate archive exists result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id)) if not result.scalar_one_or_none(): raise HTTPException(400, "Archive not found") - # Get next position for this printer - result = await db.execute( - select(func.max(PrintQueueItem.position)) - .where(PrintQueueItem.printer_id == data.printer_id) - .where(PrintQueueItem.status == "pending") - ) + # Get next position for this printer (or for unassigned items) + if data.printer_id is not None: + result = await db.execute( + select(func.max(PrintQueueItem.position)) + .where(PrintQueueItem.printer_id == data.printer_id) + .where(PrintQueueItem.status == "pending") + ) + else: + # For unassigned items, get max position across all unassigned + result = await db.execute( + select(func.max(PrintQueueItem.position)) + .where(PrintQueueItem.printer_id.is_(None)) + .where(PrintQueueItem.status == "pending") + ) max_pos = result.scalar() or 0 item = PrintQueueItem( @@ -127,7 +140,7 @@ async def add_to_queue( # Load relationships for response await db.refresh(item, ["archive", "printer"]) - logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id}") + logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id or 'unassigned'}") # MQTT relay - publish queue job added try: @@ -176,8 +189,8 @@ async def update_queue_item( update_data = data.model_dump(exclude_unset=True) - # Validate new printer_id if being changed - if "printer_id" in update_data: + # Validate new printer_id if being changed (and not None) + if "printer_id" in update_data and update_data["printer_id"] is not None: result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"])) if not result.scalar_one_or_none(): raise HTTPException(400, "Printer not found") diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index a1542d79e..98ed0ce36 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -513,7 +513,7 @@ async def export_backup( for qi in queue_items: backup["print_queue"].append( { - "printer_serial": printer_id_to_serial.get(qi.printer_id), + "printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None, "archive_hash": archive_id_to_hash.get(qi.archive_id), "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None, "position": qi.position, @@ -521,6 +521,7 @@ async def export_backup( "require_previous_success": qi.require_previous_success, "auto_off_after": qi.auto_off_after, "manual_start": qi.manual_start, + "ams_mapping": qi.ams_mapping, "status": qi.status, "started_at": qi.started_at.isoformat() if qi.started_at else None, "completed_at": qi.completed_at.isoformat() if qi.completed_at else None, @@ -1519,32 +1520,44 @@ async def import_backup( skipped_details["print_queue"] = [] for qi_data in backup["print_queue"]: - printer_serial = qi_data.get("printer_serial") + printer_serial = qi_data.get("printer_serial") # Can be None for unassigned items archive_hash = qi_data.get("archive_hash") - if not printer_serial or not archive_hash: + # Archive is required, but printer can be None (unassigned) + if not archive_hash: skipped["print_queue"] += 1 continue - printer_id = printer_serial_to_id.get(printer_serial) + # Look up printer_id (None if unassigned or printer not found) + printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None archive_id = archive_hash_to_id.get(archive_hash) - if not printer_id or not archive_id: + # Archive must exist, but printer is optional (unassigned items) + if not archive_id: skipped["print_queue"] += 1 - skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8] if archive_hash else 'N/A'}") + skipped_details["print_queue"].append( + f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}" + ) + continue + + # If printer_serial was specified but printer not found, skip + if printer_serial and not printer_id: + skipped["print_queue"] += 1 + skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}") continue project_name = qi_data.get("project_name") project_id = project_name_to_id.get(project_name) if project_name else None qi = PrintQueueItem( - printer_id=printer_id, + printer_id=printer_id, # Can be None for unassigned items archive_id=archive_id, project_id=project_id, position=qi_data.get("position", 0), require_previous_success=qi_data.get("require_previous_success", False), auto_off_after=qi_data.get("auto_off_after", False), manual_start=qi_data.get("manual_start", False), + ams_mapping=qi_data.get("ams_mapping"), status=qi_data.get("status", "pending"), error_message=qi_data.get("error_message"), ) @@ -1887,11 +1900,16 @@ async def update_virtual_printer_settings( new_model = model if model is not None else current_model # Validate mode - if new_mode not in ("immediate", "queue"): + # "review" is the new name for "queue" (pending review before archiving) + # "print_queue" archives and adds to print queue (unassigned) + if new_mode not in ("immediate", "queue", "review", "print_queue"): return JSONResponse( status_code=400, - content={"detail": "Mode must be 'immediate' or 'queue'"}, + content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"}, ) + # Normalize legacy "queue" to "review" for storage + if new_mode == "queue": + new_mode = "review" # Validate model if model is not None and model not in VIRTUAL_PRINTER_MODELS: diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 9f526cda1..c952ae696 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -405,6 +405,50 @@ async def run_migrations(conn): except Exception: pass + # Migration: Make printer_id nullable in print_queue for unassigned queue items + # SQLite doesn't support ALTER COLUMN, so we need to recreate the table + try: + # Check if printer_id is already nullable by trying to insert NULL + # This is a safe check that won't affect existing data + result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'")) + row = result.fetchone() + if row and "printer_id INTEGER NOT NULL" in (row[0] or ""): + # Need to migrate - printer_id is currently NOT NULL + await conn.execute( + text(""" + CREATE TABLE print_queue_new ( + id INTEGER PRIMARY KEY, + printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE, + archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, + position INTEGER DEFAULT 0, + scheduled_time DATETIME, + manual_start BOOLEAN DEFAULT 0, + require_previous_success BOOLEAN DEFAULT 0, + auto_off_after BOOLEAN DEFAULT 0, + ams_mapping TEXT, + status VARCHAR(20) DEFAULT 'pending', + started_at DATETIME, + completed_at DATETIME, + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + await conn.execute( + text(""" + INSERT INTO print_queue_new + SELECT id, printer_id, archive_id, project_id, position, scheduled_time, + manual_start, require_previous_success, auto_off_after, ams_mapping, + status, started_at, completed_at, error_message, created_at + FROM print_queue + """) + ) + await conn.execute(text("DROP TABLE print_queue")) + await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue")) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 900c763cd..a1057b674 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -14,7 +14,7 @@ class PrintQueueItem(Base): id: Mapped[int] = mapped_column(primary_key=True) # Links - printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE")) + printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True) archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE")) project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index c9e7a2e23..12f0b0226 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -16,7 +16,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime) class PrintQueueItemCreate(BaseModel): - printer_id: int + printer_id: int | None = None # None = unassigned, user assigns later archive_id: int scheduled_time: datetime | None = None # None = ASAP (next when idle) require_previous_success: bool = False @@ -39,7 +39,7 @@ class PrintQueueItemUpdate(BaseModel): class PrintQueueItemResponse(BaseModel): id: int - printer_id: int + printer_id: int | None # None = unassigned archive_id: int position: int scheduled_time: UTCDatetime diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index c03f4f851..2608efa27 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -54,7 +54,10 @@ class AppSettings(BaseModel): # Virtual Printer virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads") virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication") - virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'") + virtual_printer_mode: str = Field( + default="immediate", + description="Mode: 'immediate' (archive now), 'review' (pending review), or 'print_queue' (add to print queue)", + ) # Dark mode theme settings dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant") diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 15b96121f..d298ac10a 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -290,11 +290,16 @@ class VirtualPrinterManager: # Store file reference for MQTT correlation self._pending_files[file_path.name] = file_path - # In immediate mode, archive right away - # In queue mode, create pending upload record + # Handle based on mode: + # - immediate: archive right away + # - review: create pending upload record for user review before archiving + # - print_queue: archive and add to print queue (unassigned) if self._mode == "immediate": await self._archive_file(file_path, source_ip) + elif self._mode == "print_queue": + await self._add_to_print_queue(file_path, source_ip) else: + # "review" mode (or legacy "queue" mode) await self._queue_file(file_path, source_ip) async def _on_print_command(self, filename: str, data: dict) -> None: @@ -408,6 +413,74 @@ class VirtualPrinterManager: except Exception as e: logger.error(f"Error queueing file: {e}") + async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None: + """Archive file and add to print queue (unassigned). + + Args: + file_path: Path to the 3MF file + source_ip: IP address of uploader + """ + if not self._session_factory: + logger.error("Cannot add to print queue: no database session factory configured") + return + + # Only process 3MF files + if file_path.suffix.lower() != ".3mf": + logger.debug(f"Skipping non-3MF file: {file_path.name}") + self._pending_files.pop(file_path.name, None) + try: + file_path.unlink() + except Exception: + pass + return + + try: + from backend.app.models.print_queue import PrintQueueItem + from backend.app.services.archive import ArchiveService + + async with self._session_factory() as db: + service = ArchiveService(db) + + # First, archive the print + archive = await service.archive_print( + printer_id=None, # No physical printer + source_file=file_path, + print_data={ + "status": "archived", + "source": "virtual_printer", + "source_ip": source_ip, + }, + ) + + if archive: + logger.info(f"Archived virtual printer upload: {archive.id} - {archive.print_name}") + + # Now add to print queue (unassigned) + queue_item = PrintQueueItem( + printer_id=None, # Unassigned - user will assign later + archive_id=archive.id, + position=1, # Will be adjusted when assigned to a printer + status="pending", + ) + db.add(queue_item) + await db.commit() + + logger.info(f"Added to print queue (unassigned): queue_id={queue_item.id}, archive_id={archive.id}") + + # Clean up uploaded file (it's now copied to archive) + try: + file_path.unlink() + except Exception: + pass + + # Remove from pending + self._pending_files.pop(file_path.name, None) + else: + logger.error(f"Failed to archive file: {file_path.name}") + + except Exception as e: + logger.error(f"Error adding to print queue: {e}") + def get_status(self) -> dict: """Get virtual printer status. diff --git a/backend/tests/integration/test_virtual_printer_api.py b/backend/tests/integration/test_virtual_printer_api.py index 0815dda86..1549e457d 100644 --- a/backend/tests/integration/test_virtual_printer_api.py +++ b/backend/tests/integration/test_virtual_printer_api.py @@ -53,11 +53,31 @@ class TestVirtualPrinterSettingsAPI: @pytest.mark.integration async def test_update_mode(self, async_client: AsyncClient): """Verify mode can be updated.""" + response = await async_client.put("/api/v1/settings/virtual-printer?mode=review") + + assert response.status_code == 200 + result = response.json() + assert result["mode"] == "review" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mode_to_print_queue(self, async_client: AsyncClient): + """Verify mode can be set to print_queue.""" + response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue") + + assert response.status_code == 200 + result = response.json() + assert result["mode"] == "print_queue" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mode_legacy_queue_maps_to_review(self, async_client: AsyncClient): + """Verify legacy 'queue' mode is normalized to 'review'.""" response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue") assert response.status_code == 200 result = response.json() - assert result["mode"] == "queue" + assert result["mode"] == "review" # Legacy queue maps to review @pytest.mark.asyncio @pytest.mark.integration diff --git a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx index 6c7e4f090..5fb56124f 100644 --- a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx +++ b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx @@ -99,11 +99,11 @@ describe('VirtualPrinterSettings', () => { }); }); - it('renders archive mode section', async () => { + it('renders mode section', async () => { render(); await waitFor(() => { - expect(screen.getByText('Archive Mode')).toBeInTheDocument(); + expect(screen.getByText('Mode')).toBeInTheDocument(); }); }); @@ -259,28 +259,48 @@ describe('VirtualPrinterSettings', () => { }); }); - describe('archive mode', () => { - it('renders immediate mode option', async () => { + describe('mode selection', () => { + it('renders Archive mode option', async () => { render(); await waitFor(() => { - expect(screen.getByText('Immediate')).toBeInTheDocument(); - expect( - screen.getByText('Archive files as soon as they are uploaded') - ).toBeInTheDocument(); + expect(screen.getByText('Archive')).toBeInTheDocument(); + expect(screen.getByText('Archive files immediately')).toBeInTheDocument(); }); }); - it('renders queue mode option', async () => { + it('renders Review mode option', async () => { render(); await waitFor(() => { - expect(screen.getByText('Queue for Review')).toBeInTheDocument(); - expect(screen.getByText('Review and tag files before archiving')).toBeInTheDocument(); + expect(screen.getByText('Review')).toBeInTheDocument(); + expect(screen.getByText('Review and tag before archiving')).toBeInTheDocument(); }); }); - it('highlights current mode', async () => { + it('renders Queue mode option', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Queue')).toBeInTheDocument(); + expect(screen.getByText('Archive and add to print queue')).toBeInTheDocument(); + }); + }); + + it('highlights current mode (review)', async () => { + vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue( + createMockSettings({ mode: 'review' }) + ); + + render(); + + await waitFor(() => { + const reviewButton = screen.getByText('Review').closest('button'); + expect(reviewButton?.className).toContain('border-bambu-green'); + }); + }); + + it('highlights current mode (legacy queue maps to review)', async () => { vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue( createMockSettings({ mode: 'queue' }) ); @@ -288,30 +308,52 @@ describe('VirtualPrinterSettings', () => { render(); await waitFor(() => { - const queueButton = screen.getByText('Queue for Review').closest('button'); - expect(queueButton?.className).toContain('border-bambu-green'); + const reviewButton = screen.getByText('Review').closest('button'); + expect(reviewButton?.className).toContain('border-bambu-green'); }); }); - it('changes mode on click', async () => { + it('changes mode to review on click', async () => { const user = userEvent.setup(); vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue( - createMockSettings({ mode: 'queue' }) + createMockSettings({ mode: 'review' }) ); render(); await waitFor(() => { - expect(screen.getByText('Queue for Review')).toBeInTheDocument(); + expect(screen.getByText('Review')).toBeInTheDocument(); }); - const queueButton = screen.getByText('Queue for Review').closest('button'); + const reviewButton = screen.getByText('Review').closest('button'); + if (reviewButton) { + await user.click(reviewButton); + } + + await waitFor(() => { + expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'review' }); + }); + }); + + it('changes mode to print_queue on click', async () => { + const user = userEvent.setup(); + vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue( + createMockSettings({ mode: 'print_queue' }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Queue')).toBeInTheDocument(); + }); + + const queueButton = screen.getByText('Queue').closest('button'); if (queueButton) { await user.click(queueButton); } await waitFor(() => { - expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' }); + expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'print_queue' }); }); }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ca6337aad..dc1870708 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -814,7 +814,7 @@ export interface DiscoveredTasmotaDevice { // Print Queue types export interface PrintQueueItem { id: number; - printer_id: number; + printer_id: number | null; // null = unassigned archive_id: number; position: number; scheduled_time: string | null; @@ -834,7 +834,7 @@ export interface PrintQueueItem { } export interface PrintQueueItemCreate { - printer_id: number; + printer_id?: number | null; // null = unassigned archive_id: number; scheduled_time?: string | null; require_previous_success?: boolean; @@ -844,7 +844,7 @@ export interface PrintQueueItemCreate { } export interface PrintQueueItemUpdate { - printer_id?: number; + printer_id?: number | null; // null = unassign position?: number; scheduled_time?: string | null; require_previous_success?: boolean; @@ -2516,7 +2516,7 @@ export const discoveryApi = { export interface VirtualPrinterStatus { enabled: boolean; running: boolean; - mode: 'immediate' | 'queue'; + mode: 'immediate' | 'queue' | 'review' | 'print_queue'; // 'queue' is legacy, normalized to 'review' name: string; serial: string; model: string; @@ -2527,7 +2527,7 @@ export interface VirtualPrinterStatus { export interface VirtualPrinterSettings { enabled: boolean; access_code_set: boolean; - mode: 'immediate' | 'queue'; + mode: 'immediate' | 'queue' | 'review' | 'print_queue'; // 'queue' is legacy, normalized to 'review' model: string; status: VirtualPrinterStatus; } @@ -2558,7 +2558,7 @@ export const virtualPrinterApi = { updateSettings: (data: { enabled?: boolean; access_code?: string; - mode?: 'immediate' | 'queue'; + mode?: 'immediate' | 'review' | 'print_queue'; model?: string; }) => { const params = new URLSearchParams(); diff --git a/frontend/src/components/EditQueueItemModal.tsx b/frontend/src/components/EditQueueItemModal.tsx index c1eff404e..0191e5807 100644 --- a/frontend/src/components/EditQueueItemModal.tsx +++ b/frontend/src/components/EditQueueItemModal.tsx @@ -17,7 +17,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { const queryClient = useQueryClient(); const { showToast } = useToast(); - const [printerId, setPrinterId] = useState(item.printer_id); + const [printerId, setPrinterId] = useState(item.printer_id); // Check if scheduled_time is a "placeholder" far-future date (more than 6 months out) const isPlaceholderDate = item.scheduled_time && @@ -69,8 +69,8 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { // Fetch printer status when a printer is selected const { data: printerStatus } = useQuery({ queryKey: ['printer-status', printerId], - queryFn: () => api.getPrinterStatus(printerId), - enabled: !!printerId, + queryFn: () => api.getPrinterStatus(printerId!), + enabled: printerId !== null, }); // Clear manual mappings when printer changes (but not on initial load) @@ -370,21 +370,31 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { No printers configured ) : ( - + <> + + {printerId === null && ( +

+ + Assign a printer to enable printing +

+ )} + )} {/* Filament Mapping Section */} - {printerId && hasFilamentReqs && ( + {printerId !== null && hasFilamentReqs && (
- {/* Archive Mode */} + {/* Mode */}
-
Archive Mode
-
+
Mode
+
+
diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 666569fd6..a211af00e 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -195,9 +195,9 @@ function SortableQueueItem({
- + - {item.printer_name || `Printer #${item.printer_id}`} + {item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)} {item.print_time_seconds && ( @@ -600,10 +600,16 @@ export function QueuePage() {