ReprintModal.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import { useState, useEffect, useMemo } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import { Card, CardContent } from './Card';
  6. import { Button } from './Button';
  7. import { getColorName } from '../utils/colors';
  8. interface ReprintModalProps {
  9. archiveId: number;
  10. archiveName: string;
  11. onClose: () => void;
  12. onSuccess: () => void;
  13. }
  14. // Print options with defaults
  15. interface PrintOptions {
  16. timelapse: boolean;
  17. bed_levelling: boolean;
  18. flow_cali: boolean;
  19. vibration_cali: boolean;
  20. layer_inspect: boolean;
  21. }
  22. const DEFAULT_PRINT_OPTIONS: PrintOptions = {
  23. bed_levelling: true,
  24. flow_cali: false,
  25. vibration_cali: true,
  26. layer_inspect: false,
  27. timelapse: false,
  28. };
  29. export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
  30. const queryClient = useQueryClient();
  31. const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
  32. const [isRefreshing, setIsRefreshing] = useState(false);
  33. const [showOptions, setShowOptions] = useState(false);
  34. const [printOptions, setPrintOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
  35. // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
  36. const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
  37. // Clear manual mappings when printer changes
  38. useEffect(() => {
  39. setManualMappings({});
  40. }, [selectedPrinter]);
  41. // Close on Escape key
  42. useEffect(() => {
  43. const handleKeyDown = (e: KeyboardEvent) => {
  44. if (e.key === 'Escape') onClose();
  45. };
  46. window.addEventListener('keydown', handleKeyDown);
  47. return () => window.removeEventListener('keydown', handleKeyDown);
  48. }, [onClose]);
  49. const { data: printers, isLoading: loadingPrinters } = useQuery({
  50. queryKey: ['printers'],
  51. queryFn: api.getPrinters,
  52. });
  53. // Fetch filament requirements from the archived 3MF
  54. const { data: filamentReqs } = useQuery({
  55. queryKey: ['archive-filaments', archiveId],
  56. queryFn: () => api.getArchiveFilamentRequirements(archiveId),
  57. });
  58. // Fetch printer status when a printer is selected
  59. const { data: printerStatus } = useQuery({
  60. queryKey: ['printer-status', selectedPrinter],
  61. queryFn: () => api.getPrinterStatus(selectedPrinter!),
  62. enabled: !!selectedPrinter,
  63. });
  64. const reprintMutation = useMutation({
  65. mutationFn: () => {
  66. if (!selectedPrinter) throw new Error('No printer selected');
  67. return api.reprintArchive(archiveId, selectedPrinter, {
  68. ams_mapping: amsMapping,
  69. ...printOptions,
  70. });
  71. },
  72. onSuccess: () => {
  73. onSuccess();
  74. onClose();
  75. },
  76. });
  77. const activePrinters = printers?.filter((p) => p.is_active) || [];
  78. // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
  79. const normalizeColor = (color: string | null | undefined): string => {
  80. if (!color) return '#808080';
  81. // Remove alpha channel if present (8-char hex to 6-char)
  82. const hex = color.replace('#', '').substring(0, 6);
  83. return `#${hex}`;
  84. };
  85. // Helper to format slot label for display
  86. const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
  87. if (isExternal) return 'External';
  88. const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId)); // A, B, C, D
  89. if (isHt) return `HT-${letter}`;
  90. return `AMS-${letter} Slot ${trayId + 1}`;
  91. };
  92. // Calculate global tray ID for MQTT command
  93. // Regular AMS: (ams_id * 4) + slot_id, External: 254
  94. const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
  95. if (isExternal) return 254;
  96. return amsId * 4 + trayId;
  97. };
  98. // Build a list of all loaded filaments from printer's AMS/HT/External with location info
  99. const loadedFilaments = useMemo(() => {
  100. const filaments: Array<{
  101. type: string;
  102. color: string;
  103. colorName: string;
  104. amsId: number;
  105. trayId: number;
  106. isHt: boolean;
  107. isExternal: boolean;
  108. label: string;
  109. globalTrayId: number;
  110. }> = [];
  111. // Add filaments from all AMS units (regular and HT)
  112. printerStatus?.ams?.forEach((amsUnit) => {
  113. const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
  114. amsUnit.tray.forEach((tray) => {
  115. if (tray.tray_type) {
  116. const color = normalizeColor(tray.tray_color);
  117. filaments.push({
  118. type: tray.tray_type,
  119. color,
  120. colorName: getColorName(color),
  121. amsId: amsUnit.id,
  122. trayId: tray.id,
  123. isHt,
  124. isExternal: false,
  125. label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
  126. globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
  127. });
  128. }
  129. });
  130. });
  131. // Add external spool if loaded
  132. if (printerStatus?.vt_tray?.tray_type) {
  133. const color = normalizeColor(printerStatus.vt_tray.tray_color);
  134. filaments.push({
  135. type: printerStatus.vt_tray.tray_type,
  136. color,
  137. colorName: getColorName(color),
  138. amsId: -1,
  139. trayId: 0,
  140. isHt: false,
  141. isExternal: true,
  142. label: 'External',
  143. globalTrayId: 254,
  144. });
  145. }
  146. return filaments;
  147. }, [printerStatus]);
  148. // Compare required filaments with loaded filaments
  149. // Match by filament TYPE (not slot), since the printer dynamically maps slots
  150. // Respects manual overrides when set
  151. const filamentComparison = useMemo(() => {
  152. if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
  153. // Helper to normalize color for comparison (case-insensitive, strip #)
  154. const normalizeColorForCompare = (color: string | undefined): string => {
  155. if (!color) return '';
  156. return color.replace('#', '').toLowerCase().substring(0, 6); // Strip alpha
  157. };
  158. // Helper to check if two colors are similar (within threshold)
  159. const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
  160. const hex1 = normalizeColorForCompare(color1);
  161. const hex2 = normalizeColorForCompare(color2);
  162. if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
  163. const r1 = parseInt(hex1.substring(0, 2), 16);
  164. const g1 = parseInt(hex1.substring(2, 4), 16);
  165. const b1 = parseInt(hex1.substring(4, 6), 16);
  166. const r2 = parseInt(hex2.substring(0, 2), 16);
  167. const g2 = parseInt(hex2.substring(2, 4), 16);
  168. const b2 = parseInt(hex2.substring(4, 6), 16);
  169. // Check if each RGB component is within threshold
  170. return Math.abs(r1 - r2) <= threshold &&
  171. Math.abs(g1 - g2) <= threshold &&
  172. Math.abs(b1 - b2) <= threshold;
  173. };
  174. // Track which trays have been assigned to avoid duplicates
  175. // First, mark all manually assigned trays as used
  176. const usedTrayIds = new Set<number>(Object.values(manualMappings));
  177. return filamentReqs.filaments.map((req) => {
  178. const slotId = req.slot_id || 0;
  179. // Check if there's a manual override for this slot
  180. if (slotId > 0 && manualMappings[slotId] !== undefined) {
  181. const manualTrayId = manualMappings[slotId];
  182. const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
  183. if (manualLoaded) {
  184. const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
  185. const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
  186. colorsAreSimilar(manualLoaded.color, req.color);
  187. let status: 'match' | 'type_only' | 'mismatch' | 'empty';
  188. if (typeMatch && colorMatch) {
  189. status = 'match';
  190. } else if (typeMatch) {
  191. status = 'type_only';
  192. } else {
  193. status = 'mismatch';
  194. }
  195. return {
  196. ...req,
  197. loaded: manualLoaded,
  198. hasFilament: true,
  199. typeMatch,
  200. colorMatch,
  201. status,
  202. isManual: true,
  203. };
  204. }
  205. }
  206. // Auto-match: Find a loaded filament that matches by TYPE
  207. // Priority: exact color match > similar color match > type-only match
  208. // IMPORTANT: Exclude trays that are already assigned (manually or auto)
  209. const exactMatch = loadedFilaments.find(
  210. (f) => !usedTrayIds.has(f.globalTrayId) &&
  211. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  212. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  213. );
  214. const similarMatch = !exactMatch && loadedFilaments.find(
  215. (f) => !usedTrayIds.has(f.globalTrayId) &&
  216. f.type?.toUpperCase() === req.type?.toUpperCase() &&
  217. colorsAreSimilar(f.color, req.color)
  218. );
  219. const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
  220. (f) => !usedTrayIds.has(f.globalTrayId) &&
  221. f.type?.toUpperCase() === req.type?.toUpperCase()
  222. );
  223. const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
  224. // Mark this tray as used so it won't be assigned to another slot
  225. if (loaded) {
  226. usedTrayIds.add(loaded.globalTrayId);
  227. }
  228. const hasFilament = !!loaded;
  229. const typeMatch = hasFilament;
  230. const colorMatch = !!exactMatch || !!similarMatch;
  231. // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
  232. let status: 'match' | 'type_only' | 'mismatch' | 'empty';
  233. if (exactMatch || similarMatch) {
  234. status = 'match';
  235. } else if (typeOnlyMatch) {
  236. status = 'type_only';
  237. } else {
  238. status = 'mismatch';
  239. }
  240. return {
  241. ...req,
  242. loaded,
  243. hasFilament,
  244. typeMatch,
  245. colorMatch,
  246. status,
  247. isManual: false,
  248. };
  249. });
  250. }, [filamentReqs, loadedFilaments, manualMappings]);
  251. // Build AMS mapping from auto-matched filaments
  252. // Format: array matching 3MF filament slot structure
  253. // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
  254. // e.g., slots 1 and 3 used with trays 5 and 2 → [5, -1, 2, -1]
  255. const amsMapping = useMemo(() => {
  256. if (filamentComparison.length === 0) return undefined;
  257. // Find the max slot_id to determine array size
  258. const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
  259. if (maxSlotId <= 0) return undefined;
  260. // Create array with -1 for all positions
  261. const mapping = new Array(maxSlotId).fill(-1);
  262. // Fill in tray IDs at correct positions (slot_id - 1)
  263. filamentComparison.forEach((f) => {
  264. if (f.slot_id && f.slot_id > 0) {
  265. mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
  266. }
  267. });
  268. return mapping;
  269. }, [filamentComparison]);
  270. const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
  271. return (
  272. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
  273. <Card className="w-full max-w-lg">
  274. <CardContent>
  275. {/* Header */}
  276. <div className="flex items-center justify-between mb-4">
  277. <h2 className="text-lg font-semibold text-white">Re-print</h2>
  278. <Button variant="ghost" size="sm" onClick={onClose}>
  279. <X className="w-5 h-5" />
  280. </Button>
  281. </div>
  282. <p className="text-sm text-bambu-gray mb-4">
  283. Send <span className="text-white">{archiveName}</span> to a printer
  284. </p>
  285. {/* Printer selection */}
  286. {loadingPrinters ? (
  287. <div className="flex justify-center py-8">
  288. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  289. </div>
  290. ) : activePrinters.length === 0 ? (
  291. <div className="text-center py-8 text-bambu-gray">
  292. No active printers available
  293. </div>
  294. ) : (
  295. <div className="space-y-2 mb-6">
  296. {activePrinters.map((printer) => (
  297. <button
  298. key={printer.id}
  299. onClick={() => setSelectedPrinter(printer.id)}
  300. className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
  301. selectedPrinter === printer.id
  302. ? 'border-bambu-green bg-bambu-green/10'
  303. : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
  304. }`}
  305. >
  306. <div
  307. className={`p-2 rounded-lg ${
  308. selectedPrinter === printer.id
  309. ? 'bg-bambu-green/20'
  310. : 'bg-bambu-dark-tertiary'
  311. }`}
  312. >
  313. <Printer
  314. className={`w-5 h-5 ${
  315. selectedPrinter === printer.id
  316. ? 'text-bambu-green'
  317. : 'text-bambu-gray'
  318. }`}
  319. />
  320. </div>
  321. <div className="text-left">
  322. <p className="text-white font-medium">{printer.name}</p>
  323. <p className="text-xs text-bambu-gray">
  324. {printer.model || 'Unknown model'} • {printer.ip_address}
  325. </p>
  326. </div>
  327. </button>
  328. ))}
  329. </div>
  330. )}
  331. {/* Filament comparison - show when printer selected and has filament requirements */}
  332. {selectedPrinter && filamentComparison.length > 0 && (
  333. <div className="mb-4">
  334. <div className="flex items-center gap-2 mb-2">
  335. <span className="text-sm text-bambu-gray">Filament Check</span>
  336. <button
  337. onClick={async () => {
  338. if (!selectedPrinter) return;
  339. setIsRefreshing(true);
  340. try {
  341. // Request fresh data from printer via MQTT pushall command
  342. await api.refreshPrinterStatus(selectedPrinter);
  343. // Wait a moment for printer to respond, then refetch
  344. await new Promise((r) => setTimeout(r, 500));
  345. await queryClient.refetchQueries({ queryKey: ['printer-status', selectedPrinter] });
  346. } finally {
  347. setIsRefreshing(false);
  348. }
  349. }}
  350. 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"
  351. title="Re-read AMS status from printer"
  352. disabled={isRefreshing}
  353. >
  354. <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
  355. <span>Re-read</span>
  356. </button>
  357. {hasTypeMismatch ? (
  358. <span className="text-xs text-orange-400 flex items-center gap-1">
  359. <AlertTriangle className="w-3 h-3" />
  360. Type not found
  361. </span>
  362. ) : filamentComparison.some((f) => f.status === 'type_only') ? (
  363. <span className="text-xs text-yellow-400 flex items-center gap-1">
  364. <AlertTriangle className="w-3 h-3" />
  365. Color mismatch
  366. </span>
  367. ) : (
  368. <span className="text-xs text-bambu-green flex items-center gap-1">
  369. <Check className="w-3 h-3" />
  370. Ready
  371. </span>
  372. )}
  373. </div>
  374. <div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
  375. {filamentComparison.map((item, idx) => (
  376. <div
  377. key={idx}
  378. className="grid items-center gap-2"
  379. style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
  380. >
  381. {/* Required color */}
  382. <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
  383. <Circle
  384. className="w-3 h-3 flex-shrink-0"
  385. fill={item.color}
  386. stroke={item.color}
  387. />
  388. </span>
  389. {/* Required type + grams */}
  390. <span className="text-white truncate">
  391. {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
  392. </span>
  393. {/* Arrow */}
  394. <span className="text-bambu-gray">→</span>
  395. {/* Slot selector dropdown */}
  396. <select
  397. value={item.loaded?.globalTrayId ?? ''}
  398. onChange={(e) => {
  399. const slotId = item.slot_id || 0;
  400. if (slotId > 0) {
  401. const value = e.target.value;
  402. if (value === '') {
  403. // Clear manual override
  404. setManualMappings((prev) => {
  405. const next = { ...prev };
  406. delete next[slotId];
  407. return next;
  408. });
  409. } else {
  410. setManualMappings((prev) => ({
  411. ...prev,
  412. [slotId]: parseInt(value, 10),
  413. }));
  414. }
  415. }
  416. }}
  417. 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 ${
  418. item.status === 'match'
  419. ? 'border-bambu-green/50 text-bambu-green'
  420. : item.status === 'type_only'
  421. ? 'border-yellow-400/50 text-yellow-400'
  422. : 'border-orange-400/50 text-orange-400'
  423. } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
  424. title={item.isManual ? 'Manually selected' : 'Auto-matched'}
  425. >
  426. <option value="" className="bg-bambu-dark text-bambu-gray">
  427. -- Select slot --
  428. </option>
  429. {loadedFilaments.map((f) => (
  430. <option
  431. key={f.globalTrayId}
  432. value={f.globalTrayId}
  433. className="bg-bambu-dark text-white"
  434. >
  435. {f.label}: {f.type} ({f.colorName})
  436. </option>
  437. ))}
  438. </select>
  439. {/* Status icon */}
  440. {item.status === 'match' ? (
  441. <Check className="w-3 h-3 text-bambu-green" />
  442. ) : item.status === 'type_only' ? (
  443. <span title="Same type, different color">
  444. <AlertTriangle className="w-3 h-3 text-yellow-400" />
  445. </span>
  446. ) : (
  447. <span title="Filament type not loaded">
  448. <AlertTriangle className="w-3 h-3 text-orange-400" />
  449. </span>
  450. )}
  451. </div>
  452. ))}
  453. </div>
  454. {hasTypeMismatch && (
  455. <p className="text-xs text-orange-400 mt-2">
  456. Required filament type not found in printer.
  457. </p>
  458. )}
  459. </div>
  460. )}
  461. {/* Print Options */}
  462. {selectedPrinter && (
  463. <div className="mb-4">
  464. <button
  465. onClick={() => setShowOptions(!showOptions)}
  466. className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
  467. >
  468. <Settings className="w-4 h-4" />
  469. <span>Print Options</span>
  470. {showOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
  471. </button>
  472. {showOptions && (
  473. <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
  474. {[
  475. { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
  476. { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
  477. { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
  478. { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
  479. { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
  480. ].map(({ key, label, desc }) => (
  481. <label key={key} className="flex items-center justify-between cursor-pointer group">
  482. <div>
  483. <span className="text-sm text-white">{label}</span>
  484. <p className="text-xs text-bambu-gray">{desc}</p>
  485. </div>
  486. <div
  487. className={`relative w-10 h-5 rounded-full transition-colors ${
  488. printOptions[key as keyof PrintOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  489. }`}
  490. onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof PrintOptions] }))}
  491. >
  492. <div
  493. className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
  494. printOptions[key as keyof PrintOptions] ? 'translate-x-5' : 'translate-x-0.5'
  495. }`}
  496. />
  497. </div>
  498. </label>
  499. ))}
  500. </div>
  501. )}
  502. </div>
  503. )}
  504. {/* Error message */}
  505. {reprintMutation.isError && (
  506. <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  507. {(reprintMutation.error as Error).message || 'Failed to start print'}
  508. </div>
  509. )}
  510. {/* Actions */}
  511. <div className="flex gap-3">
  512. <Button variant="secondary" onClick={onClose} className="flex-1">
  513. Cancel
  514. </Button>
  515. <Button
  516. onClick={() => reprintMutation.mutate()}
  517. disabled={!selectedPrinter || reprintMutation.isPending}
  518. className="flex-1"
  519. >
  520. {reprintMutation.isPending ? (
  521. <>
  522. <Loader2 className="w-4 h-4 animate-spin" />
  523. Sending...
  524. </>
  525. ) : (
  526. <>
  527. <Printer className="w-4 h-4" />
  528. Print
  529. </>
  530. )}
  531. </Button>
  532. </div>
  533. </CardContent>
  534. </Card>
  535. </div>
  536. );
  537. }