SpoolBuddyAmsPage.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
  2. import { useOutletContext } from 'react-router-dom';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
  6. import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
  7. import { api } from '../../api/client';
  8. import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
  9. import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool } from '../../utils/amsHelpers';
  10. import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
  11. import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
  12. import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
  13. import { AssignSpoolModal } from '../../components/AssignSpoolModal';
  14. import { LinkSpoolModal } from '../../components/LinkSpoolModal';
  15. import { useToast } from '../../contexts/ToastContext';
  16. function getAmsName(amsId: number): string {
  17. if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
  18. if (amsId >= 128 && amsId <= 135) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`;
  19. return `AMS ${amsId}`;
  20. }
  21. function mapModelCode(ssdpModel: string | null): string {
  22. if (!ssdpModel) return '';
  23. const modelMap: Record<string, string> = {
  24. 'O1D': 'H2D', 'O1E': 'H2D Pro', 'O2D': 'H2D Pro', 'O1C': 'H2C', 'O1C2': 'H2C', 'O1S': 'H2S',
  25. 'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E',
  26. 'N6': 'X2D',
  27. 'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S',
  28. 'N2S': 'A1', 'N1': 'A1 Mini',
  29. 'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'X2D': 'X2D', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S',
  30. 'A1': 'A1', 'A1 Mini': 'A1 Mini', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S',
  31. };
  32. return modelMap[ssdpModel] || ssdpModel;
  33. }
  34. function isTrayEmpty(tray: AMSTray): boolean {
  35. return !tray.tray_type || tray.tray_type === '';
  36. }
  37. function trayColorToCSS(color: string | null): string {
  38. if (!color) return '#808080';
  39. return `#${color.slice(0, 6)}`;
  40. }
  41. export function SpoolBuddyAmsPage() {
  42. const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
  43. const { t } = useTranslation();
  44. const queryClient = useQueryClient();
  45. const { showToast } = useToast();
  46. const { data: status } = useQuery<PrinterStatus>({
  47. queryKey: ['printerStatus', selectedPrinterId],
  48. queryFn: () => api.getPrinterStatus(selectedPrinterId!),
  49. enabled: selectedPrinterId !== null,
  50. staleTime: 30 * 1000,
  51. });
  52. const { data: printer } = useQuery({
  53. queryKey: ['printer', selectedPrinterId],
  54. queryFn: () => api.getPrinter(selectedPrinterId!),
  55. enabled: selectedPrinterId !== null,
  56. staleTime: 60 * 1000,
  57. });
  58. const { data: slotPresets } = useQuery({
  59. queryKey: ['slotPresets', selectedPrinterId],
  60. queryFn: () => api.getSlotPresets(selectedPrinterId!),
  61. enabled: selectedPrinterId !== null,
  62. staleTime: 2 * 60 * 1000,
  63. });
  64. const { data: settings } = useQuery({
  65. queryKey: ['settings'],
  66. queryFn: () => api.getSettings(),
  67. staleTime: 5 * 60 * 1000,
  68. });
  69. // Fetch Spoolman status to enable fill-level chain
  70. const { data: spoolmanStatus } = useQuery({
  71. queryKey: ['spoolman-status'],
  72. queryFn: api.getSpoolmanStatus,
  73. staleTime: 60 * 1000,
  74. });
  75. const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected;
  76. // Fetch linked spools map (tag -> spool info) for Spoolman fill levels
  77. const { data: linkedSpoolsData } = useQuery({
  78. queryKey: ['linked-spools'],
  79. queryFn: api.getLinkedSpools,
  80. enabled: !!spoolmanEnabled,
  81. staleTime: 30 * 1000,
  82. });
  83. const linkedSpools = linkedSpoolsData?.linked;
  84. // Fetch all Spoolman slot assignments + spool metadata so the fill-bar
  85. // and the Link/Unlink button reflect slot-assigned-only Spoolman spools
  86. // (spools assigned to a slot via AssignToAmsModal but not tag-linked).
  87. // Without these, AmsUnitCard shows an empty fill bar and the Link button
  88. // stays active even though the slot is occupied.
  89. const { data: spoolmanSlotAssignmentsAll = [] } = useQuery({
  90. queryKey: ['spoolman-slot-assignments-all'],
  91. queryFn: () => api.getSpoolmanSlotAssignments(),
  92. enabled: !!spoolmanEnabled,
  93. staleTime: 30 * 1000,
  94. });
  95. const { data: spoolmanInventorySpoolsCache = [] } = useQuery({
  96. queryKey: ['spoolman-inventory-spools'],
  97. queryFn: () => api.getSpoolmanInventorySpools(false),
  98. enabled: !!spoolmanEnabled,
  99. staleTime: 30 * 1000,
  100. });
  101. const { data: assignments } = useQuery({
  102. queryKey: ['spool-assignments', selectedPrinterId],
  103. queryFn: () => api.getAssignments(selectedPrinterId!),
  104. enabled: selectedPrinterId !== null,
  105. staleTime: 30 * 1000,
  106. });
  107. // Build fill-level override map from inventory assignments
  108. // Key: "amsId-trayId", Value: fill percentage (0-100)
  109. const fillOverrides = useMemo(() => {
  110. const map: Record<string, number> = {};
  111. if (!assignments) return map;
  112. for (const a of assignments) {
  113. const sp = a.spool;
  114. if (sp && sp.label_weight > 0 && sp.weight_used != null) {
  115. const fill = Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
  116. map[`${a.ams_id}-${a.tray_id}`] = fill;
  117. }
  118. }
  119. return map;
  120. }, [assignments]);
  121. // Look up Spoolman fill level for a given tray
  122. const printerSerial = printer?.serial_number ?? '';
  123. const getSpoolmanFillForSlot = useCallback((amsId: number, trayId: number, tray: AMSTray | null): number | null => {
  124. // Stage 1: slot-assigned Spoolman spool. The user's explicit, recent
  125. // action — must outrank the tag-link to avoid #1457, where a non-RFID
  126. // slot's deterministic fallback tag stayed bound to the previous spool
  127. // in Spoolman's extra.tag and the fill bar reported the old (stale)
  128. // spool's remaining weight instead of the freshly assigned one.
  129. if (selectedPrinterId !== null && spoolmanSlotAssignmentsAll.length && spoolmanInventorySpoolsCache.length) {
  130. const slotAssign = spoolmanSlotAssignmentsAll.find(a =>
  131. a.printer_id === selectedPrinterId &&
  132. a.ams_id === amsId &&
  133. a.tray_id === trayId,
  134. );
  135. if (slotAssign) {
  136. const spool = spoolmanInventorySpoolsCache.find(s => s.id === slotAssign.spoolman_spool_id);
  137. if (spool && (spool.label_weight ?? 0) > 0) {
  138. return Math.round(Math.max(0, spool.label_weight - spool.weight_used) / spool.label_weight * 100);
  139. }
  140. }
  141. }
  142. // Stage 2: tag-linked spool (linkedSpools map keyed by tag/UUID).
  143. if (linkedSpools && printerSerial) {
  144. const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
  145. const linkedSpool = tag ? linkedSpools[tag] : undefined;
  146. const tagFill = getSpoolmanFillLevel(linkedSpool);
  147. if (tagFill !== null) return tagFill;
  148. }
  149. return null;
  150. }, [linkedSpools, printerSerial, selectedPrinterId, spoolmanSlotAssignmentsAll, spoolmanInventorySpoolsCache]);
  151. const isConnected = status?.connected ?? false;
  152. // Cache AMS data per printer to prevent it disappearing on idle/offline printers
  153. const cachedAmsData = useRef<Record<number, PrinterStatus['ams']>>({});
  154. useEffect(() => {
  155. if (selectedPrinterId && status?.ams && status.ams.length > 0) {
  156. cachedAmsData.current[selectedPrinterId] = status.ams;
  157. }
  158. }, [status?.ams, selectedPrinterId]);
  159. const amsUnits = useMemo(() => {
  160. const live = status?.ams;
  161. if (live && live.length > 0) return live;
  162. return (selectedPrinterId ? cachedAmsData.current[selectedPrinterId] : null) ?? [];
  163. }, [status?.ams, selectedPrinterId]);
  164. const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
  165. const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
  166. // Build Spoolman fill-level override map for regular AMS cards
  167. const spoolmanFillOverrides = useMemo(() => {
  168. const map: Record<string, number> = {};
  169. if (!linkedSpools || !printerSerial) return map;
  170. for (const unit of regularAms) {
  171. for (let i = 0; i < (unit.tray?.length ?? 0); i++) {
  172. const tray = unit.tray![i];
  173. const fill = getSpoolmanFillForSlot(unit.id, i, isTrayEmpty(tray) ? null : tray);
  174. if (fill !== null) map[`${unit.id}-${i}`] = fill;
  175. }
  176. }
  177. return map;
  178. }, [linkedSpools, printerSerial, regularAms, getSpoolmanFillForSlot]);
  179. // Cache tray_now to prevent flickering when undefined values come in
  180. // Valid tray IDs: 0-253 for AMS, 254 for external spool
  181. // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
  182. const cachedTrayNow = useRef<number | undefined>(undefined);
  183. const currentTrayNow = status?.tray_now;
  184. if (currentTrayNow !== undefined && currentTrayNow !== 255) {
  185. cachedTrayNow.current = currentTrayNow;
  186. } else if (currentTrayNow === 255) {
  187. cachedTrayNow.current = undefined;
  188. }
  189. const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)
  190. ? currentTrayNow
  191. : cachedTrayNow.current;
  192. const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
  193. const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
  194. const amsThresholds: AmsThresholds | undefined = settings ? {
  195. humidityGood: Number(settings.ams_humidity_good) || 40,
  196. humidityFair: Number(settings.ams_humidity_fair) || 60,
  197. tempGood: Number(settings.ams_temp_good) || 28,
  198. tempFair: Number(settings.ams_temp_fair) || 35,
  199. } : undefined;
  200. // Cache ams_extruder_map to prevent L/R indicators bouncing on updates
  201. const cachedAmsExtruderMap = useRef<Record<string, number>>({});
  202. useEffect(() => {
  203. if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
  204. cachedAmsExtruderMap.current = status.ams_extruder_map;
  205. }
  206. }, [status?.ams_extruder_map]);
  207. const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
  208. ? status.ams_extruder_map
  209. : cachedAmsExtruderMap.current;
  210. const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
  211. if (!isDualNozzle) return null;
  212. const mappedExtruderId = amsExtruderMap[String(amsId)];
  213. const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
  214. const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
  215. // extruder 0 = right, 1 = left
  216. return extruderId === 1 ? 'L' : 'R';
  217. }, [isDualNozzle, amsExtruderMap]);
  218. const [configureSlotModal, setConfigureSlotModal] = useState<{
  219. amsId: number;
  220. trayId: number;
  221. trayCount: number;
  222. trayType?: string;
  223. trayColor?: string;
  224. traySubBrands?: string;
  225. trayInfoIdx?: string;
  226. extruderId?: number;
  227. caliIdx?: number | null;
  228. savedPresetId?: string;
  229. } | null>(null);
  230. // Slot action picker: shown before opening configure or assign modal
  231. const [slotActionPicker, setSlotActionPicker] = useState<{
  232. amsId: number;
  233. trayId: number;
  234. trayCount: number;
  235. tray: AMSTray | null;
  236. trayType?: string;
  237. trayColor?: string;
  238. traySubBrands?: string;
  239. trayInfoIdx?: string;
  240. extruderId?: number;
  241. caliIdx?: number | null;
  242. savedPresetId?: string;
  243. location: string;
  244. } | null>(null);
  245. // Assign spool modal state (inventory)
  246. const [assignSpoolModal, setAssignSpoolModal] = useState<{
  247. printerId: number;
  248. amsId: number;
  249. trayId: number;
  250. trayInfo: { type: string; material?: string; profile?: string; color: string; location: string };
  251. } | null>(null);
  252. // Link spool modal state (Spoolman)
  253. const [linkSpoolModal, setLinkSpoolModal] = useState<{
  254. tagUid: string;
  255. trayUuid: string;
  256. printerId: number;
  257. amsId: number;
  258. trayId: number;
  259. } | null>(null);
  260. const getAssignment = useCallback((amsId: number, trayId: number): SpoolAssignment | undefined => {
  261. return assignments?.find(a => a.ams_id === Number(amsId) && a.tray_id === Number(trayId));
  262. }, [assignments]);
  263. const getLinkedSpool = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
  264. if (!linkedSpools || !printerSerial) return undefined;
  265. const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
  266. return tag ? linkedSpools[tag] : undefined;
  267. }, [linkedSpools, printerSerial]);
  268. const unassignMutation = useMutation({
  269. mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>
  270. api.unassignSpool(printerId, amsId, trayId),
  271. onSuccess: () => {
  272. // Two cache-key shapes coexist for spool assignments: this page and a
  273. // few SpoolBuddy components key by printerId, while AssignSpoolModal
  274. // (and most of Bambuddy) keys without it. Both must be invalidated
  275. // here, otherwise after a SpoolBuddy unassign the modal opens with a
  276. // stale assignments list, sees the just-freed spool as still taken,
  277. // filters it out, and shows "no spools available" — even though it's
  278. // sitting in inventory ready to re-assign (#1133 follow-up).
  279. queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
  280. queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  281. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  282. setSlotActionPicker(null);
  283. },
  284. });
  285. const unlinkSpoolMutation = useMutation({
  286. mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
  287. onSuccess: (result) => {
  288. showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');
  289. queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
  290. queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
  291. // Backend (spoolman.py:986) also deletes the SpoolmanSlotAssignment row,
  292. // so invalidate every cache that depends on slot assignments. Without
  293. // these PrintersPage / InventoryPage / SpoolBuddyDashboard keep showing
  294. // the spool as still assigned for ~30s until refetchInterval kicks in.
  295. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  296. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
  297. queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  298. setSlotActionPicker(null);
  299. },
  300. onError: (error: Error) => {
  301. showToast(error.message || t('spoolman.unlinkFailed'), 'error');
  302. },
  303. });
  304. // Unassign a Spoolman spool from an AMS slot (slot-only assignment, no tag link).
  305. // Distinct from unlinkSpoolMutation, which also clears the tag binding via
  306. // /spoolman/spools/<id>/unlink. This one only deletes the SpoolmanSlotAssignment
  307. // row so the spool remains tag-linked (if it was) but is no longer bound to a slot.
  308. const unassignSpoolmanSlotMutation = useMutation({
  309. mutationFn: (spoolmanSpoolId: number) => api.unassignSpoolmanSlot(spoolmanSpoolId),
  310. onSuccess: () => {
  311. showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
  312. // Invalidate every query that filters or counts spools by slot-assignment
  313. // state, otherwise re-opening LinkSpoolModal right after an unassign
  314. // shows a stale list that still treats the freed spool as taken.
  315. // unlinkSpoolMutation does the same set — keep them aligned so both
  316. // unbind paths refresh the same caches.
  317. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  318. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
  319. queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  320. queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
  321. queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
  322. setSlotActionPicker(null);
  323. },
  324. onError: (error: Error) => {
  325. showToast(error.message || t('inventory.assignFailed', 'Failed to unassign spool'), 'error');
  326. },
  327. });
  328. const getActiveSlotForAms = useCallback((amsId: number): number | null => {
  329. if (effectiveTrayNow === undefined) return null;
  330. if (amsId <= 3) {
  331. const activeAmsId = Math.floor(effectiveTrayNow / 4);
  332. if (activeAmsId === amsId) return effectiveTrayNow % 4;
  333. }
  334. if (amsId >= 128 && amsId <= 135) {
  335. // AMS-HT: global tray ID equals the AMS unit ID itself (128, 129, ...)
  336. if (effectiveTrayNow === getGlobalTrayId(amsId, 0, false)) return 0;
  337. }
  338. return null;
  339. }, [effectiveTrayNow]);
  340. const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
  341. const globalTrayId = getGlobalTrayId(amsId, trayId, false);
  342. const slotPreset = slotPresets?.[globalTrayId];
  343. const mappedExtruderId = amsExtruderMap[String(amsId)];
  344. const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
  345. const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
  346. const slotData = {
  347. amsId,
  348. trayId,
  349. trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
  350. tray,
  351. trayType: tray?.tray_type || undefined,
  352. trayColor: tray?.tray_color || undefined,
  353. traySubBrands: tray?.tray_sub_brands || undefined,
  354. trayInfoIdx: tray?.tray_info_idx || undefined,
  355. extruderId: isDualNozzle ? extruderId : undefined,
  356. caliIdx: tray?.cali_idx,
  357. savedPresetId: slotPreset?.preset_id,
  358. location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
  359. };
  360. setSlotActionPicker(slotData);
  361. }, [slotPresets, amsExtruderMap, isDualNozzle]);
  362. const handleExtSlotClick = useCallback((extTray: AMSTray) => {
  363. const extTrayId = extTray.id ?? 254;
  364. const slotTrayId = extTrayId - 254;
  365. const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
  366. const slotData = {
  367. amsId: 255,
  368. trayId: slotTrayId,
  369. trayCount: 1,
  370. tray: isTrayEmpty(extTray) ? null : extTray,
  371. trayType: extTray.tray_type || undefined,
  372. trayColor: extTray.tray_color || undefined,
  373. traySubBrands: extTray.tray_sub_brands || undefined,
  374. trayInfoIdx: extTray.tray_info_idx || undefined,
  375. extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
  376. caliIdx: extTray.cali_idx,
  377. savedPresetId: extSlotPreset?.preset_id,
  378. location: isDualNozzle
  379. ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
  380. : 'External',
  381. };
  382. setSlotActionPicker(slotData);
  383. }, [slotPresets, isDualNozzle]);
  384. const openConfigureFromPicker = useCallback(() => {
  385. if (!slotActionPicker) return;
  386. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  387. const { tray, location, ...configData } = slotActionPicker;
  388. setSlotActionPicker(null);
  389. setConfigureSlotModal(configData);
  390. }, [slotActionPicker]);
  391. const openAssignFromPicker = useCallback(() => {
  392. if (!slotActionPicker || !selectedPrinterId) return;
  393. const { amsId, trayId, trayType, trayColor, location } = slotActionPicker;
  394. setSlotActionPicker(null);
  395. setAssignSpoolModal({
  396. printerId: selectedPrinterId,
  397. amsId,
  398. trayId,
  399. trayInfo: {
  400. type: trayType || '',
  401. material: trayType,
  402. color: trayColor?.slice(0, 6) || '',
  403. location,
  404. },
  405. });
  406. }, [slotActionPicker, selectedPrinterId]);
  407. const openLinkFromPicker = useCallback(() => {
  408. if (!slotActionPicker || !selectedPrinterId) return;
  409. const { amsId, trayId, tray } = slotActionPicker;
  410. const linkTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase() || '';
  411. setSlotActionPicker(null);
  412. setLinkSpoolModal({
  413. tagUid: tray?.tag_uid || linkTag,
  414. trayUuid: tray?.tray_uuid || '',
  415. printerId: selectedPrinterId,
  416. amsId,
  417. trayId,
  418. });
  419. }, [slotActionPicker, selectedPrinterId, printerSerial]);
  420. const handleUnassignFromPicker = useCallback(() => {
  421. if (!slotActionPicker || !selectedPrinterId) return;
  422. const { amsId, trayId } = slotActionPicker;
  423. unassignMutation.mutate({ printerId: selectedPrinterId, amsId, trayId });
  424. }, [slotActionPicker, selectedPrinterId, unassignMutation]);
  425. // Set alert for low filament in status bar
  426. useEffect(() => {
  427. if (!isConnected && selectedPrinterId) {
  428. setAlert({ type: 'warning', message: t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected') });
  429. return;
  430. }
  431. for (const unit of amsUnits) {
  432. const trays = unit.tray || [];
  433. for (let i = 0; i < trays.length; i++) {
  434. const tray = trays[i];
  435. if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) {
  436. const isExternal = unit.id === 254 || unit.id === 255;
  437. const isHt = !isExternal && unit.id >= 128;
  438. const slot = formatSlotLabel(unit.id, i, isHt, isExternal);
  439. setAlert({
  440. type: 'warning',
  441. message: `Low Filament: ${tray.tray_type} (${slot}) - ${tray.remain}% remaining`,
  442. });
  443. return;
  444. }
  445. }
  446. }
  447. setAlert(null);
  448. }, [amsUnits, isConnected, selectedPrinterId, setAlert, t]);
  449. // Build list of single-slot items (AMS-HT + External) for compact rendering
  450. const singleSlots = useMemo(() => {
  451. const items: {
  452. key: string; label: string; tray: AMSTray; isEmpty: boolean; isActive: boolean;
  453. temp?: number | null; humidity?: number | null; nozzleSide?: 'L' | 'R' | null;
  454. effectiveFill: number | null;
  455. onClick: () => void;
  456. }[] = [];
  457. for (const unit of htAms) {
  458. const tray = unit.tray?.[0] || {
  459. id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
  460. tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
  461. cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
  462. };
  463. // Fill level fallback chain: Spoolman → Inventory → AMS remain
  464. const spoolmanFill = getSpoolmanFillForSlot(unit.id, 0, isTrayEmpty(tray) ? null : tray);
  465. const invFill = fillOverrides[`${unit.id}-0`] ?? null;
  466. const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
  467. // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
  468. const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
  469. items.push({
  470. key: `ht-${unit.id}`,
  471. label: getAmsName(unit.id),
  472. tray,
  473. isEmpty: isTrayEmpty(tray),
  474. isActive: getActiveSlotForAms(unit.id) === 0,
  475. temp: unit.temp,
  476. humidity: unit.humidity,
  477. nozzleSide: getNozzleSide(unit.id),
  478. effectiveFill: spoolmanFill ?? resolvedInvFill ?? amsFill,
  479. onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray),
  480. });
  481. }
  482. for (const extTray of vtTrays) {
  483. const extTrayId = extTray.id ?? 254;
  484. // On dual-nozzle (H2C/H2D), tray_now=254 means "external spool"
  485. // generically — use active_extruder to determine L vs R:
  486. // extruder 1=left → Ext-L (id=254), extruder 0=right → Ext-R (id=255)
  487. const isExtActive = isDualNozzle && effectiveTrayNow === 254
  488. ? (extTrayId === 254 && status?.active_extruder === 1) ||
  489. (extTrayId === 255 && status?.active_extruder === 0)
  490. : effectiveTrayNow === extTrayId;
  491. const extSlotTrayId = extTrayId - 254;
  492. // Fill level fallback chain: Spoolman → Inventory → AMS remain
  493. const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray);
  494. const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
  495. const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
  496. // If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
  497. const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
  498. items.push({
  499. key: `ext-${extTrayId}`,
  500. label: isDualNozzle
  501. ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
  502. : t('printers.ext', 'Ext'),
  503. tray: extTray,
  504. isEmpty: isTrayEmpty(extTray),
  505. isActive: isExtActive,
  506. nozzleSide: null,
  507. effectiveFill: extSpoolmanFill ?? extResolvedInvFill ?? extAmsFill,
  508. onClick: () => handleExtSlotClick(extTray),
  509. });
  510. }
  511. return items;
  512. }, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot]);
  513. return (
  514. <div className="h-full flex flex-col p-4">
  515. <div className="flex-1 min-h-0">
  516. {!selectedPrinterId ? (
  517. <div className="flex-1 flex items-center justify-center h-full">
  518. <div className="text-center text-white/50">
  519. <p className="text-lg mb-2">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>
  520. <p className="text-sm">{t('spoolbuddy.ams.selectPrinter', 'Select a printer from the top bar')}</p>
  521. </div>
  522. </div>
  523. ) : !isConnected ? (
  524. <div className="flex-1 flex items-center justify-center h-full">
  525. <div className="text-center text-white/50">
  526. <p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
  527. </div>
  528. </div>
  529. ) : amsUnits.length === 0 && vtTrays.length === 0 ? (
  530. <div className="flex-1 flex items-center justify-center h-full">
  531. <div className="text-center text-white/50">
  532. <Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
  533. <p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
  534. <p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
  535. </div>
  536. </div>
  537. ) : (
  538. <div className="flex flex-col gap-3 h-full">
  539. {/* Regular AMS cards — 4-slot, 2-col grid */}
  540. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  541. {regularAms.map((unit) => (
  542. <AmsUnitCard
  543. key={unit.id}
  544. unit={unit}
  545. activeSlot={getActiveSlotForAms(unit.id)}
  546. onConfigureSlot={handleAmsSlotClick}
  547. isDualNozzle={isDualNozzle}
  548. nozzleSide={getNozzleSide(unit.id)}
  549. thresholds={amsThresholds}
  550. fillOverrides={fillOverrides}
  551. spoolmanFillOverrides={spoolmanFillOverrides}
  552. />
  553. ))}
  554. </div>
  555. {/* Third row: single-slot cards (AMS-HT + External) — half-width to align with AMS cards */}
  556. {singleSlots.length > 0 && (
  557. <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
  558. {singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, effectiveFill, onClick }) => {
  559. const color = trayColorToCSS(tray.tray_color);
  560. return (
  561. <div
  562. key={key}
  563. className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-3 ${isActive ? 'ring-2 ring-bambu-green' : ''}`}
  564. onClick={onClick}
  565. >
  566. {/* Spool */}
  567. <div className="relative w-10 h-10 flex-shrink-0">
  568. {isEmpty ? (
  569. <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
  570. <div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
  571. </div>
  572. ) : (
  573. <svg viewBox="0 0 56 56" className="w-full h-full">
  574. <circle cx="28" cy="28" r="26" fill={color} />
  575. <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
  576. <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
  577. <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
  578. <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
  579. </svg>
  580. )}
  581. {isActive && (
  582. <div className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-bambu-green rounded-full" />
  583. )}
  584. </div>
  585. {/* Info */}
  586. <div className="min-w-0">
  587. <div className="flex items-center gap-1">
  588. <span className="text-xs text-white/50 font-medium truncate">{label}</span>
  589. {nozzleSide && <NozzleBadge side={nozzleSide} />}
  590. </div>
  591. <div className="text-sm text-white/80 truncate">
  592. {isEmpty ? 'Empty' : tray.tray_type || '?'}
  593. </div>
  594. {(temp != null || humidity != null) && (
  595. <div className="flex items-center gap-1.5">
  596. {temp != null && (
  597. <TemperatureIndicator
  598. temp={temp}
  599. goodThreshold={amsThresholds?.tempGood}
  600. fairThreshold={amsThresholds?.tempFair}
  601. />
  602. )}
  603. {humidity != null && (
  604. <HumidityIndicator
  605. humidity={humidity}
  606. goodThreshold={amsThresholds?.humidityGood}
  607. fairThreshold={amsThresholds?.humidityFair}
  608. />
  609. )}
  610. </div>
  611. )}
  612. </div>
  613. {/* Fill bar */}
  614. {!isEmpty && effectiveFill != null && effectiveFill >= 0 && (
  615. <div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0 flex flex-col-reverse">
  616. <div
  617. className="w-full rounded-full"
  618. style={{
  619. height: `${effectiveFill}%`,
  620. backgroundColor: getFillBarColor(effectiveFill),
  621. }}
  622. />
  623. </div>
  624. )}
  625. </div>
  626. );
  627. })}
  628. </div>
  629. )}
  630. </div>
  631. )}
  632. </div>
  633. {configureSlotModal && selectedPrinterId && (
  634. <ConfigureAmsSlotModal
  635. isOpen={!!configureSlotModal}
  636. onClose={() => setConfigureSlotModal(null)}
  637. printerId={selectedPrinterId}
  638. slotInfo={configureSlotModal}
  639. printerModel={mapModelCode(printer?.model ?? null) || undefined}
  640. fullScreen
  641. onSuccess={() => {
  642. queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });
  643. queryClient.invalidateQueries({ queryKey: ['printerStatus', selectedPrinterId] });
  644. }}
  645. />
  646. )}
  647. {/* Slot action picker */}
  648. {slotActionPicker && selectedPrinterId && (() => {
  649. const assignment = getAssignment(slotActionPicker.amsId, slotActionPicker.trayId);
  650. const linked = getLinkedSpool(slotActionPicker.amsId, slotActionPicker.trayId, slotActionPicker.tray);
  651. // Slot-only Spoolman assignment (no tag link). Resolves the spool details
  652. // from spoolmanInventorySpoolsCache so we can show "Assigned spool: …"
  653. // and an Unassign button — without this branch, picking a slot that was
  654. // assigned via the dashboard's Assign-to-AMS flow showed only "Configure"
  655. // with no info about which spool was bound.
  656. const spoolmanAssign = spoolmanEnabled
  657. ? spoolmanSlotAssignmentsAll.find(a =>
  658. a.printer_id === selectedPrinterId &&
  659. a.ams_id === slotActionPicker.amsId &&
  660. a.tray_id === slotActionPicker.trayId,
  661. )
  662. : undefined;
  663. const spoolmanAssignedSpool = spoolmanAssign
  664. ? spoolmanInventorySpoolsCache.find(s => s.id === spoolmanAssign.spoolman_spool_id) ?? null
  665. : null;
  666. return (
  667. <div className="fixed inset-0 z-50 flex items-center justify-center">
  668. <div
  669. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  670. onClick={() => setSlotActionPicker(null)}
  671. />
  672. <div className="relative w-full max-w-sm mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
  673. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  674. <div className="flex items-center gap-2">
  675. {slotActionPicker.trayColor && (
  676. <span
  677. className="w-4 h-4 rounded-full border border-black/20"
  678. style={{ backgroundColor: `#${slotActionPicker.trayColor.slice(0, 6)}` }}
  679. />
  680. )}
  681. <h2 className="text-lg font-semibold text-white">{slotActionPicker.location}</h2>
  682. {slotActionPicker.traySubBrands && (
  683. <span className="text-sm text-bambu-gray">({slotActionPicker.traySubBrands})</span>
  684. )}
  685. </div>
  686. <button
  687. onClick={() => setSlotActionPicker(null)}
  688. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  689. >
  690. <X className="w-5 h-5" />
  691. </button>
  692. </div>
  693. <div className="p-4 space-y-2">
  694. {/* Currently assigned/linked spool info */}
  695. {!spoolmanEnabled && assignment?.spool && (
  696. <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
  697. <p className="text-xs text-bambu-gray mb-1">{t('inventory.assignedSpool', 'Assigned spool')}</p>
  698. <div className="flex items-center gap-2">
  699. {assignment.spool.rgba && (
  700. <span
  701. className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
  702. style={{ backgroundColor: `#${assignment.spool.rgba.substring(0, 6)}` }}
  703. />
  704. )}
  705. <span className="text-sm text-white">
  706. {assignment.spool.brand ? `${assignment.spool.brand} ` : ''}{assignment.spool.material}
  707. {assignment.spool.color_name ? ` - ${assignment.spool.color_name}` : ''}
  708. </span>
  709. <span className="text-[10px] font-mono text-zinc-500 shrink-0 ml-auto">#{assignment.spool.id}</span>
  710. </div>
  711. </div>
  712. )}
  713. {/* #1457: Assigned-spool block is rendered FIRST when a slot
  714. assignment exists, regardless of whether a (possibly stale)
  715. tag-link also exists. The tag-link block is the fallback
  716. for slots that have only a tag-link. */}
  717. {spoolmanEnabled && linked && !spoolmanAssignedSpool && (
  718. <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
  719. <p className="text-xs text-bambu-gray mb-1">{t('spoolman.linkedSpool', 'Linked spool')}</p>
  720. <div className="flex items-center gap-2">
  721. <span className="text-sm text-white">
  722. Spoolman #{linked.id}
  723. {linked.remaining_weight != null ? ` (${Math.round(linked.remaining_weight)}g)` : ''}
  724. </span>
  725. </div>
  726. </div>
  727. )}
  728. {spoolmanEnabled && spoolmanAssignedSpool && (
  729. <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
  730. <p className="text-xs text-bambu-gray mb-1">{t('inventory.assignedSpool', 'Assigned spool')}</p>
  731. <div className="flex items-center gap-2">
  732. {spoolmanAssignedSpool.rgba && (
  733. <span
  734. className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
  735. style={{ backgroundColor: `#${spoolmanAssignedSpool.rgba.substring(0, 6)}` }}
  736. />
  737. )}
  738. <span className="text-sm text-white">
  739. {spoolmanAssignedSpool.brand ? `${spoolmanAssignedSpool.brand} ` : ''}{spoolmanAssignedSpool.material}
  740. {spoolmanAssignedSpool.color_name ? ` - ${spoolmanAssignedSpool.color_name}` : ''}
  741. </span>
  742. </div>
  743. </div>
  744. )}
  745. <button
  746. onClick={openConfigureFromPicker}
  747. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-blue transition-colors text-left"
  748. >
  749. <Settings2 className="w-5 h-5 text-bambu-blue flex-shrink-0" />
  750. <div>
  751. <p className="text-white font-medium">{t('configureAmsSlot.title')}</p>
  752. <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.configureDesc', 'Set filament preset, K-profile, and color')}</p>
  753. </div>
  754. </button>
  755. {/* Inventory: Assign or Unassign in local mode.
  756. BL-RFID-detected slots are owned by the printer firmware —
  757. suppress assign/unassign there to keep parity with the
  758. Spoolman branch (Phase 14 A3). Manual changes would be
  759. overwritten on the next RFID re-read. */}
  760. {!spoolmanEnabled && (() => {
  761. if (isBambuLabSpool(slotActionPicker?.tray)) return null;
  762. if (assignment) {
  763. return (
  764. <button
  765. onClick={handleUnassignFromPicker}
  766. disabled={unassignMutation.isPending}
  767. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left"
  768. >
  769. <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
  770. <div>
  771. <p className="text-amber-400 font-medium">{t('inventory.unassignSpool', 'Unassign')}</p>
  772. <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unassignDesc', 'Remove inventory spool from this slot')}</p>
  773. </div>
  774. </button>
  775. );
  776. }
  777. return (
  778. <button
  779. onClick={openAssignFromPicker}
  780. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
  781. >
  782. <Package className="w-5 h-5 text-bambu-green flex-shrink-0" />
  783. <div>
  784. <p className="text-white font-medium">{t('inventory.assignSpool')}</p>
  785. <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.assignDesc', 'Track a spool from your inventory')}</p>
  786. </div>
  787. </button>
  788. );
  789. })()}
  790. {/* Spoolman: Link / Unlink (tag-linked) or Unassign (slot-only) */}
  791. {spoolmanEnabled && (() => {
  792. if (linked?.id) {
  793. return (
  794. <button
  795. onClick={() => unlinkSpoolMutation.mutate(linked.id)}
  796. disabled={unlinkSpoolMutation.isPending}
  797. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left active:opacity-50"
  798. >
  799. <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
  800. <div>
  801. <p className="text-amber-400 font-medium">{t('inventory.unassignSpool')}</p>
  802. <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unlinkDesc', 'Remove Spoolman link from this slot')}</p>
  803. </div>
  804. </button>
  805. );
  806. }
  807. // Slot-only assignment (no tag link): show Unassign so the
  808. // user can clear it. Previously this branch returned null
  809. // and only the Configure button remained, hiding the fact
  810. // that a spool was bound to the slot at all.
  811. if (spoolmanAssignedSpool) {
  812. return (
  813. <button
  814. onClick={() => unassignSpoolmanSlotMutation.mutate(spoolmanAssignedSpool.id)}
  815. disabled={unassignSpoolmanSlotMutation.isPending}
  816. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left active:opacity-50"
  817. >
  818. <Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
  819. <div>
  820. <p className="text-amber-400 font-medium">{t('inventory.unassignSpool')}</p>
  821. <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unassignDesc', 'Remove inventory spool from this slot')}</p>
  822. </div>
  823. </button>
  824. );
  825. }
  826. return (
  827. <button
  828. onClick={openLinkFromPicker}
  829. className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
  830. >
  831. <Link2 className="w-5 h-5 text-bambu-green flex-shrink-0" />
  832. <div>
  833. <p className="text-white font-medium">{t('inventory.assignSpool')}</p>
  834. <p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.linkDesc', 'Link a Spoolman spool to this slot')}</p>
  835. </div>
  836. </button>
  837. );
  838. })()}
  839. </div>
  840. </div>
  841. </div>
  842. );
  843. })()}
  844. {/* Assign spool modal (inventory) */}
  845. {assignSpoolModal && (
  846. <AssignSpoolModal
  847. isOpen={!!assignSpoolModal}
  848. onClose={() => {
  849. setAssignSpoolModal(null);
  850. // Same dual-key invalidation as the unassign path — the AMS
  851. // status panel reads the printerId-keyed query while the
  852. // shared AssignSpoolModal reads the unkeyed one.
  853. queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
  854. queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  855. }}
  856. printerId={assignSpoolModal.printerId}
  857. amsId={assignSpoolModal.amsId}
  858. trayId={assignSpoolModal.trayId}
  859. trayInfo={assignSpoolModal.trayInfo}
  860. spoolmanEnabled={!!spoolmanEnabled}
  861. />
  862. )}
  863. {/* Link spool modal (Spoolman) */}
  864. {linkSpoolModal && (
  865. <LinkSpoolModal
  866. isOpen={!!linkSpoolModal}
  867. onClose={() => setLinkSpoolModal(null)}
  868. tagUid={linkSpoolModal.tagUid}
  869. trayUuid={linkSpoolModal.trayUuid}
  870. printerId={linkSpoolModal.printerId}
  871. amsId={linkSpoolModal.amsId}
  872. trayId={linkSpoolModal.trayId}
  873. />
  874. )}
  875. </div>
  876. );
  877. }