Files
bambuddy/frontend/src/components/PrintModal/PrinterSelector.tsx
T
maziggy aa443c6e83 fix(print): keep filament gram usage visible when the name is long (#2669)
In the Print dialog's Filament Mapping, each required filament shows its
name and the grams the job needs, e.g. "Bambu PLA Basic (281.2g)". Name and
grams shared one fixed-width column with truncate on the whole string, so a
long name pushed the "(...g)" off the end and clipped it -- partially on a
wide screen, entirely in mobile portrait. The gram usage is the number that
matters (does the spool have enough left?), so it shouldn't be the part that
gets dropped.

Pin the gram usage (shrink-0, whitespace-nowrap) and let only the name
truncate, with the full name on hover. Applied to both the Specific-Printer
(FilamentMapping) and Any-model (PrinterSelector) panels. Layout only.
2026-07-27 09:22:14 +02:00

735 lines
29 KiB
TypeScript

import { useState, useMemo } from 'react';
import { useQueryClient, useQueries } from '@tanstack/react-query';
import {
Printer as PrinterIcon,
Loader2,
AlertCircle,
AlertTriangle,
Check,
Circle,
RefreshCw,
Wand2,
Users,
} from 'lucide-react';
import { api, type PrinterStatus } from '../../api/client';
import { getColorName } from '../../utils/colors';
import { isGcodeCompatible } from '../../utils/printer';
import {
normalizeColorForCompare,
colorsAreSimilar,
autoMatchFilament,
filterFilamentsByNozzle,
effectivePreferLowest,
} from '../../utils/amsHelpers';
import type { PrinterSelectorProps, AssignmentMode } from './types';
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
/** Per-printer mapping results (only used when multiple printers selected) */
printerMappingResults?: PrinterMappingResult[];
/** Filament requirements for the print */
filamentReqs?: { filaments: FilamentRequirement[] };
/** Callback to auto-configure a printer */
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;
/** Selected target location (when assignmentMode is 'model') */
targetLocation?: string | null;
/** Handler for target location change */
onTargetLocationChange?: (location: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
/** States where the printer is available to accept a new print */
const AVAILABLE_STATES = new Set(['IDLE', 'FINISH', 'FAILED']);
/**
* Inline AMS mapping editor for a single printer.
*/
function InlineMappingEditor({
printerResult,
filamentReqs,
onUpdateConfig,
}: {
printerResult: PrinterMappingResult;
filamentReqs: FilamentRequirement[];
onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
}) {
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleSlotChange = (slotId: number, value: string) => {
if (slotId <= 0) return;
const newMappings = { ...printerResult.config.manualMappings };
if (value === '') {
delete newMappings[slotId];
} else {
newMappings[slotId] = parseInt(value, 10);
}
onUpdateConfig({
useDefault: false,
manualMappings: newMappings,
autoConfigured: false,
});
};
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await api.refreshPrinterStatus(printerResult.printerId);
await new Promise((r) => setTimeout(r, 500));
await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });
} finally {
setIsRefreshing(false);
}
};
// Compute current slot assignments
const slotAssignments = filamentReqs.map((req) => {
const slotId = req.slot_id || 0;
const currentMapping = printerResult.config.manualMappings[slotId];
let loaded: LoadedFilament | undefined;
let isManual = false;
if (currentMapping !== undefined) {
loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
isManual = true;
} else {
const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
loaded = autoMatchFilament(
req,
printerResult.loadedFilaments,
usedTrayIds,
effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup),
printerResult.inventoryByTrayId,
) as LoadedFilament | undefined;
}
// Determine status
let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
if (loaded) {
const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch =
normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(loaded.color, req.color);
if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only';
}
}
return { req, loaded, status, isManual };
});
return (
<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">Custom slot mapping</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>
</div>
{slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
<div
key={idx}
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
<span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
<Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
</span>
{/* Only the name truncates; the gram usage is pinned (shrink-0) so
it never clips on narrow/mobile widths (#2669). */}
<span className="text-white flex items-center gap-1 min-w-0">
<span className="truncate min-w-0" title={req.type}>{req.type}</span>
<span className="text-bambu-gray shrink-0 whitespace-nowrap">({req.used_grams}g)</span>
</span>
<span className="text-bambu-gray">→</span>
<select
value={loaded?.globalTrayId ?? ''}
onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: status === 'type_only'
? 'border-yellow-500 dark:border-yellow-400/50 text-yellow-700 dark:text-yellow-400'
: 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
} ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={isManual ? 'Manually selected' : 'Auto-matched'}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- Select slot --
</option>
{filterFilamentsByNozzle(printerResult.loadedFilaments, req.nozzle_id)
.map((f) => (
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
{f.label}: {f.traySubBrands || f.type} ({f.colorName})
</option>
))}
</select>
{status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : status === 'type_only' ? (
<span title="Same type, different color">
<AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-400" />
</span>
) : (
<span title="Filament type not loaded">
<AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400" />
</span>
)}
</div>
))}
</div>
);
}
/**
* Printer selection component with grid-based UI.
* Supports single or multi-select modes.
* When multiple printers are selected, shows per-printer mapping overrides.
*/
export function PrinterSelector({
printers,
selectedPrinterIds,
onMultiSelect,
isLoading = false,
allowMultiple = false,
showInactive = false,
disableBusy = false,
printerMappingResults,
filamentReqs,
onAutoConfigurePrinter,
onUpdatePrinterConfig,
assignmentMode = 'printer',
onAssignmentModeChange,
targetModel,
onTargetModelChange,
targetLocation,
onTargetLocationChange,
slicedForModel,
}: PrinterSelectorWithMappingProps) {
// State for showing all printers vs only matching model
const [showAllPrinters, setShowAllPrinters] = useState(false);
// Filter printers based on showInactive flag
const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
// Fetch printer statuses to determine busy/idle state
const statusQueries = useQueries({
queries: activePrinters.map((printer) => ({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
staleTime: 5000,
})),
});
// Build a map of printer ID -> status for quick lookup
const printerStatusMap = useMemo(() => {
const map = new Map<number, PrinterStatus>();
activePrinters.forEach((printer, idx) => {
const query = statusQueries[idx];
if (query?.data) {
map.set(printer.id, query.data);
}
});
return map;
}, [activePrinters, statusQueries]);
const isPrinterBusy = (printerId: number): boolean => {
const status = printerStatusMap.get(printerId);
if (!status) return false; // Unknown state — don't block
if (!status.connected) return true;
return !AVAILABLE_STATES.has(status.state ?? '');
};
const getPrinterStateLabel = (printerId: number): string | null => {
const status = printerStatusMap.get(printerId);
if (!status) return null;
if (!status.connected) return 'Offline';
const state = status.state;
if (!state) return null;
if (state === 'RUNNING') return status.stg_cur_name || 'Printing';
if (state === 'PREPARE') return 'Preparing';
if (state === 'PAUSE') return 'Paused';
if (state === 'IDLE') return 'Idle';
if (state === 'FINISH') return 'Finished';
if (state === 'FAILED') return 'Failed';
return state;
};
// 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]);
// Get unique locations for the selected target model (for location filtering)
const uniqueLocations = useMemo(() => {
if (!targetModel) return [];
const locations = activePrinters
.filter(p => p.model === targetModel && p.location)
.map(p => p.location)
.filter((l): l is string => Boolean(l));
return [...new Set(locations)].sort();
}, [activePrinters, targetModel]);
// 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 &&
printerMappingResults &&
filamentReqs?.filaments &&
filamentReqs.filaments.length > 0 &&
onAutoConfigurePrinter &&
onUpdatePrinterConfig;
if (isLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
);
}
if (displayPrinters.length === 0) {
return (
<div className="flex items-center gap-2 text-red-700 dark:text-red-400 text-sm mb-4">
<AlertCircle className="w-4 h-4" />
No {showInactive ? '' : 'active '}printers available
</div>
);
}
const handlePrinterClick = (printerId: number) => {
if (disableBusy && isPrinterBusy(printerId)) return;
if (allowMultiple) {
if (selectedPrinterIds.includes(printerId)) {
onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
} else {
onMultiSelect([...selectedPrinterIds, printerId]);
}
} else {
onMultiSelect([printerId]);
}
};
const handleSelectAll = () => {
const selectable = disableBusy
? displayPrinters.filter((p) => !isPrinterBusy(p.id))
: displayPrinters;
onMultiSelect(selectable.map((p) => p.id));
};
const handleDeselectAll = () => {
onMultiSelect([]);
};
const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {
e.stopPropagation();
if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;
if (enabled) {
onAutoConfigurePrinter(printerId);
} else {
onUpdatePrinterConfig(printerId, {
useDefault: true,
manualMappings: {},
autoConfigured: false,
});
}
};
const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
const selectedCount = selectedPrinterIds.length;
const getPrinterMappingResult = (printerId: number) => {
return printerMappingResults?.find((r) => r.printerId === printerId);
};
return (
<div className="space-y-2 mb-6">
{/* 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 when it has printers. NEVER
// silently fall back to another model (#2578): uniqueModels is
// alphabetically sorted, so the old `uniqueModels[0]` default
// quietly targeted e.g. H2D for an X1C-sliced file whenever the
// metadata hadn't loaded yet. Leave it unset and let the user
// pick from the dropdown instead.
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
? slicedForModel
: null;
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" />
{/* In model mode the label must reflect the ACTUAL scheduler
target, not the slice metadata — an edited queue item can
target a different model than the file was sliced for (#2578). */}
<span className="text-sm">
Any {(assignmentMode === 'model' ? targetModel : null) || slicedForModel || 'Model'}
</span>
</button>
</div>
)}
{/* Model selection and location filter (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && (
<div className="space-y-3 mb-4">
{/* Model selector — always visible in model mode so a wrong target
on an existing queue item can be seen and fixed (#2578).
Incompatible models are disabled: G-code sliced for one model
must only go to that model or its interchange family. */}
<div>
<label className="block text-xs text-bambu-gray mb-1">
Target Model
{slicedForModel && <span className="ml-2 text-bambu-gray/70">(sliced for {slicedForModel})</span>}
</label>
<select
value={targetModel || ''}
onChange={(e) => {
onTargetModelChange!(e.target.value || null);
// Clear location when model changes
if (onTargetLocationChange) {
onTargetLocationChange(null);
}
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
>
<option value="">Select a model...</option>
{uniqueModels.map((model) => {
const compatible = isGcodeCompatible(slicedForModel, model);
return (
<option key={model} value={model} disabled={!compatible}>
{model}
{!compatible ? ` — incompatible with ${slicedForModel} G-code` : ''}
</option>
);
})}
</select>
</div>
{/* Cross-model state on an existing row: same file/target mismatch
the specific-printer path already warns about (#2578). */}
{slicedForModel && targetModel && targetModel !== slicedForModel && (
isGcodeCompatible(slicedForModel, targetModel) ? (
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
<span className="text-sm text-yellow-700 dark:text-yellow-400">
File was sliced for {slicedForModel}, but will be dispatched to a {targetModel} printer
</span>
</div>
) : (
<div className="p-3 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
<span className="text-sm text-red-700 dark:text-red-400">
File was sliced for {slicedForModel} and cannot be dispatched to {targetModel} printers —
select a compatible model
</span>
</div>
)
)}
{/* Location filter (only show when target model is selected and locations exist) */}
{targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
<div>
<label className="block text-xs text-bambu-gray mb-1">Location Filter (optional)</label>
<select
value={targetLocation || ''}
onChange={(e) => onTargetLocationChange(e.target.value || null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
>
<option value="">Any location</option>
{uniqueLocations.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
)}
{/* Info text */}
{targetModel && (
<p className="text-xs text-bambu-gray">
Scheduler will assign to first available idle {targetModel} printer
{targetLocation ? ` in ${targetLocation}` : ''}
</p>
)}
</div>
)}
{/* 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
? 'Select printers'
: `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
</span>
<div className="flex gap-2">
{selectedCount < displayPrinters.length && (
<button
type="button"
onClick={handleSelectAll}
className="text-bambu-green hover:text-bambu-green/80 transition-colors"
>
Select all
</button>
)}
{selectedCount > 0 && (
<button
type="button"
onClick={handleDeselectAll}
className="text-bambu-gray hover:text-white transition-colors"
>
Clear
</button>
)}
</div>
</div>
)}
{/* 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;
const busy = isPrinterBusy(printer.id);
const disabled = disableBusy && busy;
const stateLabel = getPrinterStateLabel(printer.id);
return (
<div key={printer.id}>
{/* Printer selection button */}
<button
type="button"
onClick={() => handlePrinterClick(printer.id)}
disabled={disabled}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
disabled
? 'border-bambu-dark-tertiary bg-bambu-dark opacity-50 cursor-not-allowed'
: selected
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
} ${!printer.is_active ? 'opacity-60' : ''}`}
>
<div
className={`p-2 rounded-lg ${
disabled ? 'bg-bambu-dark-tertiary' : selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
}`}
>
<PrinterIcon
className={`w-5 h-5 ${
disabled ? 'text-bambu-gray/50' : selected ? 'text-bambu-green' : 'text-bambu-gray'
}`}
/>
</div>
<div className="text-left flex-1">
<p className={`font-medium ${disabled ? 'text-bambu-gray' : 'text-white'}`}>
{printer.name}
{!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
</p>
<p className="text-xs text-bambu-gray">
{printer.model || 'Unknown model'} • {printer.ip_address}
</p>
</div>
{stateLabel && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
busy
? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400'
: 'bg-bambu-green/20 text-bambu-green'
}`}>
{stateLabel}
</span>
)}
{allowMultiple && (
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
disabled
? 'border-bambu-gray/30'
: selected
? 'bg-bambu-green border-bambu-green'
: 'border-bambu-gray/50'
}`}
>
{selected && <Check className="w-3 h-3 text-white" />}
</div>
)}
</button>
{/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}
{selected && showMappingOptions && mappingResult && (
<div className="ml-4 mt-2 mb-3">
{/* Override checkbox row */}
<div className="flex items-center gap-2">
<label
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={hasOverride}
onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}
className="w-3.5 h-3.5 rounded border-bambu-gray/30 bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
/>
<span className="text-xs text-bambu-gray">Custom mapping</span>
</label>
{/* Match status indicator */}
<span className={`text-xs ml-2 ${
mappingResult.matchStatus === 'full'
? 'text-bambu-green'
: mappingResult.matchStatus === 'partial'
? 'text-yellow-700 dark:text-yellow-400'
: 'text-orange-700 dark:text-orange-400'
}`}>
({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)
</span>
{/* Loading indicator */}
{mappingResult.isLoading && (
<RefreshCw className="w-3 h-3 text-bambu-gray animate-spin" />
)}
{/* Auto-configure button (when override is enabled) */}
{hasOverride && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAutoConfigurePrinter!(printer.id);
}}
className="ml-auto 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"
>
<Wand2 className="w-3 h-3" />
Auto
</button>
)}
</div>
{/* Inline mapping editor (shown when override is checked) */}
{hasOverride && (
<InlineMappingEditor
printerResult={mappingResult}
filamentReqs={filamentReqs!.filaments}
onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}
/>
)}
</div>
)}
</div>
);
})}
{/* 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-600 dark: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-700 dark: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-700 dark:text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select a target printer model
</p>
)}
</div>
);
}