Merge remote-tracking branch 'upstream/dev' into feature/billing

This commit is contained in:
behrinml
2026-08-05 20:19:32 +02:00
215 changed files with 28788 additions and 2152 deletions
@@ -27,11 +27,62 @@ export function FilamentMapping({
forceColorMatch,
onForceColorMatchChange,
plateLabel,
archiveAmsMapping,
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
// "Mapping" toggle (only shown when the archive has a saved slicer pick):
// ON selects every slot straight from `archiveAmsMapping`, bypassing the
// type/color auto-match entirely — same mechanism as a manual per-slot
// pick (`manualMappings`), just applied to every required slot at once.
// OFF removes exactly those overrides so the panel falls back to its
// normal auto-match, without touching any *other* manual picks the user
// made by hand.
const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
// Which slot IDs the ON branch below actually wrote into manualMappings —
// so OFF can undo exactly those and leave any *other* manual pick the user
// made by hand (before or after pressing the button) untouched.
const appliedSlotIdsRef = useRef<number[]>([]);
// Reset the toggle whenever the saved mapping it would apply changes — a
// different printer, plate selection, or archive entirely. Without this
// the button can read ON (green) from a previous printer/archive/plate
// even though it was never pressed against the mapping currently in scope.
useEffect(() => {
setUsingArchiveMapping(false);
appliedSlotIdsRef.current = [];
}, [archiveAmsMapping, plateLabel, printerId]);
const toggleArchiveMapping = () => {
if (!archiveAmsMapping || !filamentReqs?.filaments) return;
if (usingArchiveMapping) {
const next = { ...manualMappings };
for (const slotId of appliedSlotIdsRef.current) {
delete next[slotId];
}
onManualMappingChange(next);
appliedSlotIdsRef.current = [];
setUsingArchiveMapping(false);
return;
}
const next = { ...manualMappings };
const appliedSlotIds: number[] = [];
for (const req of filamentReqs.filaments) {
const idx = req.slot_id - 1;
// A negative value (e.g. the external spool sentinel) means the
// slicer didn't resolve this filament to an AMS tray — leave that
// slot's existing auto-match/manual pick alone rather than clearing it.
if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
next[req.slot_id] = archiveAmsMapping[idx];
appliedSlotIds.push(req.slot_id);
}
}
onManualMappingChange(next);
appliedSlotIdsRef.current = appliedSlotIds;
setUsingArchiveMapping(true);
};
// Fetch printer status
const { data: printerStatus } = useQuery({
@@ -228,16 +279,33 @@ export function FilamentMapping({
{isExpanded && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-bambu-gray">Click to change slot assignment</span>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Re-read</span>
</button>
<span className="text-xs text-bambu-gray">{t('printModal.clickToChangeSlot')}</span>
<div className="flex items-center gap-1.5">
{archiveAmsMapping && (
<button
type="button"
onClick={toggleArchiveMapping}
title={t('printModal.useArchiveMappingTooltip')}
className={`flex items-center gap-1 px-2 py-0.5 text-xs rounded border transition-colors ${
usingArchiveMapping
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
: 'border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
<Check className="w-3 h-3" />
<span>{t('printModal.useArchiveMapping')}</span>
</button>
)}
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>{t('printModal.reRead')}</span>
</button>
</div>
</div>
{filamentComparison.map((item, idx) => {
// #1717: surface the same per-slot force-color-match checkbox here
@@ -19,6 +19,8 @@ export function PlateSelector({
onSelectAll,
onDeselectAll,
multiSelect,
quantities,
onQuantityChange,
}: PlateSelectorProps) {
const { t } = useTranslation();
@@ -28,6 +30,10 @@ export function PlateSelector({
}
const allSelected = selectedPlates.size === plates.length;
const showQuantities = !!quantities && !!onQuantityChange;
const totalRuns = showQuantities
? plates.reduce((sum, p) => (selectedPlates.has(p.index) ? sum + (quantities[p.index] ?? 1) : sum), 0)
: 0;
return (
<div className="mb-4">
@@ -60,15 +66,22 @@ export function PlateSelector({
{plates.map((plate) => {
const isSelected = selectedPlates.has(plate.index);
return (
<button
/* The quantity stepper can't live inside the selection button —
nesting an input in a button is invalid and every keystroke
would toggle the plate. The card is a div; the selectable
region stays a button beside the stepper. */
<div
key={plate.index}
type="button"
onClick={() => onToggle(plate.index)}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
isSelected
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
<button
type="button"
onClick={() => onToggle(plate.index)}
className="flex items-center gap-2 text-left min-w-0 flex-1"
>
{multiSelect && (
isSelected
@@ -114,9 +127,33 @@ export function PlateSelector({
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
{showQuantities && isSelected && (
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-xs text-bambu-gray" aria-hidden="true">×</span>
<input
type="number"
min={1}
max={999}
value={quantities[plate.index] ?? 1}
aria-label={t('queue.plateQuantityLabel', {
plate: plate.name || t('queue.plateNumber', { index: plate.index }),
})}
onChange={(e) =>
onQuantityChange(plate.index, Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))
}
className="w-14 px-1.5 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
/>
</div>
)}
</div>
);
})}
</div>
{showQuantities && totalRuns > selectedPlates.size && (
<p className="text-xs text-bambu-gray mt-2">
{t('queue.plateQuantityTotal', { count: totalRuns })}
</p>
)}
</div>
);
}
@@ -12,6 +12,7 @@ import {
CALIBRATION_MODE_ACTIVE,
CALIBRATION_MODE_INACTIVE,
} from '../../utils/calibrationMode';
import { MAX_CHAMBER_TEMP_C } from '../../utils/printer';
type OptionConfig = {
key: keyof PrintOptionsType;
@@ -79,7 +80,7 @@ export function PrintOptionsPanel({
if (Number.isNaN(parsed)) return;
onChange({
...options,
preheat_chamber_target_override: Math.max(0, Math.min(60, parsed)),
preheat_chamber_target_override: Math.max(0, Math.min(MAX_CHAMBER_TEMP_C, parsed)),
});
};
@@ -188,7 +189,7 @@ export function PrintOptionsPanel({
<input
type="number"
min={0}
max={60}
max={MAX_CHAMBER_TEMP_C}
step={1}
value={options.preheat_chamber_target_override ?? ''}
onChange={(e) => handlePreheatTarget(e.target.value)}
@@ -0,0 +1,160 @@
import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import { ArrowDown, ArrowUp, Layers, Printer as PrinterIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../../api/client';
/** One sliced file offered as an alternative for a cross-model job (#671). */
export interface VariantCandidate {
id: number;
filename: string;
sliced_for_model: string | null;
}
interface VariantCandidatesProps {
candidates: VariantCandidate[];
onReorder: (next: VariantCandidate[]) => void;
/** file id -> chosen plate, for the multi-plate candidates only. */
plateByFile: Record<number, number | null>;
onPlateChange: (fileId: number, plateId: number | null) => void;
/** Show the set without offering to change it — the edit-queue-item case,
* where reordering would need a variant-level API that doesn't exist. */
readOnly?: boolean;
/** Replaces the help line under the heading when read-only. */
readOnlyNote?: string;
}
/**
* The ordered candidate list for a cross-model print (#671).
*
* Only two things are configured per candidate: its order, and — when the file
* holds more than one plate — which plate to run. Everything else on the modal
* (filament overrides, print options, schedule) stays shared, because that is
* already how model-based assignment works: the printer is unknown at queue
* time, so there is nothing per-machine to configure. The AMS mapping in
* particular is deliberately absent — the scheduler computes it against the
* printer it actually picks, exactly as it does for a single-model job.
*
* Order is the answer to "which would you rather have when both are free", so
* it is explicit rather than left to whichever printer the matcher saw first.
* Move buttons instead of drag: the list is two or three rows, and buttons work
* from the keyboard without a drag-and-drop dependency.
*/
export function VariantCandidates({
candidates,
onReorder,
plateByFile,
onPlateChange,
readOnly = false,
readOnlyNote,
}: VariantCandidatesProps) {
const { t } = useTranslation();
const plateQueries = useQueries({
// Read-only mode shows a job that is already queued — its plates were
// chosen when it was created, so there is nothing to fetch or offer.
queries: readOnly
? []
: candidates.map((c) => ({
queryKey: ['library-file-plates', c.id],
queryFn: () => api.getLibraryFilePlates(c.id),
staleTime: 60_000,
})),
});
const platesByFile = useMemo(() => {
const out: Record<number, { index: number; name: string | null }[]> = {};
candidates.forEach((c, i) => {
const data = plateQueries[i]?.data;
if (data?.is_multi_plate && data.plates.length > 1) {
out[c.id] = data.plates.map((p) => ({ index: p.index, name: p.name }));
}
});
return out;
}, [candidates, plateQueries]);
const move = (from: number, to: number) => {
if (to < 0 || to >= candidates.length) return;
const next = [...candidates];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
onReorder(next);
};
return (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<PrinterIcon className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-bambu-gray">{t('printModal.variants.title')}</span>
</div>
<p className="text-xs text-bambu-gray mb-2">
{readOnly ? (readOnlyNote ?? t('printModal.variants.help')) : t('printModal.variants.help')}
</p>
<div className="space-y-2">
{candidates.map((candidate, index) => {
const plates = platesByFile[candidate.id];
// Shown exactly as the 3MF declares it. The backend normalizes when it
// resolves the candidate; echoing its own words here avoids a second,
// possibly disagreeing, normalizer in the browser.
const model = candidate.sliced_for_model;
return (
<div
key={candidate.id}
className="flex flex-wrap items-center gap-2 rounded border border-bambu-dark-tertiary p-2"
>
<span className="text-xs font-mono text-bambu-gray w-5 shrink-0">{index + 1}.</span>
<span className="px-2 py-0.5 rounded-full bg-bambu-green/10 text-bambu-green text-xs shrink-0">
{model || t('printModal.variants.unknownModel')}
</span>
<span className="text-sm text-white truncate min-w-0 flex-1" title={candidate.filename}>
{candidate.filename}
</span>
{plates && (
<label className="flex items-center gap-1 text-xs text-bambu-gray shrink-0">
<Layers className="w-3 h-3" />
<select
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-1 py-0.5 text-xs text-white"
value={plateByFile[candidate.id] ?? plates[0].index}
onChange={(e) => onPlateChange(candidate.id, Number(e.target.value))}
aria-label={t('printModal.variants.plateFor', { filename: candidate.filename })}
>
{plates.map((p) => (
<option key={p.index} value={p.index}>
{p.name || t('printModal.plateN', { n: p.index })}
</option>
))}
</select>
</label>
)}
{!readOnly && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => move(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
aria-label={t('printModal.variants.moveUp')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => move(index, index + 1)}
disabled={index === candidates.length - 1}
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
aria-label={t('printModal.variants.moveDown')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,46 @@
/**
* Reading the archive's saved slicer AMS-slot pick back out of `extra_data`.
*
* A virtual printer with "Save AMS mapping" on stores the slicer's own
* live-resolved tray choice on the archive as
* `extra_data.slicer_ams_mapping = { mapping, printer_id }` (written by
* `ArchiveService.archive_print`). The tray IDs in `mapping` are global tray
* IDs, which only mean something against the AMS layout of the one printer
* they were resolved against — slot 3 on another printer can hold a completely
* different spool. `printer_id` records which printer that was.
*
* Lives here rather than inline in the modal so the printer-scoping rule can
* be tested on its own: it is the only thing standing between a saved mapping
* and the wrong physical spool.
*/
/** Shape of `extra_data.slicer_ams_mapping`. Every field optional — this is
* free-form JSON off the wire, and older archives predate the key entirely. */
export interface SavedSlicerAmsMapping {
mapping?: number[];
printer_id?: number;
}
/**
* The saved mapping, but only when it is safe to apply to `printerId`.
*
* Returns `undefined` — meaning "no saved mapping in scope, behave as before" —
* when the archive has none, when the stored value is malformed, when no
* printer is selected yet, or when the selected printer is not the one the
* mapping was resolved against.
*/
export function resolveArchiveSlicerAmsMapping(
extraData: Record<string, unknown> | null | undefined,
printerId: number | null | undefined,
): number[] | undefined {
// No printer selected means there is nothing to compare against. Bailing
// here also stops `undefined === undefined` from reading as a match below.
if (printerId == null) return undefined;
const saved = extraData?.slicer_ams_mapping as SavedSlicerAmsMapping | undefined;
if (!saved || typeof saved !== 'object') return undefined;
if (saved.printer_id !== printerId) return undefined;
if (!Array.isArray(saved.mapping) || saved.mapping.length === 0) return undefined;
return saved.mapping;
}
+226 -21
View File
@@ -22,12 +22,14 @@ import { getCurrencySymbol } from '../../utils/currency';
import { getBedTypeInfo } from '../../utils/bedType';
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
import { FilamentMapping } from './FilamentMapping';
import { FilamentOverride } from './FilamentOverride';
import { PlateSelector } from './PlateSelector';
import { PrinterSelector } from './PrinterSelector';
import { PrintOptionsPanel } from './PrintOptions';
import { ScheduleOptionsPanel } from './ScheduleOptions';
import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
import { CostCenterSelect } from './CostCenterSelect';
import type {
AssignmentMode,
@@ -58,6 +60,7 @@ export function PrintModal({
onSuccess,
projectId,
cleanupLibraryAfterDispatch,
variantFiles,
}: PrintModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -68,6 +71,27 @@ export function PrintModal({
const isLibraryFile = !!libraryFileId && !archiveId;
const isEditing = mode === 'edit-queue-item';
// Cross-model alternatives (#671). One candidate is not a choice, so a
// single-entry list behaves exactly like an ordinary print.
const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1;
// Editing an already-queued cross-model item. The candidates are shown so the
// dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
// did before, offering a printer picker whose Save would have left a row with
// both variants and a printer_id. They are not editable here: changing the
// set after queueing needs a variant-level API that doesn't exist, and the
// backend refuses the printer/model change either way.
const editingVariants: VariantCandidate[] =
mode === 'edit-queue-item' && (queueItem?.variants?.length ?? 0) > 1
? queueItem!.variants!.map((v) => ({
id: v.library_file_id,
filename: v.filename,
sliced_for_model: v.target_model,
}))
: [];
const hasEditingVariants = editingVariants.length > 0;
const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
type FilamentWarningItem = {
printerName: string;
slotLabel: string;
@@ -101,6 +125,11 @@ export function PrintModal({
// Quantity — number of copies (creates a batch if > 1)
const [quantity, setQuantity] = useState(1);
// Per-plate quantities for multi-plate files (#342). Keyed by plate index;
// a plate with no entry means one run. Only used in create mode on a
// multi-plate file, where it replaces the single global Quantity field.
const [plateQuantities, setPlateQuantities] = useState<Record<number, number>>({});
const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
if (mode === 'edit-queue-item' && queueItem) {
return {
@@ -165,6 +194,11 @@ export function PrintModal({
// Assignment mode: 'printer' (specific) or 'model' (any of model)
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
// Cross-model alternatives are model-based by definition — naming one
// printer would defeat the point of offering the other file.
if (isCrossModel) {
return 'model';
}
// Initialize from queue item if editing with target_model
if (mode === 'edit-queue-item' && queueItem?.target_model) {
return 'model';
@@ -357,6 +391,19 @@ export function PrintModal({
// Get sliced_for_model from archive or library file
const slicedForModel = archiveDetails?.sliced_for_model || libraryFileDetails?.sliced_for_model || null;
// The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
// mapping" virtual-printer setting) — undefined for library files or
// archives that predate the feature / had it off at print time, and
// deliberately undefined unless the selected printer is the one the mapping
// was resolved against. See `resolveArchiveSlicerAmsMapping`.
const archiveSlicerAmsMapping = useMemo(
() =>
isLibraryFile
? undefined
: resolveArchiveSlicerAmsMapping(archiveDetails?.extra_data, effectivePrinterId),
[isLibraryFile, archiveDetails?.extra_data, effectivePrinterId],
);
// Fetch plates for archives
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
queryKey: ['archive-plates', archiveId],
@@ -403,6 +450,42 @@ export function PrintModal({
enabled: assignmentMode === 'model' && !!targetModel,
});
// A cross-model job (#671) has no single target model, so the query above is
// disabled and the override UI would silently vanish — leaving less control
// than the ordinary "Any X1C" flow offers. Ask each candidate's model instead
// and offer the union: the job can land on any of them, so anything loaded on
// any of them is a legitimate choice. Picking one only some models have is
// allowed and meaningful — it narrows which candidates can match.
const candidateModels = useMemo(
() => Array.from(new Set(candidates.map((c) => c.sliced_for_model).filter((m): m is string => !!m))),
[candidates],
);
const candidateFilamentQueries = useQueries({
queries: isCrossModel
? candidateModels.map((model) => ({
queryKey: ['available-filaments', model, targetLocation],
queryFn: () => api.getAvailableFilaments(model, targetLocation ?? undefined),
}))
: [],
});
const crossModelFilaments = useMemo(() => {
const seen = new Set<string>();
const merged: NonNullable<typeof availableFilaments> = [];
for (const query of candidateFilamentQueries) {
for (const filament of query.data ?? []) {
// Same type+colour loaded on two models is one choice, not two.
const key = `${filament.type}|${filament.color}|${filament.tray_info_idx}`;
if (!seen.has(key)) {
seen.add(key);
merged.push(filament);
}
}
}
return merged;
}, [candidateFilamentQueries]);
const effectiveAvailableFilaments = isCrossModel ? crossModelFilaments : availableFilaments;
// Only fetch printer status when single printer selected (for filament mapping)
const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({
queryKey: ['printer-status', effectivePrinterId],
@@ -821,17 +904,21 @@ export function PrintModal({
showToast('Please select at least one printer', 'error');
return;
}
if (assignmentMode === 'model' && !targetModel) {
// A cross-model job has no single target model — each candidate carries its
// own, and the backend gates each of them separately. Both checks below are
// about the one-model case only.
if (!isCrossModel && assignmentMode === 'model' && !targetModel) {
showToast('Please select a target printer model', 'error');
return;
}
// Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
// gets inline feedback instead of a failed request.
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
return;
}
setIsSubmitting(true);
// Calculate total API calls: plates × printers (or 1 for model-based)
const platesToQueue = selectedPlates.size > 1
@@ -892,28 +979,87 @@ export function PrintModal({
? buildFilamentOverridesArray(perPlateReqs.get(plateId))
: filamentOverridesArray;
// Multi-plate auto-batch: when the user adds 2+ plates from one source in
// 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.
// Cross-model alternatives (#671): ONE item carrying a candidate per file,
// in the order the user arranged. This returns before the plate/printer
// fan-out below because it deliberately fans out to nothing — the whole
// point is that exactly one of these candidates ever runs.
//
// Filament overrides are shared rather than per-candidate, matching how
// single-model assignment already behaves: the printer is unknown at queue
// time, so what is expressed here is "this job needs PETG", which is true of
// every slice of the same job. The AMS mapping is likewise absent — the
// scheduler computes it against the printer it actually picks.
if (isCrossModel) {
try {
await api.addToQueue({
variants: candidates.map((c) => ({
library_file_id: c.id,
plate_id: candidatePlates[c.id] ?? null,
filament_overrides: filamentOverridesArray,
})),
target_location: targetLocation,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
gcode_injection: scheduleOptions.gcodeInjection,
manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
: undefined,
quantity,
...printOptions,
project_id: projectId ?? undefined,
});
showToast(t('printModal.variants.queued', { count: candidates.length }), 'success');
queryClient.invalidateQueries({ queryKey: ['queue'] });
onSuccess?.();
onClose();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), 'error');
} finally {
setIsSubmitting(false);
}
return;
}
// Batch order (#342): a create submission that produces more than one run
// from one source is pre-created as a batch carrying per-plate targets,
// and its id is passed to each subsequent addToQueue call. The targets are
// what make the order able to say a failed run is still owed — without
// them the batch only knows what it happened to queue. Only for
// single-target submissions; multi-printer fan-out keeps the old per-item
// shape, where "how many" is answered by the printer count.
const plateTargets = platesToQueue.map((plate, index) => {
const plateIndex = plate ? plate.index : selectedPlate;
return {
plate_id: plateIndex,
plate_name: plate ? (plate.name || null) : null,
quantity_target: quantityForPlate(plateIndex),
sort_order: index,
};
});
const totalRuns = plateTargets.reduce((sum, target) => sum + target.quantity_target, 0);
const shouldAutoBatch =
mode === 'create'
&& platesToQueue.length > 1
&& (platesToQueue.length > 1 || totalRuns > 1)
&& (assignmentMode === 'model' || selectedPrinters.length === 1);
let autoBatchId: number | null = null;
if (shouldAutoBatch) {
try {
const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
const batchName = `${baseName || 'Batch'} · ${platesToQueue.length} plates`;
const batchName = platesToQueue.length > 1
? `${baseName || 'Batch'} · ${platesToQueue.length} plates`
: `${baseName || 'Batch'} ×${totalRuns}`;
const batch = await api.createBatch({
name: batchName,
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
plates: plateTargets,
});
autoBatchId = batch.id;
} catch {
// Non-fatal: fall back to ungrouped items so the queue still works.
// The server still creates a plain batch when quantity > 1, so the
// queue grouping survives even when the order layer doesn't.
autoBatchId = null;
}
}
@@ -1001,8 +1147,9 @@ export function PrintModal({
} else {
// Add-to-queue mode with model-based assignment
const queueData = getQueueData(null, plateId);
if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
applyAsapInsertion(queueData, null, effectiveQuantity);
const plateQuantity = quantityForPlate(plateId);
if (plateQuantity > 1) queueData.quantity = plateQuantity;
applyAsapInsertion(queueData, null, plateQuantity);
await addToQueueMutation.mutateAsync(queueData);
}
results.success++;
@@ -1062,8 +1209,9 @@ export function PrintModal({
} else {
// 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);
const plateQuantity = quantityForPlate(plateId);
if (plateQuantity > 1) queueData.quantity = plateQuantity;
applyAsapInsertion(queueData, printerId, plateQuantity);
// Apply stagger offset for groups after the first
if (useStagger) {
const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -1131,9 +1279,11 @@ export function PrintModal({
// Need valid printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
if (assignmentMode === 'model' && !targetModel) return false;
// Both are about the single-model case. A cross-model job has no one target
// model, and each candidate is gated against its own by the backend (#671).
if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false;
// Cross-model mismatch cannot be queued (#2578)
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
// For multi-plate files, need at least one plate selected
if (isMultiPlate && selectedPlates.size === 0) return false;
@@ -1162,11 +1312,27 @@ export function PrintModal({
perPlateReqsPending,
perPlateReqsFailed,
printerStatusLoading,
isCrossModel,
]);
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
// On a multi-plate file the per-plate steppers own the quantity and the
// global field is hidden (#342) — the reporter's case is "plate 1 once,
// plate 2 twice", which one shared number cannot express. Single-plate
// files, and edit mode, keep the single field exactly as before.
const usePerPlateQuantities = mode === 'create' && isMultiPlate && plates.length > 1;
/** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
const quantityForPlate = (plateIndex: number | null): number => {
if (!usePerPlateQuantities || plateIndex == null) return effectiveQuantity;
// Multi-printer fan-out already means one copy per printer; multiplying by
// a per-plate count on top would silently produce plates × printers × n.
if (assignmentMode === 'printer' && selectedPrinters.length > 1) return 1;
return Math.max(1, plateQuantities[plateIndex] ?? 1);
};
// Clear gcode_injection if the admin removes all snippets while the modal
// is open — the checkbox itself hides via hasGcodeSnippets in
// ScheduleOptions, but the boolean would otherwise stay true and ship to
@@ -1235,8 +1401,13 @@ export function PrintModal({
// is the filament each slot must be printed in, which the scheduler matches
// against whatever printer of the model it picks. Needs the model's loaded
// filaments to offer as alternatives.
// Cross-model items have no targetModel by design — their candidates each
// carry their own — so gate on having somewhere to source choices from.
const showFilamentOverride =
assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
assignmentMode === 'model'
&& (isCrossModel || !!targetModel)
&& !!effectiveAvailableFilaments
&& effectiveAvailableFilaments.length > 0;
// Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
// Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
@@ -1329,10 +1500,39 @@ export function PrintModal({
onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
multiSelect={!isEditing}
quantities={usePerPlateQuantities ? plateQuantities : undefined}
onQuantityChange={usePerPlateQuantities
? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value }))
: undefined}
/>
{/* Cross-model alternatives (#671) replace the printer picker entirely:
the user already answered "which printer" by choosing these files,
and the remaining question is only which they'd rather have. */}
{isCrossModel && (
<VariantCandidates
candidates={candidates}
onReorder={setCandidates}
plateByFile={candidatePlates}
onPlateChange={(fileId, plateId) =>
setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
}
/>
)}
{hasEditingVariants && (
<VariantCandidates
candidates={editingVariants}
readOnly
readOnlyNote={t('printModal.variants.editNote')}
onReorder={() => {}}
plateByFile={{}}
onPlateChange={() => {}}
/>
)}
{/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
{!initialSelectedPrinterIds?.length && (
{!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && (
<PrinterSelector
printers={printers || []}
selectedPrinterIds={selectedPrinters}
@@ -1364,7 +1564,7 @@ export function PrintModal({
{showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
<FilamentOverride
filamentReqs={effectiveFilamentReqs}
availableFilaments={availableFilaments!}
availableFilaments={effectiveAvailableFilaments!}
overrides={filamentOverrides}
onChange={setFilamentOverrides}
forceColorMatch={forceColorMatch}
@@ -1460,6 +1660,7 @@ export function PrintModal({
onForceColorMatchChange={(slotId, value) =>
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
}
archiveAmsMapping={archiveSlicerAmsMapping}
/>
)}
@@ -1483,7 +1684,7 @@ export function PrintModal({
setEstimatedCostsByPlate((prev) => ({ ...prev, [plateId]: cost }))
}
budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
quantity={effectiveQuantity}
quantity={quantityForPlate(plateId)}
defaultExpanded={false}
currencySymbol={currencySymbol}
defaultCostPerKg={defaultCostPerKg}
@@ -1491,6 +1692,7 @@ export function PrintModal({
onForceColorMatchChange={(slotId, value) =>
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
}
archiveAmsMapping={archiveSlicerAmsMapping}
/>
);
})}
@@ -1513,8 +1715,11 @@ export function PrintModal({
/>
)}
{/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
{mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
{/* Quantity — create multiple copies (batch). Hidden for multi-printer
selection, and for multi-plate files where the per-plate steppers
in PlateSelector own the number instead (#342). */}
{mode !== 'edit-queue-item' && !usePerPlateQuantities
&& (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
<div className="flex items-center gap-3">
<label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
{t('queue.quantity', 'Quantity')}
@@ -1,6 +1,8 @@
import type { PrintQueueItem, Printer, CalibrationMode } from '../../api/client';
import type { VariantCandidate } from './VariantCandidates';
export type { CalibrationMode };
export type { VariantCandidate };
/**
* Mode of operation for the PrintModal.
@@ -38,6 +40,18 @@ export interface PrintModalProps {
/** Delete the LibraryFile after dispatch — used by the Printers-page Direct-Print flow
* so transient uploads don't linger in File Manager. Only applies to library-file prints. */
cleanupLibraryAfterDispatch?: boolean;
/**
* Cross-model alternatives (#671): the same job sliced for several printers,
* to be queued as ONE item that runs on whichever frees up first.
*
* Supplied by the File Manager when the user multi-selects sliced files, or
* when the clicked file belongs to a variant group. Two or more entries put
* the modal in cross-model mode: the printer picker is replaced by the ordered
* candidate list, and submit posts `variants` instead of a single file.
* `libraryFileId` must still be the first candidate — the shared filament and
* plate preview reads from it.
*/
variantFiles?: VariantCandidate[];
}
/**
@@ -183,6 +197,14 @@ export interface PlateSelectorProps {
onDeselectAll?: () => void;
/** Whether multi-select (checkboxes) is enabled */
multiSelect?: boolean;
/**
* How many runs of each plate to queue, keyed by plate index (#342). When
* provided, each selected plate gets its own quantity control and the
* modal's single global Quantity field is hidden — one number per plate is
* the whole point, and two controls for the same value would be ambiguous.
*/
quantities?: Record<number, number>;
onQuantityChange?: (plateIndex: number, quantity: number) => void;
}
/**
@@ -227,6 +249,14 @@ export interface FilamentMappingProps {
* plate. Each plate prints its own subset of the file's slots and gets its
* own AMS mapping, so the panels have to be told apart. */
plateLabel?: string;
/** The archive's own saved AMS-slot pick from the slicer
* (`extra_data.slicer_ams_mapping`, written when the source virtual
* printer has "Save AMS mapping" enabled) — position = slot_id-1, value =
* global tray ID. When present, a "Mapping" toggle next to "Re-read" lets
* the user select every slot from this array instead of the type/color
* auto-match. Undefined/omitted when the archive has no saved mapping —
* the toggle is hidden and behaviour is unchanged. */
archiveAmsMapping?: number[];
}
/**