ReprintModal.tsx 20 KB

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