PrinterSelector.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. import { useState, useMemo } from 'react';
  2. import { useQueryClient, useQueries } from '@tanstack/react-query';
  3. import {
  4. Printer as PrinterIcon,
  5. Loader2,
  6. AlertCircle,
  7. AlertTriangle,
  8. Check,
  9. Circle,
  10. RefreshCw,
  11. Wand2,
  12. Users,
  13. } from 'lucide-react';
  14. import { api, type PrinterStatus } from '../../api/client';
  15. import { getColorName } from '../../utils/colors';
  16. import { isGcodeCompatible } from '../../utils/printer';
  17. import {
  18. normalizeColorForCompare,
  19. colorsAreSimilar,
  20. autoMatchFilament,
  21. filterFilamentsByNozzle,
  22. effectivePreferLowest,
  23. } from '../../utils/amsHelpers';
  24. import type { PrinterSelectorProps, AssignmentMode } from './types';
  25. import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
  26. import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
  27. interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
  28. /** Per-printer mapping results (only used when multiple printers selected) */
  29. printerMappingResults?: PrinterMappingResult[];
  30. /** Filament requirements for the print */
  31. filamentReqs?: { filaments: FilamentRequirement[] };
  32. /** Callback to auto-configure a printer */
  33. onAutoConfigurePrinter?: (printerId: number) => void;
  34. /** Callback to update printer config */
  35. onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
  36. /** Current assignment mode */
  37. assignmentMode?: AssignmentMode;
  38. /** Handler for assignment mode change */
  39. onAssignmentModeChange?: (mode: AssignmentMode) => void;
  40. /** Selected target model (when assignmentMode is 'model') */
  41. targetModel?: string | null;
  42. /** Handler for target model change */
  43. onTargetModelChange?: (model: string | null) => void;
  44. /** Selected target location (when assignmentMode is 'model') */
  45. targetLocation?: string | null;
  46. /** Handler for target location change */
  47. onTargetLocationChange?: (location: string | null) => void;
  48. /** Suggested model from sliced file (for pre-selection) */
  49. slicedForModel?: string | null;
  50. }
  51. /** States where the printer is available to accept a new print */
  52. const AVAILABLE_STATES = new Set(['IDLE', 'FINISH', 'FAILED']);
  53. /**
  54. * Inline AMS mapping editor for a single printer.
  55. */
  56. function InlineMappingEditor({
  57. printerResult,
  58. filamentReqs,
  59. onUpdateConfig,
  60. }: {
  61. printerResult: PrinterMappingResult;
  62. filamentReqs: FilamentRequirement[];
  63. onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
  64. }) {
  65. const queryClient = useQueryClient();
  66. const [isRefreshing, setIsRefreshing] = useState(false);
  67. const handleSlotChange = (slotId: number, value: string) => {
  68. if (slotId <= 0) return;
  69. const newMappings = { ...printerResult.config.manualMappings };
  70. if (value === '') {
  71. delete newMappings[slotId];
  72. } else {
  73. newMappings[slotId] = parseInt(value, 10);
  74. }
  75. onUpdateConfig({
  76. useDefault: false,
  77. manualMappings: newMappings,
  78. autoConfigured: false,
  79. });
  80. };
  81. const handleRefresh = async () => {
  82. setIsRefreshing(true);
  83. try {
  84. await api.refreshPrinterStatus(printerResult.printerId);
  85. await new Promise((r) => setTimeout(r, 500));
  86. await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });
  87. } finally {
  88. setIsRefreshing(false);
  89. }
  90. };
  91. // Compute current slot assignments
  92. const slotAssignments = filamentReqs.map((req) => {
  93. const slotId = req.slot_id || 0;
  94. const currentMapping = printerResult.config.manualMappings[slotId];
  95. let loaded: LoadedFilament | undefined;
  96. let isManual = false;
  97. if (currentMapping !== undefined) {
  98. loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
  99. isManual = true;
  100. } else {
  101. const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
  102. const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
  103. loaded = autoMatchFilament(
  104. req,
  105. printerResult.loadedFilaments,
  106. usedTrayIds,
  107. effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup),
  108. printerResult.inventoryByTrayId,
  109. ) as LoadedFilament | undefined;
  110. }
  111. // Determine status
  112. let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
  113. if (loaded) {
  114. const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
  115. const colorMatch =
  116. normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
  117. colorsAreSimilar(loaded.color, req.color);
  118. if (typeMatch && colorMatch) {
  119. status = 'match';
  120. } else if (typeMatch) {
  121. status = 'type_only';
  122. }
  123. }
  124. return { req, loaded, status, isManual };
  125. });
  126. return (
  127. <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
  128. <div className="flex items-center justify-between mb-2">
  129. <span className="text-xs text-bambu-gray">Custom slot mapping</span>
  130. <button
  131. type="button"
  132. onClick={handleRefresh}
  133. 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"
  134. disabled={isRefreshing}
  135. >
  136. <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
  137. <span>Re-read</span>
  138. </button>
  139. </div>
  140. {slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
  141. <div
  142. key={idx}
  143. className="grid items-center gap-2 text-xs"
  144. style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
  145. >
  146. <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
  147. <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
  148. </span>
  149. {/* Only the name truncates; the gram usage is pinned (shrink-0) so
  150. it never clips on narrow/mobile widths (#2669). */}
  151. <span className="text-white flex items-center gap-1 min-w-0">
  152. <span className="truncate min-w-0" title={req.type}>{req.type}</span>
  153. <span className="text-bambu-gray shrink-0 whitespace-nowrap">({req.used_grams}g)</span>
  154. </span>
  155. <span className="text-bambu-gray">→</span>
  156. <select
  157. value={loaded?.globalTrayId ?? ''}
  158. onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}
  159. 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 ${
  160. status === 'match'
  161. ? 'border-bambu-green/50 text-bambu-green'
  162. : status === 'type_only'
  163. ? 'border-yellow-500 dark:border-yellow-400/50 text-yellow-700 dark:text-yellow-400'
  164. : 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
  165. } ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}
  166. title={isManual ? 'Manually selected' : 'Auto-matched'}
  167. >
  168. <option value="" className="bg-bambu-dark text-bambu-gray">
  169. -- Select slot --
  170. </option>
  171. {filterFilamentsByNozzle(printerResult.loadedFilaments, req.nozzle_id)
  172. .map((f) => (
  173. <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
  174. {f.label}: {f.traySubBrands || f.type} ({f.colorName})
  175. </option>
  176. ))}
  177. </select>
  178. {status === 'match' ? (
  179. <Check className="w-3 h-3 text-bambu-green" />
  180. ) : status === 'type_only' ? (
  181. <span title="Same type, different color">
  182. <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-400" />
  183. </span>
  184. ) : (
  185. <span title="Filament type not loaded">
  186. <AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400" />
  187. </span>
  188. )}
  189. </div>
  190. ))}
  191. </div>
  192. );
  193. }
  194. /**
  195. * Printer selection component with grid-based UI.
  196. * Supports single or multi-select modes.
  197. * When multiple printers are selected, shows per-printer mapping overrides.
  198. */
  199. export function PrinterSelector({
  200. printers,
  201. selectedPrinterIds,
  202. onMultiSelect,
  203. isLoading = false,
  204. allowMultiple = false,
  205. showInactive = false,
  206. disableBusy = false,
  207. printerMappingResults,
  208. filamentReqs,
  209. onAutoConfigurePrinter,
  210. onUpdatePrinterConfig,
  211. assignmentMode = 'printer',
  212. onAssignmentModeChange,
  213. targetModel,
  214. onTargetModelChange,
  215. targetLocation,
  216. onTargetLocationChange,
  217. slicedForModel,
  218. }: PrinterSelectorWithMappingProps) {
  219. // State for showing all printers vs only matching model
  220. const [showAllPrinters, setShowAllPrinters] = useState(false);
  221. // Filter printers based on showInactive flag
  222. const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
  223. // Fetch printer statuses to determine busy/idle state
  224. const statusQueries = useQueries({
  225. queries: activePrinters.map((printer) => ({
  226. queryKey: ['printerStatus', printer.id],
  227. queryFn: () => api.getPrinterStatus(printer.id),
  228. staleTime: 5000,
  229. })),
  230. });
  231. // Build a map of printer ID -> status for quick lookup
  232. const printerStatusMap = useMemo(() => {
  233. const map = new Map<number, PrinterStatus>();
  234. activePrinters.forEach((printer, idx) => {
  235. const query = statusQueries[idx];
  236. if (query?.data) {
  237. map.set(printer.id, query.data);
  238. }
  239. });
  240. return map;
  241. }, [activePrinters, statusQueries]);
  242. const isPrinterBusy = (printerId: number): boolean => {
  243. const status = printerStatusMap.get(printerId);
  244. if (!status) return false; // Unknown state — don't block
  245. if (!status.connected) return true;
  246. return !AVAILABLE_STATES.has(status.state ?? '');
  247. };
  248. const getPrinterStateLabel = (printerId: number): string | null => {
  249. const status = printerStatusMap.get(printerId);
  250. if (!status) return null;
  251. if (!status.connected) return 'Offline';
  252. const state = status.state;
  253. if (!state) return null;
  254. if (state === 'RUNNING') return status.stg_cur_name || 'Printing';
  255. if (state === 'PREPARE') return 'Preparing';
  256. if (state === 'PAUSE') return 'Paused';
  257. if (state === 'IDLE') return 'Idle';
  258. if (state === 'FINISH') return 'Finished';
  259. if (state === 'FAILED') return 'Failed';
  260. return state;
  261. };
  262. // Filter by sliced model (only in printer mode, when slicedForModel is set)
  263. const displayPrinters = useMemo(() => {
  264. if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
  265. return activePrinters;
  266. }
  267. // Filter to only show printers matching the sliced model
  268. const matching = activePrinters.filter((p) => p.model === slicedForModel);
  269. // If no matching printers, show all
  270. return matching.length > 0 ? matching : activePrinters;
  271. }, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
  272. // Check if there are hidden printers due to model filtering
  273. const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
  274. // Get unique models from available printers (for model-based assignment)
  275. const uniqueModels = useMemo(() => {
  276. const models = activePrinters
  277. .map(p => p.model)
  278. .filter((m): m is string => Boolean(m));
  279. return [...new Set(models)].sort();
  280. }, [activePrinters]);
  281. // Get unique locations for the selected target model (for location filtering)
  282. const uniqueLocations = useMemo(() => {
  283. if (!targetModel) return [];
  284. const locations = activePrinters
  285. .filter(p => p.model === targetModel && p.location)
  286. .map(p => p.location)
  287. .filter((l): l is string => Boolean(l));
  288. return [...new Set(locations)].sort();
  289. }, [activePrinters, targetModel]);
  290. // Check if model-based assignment is available (need callbacks and multiple printers of same model)
  291. const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
  292. const showMappingOptions = allowMultiple &&
  293. selectedPrinterIds.length > 1 &&
  294. printerMappingResults &&
  295. filamentReqs?.filaments &&
  296. filamentReqs.filaments.length > 0 &&
  297. onAutoConfigurePrinter &&
  298. onUpdatePrinterConfig;
  299. if (isLoading) {
  300. return (
  301. <div className="flex justify-center py-8">
  302. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  303. </div>
  304. );
  305. }
  306. if (displayPrinters.length === 0) {
  307. return (
  308. <div className="flex items-center gap-2 text-red-700 dark:text-red-400 text-sm mb-4">
  309. <AlertCircle className="w-4 h-4" />
  310. No {showInactive ? '' : 'active '}printers available
  311. </div>
  312. );
  313. }
  314. const handlePrinterClick = (printerId: number) => {
  315. if (disableBusy && isPrinterBusy(printerId)) return;
  316. if (allowMultiple) {
  317. if (selectedPrinterIds.includes(printerId)) {
  318. onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
  319. } else {
  320. onMultiSelect([...selectedPrinterIds, printerId]);
  321. }
  322. } else {
  323. onMultiSelect([printerId]);
  324. }
  325. };
  326. const handleSelectAll = () => {
  327. const selectable = disableBusy
  328. ? displayPrinters.filter((p) => !isPrinterBusy(p.id))
  329. : displayPrinters;
  330. onMultiSelect(selectable.map((p) => p.id));
  331. };
  332. const handleDeselectAll = () => {
  333. onMultiSelect([]);
  334. };
  335. const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {
  336. e.stopPropagation();
  337. if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;
  338. if (enabled) {
  339. onAutoConfigurePrinter(printerId);
  340. } else {
  341. onUpdatePrinterConfig(printerId, {
  342. useDefault: true,
  343. manualMappings: {},
  344. autoConfigured: false,
  345. });
  346. }
  347. };
  348. const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
  349. const selectedCount = selectedPrinterIds.length;
  350. const getPrinterMappingResult = (printerId: number) => {
  351. return printerMappingResults?.find((r) => r.printerId === printerId);
  352. };
  353. return (
  354. <div className="space-y-2 mb-6">
  355. {/* Assignment mode toggle (model vs specific printer) */}
  356. {modelAssignmentAvailable && (
  357. <div className="flex gap-2 mb-4">
  358. <button
  359. type="button"
  360. onClick={() => {
  361. onAssignmentModeChange!('printer');
  362. onTargetModelChange!(null);
  363. }}
  364. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  365. assignmentMode === 'printer'
  366. ? 'border-bambu-green bg-bambu-green/10 text-white'
  367. : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
  368. }`}
  369. >
  370. <PrinterIcon className="w-4 h-4" />
  371. <span className="text-sm">Specific Printer</span>
  372. </button>
  373. <button
  374. type="button"
  375. onClick={() => {
  376. onAssignmentModeChange!('model');
  377. onMultiSelect([]);
  378. // Pre-select the sliced-for model when it has printers. NEVER
  379. // silently fall back to another model (#2578): uniqueModels is
  380. // alphabetically sorted, so the old `uniqueModels[0]` default
  381. // quietly targeted e.g. H2D for an X1C-sliced file whenever the
  382. // metadata hadn't loaded yet. Leave it unset and let the user
  383. // pick from the dropdown instead.
  384. const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
  385. ? slicedForModel
  386. : null;
  387. onTargetModelChange!(defaultModel);
  388. }}
  389. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  390. assignmentMode === 'model'
  391. ? 'border-bambu-green bg-bambu-green/10 text-white'
  392. : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
  393. }`}
  394. >
  395. <Users className="w-4 h-4" />
  396. {/* In model mode the label must reflect the ACTUAL scheduler
  397. target, not the slice metadata — an edited queue item can
  398. target a different model than the file was sliced for (#2578). */}
  399. <span className="text-sm">
  400. Any {(assignmentMode === 'model' ? targetModel : null) || slicedForModel || 'Model'}
  401. </span>
  402. </button>
  403. </div>
  404. )}
  405. {/* Model selection and location filter (when in model mode) */}
  406. {assignmentMode === 'model' && modelAssignmentAvailable && (
  407. <div className="space-y-3 mb-4">
  408. {/* Model selector — always visible in model mode so a wrong target
  409. on an existing queue item can be seen and fixed (#2578).
  410. Incompatible models are disabled: G-code sliced for one model
  411. must only go to that model or its interchange family. */}
  412. <div>
  413. <label className="block text-xs text-bambu-gray mb-1">
  414. Target Model
  415. {slicedForModel && <span className="ml-2 text-bambu-gray/70">(sliced for {slicedForModel})</span>}
  416. </label>
  417. <select
  418. value={targetModel || ''}
  419. onChange={(e) => {
  420. onTargetModelChange!(e.target.value || null);
  421. // Clear location when model changes
  422. if (onTargetLocationChange) {
  423. onTargetLocationChange(null);
  424. }
  425. }}
  426. 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"
  427. >
  428. <option value="">Select a model...</option>
  429. {uniqueModels.map((model) => {
  430. const compatible = isGcodeCompatible(slicedForModel, model);
  431. return (
  432. <option key={model} value={model} disabled={!compatible}>
  433. {model}
  434. {!compatible ? ` — incompatible with ${slicedForModel} G-code` : ''}
  435. </option>
  436. );
  437. })}
  438. </select>
  439. </div>
  440. {/* Cross-model state on an existing row: same file/target mismatch
  441. the specific-printer path already warns about (#2578). */}
  442. {slicedForModel && targetModel && targetModel !== slicedForModel && (
  443. isGcodeCompatible(slicedForModel, targetModel) ? (
  444. <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">
  445. <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
  446. <span className="text-sm text-yellow-700 dark:text-yellow-400">
  447. File was sliced for {slicedForModel}, but will be dispatched to a {targetModel} printer
  448. </span>
  449. </div>
  450. ) : (
  451. <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">
  452. <AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
  453. <span className="text-sm text-red-700 dark:text-red-400">
  454. File was sliced for {slicedForModel} and cannot be dispatched to {targetModel} printers —
  455. select a compatible model
  456. </span>
  457. </div>
  458. )
  459. )}
  460. {/* Location filter (only show when target model is selected and locations exist) */}
  461. {targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
  462. <div>
  463. <label className="block text-xs text-bambu-gray mb-1">Location Filter (optional)</label>
  464. <select
  465. value={targetLocation || ''}
  466. onChange={(e) => onTargetLocationChange(e.target.value || null)}
  467. 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"
  468. >
  469. <option value="">Any location</option>
  470. {uniqueLocations.map((location) => (
  471. <option key={location} value={location}>
  472. {location}
  473. </option>
  474. ))}
  475. </select>
  476. </div>
  477. )}
  478. {/* Info text */}
  479. {targetModel && (
  480. <p className="text-xs text-bambu-gray">
  481. Scheduler will assign to first available idle {targetModel} printer
  482. {targetLocation ? ` in ${targetLocation}` : ''}
  483. </p>
  484. )}
  485. </div>
  486. )}
  487. {/* Multi-select header (only in printer mode) */}
  488. {assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
  489. <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
  490. <span>
  491. {selectedCount === 0
  492. ? 'Select printers'
  493. : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
  494. </span>
  495. <div className="flex gap-2">
  496. {selectedCount < displayPrinters.length && (
  497. <button
  498. type="button"
  499. onClick={handleSelectAll}
  500. className="text-bambu-green hover:text-bambu-green/80 transition-colors"
  501. >
  502. Select all
  503. </button>
  504. )}
  505. {selectedCount > 0 && (
  506. <button
  507. type="button"
  508. onClick={handleDeselectAll}
  509. className="text-bambu-gray hover:text-white transition-colors"
  510. >
  511. Clear
  512. </button>
  513. )}
  514. </div>
  515. </div>
  516. )}
  517. {/* Printer list (only in printer mode) */}
  518. {assignmentMode === 'printer' && displayPrinters.map((printer) => {
  519. const selected = isSelected(printer.id);
  520. const mappingResult = getPrinterMappingResult(printer.id);
  521. const hasOverride = mappingResult && !mappingResult.config.useDefault;
  522. const busy = isPrinterBusy(printer.id);
  523. const disabled = disableBusy && busy;
  524. const stateLabel = getPrinterStateLabel(printer.id);
  525. return (
  526. <div key={printer.id}>
  527. {/* Printer selection button */}
  528. <button
  529. type="button"
  530. onClick={() => handlePrinterClick(printer.id)}
  531. disabled={disabled}
  532. className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
  533. disabled
  534. ? 'border-bambu-dark-tertiary bg-bambu-dark opacity-50 cursor-not-allowed'
  535. : selected
  536. ? 'border-bambu-green bg-bambu-green/10'
  537. : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
  538. } ${!printer.is_active ? 'opacity-60' : ''}`}
  539. >
  540. <div
  541. className={`p-2 rounded-lg ${
  542. disabled ? 'bg-bambu-dark-tertiary' : selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
  543. }`}
  544. >
  545. <PrinterIcon
  546. className={`w-5 h-5 ${
  547. disabled ? 'text-bambu-gray/50' : selected ? 'text-bambu-green' : 'text-bambu-gray'
  548. }`}
  549. />
  550. </div>
  551. <div className="text-left flex-1">
  552. <p className={`font-medium ${disabled ? 'text-bambu-gray' : 'text-white'}`}>
  553. {printer.name}
  554. {!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
  555. </p>
  556. <p className="text-xs text-bambu-gray">
  557. {printer.model || 'Unknown model'} • {printer.ip_address}
  558. </p>
  559. </div>
  560. {stateLabel && (
  561. <span className={`text-xs px-2 py-0.5 rounded-full ${
  562. busy
  563. ? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400'
  564. : 'bg-bambu-green/20 text-bambu-green'
  565. }`}>
  566. {stateLabel}
  567. </span>
  568. )}
  569. {allowMultiple && (
  570. <div
  571. className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
  572. disabled
  573. ? 'border-bambu-gray/30'
  574. : selected
  575. ? 'bg-bambu-green border-bambu-green'
  576. : 'border-bambu-gray/50'
  577. }`}
  578. >
  579. {selected && <Check className="w-3 h-3 text-white" />}
  580. </div>
  581. )}
  582. </button>
  583. {/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}
  584. {selected && showMappingOptions && mappingResult && (
  585. <div className="ml-4 mt-2 mb-3">
  586. {/* Override checkbox row */}
  587. <div className="flex items-center gap-2">
  588. <label
  589. className="flex items-center gap-2 cursor-pointer"
  590. onClick={(e) => e.stopPropagation()}
  591. >
  592. <input
  593. type="checkbox"
  594. checked={hasOverride}
  595. onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}
  596. 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"
  597. />
  598. <span className="text-xs text-bambu-gray">Custom mapping</span>
  599. </label>
  600. {/* Match status indicator */}
  601. <span className={`text-xs ml-2 ${
  602. mappingResult.matchStatus === 'full'
  603. ? 'text-bambu-green'
  604. : mappingResult.matchStatus === 'partial'
  605. ? 'text-yellow-700 dark:text-yellow-400'
  606. : 'text-orange-700 dark:text-orange-400'
  607. }`}>
  608. ({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)
  609. </span>
  610. {/* Loading indicator */}
  611. {mappingResult.isLoading && (
  612. <RefreshCw className="w-3 h-3 text-bambu-gray animate-spin" />
  613. )}
  614. {/* Auto-configure button (when override is enabled) */}
  615. {hasOverride && (
  616. <button
  617. type="button"
  618. onClick={(e) => {
  619. e.stopPropagation();
  620. onAutoConfigurePrinter!(printer.id);
  621. }}
  622. 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"
  623. >
  624. <Wand2 className="w-3 h-3" />
  625. Auto
  626. </button>
  627. )}
  628. </div>
  629. {/* Inline mapping editor (shown when override is checked) */}
  630. {hasOverride && (
  631. <InlineMappingEditor
  632. printerResult={mappingResult}
  633. filamentReqs={filamentReqs!.filaments}
  634. onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}
  635. />
  636. )}
  637. </div>
  638. )}
  639. </div>
  640. );
  641. })}
  642. {/* Show hidden printers toggle */}
  643. {assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
  644. <button
  645. type="button"
  646. onClick={() => setShowAllPrinters(true)}
  647. className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
  648. >
  649. <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-400" />
  650. {hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —
  651. <span className="underline">show all</span>
  652. </button>
  653. )}
  654. {/* Show matching only toggle */}
  655. {assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
  656. <button
  657. type="button"
  658. onClick={() => setShowAllPrinters(false)}
  659. className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
  660. >
  661. <span className="underline">Show only {slicedForModel} printers</span>
  662. </button>
  663. )}
  664. {/* Warning when no printer selected (only in printer mode) */}
  665. {assignmentMode === 'printer' && selectedCount === 0 && (
  666. <p className="text-xs text-orange-700 dark:text-orange-400 mt-1 flex items-center gap-1">
  667. <AlertCircle className="w-3 h-3" />
  668. Select at least one printer
  669. </p>
  670. )}
  671. {/* Warning when no model selected (only in model mode) */}
  672. {assignmentMode === 'model' && !targetModel && (
  673. <p className="text-xs text-orange-700 dark:text-orange-400 mt-1 flex items-center gap-1">
  674. <AlertCircle className="w-3 h-3" />
  675. Select a target printer model
  676. </p>
  677. )}
  678. </div>
  679. );
  680. }