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:
maziggy
2026-01-27 09:35:28 +01:00
parent 60d9505c0d
commit e57fb87a52
9 changed files with 9360 additions and 8731 deletions
+8
View File
@@ -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
+51
View File
@@ -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."""
+27
View File
@@ -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()
+27
View File
@@ -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') =>
+288 -1
View File
@@ -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
View File
@@ -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>