import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react'; import type { InventorySpool } from '../../api/client'; import { spoolbuddyApi, api } from '../../api/client'; import { SpoolIcon } from './SpoolIcon'; import { spoolColorString } from '../../utils/colors'; const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight'; function getDefaultCoreWeight(): number { try { const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY); if (stored) { const weight = parseInt(stored, 10); if (weight >= 0 && weight <= 500) return weight; } } catch { // Ignore errors } return 250; } interface InventorySpoolInfoCardProps { spool: InventorySpool; liveScaleWeight: number | null; persistedGrossWeight?: number | null; onClose?: () => void; onSyncWeight?: () => void; onAssignToAms?: () => void; isAssigned?: boolean; onUnassignFromAms?: () => void; className?: string; } export function InventorySpoolInfoCard({ spool, liveScaleWeight, persistedGrossWeight, onClose, onSyncWeight, onAssignToAms, isAssigned, onUnassignFromAms, className, }: InventorySpoolInfoCardProps) { const { t } = useTranslation(); const [syncing, setSyncing] = useState(false); const [synced, setSynced] = useState(false); const [syncedGrossWeight, setSyncedGrossWeight] = useState(null); // Fetch k_profiles if not already present in the spool object const { data: fetchedKProfiles } = useQuery({ queryKey: ['spool-k-profiles', spool.id], queryFn: () => api.getSpoolKProfiles(spool.id), // Inventory list payloads may omit k_profiles, so lazily fetch when missing. enabled: !spool.k_profiles || spool.k_profiles.length === 0, staleTime: 5 * 60 * 1000, }); // Use fetched k_profiles if available, otherwise use the ones from the spool object const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles; const colorHex = spoolColorString(spool.rgba); const coreWeight = (spool.core_weight && spool.core_weight > 0) ? spool.core_weight : getDefaultCoreWeight(); const grossWeightFromScale = liveScaleWeight !== null ? Math.round(Math.max(0, liveScaleWeight)) : null; // Inventory scenario: prefer the most recently synced value in this modal session. const displayedGrossWeight = syncedGrossWeight ?? ( persistedGrossWeight !== undefined ? (persistedGrossWeight !== null ? Math.round(Math.max(0, persistedGrossWeight)) : null) : grossWeightFromScale ); const inventoryRemaining = Math.round(Math.max(0, (spool.label_weight || 0) - (spool.weight_used || 0) )); // Use live scale for remaining/fill only when scale has a meaningful reading. const minDynamicScaleReading = 10; const useDynamicRemaining = grossWeightFromScale !== null && grossWeightFromScale >= minDynamicScaleReading; const remaining = useDynamicRemaining ? Math.round(Math.max(0, grossWeightFromScale - coreWeight)) : inventoryRemaining; const labelWeight = Math.round(spool.label_weight || 1000); const fillPercent = labelWeight > 0 ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null; const fillColor = fillPercent !== null ? (fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444') : '#808080'; const netWeight = Math.max(0, (spool.label_weight || 0) - (spool.weight_used || 0) ); const calculatedWeight = netWeight + coreWeight; const difference = grossWeightFromScale !== null ? grossWeightFromScale - calculatedWeight : null; const isMatch = difference !== null ? Math.abs(difference) <= 50 : null; // Inventory fallback so gross is always populated across spools. const inventoryDerivedGrossWeight = Math.round(calculatedWeight); const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight; const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null) ? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\u00B0C` : null; const slicerPreset = spool.slicer_filament_name || spool.slicer_filament || null; const note = spool.note?.trim() || null; const kFactorSummary = (kProfiles && kProfiles.length > 0) ? Array.from(new Set(kProfiles.map(kp => kp.k_value.toFixed(3)))).join(', ') : null; const handleSyncWeight = async () => { if (liveScaleWeight === null) return; const roundedLiveWeight = Math.round(Math.max(0, liveScaleWeight)); setSyncing(true); try { await spoolbuddyApi.updateSpoolWeight(spool.id, roundedLiveWeight); setSyncedGrossWeight(roundedLiveWeight); setSynced(true); onSyncWeight?.(); setTimeout(() => setSynced(false), 3000); } catch (e) { console.error('Failed to sync weight:', e); } finally { setSyncing(false); } }; return (
{fillPercent !== null && (
{fillPercent}%
)}

{spool.color_name || 'Unknown color'}

#{spool.id}

{spool.brand} • {spool.material} {spool.subtype && ` ${spool.subtype}`}

{remaining}g / {labelWeight}g

{t('spoolbuddy.spool.remaining', 'Remaining')}

{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')} {resolvedGrossWeight}g
{t('spoolbuddy.spool.coreWeight', 'Core')} {coreWeight}g
{t('spoolbuddy.dashboard.spoolSize', 'Spool size')} {labelWeight}g
{t('spoolbuddy.spool.scaleWeight', 'Scale')} {grossWeightFromScale !== null ? ( {grossWeightFromScale}g {isMatch ? ( ) : ( <> )} ) : ( {'\u2014'} )}
{t('spoolbuddy.dashboard.tagId', 'Tag')} {spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
{nozzleTempRange && (
{t('spoolbuddy.inventory.nozzleTemp', 'Nozzle')} {nozzleTempRange}
)} {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')} {spool.cost_per_kg.toFixed(2)}/kg
)} {kFactorSummary && (
{t('spoolbuddy.inventory.kProfiles', 'K-Profile')} {kFactorSummary}
)} {slicerPreset && (

{t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}

{slicerPreset}

)} {note && (

{t('spoolbuddy.inventory.note', 'Note')}

{note}

)}
{onAssignToAms && ( )} {onUnassignFromAms && ( )} {onClose && ( )}
); }