feat: Unify print dispatch through the scheduler (#1625)

This commit is contained in:
Ed
2026-06-26 12:31:48 +02:00
committed by GitHub
parent 0b43ac0d25
commit 4c67d8a4e1
63 changed files with 1704 additions and 5049 deletions
@@ -8,8 +8,8 @@ import { getBedTypeInfo } from '../../utils/bedType';
/**
* Plate selection grid for multi-plate 3MF files.
* Shows thumbnails, names, objects, and print times for each plate.
* In multi-select mode (add-to-queue), plates have checkboxes for selecting a subset.
* In single-select mode (reprint/edit), only one plate can be selected at a time.
* In multi-select mode, plates have checkboxes for selecting a subset.
* In single-select mode, only one plate can be selected at a time.
*/
export function PlateSelector({
plates,
@@ -12,7 +12,6 @@ import {
Users,
} from 'lucide-react';
import { api, type PrinterStatus } from '../../api/client';
import { useDispatchedPrinterIds } from '../../hooks/useDispatchedPrinterIds';
import { getColorName } from '../../utils/colors';
import {
normalizeColorForCompare,
@@ -256,14 +255,7 @@ export function PrinterSelector({
return map;
}, [activePrinters, statusQueries]);
// Printers with a queued/active background dispatch — accepted by Bambuddy
// but not yet reflected in PrinterStatus.state (which only flips on
// PRINT_START from the printer itself). Backend rejects double-sends with
// 409 anyway; this just stops the operator from picking them in the modal.
const dispatchedPrinterIds = useDispatchedPrinterIds();
const isPrinterBusy = (printerId: number): boolean => {
if (dispatchedPrinterIds.has(printerId)) return true;
const status = printerStatusMap.get(printerId);
if (!status) return false; // Unknown state — don't block
if (!status.connected) return true;
@@ -271,7 +263,6 @@ export function PrinterSelector({
};
const getPrinterStateLabel = (printerId: number): string | null => {
if (dispatchedPrinterIds.has(printerId)) return 'Dispatching...';
const status = printerStatusMap.get(printerId);
if (!status) return null;
if (!status.connected) return 'Offline';
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Calendar, Clock, Hand, Power, Layers, Code } from 'lucide-react';
import { Calendar, Clock, Hand, Power, Layers, Code, ListOrdered } from 'lucide-react';
import type { ScheduleOptionsProps, ScheduleType } from './types';
import {
formatDateInput,
@@ -16,7 +16,7 @@ import {
/**
* Schedule options component for queue items.
* Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
* Includes schedule type (ASAP/Queue/Schedule), datetime picker,
* and options for require previous success and auto power off.
*/
export function ScheduleOptionsPanel({
@@ -70,7 +70,11 @@ export function ScheduleOptionsPanel({
}, [options.scheduleType, options.scheduledTime, dateFormat, timeFormat, onChange, options]);
const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
onChange({ ...options, scheduleType });
onChange({
...options,
scheduleType,
requireManualStart: scheduleType === 'queue' ? options.requireManualStart : false,
});
};
const updateScheduledTime = (newDateValue: string, newTimeValue: string) => {
@@ -122,7 +126,7 @@ export function ScheduleOptionsPanel({
<div className="space-y-4">
{/* Schedule type */}
<div>
<label className="block text-sm text-bambu-gray mb-2">When to print</label>
<label className="block text-sm text-bambu-gray mb-2">{t('printModal.whenToPrint')}</label>
<div className="flex gap-2">
<button
type="button"
@@ -134,7 +138,19 @@ export function ScheduleOptionsPanel({
onClick={() => handleScheduleTypeChange('asap')}
>
<Clock className="w-4 h-4" />
ASAP
{t('printModal.asap')}
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
options.scheduleType === 'queue'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => handleScheduleTypeChange('queue')}
>
<ListOrdered className="w-4 h-4" />
{t('printModal.queue')}
</button>
<button
type="button"
@@ -146,19 +162,7 @@ export function ScheduleOptionsPanel({
onClick={() => handleScheduleTypeChange('scheduled')}
>
<Calendar className="w-4 h-4" />
Scheduled
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
options.scheduleType === 'manual'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => handleScheduleTypeChange('manual')}
>
<Hand className="w-4 h-4" />
Queue Only
{t('printModal.schedule')}
</button>
</div>
</div>
@@ -166,7 +170,7 @@ export function ScheduleOptionsPanel({
{/* Scheduled time input */}
{options.scheduleType === 'scheduled' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
<label className="block text-sm text-bambu-gray mb-1">{t('printModal.dateTime')}</label>
<div className="flex gap-2">
{/* Date input */}
<div className="flex-1 relative">
@@ -185,7 +189,7 @@ export function ScheduleOptionsPanel({
type="button"
onClick={openCalendar}
className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
title="Open calendar"
title={t('printModal.openCalendar')}
>
<Calendar className="w-4 h-4" />
</button>
@@ -216,12 +220,29 @@ export function ScheduleOptionsPanel({
</div>
{(!isDateValid || !isTimeValid) && (
<p className="mt-1 text-xs text-red-400">
Please enter a valid date and time
{t('printModal.invalidDateTime')}
</p>
)}
</div>
)}
{/* Manual start */}
{options.scheduleType === 'queue' && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requireManualStart"
checked={options.requireManualStart}
onChange={(e) => onChange({ ...options, requireManualStart: e.target.checked })}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="requireManualStart" className="text-sm flex items-center gap-1 text-bambu-gray">
<Hand className="w-3.5 h-3.5" />
{t('printModal.requireManualStart')}
</label>
</div>
)}
{/* Require previous success */}
<div className="flex items-center gap-2">
<input
@@ -232,7 +253,7 @@ export function ScheduleOptionsPanel({
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
Only start if previous print succeeded
{t('printModal.requirePreviousSuccess')}
</label>
</div>
@@ -248,7 +269,7 @@ export function ScheduleOptionsPanel({
/>
<label htmlFor="autoOffAfter" className={`text-sm flex items-center gap-1 ${canControlPrinter ? 'text-bambu-gray' : 'text-bambu-gray/50'}`}>
<Power className="w-3.5 h-3.5" />
Power off printer when done
{t('printModal.autoOffAfter')}
</label>
</div>
@@ -270,7 +291,7 @@ export function ScheduleOptionsPanel({
)}
{/* Stagger start */}
{showStagger && options.scheduleType !== 'manual' && (
{showStagger && options.scheduleType !== 'queue' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
@@ -341,10 +362,10 @@ export function ScheduleOptionsPanel({
{/* Help text */}
<p className="text-xs text-bambu-gray">
{options.scheduleType === 'asap'
? 'Print will start as soon as the printer is idle.'
? t('printModal.helpAsap')
: options.scheduleType === 'scheduled'
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
: "Print will be staged but won't start automatically. Use the Start button to release it to the queue."}
? t('printModal.helpSchedule')
: t('printModal.helpQueue')}
</p>
</div>
);
+122 -227
View File
@@ -1,8 +1,8 @@
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
import type { PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
import { api } from '../../api/client';
import { useAuth } from '../../contexts/AuthContext';
import { Card, CardContent } from '../Card';
@@ -32,13 +32,12 @@ import type {
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
/**
* Unified PrintModal component that handles three modes:
* - 'reprint': Immediate print from archive or library file (supports multi-printer)
* - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)
* - 'edit-queue-item': Edit existing queue item (supports multi-printer)
* Unified PrintModal component that handles queue item creation and editing.
* - 'create': Create a print queue item from an archive or library file
* - 'edit-queue-item': Edit existing queue item
*
* Both archiveId and libraryFileId are supported. Library files can be printed immediately
* or added to queue (archive is created at print start time, not when queued).
* Both archiveId and libraryFileId are supported. Library files are archived at
* print start time by the scheduler, not when queued.
*/
export function PrintModal({
mode,
@@ -59,6 +58,7 @@ export function PrintModal({
// Determine if we're printing a library file
const isLibraryFile = !!libraryFileId && !archiveId;
const isEditing = mode === 'edit-queue-item';
type FilamentWarningItem = {
printerName: string;
@@ -79,7 +79,7 @@ export function PrintModal({
return [];
});
// Multi-select plates: in add-to-queue mode users can pick a subset of plates
// Multi-select plates: create mode users can pick a subset of plates
const [selectedPlates, setSelectedPlates] = useState<Set<number>>(() => {
if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
return new Set([queueItem.plate_id]);
@@ -109,10 +109,8 @@ export function PrintModal({
const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
if (mode === 'edit-queue-item' && queueItem) {
let scheduleType: ScheduleType = 'asap';
if (queueItem.manual_start) {
scheduleType = 'manual';
} else if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
let scheduleType: ScheduleType = 'queue';
if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
scheduleType = 'scheduled';
}
@@ -126,6 +124,7 @@ export function PrintModal({
return {
scheduleType,
scheduledTime,
requireManualStart: queueItem.manual_start,
requirePreviousSuccess: queueItem.require_previous_success,
autoOffAfter: queueItem.auto_off_after,
gcodeInjection: queueItem.gcode_injection ?? false,
@@ -267,7 +266,7 @@ export function PrintModal({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
staleTime: 30 * 1000,
enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'),
enabled: !isEditing && assignmentMode === 'printer',
});
// Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
@@ -353,12 +352,6 @@ export function PrintModal({
// Combine filament requirements from either source
const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
const selectedPlateName = useMemo(() => {
if (selectedPlate === null || !platesData?.plates?.length) {
return undefined;
}
return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;
}, [platesData, selectedPlate]);
// Fetch available filaments for model-based assignment (for filament override UI)
const { data: availableFilaments } = useQuery({
@@ -382,6 +375,33 @@ export function PrintModal({
printerStatus?.ams_filament_backup,
);
const isPrinterCurrentlyDispatchable = (status: PrinterStatus | undefined): boolean => {
if (!status?.connected) return false;
if (status.awaiting_plate_clear) return false;
if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
};
const asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false;
if (selectedPrinters.length === 0) return false;
try {
const statuses = await Promise.all(
selectedPrinters.map((printerId) =>
queryClient.fetchQuery({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId),
staleTime: 0,
}),
),
);
return statuses.some((status) => !isPrinterCurrentlyDispatchable(status));
} catch {
return true;
}
};
// Get AMS mapping from hook (only when single printer selected)
const { amsMapping } = useFilamentMapping(
effectiveFilamentReqs,
@@ -545,15 +565,13 @@ export function PrintModal({
},
});
const willUseStagger = scheduleOptions.staggerEnabled && selectedPrinters.length > 1;
const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
e?.preventDefault();
if (
!options?.skipFilamentCheck &&
!settings?.disable_filament_warnings &&
(mode === 'reprint' || mode === 'add-to-queue') &&
!isEditing &&
assignmentMode === 'printer'
) {
const warningItems: FilamentWarningItem[] = [];
@@ -684,12 +702,12 @@ export function PrintModal({
const filamentOverridesArray = buildFilamentOverridesArray();
// Multi-plate auto-batch: when the user adds 2+ plates from one source in
// a single add-to-queue submission, pre-create a PrintBatch and pass its
// a single create submission, pre-create a PrintBatch and pass its
// id to each subsequent addToQueue call so the queue UI groups them as a
// collapsible batch. Only triggered for single-target submissions —
// multi-printer fan-out keeps the old per-item shape.
const shouldAutoBatch =
mode === 'add-to-queue'
mode === 'create'
&& platesToQueue.length > 1
&& (assignmentMode === 'model' || selectedPrinters.length === 1);
let autoBatchId: number | null = null;
@@ -709,7 +727,22 @@ export function PrintModal({
}
}
// Common queue data for add-to-queue and edit modes
const asapInsertionCounts = new Map<string, number>();
const applyAsapInsertion = (
queueData: PrintQueueItemCreate,
printerId: number | null,
itemCount = 1,
) => {
if (scheduleOptions.scheduleType !== 'asap') return;
const scopeKey = printerId !== null ? `printer:${printerId}` : 'unassigned';
const insertPosition = (asapInsertionCounts.get(scopeKey) ?? 0) + 1;
queueData.insert_at_top = true;
queueData.insert_position = insertPosition;
asapInsertionCounts.set(scopeKey, insertPosition + itemCount - 1);
};
// Common queue data for create and edit modes
const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
@@ -721,7 +754,7 @@ export function PrintModal({
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
gcode_injection: scheduleOptions.gcodeInjection,
manual_start: scheduleOptions.scheduleType === 'manual',
manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
// When the user clicks "Print Anyway" on the frontend deficit warning,
// persist that acknowledgement so the scheduler doesn't immediately
// re-flag the item on its first dispatch tick (#1698-followup).
@@ -734,16 +767,11 @@ export function PrintModal({
...printOptions,
project_id: projectId ?? undefined,
batch_id: autoBatchId ?? undefined,
cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
});
// Model-based assignment
if (assignmentMode === 'model') {
if (mode === 'reprint') {
showToast('Model-based assignment only works with queue mode', 'error');
setIsSubmitting(false);
return;
}
let progressCounter = 0;
for (const plate of platesToQueue) {
progressCounter++;
@@ -761,7 +789,7 @@ export function PrintModal({
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
gcode_injection: scheduleOptions.gcodeInjection,
manual_start: scheduleOptions.scheduleType === 'manual',
manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
ams_mapping: undefined,
plate_id: plateId,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -774,6 +802,7 @@ export function PrintModal({
// Add-to-queue mode with model-based assignment
const queueData = getQueueData(null, plateId);
if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
applyAsapInsertion(queueData, null, effectiveQuantity);
await addToQueueMutation.mutateAsync(queueData);
}
results.success++;
@@ -787,7 +816,7 @@ export function PrintModal({
// Printer-based assignment: loop through plates × printers
// Compute stagger base time once before the loop
const useStagger = scheduleOptions.staggerEnabled
&& (mode === 'add-to-queue' || mode === 'reprint')
&& !isEditing
&& selectedPrinters.length > 1;
const staggerBaseTime = useStagger
? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -805,46 +834,7 @@ export function PrintModal({
setSubmitProgress({ current: progressCounter, total: totalCount });
try {
if (mode === 'reprint' && !useStagger) {
const printerMapping = getMappingForPrinter(printerId);
if (scheduleOptions.gcodeInjection && effectiveQuantity > 1) {
// Auto-print injection only happens in the scheduler path. A direct
// immediate reprint bypasses it, so the first copy would print without
// its end-snippet and stay stuck on the plate — defeating auto-eject and
// blocking the injected copies queued behind it. Queue *all* copies so
// every one is dispatched (and injected) by the scheduler.
const queueData = getQueueData(printerId, plateId);
queueData.quantity = effectiveQuantity;
await addToQueueMutation.mutateAsync(queueData);
} else {
// Reprint mode - start print immediately (single plate only, multi-select not available)
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
plate_id: selectedPlate ?? undefined,
plate_name: selectedPlateName,
ams_mapping: printerMapping,
...printOptions,
project_id: projectId,
cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
});
} else {
// project_id is intentionally omitted here: reprintArchive targets an existing
// archive that already carries its own project association from the original print.
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
plate_name: selectedPlateName,
ams_mapping: printerMapping,
...printOptions,
});
}
// Queue remaining copies if quantity > 1
if (effectiveQuantity > 1) {
const queueData = getQueueData(printerId, plateId);
queueData.quantity = effectiveQuantity - 1;
await addToQueueMutation.mutateAsync(queueData);
}
}
} else if (mode === 'edit-queue-item' && progressCounter === 1) {
if (isEditing && progressCounter === 1) {
// Edit mode - update the original queue item for the first entry
const printerMapping = getMappingForPrinter(printerId);
const updateData: PrintQueueItemUpdate = {
@@ -854,7 +844,7 @@ export function PrintModal({
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
gcode_injection: scheduleOptions.gcodeInjection,
manual_start: scheduleOptions.scheduleType === 'manual',
manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
ams_mapping: printerMapping,
plate_id: plateId,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -864,9 +854,10 @@ export function PrintModal({
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode, stagger-reprint mode, or edit mode with additional entries
// New print mode, staggered print, or edit mode with additional entries
const queueData = getQueueData(printerId, plateId);
if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
applyAsapInsertion(queueData, printerId, effectiveQuantity);
// Apply stagger offset for groups after the first
if (useStagger) {
const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -893,19 +884,28 @@ export function PrintModal({
setIsSubmitting(false);
// Show result toast (skip for direct reprint — the dispatch toast handles it)
// Show result toast
if (results.failed === 0) {
if (mode === 'reprint' && willUseStagger) {
// Stagger-reprint routed through queue
showToast(t('queue.itemsQueued', { count: results.success }));
} else if (mode !== 'reprint') {
if (isEditing) {
if (mode === 'edit-queue-item') {
showToast('Queue item updated');
} else if (results.success === 1) {
showToast(assignmentMode === 'model' ? `Queued for any ${targetModel}` : t('queue.printQueued'));
} else {
showToast(t('queue.itemsQueued', { count: results.success }));
}
} else if (results.success === 1) {
const waitForIdleToast = await asapToastShouldPromiseLaterStart();
showToast(
waitForIdleToast
? t('queue.printQueuedWillStartWhenIdle')
: assignmentMode === 'model'
? `Queued for any ${targetModel}`
: t('queue.printQueued'),
);
} else {
const waitForIdleToast = await asapToastShouldPromiseLaterStart();
showToast(
waitForIdleToast
? t('queue.printQueuedWillStartWhenIdle')
: t('queue.itemsQueued', { count: results.success }),
);
}
queryClient.invalidateQueries({ queryKey: ['queue'] });
onSuccess?.();
@@ -927,26 +927,22 @@ export function PrintModal({
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
if (assignmentMode === 'model' && !targetModel) return false;
// Model-based assignment only works in queue modes (not immediate reprint)
if (assignmentMode === 'model' && mode === 'reprint') return false;
// For multi-plate files, need at least one plate selected
if (isMultiPlate && selectedPlates.size === 0) return false;
return true;
}, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlates.size, isPending]);
}, [selectedPrinters.length, assignmentMode, targetModel, isMultiPlate, selectedPlates.size, isPending]);
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
// Keep scheduleOptions.gcodeInjection in sync with the checkbox's render
// condition. The checkbox only renders for reprint + snippets configured +
// condition. The checkbox only renders for create + snippets configured +
// quantity > 1, so if the user ticks it at quantity 2 then drops back to 1
// the box hides but the state stays true — and the immediate-reprint path
// would then silently bypass injection.
// the box hides but the state stays true.
useEffect(() => {
if (
mode === 'reprint' &&
mode === 'create' &&
scheduleOptions.gcodeInjection &&
(effectiveQuantity <= 1 || !settings?.gcode_snippets)
) {
@@ -956,41 +952,12 @@ export function PrintModal({
// Modal title and action button text based on mode
const getModalConfig = () => {
const printerCount = selectedPrinters.length;
if (mode === 'reprint') {
const staggerReprint = willUseStagger && printerCount > 1;
let submitText = staggerReprint
? t('printModal.staggerToPrinters', { count: printerCount, defaultValue: 'Stagger to {{count}} printers' })
: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print');
if (effectiveQuantity > 1) {
submitText = `${submitText} ×${effectiveQuantity}`;
}
if (!isEditing) {
return {
title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
title: t('common.print'),
icon: Printer,
submitText,
submitIcon: staggerReprint ? Calendar : Printer,
loadingText: submitProgress.total > 1
? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('queue.sending'),
};
}
if (mode === 'add-to-queue') {
let submitText = t('queue.addToQueue');
if (selectedPlates.size > 1) {
submitText = t('queue.queueSelectedPlates', { count: selectedPlates.size });
} else if (printerCount > 1) {
submitText = t('queue.queueToPrinters', { count: printerCount });
}
if (effectiveQuantity > 1) {
submitText = `${submitText} ×${effectiveQuantity}`;
}
return {
title: t('queue.schedulePrint'),
icon: Calendar,
submitText,
submitIcon: Calendar,
submitText: t('common.print'),
submitIcon: Printer,
loadingText: submitProgress.total > 1
? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
: t('queue.adding'),
@@ -1046,13 +1013,9 @@ export function PrintModal({
className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<CardContent className={mode === 'reprint' ? '' : 'p-0'}>
<CardContent className="p-0">
{/* Header */}
<div
className={`flex items-center justify-between ${
mode === 'reprint' ? 'mb-4' : 'p-4 border-b border-bambu-dark-tertiary'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<TitleIcon className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
@@ -1062,22 +1025,11 @@ export function PrintModal({
</Button>
</div>
<form onSubmit={handleSubmit} className={mode === 'reprint' ? '' : 'p-4 space-y-4'}>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Archive name */}
<p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
{mode === 'reprint' ? (
<>
Send <span className="text-white">{archiveName}</span> to{' '}
{initialSelectedPrinterIds?.length === 1 && printers
? <span className="text-white">{printers.find(p => p.id === initialSelectedPrinterIds[0])?.name ?? 'printer(s)'}</span>
: 'printer(s)'}
</>
) : (
<>
<span className="block text-bambu-gray mb-1">Print Job</span>
<span className="text-white font-medium truncate block">{archiveName}</span>
</>
)}
<p className="text-sm text-bambu-gray">
<span className="block text-bambu-gray mb-1">Print Job</span>
<span className="text-white font-medium truncate block">{archiveName}</span>
</p>
{/* Build-plate badge for the selected (or sole) plate — surfaced
@@ -1108,7 +1060,7 @@ export function PrintModal({
onToggle={(plateIndex) => {
setSelectedPlates(prev => {
const next = new Set(prev);
if (mode === 'add-to-queue') {
if (!isEditing) {
// Multi-select: toggle the plate
if (next.has(plateIndex)) {
next.delete(plateIndex);
@@ -1123,9 +1075,9 @@ export function PrintModal({
return next;
});
}}
onSelectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
onDeselectAll={mode === 'add-to-queue' ? () => setSelectedPlates(new Set()) : undefined}
multiSelect={mode === 'add-to-queue'}
onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
multiSelect={!isEditing}
/>
{/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
@@ -1137,17 +1089,17 @@ export function PrintModal({
isLoading={loadingPrinters}
allowMultiple={true}
showInactive={mode === 'edit-queue-item'}
disableBusy={mode === 'reprint'}
disableBusy={false}
printerMappingResults={multiPrinterMapping.printerResults}
filamentReqs={effectiveFilamentReqs}
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
assignmentMode={assignmentMode}
onAssignmentModeChange={!isEditing ? setAssignmentMode : undefined}
targetModel={targetModel}
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
onTargetModelChange={!isEditing ? setTargetModel : undefined}
targetLocation={targetLocation}
onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
onTargetLocationChange={!isEditing ? setTargetLocation : undefined}
slicedForModel={slicedForModel}
/>
)}
@@ -1210,7 +1162,7 @@ export function PrintModal({
)}
{/* Print options */}
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
{(mode === 'create' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
<PrintOptionsPanel
options={printOptions}
onChange={setPrintOptions}
@@ -1242,74 +1194,17 @@ export function PrintModal({
</div>
)}
{/* Stagger option for reprint mode with multiple printers */}
{mode === 'reprint' && assignmentMode === 'printer' && selectedPrinters.length > 1 && (
<div className="space-y-2 pb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="staggerEnabledReprint"
checked={scheduleOptions.staggerEnabled}
onChange={(e) => setScheduleOptions({ ...scheduleOptions, staggerEnabled: e.target.checked })}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="staggerEnabledReprint" className="text-sm flex items-center gap-1 text-bambu-gray">
<Layers className="w-3.5 h-3.5" />
{t('printModal.staggerPrinterStarts', 'Stagger printer starts')}
</label>
</div>
{scheduleOptions.staggerEnabled && (() => {
const groupSize = scheduleOptions.staggerGroupSize;
const interval = scheduleOptions.staggerIntervalMinutes;
const groupCount = Math.ceil(selectedPrinters.length / groupSize);
const totalMinutes = (groupCount - 1) * interval;
return (
<p className="ml-6 text-xs text-bambu-gray">
{t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {
printers: selectedPrinters.length,
groups: groupCount,
size: groupSize,
interval,
})}
{groupCount > 1
? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`
: ''}
</p>
);
})()}
</div>
)}
{/* Schedule options - only for queue modes */}
{mode !== 'reprint' && (
<ScheduleOptionsPanel
options={scheduleOptions}
onChange={setScheduleOptions}
dateFormat={settings?.date_format || 'system'}
timeFormat={settings?.time_format || 'system'}
canControlPrinter={hasPermission('printers:control')}
showStagger={mode === 'add-to-queue' && assignmentMode === 'printer' && selectedPrinters.length > 1}
printerCount={selectedPrinters.length}
hasGcodeSnippets={!!settings?.gcode_snippets}
/>
)}
{/* G-code injection for reprint mode (only shown when quantity > 1 — applies to queued copies) */}
{mode === 'reprint' && !!settings?.gcode_snippets && effectiveQuantity > 1 && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="gcodeInjectionReprint"
checked={scheduleOptions.gcodeInjection}
onChange={(e) => setScheduleOptions({ ...scheduleOptions, gcodeInjection: e.target.checked })}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="gcodeInjectionReprint" className="text-sm flex items-center gap-1 text-bambu-gray">
<Code className="w-3.5 h-3.5" />
{t('printModal.gcodeInjection', 'Inject auto-print G-code')}
</label>
</div>
)}
{/* Schedule options */}
<ScheduleOptionsPanel
options={scheduleOptions}
onChange={setScheduleOptions}
dateFormat={settings?.date_format || 'system'}
timeFormat={settings?.time_format || 'system'}
canControlPrinter={hasPermission('printers:control')}
showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
printerCount={selectedPrinters.length}
hasGcodeSnippets={!!settings?.gcode_snippets}
/>
{/* Error message */}
{updateQueueMutation.isError && (
@@ -1319,7 +1214,7 @@ export function PrintModal({
)}
{/* Actions */}
<div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
<div className="flex gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
Cancel
</Button>
+7 -6
View File
@@ -2,11 +2,10 @@ import type { PrintQueueItem, Printer } from '../../api/client';
/**
* Mode of operation for the PrintModal.
* - 'reprint': Immediate print from archive (no schedule options)
* - 'add-to-queue': Schedule print to queue (includes schedule options)
* - 'create': Create a print queue item from an archive or library file
* - 'edit-queue-item': Edit existing queue item (all options + existing values)
*/
export type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';
export type PrintModalMode = 'create' | 'edit-queue-item';
/**
* Props for the unified PrintModal component.
@@ -66,7 +65,7 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
/**
* Schedule type for queue items.
*/
export type ScheduleType = 'asap' | 'scheduled' | 'manual';
export type ScheduleType = 'asap' | 'queue' | 'scheduled';
/**
* Schedule options for queue items.
@@ -74,6 +73,7 @@ export type ScheduleType = 'asap' | 'scheduled' | 'manual';
export interface ScheduleOptions {
scheduleType: ScheduleType;
scheduledTime: string;
requireManualStart: boolean;
requirePreviousSuccess: boolean;
autoOffAfter: boolean;
gcodeInjection: boolean;
@@ -88,6 +88,7 @@ export interface ScheduleOptions {
export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
scheduleType: 'asap',
scheduledTime: '',
requireManualStart: false,
requirePreviousSuccess: false,
autoOffAfter: false,
gcodeInjection: false,
@@ -140,7 +141,7 @@ export interface PrinterSelectorProps {
allowMultiple?: boolean;
/** Show inactive printers (for edit mode where original assignment may be inactive) */
showInactive?: boolean;
/** Disable selection of busy printers (used in reprint mode) */
/** Disable selection of busy printers */
disableBusy?: boolean;
/** Current assignment mode */
assignmentMode?: AssignmentMode;
@@ -168,7 +169,7 @@ export interface PlateSelectorProps {
onToggle: (plateIndex: number) => void;
onSelectAll?: () => void;
onDeselectAll?: () => void;
/** Whether multi-select (checkboxes) is enabled — true in add-to-queue mode */
/** Whether multi-select (checkboxes) is enabled */
multiSelect?: boolean;
}