import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Search, X, Package } from 'lucide-react'; import { api } from '../../api/client'; import type { InventorySpool, SpoolAssignment } from '../../api/client'; import { resolveSpoolColorName } from '../../utils/colors'; import { formatSlotLabel } from '../../utils/amsHelpers'; type FilterMode = 'all' | 'in_ams' | string; // string = material name function spoolColor(spool: InventorySpool): string { if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`; return '#808080'; } function spoolRemaining(spool: InventorySpool): number { return Math.max(0, spool.label_weight - spool.weight_used); } function spoolPct(spool: InventorySpool): number { if (spool.label_weight <= 0) return 0; return Math.max(0, Math.min(100, ((spool.label_weight - spool.weight_used) / spool.label_weight) * 100)); } function spoolDisplayName(spool: InventorySpool): string { const parts = [spool.material]; if (spool.subtype) parts.push(spool.subtype); return parts.join(' '); } function assignmentLabel(a: SpoolAssignment): string { const isExternal = a.ams_id === 254 || a.ams_id === 255; const isHt = !isExternal && a.ams_id >= 128; return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal); } /* Spool circle — same style as AMS page tray slots */ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) { return ( ); } export function SpoolBuddyInventoryPage() { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); const [filterMode, setFilterMode] = useState('all'); const [selectedSpoolId, setSelectedSpoolId] = useState(null); const { data: spoolmanSettings } = useQuery({ queryKey: ['spoolman-settings'], queryFn: api.getSpoolmanSettings, staleTime: 5 * 60 * 1000, }); const { data: spools = [], isLoading } = useQuery({ queryKey: ['inventory-spools'], queryFn: () => api.getSpools(false), refetchInterval: 30000, }); const { data: assignments = [] } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), refetchInterval: 30000, }); // Build assignment lookup: spool_id → assignment const assignmentMap = useMemo(() => { const map: Record = {}; assignments.forEach(a => { map[a.spool_id] = a; }); return map; }, [assignments]); const activeSpools = useMemo(() => spools.filter(s => !s.archived_at), [spools]); // Spools that have an AMS assignment const assignedSpoolIds = useMemo(() => new Set(assignments.map(a => a.spool_id)), [assignments]); const inAmsCount = useMemo(() => activeSpools.filter(s => assignedSpoolIds.has(s.id)).length, [activeSpools, assignedSpoolIds]); // Unique materials for filter pills const materials = useMemo(() => { const set = new Set(); activeSpools.forEach(s => set.add(s.material)); return Array.from(set).sort(); }, [activeSpools]); // Filter and sort const filteredSpools = useMemo(() => { let list = activeSpools; if (filterMode === 'in_ams') { list = list.filter(s => assignedSpoolIds.has(s.id)); } else if (filterMode !== 'all') { list = list.filter(s => s.material === filterMode); } if (searchQuery.trim()) { const q = searchQuery.toLowerCase().trim(); list = list.filter(s => s.material.toLowerCase().includes(q) || (s.subtype && s.subtype.toLowerCase().includes(q)) || (s.brand && s.brand.toLowerCase().includes(q)) || (s.color_name && s.color_name.toLowerCase().includes(q)) || (s.note && s.note.toLowerCase().includes(q)) ); } // Sort: assigned spools first (by slot label), then by most recently updated return [...list].sort((a, b) => { const aAssigned = assignedSpoolIds.has(a.id) ? 0 : 1; const bAssigned = assignedSpoolIds.has(b.id) ? 0 : 1; if (aAssigned !== bAssigned) return aAssigned - bAssigned; return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); }); }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]); // Spoolman iframe mode const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url; if (spoolmanEnabled) { return ( ); } return ( {/* Search + filter pills */} {/* Search */} setSearchQuery(e.target.value)} placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')} className="w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green" /> {searchQuery && ( setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60" > )} {/* Filter pills — inline scrollable row */} setFilterMode('all')} label={`${t('spoolbuddy.inventory.all', 'All')} (${activeSpools.length})`} green /> {inAmsCount > 0 && ( setFilterMode('in_ams')} label={`${t('spoolbuddy.inventory.inAms', 'In AMS')} (${inAmsCount})`} /> )} {materials.map(mat => ( setFilterMode(filterMode === mat ? 'all' : mat)} label={mat} /> ))} {/* Spool grid */} {isLoading ? ( ) : filteredSpools.length === 0 ? ( {searchQuery || filterMode !== 'all' ? t('spoolbuddy.inventory.noResults', 'No spools match your filters') : t('spoolbuddy.inventory.empty', 'No spools in inventory')} ) : ( {filteredSpools.map(spool => ( setSelectedSpoolId(spool.id)} /> ))} )} {/* Detail modal — look up spool from live query data so it stays current */} {selectedSpoolId != null && (() => { const liveSpool = spools.find(s => s.id === selectedSpoolId); if (!liveSpool) return null; return ( setSelectedSpoolId(null)} /> ); })()} ); } /* Filter pill button */ function FilterPill({ active, onClick, label, green }: { active: boolean; onClick: () => void; label: string; green?: boolean; }) { return ( {label} ); } /* Catalog-style spool card matching the mockup */ function CatalogCard({ spool, assignment, onClick }: { spool: InventorySpool; assignment?: SpoolAssignment; onClick: () => void; }) { const color = spoolColor(spool); const pct = spoolPct(spool); const remaining = spoolRemaining(spool); const colorName = resolveSpoolColorName(spool.color_name, spool.rgba); return ( {/* Spool icon */} {/* Material + Subtype */} {spoolDisplayName(spool)} {/* Color dot + name */} {colorName || '-'} {/* Fill bar + weight */} 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(pct, 100)}%` }} /> {Math.round(remaining)}g ({Math.round(pct)}%) {/* AMS location badge */} {assignment && ( {assignmentLabel(assignment)} )} ); } /* Detail bottom sheet */ function SpoolDetailModal({ spool, assignment, onClose }: { spool: InventorySpool; assignment?: SpoolAssignment; onClose: () => void; }) { const { t } = useTranslation(); const color = spoolColor(spool); const pct = spoolPct(spool); const remaining = spoolRemaining(spool); const colorName = resolveSpoolColorName(spool.color_name, spool.rgba); return ( e.stopPropagation()} > {/* Header with spool icon */} {spoolDisplayName(spool)} {spool.brand && ( {spool.brand} )} {colorName || '-'} {/* Remaining bar */} {t('spoolbuddy.inventory.remaining', 'Remaining')} {Math.round(remaining)}g ({Math.round(pct)}%) 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(pct, 100)}%` }} /> {/* AMS location */} {assignment && ( {assignmentLabel(assignment)} {assignment.printer_name && ( {assignment.printer_name} )} )} {/* Detail grid */} 0 ? `${Math.round(spool.weight_used)}g` : '-'} /> 0 ? `${spool.core_weight}g` : '-'} /> {spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && ( )} {spool.cost_per_kg != null && spool.cost_per_kg > 0 && ( )} {spool.last_scale_weight != null && ( )} {spool.tag_uid && ( )} {(spool.slicer_filament_name || spool.slicer_filament) && ( )} {/* K-Profiles */} {spool.k_profiles && spool.k_profiles.length > 0 && ( {t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')} {spool.k_profiles.map(kp => ( {kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`} {kp.k_value.toFixed(3)} ))} )} {/* Note */} {spool.note && ( {t('spoolbuddy.inventory.note', 'Note')} {spool.note} )} {/* Close button */} {t('spoolbuddy.inventory.close', 'Close')} ); } function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return ( {label} {value} ); }
{searchQuery || filterMode !== 'all' ? t('spoolbuddy.inventory.noResults', 'No spools match your filters') : t('spoolbuddy.inventory.empty', 'No spools in inventory')}
{spoolDisplayName(spool)}
{Math.round(remaining)}g ({Math.round(pct)}%)
{spool.brand}
{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}
{t('spoolbuddy.inventory.note', 'Note')}
{spool.note}
{label}
{value}