mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Merge remote-tracking branch 'upstream/dev' into feature/billing
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user