AssignSpoolModal.tsx 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. import { useEffect, useMemo, useRef, useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Loader2, Package, Search, QrCode, ArrowLeft } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { InventorySpool, SpoolAssignment } from '../api/client';
  7. import { Button } from './Button';
  8. import { MaterialMismatchConfirm } from './MaterialMismatchConfirm';
  9. import { QrSpoolScanner } from './QrSpoolScanner';
  10. import { useToast } from '../contexts/ToastContext';
  11. import { findMaterialMismatch, normalizeValue, stripProfileQualifier, type MaterialMismatch } from '../utils/assignMismatch';
  12. import { exactSpoolIdQuery, filterSpoolsByQuery } from '../utils/inventorySearch';
  13. import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
  14. import { parseSpoolQr, scannedSpoolId } from '../utils/qrAssignTarget';
  15. import { useUiSettings } from '../hooks/useUiSettings';
  16. interface AssignSpoolModalProps {
  17. isOpen: boolean;
  18. onClose: () => void;
  19. printerId: number;
  20. amsId: number;
  21. trayId: number;
  22. trayInfo?: {
  23. type: string;
  24. material?: string;
  25. profile?: string;
  26. color: string;
  27. location: string;
  28. /**
  29. * For a slot reporting no filament: getEmptySlotKind's answer. Only
  30. * 'physical' (the firmware says no spool) reads as an empty slot; anything
  31. * else may be a spool it can't identify (#2527).
  32. */
  33. emptyKind?: 'physical' | 'reset' | null;
  34. };
  35. spoolmanEnabled?: boolean;
  36. }
  37. function spoolSummary(spool: InventorySpool): string {
  38. return [spool.brand, spool.material, spool.subtype].filter(Boolean).join(' ');
  39. }
  40. export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo, spoolmanEnabled }: AssignSpoolModalProps) {
  41. const { t } = useTranslation();
  42. const queryClient = useQueryClient();
  43. const { showToast } = useToast();
  44. const [disableFiltering, setDisableFiltering] = useState(false);
  45. const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
  46. const [selectedSpoolmanSpoolId, setSelectedSpoolmanSpoolId] = useState<number | null>(null);
  47. useEffect(() => {
  48. setSelectedSpoolId(null);
  49. setSelectedSpoolmanSpoolId(null);
  50. }, [disableFiltering]);
  51. const [searchFilter, setSearchFilter] = useState('');
  52. // A pick waiting on the material-mismatch confirm, with what
  53. // findMaterialMismatch found. `move` for a scanned spool (#1574): the one in
  54. // the user's hand leaves any other slot it is in.
  55. const [mismatch, setMismatch] = useState<{ spoolId: number; move: boolean; details: MaterialMismatch } | null>(null);
  56. // Scanning a spool's QR instead of picking it (#1574), into this same slot.
  57. const [scanning, setScanning] = useState(false);
  58. const [scanError, setScanError] = useState<string | null>(null);
  59. // Synchronous in-flight guard: a mutation's isPending only updates next render.
  60. const scanInFlightRef = useRef(false);
  61. useEffect(() => {
  62. if (isOpen) {
  63. setDisableFiltering(false);
  64. setScanning(false);
  65. setScanError(null);
  66. scanInFlightRef.current = false;
  67. }
  68. }, [isOpen]);
  69. // Unique cache key — different consumers of `['inventory-spools']` call
  70. // `getSpools()` with different `includeArchived` arguments (InventoryPage:
  71. // true, SpoolBuddyDashboard / SpoolBuddyInventoryPage: false), but they
  72. // all share the same key. React Query treats them as one query and
  73. // serves whichever response landed first, so a SpoolBuddy component
  74. // priming the cache with the archived-excluded payload makes the picker
  75. // miss spools that *are* archived OR (more subtly) miss any spool that
  76. // wasn't yet present when SpoolBuddy ran its initial fetch. The picker
  77. // gets its own key + a fetch-everything call so this consumer is never
  78. // at the mercy of someone else's cache state. Archived spools are then
  79. // explicitly excluded client-side because the backend rejects archived
  80. // assignments with HTTP 400 anyway, so listing them would only let the
  81. // user click a button that fails.
  82. const { data: spools, isLoading, isFetching: spoolsFetching } = useQuery({
  83. queryKey: ['inventory-spools', 'assign-modal'],
  84. queryFn: () => api.getSpools(true),
  85. enabled: isOpen && !spoolmanEnabled,
  86. });
  87. const { data: assignments } = useQuery({
  88. queryKey: ['spool-assignments'],
  89. queryFn: () => api.getAssignments(),
  90. enabled: isOpen,
  91. });
  92. const { data: settings } = useUiSettings({ enabled: isOpen });
  93. const { data: spoolmanSpools, isLoading: spoolmanLoading } = useQuery({
  94. queryKey: ['spoolman-inventory-spools', 'assign-modal'],
  95. queryFn: () => api.getSpoolmanInventorySpools(false),
  96. enabled: isOpen && !!spoolmanEnabled,
  97. });
  98. // Spoolman SlotAssignments across all printers — used to filter out spools
  99. // already bound to another slot. Without this filter the modal offers spools
  100. // that are already in use elsewhere (e.g. an h2d-1 slot's spool appearing
  101. // in the x1c-2 assign list), and assigning would silently steal it from
  102. // the other printer's slot.
  103. const { data: allSpoolmanAssignments } = useQuery({
  104. // Under the per-slot key, so every slot invalidation refreshes it too.
  105. queryKey: ['spoolman-slot-assignments', 'all'],
  106. queryFn: () => api.getSpoolmanSlotAssignments(),
  107. enabled: isOpen && !!spoolmanEnabled,
  108. });
  109. // ids of spools already in some Spoolman slot — excluding the current slot
  110. // (so a user could in theory re-pick the same spool, though the modal is
  111. // typically only opened from empty slots).
  112. const assignedSpoolmanSpoolIds = useMemo(() => {
  113. if (!allSpoolmanAssignments) return new Set<number>();
  114. return new Set(
  115. allSpoolmanAssignments
  116. .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
  117. .map(a => a.spoolman_spool_id),
  118. );
  119. }, [allSpoolmanAssignments, printerId, amsId, trayId]);
  120. // #1414: nudge the printer to republish its state after we assign a
  121. // spool. The backend assign-spool path already issues an MQTT command,
  122. // but firmware (especially A1 mini external slots and any non-RFID
  123. // assignment) doesn't always echo the new tray state back on its own,
  124. // so the printer card sits on stale data and the user has to press
  125. // Force-refresh to see the assignment. Calling /refresh-status forces
  126. // a pushall the way the Force-refresh button does. Failures are
  127. // intentionally swallowed — the assignment itself succeeded; if the
  128. // refresh is offline the next poll / websocket update will catch up.
  129. const nudgePrinterRepublish = () => {
  130. api.refreshPrinterStatus(printerId).catch(() => {});
  131. queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
  132. };
  133. const assignMutation = useMutation({
  134. mutationFn: ({ spoolId, move }: { spoolId: number; move?: boolean }) =>
  135. api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId, ...(move && { move }) }),
  136. onSuccess: (newAssignment, { move }) => {
  137. // Immediately update cache so UI reflects the new assignment without waiting for refetch
  138. queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
  139. const filtered = (old || []).filter(a =>
  140. !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId) &&
  141. // A move took the spool out of its other slots as well.
  142. !(move && a.spool_id === newAssignment.spool_id)
  143. );
  144. filtered.push(newAssignment);
  145. return filtered;
  146. });
  147. queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  148. nudgePrinterRepublish();
  149. // When the AMS slot was empty at assign time (tray_state ∈ {9, 10}), the
  150. // backend persists the assignment but deliberately skips the MQTT
  151. // `ams_filament_setting` push because Bambu firmware drops it silently
  152. // for empty slots. `on_ams_change` re-fires the configuration once a
  153. // spool is detected in the slot (#1680). The success-but-pending case
  154. // gets a distinct toast so the user understands the slot hasn't been
  155. // configured on the printer yet — saying "AMS slot configured" reads
  156. // as a lie in that state. Mirror of `spoolbuddy/AssignToAmsModal.tsx`,
  157. // which has handled this since the SpoolBuddy assign flow shipped.
  158. const toastKey = newAssignment.pending_config
  159. ? 'inventory.assignPendingInsert'
  160. : 'inventory.assignSuccess';
  161. showToast(t(toastKey), 'success');
  162. setMismatch(null);
  163. onClose();
  164. },
  165. onError: (error: Error) => {
  166. showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
  167. scanInFlightRef.current = false; // a scan can try again
  168. },
  169. });
  170. const assignSpoolmanMutation = useMutation({
  171. mutationFn: ({ spoolmanSpoolId, move }: { spoolmanSpoolId: number; move?: boolean }) =>
  172. api.assignSpoolmanSlot({
  173. spoolman_spool_id: spoolmanSpoolId,
  174. printer_id: printerId,
  175. ams_id: amsId,
  176. tray_id: trayId,
  177. ...(move && { move }),
  178. }),
  179. onSuccess: (_data, { move }) => {
  180. queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
  181. // Prefix match: also the all-printers list this dialog filters
  182. // "assigned elsewhere" by.
  183. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  184. // A move clears the fallback tag linking the spool to its old slot.
  185. if (move) queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
  186. nudgePrinterRepublish();
  187. showToast(t('inventory.assignSuccess'), 'success');
  188. onClose();
  189. },
  190. onError: (error: Error) => {
  191. const message = `${t('inventory.assignFailed')}: ${error.message}`;
  192. // A scan says why in the scanner: a toast sits under this dialog's
  193. // backdrop, the SpoolBuddy kiosk shows none, and the scanner doesn't send
  194. // a label again while it stays in view. (A built-in assign's error shows
  195. // below the dialog's buttons.)
  196. if (scanInFlightRef.current) setScanError(message);
  197. else showToast(message, 'error');
  198. scanInFlightRef.current = false; // a scan can try again
  199. },
  200. });
  201. if (!isOpen) return null;
  202. // Filter out spools already assigned to other slots
  203. const assignedSpoolIds = new Set(
  204. (assignments || [])
  205. .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
  206. .map(a => a.spool_id)
  207. );
  208. // Show every spool that isn't already taken by another slot — including
  209. // RFID-tagged Bambu Lab spools (#1133). The earlier "manual spools only"
  210. // gate (tag_uid && tray_uuid both null) blocked the workflow where a
  211. // user has a Bambu Lab spool in inventory but doesn't want to scan it
  212. // via SpoolBuddy NFC every time and just wants to pick it from the list.
  213. // External slots (amsId 254/255) have always been allowed to pick from
  214. // any spool because the slot itself has no RFID reader; that
  215. // distinction collapses now that AMS slots also accept any spool.
  216. //
  217. // The "Show all spools" toggle (disableFiltering) bypasses BOTH this
  218. // gate and the material/profile filter below, making it a real escape
  219. // hatch for cases where MQTT has auto-reassigned a spool to another
  220. // slot a fraction of a second after a manual unassign — without this,
  221. // the toggle's label is a lie ("Show all" but actually filters by
  222. // assignment). The backend's assign_spool route is upsert-per-
  223. // (printer, ams, tray), so picking a spool that's currently taken by
  224. // a different slot creates a second assignment row; that's a foot-gun
  225. // for normal flows but exactly the recovery path the toggle is for.
  226. const availableSpools = spools?.filter((spool: InventorySpool) =>
  227. !spool.archived_at &&
  228. (disableFiltering || !assignedSpoolIds.has(spool.id))
  229. );
  230. // Filtering logic with toggle: search filter always applies, AMS tray profile filter is optional.
  231. // Show a spool if EITHER the slicer profile matches exactly OR the material overlaps with the
  232. // tray's material (partial-match both directions — "PLA" spool accepts a "PLA Basic" slot and
  233. // vice versa). Manually-added inventory spools typically have no slicer_filament_name; gating
  234. // on strict profile equality alone hid them even when the material matched (#1047).
  235. //
  236. // A "#123" search skips the tray match: typing a spool's number is choosing
  237. // that spool, and the material-mismatch warning still asks on assign
  238. // (#3301). Before, a spool of another material came up empty for its own
  239. // number, as if it had been deleted.
  240. const exactSpoolId = exactSpoolIdQuery(searchFilter);
  241. let filteredSpools = availableSpools;
  242. if (!disableFiltering && exactSpoolId === null) {
  243. const trayProfile = stripProfileQualifier(normalizeValue(trayInfo?.profile));
  244. const trayMaterial = normalizeValue(trayInfo?.material || trayInfo?.type);
  245. if (trayProfile || trayMaterial) {
  246. filteredSpools = filteredSpools?.filter((spool: InventorySpool) => {
  247. const spoolProfile = stripProfileQualifier(normalizeValue(spool.slicer_filament_name || spool.slicer_filament));
  248. const spoolMaterial = normalizeValue(spool.material);
  249. if (trayProfile && spoolProfile && spoolProfile === trayProfile) return true;
  250. if (trayMaterial && spoolMaterial) {
  251. return (
  252. spoolMaterial === trayMaterial ||
  253. trayMaterial.includes(spoolMaterial) ||
  254. spoolMaterial.includes(trayMaterial)
  255. );
  256. }
  257. // Neither side has filterable info on whatever dimension remains — show it.
  258. return !spoolProfile && !spoolMaterial;
  259. });
  260. }
  261. }
  262. if (searchFilter && filteredSpools) {
  263. filteredSpools = filterSpoolsByQuery(filteredSpools, searchFilter);
  264. }
  265. // Why a "#123" search found nothing, when it did -- the spool is not gone
  266. // just because it is not offered here.
  267. let exactSpoolMissReason: string | null = null;
  268. if (exactSpoolId !== null && spools && filteredSpools?.length === 0) {
  269. const wanted = spools.find((spool: InventorySpool) => spool.id === exactSpoolId);
  270. exactSpoolMissReason = !wanted
  271. ? t('inventory.spoolIdNotFound', { id: exactSpoolId })
  272. : wanted.archived_at
  273. ? t('inventory.spoolIdArchived', { id: exactSpoolId })
  274. : t('inventory.spoolIdInOtherSlot', { id: exactSpoolId });
  275. }
  276. // Assign a built-in spool, asking first when its material doesn't match the
  277. // slot's. Shared by the picker and the QR scan.
  278. const requestAssign = (spool: InventorySpool, move: boolean) => {
  279. const details = !settings?.disable_filament_warnings && trayInfo ? findMaterialMismatch(spool, trayInfo) : null;
  280. if (details) {
  281. setMismatch({ spoolId: spool.id, move, details });
  282. return;
  283. }
  284. assignMutation.mutate({ spoolId: spool.id, move });
  285. };
  286. const handleAssign = () => {
  287. if (selectedSpoolmanSpoolId !== null) {
  288. assignSpoolmanMutation.mutate({ spoolmanSpoolId: selectedSpoolmanSpoolId });
  289. return;
  290. }
  291. if (!selectedSpoolId) return;
  292. const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId);
  293. if (!selectedSpool) {
  294. showToast(t('inventory.assignFailed'), 'error');
  295. return;
  296. }
  297. requestAssign(selectedSpool, false);
  298. };
  299. const handleConfirmMismatch = () => {
  300. if (!mismatch) return;
  301. assignMutation.mutate({ spoolId: mismatch.spoolId, move: mismatch.move });
  302. setMismatch(null);
  303. };
  304. const cancelMismatch = () => {
  305. setMismatch(null);
  306. scanInFlightRef.current = false; // back to scanning, if that's where it came from
  307. };
  308. // A scanned label (#1574) goes to this slot — the one the dialog was opened
  309. // for, so there is no target to pick. The spool is the one in the user's
  310. // hand, so it moves here from any other slot; the picker's "assigned
  311. // elsewhere" filter doesn't apply. The dialog closes when the assign succeeds.
  312. const handleScan = (text: string) => {
  313. if (scanInFlightRef.current || assignMutation.isPending || assignSpoolmanMutation.isPending) return;
  314. const scanned = parseSpoolQr(text);
  315. if (!scanned) {
  316. setScanError(t('inventory.qrAssign.invalidQr'));
  317. return;
  318. }
  319. const id = scannedSpoolId(scanned, !!spoolmanEnabled);
  320. if (id === null) {
  321. setScanError(t('inventory.qrAssign.spoolmanLabel'));
  322. return;
  323. }
  324. if (spoolmanEnabled) {
  325. // Spoolman's spool list here leaves archived spools out, so the backend,
  326. // which looks the spool up itself, is the one to refuse a missing or
  327. // archived spool.
  328. setScanError(null);
  329. scanInFlightRef.current = true;
  330. assignSpoolmanMutation.mutate({ spoolmanSpoolId: id, move: true });
  331. return;
  332. }
  333. const spool = spools?.find((s: InventorySpool) => s.id === id);
  334. if (!spool) {
  335. setScanError(t('inventory.spoolIdNotFound', { id }));
  336. return;
  337. }
  338. if (spool.archived_at) {
  339. setScanError(t('inventory.spoolIdArchived', { id }));
  340. return;
  341. }
  342. setScanError(null);
  343. scanInFlightRef.current = true;
  344. requestAssign(spool, true);
  345. };
  346. const stopScanning = () => {
  347. setScanning(false);
  348. setScanError(null);
  349. scanInFlightRef.current = false;
  350. };
  351. const isAssigning = assignMutation.isPending || assignSpoolmanMutation.isPending;
  352. // What the slot holds now, for the context box: the spool assigned to it,
  353. // else what the printer reports there.
  354. const currentSpool: { id: number; spool?: InventorySpool } | null = (() => {
  355. if (spoolmanEnabled) {
  356. const row = allSpoolmanAssignments?.find(a => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId);
  357. return row ? { id: row.spoolman_spool_id, spool: spoolmanSpools?.find(s => s.id === row.spoolman_spool_id) } : null;
  358. }
  359. const row = assignments?.find(a => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId);
  360. return row ? { id: row.spool_id, spool: row.spool ?? spools?.find(s => s.id === row.spool_id) } : null;
  361. })();
  362. // No filament reported is an empty slot only when the firmware says so; a
  363. // spool it can't identify reports none too (#2527).
  364. const physicalLabel = trayInfo?.type || (trayInfo?.emptyKind === 'physical' ? t('ams.emptySlot') : t('ams.emptySlotReset'));
  365. return (
  366. <>
  367. <div className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center p-4 overflow-y-auto">
  368. <div
  369. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  370. onClick={onClose}
  371. />
  372. <div className="relative w-full max-w-2xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto">
  373. {/* Header */}
  374. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  375. <div className="flex items-center gap-2">
  376. {scanning ? <QrCode className="w-5 h-5 text-bambu-green" /> : <Package className="w-5 h-5 text-bambu-green" />}
  377. <h2 className="text-lg font-semibold text-white">
  378. {scanning ? t('inventory.qrAssign.scanTitle') : t('inventory.assignSpool')}
  379. </h2>
  380. </div>
  381. <button
  382. onClick={onClose}
  383. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  384. >
  385. <X className="w-5 h-5" />
  386. </button>
  387. </div>
  388. {/* Content */}
  389. <div className="p-4 space-y-4 overflow-y-auto">
  390. {/* Tray info: the slot being filled, what the printer reports in it,
  391. and the spool assigned to it now. */}
  392. {trayInfo && (
  393. <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  394. <p className="text-xs text-bambu-gray mb-1">{t('inventory.selectSpool')}:</p>
  395. <div className="flex items-center gap-2">
  396. {trayInfo.color && (
  397. <span
  398. className="w-4 h-4 rounded-full border border-black/20"
  399. style={getSwatchStyle(trayInfo.color)}
  400. />
  401. )}
  402. <span className="text-white font-medium">{physicalLabel}</span>
  403. <span className="text-bambu-gray">({trayInfo.location})</span>
  404. </div>
  405. {currentSpool && (
  406. <p className="mt-1 text-xs text-bambu-gray truncate">
  407. {t('inventory.assigned')}:{' '}
  408. <span className="text-white">
  409. #{currentSpool.id}
  410. {currentSpool.spool ? ` ${spoolSummary(currentSpool.spool)}` : ''}
  411. </span>
  412. </p>
  413. )}
  414. </div>
  415. )}
  416. {scanning ? (
  417. <QrSpoolScanner
  418. onDecode={handleScan}
  419. // A built-in scan is looked up in the spool list, so it waits
  420. // while the list loads or refreshes: a spool missing from an old
  421. // list would be turned down, and the scanner doesn't send a label
  422. // again while it stays in view.
  423. busy={isAssigning || mismatch !== null || spoolsFetching}
  424. error={scanError}
  425. />
  426. ) : (
  427. <>
  428. {/* Search filter, and scanning the spool's QR instead (#1574) */}
  429. <div className="flex flex-wrap gap-2">
  430. <div className="relative flex-1 min-w-[12rem]">
  431. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  432. <input
  433. type="text"
  434. value={searchFilter}
  435. onChange={(e) => setSearchFilter(e.target.value)}
  436. placeholder={t('inventory.searchSpools')}
  437. className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
  438. />
  439. </div>
  440. <Button
  441. variant="secondary"
  442. onClick={() => {
  443. setScanError(null);
  444. setScanning(true);
  445. }}
  446. >
  447. <QrCode className="w-4 h-4" />
  448. <span className="whitespace-nowrap">{t('inventory.qrAssign.scanTitle')}</span>
  449. </Button>
  450. </div>
  451. {/* Spool list */}
  452. <div className="space-y-3">
  453. {!spoolmanEnabled && (isLoading ? (
  454. <div className="flex justify-center py-8">
  455. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  456. </div>
  457. ) : filteredSpools && filteredSpools.length > 0 ? (
  458. <div className="max-h-96 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2">
  459. {filteredSpools.map((spool: InventorySpool) => (
  460. <button
  461. key={spool.id}
  462. onClick={() => { setSelectedSpoolId(spool.id); setSelectedSpoolmanSpoolId(null); }}
  463. title={spool.note || undefined}
  464. className={`p-2.5 rounded-lg border text-left transition-colors ${
  465. selectedSpoolId === spool.id
  466. ? 'bg-bambu-green/20 border-bambu-green'
  467. : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
  468. }`}
  469. >
  470. <div className="flex items-start justify-between gap-2">
  471. <p className="text-white text-sm font-medium truncate">
  472. {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
  473. </p>
  474. <span className="text-[10px] font-mono text-bambu-gray shrink-0">#{spool.id}</span>
  475. </div>
  476. <div className="flex items-center gap-1.5 mt-1">
  477. {spool.rgba && (
  478. <span
  479. className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
  480. style={getSwatchStyle(spool.rgba)}
  481. />
  482. )}
  483. <span className="text-xs text-bambu-gray truncate">
  484. {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
  485. </span>
  486. </div>
  487. {spool.label_weight && (
  488. <p className="text-xs text-bambu-gray mt-1">
  489. {Math.max(0, Math.round(spool.label_weight - spool.weight_used))} / {spool.label_weight}g
  490. </p>
  491. )}
  492. {spool.note && (
  493. <p className="text-[10px] text-bambu-gray/70 mt-1 truncate" title={spool.note}>
  494. {spool.note}
  495. </p>
  496. )}
  497. </button>
  498. ))}
  499. </div>
  500. ) : exactSpoolMissReason ? (
  501. <div className="text-center py-8 text-bambu-gray">
  502. <p>{exactSpoolMissReason}</p>
  503. </div>
  504. ) : availableSpools && availableSpools.length === 0 ? (
  505. <div className="text-center py-8 text-bambu-gray">
  506. <p>{t('inventory.noAvailableSpools')}</p>
  507. {/* Diagnostic counter — when the picker is empty, having
  508. the raw fetch / filter counts visible makes a
  509. "spool I expected to see is missing" report
  510. immediately answerable: if `total fetched` is 0 the
  511. backend / cache returned nothing; if it's > 0 then
  512. the archived / assigned-elsewhere filter ate the
  513. spool and the toggle is the right escape hatch. */}
  514. {spools && (
  515. <p className="text-[10px] mt-2 opacity-60">
  516. {spools.length} fetched · {spools.filter(s => s.archived_at).length} archived ·{' '}
  517. {spools.filter(s => assignedSpoolIds.has(s.id)).length} assigned to other slots
  518. </p>
  519. )}
  520. </div>
  521. ) : (
  522. <div className="text-center py-8 text-bambu-gray">
  523. <p>{t('inventory.noSpoolsMatch')}</p>
  524. {availableSpools && (
  525. <p className="text-[10px] mt-2 opacity-60">
  526. {availableSpools.length} unassigned spools — {(availableSpools.length) - (filteredSpools?.length ?? 0)} filtered by tray match. Try "Show all spools".
  527. </p>
  528. )}
  529. </div>
  530. ))}
  531. {spoolmanEnabled && (
  532. <>
  533. {spoolmanLoading ? (
  534. <div className="flex justify-center py-4">
  535. <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />
  536. </div>
  537. ) : spoolmanSpools && spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)).length > 0 ? (
  538. <>
  539. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide pt-1">
  540. {t('inventory.spoolmanSpools')}
  541. </p>
  542. <div className="max-h-64 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2">
  543. {filterSpoolsByQuery(spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)), searchFilter)
  544. .map((spool: InventorySpool) => (
  545. <button
  546. key={`spoolman-${spool.id}`}
  547. onClick={() => {
  548. setSelectedSpoolmanSpoolId(spool.id);
  549. setSelectedSpoolId(null);
  550. }}
  551. title={spool.note || undefined}
  552. className={`p-2.5 rounded-lg border text-left transition-colors ${
  553. selectedSpoolmanSpoolId === spool.id
  554. ? 'bg-bambu-green/20 border-bambu-green'
  555. : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
  556. }`}
  557. >
  558. <div className="flex items-start justify-between gap-2">
  559. <p className="text-white text-sm font-medium truncate">
  560. {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
  561. </p>
  562. <span className="text-[10px] font-mono text-bambu-gray shrink-0">#{spool.id}</span>
  563. </div>
  564. <div className="flex items-center gap-1.5 mt-1">
  565. {spool.rgba && (
  566. <span
  567. className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
  568. style={getSwatchStyle(spool.rgba)}
  569. />
  570. )}
  571. <span className="text-xs text-bambu-gray truncate">
  572. {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
  573. </span>
  574. </div>
  575. {spool.label_weight && (
  576. <p className="text-xs text-bambu-gray mt-1">
  577. {Math.max(0, Math.round(spool.label_weight - spool.weight_used))} / {spool.label_weight}g
  578. </p>
  579. )}
  580. {spool.note && (
  581. <p className="text-[10px] text-bambu-gray/70 mt-1 truncate" title={spool.note}>
  582. {spool.note}
  583. </p>
  584. )}
  585. </button>
  586. ))}
  587. </div>
  588. </>
  589. ) : null}
  590. </>
  591. )}
  592. </div>
  593. </>
  594. )}
  595. </div>
  596. {/* Footer: Back returns from the scan to the picker; the dialog itself
  597. closes once an assign succeeds. */}
  598. {scanning ? (
  599. <div className="flex justify-start p-4 border-t border-bambu-dark-tertiary">
  600. <Button variant="secondary" onClick={stopScanning} disabled={isAssigning}>
  601. <ArrowLeft className="w-4 h-4" />
  602. {t('inventory.qrAssign.back')}
  603. </Button>
  604. </div>
  605. ) : (
  606. /* Footer with filtering toggle */
  607. <div className="flex justify-between items-center p-4 border-t border-bambu-dark-tertiary">
  608. <div className="flex items-center gap-2">
  609. <input
  610. id="disable-filtering-toggle"
  611. type="checkbox"
  612. checked={disableFiltering}
  613. onChange={() => setDisableFiltering(v => !v)}
  614. className="accent-bambu-green w-4 h-4 rounded focus:ring-0 border-bambu-dark-tertiary"
  615. />
  616. <label htmlFor="disable-filtering-toggle" className="text-xs text-bambu-gray select-none cursor-pointer">
  617. {t('inventory.showAllSpools')}
  618. </label>
  619. </div>
  620. <div className="flex gap-2">
  621. <Button variant="secondary" onClick={onClose}>
  622. {t('common.cancel')}
  623. </Button>
  624. <Button
  625. onClick={handleAssign}
  626. disabled={(!selectedSpoolId && selectedSpoolmanSpoolId === null) || assignMutation.isPending || assignSpoolmanMutation.isPending}
  627. >
  628. {(assignMutation.isPending || assignSpoolmanMutation.isPending) ? (
  629. <>
  630. <Loader2 className="w-4 h-4 animate-spin" />
  631. {t('inventory.assigning')}
  632. </>
  633. ) : (
  634. <>
  635. <Package className="w-4 h-4" />
  636. {t('inventory.assignSpool')}
  637. </>
  638. )}
  639. </Button>
  640. </div>
  641. </div>
  642. )}
  643. {assignMutation.isError && (
  644. <div className="mx-4 mb-4 p-2 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded text-sm text-red-700 dark:text-red-400">
  645. {(assignMutation.error as Error).message}
  646. </div>
  647. )}
  648. </div>
  649. </div>
  650. {mismatch && trayInfo && (
  651. <MaterialMismatchConfirm
  652. mismatch={mismatch.details}
  653. location={trayInfo.location}
  654. isLoading={assignMutation.isPending}
  655. onConfirm={handleConfirmMismatch}
  656. onCancel={() => {
  657. if (!assignMutation.isPending) cancelMismatch();
  658. }}
  659. />
  660. )}
  661. </>
  662. );
  663. }