| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- import { useState, useMemo } from 'react';
- import { useQueryClient } from '@tanstack/react-query';
- import {
- Printer as PrinterIcon,
- Loader2,
- AlertCircle,
- AlertTriangle,
- Check,
- Circle,
- RefreshCw,
- Wand2,
- Users,
- } from 'lucide-react';
- import { api } from '../../api/client';
- import { getColorName } from '../../utils/colors';
- import {
- normalizeColorForCompare,
- colorsAreSimilar,
- } 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;
- /** Suggested model from sliced file (for pre-selection) */
- slicedForModel?: string | null;
- }
- /**
- * 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 {
- // Auto-match logic
- const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
- const exactMatch = printerResult.loadedFilaments.find(
- (f) =>
- !usedTrayIds.has(f.globalTrayId) &&
- f.type?.toUpperCase() === req.type?.toUpperCase() &&
- normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
- );
- const similarMatch = exactMatch
- ? undefined
- : printerResult.loadedFilaments.find(
- (f) =>
- !usedTrayIds.has(f.globalTrayId) &&
- f.type?.toUpperCase() === req.type?.toUpperCase() &&
- colorsAreSimilar(f.color, req.color)
- );
- const typeOnlyMatch =
- exactMatch || similarMatch
- ? undefined
- : printerResult.loadedFilaments.find(
- (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
- );
- loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
- }
- // 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-400/50 text-yellow-400'
- : 'border-orange-400/50 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>
- {printerResult.loadedFilaments.map((f) => (
- <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
- {f.label}: {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-400" />
- </span>
- ) : (
- <span title="Filament type not loaded">
- <AlertTriangle className="w-3 h-3 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,
- printerMappingResults,
- 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 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 &&
- 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-400 text-sm mb-4">
- <AlertCircle className="w-4 h-4" />
- No {showInactive ? '' : 'active '}printers available
- </div>
- );
- }
- const handlePrinterClick = (printerId: number) => {
- if (allowMultiple) {
- if (selectedPrinterIds.includes(printerId)) {
- onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
- } else {
- onMultiSelect([...selectedPrinterIds, printerId]);
- }
- } else {
- onMultiSelect([printerId]);
- }
- };
- const handleSelectAll = () => {
- onMultiSelect(displayPrinters.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 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
- ? '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;
- return (
- <div key={printer.id}>
- {/* Printer selection button */}
- <button
- type="button"
- onClick={() => handlePrinterClick(printer.id)}
- className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
- 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 ${
- selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
- }`}
- >
- <PrinterIcon
- className={`w-5 h-5 ${
- selected ? 'text-bambu-green' : 'text-bambu-gray'
- }`}
- />
- </div>
- <div className="text-left flex-1">
- <p className="text-white font-medium">
- {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>
- {allowMultiple && (
- <div
- className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
- 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-400'
- : '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-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>
- );
- }
|