Procházet zdrojové kódy

Add batch print quantity to print/schedule dialog (#342)

maziggy před 1 měsícem
rodič
revize
3270179090

+ 1 - 0
CHANGELOG.md

@@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default. Requested by @Mofoss.
 - **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default. Requested by @Mofoss.
 - **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts. Requested by @Percy2Live.
 - **REST/Webhook Smart Plug Type** ([#472](https://github.com/maziggy/bambuddy/issues/472)) — New "REST" smart plug type for controlling power via generic HTTP APIs. Works with any home automation platform that has an HTTP endpoint (openHAB, ioBroker, FHEM, Node-RED, etc.). Configure separate ON/OFF URLs with custom HTTP methods (GET/POST/PUT/PATCH), request bodies, and headers. Optional status polling via a GET endpoint with JSON path extraction for state, power, and energy monitoring. Fully controllable — supports auto on/off with prints, daily scheduling, sidebar quick-toggle, and power alerts. Requested by @Percy2Live.
 - **Configurable Default Print Options** ([#858](https://github.com/maziggy/bambuddy/issues/858)) — Print options (bed levelling, flow calibration, vibration calibration, first layer inspection, timelapse) now have configurable defaults in Settings → Workflow. Set your preferred defaults once and every new print dialog starts with those values. Still overridable per print. Requested by @NoahTingey.
 - **Configurable Default Print Options** ([#858](https://github.com/maziggy/bambuddy/issues/858)) — Print options (bed levelling, flow calibration, vibration calibration, first layer inspection, timelapse) now have configurable defaults in Settings → Workflow. Set your preferred defaults once and every new print dialog starts with those values. Still overridable per print. Requested by @NoahTingey.
+- **Batch Print Quantity** ([#342](https://github.com/maziggy/bambuddy/issues/342)) — Print multiple copies of a file in one step. The print and schedule dialogs now have a quantity field — set it to any number and the system creates that many queue items automatically. When quantity is greater than one, items are grouped into a batch for tracking. In the direct print dialog, the first copy prints immediately while the remaining copies are queued. The queue page shows a batch badge on grouped items. Batch progress and cancellation are available via the API. Requested by @cimdDev.
 
 
 ### Improved
 ### Improved
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.

+ 1 - 0
README.md

@@ -108,6 +108,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - Print queue with drag-and-drop and timeline schedule view
 - Print queue with drag-and-drop and timeline schedule view
 - Multi-printer selection (send to multiple printers at once)
 - Multi-printer selection (send to multiple printers at once)
+- Batch print quantity (print multiple copies — set quantity in the print/schedule dialog, first copy prints immediately, rest are queued)
 - Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)
 - Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)
 - Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow
 - Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering

+ 183 - 32
backend/app/api/routes/print_queue.py

@@ -18,10 +18,12 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
+from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
+    PrintBatchResponse,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
     PrintQueueItemCreate,
@@ -209,6 +211,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # User tracking (Issue #206)
         # User tracking (Issue #206)
         "created_by_id": item.created_by_id,
         "created_by_id": item.created_by_id,
         "created_by_username": item.created_by.username if item.created_by else None,
         "created_by_username": item.created_by.username if item.created_by else None,
+        # Batch grouping
+        "batch_id": item.batch_id,
+        "batch_name": item.batch.name if item.batch else None,
     }
     }
     response = PrintQueueItemResponse(**item_dict)
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
     if item.archive:
@@ -281,6 +286,7 @@ async def list_queue(
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
             selectinload(PrintQueueItem.created_by),
+            selectinload(PrintQueueItem.batch),
         )
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
     )
@@ -407,6 +413,36 @@ async def add_to_queue(
             all_types = existing_types | set(override_types)
             all_types = existing_types | set(override_types)
             required_filament_types = json.dumps(sorted(all_types))
             required_filament_types = json.dumps(sorted(all_types))
 
 
+    # Validate quantity
+    quantity = max(1, data.quantity)
+
+    # Create batch if quantity > 1
+    batch = None
+    batch_id = None
+    if quantity > 1:
+        # Derive batch name from source file
+        batch_name_base = "Batch"
+        if archive:
+            batch_name_base = archive.print_name or archive.filename or "Batch"
+        elif library_file:
+            if library_file.file_metadata:
+                batch_name_base = library_file.file_metadata.get("print_name") or library_file.filename
+            else:
+                batch_name_base = library_file.filename
+        batch_name_base = batch_name_base.replace(".gcode.3mf", "").replace(".3mf", "")
+
+        batch = PrintBatch(
+            name=f"{batch_name_base} ×{quantity}",
+            archive_id=data.archive_id,
+            library_file_id=data.library_file_id,
+            quantity=quantity,
+            status="active",
+            created_by_id=current_user.id if current_user else None,
+        )
+        db.add(batch)
+        await db.flush()  # Get batch.id before creating items
+        batch_id = batch.id
+
     # Get next position for this printer (or for unassigned/model-based items)
     # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
     if data.printer_id is not None:
         result = await db.execute(
         result = await db.execute(
@@ -423,40 +459,48 @@ async def add_to_queue(
         )
         )
     max_pos = result.scalar() or 0
     max_pos = result.scalar() or 0
 
 
-    item = PrintQueueItem(
-        printer_id=data.printer_id,
-        target_model=target_model_norm,
-        target_location=data.target_location,
-        required_filament_types=required_filament_types,
-        filament_overrides=filament_overrides_json,
-        archive_id=data.archive_id,
-        library_file_id=data.library_file_id,
-        scheduled_time=data.scheduled_time,
-        require_previous_success=data.require_previous_success,
-        auto_off_after=data.auto_off_after,
-        manual_start=data.manual_start,
-        ams_mapping=json.dumps(data.ams_mapping) if data.ams_mapping else None,
-        plate_id=data.plate_id,
-        bed_levelling=data.bed_levelling,
-        flow_cali=data.flow_cali,
-        vibration_cali=data.vibration_cali,
-        layer_inspect=data.layer_inspect,
-        timelapse=data.timelapse,
-        use_ams=data.use_ams,
-        position=max_pos + 1,
-        status="pending",
-        created_by_id=current_user.id if current_user else None,
-    )
-    db.add(item)
+    ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
+    items = []
+    for i in range(quantity):
+        item = PrintQueueItem(
+            printer_id=data.printer_id,
+            target_model=target_model_norm,
+            target_location=data.target_location,
+            required_filament_types=required_filament_types,
+            filament_overrides=filament_overrides_json,
+            archive_id=data.archive_id,
+            library_file_id=data.library_file_id,
+            scheduled_time=data.scheduled_time,
+            require_previous_success=data.require_previous_success,
+            auto_off_after=data.auto_off_after,
+            manual_start=data.manual_start,
+            ams_mapping=ams_mapping_json,
+            plate_id=data.plate_id,
+            bed_levelling=data.bed_levelling,
+            flow_cali=data.flow_cali,
+            vibration_cali=data.vibration_cali,
+            layer_inspect=data.layer_inspect,
+            timelapse=data.timelapse,
+            use_ams=data.use_ams,
+            position=max_pos + 1 + i,
+            status="pending",
+            created_by_id=current_user.id if current_user else None,
+            batch_id=batch_id,
+        )
+        db.add(item)
+        items.append(item)
+
     await db.commit()
     await db.commit()
-    await db.refresh(item)
 
 
-    # Load relationships for response
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    # Refresh the first item for the response
+    item = items[0]
+    await db.refresh(item)
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
-    logger.info("Added %s to queue for %s", source_name, target_desc)
+    qty_desc = f" (×{quantity})" if quantity > 1 else ""
+    logger.info("Added %s to queue for %s%s", source_name, target_desc, qty_desc)
 
 
     # MQTT relay - publish queue job added
     # MQTT relay - publish queue job added
     try:
     try:
@@ -481,6 +525,8 @@ async def add_to_queue(
             else f"Job #{item.id}"
             else f"Job #{item.id}"
         )
         )
         job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
         job_name = job_name.replace(".gcode.3mf", "").replace(".3mf", "")
+        if quantity > 1:
+            job_name = f"{job_name} ×{quantity}"
         target = (
         target = (
             item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
             item.printer.name if item.printer else (f"Any {item.target_model}" if target_model_norm else "Unassigned")
         )
         )
@@ -561,6 +607,106 @@ async def bulk_update_queue_items(
     )
     )
 
 
 
 
+# --- Batch endpoints ---
+
+
+@router.get("/batches", response_model=list[PrintBatchResponse])
+async def list_batches(
+    status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
+    """List all print batches with progress stats."""
+    query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
+    if status:
+        query = query.where(PrintBatch.status == status)
+    result = await db.execute(query)
+    batches = result.scalars().all()
+
+    responses = []
+    for batch in batches:
+        responses.append(await _build_batch_response(db, batch))
+    return responses
+
+
+@router.get("/batches/{batch_id}", response_model=PrintBatchResponse)
+async def get_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
+    """Get a print batch with progress stats."""
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+    return await _build_batch_response(db, batch)
+
+
+@router.delete("/batches/{batch_id}")
+async def cancel_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
+):
+    """Cancel all pending items in a batch and mark batch as cancelled."""
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+
+    # Cancel all pending queue items in this batch
+    result = await db.execute(
+        select(PrintQueueItem).where(and_(PrintQueueItem.batch_id == batch_id, PrintQueueItem.status == "pending"))
+    )
+    pending_items = result.scalars().all()
+    cancelled_count = 0
+    for item in pending_items:
+        item.status = "cancelled"
+        cancelled_count += 1
+
+    batch.status = "cancelled"
+    await db.commit()
+
+    return {"message": f"Batch cancelled, {cancelled_count} pending items cancelled"}
+
+
+async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBatchResponse:
+    """Build a batch response with derived counts from queue items."""
+    # Count queue items by status
+    result = await db.execute(
+        select(PrintQueueItem.status, func.count(PrintQueueItem.id))
+        .where(PrintQueueItem.batch_id == batch.id)
+        .group_by(PrintQueueItem.status)
+    )
+    status_counts = {row[0]: row[1] for row in result.fetchall()}
+
+    # Load created_by for username
+    created_by_username = None
+    if batch.created_by_id:
+        result = await db.execute(select(User).where(User.id == batch.created_by_id))
+        user = result.scalar_one_or_none()
+        if user:
+            created_by_username = user.username
+
+    return PrintBatchResponse(
+        id=batch.id,
+        name=batch.name,
+        archive_id=batch.archive_id,
+        library_file_id=batch.library_file_id,
+        quantity=batch.quantity,
+        status=batch.status,
+        created_at=batch.created_at,
+        created_by_id=batch.created_by_id,
+        created_by_username=created_by_username,
+        pending_count=status_counts.get("pending", 0),
+        printing_count=status_counts.get("printing", 0),
+        completed_count=status_counts.get("completed", 0),
+        failed_count=status_counts.get("failed", 0),
+        cancelled_count=status_counts.get("cancelled", 0),
+    )
+
+
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 async def get_queue_item(
 async def get_queue_item(
     item_id: int,
     item_id: int,
@@ -575,6 +721,7 @@ async def get_queue_item(
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
             selectinload(PrintQueueItem.created_by),
+            selectinload(PrintQueueItem.batch),
         )
         )
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
     )
     )
@@ -656,7 +803,7 @@ async def update_queue_item(
         setattr(item, field, value)
         setattr(item, field, value)
 
 
     await db.commit()
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
 
     logger.info("Updated queue item %s", item_id)
     logger.info("Updated queue item %s", item_id)
     return _enrich_response(item)
     return _enrich_response(item)
@@ -844,7 +991,11 @@ async def start_queue_item(
     """
     """
     result = await db.execute(
     result = await db.execute(
         select(PrintQueueItem)
         select(PrintQueueItem)
-        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.batch),
+        )
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
@@ -857,7 +1008,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     item.manual_start = False
     await db.commit()
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
 
     logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
     logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
     return _enrich_response(item)
     return _enrich_response(item)

+ 9 - 0
backend/app/core/database.py

@@ -95,6 +95,7 @@ async def init_db():
         notification_template,
         notification_template,
         orca_base_cache,
         orca_base_cache,
         pending_upload,
         pending_upload,
+        print_batch,
         print_log,
         print_log,
         print_queue,
         print_queue,
         printer,
         printer,
@@ -1609,6 +1610,14 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add batch_id column to print_queue for batch grouping
+    try:
+        await conn.execute(
+            text("ALTER TABLE print_queue ADD COLUMN batch_id INTEGER REFERENCES print_batches(id) ON DELETE SET NULL")
+        )
+    except OperationalError:
+        pass
+
     # Seed default settings keys that must exist on fresh install
     # Seed default settings keys that must exist on fresh install
     default_settings = [
     default_settings = [
         ("advanced_auth_enabled", "false"),
         ("advanced_auth_enabled", "false"),

+ 2 - 0
backend/app/models/__init__.py

@@ -14,6 +14,7 @@ from backend.app.models.notification import NotificationLog
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -44,6 +45,7 @@ __all__ = [
     "AMSSensorHistory",
     "AMSSensorHistory",
     "AmsLabel",
     "AmsLabel",
     "PendingUpload",
     "PendingUpload",
+    "PrintBatch",
     "LibraryFolder",
     "LibraryFolder",
     "LibraryFile",
     "LibraryFile",
     "User",
     "User",

+ 45 - 0
backend/app/models/print_batch.py

@@ -0,0 +1,45 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PrintBatch(Base):
+    """Batch grouping for multiple queue items created from the same file."""
+
+    __tablename__ = "print_batches"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+
+    # Source file (one of these)
+    archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
+    )
+
+    # Total requested quantity (for display — actual items may differ if cancelled)
+    quantity: Mapped[int] = mapped_column(Integer, default=1)
+
+    # Status: active, completed, cancelled
+    status: Mapped[str] = mapped_column(String(20), default="active")
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    # User tracking
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    # Relationships
+    archive: Mapped["PrintArchive | None"] = relationship()
+    library_file: Mapped["LibraryFile | None"] = relationship()
+    created_by: Mapped["User | None"] = relationship()
+    queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.library import LibraryFile  # noqa: E402
+from backend.app.models.print_queue import PrintQueueItem  # noqa: E402
+from backend.app.models.user import User  # noqa: E402

+ 3 - 0
backend/app/models/print_queue.py

@@ -33,6 +33,7 @@ class PrintQueueItem(Base):
         ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
         ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
     )
     )
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
 
 
     # Scheduling
     # Scheduling
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
@@ -84,11 +85,13 @@ class PrintQueueItem(Base):
     archive: Mapped["PrintArchive | None"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
+    batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
+from backend.app.models.print_batch import PrintBatch  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
 from backend.app.models.user import User  # noqa: E402
 from backend.app.models.user import User  # noqa: E402

+ 29 - 0
backend/app/schemas/print_queue.py

@@ -40,6 +40,8 @@ class PrintQueueItemCreate(BaseModel):
     layer_inspect: bool = False
     layer_inspect: bool = False
     timelapse: bool = False
     timelapse: bool = False
     use_ams: bool = True
     use_ams: bool = True
+    # Batch: create multiple copies (creates a batch if > 1)
+    quantity: int = 1
 
 
 
 
 class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
@@ -111,6 +113,10 @@ class PrintQueueItemResponse(BaseModel):
     created_by_id: int | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
     created_by_username: str | None = None
 
 
+    # Batch grouping
+    batch_id: int | None = None
+    batch_name: str | None = None
+
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
@@ -149,3 +155,26 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     updated_count: int
     updated_count: int
     skipped_count: int  # Items that were not pending
     skipped_count: int  # Items that were not pending
     message: str
     message: str
+
+
+class PrintBatchResponse(BaseModel):
+    """Response for a print batch with progress stats."""
+
+    id: int
+    name: str
+    archive_id: int | None = None
+    library_file_id: int | None = None
+    quantity: int
+    status: str
+    created_at: UTCDatetime
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+    # Derived counts
+    pending_count: int = 0
+    printing_count: int = 0
+    completed_count: int = 0
+    failed_count: int = 0
+    cancelled_count: int = 0
+
+    class Config:
+        from_attributes = True

+ 212 - 0
backend/tests/integration/test_print_queue_api.py

@@ -1423,3 +1423,215 @@ class TestAbortedStatusNormalisation:
                     pass
                     pass
 
 
         assert item.status == "completed"
         assert item.status == "completed"
+
+    # ========================================================================
+    # Batch quantity tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_default(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify quantity=1 (default) creates a single item with no batch."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["batch_id"] is None
+        assert result["batch_name"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_one_explicit(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify quantity=1 explicitly creates a single item with no batch."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 1,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["batch_id"] is None
+        assert result["batch_name"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_creates_batch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify quantity > 1 creates a batch and multiple queue items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # First item is returned, linked to a batch
+        assert result["batch_id"] is not None
+        assert result["batch_name"] is not None
+        assert "×3" in result["batch_name"]
+
+        # Verify all 3 items were created
+        list_response = await async_client.get("/api/v1/queue/")
+        items = list_response.json()
+        batch_items = [i for i in items if i["batch_id"] == result["batch_id"]]
+        assert len(batch_items) == 3
+        # All items should have the same settings
+        for item in batch_items:
+            assert item["printer_id"] == printer.id
+            assert item["archive_id"] == archive.id
+            assert item["status"] == "pending"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_sequential_positions(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify batch items get sequential positions."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        batch_id = response.json()["batch_id"]
+
+        list_response = await async_client.get("/api/v1/queue/")
+        items = list_response.json()
+        batch_items = sorted(
+            [i for i in items if i["batch_id"] == batch_id],
+            key=lambda i: i["position"],
+        )
+        positions = [i["position"] for i in batch_items]
+        assert positions == [positions[0], positions[0] + 1, positions[0] + 2]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_quantity_with_print_options(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify print options are applied to all batch items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 2,
+            "bed_levelling": False,
+            "timelapse": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        batch_id = response.json()["batch_id"]
+
+        list_response = await async_client.get("/api/v1/queue/")
+        batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
+        assert len(batch_items) == 2
+        for item in batch_items:
+            assert item["bed_levelling"] is False
+            assert item["timelapse"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify batch can be retrieved with progress stats."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # Create a batch of 3
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        batch_id = response.json()["batch_id"]
+
+        # Get batch
+        response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == batch_id
+        assert result["quantity"] == 3
+        assert result["status"] == "active"
+        assert result["pending_count"] == 3
+        assert result["printing_count"] == 0
+        assert result["completed_count"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_batches(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify batches can be listed."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # Create two batches
+        for qty in [2, 3]:
+            await async_client.post(
+                "/api/v1/queue/",
+                json={"printer_id": printer.id, "archive_id": archive.id, "quantity": qty},
+            )
+
+        response = await async_client.get("/api/v1/queue/batches")
+        assert response.status_code == 200
+        batches = response.json()
+        assert len(batches) >= 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify cancelling a batch cancels all pending items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "quantity": 3,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        batch_id = response.json()["batch_id"]
+
+        # Cancel the batch
+        response = await async_client.delete(f"/api/v1/queue/batches/{batch_id}")
+        assert response.status_code == 200
+
+        # Verify all items are cancelled
+        list_response = await async_client.get("/api/v1/queue/")
+        batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
+        for item in batch_items:
+            assert item["status"] == "cancelled"
+
+        # Verify batch status
+        batch_response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
+        assert batch_response.json()["status"] == "cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_batch_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent batch."""
+        response = await async_client.get("/api/v1/queue/batches/9999")
+        assert response.status_code == 404

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

@@ -48,6 +48,8 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
   archive_thumbnail: null,
   archive_thumbnail: null,
   printer_name: 'Test Printer',
   printer_name: 'Test Printer',
   print_time_seconds: 3600,
   print_time_seconds: 3600,
+  batch_id: null,
+  batch_name: null,
   ...overrides,
   ...overrides,
 });
 });
 
 
@@ -1057,4 +1059,85 @@ describe('PrintModal', () => {
       expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
       expect((queueRequests[2] as { plate_id: number }).plate_id).toBe(3);
     });
     });
   });
   });
+
+  describe('batch quantity', () => {
+    it('shows quantity input in reprint mode', () => {
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
+    });
+
+    it('shows quantity input in add-to-queue mode', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.getByLabelText('Quantity')).toBeInTheDocument();
+    });
+
+    it('does not show quantity input in edit-queue-item mode', () => {
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Benchy"
+          queueItem={createMockQueueItem()}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      expect(screen.queryByLabelText('Quantity')).not.toBeInTheDocument();
+    });
+
+    it('defaults quantity to 1', () => {
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Benchy"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      const input = screen.getByLabelText('Quantity') as HTMLInputElement;
+      expect(input.value).toBe('1');
+    });
+
+    it('quantity input has default value of 1 and accepts changes', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      const input = screen.getByLabelText('Quantity') as HTMLInputElement;
+      expect(input.value).toBe('1');
+
+      await user.tripleClick(input);
+      await user.keyboard('5');
+      expect(input.value).toBe('5');
+    });
+  });
 });
 });

+ 30 - 0
frontend/src/api/client.ts

@@ -1365,6 +1365,26 @@ export interface PrintQueueItem {
   // User tracking (Issue #206)
   // User tracking (Issue #206)
   created_by_id?: number | null;
   created_by_id?: number | null;
   created_by_username?: string | null;
   created_by_username?: string | null;
+  // Batch grouping
+  batch_id?: number | null;
+  batch_name?: string | null;
+}
+
+export interface PrintBatch {
+  id: number;
+  name: string;
+  archive_id: number | null;
+  library_file_id: number | null;
+  quantity: number;
+  status: string;
+  created_at: string;
+  created_by_id: number | null;
+  created_by_username: string | null;
+  pending_count: number;
+  printing_count: number;
+  completed_count: number;
+  failed_count: number;
+  cancelled_count: number;
 }
 }
 
 
 export interface PrintQueueItemCreate {
 export interface PrintQueueItemCreate {
@@ -1387,6 +1407,8 @@ export interface PrintQueueItemCreate {
   layer_inspect?: boolean;
   layer_inspect?: boolean;
   timelapse?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
   use_ams?: boolean;
+  // Batch: create multiple copies (creates a batch if > 1)
+  quantity?: number;
 }
 }
 
 
 export interface PrintQueueItemUpdate {
 export interface PrintQueueItemUpdate {
@@ -3448,6 +3470,14 @@ export const api = {
       method: 'PATCH',
       method: 'PATCH',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
+  // Batches
+  getBatches: (status?: string) => {
+    const params = status ? `?status=${status}` : '';
+    return request<PrintBatch[]>(`/queue/batches${params}`);
+  },
+  getBatch: (id: number) => request<PrintBatch>(`/queue/batches/${id}`),
+  cancelBatch: (id: number) =>
+    request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }),
 
 
   // K-Profiles
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>

+ 49 - 4
frontend/src/components/PrintModal/index.tsx

@@ -87,6 +87,9 @@ export function PrintModal({
   // Derived single-plate value for filament queries and single-select contexts
   // Derived single-plate value for filament queries and single-select contexts
   const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
   const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
 
 
+  // Quantity — number of copies (creates a batch if > 1)
+  const [quantity, setQuantity] = useState(1);
+
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
     if (mode === 'edit-queue-item' && queueItem) {
       return {
       return {
@@ -686,7 +689,9 @@ export function PrintModal({
             await updateQueueMutation.mutateAsync(updateData);
             await updateQueueMutation.mutateAsync(updateData);
           } else {
           } else {
             // Add-to-queue mode with model-based assignment
             // Add-to-queue mode with model-based assignment
-            await addToQueueMutation.mutateAsync(getQueueData(null, plateId));
+            const queueData = getQueueData(null, plateId);
+            if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
+            await addToQueueMutation.mutateAsync(queueData);
           }
           }
           results.success++;
           results.success++;
         } catch (error) {
         } catch (error) {
@@ -735,6 +740,12 @@ export function PrintModal({
                   ...printOptions,
                   ...printOptions,
                 });
                 });
               }
               }
+              // Queue remaining copies if quantity > 1
+              if (effectiveQuantity > 1) {
+                const queueData = getQueueData(printerId, plateId);
+                queueData.quantity = effectiveQuantity - 1;
+                await addToQueueMutation.mutateAsync(queueData);
+              }
             } else if (mode === 'edit-queue-item' && progressCounter === 1) {
             } else if (mode === 'edit-queue-item' && progressCounter === 1) {
               // Edit mode - update the original queue item for the first entry
               // Edit mode - update the original queue item for the first entry
               const printerMapping = getMappingForPrinter(printerId);
               const printerMapping = getMappingForPrinter(printerId);
@@ -756,6 +767,7 @@ export function PrintModal({
             } else {
             } else {
               // Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries
               // Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries
               const queueData = getQueueData(printerId, plateId);
               const queueData = getQueueData(printerId, plateId);
+              if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
               // Apply stagger offset for groups after the first
               // Apply stagger offset for groups after the first
               if (useStagger) {
               if (useStagger) {
                 const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
                 const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -825,18 +837,25 @@ export function PrintModal({
     return true;
     return true;
   }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);
   }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);
 
 
+  // Quantity only applies for single-printer or model-based assignment (not multi-printer)
+  const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
+
   // Modal title and action button text based on mode
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {
     const printerCount = selectedPrinters.length;
     const printerCount = selectedPrinters.length;
 
 
     if (mode === 'reprint') {
     if (mode === 'reprint') {
       const staggerReprint = willUseStagger && printerCount > 1;
       const staggerReprint = willUseStagger && printerCount > 1;
+      let submitText = staggerReprint
+        ? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
+        : printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print');
+      if (effectiveQuantity > 1) {
+        submitText = `${submitText} ×${effectiveQuantity}`;
+      }
       return {
       return {
         title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
         title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
         icon: Printer,
         icon: Printer,
-        submitText: staggerReprint
-          ? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
-          : printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
+        submitText,
         submitIcon: staggerReprint ? Calendar : Printer,
         submitIcon: staggerReprint ? Calendar : Printer,
         loadingText: submitProgress.total > 1
         loadingText: submitProgress.total > 1
           ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
           ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
@@ -850,6 +869,9 @@ export function PrintModal({
       } else if (printerCount > 1) {
       } else if (printerCount > 1) {
         submitText = t('queue.queueToPrinters', { count: printerCount });
         submitText = t('queue.queueToPrinters', { count: printerCount });
       }
       }
+      if (effectiveQuantity > 1) {
+        submitText = `${submitText} ×${effectiveQuantity}`;
+      }
       return {
       return {
         title: t('queue.schedulePrint'),
         title: t('queue.schedulePrint'),
         icon: Calendar,
         icon: Calendar,
@@ -1037,6 +1059,29 @@ export function PrintModal({
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
               <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
             )}
             )}
 
 
+            {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
+            {mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
+              <div className="flex items-center gap-3">
+                <label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
+                  {t('queue.quantity', 'Quantity')}
+                </label>
+                <input
+                  id="printQuantity"
+                  type="number"
+                  min={1}
+                  max={999}
+                  value={quantity}
+                  onChange={(e) => setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))}
+                  className="w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                />
+                {quantity > 1 && (
+                  <span className="text-xs text-bambu-gray">
+                    {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
+                  </span>
+                )}
+              </div>
+            )}
+
             {/* Stagger option for reprint mode with multiple printers */}
             {/* Stagger option for reprint mode with multiple printers */}
             {mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (
             {mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (
               <div className="space-y-2 pb-2">
               <div className="space-y-2 pb-2">

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
     clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
     plateNumber: 'Platte {{index}}',
     plateNumber: 'Platte {{index}}',
+    // Batch / quantity
+    quantity: 'Menge',
+    quantityHint: 'Erstellt {{count}} Warteschlangeneinträge',
+    activeBatches: 'Aktive Stapel',
+    batchProgress: '{{completed}} von {{total}} abgeschlossen',
+    cancelBatch: 'Verbleibende abbrechen',
+    batchCancelled: 'Verbleibende Stapeleinträge abgebrochen',
+    cancelBatchConfirmTitle: 'Stapel abbrechen',
+    cancelBatchConfirmMessage: 'Alle verbleibenden ausstehenden Einträge in diesem Stapel abbrechen?',
+    batch: 'Stapel',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'Aktuell druckend',
       currentlyPrinting: 'Aktuell druckend',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Plate cleared — ready for next print',
     clearPlateSuccess: 'Plate cleared — ready for next print',
     plateReady: 'Plate cleared — ready for next print',
     plateReady: 'Plate cleared — ready for next print',
     plateNumber: 'Plate {{index}}',
     plateNumber: 'Plate {{index}}',
+    // Batch / quantity
+    quantity: 'Quantity',
+    quantityHint: 'Creates {{count}} queue items',
+    activeBatches: 'Active Batches',
+    batchProgress: '{{completed}} of {{total}} completed',
+    cancelBatch: 'Cancel Remaining',
+    batchCancelled: 'Remaining batch items cancelled',
+    cancelBatchConfirmTitle: 'Cancel Batch',
+    cancelBatchConfirmMessage: 'Cancel all remaining pending items in this batch?',
+    batch: 'Batch',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'Currently Printing',
       currentlyPrinting: 'Currently Printing',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Plateau vidé — prêt pour l\'impression suivante',
     clearPlateSuccess: 'Plateau vidé — prêt pour l\'impression suivante',
     plateReady: 'Plateau vidé — prêt pour l\'impression suivante',
     plateReady: 'Plateau vidé — prêt pour l\'impression suivante',
     plateNumber: 'Plateau {{index}}',
     plateNumber: 'Plateau {{index}}',
+    // Batch / quantity
+    quantity: 'Quantité',
+    quantityHint: 'Crée {{count}} éléments de file d\'attente',
+    activeBatches: 'Lots actifs',
+    batchProgress: '{{completed}} sur {{total}} terminés',
+    cancelBatch: 'Annuler les restants',
+    batchCancelled: 'Éléments restants du lot annulés',
+    cancelBatchConfirmTitle: 'Annuler le lot',
+    cancelBatchConfirmMessage: 'Annuler tous les éléments en attente restants dans ce lot ?',
+    batch: 'Lot',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'En cours',
       currentlyPrinting: 'En cours',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Piatto liberato — pronto per la prossima stampa',
     clearPlateSuccess: 'Piatto liberato — pronto per la prossima stampa',
     plateReady: 'Piatto liberato — pronto per la prossima stampa',
     plateReady: 'Piatto liberato — pronto per la prossima stampa',
     plateNumber: 'Piatto {{index}}',
     plateNumber: 'Piatto {{index}}',
+    // Batch / quantity
+    quantity: 'Quantità',
+    quantityHint: 'Crea {{count}} elementi in coda',
+    activeBatches: 'Lotti attivi',
+    batchProgress: '{{completed}} di {{total}} completati',
+    cancelBatch: 'Annulla rimanenti',
+    batchCancelled: 'Elementi rimanenti del lotto annullati',
+    cancelBatchConfirmTitle: 'Annulla lotto',
+    cancelBatchConfirmMessage: 'Annullare tutti gli elementi in sospeso rimanenti in questo lotto?',
+    batch: 'Lotto',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'In stampa',
       currentlyPrinting: 'In stampa',

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

@@ -855,6 +855,16 @@ export default {
     clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
     clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
     plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
     plateNumber: 'プレート {{index}}',
     plateNumber: 'プレート {{index}}',
+    // Batch / quantity
+    quantity: '数量',
+    quantityHint: '{{count}}件のキューアイテムを作成',
+    activeBatches: 'アクティブなバッチ',
+    batchProgress: '{{total}}件中{{completed}}件完了',
+    cancelBatch: '残りをキャンセル',
+    batchCancelled: '残りのバッチアイテムをキャンセルしました',
+    cancelBatchConfirmTitle: 'バッチをキャンセル',
+    cancelBatchConfirmMessage: 'このバッチの残りの保留中アイテムをすべてキャンセルしますか?',
+    batch: 'バッチ',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: '印刷中',
       currentlyPrinting: '印刷中',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',
     clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',
     plateReady: 'Placa limpa — pronta para a próxima impressão',
     plateReady: 'Placa limpa — pronta para a próxima impressão',
     plateNumber: 'Placa {{index}}',
     plateNumber: 'Placa {{index}}',
+    // Batch / quantity
+    quantity: 'Quantidade',
+    quantityHint: 'Cria {{count}} itens na fila',
+    activeBatches: 'Lotes ativos',
+    batchProgress: '{{completed}} de {{total}} concluídos',
+    cancelBatch: 'Cancelar restantes',
+    batchCancelled: 'Itens restantes do lote cancelados',
+    cancelBatchConfirmTitle: 'Cancelar lote',
+    cancelBatchConfirmMessage: 'Cancelar todos os itens pendentes restantes neste lote?',
+    batch: 'Lote',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: 'Imprimindo Atualmente',
       currentlyPrinting: 'Imprimindo Atualmente',

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

@@ -856,6 +856,16 @@ export default {
     clearPlateSuccess: '打印板已清理 — 准备进行下一个打印',
     clearPlateSuccess: '打印板已清理 — 准备进行下一个打印',
     plateReady: '打印板已清理 — 准备进行下一个打印',
     plateReady: '打印板已清理 — 准备进行下一个打印',
     plateNumber: '板 {{index}}',
     plateNumber: '板 {{index}}',
+    // Batch / quantity
+    quantity: '数量',
+    quantityHint: '创建 {{count}} 个队列项目',
+    activeBatches: '活跃批次',
+    batchProgress: '已完成 {{completed}}/{{total}}',
+    cancelBatch: '取消剩余',
+    batchCancelled: '已取消剩余批次项目',
+    cancelBatchConfirmTitle: '取消批次',
+    cancelBatchConfirmMessage: '取消此批次中所有剩余的待处理项目?',
+    batch: '批次',
     // Sections
     // Sections
     sections: {
     sections: {
       currentlyPrinting: '正在打印',
       currentlyPrinting: '正在打印',

+ 5 - 0
frontend/src/pages/QueuePage.tsx

@@ -480,6 +480,11 @@ function SortableQueueItem({
                 <ExternalLink className="w-3.5 h-3.5" />
                 <ExternalLink className="w-3.5 h-3.5" />
               </Link>
               </Link>
             ) : null}
             ) : null}
+            {item.batch_name && (
+              <span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-purple-500/20 text-purple-300 rounded border border-purple-500/30">
+                {item.batch_name}
+              </span>
+            )}
           </div>
           </div>
 
 
           <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
           <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-DKoL3gto.js


+ 1 - 1
static/index.html

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

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