import { useEffect, useMemo, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Loader2, Package, Search } from 'lucide-react'; import { api } from '../api/client'; import type { InventorySpool, SpoolAssignment } from '../api/client'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; import { filterSpoolsByQuery } from '../utils/inventorySearch'; interface AssignSpoolModalProps { isOpen: boolean; onClose: () => void; printerId: number; amsId: number; trayId: number; trayInfo?: { type: string; material?: string; profile?: string; color: string; location: string; }; spoolmanEnabled?: boolean; } export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo, spoolmanEnabled }: AssignSpoolModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [disableFiltering, setDisableFiltering] = useState(false); const [selectedSpoolId, setSelectedSpoolId] = useState(null); const [selectedSpoolmanSpoolId, setSelectedSpoolmanSpoolId] = useState(null); useEffect(() => { setSelectedSpoolId(null); setSelectedSpoolmanSpoolId(null); }, [disableFiltering]); const [searchFilter, setSearchFilter] = useState(''); const [pendingAssignId, setPendingAssignId] = useState(null); const [showMismatchConfirm, setShowMismatchConfirm] = useState(false); const [mismatchDetails, setMismatchDetails] = useState<{ type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile'; spoolMaterial: string; trayMaterial: string; spoolProfile?: string; trayProfile?: string; } | null>(null); useEffect(() => { if (isOpen) { setDisableFiltering(false); } }, [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', 'assign-modal'], queryFn: () => api.getSpools(true), enabled: isOpen && !spoolmanEnabled, }); const { data: assignments } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), enabled: isOpen, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings(), enabled: isOpen, }); const { data: spoolmanSpools, isLoading: spoolmanLoading } = useQuery({ queryKey: ['spoolman-inventory-spools', 'assign-modal'], queryFn: () => api.getSpoolmanInventorySpools(false), enabled: isOpen && !!spoolmanEnabled, }); // Spoolman SlotAssignments across all printers — used to filter out spools // already bound to another slot. Without this filter the modal offers spools // that are already in use elsewhere (e.g. an h2d-1 slot's spool appearing // in the x1c-2 assign list), and assigning would silently steal it from // the other printer's slot. const { data: allSpoolmanAssignments } = useQuery({ queryKey: ['spoolman-slot-assignments-all'], queryFn: () => api.getSpoolmanSlotAssignments(), enabled: isOpen && !!spoolmanEnabled, }); // ids of spools already in some Spoolman slot — excluding the current slot // (so a user could in theory re-pick the same spool, though the modal is // typically only opened from empty slots). const assignedSpoolmanSpoolIds = useMemo(() => { if (!allSpoolmanAssignments) return new Set(); return new Set( allSpoolmanAssignments .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)) .map(a => a.spoolman_spool_id), ); }, [allSpoolmanAssignments, printerId, amsId, trayId]); const assignMutation = useMutation({ mutationFn: (spoolId: number) => api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }), onSuccess: (newAssignment) => { // Immediately update cache so UI reflects the new assignment without waiting for refetch queryClient.setQueryData(['spool-assignments'], (old) => { const filtered = (old || []).filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId) ); filtered.push(newAssignment); return filtered; }); queryClient.invalidateQueries({ queryKey: ['spool-assignments'] }); showToast(t('inventory.assignSuccess'), 'success'); setShowMismatchConfirm(false); setPendingAssignId(null); setMismatchDetails(null); onClose(); }, onError: (error: Error) => { showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error'); }, }); const assignSpoolmanMutation = useMutation({ mutationFn: (spoolmanSpoolId: number) => api.assignSpoolmanSlot({ spoolman_spool_id: spoolmanSpoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] }); queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] }); showToast(t('inventory.assignSuccess'), 'success'); onClose(); }, onError: (error: Error) => { showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error'); }, }); // --- Material/profile mismatch logic --- const normalizeValue = (value: string | undefined | null) => (value ?? '').trim().toUpperCase(); const checkMaterialMatch = ( spoolMaterial: string | undefined | null, trayMaterial: string | undefined | null ): 'exact' | 'partial' | 'none' => { const normalizedSpool = normalizeValue(spoolMaterial); const normalizedTray = normalizeValue(trayMaterial); if (!normalizedSpool || !normalizedTray) return 'none'; if (normalizedSpool === normalizedTray) return 'exact'; if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) { return 'partial'; } return 'none'; }; // Bambu Studio / OrcaSlicer profile names carry a printer/nozzle/variant qualifier after // `@` (e.g. "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"), while the tray's // profile is typically the bare base name. Strip the qualifier before comparing so identical // base profiles don't trigger a mismatch warning (#1047). const stripProfileQualifier = (value: string) => value.split('@')[0].trim(); const checkProfileMatch = ( spoolProfile: string | undefined | null, trayProfile: string | undefined | null ): boolean => { const normalizedSpoolProfile = stripProfileQualifier(normalizeValue(spoolProfile)); const normalizedTrayProfile = stripProfileQualifier(normalizeValue(trayProfile)); if (!normalizedSpoolProfile || !normalizedTrayProfile) return false; return normalizedSpoolProfile === normalizedTrayProfile; }; if (!isOpen) return null; // Filter out spools already assigned to other slots const assignedSpoolIds = new Set( (assignments || []) .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)) .map(a => a.spool_id) ); // 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. // Show a spool if EITHER the slicer profile matches exactly OR the material overlaps with the // 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 = availableSpools; if (!disableFiltering) { const trayProfile = stripProfileQualifier(normalizeValue(trayInfo?.profile)); const trayMaterial = normalizeValue(trayInfo?.material || trayInfo?.type); if (trayProfile || trayMaterial) { filteredSpools = filteredSpools?.filter((spool: InventorySpool) => { const spoolProfile = stripProfileQualifier(normalizeValue(spool.slicer_filament_name || spool.slicer_filament)); const spoolMaterial = normalizeValue(spool.material); if (trayProfile && spoolProfile && spoolProfile === trayProfile) return true; if (trayMaterial && spoolMaterial) { return ( spoolMaterial === trayMaterial || trayMaterial.includes(spoolMaterial) || spoolMaterial.includes(trayMaterial) ); } // Neither side has filterable info on whatever dimension remains — show it. return !spoolProfile && !spoolMaterial; }); } } if (searchFilter && filteredSpools) { filteredSpools = filterSpoolsByQuery(filteredSpools, searchFilter); } const handleAssign = () => { if (selectedSpoolmanSpoolId !== null) { assignSpoolmanMutation.mutate(selectedSpoolmanSpoolId); return; } if (!selectedSpoolId) return; const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId); if (!selectedSpool) { showToast(t('inventory.assignFailed'), 'error'); return; } if (!settings?.disable_filament_warnings && trayInfo) { const trayMaterial = trayInfo.material || trayInfo.type; const materialMatchResult = checkMaterialMatch(selectedSpool.material, trayMaterial); const spoolProfile = selectedSpool.slicer_filament_name || selectedSpool.slicer_filament; const trayProfile = trayInfo.profile || trayInfo.type; const profileMatches = checkProfileMatch(spoolProfile, trayProfile); // Always evaluate both checks; if both fail, show a combined warning. if (materialMatchResult !== 'exact' || !profileMatches) { let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile'; if (materialMatchResult === 'none' && !profileMatches) { mismatchType = 'material_profile'; } else if (materialMatchResult === 'partial' && !profileMatches) { mismatchType = 'partial_profile'; } else if (materialMatchResult === 'none') { mismatchType = 'material'; } else if (materialMatchResult === 'partial') { mismatchType = 'partial'; } setPendingAssignId(selectedSpoolId); setMismatchDetails({ type: mismatchType, spoolMaterial: selectedSpool.material || '', trayMaterial: trayMaterial || '', spoolProfile: spoolProfile || undefined, trayProfile: trayProfile || undefined, }); setShowMismatchConfirm(true); return; } } assignMutation.mutate(selectedSpoolId); }; const handleConfirmMismatch = () => { if (!pendingAssignId) return; assignMutation.mutate(pendingAssignId); setShowMismatchConfirm(false); setPendingAssignId(null); }; return ( <>
{/* Header */}

{t('inventory.assignSpool')}

{/* Content */}
{/* Tray info */} {trayInfo && (

{t('inventory.selectSpool')}:

{trayInfo.color && ( )} {trayInfo.type || t('ams.emptySlot')} ({trayInfo.location})
)} {/* Search filter */}
setSearchFilter(e.target.value)} placeholder={t('inventory.searchSpools')} 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" />
{/* Spool list */}
{!spoolmanEnabled && (isLoading ? (
) : filteredSpools && filteredSpools.length > 0 ? (
{filteredSpools.map((spool: InventorySpool) => ( ))}
) : availableSpools && availableSpools.length === 0 ? (

{t('inventory.noAvailableSpools')}

{/* 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 && (

{spools.length} fetched · {spools.filter(s => s.archived_at).length} archived ·{' '} {spools.filter(s => assignedSpoolIds.has(s.id)).length} assigned to other slots

)}
) : (

{t('inventory.noSpoolsMatch')}

{availableSpools && (

{availableSpools.length} unassigned spools — {(availableSpools.length) - (filteredSpools?.length ?? 0)} filtered by tray match. Try "Show all spools".

)}
))} {spoolmanEnabled && ( <> {spoolmanLoading ? (
) : spoolmanSpools && spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)).length > 0 ? ( <>

{t('inventory.spoolmanSpools')}

{filterSpoolsByQuery(spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)), searchFilter) .map((spool: InventorySpool) => ( ))}
) : null} )}
{/* Footer with filtering toggle */}
setDisableFiltering(v => !v)} className="accent-bambu-green w-4 h-4 rounded focus:ring-0 border-bambu-dark-tertiary" />
{assignMutation.isError && (
{(assignMutation.error as Error).message}
)}
{showMismatchConfirm && trayInfo && selectedSpoolId && mismatchDetails && (() => { let message = ''; if (mismatchDetails.type === 'material') { message = t('inventory.assignMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: trayInfo.location, }); } else if (mismatchDetails.type === 'partial') { message = t('inventory.assignPartialMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: trayInfo.location, }); } else if (mismatchDetails.type === 'material_profile') { message = `${t('inventory.assignMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: trayInfo.location, })}\n\n${t('inventory.assignProfileMismatchMessage', { spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'), trayProfile: mismatchDetails.trayProfile || t('common.unknown'), location: trayInfo.location, })}`; } else if (mismatchDetails.type === 'partial_profile') { message = `${t('inventory.assignPartialMismatchMessage', { spoolMaterial: mismatchDetails.spoolMaterial, trayMaterial: mismatchDetails.trayMaterial, location: trayInfo.location, })}\n\n${t('inventory.assignProfileMismatchMessage', { spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'), trayProfile: mismatchDetails.trayProfile || t('common.unknown'), location: trayInfo.location, })}`; } else if (mismatchDetails.type === 'profile') { message = t('inventory.assignProfileMismatchMessage', { spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'), trayProfile: mismatchDetails.trayProfile || t('common.unknown'), location: trayInfo.location, }); } return ( { if (!assignMutation.isPending) { setShowMismatchConfirm(false); setPendingAssignId(null); setMismatchDetails(null); } }} /> ); })()} ); }