SpoolBuddyAmsPage.tsx 44 KB

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