fix(#1133): list every spool in the AMS-slot inventory picker

The picker that opens from <FilamentHoverCard> / SpoolBuddy's slot-action
  sheet had two stacked filters that together blocked a real workflow:

  1. AssignSpoolModal only listed spools whose tag_uid AND tray_uuid were
     both null, hiding any Bambu Lab spool that had been auto-created from
     RFID or scanned via SpoolBuddy NFC.
  2. FilamentHoverCard rendered its inventory section only when the slot's
     vendor was not 'Bambu Lab', so even with #1 fixed the assign button
     wasn't visible on a BL slot.

  Both filters blocked the same use case: a user has a Bambu Lab spool in
  inventory but doesn't want to scan via SpoolBuddy NFC every time and
  just wants to pick it from the list.

  Both gates removed. Modal lists every spool that isn't already taken by
  another (printer/ams_id/tray_id) tuple. Hover-card inventory section
  renders for every vendor including Bambu Lab. The AMS-vs-external
  special-case in the modal collapsed too — external slots used to be the
  only path that allowed picking a tagged spool, that distinction is gone.

  Empty slots lost their assign affordance entirely. A physically empty
  slot has no spool to attach an inventory record to, and offering the
  action there only led to users assigning the wrong spool to a slot the
  printer hadn't actually loaded yet. Bambuddy: EmptySlotHoverCard's
  inventory prop removed; PrintersPage drops the matching inventory
  props on three sites. SpoolBuddy: slot-action picker gates the
  assign/unassign block on slotActionPicker.tray !== null.

  Modal also gets defensive hardening from the rollout investigation:
  its own dedicated cache key (['inventory-spools', 'assign-modal']) so
  it can't be poisoned by other components calling getSpools() with
  different includeArchived args; getSpools(true) + client-side
  !archived_at filter so the picker sees the full inventory regardless
  of cache priming order; "Show all spools" toggle now bypasses BOTH
  filters (was only bypassing material/profile, label was a lie); a
  small "X fetched · Y archived · Z assigned" counter in the empty state
  so future "missing spool" reports are debuggable from screenshots.

  i18n.inventory.noManualSpools renamed to inventory.noAvailableSpools
  with new copy ("No spools available. Add a spool to your inventory or
  unassign one from another slot first.") since the empty-state premise
  changed. Localised across all 8 languages.

  15 frontend tests: assign/unassign render for vendor: 'Bambu Lab',
  non-BL vendors unchanged, EmptySlotHoverCard renders no assign
  affordance, configure button still works on empty slots, picker lists
  BL spools alongside manual ones, picker drops spools assigned
  elsewhere unless toggle is on, picker drops archived spools always,
  toggle escape-hatch shows everything, empty-state copy update.
  SpoolBuddy unassign now invalidates both ['spool-assignments'] and
  ['spool-assignments', printerId] so the modal's cache stays fresh
  (dual cache-key consolidation deferred to its own PR).
This commit is contained in:
maziggy
2026-04-26 11:45:18 +02:00
parent 096bdd92a8
commit 11fe68578e
14 changed files with 269 additions and 91 deletions
+57 -10
View File
@@ -49,9 +49,22 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
}
}, [isOpen]);
// Unique cache key — different consumers of `['inventory-spools']` call
// `getSpools()` with different `includeArchived` arguments (InventoryPage:
// true, SpoolBuddyDashboard / SpoolBuddyInventoryPage: false), but they
// all share the same key. React Query treats them as one query and
// serves whichever response landed first, so a SpoolBuddy component
// priming the cache with the archived-excluded payload makes the picker
// miss spools that *are* archived OR (more subtly) miss any spool that
// wasn't yet present when SpoolBuddy ran its initial fetch. The picker
// gets its own key + a fetch-everything call so this consumer is never
// at the mercy of someone else's cache state. Archived spools are then
// explicitly excluded client-side because the backend rejects archived
// assignments with HTTP 400 anyway, so listing them would only let the
// user click a button that fails.
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(),
queryKey: ['inventory-spools', 'assign-modal'],
queryFn: () => api.getSpools(true),
enabled: isOpen,
});
@@ -137,11 +150,27 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
.map(a => a.spool_id)
);
// External slots (amsId 254 or 255) have no RFID reader, so show all spools.
// AMS slots only show manual spools (no tag_uid or tray_uuid).
const isExternalSlot = amsId === 254 || amsId === 255;
const manualSpools = spools?.filter((spool: InventorySpool) =>
!assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid))
// Show every spool that isn't already taken by another slot — including
// RFID-tagged Bambu Lab spools (#1133). The earlier "manual spools only"
// gate (tag_uid && tray_uuid both null) blocked the workflow where a
// user has a Bambu Lab spool in inventory but doesn't want to scan it
// via SpoolBuddy NFC every time and just wants to pick it from the list.
// External slots (amsId 254/255) have always been allowed to pick from
// any spool because the slot itself has no RFID reader; that
// distinction collapses now that AMS slots also accept any spool.
//
// The "Show all spools" toggle (disableFiltering) bypasses BOTH this
// gate and the material/profile filter below, making it a real escape
// hatch for cases where MQTT has auto-reassigned a spool to another
// slot a fraction of a second after a manual unassign — without this,
// the toggle's label is a lie ("Show all" but actually filters by
// assignment). The backend's assign_spool route is upsert-per-
// (printer, ams, tray), so picking a spool that's currently taken by
// a different slot creates a second assignment row; that's a foot-gun
// for normal flows but exactly the recovery path the toggle is for.
const availableSpools = spools?.filter((spool: InventorySpool) =>
!spool.archived_at &&
(disableFiltering || !assignedSpoolIds.has(spool.id))
);
// Filtering logic with toggle: search filter always applies, AMS tray profile filter is optional.
@@ -149,7 +178,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
// tray's material (partial-match both directions — "PLA" spool accepts a "PLA Basic" slot and
// vice versa). Manually-added inventory spools typically have no slicer_filament_name; gating
// on strict profile equality alone hid them even when the material matched (#1047).
let filteredSpools = manualSpools;
let filteredSpools = availableSpools;
if (!disableFiltering) {
const trayProfile = stripProfileQualifier(normalizeValue(trayInfo?.profile));
const trayMaterial = normalizeValue(trayInfo?.material || trayInfo?.type);
@@ -326,13 +355,31 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
</button>
))}
</div>
) : manualSpools && manualSpools.length === 0 ? (
) : availableSpools && availableSpools.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
<p>{t('inventory.noManualSpools')}</p>
<p>{t('inventory.noAvailableSpools')}</p>
{/* Diagnostic counter — when the picker is empty, having
the raw fetch / filter counts visible makes a
"spool I expected to see is missing" report
immediately answerable: if `total fetched` is 0 the
backend / cache returned nothing; if it's > 0 then
the archived / assigned-elsewhere filter ate the
spool and the toggle is the right escape hatch. */}
{spools && (
<p className="text-[10px] mt-2 opacity-60">
{spools.length} fetched · {spools.filter(s => s.archived_at).length} archived ·{' '}
{spools.filter(s => assignedSpoolIds.has(s.id)).length} assigned to other slots
</p>
)}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>{t('inventory.noSpoolsMatch')}</p>
{availableSpools && (
<p className="text-[10px] mt-2 opacity-60">
{availableSpools.length} unassigned spools — {(availableSpools.length) - (filteredSpools?.length ?? 0)} filtered by tray match. Try "Show all spools".
</p>
)}
</div>
)}
</div>
+14 -20
View File
@@ -345,8 +345,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
</div>
)}
{/* Inventory section - only for non-Bambu spools */}
{inventory && data.vendor !== 'Bambu Lab' && (
{/* Inventory section — shown for every vendor including
Bambu Lab (#1133). The earlier "non-Bambu only" gate
prevented users from manually assigning a Bambu spool
in inventory to an AMS slot when they didn't want to
re-scan via SpoolBuddy NFC. */}
{inventory && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
{inventory.assignedSpool ? (
<>
@@ -468,13 +472,18 @@ interface EmptySlotHoverCardProps {
children: ReactNode;
className?: string;
configureSlot?: ConfigureSlotConfig;
inventory?: InventoryConfig;
}
/**
* Wrapper for empty slots - shows "Empty" on hover with optional configure button
* Wrapper for empty slots - shows "Empty" on hover with optional configure button.
*
* The "Assign spool" affordance was removed from empty slots in #1133: a
* physically empty slot has no spool to attach to, and offering the
* action there only led to users assigning the wrong spool to a slot
* the printer hadn't actually loaded yet. Assignment now requires a
* loaded slot (which renders FilamentHoverCard, where the button lives).
*/
export function EmptySlotHoverCard({ children, className = '', configureSlot, inventory }: EmptySlotHoverCardProps) {
export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -531,21 +540,6 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, in
</button>
</div>
)}
{/* Assign spool button - allows assigning inventory spool to empty slot */}
{inventory?.onAssignSpool && (
<div className="px-2 pb-2">
<button
onClick={(e) => {
e.stopPropagation();
inventory.onAssignSpool?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
>
<Package className="w-3.5 h-3.5" />
{t('inventory.assignSpool')}
</button>
</div>
)}
</div>
<div className="
absolute left-1/2 -translate-x-1/2 top-full w-0 h-0