mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 07:01:40 +02:00
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
This commit is contained in:
@@ -61,6 +61,12 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Select All / Deselect All buttons
|
||||
- Bulk download as ZIP when multiple files selected
|
||||
- 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
|
||||
- **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 touch-based panning when zoomed in
|
||||
- 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
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.schemas.print_queue import (
|
||||
PrintQueueBulkUpdate,
|
||||
PrintQueueBulkUpdateResponse,
|
||||
PrintQueueItemCreate,
|
||||
PrintQueueItemResponse,
|
||||
PrintQueueItemUpdate,
|
||||
@@ -201,6 +203,55 @@ async def add_to_queue(
|
||||
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)
|
||||
async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific queue item."""
|
||||
|
||||
@@ -100,3 +100,30 @@ class PrintQueueReorderItem(BaseModel):
|
||||
|
||||
class PrintQueueReorder(BaseModel):
|
||||
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
|
||||
|
||||
@@ -734,3 +734,232 @@ class TestQueueLibraryFileSupport:
|
||||
assert our_item is not None
|
||||
assert our_item["library_file_name"] == "Custom Print Name"
|
||||
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()
|
||||
|
||||
@@ -1065,6 +1065,28 @@ export interface PrintQueueItemUpdate {
|
||||
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
|
||||
export interface MQTTLogEntry {
|
||||
timestamp: string;
|
||||
@@ -2374,6 +2396,11 @@ export const api = {
|
||||
request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
|
||||
startQueueItem: (id: number) =>
|
||||
request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
|
||||
bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
|
||||
request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// K-Profiles
|
||||
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
|
||||
|
||||
@@ -41,10 +41,13 @@ import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Hand,
|
||||
Check,
|
||||
CheckSquare,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
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 { Button } from '../components/Button';
|
||||
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
|
||||
function SortableQueueItem({
|
||||
item,
|
||||
@@ -105,6 +265,8 @@ function SortableQueueItem({
|
||||
onRequeue,
|
||||
onStart,
|
||||
timeFormat = 'system',
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
item: PrintQueueItem;
|
||||
position?: number;
|
||||
@@ -115,6 +277,8 @@ function SortableQueueItem({
|
||||
onRequeue: () => void;
|
||||
onStart: () => void;
|
||||
timeFormat?: TimeFormat;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -146,6 +310,23 @@ function SortableQueueItem({
|
||||
`}
|
||||
>
|
||||
<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 */}
|
||||
{isPending ? (
|
||||
<div
|
||||
@@ -355,6 +536,8 @@ export function QueuePage() {
|
||||
type: 'cancel' | 'remove' | 'stop';
|
||||
item: PrintQueueItem;
|
||||
} | null>(null);
|
||||
const [selectedItems, setSelectedItems] = useState<number[]>([]);
|
||||
const [showBulkEditModal, setShowBulkEditModal] = useState(false);
|
||||
const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
|
||||
const saved = localStorage.getItem('queue.historySortBy');
|
||||
return (saved as 'date' | 'name' | 'printer') || 'date';
|
||||
@@ -473,6 +656,38 @@ export function QueuePage() {
|
||||
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 items = queue?.filter(i => i.status === 'pending') || [];
|
||||
|
||||
@@ -502,6 +717,16 @@ export function QueuePage() {
|
||||
return pendingSortAsc ? cmp : -cmp;
|
||||
});
|
||||
}, [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 historyItems = useMemo(() => {
|
||||
const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
|
||||
@@ -736,6 +961,51 @@ export function QueuePage() {
|
||||
</Button>
|
||||
</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
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -758,6 +1028,8 @@ export function QueuePage() {
|
||||
onRequeue={() => {}}
|
||||
onStart={() => startMutation.mutate(item.id)}
|
||||
timeFormat={timeFormat}
|
||||
isSelected={selectedItems.includes(item.id)}
|
||||
onToggleSelect={() => handleToggleSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -891,6 +1163,21 @@ export function QueuePage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-qV1ROCuN.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-jEq4K9YC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BQIOMqJ9.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user