mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 21:51:23 +02:00
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.
735 lines
29 KiB
TypeScript
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>
|
|
);
|
|
}
|