mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
feat: Unify print dispatch through the scheduler (#1625)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user