mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
A queue item's "Any <model>" button labeled itself from the file's slice metadata while the scheduler used the row's target_model, so an X1C-sliced item targeting H2D showed "Any X1C" above "assign to first idle H2D". The mismatch itself was created silently: sliced-for metadata loads async, and switching to model mode before it arrived pre-selected the alphabetically first model (H2D on a mixed farm), after which the model dropdown hid itself. Nothing validated compatibility, so the scheduler would hand X1C G-code to an H2D. Frontend: never default the target silently, keep the dropdown visible in model mode (incompatible models disabled), label from the actual target, warn on mismatch, block submit when incompatible. Backend: new GCODE_COMPAT_FAMILIES table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match; missing metadata never blocks). Queue create and update reject incompatible targets with 400; the scheduler holds back pre-existing mismatched rows with an actionable waiting_reason instead of dispatching them.
732 lines
29 KiB
TypeScript
732 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>
|
|
<span className="text-white truncate">
|
|
{req.type} <span className="text-bambu-gray">({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>
|
|
);
|
|
}
|