import { useState, useMemo, useEffect, type ReactNode } from 'react'; 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, } from 'lucide-react'; import { api, spoolbuddyApi } from '../api/client'; import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client'; import { Button } from '../components/Button'; import { SpoolFormModal } from '../components/SpoolFormModal'; import { ConfirmModal } from '../components/ConfirmModal'; import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal'; import { useToast } from '../contexts/ToastContext'; import { resolveSpoolColorName } from '../utils/colors'; import { getCurrencySymbol } from '../utils/currency'; import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date'; import { formatSlotLabel } from '../utils/amsHelpers'; type ArchiveFilter = 'active' | 'archived'; type UsageFilter = 'all' | 'used' | 'new' | 'lowstock'; type ViewMode = 'table' | 'cards'; 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 { return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${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: '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); } 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', 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})` : ''} ); }, 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}`; }, 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: when Spoolman is enabled, embed its UI; otherwise show internal inventory export default function InventoryPageRouter() { const { data: spoolmanSettings } = useQuery({ queryKey: ['spoolman-settings'], queryFn: api.getSpoolmanSettings, staleTime: 5 * 60 * 1000, }); if (spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url) { return (