PrinterSelector.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. import { useState, useMemo } from 'react';
  2. import { useQueryClient } 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 } from '../../api/client';
  15. import { getColorName } from '../../utils/colors';
  16. import {
  17. normalizeColorForCompare,
  18. colorsAreSimilar,
  19. } from '../../utils/amsHelpers';
  20. import type { PrinterSelectorProps, AssignmentMode } from './types';
  21. import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
  22. import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
  23. interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
  24. /** Per-printer mapping results (only used when multiple printers selected) */
  25. printerMappingResults?: PrinterMappingResult[];
  26. /** Filament requirements for the print */
  27. filamentReqs?: { filaments: FilamentRequirement[] };
  28. /** Callback to auto-configure a printer */
  29. onAutoConfigurePrinter?: (printerId: number) => void;
  30. /** Callback to update printer config */
  31. onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
  32. /** Current assignment mode */
  33. assignmentMode?: AssignmentMode;
  34. /** Handler for assignment mode change */
  35. onAssignmentModeChange?: (mode: AssignmentMode) => void;
  36. /** Selected target model (when assignmentMode is 'model') */
  37. targetModel?: string | null;
  38. /** Handler for target model change */
  39. onTargetModelChange?: (model: string | null) => void;
  40. /** Suggested model from sliced file (for pre-selection) */
  41. slicedForModel?: string | null;
  42. }
  43. /**
  44. * Inline AMS mapping editor for a single printer.
  45. */
  46. function InlineMappingEditor({
  47. printerResult,
  48. filamentReqs,
  49. onUpdateConfig,
  50. }: {
  51. printerResult: PrinterMappingResult;
  52. filamentReqs: FilamentRequirement[];
  53. onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
  54. }) {
  55. const queryClient = useQueryClient();
  56. const [isRefreshing, setIsRefreshing] = useState(false);
  57. const handleSlotChange = (slotId: number, value: string) => {
  58. if (slotId <= 0) return;
  59. const newMappings = { ...printerResult.config.manualMappings };
  60. if (value === '') {
  61. delete newMappings[slotId];
  62. } else {
  63. newMappings[slotId] = parseInt(value, 10);
  64. }
  65. onUpdateConfig({
  66. useDefault: false,
  67. manualMappings: newMappings,
  68. autoConfigured: false,
  69. });
  70. };
  71. const handleRefresh = async () => {
  72. setIsRefreshing(true);
  73. try {
  74. await api.refreshPrinterStatus(printerResult.printerId);
  75. await new Promise((r) => setTimeout(r, 500));
  76. await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });
  77. } finally {
  78. setIsRefreshing(false);
  79. }
  80. };
  81. // Compute current slot assignments
  82. const slotAssignments = filamentReqs.map((req) => {
  83. const slotId = req.slot_id || 0;
  84. const currentMapping = printerResult.config.manualMappings[slotId];
  85. let loaded: LoadedFilament | undefined;
  86. let isManual = false;
  87. if (currentMapping !== undefined) {
  88. loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
  89. isManual = true;
  90. } else {
  91. // Auto-match logic
  92. const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
  93. const exactMatch = printerResult.loadedFilaments.find(
  94. (f) =>
  95. !usedTrayIds.has(f.globalTrayId) &&
  96. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  97. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  98. );
  99. const similarMatch = exactMatch
  100. ? undefined
  101. : printerResult.loadedFilaments.find(
  102. (f) =>
  103. !usedTrayIds.has(f.globalTrayId) &&
  104. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  105. colorsAreSimilar(f.color, req.color)
  106. );
  107. const typeOnlyMatch =
  108. exactMatch || similarMatch
  109. ? undefined
  110. : printerResult.loadedFilaments.find(
  111. (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
  112. );
  113. loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
  114. }
  115. // Determine status
  116. let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
  117. if (loaded) {
  118. const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
  119. const colorMatch =
  120. normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
  121. colorsAreSimilar(loaded.color, req.color);
  122. if (typeMatch && colorMatch) {
  123. status = 'match';
  124. } else if (typeMatch) {
  125. status = 'type_only';
  126. }
  127. }
  128. return { req, loaded, status, isManual };
  129. });
  130. return (
  131. <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
  132. <div className="flex items-center justify-between mb-2">
  133. <span className="text-xs text-bambu-gray">Custom slot mapping</span>
  134. <button
  135. type="button"
  136. onClick={handleRefresh}
  137. 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"
  138. disabled={isRefreshing}
  139. >
  140. <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
  141. <span>Re-read</span>
  142. </button>
  143. </div>
  144. {slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
  145. <div
  146. key={idx}
  147. className="grid items-center gap-2 text-xs"
  148. style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
  149. >
  150. <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
  151. <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
  152. </span>
  153. <span className="text-white truncate">
  154. {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
  155. </span>
  156. <span className="text-bambu-gray">→</span>
  157. <select
  158. value={loaded?.globalTrayId ?? ''}
  159. onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}
  160. 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 ${
  161. status === 'match'
  162. ? 'border-bambu-green/50 text-bambu-green'
  163. : status === 'type_only'
  164. ? 'border-yellow-400/50 text-yellow-400'
  165. : 'border-orange-400/50 text-orange-400'
  166. } ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}
  167. title={isManual ? 'Manually selected' : 'Auto-matched'}
  168. >
  169. <option value="" className="bg-bambu-dark text-bambu-gray">
  170. -- Select slot --
  171. </option>
  172. {printerResult.loadedFilaments.map((f) => (
  173. <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
  174. {f.label}: {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-400" />
  183. </span>
  184. ) : (
  185. <span title="Filament type not loaded">
  186. <AlertTriangle className="w-3 h-3 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. printerMappingResults,
  207. filamentReqs,
  208. onAutoConfigurePrinter,
  209. onUpdatePrinterConfig,
  210. assignmentMode = 'printer',
  211. onAssignmentModeChange,
  212. targetModel,
  213. onTargetModelChange,
  214. slicedForModel,
  215. }: PrinterSelectorWithMappingProps) {
  216. // State for showing all printers vs only matching model
  217. const [showAllPrinters, setShowAllPrinters] = useState(false);
  218. // Filter printers based on showInactive flag
  219. const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
  220. // Filter by sliced model (only in printer mode, when slicedForModel is set)
  221. const displayPrinters = useMemo(() => {
  222. if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
  223. return activePrinters;
  224. }
  225. // Filter to only show printers matching the sliced model
  226. const matching = activePrinters.filter((p) => p.model === slicedForModel);
  227. // If no matching printers, show all
  228. return matching.length > 0 ? matching : activePrinters;
  229. }, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
  230. // Check if there are hidden printers due to model filtering
  231. const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
  232. // Get unique models from available printers (for model-based assignment)
  233. const uniqueModels = useMemo(() => {
  234. const models = activePrinters
  235. .map(p => p.model)
  236. .filter((m): m is string => Boolean(m));
  237. return [...new Set(models)].sort();
  238. }, [activePrinters]);
  239. // Check if model-based assignment is available (need callbacks and multiple printers of same model)
  240. const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
  241. const showMappingOptions = allowMultiple &&
  242. selectedPrinterIds.length > 1 &&
  243. printerMappingResults &&
  244. filamentReqs?.filaments &&
  245. filamentReqs.filaments.length > 0 &&
  246. onAutoConfigurePrinter &&
  247. onUpdatePrinterConfig;
  248. if (isLoading) {
  249. return (
  250. <div className="flex justify-center py-8">
  251. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  252. </div>
  253. );
  254. }
  255. if (displayPrinters.length === 0) {
  256. return (
  257. <div className="flex items-center gap-2 text-red-400 text-sm mb-4">
  258. <AlertCircle className="w-4 h-4" />
  259. No {showInactive ? '' : 'active '}printers available
  260. </div>
  261. );
  262. }
  263. const handlePrinterClick = (printerId: number) => {
  264. if (allowMultiple) {
  265. if (selectedPrinterIds.includes(printerId)) {
  266. onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
  267. } else {
  268. onMultiSelect([...selectedPrinterIds, printerId]);
  269. }
  270. } else {
  271. onMultiSelect([printerId]);
  272. }
  273. };
  274. const handleSelectAll = () => {
  275. onMultiSelect(displayPrinters.map((p) => p.id));
  276. };
  277. const handleDeselectAll = () => {
  278. onMultiSelect([]);
  279. };
  280. const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {
  281. e.stopPropagation();
  282. if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;
  283. if (enabled) {
  284. onAutoConfigurePrinter(printerId);
  285. } else {
  286. onUpdatePrinterConfig(printerId, {
  287. useDefault: true,
  288. manualMappings: {},
  289. autoConfigured: false,
  290. });
  291. }
  292. };
  293. const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
  294. const selectedCount = selectedPrinterIds.length;
  295. const getPrinterMappingResult = (printerId: number) => {
  296. return printerMappingResults?.find((r) => r.printerId === printerId);
  297. };
  298. return (
  299. <div className="space-y-2 mb-6">
  300. {/* Assignment mode toggle (model vs specific printer) */}
  301. {modelAssignmentAvailable && (
  302. <div className="flex gap-2 mb-4">
  303. <button
  304. type="button"
  305. onClick={() => {
  306. onAssignmentModeChange!('printer');
  307. onTargetModelChange!(null);
  308. }}
  309. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  310. assignmentMode === 'printer'
  311. ? 'border-bambu-green bg-bambu-green/10 text-white'
  312. : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
  313. }`}
  314. >
  315. <PrinterIcon className="w-4 h-4" />
  316. <span className="text-sm">Specific Printer</span>
  317. </button>
  318. <button
  319. type="button"
  320. onClick={() => {
  321. onAssignmentModeChange!('model');
  322. onMultiSelect([]);
  323. // Pre-select the sliced-for model if available, otherwise first model
  324. const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
  325. ? slicedForModel
  326. : uniqueModels[0];
  327. onTargetModelChange!(defaultModel);
  328. }}
  329. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
  330. assignmentMode === 'model'
  331. ? 'border-bambu-green bg-bambu-green/10 text-white'
  332. : 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
  333. }`}
  334. >
  335. <Users className="w-4 h-4" />
  336. <span className="text-sm">Any {slicedForModel || 'Model'}</span>
  337. </button>
  338. </div>
  339. )}
  340. {/* Model info (when in model mode) */}
  341. {assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
  342. <p className="text-xs text-bambu-gray mb-4">
  343. Scheduler will assign to first available idle {targetModel} printer
  344. </p>
  345. )}
  346. {/* Multi-select header (only in printer mode) */}
  347. {assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
  348. <div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
  349. <span>
  350. {selectedCount === 0
  351. ? 'Select printers'
  352. : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
  353. </span>
  354. <div className="flex gap-2">
  355. {selectedCount < displayPrinters.length && (
  356. <button
  357. type="button"
  358. onClick={handleSelectAll}
  359. className="text-bambu-green hover:text-bambu-green/80 transition-colors"
  360. >
  361. Select all
  362. </button>
  363. )}
  364. {selectedCount > 0 && (
  365. <button
  366. type="button"
  367. onClick={handleDeselectAll}
  368. className="text-bambu-gray hover:text-white transition-colors"
  369. >
  370. Clear
  371. </button>
  372. )}
  373. </div>
  374. </div>
  375. )}
  376. {/* Printer list (only in printer mode) */}
  377. {assignmentMode === 'printer' && displayPrinters.map((printer) => {
  378. const selected = isSelected(printer.id);
  379. const mappingResult = getPrinterMappingResult(printer.id);
  380. const hasOverride = mappingResult && !mappingResult.config.useDefault;
  381. return (
  382. <div key={printer.id}>
  383. {/* Printer selection button */}
  384. <button
  385. type="button"
  386. onClick={() => handlePrinterClick(printer.id)}
  387. className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
  388. selected
  389. ? 'border-bambu-green bg-bambu-green/10'
  390. : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
  391. } ${!printer.is_active ? 'opacity-60' : ''}`}
  392. >
  393. <div
  394. className={`p-2 rounded-lg ${
  395. selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
  396. }`}
  397. >
  398. <PrinterIcon
  399. className={`w-5 h-5 ${
  400. selected ? 'text-bambu-green' : 'text-bambu-gray'
  401. }`}
  402. />
  403. </div>
  404. <div className="text-left flex-1">
  405. <p className="text-white font-medium">
  406. {printer.name}
  407. {!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
  408. </p>
  409. <p className="text-xs text-bambu-gray">
  410. {printer.model || 'Unknown model'} • {printer.ip_address}
  411. </p>
  412. </div>
  413. {allowMultiple && (
  414. <div
  415. className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
  416. selected
  417. ? 'bg-bambu-green border-bambu-green'
  418. : 'border-bambu-gray/50'
  419. }`}
  420. >
  421. {selected && <Check className="w-3 h-3 text-white" />}
  422. </div>
  423. )}
  424. </button>
  425. {/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}
  426. {selected && showMappingOptions && mappingResult && (
  427. <div className="ml-4 mt-2 mb-3">
  428. {/* Override checkbox row */}
  429. <div className="flex items-center gap-2">
  430. <label
  431. className="flex items-center gap-2 cursor-pointer"
  432. onClick={(e) => e.stopPropagation()}
  433. >
  434. <input
  435. type="checkbox"
  436. checked={hasOverride}
  437. onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}
  438. 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"
  439. />
  440. <span className="text-xs text-bambu-gray">Custom mapping</span>
  441. </label>
  442. {/* Match status indicator */}
  443. <span className={`text-xs ml-2 ${
  444. mappingResult.matchStatus === 'full'
  445. ? 'text-bambu-green'
  446. : mappingResult.matchStatus === 'partial'
  447. ? 'text-yellow-400'
  448. : 'text-orange-400'
  449. }`}>
  450. ({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)
  451. </span>
  452. {/* Loading indicator */}
  453. {mappingResult.isLoading && (
  454. <RefreshCw className="w-3 h-3 text-bambu-gray animate-spin" />
  455. )}
  456. {/* Auto-configure button (when override is enabled) */}
  457. {hasOverride && (
  458. <button
  459. type="button"
  460. onClick={(e) => {
  461. e.stopPropagation();
  462. onAutoConfigurePrinter!(printer.id);
  463. }}
  464. 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"
  465. >
  466. <Wand2 className="w-3 h-3" />
  467. Auto
  468. </button>
  469. )}
  470. </div>
  471. {/* Inline mapping editor (shown when override is checked) */}
  472. {hasOverride && (
  473. <InlineMappingEditor
  474. printerResult={mappingResult}
  475. filamentReqs={filamentReqs!.filaments}
  476. onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}
  477. />
  478. )}
  479. </div>
  480. )}
  481. </div>
  482. );
  483. })}
  484. {/* Show hidden printers toggle */}
  485. {assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
  486. <button
  487. type="button"
  488. onClick={() => setShowAllPrinters(true)}
  489. className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
  490. >
  491. <AlertTriangle className="w-3 h-3 text-yellow-400" />
  492. {hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —
  493. <span className="underline">show all</span>
  494. </button>
  495. )}
  496. {/* Show matching only toggle */}
  497. {assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
  498. <button
  499. type="button"
  500. onClick={() => setShowAllPrinters(false)}
  501. className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
  502. >
  503. <span className="underline">Show only {slicedForModel} printers</span>
  504. </button>
  505. )}
  506. {/* Warning when no printer selected (only in printer mode) */}
  507. {assignmentMode === 'printer' && selectedCount === 0 && (
  508. <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
  509. <AlertCircle className="w-3 h-3" />
  510. Select at least one printer
  511. </p>
  512. )}
  513. {/* Warning when no model selected (only in model mode) */}
  514. {assignmentMode === 'model' && !targetModel && (
  515. <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
  516. <AlertCircle className="w-3 h-3" />
  517. Select a target printer model
  518. </p>
  519. )}
  520. </div>
  521. );
  522. }