import { 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'; interface AssignSpoolModalProps { isOpen: boolean; onClose: () => void; printerId: number; amsId: number; trayId: number; trayInfo?: { type: string; material?: string; profile?: string; color: string; location: string; }; } export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo }: AssignSpoolModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [selectedSpoolId, setSelectedSpoolId] = useState(null); 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); const { data: spools, isLoading } = useQuery({ queryKey: ['inventory-spools'], queryFn: () => api.getSpools(), enabled: isOpen, }); const { data: assignments } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), enabled: isOpen, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings(), enabled: isOpen, }); 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'); }, }); // --- 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'; }; const checkProfileMatch = ( spoolProfile: string | undefined | null, trayProfile: string | undefined | null ): boolean => { const normalizedSpoolProfile = normalizeValue(spoolProfile); const normalizedTrayProfile = 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) ); // 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)) ); const filteredSpools = manualSpools?.filter((spool: InventorySpool) => { if (!searchFilter) return true; const q = searchFilter.toLowerCase(); return ( spool.material.toLowerCase().includes(q) || (spool.brand?.toLowerCase().includes(q) ?? false) || (spool.color_name?.toLowerCase().includes(q) ?? false) || (spool.subtype?.toLowerCase().includes(q) ?? false) ); }); const handleAssign = () => { 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 */}
{isLoading ? (
) : filteredSpools && filteredSpools.length > 0 ? (
{filteredSpools.map((spool: InventorySpool) => ( ))}
) : manualSpools && manualSpools.length === 0 ? (

{t('inventory.noManualSpools')}

) : (

{t('inventory.noSpoolsMatch')}

)}
{/* Footer */}
{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); } }} /> ); })()} ); }