Просмотр исходного кода

Add queue bulk editing and fix milestone time display

New Features:
- Queue bulk edit: Select multiple pending items and edit printer
  assignment, print options, or cancel them at once (Issue #159)
- Tri-state toggles (unchanged/on/off) for selective field updates

Fixes:
- Progress milestone notifications showed wrong time (e.g., "17m" instead
  of "17h 47m") - fixed by converting remaining_time from minutes to
  seconds (Issue #157)
- File Manager folder names now show full name on hover tooltip (Issue #160)

Backend:
- Added PATCH /queue/bulk endpoint for bulk updates
- Route ordering fixed to prevent /bulk matching as /{item_id}

Frontend:
- Added checkbox selection to pending queue items
- Added bulk action toolbar with Select All, Edit Selected, Cancel Selected
- Created BulkEditModal with tri-state toggles for each setting

Tests:
- Added 7 integration tests for bulk update endpoint

Closes #159
maziggy 4 месяцев назад
Родитель
Сommit
e57fb87a52

+ 8 - 0
CHANGELOG.md

@@ -61,6 +61,12 @@ All notable changes to Bambuddy will be documented in this file.
   - Select All / Deselect All buttons
   - Select All / Deselect All buttons
   - Bulk download as ZIP when multiple files selected
   - Bulk download as ZIP when multiple files selected
   - Bulk delete for multiple files at once
   - Bulk delete for multiple files at once
+- **Queue Bulk Edit** - Select and edit multiple queue items at once (Issue #159):
+  - Checkbox selection for pending queue items
+  - Select All / Deselect All in toolbar
+  - Bulk edit: printer assignment, print options, queue options
+  - Bulk cancel selected items
+  - Tri-state toggles: unchanged / on / off for each setting
 
 
 ### Fixes
 ### Fixes
 - **Plate Calibration Persistence** - Fixed plate detection reference images not persisting after restart in Docker deployments
 - **Plate Calibration Persistence** - Fixed plate detection reference images not persisting after restart in Docker deployments
@@ -80,6 +86,8 @@ All notable changes to Bambuddy will be documented in this file.
   - Added pinch-to-zoom support for mobile/touch devices
   - Added pinch-to-zoom support for mobile/touch devices
   - Added touch-based panning when zoomed in
   - Added touch-based panning when zoomed in
   - Both embedded camera viewer and standalone camera page updated
   - Both embedded camera viewer and standalone camera page updated
+- **Progress Milestone Time** - Fixed milestone notifications showing wrong time (e.g., "17m" instead of "17h 47m") by converting remaining_time from minutes to seconds (Issue #157)
+- **File Manager Folder Tooltip** - Long folder names in File Manager navigation now show full name on hover (Issue #160)
 
 
 ## [0.1.6b11] - 2026-01-22
 ## [0.1.6b11] - 2026-01-22
 
 

+ 51 - 0
backend/app/api/routes/print_queue.py

@@ -15,6 +15,8 @@ from backend.app.models.library import LibraryFile
 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.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
+    PrintQueueBulkUpdate,
+    PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
     PrintQueueItemCreate,
     PrintQueueItemResponse,
     PrintQueueItemResponse,
     PrintQueueItemUpdate,
     PrintQueueItemUpdate,
@@ -201,6 +203,55 @@ async def add_to_queue(
     return _enrich_response(item)
     return _enrich_response(item)
 
 
 
 
+@router.patch("/bulk", response_model=PrintQueueBulkUpdateResponse)
+async def bulk_update_queue_items(
+    data: PrintQueueBulkUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Bulk update multiple queue items with the same values.
+
+    Only pending items can be updated. Non-pending items are skipped.
+    """
+    if not data.item_ids:
+        raise HTTPException(400, "No item IDs provided")
+
+    # Get fields to update (exclude item_ids and unset fields)
+    update_data = data.model_dump(exclude={"item_ids"}, exclude_unset=True)
+    if not update_data:
+        raise HTTPException(400, "No fields to update")
+
+    # Validate printer_id if being changed
+    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")
+
+    # Fetch all items
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
+    items = result.scalars().all()
+
+    updated_count = 0
+    skipped_count = 0
+
+    for item in items:
+        if item.status != "pending":
+            skipped_count += 1
+            continue
+
+        for field, value in update_data.items():
+            setattr(item, field, value)
+        updated_count += 1
+
+    await db.commit()
+
+    logger.info(f"Bulk updated {updated_count} queue items, skipped {skipped_count}")
+    return PrintQueueBulkUpdateResponse(
+        updated_count=updated_count,
+        skipped_count=skipped_count,
+        message=f"Updated {updated_count} items" + (f", skipped {skipped_count} non-pending" if skipped_count else ""),
+    )
+
+
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
 async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
 async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific queue item."""
     """Get a specific queue item."""

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

@@ -100,3 +100,30 @@ class PrintQueueReorderItem(BaseModel):
 
 
 class PrintQueueReorder(BaseModel):
 class PrintQueueReorder(BaseModel):
     items: list[PrintQueueReorderItem]
     items: list[PrintQueueReorderItem]
+
+
+class PrintQueueBulkUpdate(BaseModel):
+    """Bulk update multiple queue items with the same values."""
+
+    item_ids: list[int]
+    # Fields to update (all optional - only set fields are applied)
+    printer_id: int | None = None
+    scheduled_time: datetime | None = None
+    require_previous_success: bool | None = None
+    auto_off_after: bool | None = None
+    manual_start: bool | None = None
+    # Print options
+    bed_levelling: bool | None = None
+    flow_cali: bool | None = None
+    vibration_cali: bool | None = None
+    layer_inspect: bool | None = None
+    timelapse: bool | None = None
+    use_ams: bool | None = None
+
+
+class PrintQueueBulkUpdateResponse(BaseModel):
+    """Response for bulk update operation."""
+
+    updated_count: int
+    skipped_count: int  # Items that were not pending
+    message: str

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

@@ -734,3 +734,232 @@ class TestQueueLibraryFileSupport:
         assert our_item is not None
         assert our_item is not None
         assert our_item["library_file_name"] == "Custom Print Name"
         assert our_item["library_file_name"] == "Custom Print Name"
         assert our_item["print_time_seconds"] == 7200
         assert our_item["print_time_seconds"] == 7200
+
+
+class TestBulkUpdateEndpoint:
+    """Tests for the /queue/bulk endpoint."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Bulk Test Printer {counter}",
+                "ip_address": f"192.168.1.{150 + counter}",
+                "serial_number": f"TESTBULK{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"bulk_test_{counter}.3mf",
+                "print_name": f"Bulk Test Print {counter}",
+                "file_path": f"/tmp/bulk_test_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"bulkhash{counter:04d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+
+        async def _create_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": 1,
+                "bed_levelling": True,
+                "flow_cali": False,
+                "vibration_cali": True,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify bulk update can change a single field on multiple items."""
+        item1 = await queue_item_factory(bed_levelling=True)
+        item2 = await queue_item_factory(bed_levelling=True)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item1.id, item2.id], "bed_levelling": False},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["updated_count"] == 2
+        assert result["skipped_count"] == 0
+
+        # Verify items were updated
+        await db_session.refresh(item1)
+        await db_session.refresh(item2)
+        assert item1.bed_levelling is False
+        assert item2.bed_levelling is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify bulk update can change multiple fields at once."""
+        item1 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
+        item2 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={
+                "item_ids": [item1.id, item2.id],
+                "bed_levelling": False,
+                "flow_cali": True,
+                "manual_start": True,
+            },
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["updated_count"] == 2
+
+        await db_session.refresh(item1)
+        assert item1.bed_levelling is False
+        assert item1.flow_cali is True
+        assert item1.manual_start is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify bulk update skips non-pending items."""
+        pending_item = await queue_item_factory(status="pending", bed_levelling=True)
+        printing_item = await queue_item_factory(status="printing", bed_levelling=True)
+        completed_item = await queue_item_factory(status="completed", bed_levelling=True)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={
+                "item_ids": [pending_item.id, printing_item.id, completed_item.id],
+                "bed_levelling": False,
+            },
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["updated_count"] == 1
+        assert result["skipped_count"] == 2
+
+        # Only pending item should be updated
+        await db_session.refresh(pending_item)
+        await db_session.refresh(printing_item)
+        await db_session.refresh(completed_item)
+        assert pending_item.bed_levelling is False
+        assert printing_item.bed_levelling is True
+        assert completed_item.bed_levelling is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_change_printer(
+        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
+    ):
+        """Verify bulk update can reassign items to a different printer."""
+        new_printer = await printer_factory(name="New Target Printer")
+        item1 = await queue_item_factory()
+        item2 = await queue_item_factory()
+
+        original_printer_id = item1.printer_id
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item1.id, item2.id], "printer_id": new_printer.id},
+        )
+        assert response.status_code == 200
+
+        await db_session.refresh(item1)
+        await db_session.refresh(item2)
+        assert item1.printer_id == new_printer.id
+        assert item2.printer_id == new_printer.id
+        assert item1.printer_id != original_printer_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_empty_item_ids(self, async_client: AsyncClient):
+        """Verify 400 error when item_ids is empty."""
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [], "bed_levelling": False},
+        )
+        assert response.status_code == 400
+        assert "no item" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_no_fields(self, async_client: AsyncClient, queue_item_factory):
+        """Verify 400 error when no fields to update."""
+        item = await queue_item_factory()
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item.id]},
+        )
+        assert response.status_code == 400
+        assert "no fields" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_invalid_printer(self, async_client: AsyncClient, queue_item_factory):
+        """Verify 400 error when printer_id doesn't exist."""
+        item = await queue_item_factory()
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [item.id], "printer_id": 99999},
+        )
+        assert response.status_code == 400
+        assert "printer not found" in response.json()["detail"].lower()

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

@@ -1065,6 +1065,28 @@ export interface PrintQueueItemUpdate {
   use_ams?: boolean;
   use_ams?: boolean;
 }
 }
 
 
+export interface PrintQueueBulkUpdate {
+  item_ids: number[];
+  printer_id?: number | null;
+  scheduled_time?: string | null;
+  require_previous_success?: boolean;
+  auto_off_after?: boolean;
+  manual_start?: boolean;
+  // Print options
+  bed_levelling?: boolean;
+  flow_cali?: boolean;
+  vibration_cali?: boolean;
+  layer_inspect?: boolean;
+  timelapse?: boolean;
+  use_ams?: boolean;
+}
+
+export interface PrintQueueBulkUpdateResponse {
+  updated_count: number;
+  skipped_count: number;
+  message: string;
+}
+
 // MQTT Logging types
 // MQTT Logging types
 export interface MQTTLogEntry {
 export interface MQTTLogEntry {
   timestamp: string;
   timestamp: string;
@@ -2374,6 +2396,11 @@ export const api = {
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
   startQueueItem: (id: number) =>
   startQueueItem: (id: number) =>
     request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
     request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
+  bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
+    request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
 
 
   // K-Profiles
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>

+ 288 - 1
frontend/src/pages/QueuePage.tsx

@@ -41,10 +41,13 @@ import {
   ArrowUp,
   ArrowUp,
   ArrowDown,
   ArrowDown,
   Hand,
   Hand,
+  Check,
+  CheckSquare,
+  Square,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
-import type { PrintQueueItem } from '../api/client';
+import type { PrintQueueItem, PrintQueueBulkUpdate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -94,6 +97,163 @@ function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
   );
   );
 }
 }
 
 
+// Bulk edit modal for multiple queue items
+function BulkEditModal({
+  selectedCount,
+  printers,
+  onSave,
+  onClose,
+  isSaving,
+}: {
+  selectedCount: number;
+  printers: { id: number; name: string }[];
+  onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
+  onClose: () => void;
+  isSaving: boolean;
+}) {
+  const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
+  const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
+  const [autoOffAfter, setAutoOffAfter] = useState<boolean | 'unchanged'>('unchanged');
+  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState<boolean | 'unchanged'>('unchanged');
+  const [bedLevelling, setBedLevelling] = useState<boolean | 'unchanged'>('unchanged');
+  const [flowCali, setFlowCali] = useState<boolean | 'unchanged'>('unchanged');
+  const [vibrationCali, setVibrationCali] = useState<boolean | 'unchanged'>('unchanged');
+  const [layerInspect, setLayerInspect] = useState<boolean | 'unchanged'>('unchanged');
+  const [timelapse, setTimelapse] = useState<boolean | 'unchanged'>('unchanged');
+  const [useAms, setUseAms] = useState<boolean | 'unchanged'>('unchanged');
+
+  const handleSave = () => {
+    const data: Partial<PrintQueueBulkUpdate> = {};
+    if (printerId !== 'unchanged') data.printer_id = printerId;
+    if (manualStart !== 'unchanged') data.manual_start = manualStart;
+    if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter;
+    if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess;
+    if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling;
+    if (flowCali !== 'unchanged') data.flow_cali = flowCali;
+    if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali;
+    if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect;
+    if (timelapse !== 'unchanged') data.timelapse = timelapse;
+    if (useAms !== 'unchanged') data.use_ams = useAms;
+    onSave(data);
+  };
+
+  const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' ||
+    requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' ||
+    vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged';
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
+      <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">
+            Edit {selectedCount} Item{selectedCount !== 1 ? 's' : ''}
+          </h2>
+          <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        <div className="p-4 space-y-4">
+          <p className="text-sm text-bambu-gray">
+            Only changed settings will be applied to selected items.
+          </p>
+
+          {/* Printer Assignment */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-2">Printer</label>
+            <select
+              value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
+              onChange={(e) => {
+                const val = e.target.value;
+                if (val === 'unchanged') setPrinterId('unchanged');
+                else if (val === 'null') setPrinterId(null);
+                else setPrinterId(Number(val));
+              }}
+              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"
+            >
+              <option value="unchanged">— No change —</option>
+              <option value="null">Unassigned</option>
+              {printers.map(p => (
+                <option key={p.id} value={p.id}>{p.name}</option>
+              ))}
+            </select>
+          </div>
+
+          {/* Queue Options */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-2">Queue Options</label>
+            <div className="space-y-2">
+              <TriStateToggle label="Staged (manual start)" value={manualStart} onChange={setManualStart} />
+              <TriStateToggle label="Auto power off after print" value={autoOffAfter} onChange={setAutoOffAfter} />
+              <TriStateToggle label="Require previous success" value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} />
+            </div>
+          </div>
+
+          {/* Print Options */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-2">Print Options</label>
+            <div className="space-y-2">
+              <TriStateToggle label="Bed levelling" value={bedLevelling} onChange={setBedLevelling} />
+              <TriStateToggle label="Flow calibration" value={flowCali} onChange={setFlowCali} />
+              <TriStateToggle label="Vibration calibration" value={vibrationCali} onChange={setVibrationCali} />
+              <TriStateToggle label="First layer inspection" value={layerInspect} onChange={setLayerInspect} />
+              <TriStateToggle label="Timelapse" value={timelapse} onChange={setTimelapse} />
+              <TriStateToggle label="Use AMS" value={useAms} onChange={setUseAms} />
+            </div>
+          </div>
+        </div>
+
+        <div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
+          <Button variant="secondary" onClick={onClose}>Cancel</Button>
+          <Button
+            onClick={handleSave}
+            disabled={!hasChanges || isSaving}
+          >
+            {isSaving ? 'Saving...' : 'Apply Changes'}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Tri-state toggle for bulk edit (unchanged / on / off)
+function TriStateToggle({
+  label,
+  value,
+  onChange,
+}: {
+  label: string;
+  value: boolean | 'unchanged';
+  onChange: (val: boolean | 'unchanged') => void;
+}) {
+  return (
+    <div className="flex items-center justify-between py-1">
+      <span className="text-sm text-bambu-gray">{label}</span>
+      <div className="flex items-center gap-1 bg-bambu-dark rounded-lg p-0.5">
+        <button
+          onClick={() => onChange('unchanged')}
+          className={`px-2 py-1 text-xs rounded ${value === 'unchanged' ? 'bg-bambu-dark-tertiary text-white' : 'text-bambu-gray hover:text-white'}`}
+        >
+          —
+        </button>
+        <button
+          onClick={() => onChange(false)}
+          className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'}`}
+        >
+          Off
+        </button>
+        <button
+          onClick={() => onChange(true)}
+          className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'}`}
+        >
+          On
+        </button>
+      </div>
+    </div>
+  );
+}
+
 // Sortable queue item for drag and drop
 // Sortable queue item for drag and drop
 function SortableQueueItem({
 function SortableQueueItem({
   item,
   item,
@@ -105,6 +265,8 @@ function SortableQueueItem({
   onRequeue,
   onRequeue,
   onStart,
   onStart,
   timeFormat = 'system',
   timeFormat = 'system',
+  isSelected = false,
+  onToggleSelect,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
   position?: number;
   position?: number;
@@ -115,6 +277,8 @@ function SortableQueueItem({
   onRequeue: () => void;
   onRequeue: () => void;
   onStart: () => void;
   onStart: () => void;
   timeFormat?: TimeFormat;
   timeFormat?: TimeFormat;
+  isSelected?: boolean;
+  onToggleSelect?: () => void;
 }) {
 }) {
   const {
   const {
     attributes,
     attributes,
@@ -146,6 +310,23 @@ function SortableQueueItem({
       `}
       `}
     >
     >
       <div className="flex items-center gap-4 p-4">
       <div className="flex items-center gap-4 p-4">
+        {/* Selection checkbox for pending items */}
+        {isPending && onToggleSelect && (
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              onToggleSelect();
+            }}
+            className={`flex items-center justify-center w-6 h-6 rounded border transition-colors ${
+              isSelected
+                ? 'bg-bambu-green border-bambu-green text-white'
+                : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
+            }`}
+          >
+            {isSelected && <Check className="w-4 h-4" />}
+          </button>
+        )}
+
         {/* Drag handle or position number */}
         {/* Drag handle or position number */}
         {isPending ? (
         {isPending ? (
           <div
           <div
@@ -355,6 +536,8 @@ export function QueuePage() {
     type: 'cancel' | 'remove' | 'stop';
     type: 'cancel' | 'remove' | 'stop';
     item: PrintQueueItem;
     item: PrintQueueItem;
   } | null>(null);
   } | null>(null);
+  const [selectedItems, setSelectedItems] = useState<number[]>([]);
+  const [showBulkEditModal, setShowBulkEditModal] = useState(false);
   const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
   const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
     const saved = localStorage.getItem('queue.historySortBy');
     const saved = localStorage.getItem('queue.historySortBy');
     return (saved as 'date' | 'name' | 'printer') || 'date';
     return (saved as 'date' | 'name' | 'printer') || 'date';
@@ -473,6 +656,38 @@ export function QueuePage() {
     onError: () => showToast('Failed to clear history', 'error'),
     onError: () => showToast('Failed to clear history', 'error'),
   });
   });
 
 
+  const bulkUpdateMutation = useMutation({
+    mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      setSelectedItems([]);
+      setShowBulkEditModal(false);
+      showToast(result.message);
+    },
+    onError: () => showToast('Failed to update items', 'error'),
+  });
+
+  const bulkCancelMutation = useMutation({
+    mutationFn: async (ids: number[]) => {
+      for (const id of ids) {
+        await api.cancelQueueItem(id);
+      }
+      return ids.length;
+    },
+    onSuccess: (count) => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      setSelectedItems([]);
+      showToast(`Cancelled ${count} item${count !== 1 ? 's' : ''}`);
+    },
+    onError: () => showToast('Failed to cancel items', 'error'),
+  });
+
+  const handleToggleSelect = (id: number) => {
+    setSelectedItems(prev =>
+      prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
+    );
+  };
+
   const pendingItems = useMemo(() => {
   const pendingItems = useMemo(() => {
     const items = queue?.filter(i => i.status === 'pending') || [];
     const items = queue?.filter(i => i.status === 'pending') || [];
 
 
@@ -502,6 +717,16 @@ export function QueuePage() {
       return pendingSortAsc ? cmp : -cmp;
       return pendingSortAsc ? cmp : -cmp;
     });
     });
   }, [queue, pendingSortBy, pendingSortAsc]);
   }, [queue, pendingSortBy, pendingSortAsc]);
+
+  const handleSelectAll = () => {
+    const allPendingIds = pendingItems.map(i => i.id);
+    if (selectedItems.length === allPendingIds.length) {
+      setSelectedItems([]);
+    } else {
+      setSelectedItems(allPendingIds);
+    }
+  };
+
   const activeItems = queue?.filter(i => i.status === 'printing') || [];
   const activeItems = queue?.filter(i => i.status === 'printing') || [];
   const historyItems = useMemo(() => {
   const historyItems = useMemo(() => {
     const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
     const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
@@ -736,6 +961,51 @@ export function QueuePage() {
                   </Button>
                   </Button>
                 </div>
                 </div>
               </div>
               </div>
+
+              {/* Bulk action toolbar */}
+              <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-dark rounded-lg">
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={handleSelectAll}
+                  className="flex items-center gap-2"
+                >
+                  {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
+                    <CheckSquare className="w-4 h-4 text-bambu-green" />
+                  ) : (
+                    <Square className="w-4 h-4" />
+                  )}
+                  {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? 'Deselect All' : 'Select All'}
+                </Button>
+                {selectedItems.length > 0 && (
+                  <>
+                    <span className="text-sm text-bambu-gray">
+                      {selectedItems.length} selected
+                    </span>
+                    <div className="h-4 w-px bg-bambu-dark-tertiary" />
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => setShowBulkEditModal(true)}
+                      className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
+                    >
+                      <Pencil className="w-4 h-4" />
+                      Edit Selected
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => bulkCancelMutation.mutate(selectedItems)}
+                      className="flex items-center gap-2 text-red-400 hover:text-red-300"
+                      disabled={bulkCancelMutation.isPending}
+                    >
+                      <X className="w-4 h-4" />
+                      Cancel Selected
+                    </Button>
+                  </>
+                )}
+              </div>
+
               <DndContext
               <DndContext
                 sensors={sensors}
                 sensors={sensors}
                 collisionDetection={closestCenter}
                 collisionDetection={closestCenter}
@@ -758,6 +1028,8 @@ export function QueuePage() {
                         onRequeue={() => {}}
                         onRequeue={() => {}}
                         onStart={() => startMutation.mutate(item.id)}
                         onStart={() => startMutation.mutate(item.id)}
                         timeFormat={timeFormat}
                         timeFormat={timeFormat}
+                        isSelected={selectedItems.includes(item.id)}
+                        onToggleSelect={() => handleToggleSelect(item.id)}
                       />
                       />
                     ))}
                     ))}
                   </div>
                   </div>
@@ -891,6 +1163,21 @@ export function QueuePage() {
           onCancel={() => setShowClearHistoryConfirm(false)}
           onCancel={() => setShowClearHistoryConfirm(false)}
         />
         />
       )}
       )}
+
+      {/* Bulk Edit Modal */}
+      {showBulkEditModal && (
+        <BulkEditModal
+          selectedCount={selectedItems.length}
+          printers={printers?.map(p => ({ id: p.id, name: p.name })) || []}
+          onSave={(data) => {
+            if (Object.keys(data).length > 0) {
+              bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data });
+            }
+          }}
+          onClose={() => setShowBulkEditModal(false)}
+          isSaving={bulkUpdateMutation.isPending}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-jEq4K9YC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-qV1ROCuN.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-qV1ROCuN.js"></script>
+    <script type="module" crossorigin src="/assets/index-jEq4K9YC.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BQIOMqJ9.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BQIOMqJ9.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов