import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns, ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, } from 'lucide-react'; import { ForecastPanel } from '../components/ForecastPanel'; import { api, spoolbuddyApi, ApiError } from '../api/client'; import type { InventorySpool, SpoolCatalogEntry } from '../api/client'; import { Button } from '../components/Button'; import { FilamentSwatch } from '../components/FilamentSwatch'; import { buildFilamentBackground } from '../components/filamentSwatchHelpers'; import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal'; import { ConfirmModal } from '../components/ConfirmModal'; import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal'; import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { resolveSpoolColorName } from '../utils/colors'; import { getCurrencySymbol } from '../utils/currency'; import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date'; import { formatSlotLabel } from '../utils/amsHelpers'; import { filterSpoolsByQuery } from '../utils/inventorySearch'; import { aggregateGroupSpool } from '../utils/inventoryGrouping'; type ArchiveFilter = 'active' | 'archived'; type UsageFilter = 'all' | 'used' | 'new' | 'lowstock'; type ViewMode = 'table' | 'cards' | 'forecast'; type SortDirection = 'asc' | 'desc'; type SortState = { column: string; direction: SortDirection } | null; type DisplayItem = | { type: 'single'; spool: InventorySpool } | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool }; function spoolGroupKey(s: InventorySpool): string { // Include extra_colors + effect_type so the "Group similar" toggle does // not collapse two spools that share the base colour but differ on // gradient stops or visual effect (#1154). return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`; } // Column definitions for the inventory table const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns'; const DEFAULT_COLUMNS: ColumnConfig[] = [ { id: 'id', label: '#', visible: true }, { id: 'added_time', label: 'Added', visible: true }, { id: 'encode_time', label: 'Encoded', visible: false }, { id: 'last_used_time', label: 'Last Used', visible: false }, { id: 'rgba', label: 'Color', visible: true }, { id: 'material', label: 'Material', visible: true }, { id: 'subtype', label: 'Subtype', visible: true }, { id: 'color_name', label: 'Color Name', visible: false }, { id: 'brand', label: 'Brand', visible: true }, { id: 'slicer_filament', label: 'Slicer Filament', visible: false }, { id: 'location', label: 'Location', visible: true }, { id: 'storage_location', label: 'Storage Location', visible: false }, { id: 'label_weight', label: 'Label', visible: true }, { id: 'net', label: 'Net', visible: true }, { id: 'gross', label: 'Gross', visible: false }, { id: 'added_full', label: 'Full', visible: false }, { id: 'used', label: 'Used', visible: false }, { id: 'printed_total', label: 'Printed Total', visible: false }, { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false }, { id: 'note', label: 'Note', visible: false }, { id: 'pa_k', label: 'PA(K)', visible: true }, { id: 'tag_id', label: 'Tag ID', visible: false }, { id: 'data_origin', label: 'Data Origin', visible: false }, { id: 'tag_type', label: 'Linked Tag Type', visible: false }, { id: 'stock', label: 'Stock', visible: false }, { id: 'remaining', label: 'Remaining', visible: true }, { id: 'spool_name', label: 'Spool', visible: false }, { id: 'cost_per_kg', label: 'Cost/kg', visible: false }, { id: 'weight_check', label: 'Weight Check', visible: false }, ]; function loadColumnConfig(): ColumnConfig[] { try { const stored = localStorage.getItem(COLUMN_CONFIG_KEY); if (stored) { const parsed = JSON.parse(stored) as ColumnConfig[]; const defaultIds = new Set(DEFAULT_COLUMNS.map((c) => c.id)); const storedIds = new Set(parsed.map((c) => c.id)); // Keep stored columns that still exist in defaults const validStored = parsed.filter((c) => defaultIds.has(c.id)); // Add any new default columns not in stored config const newColumns = DEFAULT_COLUMNS.filter((c) => !storedIds.has(c.id)); return [...validStored, ...newColumns]; } } catch { // Ignore errors } return DEFAULT_COLUMNS.map((c) => ({ ...c })); } function saveColumnConfig(config: ColumnConfig[]) { try { localStorage.setItem(COLUMN_CONFIG_KEY, JSON.stringify(config)); } catch { // Ignore errors } } function formatWeight(g: number, useKg = false): string { if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`; return `${Math.round(g)}g`; } // Material color mapping for pills const MATERIAL_COLORS: Record = { PLA: 'bg-green-500/20 text-green-400', ABS: 'bg-red-500/20 text-red-400', PETG: 'bg-blue-500/20 text-blue-400', TPU: 'bg-purple-500/20 text-purple-400', ASA: 'bg-orange-500/20 text-orange-400', PA: 'bg-yellow-500/20 text-yellow-400', PC: 'bg-cyan-500/20 text-cyan-400', PET: 'bg-sky-500/20 text-sky-400', }; type TFn = (key: string, opts?: Record) => string; function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string { if (!dateStr) return '-'; const date = parseUTCDate(dateStr); if (!date) return '-'; return formatDateInput(date, dateFormat); } // Slim shape for the LOCATION column — only the fields actually rendered. // Sourced from either local SpoolAssignment (lokal) or SpoolmanSlotAssignment // (Spoolman mode), so we can't reuse SpoolAssignment without dummy values. type LocationDisplay = { printer_id: number; printer_name: string | null; ams_id: number; tray_id: number; ams_label: string | null; }; type CellCtx = { spool: InventorySpool; remaining: number; pct: number; assignmentMap: Record; catalogMap: Record; currencySymbol: string; dateFormat: DateFormat; t: TFn; onSyncWeight?: (spool: InventorySpool) => void; }; // Column header labels (25 columns — matching SpoolBuddy exactly) const columnHeaders: Record string> = { id: () => '#', added_time: () => 'Added', encode_time: () => 'Encoded', last_used_time: () => 'Last Used', rgba: (t) => t('inventory.color'), material: (t) => t('inventory.material'), subtype: (t) => t('inventory.subtype'), color_name: (t) => t('inventory.colorName'), brand: (t) => t('inventory.brand'), slicer_filament: (t) => t('inventory.slicerFilament'), location: () => 'Location', storage_location: (t) => t('inventory.storageLocation'), label_weight: (t) => t('inventory.labelWeight'), net: (t) => t('inventory.net'), gross: () => 'Gross', added_full: () => 'Full', used: (t) => t('inventory.weightUsed'), printed_total: () => 'Printed Total', printed_since_weight: () => 'Printed Since Weight', note: (t) => t('inventory.note'), pa_k: () => 'PA(K)', tag_id: () => 'Tag ID', data_origin: () => 'Data Origin', tag_type: () => 'Linked Tag Type', stock: (t) => t('inventory.stock'), remaining: (t) => t('inventory.remaining'), spool_name: (t) => t('inventory.spoolName'), cost_per_kg: (t) => t('inventory.costPerKg'), weight_check: (t) => t('inventory.weightCheck'), }; // Column cell renderers (25 columns — matching SpoolBuddy exactly) const columnCells: Record ReactNode> = { id: ({ spool }) => ( {spool.id} ), added_time: ({ spool, dateFormat }) => ( {formatInventoryDate(spool.created_at, dateFormat)} ), encode_time: ({ spool, dateFormat }) => ( {formatInventoryDate(spool.encode_time, dateFormat)} ), last_used_time: ({ spool, dateFormat }) => ( {spool.last_used ? formatInventoryDate(spool.last_used, dateFormat) : 'Never'} ), rgba: ({ spool }) => (
), material: ({ spool }) => ( {spool.material} ), subtype: ({ spool }) => ( {spool.subtype || '-'} ), color_name: ({ spool }) => ( {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'} ), brand: ({ spool }) => ( {spool.brand || '-'} ), slicer_filament: ({ spool }) => ( {spool.slicer_filament_name || spool.slicer_filament || '-'} ), location: ({ spool, assignmentMap }) => { const assignment = assignmentMap[spool.id]; if (!assignment) return -; const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`; const isExternal = assignment.ams_id === 254 || assignment.ams_id === 255; const isHt = !isExternal && assignment.ams_id >= 128; const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal); return ( {printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''} ); }, storage_location: ({ spool }) => { if (!spool.storage_location) return -; return ( {spool.storage_location} ); }, label_weight: ({ spool }) => ( {formatWeight(spool.label_weight)} ), net: ({ remaining }) => ( {formatWeight(remaining)} ), gross: ({ spool, remaining }) => ( {formatWeight(remaining + spool.core_weight)} ), added_full: ({ spool }) => ( {spool.added_full == null ? '-' : spool.added_full ? 'Yes' : 'No'} ), used: ({ spool }) => ( {spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'} ), printed_total: () => ( - ), printed_since_weight: () => ( - ), note: ({ spool }) => ( {spool.note || '-'} ), pa_k: ({ spool }) => { const count = spool.k_profiles?.length ?? 0; if (count === 0) return -; return ( K ); }, tag_id: ({ spool }) => { const tag = spool.tag_uid || spool.tray_uuid; if (!tag) return -; return ( {tag.length > 12 ? `${tag.slice(0, 6)}...${tag.slice(-4)}` : tag} ); }, data_origin: ({ spool }) => ( {spool.data_origin || '-'} ), tag_type: ({ spool }) => ( {spool.tag_type || '-'} ), stock: ({ spool, t }) => { if (!spool.slicer_filament) { return ( {t('inventory.stock')} ); } return -; }, remaining: ({ remaining, pct }) => (
50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(pct, 100)}%` }} />
{Math.round(remaining)}g
), spool_name: ({ spool, catalogMap }) => { const entry = spool.core_weight_catalog_id != null ? catalogMap[spool.core_weight_catalog_id] : undefined; return {entry?.name || '-'}; }, cost_per_kg: ({ spool, currencySymbol }) => ( {spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'} ), weight_check: ({ spool, onSyncWeight }) => { const scaleWeight = spool.last_scale_weight; if (scaleWeight == null) return -; const coreWeight = spool.core_weight || 0; const calculatedWeight = Math.max(0, spool.label_weight - spool.weight_used) + coreWeight; // Edge case: scale < core_weight means spool is empty or not on scale — treat as match let difference: number; let isMatch: boolean; if (scaleWeight < coreWeight) { difference = scaleWeight - coreWeight; isMatch = true; } else { difference = scaleWeight - calculatedWeight; isMatch = Math.abs(difference) <= 50; } const diffStr = difference > 0 ? `+${Math.round(difference)}` : `${Math.round(difference)}`; const tooltip = isMatch ? `Scale: ${Math.round(scaleWeight)}g\nCalculated: ${Math.round(calculatedWeight)}g\nDifference: ${diffStr}g (within tolerance)` : `Scale: ${Math.round(scaleWeight)}g\nCalculated: ${Math.round(calculatedWeight)}g\nDifference: ${diffStr}g (mismatch!)`; return (
{Math.round(scaleWeight)}g {isMatch ? ( ) : ( <> {onSyncWeight && ( )} )}
); }, }; // Sort value extractors — return a comparable value for each sortable column const columnSortValues: Record) => string | number> = { id: (s) => s.id, added_time: (s) => s.created_at || '', encode_time: (s) => s.encode_time || '', last_used_time: (s) => s.last_used || '', material: (s) => (s.material || '').toLowerCase(), subtype: (s) => (s.subtype || '').toLowerCase(), color_name: (s) => (s.color_name || '').toLowerCase(), brand: (s) => (s.brand || '').toLowerCase(), slicer_filament: (s) => (s.slicer_filament_name || s.slicer_filament || '').toLowerCase(), location: (s, am) => { const a = am[s.id]; if (!a) return ''; const isExt = a.ams_id === 254 || a.ams_id === 255; const isHt = !isExt && a.ams_id >= 128; const label = a.ams_label ? ` (${a.ams_label})` : ''; return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`; }, storage_location: (s) => (s.storage_location || '').toLowerCase(), label_weight: (s) => s.label_weight, net: (s) => Math.max(0, s.label_weight - s.weight_used), gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight, used: (s) => s.weight_used, remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0, note: (s) => (s.note || '').toLowerCase(), data_origin: (s) => (s.data_origin || '').toLowerCase(), tag_type: (s) => (s.tag_type || '').toLowerCase(), stock: (s) => s.slicer_filament ? 1 : 0, spool_name: (s) => s.core_weight_catalog_id ?? 0, cost_per_kg: (s) => s.cost_per_kg ?? 0, weight_check: (s) => { if (s.last_scale_weight == null) return -1; const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight; return Math.abs(s.last_scale_weight - expectedGross); }, }; const SORT_STATE_KEY = 'bambuddy-inventory-sort'; function loadSortState(): SortState { try { const stored = localStorage.getItem(SORT_STATE_KEY); if (stored) return JSON.parse(stored); } catch { /* ignore */ } return null; } function saveSortState(state: SortState) { try { if (state) { localStorage.setItem(SORT_STATE_KEY, JSON.stringify(state)); } else { localStorage.removeItem(SORT_STATE_KEY); } } catch { /* ignore */ } } // Wrapper: detects Spoolman mode and passes it to the shared inventory UI export default function InventoryPageRouter() { const { data: spoolmanSettings } = useQuery({ queryKey: ['spoolman-settings'], queryFn: api.getSpoolmanSettings, staleTime: 5 * 60 * 1000, }); const spoolmanModeReady = spoolmanSettings !== undefined; const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url; return ; } function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spoolmanMode?: boolean; spoolmanModeReady?: boolean }) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, loading: authLoading } = useAuth(); const canViewForecast = !authLoading && hasPermission('inventory:forecast_read'); const [searchParams, setSearchParams] = useSearchParams(); const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null; mode: SpoolFormMode } | null>(null); const deepLinkHandled = useRef(false); const [confirmAction, setConfirmAction] = useState< | { type: 'delete' | 'archive' | 'reset-usage'; spoolId: number } | { type: 'reset-all-usage' } | null >(null); // Label printing (#809). null = closed; otherwise the IDs to print labels for. const [labelPickerSpoolIds, setLabelPickerSpoolIds] = useState(null); // Filter state const [archiveFilter, setArchiveFilter] = useState('active'); const [usageFilter, setUsageFilter] = useState('all'); const [materialFilter, setMaterialFilter] = useState(''); const [brandFilter, setBrandFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState(''); const [spoolFilter, setSpoolFilter] = useState(''); const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all'); // #1400: storage-location dropdown. Uses the sentinel `__none__` for the // "no storage location set" group, same pattern as the category filter so // users can find unfiled spools. const [storageLocationFilter, setStorageLocationFilter] = useState(''); const [search, setSearch] = useState(''); const [viewMode, setViewMode] = useState('table'); const [sortState, setSortState] = useState(loadSortState); const [columnConfig, setColumnConfig] = useState(loadColumnConfig); const [showColumnModal, setShowColumnModal] = useState(false); const [groupSimilar, setGroupSimilar] = useState(() => { try { return localStorage.getItem('bambuddy-inventory-group') === 'true'; } catch { return false; } }); const [expandedGroups, setExpandedGroups] = useState>(new Set()); // Pagination state (pageSize persisted to localStorage) const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(() => { try { const stored = localStorage.getItem('bambuddy-inventory-pageSize'); if (stored) { const n = Number(stored); if ([15, 30, 50, 100, -1].includes(n)) return n; } } catch { /* ignore */ } return 15; }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const dateFormat: DateFormat = settings?.date_format || 'system'; // Query key and fetch function differ based on data source const spoolsQueryKey = spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools']; const { data: spools, isLoading } = useQuery({ queryKey: spoolsQueryKey, queryFn: () => spoolmanMode ? api.getSpoolmanInventorySpools(true) : api.getSpools(true), refetchInterval: 30000, }); // Deep-link: open edit modal for ?spool= // Prefer the already-loaded spool list (no extra API call); fall back to a // targeted fetch for the rare case where the full list hasn't arrived yet. const _rawSpoolParam = searchParams.get('spool'); // Only accept strings of digits representing a positive integer — guards against // NaN (Number('abc')), 0, negatives, and floats like '1.5' that would produce // an invalid path parameter and trigger unnecessary 422 responses from the API. const deepLinkSpoolId = _rawSpoolParam && /^\d+$/.test(_rawSpoolParam) && Number(_rawSpoolParam) > 0 ? Number(_rawSpoolParam) : null; const deepLinkInList = spools?.find((s) => s.id === deepLinkSpoolId) ?? null; const clearDeepLinkParam = useCallback(() => { deepLinkHandled.current = true; setSearchParams((prev) => { prev.delete('spool'); return prev; }, { replace: true }); }, [setSearchParams]); // Targeted fetch — only fires when mode is known and spool isn't in the list yet const { data: deepLinkSpool, isError: deepLinkFetchFailed, error: deepLinkError } = useQuery({ queryKey: spoolmanMode ? ['spoolman-inventory-spool', deepLinkSpoolId] : ['inventory-spool', deepLinkSpoolId], queryFn: () => spoolmanMode ? api.getSpoolmanInventorySpool(deepLinkSpoolId!) : api.getSpool(deepLinkSpoolId!), enabled: spoolmanModeReady && deepLinkSpoolId !== null && deepLinkInList === null, staleTime: Infinity, retry: (failureCount, error) => failureCount < 2 && !(error instanceof ApiError && error.status === 404), }); useEffect(() => { if (deepLinkHandled.current) return; // Case 1: spool is already in the fetched list if (spoolmanModeReady && deepLinkSpoolId && deepLinkInList) { clearDeepLinkParam(); setFormModal({ spool: deepLinkInList, mode: 'edit' }); return; } // Case 2: spool was fetched individually if (deepLinkSpool) { clearDeepLinkParam(); setFormModal({ spool: deepLinkSpool, mode: 'edit' }); return; } // Case 3: fetch failed if (deepLinkFetchFailed) { clearDeepLinkParam(); const is404 = deepLinkError instanceof ApiError && deepLinkError.status === 404; showToast(t(is404 ? 'inventory.deepLinkSpoolNotFound' : 'inventory.deepLinkFetchFailed'), 'error'); } }, [ spoolmanModeReady, deepLinkSpoolId, deepLinkInList, deepLinkSpool, deepLinkFetchFailed, deepLinkError, clearDeepLinkParam, showToast, t, ]); const { data: assignments } = useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments(), refetchInterval: 30000, }); // Spoolman-mode slot assignments. spool.id IS the spoolman_spool_id, so this // feeds into the same assignmentMap that the LOCATION column reads. const { data: spoolmanSlotAssignments = [], isError: spoolmanSlotAssignmentsError, } = useQuery({ queryKey: ['spoolman-slot-assignments-all'], queryFn: () => api.getSpoolmanSlotAssignments(), enabled: spoolmanMode, refetchInterval: 30000, staleTime: 10000, retry: 1, }); // Surface a single toast when the slot-assignment endpoint goes down — the // LOCATION column would otherwise silently show "-" for every Spoolman spool. // useRef guard prevents repeated toasts during refetchInterval polls. const slotErrorToastShown = useRef(false); useEffect(() => { if (spoolmanSlotAssignmentsError && !slotErrorToastShown.current) { slotErrorToastShown.current = true; showToast(t('inventory.spoolmanUnreachable'), 'error'); } else if (!spoolmanSlotAssignmentsError) { slotErrorToastShown.current = false; } }, [spoolmanSlotAssignmentsError, showToast, t]); const { data: catalogEntries } = useQuery({ queryKey: ['spool-catalog'], queryFn: () => api.getSpoolCatalog(), }); const deleteMutation = useMutation({ mutationFn: (id: number) => spoolmanMode ? api.deleteSpoolmanInventorySpool(id) : api.deleteSpool(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); showToast(t('inventory.spoolDeleted'), 'success'); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 404) { showToast(t('inventory.deleteSpoolNotFound'), 'error'); } else if (error instanceof ApiError && error.status === 503) { showToast(t('inventory.spoolmanUnreachable'), 'error'); } else { showToast(t('inventory.deleteFailed'), 'error'); } }, }); const archiveMutation = useMutation({ mutationFn: (id: number) => spoolmanMode ? api.archiveSpoolmanInventorySpool(id) : api.archiveSpool(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); showToast(t('inventory.spoolArchived'), 'success'); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 404) { showToast(t('inventory.archiveSpoolNotFound'), 'error'); } else if (error instanceof ApiError && error.status === 503) { showToast(t('inventory.spoolmanUnreachable'), 'error'); } else { showToast(t('inventory.archiveFailed'), 'error'); } }, }); const restoreMutation = useMutation({ mutationFn: (id: number) => spoolmanMode ? api.restoreSpoolmanInventorySpool(id) : api.restoreSpool(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); showToast(t('inventory.spoolRestored'), 'success'); }, onError: (error: Error) => { if (error instanceof ApiError && error.status === 404) { showToast(t('inventory.restoreSpoolNotFound'), 'error'); } else if (error instanceof ApiError && error.status === 503) { showToast(t('inventory.spoolmanUnreachable'), 'error'); } else { showToast(t('inventory.restoreFailed'), 'error'); } }, }); const resetUsageMutation = useMutation({ mutationFn: (id: number) => spoolmanMode ? api.resetSpoolmanInventorySpoolUsage(id) : api.resetSpoolUsage(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); showToast(t('inventory.usageReset'), 'success'); }, onError: () => { showToast(t('inventory.resetUsageFailed'), 'error'); }, }); const bulkResetUsageMutation = useMutation({ mutationFn: (ids: number[]) => spoolmanMode ? api.bulkResetSpoolmanInventorySpoolUsage(ids) : api.bulkResetSpoolUsage(ids), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); showToast(t('inventory.allUsageReset', { count: data.reset }), 'success'); }, onError: () => { showToast(t('inventory.resetUsageFailed'), 'error'); }, }); // Spool IDs the "Reset all usage" button bulk-targets. Includes archived // spools too — without them, the broadened "Total Consumed" stat (which // sums archived consumption per the #1390 follow-up) would stay non-zero // after a Reset-all click, surprising the user. Backend reset endpoints // (both internal and Spoolman) already accept archived IDs without a // route-level guard, so this just removes the frontend filter. const resetableSpoolIds = useMemo( () => (spools ?? []).map((s) => s.id), [spools], ); const handleSyncWeight = async (spool: InventorySpool) => { if (spool.last_scale_weight == null) return; try { if (spoolmanMode) { await api.syncSpoolmanSpoolWeight(spool.id, spool.last_scale_weight); } else { await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight); } queryClient.invalidateQueries({ queryKey: spoolsQueryKey }); const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' '); showToast(`Synced "${spoolName}" to scale weight`, 'success'); } catch (e) { const is404 = e instanceof ApiError && e.status === 404; const is503 = e instanceof ApiError && e.status === 503; if (is404) showToast(t('inventory.syncWeightSpoolNotFound'), 'error'); else if (is503) showToast(t('inventory.syncWeightSpoolmanUnreachable'), 'error'); else showToast(t('inventory.syncWeightFailed'), 'error'); } }; // Low stock threshold from backend settings const lowStockThreshold = settings?.low_stock_threshold ?? 20; const [showThresholdInput, setShowThresholdInput] = useState(false); const [thresholdInput, setThresholdInput] = useState(lowStockThreshold.toString()); // Sync thresholdInput when lowStockThreshold changes and input is not shown useEffect(() => { if (!showThresholdInput) { setThresholdInput(lowStockThreshold.toString()); } }, [lowStockThreshold, showThresholdInput]); const updateThresholdMutation = useMutation({ mutationFn: (threshold: number) => api.updateSettings({ low_stock_threshold: threshold }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings'] }); showToast(t('common.saved'), 'success'); setShowThresholdInput(false); }, onError: () => { showToast(t('inventory.lowStockThresholdError'), 'error'); }, }); // Stats calculation. // // "Total Consumed" sums over ALL spools (active AND archived) because it's // a running counter — past consumption of a now-archived spool is real // history and silently dropping it on archive made the running total // collapse mysteriously (#1390 follow-up). The other aggregates // (totalWeight, lowStock, byMaterial, activeCount) describe what's // currently in active inventory and stay active-only. const stats = useMemo(() => { if (!spools) return null; let totalWeight = 0; let totalConsumed = 0; let lowStock = 0; let activeCount = 0; const byMaterial: Record = {}; for (const s of spools) { // "Total Consumed" is the resettable counter (weight_used - baseline) // rather than raw weight_used so the per-spool / bulk eraser zeroes // the stat without inflating remaining back to label_weight (#1390). // Computed before the archived-skip below so archived consumption // stays in the running total. totalConsumed += Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0)); if (s.archived_at) continue; activeCount++; const remaining = Math.max(0, s.label_weight - s.weight_used); totalWeight += remaining; const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0; const threshold = s.low_stock_threshold_pct ?? lowStockThreshold; if (pct < threshold) lowStock++; const mat = s.material || 'Unknown'; if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 }; byMaterial[mat].count++; byMaterial[mat].weight += remaining; } return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount }; }, [spools, lowStockThreshold]); const inPrinterCount = (assignments?.length ?? 0) + (spoolmanMode ? spoolmanSlotAssignments.length : 0); const currencySymbol = getCurrencySymbol(settings?.currency || 'USD'); // Map spool_id -> location display data for the LOCATION column. // Local SpoolAssignment entries first, then Spoolman SlotAssignment fills in // remaining IDs. Local wins on collision (defensive — modes are exclusive in // practice, but a stray pair with the same numeric id would otherwise be // unpredictable). spool.id IS the spoolman_spool_id in Spoolman mode. const assignmentMap = useMemo>(() => { const map: Record = {}; for (const a of assignments || []) { map[a.spool_id] = { printer_id: a.printer_id, printer_name: a.printer_name, ams_id: a.ams_id, tray_id: a.tray_id, ams_label: a.ams_label ?? null, }; } for (const a of spoolmanSlotAssignments) { // Defensive: skip malformed entries (missing or invalid spool id, ams id, // tray id). The Pydantic response model on the backend should already // reject these, but MITM proxies and stale CDN responses can drop fields. if ( typeof a?.spoolman_spool_id !== 'number' || a.spoolman_spool_id <= 0 || typeof a.printer_id !== 'number' || typeof a.ams_id !== 'number' || typeof a.tray_id !== 'number' ) continue; if (!map[a.spoolman_spool_id]) { map[a.spoolman_spool_id] = { printer_id: a.printer_id, printer_name: a.printer_name ?? null, ams_id: a.ams_id, tray_id: a.tray_id, ams_label: a.ams_label ?? null, }; } } return map; }, [assignments, spoolmanSlotAssignments]); // Map catalog_id -> catalog entry for spool name column const catalogMap = useMemo(() => { const map: Record = {}; for (const e of catalogEntries || []) { map[e.id] = e; } return map; }, [catalogEntries]); // Top materials by weight for stat card pills const topMaterials = useMemo(() => { if (!stats) return []; return Object.entries(stats.byMaterial) .sort((a, b) => b[1].weight - a[1].weight) .slice(0, 4); }, [stats]); // Filtering pipeline const filteredSpools = useMemo(() => { let filtered = spools || []; // Archive filter if (archiveFilter === 'active') { filtered = filtered.filter((s) => !s.archived_at); } else { filtered = filtered.filter((s) => !!s.archived_at); } // Usage filter if (usageFilter === 'used') { filtered = filtered.filter((s) => s.weight_used > 0); } else if (usageFilter === 'new') { filtered = filtered.filter((s) => s.weight_used === 0); } else if (usageFilter === 'lowstock') { filtered = filtered.filter((s) => { const remaining = Math.max(0, s.label_weight - s.weight_used); const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0; const threshold = s.low_stock_threshold_pct ?? lowStockThreshold; return pct < threshold; }); } // Material dropdown if (materialFilter) { filtered = filtered.filter((s) => s.material === materialFilter); } // Brand dropdown if (brandFilter) { filtered = filtered.filter((s) => s.brand === brandFilter); } // Category dropdown (#729) if (categoryFilter) { if (categoryFilter === '__none__') { filtered = filtered.filter((s) => !s.category); } else { filtered = filtered.filter((s) => s.category === categoryFilter); } } // Spool name dropdown if (spoolFilter) { const catalogId = Number(spoolFilter); filtered = filtered.filter((s) => s.core_weight_catalog_id === catalogId); } // Storage location dropdown (#1400). `__none__` lets the user find // spools that haven't been assigned a storage location yet. if (storageLocationFilter) { if (storageLocationFilter === '__none__') { filtered = filtered.filter((s) => !s.storage_location?.trim()); } else { filtered = filtered.filter((s) => s.storage_location?.trim() === storageLocationFilter); } } // Stock filter if (stockFilter === 'stock') { filtered = filtered.filter((s) => !s.slicer_filament); } else if (stockFilter === 'configured') { filtered = filtered.filter((s) => !!s.slicer_filament); } // Global search if (search) { filtered = filterSpoolsByQuery(filtered, search); } return filtered; }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold]); // Reset page on filter changes const resetPage = () => setPageIndex(0); // Unique values for filter dropdowns const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort(); const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[]; const uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort(); const hasUncategorized = (spools ?? []).some((s) => !s.category); const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => { const nameA = (catalogMap[a]?.name || '').toLowerCase(); const nameB = (catalogMap[b]?.name || '').toLowerCase(); return nameA.localeCompare(nameB); }); // #1400: storage-location distinct values. `.trim()` so accidental // trailing whitespace doesn't show up as a separate option. const uniqueStorageLocations = [...new Set(spools?.map((s) => s.storage_location?.trim()).filter(Boolean) as string[] || [])].sort(); const hasUnsetStorageLocation = (spools ?? []).some((s) => !s.storage_location?.trim()); // Check if any filters are non-default const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || !!storageLocationFilter || stockFilter !== 'all' || !!search; const handleColumnConfigSave = (config: ColumnConfig[]) => { setColumnConfig(config); saveColumnConfig(config); }; // Visible column IDs in order const visibleColumns = useMemo( () => columnConfig.filter((c) => c.visible).map((c) => c.id), [columnConfig] ); const handleSort = (colId: string) => { if (!columnSortValues[colId]) return; // Not sortable setSortState((prev) => { let next: SortState; if (prev?.column === colId) { // Toggle direction, or clear on third click next = prev.direction === 'asc' ? { column: colId, direction: 'desc' } : null; } else { next = { column: colId, direction: 'asc' }; } saveSortState(next); return next; }); resetPage(); }; // Sort filtered spools const sortedSpools = useMemo(() => { if (!sortState) return filteredSpools; const extractor = columnSortValues[sortState.column]; if (!extractor) return filteredSpools; const sorted = [...filteredSpools].sort((a, b) => { const va = extractor(a, assignmentMap); const vb = extractor(b, assignmentMap); if (va < vb) return sortState.direction === 'asc' ? -1 : 1; if (va > vb) return sortState.direction === 'asc' ? 1 : -1; return 0; }); return sorted; }, [filteredSpools, sortState, assignmentMap]); // Group similar spools when toggle is active const displayItems = useMemo((): DisplayItem[] => { if (!groupSimilar) return sortedSpools.map((s) => ({ type: 'single' as const, spool: s })); const groups = new Map(); for (const spool of sortedSpools) { // Only group unused & unassigned spools if (spool.weight_used > 0 || assignmentMap[spool.id]) { // Will be added as singles in the walk below } else { const key = spoolGroupKey(spool); const arr = groups.get(key); if (arr) arr.push(spool); else groups.set(key, [spool]); } } const items: DisplayItem[] = []; const processedKeys = new Set(); // Walk sortedSpools order so groups appear at the position of their first member for (const spool of sortedSpools) { if (spool.weight_used > 0 || assignmentMap[spool.id]) { items.push({ type: 'single', spool }); continue; } const key = spoolGroupKey(spool); if (processedKeys.has(key)) continue; processedKeys.add(key); const members = groups.get(key)!; if (members.length === 1) { items.push({ type: 'single', spool: members[0] }); } else { items.push({ type: 'group', key, spools: members, representative: members[0] }); } } return items; }, [sortedSpools, groupSimilar, assignmentMap]); // Pagination (after sorting) — pageSize -1 means "All" const showAll = pageSize === -1; const totalDisplayItems = displayItems.length; const effectivePageSize = showAll ? totalDisplayItems || 1 : pageSize; const totalPages = Math.max(1, Math.ceil(totalDisplayItems / effectivePageSize)); const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1); const pagedItems = showAll ? displayItems : displayItems.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize); const toggleGroupSimilar = () => { const next = !groupSimilar; setGroupSimilar(next); setExpandedGroups(new Set()); resetPage(); try { localStorage.setItem('bambuddy-inventory-group', String(next)); } catch { /* ignore */ } }; const toggleGroupExpand = (key: string) => { setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const handlePageSizeChange = (size: number) => { setPageSize(size); setPageIndex(0); try { localStorage.setItem('bambuddy-inventory-pageSize', String(size)); } catch { /* ignore */ } }; const clearAllFilters = () => { setArchiveFilter('active'); setUsageFilter('all'); setMaterialFilter(''); setBrandFilter(''); setCategoryFilter(''); setSpoolFilter(''); setStorageLocationFilter(''); setStockFilter('all'); setSearch(''); resetPage(); }; return (
{/* Header */}

{t('inventory.title')}

{t('inventory.subtitle')}

{/* Stats Bar */} {stats && !isLoading && (
{/* Total Inventory */}
{t('inventory.totalInventory')}
{formatWeight(stats.totalWeight, true)}
{stats.totalSpools} {stats.totalSpools !== 1 ? t('inventory.spools') : t('inventory.spool')}
{/* Total Consumed */}
{t('inventory.totalConsumed')}
{stats.totalConsumed > 0 && resetableSpoolIds.length > 0 && ( )}
{formatWeight(stats.totalConsumed, true)}
{t('inventory.sinceTracking')}
{/* By Material */}
{t('inventory.byMaterial')}
{topMaterials.map(([mat, data]) => ( {mat} {formatWeight(data.weight, true)} ))}
{/* In Printer */}
{t('inventory.inPrinter')}
{inPrinterCount}
{t('inventory.loadedInAms')}
{/* Low Stock */}
{t('inventory.lowStock')}
0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}
{showThresholdInput ? (
{ e.preventDefault(); const val = parseFloat(thresholdInput); if (!isNaN(val) && val >= 0.1 && val <= 99.9) { updateThresholdMutation.mutate(val); } else { showToast(t('inventory.lowStockThresholdError'), 'error'); } }} className="flex items-center gap-2" > {'<'} { // Only allow up to 2 digits before decimal and 1 after const val = e.target.value.replace(/[^\d.]/g, ''); if (/^\d{0,2}(\.\d?)?$/.test(val)) { setThresholdInput(val); } }} className="px-1.5 py-1 rounded border border-bambu-dark-tertiary text-xs text-white bg-bambu-dark-secondary focus:outline-none focus:border-bambu-green w-14 text-center" onWheel={e => e.currentTarget.blur()} disabled={updateThresholdMutation.isPending} /> %
) : ( <> {'< '}{lowStockThreshold}% )}
)} {/* Toolbar: Search + View toggle */}
{ setSearch(e.target.value); resetPage(); }} placeholder={t('inventory.search')} className="w-full pl-10 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green" /> {search && ( )}
{/* Columns button (table view only) */} {viewMode === 'table' && ( )} {/* Group similar toggle — hidden in forecast mode */} {viewMode !== 'forecast' && ( )} {/* Table / Cards toggle */}
{/* Filter chips row — hidden in forecast mode */}
{/* Active / Archived chips */}
{/* All / Used / New chips */}
{/* Stock filter chips */}
{/* Material dropdown chip */} {/* Brand dropdown chip */} {/* Category dropdown chip (#729) — only render once at least one spool carries a category, otherwise it's noise. */} {(uniqueCategories.length > 0 || categoryFilter) && ( )} {/* Spool name dropdown chip */} {uniqueSpoolCatalogIds.length > 0 && ( )} {/* Storage location dropdown chip (#1400) — only render when at least one spool carries a storage location, otherwise it's noise (matches the category chip pattern). */} {(uniqueStorageLocations.length > 0 || storageLocationFilter) && ( )} {/* Clear filters */} {hasActiveFilters && ( <>
)} {/* Results count — hidden in forecast mode */} {viewMode !== 'forecast' && ( {sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')} {groupSimilar && totalDisplayItems < sortedSpools.length && ` (${totalDisplayItems} ${t('inventory.groupedRows')})`} )}
{/* Content */} {isLoading ? (
) : viewMode === 'forecast' ? ( /* Forecast view */ ) : viewMode === 'cards' ? ( /* Cards view */ pagedItems.length > 0 ? ( <>
{pagedItems.map((item) => { if (item.type === 'group') { const { key, spools: groupSpools, representative: rep } = item; // Total remaining filament across the group (#1368) — the // headline number for the collapsed card, vs one member's. const groupRemaining = groupSpools.reduce( (sum, s) => sum + Math.max(0, s.label_weight - s.weight_used), 0, ); const groupBannerStyle = buildFilamentBackground({ rgba: rep.rgba, extraColors: rep.extra_colors, effectType: rep.effect_type, subtype: rep.subtype, effectSize: 'groupheader', }); const isExpanded = expandedGroups.has(key); return (
{/* Group header card */}
toggleGroupExpand(key)} >
{resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}

{rep.material}{rep.subtype ? ` ${rep.subtype}` : ''}

{rep.brand || '-'}

{formatWeight(groupRemaining)} {t('inventory.groupedSpools', { count: groupSpools.length })}
{/* Expanded individual spools */} {isExpanded && (
{groupSpools.map((spool) => { const remaining = Math.max(0, spool.label_weight - spool.weight_used); const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0; return ( setFormModal({ spool, mode: 'edit' })} onPrintLabel={() => setLabelPickerSpoolIds([spool.id])} onCopy={() => setFormModal({ spool: spool, mode: 'copy' })} t={t} /> ); })}
)}
); } const spool = item.spool; const remaining = Math.max(0, spool.label_weight - spool.weight_used); const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0; return ( setFormModal({ spool, mode: 'edit' })} onPrintLabel={() => setLabelPickerSpoolIds([spool.id])} onCopy={() => setFormModal({ spool: spool, mode: 'copy' })} t={t} /> ); })}
{/* Pagination for cards */} ) : ( setFormModal({ spool: null, mode: 'create' })} t={t} /> ) ) : ( /* Table view */ pagedItems.length > 0 ? (
{visibleColumns.map((colId) => { const sortable = !!columnSortValues[colId]; const isActive = sortState?.column === colId; return ( ); })} {pagedItems.map((item) => { if (item.type === 'group') { const { key, spools: groupSpools } = item; const isExpanded = expandedGroups.has(key); // Header row shows group totals (#1368): an aggregate // spool plus remaining / pct summed across all members. const headerSpool = aggregateGroupSpool(groupSpools); const remaining = Math.max(0, headerSpool.label_weight - headerSpool.weight_used); const pct = headerSpool.label_weight > 0 ? (remaining / headerSpool.label_weight) * 100 : 0; return ( toggleGroupExpand(key)} onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })} onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })} onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })} onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })} onPrintLabel={(id) => setLabelPickerSpoolIds([id])} onResetUsage={(id) => setConfirmAction({ type: 'reset-usage', spoolId: id })} visibleColumns={visibleColumns} assignmentMap={assignmentMap} catalogMap={catalogMap} currencySymbol={currencySymbol} dateFormat={dateFormat} t={t} onSyncWeight={handleSyncWeight} /> ); } const spool = item.spool; const remaining = Math.max(0, spool.label_weight - spool.weight_used); const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0; return ( setFormModal({ spool, mode: 'edit' })} onCopy={() => setFormModal({ spool: spool, mode: 'copy' })} onRestore={() => restoreMutation.mutate(spool.id)} onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })} onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })} onPrintLabel={() => setLabelPickerSpoolIds([spool.id])} onResetUsage={() => setConfirmAction({ type: 'reset-usage', spoolId: spool.id })} visibleColumns={visibleColumns} assignmentMap={assignmentMap} catalogMap={catalogMap} currencySymbol={currencySymbol} dateFormat={dateFormat} t={t} onSyncWeight={handleSyncWeight} /> ); })}
handleSort(colId) : undefined} > {columnHeaders[colId]?.(t) ?? colId} {sortable && ( isActive ? sortState.direction === 'asc' ? : : )} {t('common.actions')}
{/* Pagination inside card footer */}
{showAll ? `${totalDisplayItems} ${totalDisplayItems !== 1 ? t('inventory.spools') : t('inventory.spool')}` : <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '} {Math.min((safePageIndex + 1) * effectivePageSize, totalDisplayItems)}{' '} {t('inventory.of')} {totalDisplayItems} {t('inventory.spools')} }
{t('inventory.show')} {!showAll && ( <> {t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages} )}
) : ( setFormModal({ spool: null, mode: 'create' })} t={t} /> ) )} {/* Spool Form Modal */} {formModal !== null && ( setFormModal(null)} spool={formModal.spool} mode={formModal.mode} currencySymbol={currencySymbol} spoolmanMode={spoolmanMode} spoolsQueryKey={spoolsQueryKey} /> )} {/* Confirm Modal (delete / archive / reset-usage / reset-all-usage) */} {confirmAction && ( { if (confirmAction.type === 'delete') { deleteMutation.mutate(confirmAction.spoolId); } else if (confirmAction.type === 'archive') { archiveMutation.mutate(confirmAction.spoolId); } else if (confirmAction.type === 'reset-usage') { resetUsageMutation.mutate(confirmAction.spoolId); } else { bulkResetUsageMutation.mutate(resetableSpoolIds); } setConfirmAction(null); }} onCancel={() => setConfirmAction(null)} /> )} {/* Column Config Modal */} setShowColumnModal(false)} columns={columnConfig} defaultColumns={DEFAULT_COLUMNS} onSave={handleColumnConfigSave} /> setLabelPickerSpoolIds(null)} availableSpools={filteredSpools} initialSelectedIds={labelPickerSpoolIds ?? []} spoolmanMode={spoolmanMode} />
); } /* Pagination bar (reused for cards view) */ function PaginationBar({ pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t, }: { pageIndex: number; pageSize: number; totalRows: number; totalPages: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; t: (key: string) => string; }) { const isShowAll = pageSize === -1; if (totalPages <= 1 && !isShowAll) return null; const effectiveSize = isShowAll ? totalRows || 1 : pageSize; return (
{isShowAll ? `${totalRows} ${totalRows !== 1 ? t('inventory.spools') : t('inventory.spool')}` : <>{t('inventory.showing')} {pageIndex * effectiveSize + 1} {t('inventory.to')}{' '} {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '} {t('inventory.of')} {totalRows} {t('inventory.spools')} }
{t('inventory.show')} {!isShowAll && ( <> {t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages} )}
); } /* Spool card for cards view */ function SpoolCard({ spool, remaining, pct, onClick, onPrintLabel, onCopy, t, }: { spool: InventorySpool; remaining: number; pct: number; onClick: () => void; onPrintLabel?: () => void; onCopy?: () => void; t: (key: string, opts?: Record) => string; }) { const bannerStyle = buildFilamentBackground({ rgba: spool.rgba, extraColors: spool.extra_colors, effectType: spool.effect_type, subtype: spool.subtype, effectSize: 'card', }); return (
{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'} {onCopy && ( )}

{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}

{spool.brand || '-'}

{onPrintLabel && ( )} #{spool.id}
{t('inventory.remaining')} {Math.round(pct)}%
50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${Math.min(pct, 100)}%` }} />
{Math.round(remaining)}g
{t('inventory.labelWeight')}: {formatWeight(spool.label_weight)}
{t('inventory.weightUsed')}: {spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}
{spool.note && (
{spool.note}
)}
); } /* Single spool row for table view */ function SpoolTableRow({ spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetUsage, visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight, }: { spool: InventorySpool; remaining: number; pct: number; onEdit: () => void; onCopy?: () => void; onRestore: () => void; onArchive: () => void; onDelete: () => void; onPrintLabel?: () => void; onResetUsage?: () => void; visibleColumns: string[]; assignmentMap: Record; catalogMap: Record; currencySymbol: string; dateFormat: DateFormat; t: TFn; onSyncWeight?: (spool: InventorySpool) => void; }) { return ( {visibleColumns.map((colId) => ( {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })} ))}
e.stopPropagation()}> {onCopy && ( )} {onPrintLabel && ( )} {onResetUsage && spool.weight_used > 0 && ( // Eraser also shows on archived spools (#1390 follow-up): // archived consumed weight now counts in "Total Consumed", so // the user needs a way to zero an archived spool's tracking // counter individually without having to un-archive it first. )} {spool.archived_at ? ( ) : ( )}
); } /* Grouped spool rows for table view */ function SpoolTableGroup({ spools, headerSpool, remaining, pct, isExpanded, onToggle, onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetUsage, visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight, }: { spools: InventorySpool[]; // Aggregate of all members (summed quantities, shared identity) — rendered // in the collapsed header row so it shows group totals (#1368). headerSpool: InventorySpool; remaining: number; pct: number; isExpanded: boolean; onToggle: () => void; onEdit: (spool: InventorySpool) => void; onCopy?: (spool: InventorySpool) => void; onArchive: (id: number) => void; onDelete: (id: number) => void; onPrintLabel?: (spoolId: number) => void; onResetUsage?: (id: number) => void; visibleColumns: string[]; assignmentMap: Record; catalogMap: Record; currencySymbol: string; dateFormat: DateFormat; t: TFn; onSyncWeight?: (spool: InventorySpool) => void; }) { return ( <> {/* Group header row */} {visibleColumns.map((colId, idx) => ( {idx === 0 ? (
{columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
) : colId === 'id' ? ( {t('inventory.groupedSpools', { count: spools.length })} ) : ( columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight }) )} ))} {spools.map((s) => `#${s.id}`).join(', ')} {/* Expanded individual rows */} {isExpanded && spools.map((spool) => { const r = Math.max(0, spool.label_weight - spool.weight_used); const p = spool.label_weight > 0 ? (r / spool.label_weight) * 100 : 0; return ( onEdit(spool)} onCopy={onCopy ? () => onCopy(spool) : undefined} onRestore={() => {}} onArchive={() => onArchive(spool.id)} onDelete={() => onDelete(spool.id)} onPrintLabel={onPrintLabel ? () => onPrintLabel(spool.id) : undefined} onResetUsage={onResetUsage ? () => onResetUsage(spool.id) : undefined} visibleColumns={visibleColumns} assignmentMap={assignmentMap} catalogMap={catalogMap} currencySymbol={currencySymbol} dateFormat={dateFormat} t={t} onSyncWeight={onSyncWeight} /> ); })} ); } /* Empty state matching SpoolBuddy's design */ function EmptyFilterState({ hasFilters, onAddSpool, t, }: { hasFilters: boolean; onAddSpool: () => void; t: (key: string) => string; }) { return (
{hasFilters ? ( ) : (
+
)}

{hasFilters ? t('inventory.noSpoolsMatch') : t('inventory.noSpools').split('.')[0]}

{hasFilters ? t('inventory.noSpoolsMatchDesc') : t('inventory.noSpools') }

{!hasFilters && ( )}
); }