瀏覽代碼

Add virtual printer Queue mode and sidebar badge indicators

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)
maziggy 4 月之前
父節點
當前提交
6fd11ddc77

+ 21 - 0
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

+ 29 - 16
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")

+ 27 - 9
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 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] if archive_hash else 'N/A'}")
+                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:

+ 44 - 0
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."""

+ 1 - 1
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)
 

+ 2 - 2
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

+ 4 - 1
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")

+ 75 - 2
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.
 

+ 21 - 1
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

+ 61 - 19
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(<VirtualPrinterSettings />);
 
       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(<VirtualPrinterSettings />);
 
       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(<VirtualPrinterSettings />);
 
       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(<VirtualPrinterSettings />);
+
+      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(<VirtualPrinterSettings />);
+
+      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(<VirtualPrinterSettings />);
 
       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(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Review')).toBeInTheDocument();
+      });
+
+      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(<VirtualPrinterSettings />);
 
       await waitFor(() => {
-        expect(screen.getByText('Queue for Review')).toBeInTheDocument();
+        expect(screen.getByText('Queue')).toBeInTheDocument();
       });
 
-      const queueButton = screen.getByText('Queue for Review').closest('button');
+      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' });
       });
     });
   });

+ 6 - 6
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();

+ 24 - 14
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<number>(item.printer_id);
+  const [printerId, setPrinterId] = useState<number | null>(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
                 </div>
               ) : (
-                <select
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  value={printerId}
-                  onChange={(e) => setPrinterId(Number(e.target.value))}
-                  required
-                >
-                  {printers?.map((p) => (
-                    <option key={p.id} value={p.id}>{p.name}</option>
-                  ))}
-                </select>
+                <>
+                  <select
+                    className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
+                      printerId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
+                    }`}
+                    value={printerId ?? ''}
+                    onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
+                  >
+                    <option value="">-- Select a printer --</option>
+                    {printers?.map((p) => (
+                      <option key={p.id} value={p.id}>{p.name}</option>
+                    ))}
+                  </select>
+                  {printerId === null && (
+                    <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
+                      <AlertCircle className="w-3 h-3" />
+                      Assign a printer to enable printing
+                    </p>
+                  )}
+                </>
               )}
             </div>
 
             {/* Filament Mapping Section */}
-            {printerId && hasFilamentReqs && (
+            {printerId !== null && hasFilamentReqs && (
               <div>
                 <button
                   type="button"

+ 36 - 2
frontend/src/components/Layout.tsx

@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
-import { api, supportApi } from '../api/client';
+import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 
@@ -126,6 +126,26 @@ export function Layout() {
     refetchInterval: 60 * 1000, // Refresh every minute
   });
 
+  // Fetch pending queue items count for badge
+  const { data: queueItems } = useQuery({
+    queryKey: ['queue', 'pending'],
+    queryFn: () => api.getQueue(undefined, 'pending'),
+    staleTime: 5 * 1000, // 5 seconds
+    refetchInterval: 5 * 1000, // Refresh every 5 seconds
+    refetchOnWindowFocus: true,
+  });
+  const pendingQueueCount = queueItems?.length ?? 0;
+
+  // Fetch pending uploads count for archive badge (virtual printer review items)
+  const { data: pendingUploadsData } = useQuery({
+    queryKey: ['pending-uploads', 'count'],
+    queryFn: pendingUploadsApi.getCount,
+    staleTime: 5 * 1000, // 5 seconds
+    refetchInterval: 5 * 1000, // Refresh every 5 seconds
+    refetchOnWindowFocus: true,
+  });
+  const pendingUploadsCount = pendingUploadsData?.count ?? 0;
+
   // Calculate debug duration client-side for real-time updates
   const [debugDuration, setDebugDuration] = useState<number | null>(null);
   useEffect(() => {
@@ -418,6 +438,11 @@ export function Layout() {
                 if (!navItem) return null;
 
                 const { to, icon: Icon, labelKey } = navItem;
+                const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
+                const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
+                const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
+                const showBadge = showQueueBadge || showArchiveBadge;
+
                 return (
                   <li
                     key={id}
@@ -449,7 +474,16 @@ export function Layout() {
                       {sidebarExpanded && !isMobile && (
                         <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
                       )}
-                      <Icon className="w-5 h-5 flex-shrink-0" />
+                      <div className="relative">
+                        <Icon className="w-5 h-5 flex-shrink-0" />
+                        {showBadge && (
+                          <span className={`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${
+                            showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
+                          }`}>
+                            {badgeCount > 99 ? '99+' : badgeCount}
+                          </span>
+                        )}
+                      </div>
                       {(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
                     </NavLink>
                   </li>

+ 33 - 14
frontend/src/components/VirtualPrinterSettings.tsx

@@ -12,7 +12,7 @@ export function VirtualPrinterSettings() {
 
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localAccessCode, setLocalAccessCode] = useState('');
-  const [localMode, setLocalMode] = useState<'immediate' | 'queue'>('immediate');
+  const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
@@ -34,14 +34,19 @@ export function VirtualPrinterSettings() {
   useEffect(() => {
     if (settings) {
       setLocalEnabled(settings.enabled);
-      setLocalMode(settings.mode);
+      // Map legacy 'queue' mode to 'review'
+      let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
+      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
+        mode = 'immediate'; // fallback
+      }
+      setLocalMode(mode);
       setLocalModel(settings.model);
     }
   }, [settings]);
 
   // Update mutation
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue'; model?: string }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -53,7 +58,9 @@ export function VirtualPrinterSettings() {
       // Revert local state on error
       if (settings) {
         setLocalEnabled(settings.enabled);
-        setLocalMode(settings.mode);
+        // Map legacy 'queue' mode to 'review'
+        const mode = settings.mode === 'queue' ? 'review' : settings.mode;
+        setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
         setLocalModel(settings.model);
       }
       setPendingAction(null);
@@ -96,7 +103,7 @@ export function VirtualPrinterSettings() {
     setLocalAccessCode(''); // Clear after saving
   };
 
-  const handleModeChange = (mode: 'immediate' | 'queue') => {
+  const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
     setLocalMode(mode);
     setPendingAction('mode');
     updateMutation.mutate({ mode });
@@ -251,10 +258,10 @@ export function VirtualPrinterSettings() {
             </p>
           </div>
 
-          {/* Archive Mode */}
+          {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Archive Mode</div>
-            <div className="grid grid-cols-2 gap-3">
+            <div className="text-white font-medium mb-2">Mode</div>
+            <div className="grid grid-cols-3 gap-3">
               <button
                 onClick={() => handleModeChange('immediate')}
                 disabled={pendingAction === 'mode'}
@@ -264,20 +271,32 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Immediate</div>
-                <div className="text-xs text-bambu-gray">Archive files as soon as they are uploaded</div>
+                <div className="text-white font-medium">Archive</div>
+                <div className="text-xs text-bambu-gray">Archive files immediately</div>
+              </button>
+              <button
+                onClick={() => handleModeChange('review')}
+                disabled={pendingAction === 'mode'}
+                className={`p-3 rounded-lg border text-left transition-colors ${
+                  localMode === 'review'
+                    ? 'border-bambu-green bg-bambu-green/10'
+                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                }`}
+              >
+                <div className="text-white font-medium">Review</div>
+                <div className="text-xs text-bambu-gray">Review and tag before archiving</div>
               </button>
               <button
-                onClick={() => handleModeChange('queue')}
+                onClick={() => handleModeChange('print_queue')}
                 disabled={pendingAction === 'mode'}
                 className={`p-3 rounded-lg border text-left transition-colors ${
-                  localMode === 'queue'
+                  localMode === 'print_queue'
                     ? 'border-bambu-green bg-bambu-green/10'
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Queue for Review</div>
-                <div className="text-xs text-bambu-gray">Review and tag files before archiving</div>
+                <div className="text-white font-medium">Queue</div>
+                <div className="text-xs text-bambu-gray">Archive and add to print queue</div>
               </button>
             </div>
           </div>

+ 10 - 4
frontend/src/pages/QueuePage.tsx

@@ -195,9 +195,9 @@ function SortableQueueItem({
           </div>
 
           <div className="flex items-center gap-3 text-sm text-bambu-gray">
-            <span className="flex items-center gap-1.5">
+            <span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
               <Printer className="w-3.5 h-3.5" />
-              {item.printer_name || `Printer #${item.printer_id}`}
+              {item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
             </span>
             {item.print_time_seconds && (
               <span className="flex items-center gap-1.5">
@@ -600,10 +600,16 @@ export function QueuePage() {
       <div className="flex items-center gap-4 mb-6">
         <select
           className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-          value={filterPrinter || ''}
-          onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
+          value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
+          onChange={(e) => {
+            const val = e.target.value;
+            if (val === 'unassigned') setFilterPrinter(-1);
+            else if (val === '') setFilterPrinter(null);
+            else setFilterPrinter(Number(val));
+          }}
         >
           <option value="">All Printers</option>
+          <option value="unassigned">Unassigned</option>
           {printers?.map((p) => (
             <option key={p.id} value={p.id}>{p.name}</option>
           ))}

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


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


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


+ 2 - 2
static/index.html

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

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