This commit is contained in:
maziggy
2026-01-28 09:14:22 +01:00
parent ead2bfc822
commit 2eb21e8b42
21 changed files with 9256 additions and 8861 deletions
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Printer as PrinterIcon,
@@ -9,6 +9,7 @@ import {
Circle,
RefreshCw,
Wand2,
Users,
} from 'lucide-react';
import { api } from '../../api/client';
import { getColorName } from '../../utils/colors';
@@ -16,7 +17,7 @@ import {
normalizeColorForCompare,
colorsAreSimilar,
} from '../../utils/amsHelpers';
import type { PrinterSelectorProps } from './types';
import type { PrinterSelectorProps, AssignmentMode } from './types';
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
onAutoConfigurePrinter?: (printerId: number) => void;
/** Callback to update printer config */
onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
/** Current assignment mode */
assignmentMode?: AssignmentMode;
/** Handler for assignment mode change */
onAssignmentModeChange?: (mode: AssignmentMode) => void;
/** Selected target model (when assignmentMode is 'model') */
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
/**
@@ -212,9 +223,42 @@ export function PrinterSelector({
filamentReqs,
onAutoConfigurePrinter,
onUpdatePrinterConfig,
assignmentMode = 'printer',
onAssignmentModeChange,
targetModel,
onTargetModelChange,
slicedForModel,
}: PrinterSelectorWithMappingProps) {
// State for showing all printers vs only matching model
const [showAllPrinters, setShowAllPrinters] = useState(false);
// Filter printers based on showInactive flag
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
// Filter by sliced model (only in printer mode, when slicedForModel is set)
const displayPrinters = useMemo(() => {
if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
return activePrinters;
}
// Filter to only show printers matching the sliced model
const matching = activePrinters.filter((p) => p.model === slicedForModel);
// If no matching printers, show all
return matching.length > 0 ? matching : activePrinters;
}, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
// Check if there are hidden printers due to model filtering
const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
// Get unique models from available printers (for model-based assignment)
const uniqueModels = useMemo(() => {
const models = activePrinters
.map(p => p.model)
.filter((m): m is string => Boolean(m));
return [...new Set(models)].sort();
}, [activePrinters]);
// Check if model-based assignment is available (need callbacks and multiple printers of same model)
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
const showMappingOptions = allowMultiple &&
selectedPrinterIds.length > 1 &&
@@ -285,8 +329,56 @@ export function PrinterSelector({
return (
<div className="space-y-2 mb-6">
{/* Multi-select header */}
{allowMultiple && displayPrinters.length > 1 && (
{/* Assignment mode toggle (model vs specific printer) */}
{modelAssignmentAvailable && (
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => {
onAssignmentModeChange!('printer');
onTargetModelChange!(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
assignmentMode === 'printer'
? 'border-bambu-green bg-bambu-green/10 text-white'
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
}`}
>
<PrinterIcon className="w-4 h-4" />
<span className="text-sm">Specific Printer</span>
</button>
<button
type="button"
onClick={() => {
onAssignmentModeChange!('model');
onMultiSelect([]);
// Pre-select the sliced-for model if available, otherwise first model
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
? slicedForModel
: uniqueModels[0];
onTargetModelChange!(defaultModel);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
assignmentMode === 'model'
? 'border-bambu-green bg-bambu-green/10 text-white'
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
}`}
>
<Users className="w-4 h-4" />
<span className="text-sm">Any {slicedForModel || 'Model'}</span>
</button>
</div>
)}
{/* Model info (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
<p className="text-xs text-bambu-gray mb-4">
Scheduler will assign to first available idle {targetModel} printer
</p>
)}
{/* Multi-select header (only in printer mode) */}
{assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
<span>
{selectedCount === 0
@@ -316,7 +408,8 @@ export function PrinterSelector({
</div>
)}
{displayPrinters.map((printer) => {
{/* Printer list (only in printer mode) */}
{assignmentMode === 'printer' && displayPrinters.map((printer) => {
const selected = isSelected(printer.id);
const mappingResult = getPrinterMappingResult(printer.id);
const hasOverride = mappingResult && !mappingResult.config.useDefault;
@@ -430,13 +523,45 @@ export function PrinterSelector({
);
})}
{/* Warning when no printer selected */}
{selectedCount === 0 && (
{/* Show hidden printers toggle */}
{assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
<button
type="button"
onClick={() => setShowAllPrinters(true)}
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
>
<AlertTriangle className="w-3 h-3 text-yellow-400" />
{hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —
<span className="underline">show all</span>
</button>
)}
{/* Show matching only toggle */}
{assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
<button
type="button"
onClick={() => setShowAllPrinters(false)}
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
>
<span className="underline">Show only {slicedForModel} printers</span>
</button>
)}
{/* Warning when no printer selected (only in printer mode) */}
{assignmentMode === 'printer' && selectedCount === 0 && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select at least one printer
</p>
)}
{/* Warning when no model selected (only in model mode) */}
{assignmentMode === 'model' && !targetModel && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select a target printer model
</p>
)}
</div>
);
}
+144 -42
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
import { api } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
import { Card, CardContent } from '../Card';
@@ -19,6 +19,7 @@ import type {
PrintOptions,
ScheduleOptions,
ScheduleType,
AssignmentMode,
} from './types';
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
@@ -117,6 +118,23 @@ export function PrintModal({
// Per-printer override configs (for multi-printer selection)
const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
// Assignment mode: 'printer' (specific) or 'model' (any of model)
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
// Initialize from queue item if editing with target_model
if (mode === 'edit-queue-item' && queueItem?.target_model) {
return 'model';
}
return 'printer';
});
// Target model for model-based assignment
const [targetModel, setTargetModel] = useState<string | null>(() => {
if (mode === 'edit-queue-item' && queueItem?.target_model) {
return queueItem.target_model;
}
return null;
});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -145,6 +163,16 @@ export function PrintModal({
queryFn: api.getPrinters,
});
// Fetch archive details to get sliced_for_model
const { data: archiveDetails } = useQuery({
queryKey: ['archive', archiveId],
queryFn: () => api.getArchive(archiveId!),
enabled: !!archiveId && !isLibraryFile,
});
// Get sliced_for_model from archive or library file
const slicedForModel = archiveDetails?.sliced_for_model || null;
// Fetch plates for archives
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
queryKey: ['archive-plates', archiveId],
@@ -304,14 +332,20 @@ export function PrintModal({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
// Validate printer selection
if (selectedPrinters.length === 0) {
// Validate printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
showToast('Please select at least one printer', 'error');
return;
}
if (assignmentMode === 'model' && !targetModel) {
showToast('Please select a target printer model', 'error');
return;
}
setIsSubmitting(true);
setSubmitProgress({ current: 0, total: selectedPrinters.length });
// For model-based assignment, we just make one API call
const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
setSubmitProgress({ current: 0, total: totalCount });
const results: { success: number; failed: number; errors: string[] } = {
success: 0,
@@ -332,15 +366,16 @@ export function PrintModal({
};
// Common queue data for add-to-queue and edit modes
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
printer_id: printerId,
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: getMappingForPrinter(printerId),
ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -348,35 +383,24 @@ export function PrintModal({
...printOptions,
});
for (let i = 0; i < selectedPrinters.length; i++) {
const printerId = selectedPrinters[i];
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
// Model-based assignment: single API call
if (assignmentMode === 'model') {
setSubmitProgress({ current: 1, total: 1 });
try {
if (mode === 'reprint') {
// Reprint mode - start print immediately
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: printerMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const printerMapping = getMappingForPrinter(printerId);
// Model-based reprint not supported (need specific printer for immediate print)
showToast('Model-based assignment only works with queue mode', 'error');
setIsSubmitting(false);
return;
} else if (mode === 'edit-queue-item') {
// Edit mode - update with target_model
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
printer_id: null,
target_model: targetModel,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: printerMapping,
ams_mapping: undefined,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -385,14 +409,63 @@ export function PrintModal({
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
// Add-to-queue mode with model-based assignment
await addToQueueMutation.mutateAsync(getQueueData(null));
}
results.success++;
} catch (error) {
results.failed++;
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
results.errors.push(`${printerName}: ${(error as Error).message}`);
results.errors.push((error as Error).message);
}
} else {
// Printer-based assignment: loop through selected printers
for (let i = 0; i < selectedPrinters.length; i++) {
const printerId = selectedPrinters[i];
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
try {
if (mode === 'reprint') {
// Reprint mode - start print immediately
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: printerMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const printerMapping = getMappingForPrinter(printerId);
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
target_model: null,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: printerMapping,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
: null,
...printOptions,
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
}
results.success++;
} catch (error) {
results.failed++;
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
results.errors.push(`${printerName}: ${(error as Error).message}`);
}
}
}
@@ -400,11 +473,15 @@ export function PrintModal({
// Show result toast
if (results.failed === 0) {
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
if (assignmentMode === 'model') {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
} else {
showToast(`Print ${action} ${results.success} printers`);
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
} else {
showToast(`Print ${action} ${results.success} printers`);
}
}
queryClient.invalidateQueries({ queryKey: ['queue'] });
onSuccess?.();
@@ -422,14 +499,18 @@ export function PrintModal({
const canSubmit = useMemo(() => {
if (isPending) return false;
// Need at least one selected printer
if (selectedPrinters.length === 0) return false;
// Need valid printer/model selection
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 archive files, need a selected plate (library files skip this)
if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
return true;
}, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
}, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
// Modal title and action button text based on mode
const getModalConfig = () => {
@@ -541,8 +622,29 @@ export function PrintModal({
filamentReqs={effectiveFilamentReqs}
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
targetModel={targetModel}
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
slicedForModel={slicedForModel}
/>
{/* Compatibility warning when sliced model doesn't match selected printer */}
{slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
return (
<div className="p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-sm text-yellow-400">
File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
</span>
</div>
);
}
return null;
})()}
{/* Warning when archive data couldn't be loaded */}
{archiveDataMissing && (
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
@@ -565,7 +667,7 @@ export function PrintModal({
)}
{/* Print options */}
{(mode === 'reprint' || effectivePrinterCount > 0) && (
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
)}
@@ -104,6 +104,13 @@ export interface PlatesResponse {
plates: PlateInfo[];
}
/**
* Assignment mode for queue items.
* - 'printer': Assign to specific printer(s)
* - 'model': Assign to any printer of a specific model (load balancing)
*/
export type AssignmentMode = 'printer' | 'model';
/**
* Props for the PrinterSelector component.
*/
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
allowMultiple?: boolean;
/** Show inactive printers (for edit mode where original assignment may be inactive) */
showInactive?: boolean;
/** Current assignment mode */
assignmentMode?: AssignmentMode;
/** Handler for assignment mode change */
onAssignmentModeChange?: (mode: AssignmentMode) => void;
/** Selected target model (when assignmentMode is 'model') */
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
/**