Browse Source

Spoolbuddy frontend work

maziggy 3 months ago
parent
commit
5cb1ca3ae2

+ 25 - 22
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -1,4 +1,3 @@
-import { useTranslation } from 'react-i18next';
 import type { AMSUnit, AMSTray } from '../../api/client';
 
 function trayColorToCSS(color: string | null): string {
@@ -10,6 +9,18 @@ function isTrayEmpty(tray: AMSTray): boolean {
   return !tray.tray_type || tray.tray_type === '';
 }
 
+function getAmsName(id: number): string {
+  if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
+  if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
+  return `AMS ${id}`;
+}
+
+function formatHumidity(value: number | null): string {
+  if (value === null || value === undefined) return '-';
+  if (value > 5) return `${value}%`;
+  return `Level ${value}`;
+}
+
 interface SpoolSlotProps {
   tray: AMSTray;
   slotIndex: number;
@@ -21,47 +32,47 @@ function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
   const color = trayColorToCSS(tray.tray_color);
 
   return (
-    <div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-green-500' : ''}`}>
+    <div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''}`}>
       {/* Spool visualization */}
       <div className="relative w-14 h-14 mb-1">
         {isEmpty ? (
-          <div className="w-full h-full rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center">
-            <div className="w-3 h-3 rounded-full bg-zinc-700" />
+          <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
+            <div className="w-3 h-3 rounded-full bg-gray-600" />
           </div>
         ) : (
           <svg viewBox="0 0 56 56" className="w-full h-full">
             <circle cx="28" cy="28" r="26" fill={color} />
             <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
             <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
-            <circle cx="28" cy="28" r="8" fill="#27272a" />
-            <circle cx="28" cy="28" r="5" fill="#18181b" />
+            <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
+            <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
           </svg>
         )}
         {isActive && (
-          <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-green-500 rounded-full" />
+          <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-bambu-green rounded-full" />
         )}
       </div>
 
       {/* Material type */}
-      <span className="text-xs text-zinc-400 truncate max-w-full">
+      <span className="text-xs text-white/70 truncate max-w-full">
         {isEmpty ? 'Empty' : tray.tray_type || 'Unknown'}
       </span>
 
       {/* Fill level bar */}
       {!isEmpty && tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 && (
-        <div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-1">
+        <div className="w-full h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden mt-1">
           <div
             className="h-full rounded-full transition-all"
             style={{
               width: `${tray.remain}%`,
-              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 15 ? '#f59e0b' : '#ef4444',
+              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
             }}
           />
         </div>
       )}
 
       {/* Slot number */}
-      <span className="absolute top-1 right-1 text-[10px] text-zinc-600">{slotIndex + 1}</span>
+      <span className="absolute top-1 right-1 text-[10px] text-white/30">{slotIndex + 1}</span>
     </div>
   );
 }
@@ -72,29 +83,21 @@ interface AmsUnitCardProps {
 }
 
 export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
-  const { t } = useTranslation();
   const trays = unit.tray || [];
   const isHt = unit.is_ams_ht;
-
-  const getAmsName = (id: number): string => {
-    if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
-    if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
-    return `AMS ${id}`;
-  };
-
   const slotCount = isHt ? 1 : 4;
 
   return (
-    <div className="bg-zinc-800 rounded-lg p-3">
+    <div className="bg-bambu-dark-secondary rounded-lg p-3">
       {/* Header */}
       <div className="flex items-center justify-between mb-3">
         <span className="text-white font-medium">{getAmsName(unit.id)}</span>
         {unit.humidity !== null && unit.humidity !== undefined && (
-          <div className="flex items-center gap-1 text-xs text-zinc-500">
+          <div className="flex items-center gap-1 text-xs text-white/50">
             <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
               <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
             </svg>
-            <span>{unit.humidity > 5 ? `${unit.humidity}%` : `${t('spoolbuddy.ams.level', 'Level')} ${unit.humidity}`}</span>
+            <span>{formatHumidity(unit.humidity)}</span>
           </div>
         )}
       </div>

+ 144 - 0
frontend/src/components/spoolbuddy/LinkSpoolModal.tsx

@@ -0,0 +1,144 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X } from 'lucide-react';
+import type { InventorySpool } from '../../api/client';
+import { SpoolIcon } from './SpoolIcon';
+
+interface LinkSpoolModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  tagId: string;
+  untaggedSpools: InventorySpool[];
+  onLink: (spool: InventorySpool) => void;
+}
+
+export function LinkSpoolModal({
+  isOpen,
+  onClose,
+  tagId,
+  untaggedSpools,
+  onLink,
+}: LinkSpoolModalProps) {
+  const { t } = useTranslation();
+  const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);
+
+  const handleClose = useCallback(() => {
+    setSelectedSpool(null);
+    onClose();
+  }, [onClose]);
+
+  // Handle escape key
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    if (e.key === 'Escape') {
+      handleClose();
+    }
+  }, [handleClose]);
+
+  useEffect(() => {
+    if (isOpen) {
+      document.addEventListener('keydown', handleKeyDown);
+      document.body.style.overflow = 'hidden';
+    }
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown);
+      document.body.style.overflow = '';
+    };
+  }, [isOpen, handleKeyDown]);
+
+  if (!isOpen) return null;
+
+  const handleConfirm = () => {
+    if (selectedSpool) {
+      onLink(selectedSpool);
+      setSelectedSpool(null);
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in" onClick={handleClose}>
+      <div
+        className="bg-zinc-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 animate-slide-up"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-5 py-4 border-b border-zinc-700">
+          <div>
+            <h2 className="text-base font-semibold text-zinc-100">
+              {t('spoolbuddy.dashboard.linkTagTitle', 'Link Tag to Spool')}
+            </h2>
+            <p className="text-sm text-zinc-500 font-mono">{tagId}</p>
+          </div>
+          <button
+            onClick={handleClose}
+            className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="px-5 py-4 space-y-3 max-h-[400px] overflow-y-auto">
+          <p className="text-sm text-zinc-400">
+            {t('spoolbuddy.dashboard.selectSpool', 'Select a spool to link this tag to:')}
+          </p>
+
+          {untaggedSpools.length === 0 ? (
+            <div className="text-center py-8 text-zinc-500">
+              {t('spoolbuddy.dashboard.noUntagged', 'No spools without tags found')}
+            </div>
+          ) : (
+            <div className="space-y-2">
+              {untaggedSpools.map((spool) => (
+                <button
+                  key={spool.id}
+                  type="button"
+                  onClick={() => setSelectedSpool(spool)}
+                  className={`w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-all text-left ${
+                    selectedSpool?.id === spool.id
+                      ? 'border-green-500 bg-green-500/10'
+                      : 'border-zinc-700 hover:border-green-500/50 hover:bg-zinc-700/50'
+                  }`}
+                >
+                  <SpoolIcon
+                    color={spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080'}
+                    isEmpty={false}
+                    size={40}
+                  />
+                  <div className="flex-1 min-w-0">
+                    <div className="font-medium text-zinc-100 truncate">
+                      {spool.color_name || 'Unknown color'}
+                    </div>
+                    <div className="text-sm text-zinc-400 truncate">
+                      {spool.brand} &bull; {spool.material}
+                      {spool.subtype && ` ${spool.subtype}`}
+                    </div>
+                  </div>
+                  <div className="text-sm font-mono text-zinc-500">
+                    {Math.max(0, spool.label_weight - spool.weight_used)}g
+                  </div>
+                </button>
+              ))}
+            </div>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-end gap-2 px-5 py-4 border-t border-zinc-700">
+          <button
+            onClick={handleClose}
+            className="px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+          >
+            {t('common.cancel', 'Cancel')}
+          </button>
+          <button
+            onClick={handleConfirm}
+            disabled={!selectedSpool}
+            className="px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.dashboard.linkTag', 'Link Tag')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 3 - 3
frontend/src/components/spoolbuddy/SpoolBuddyBottomNav.tsx

@@ -49,7 +49,7 @@ export function SpoolBuddyBottomNav() {
   const { t } = useTranslation();
 
   return (
-    <nav className="h-12 bg-zinc-950 border-t border-zinc-800 flex items-stretch shrink-0">
+    <nav className="h-12 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-stretch shrink-0">
       {navItems.map((item) => (
         <NavLink
           key={item.to}
@@ -58,8 +58,8 @@ export function SpoolBuddyBottomNav() {
           className={({ isActive }) =>
             `flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
               isActive
-                ? 'text-green-500 bg-zinc-900'
-                : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900/50'
+                ? 'text-bambu-green bg-bambu-dark'
+                : 'text-white/50 hover:text-white/70 hover:bg-bambu-dark-tertiary'
             }`
           }
         >

+ 15 - 2
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -2,10 +2,12 @@ import { useState, useEffect } from 'react';
 import { Outlet } from 'react-router-dom';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
+import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 
 export function SpoolBuddyLayout() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
+  const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
   const sbState = useSpoolBuddyState();
 
   // Force dark theme on mount, restore on unmount
@@ -18,8 +20,17 @@ export function SpoolBuddyLayout() {
     };
   }, []);
 
+  // Update alert based on device state
+  useEffect(() => {
+    if (!sbState.deviceOnline) {
+      setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
+    } else {
+      setAlert(null);
+    }
+  }, [sbState.deviceOnline]);
+
   return (
-    <div className="w-screen h-screen bg-zinc-900 text-zinc-100 flex flex-col overflow-hidden">
+    <div className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden">
       <SpoolBuddyTopBar
         selectedPrinterId={selectedPrinterId}
         onPrinterChange={setSelectedPrinterId}
@@ -27,9 +38,10 @@ export function SpoolBuddyLayout() {
       />
 
       <main className="flex-1 overflow-y-auto">
-        <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState }} />
+        <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState, setAlert }} />
       </main>
 
+      <SpoolBuddyStatusBar alert={alert} />
       <SpoolBuddyBottomNav />
     </div>
   );
@@ -40,4 +52,5 @@ export interface SpoolBuddyOutletContext {
   selectedPrinterId: number | null;
   setSelectedPrinterId: (id: number) => void;
   sbState: ReturnType<typeof useSpoolBuddyState>;
+  setAlert: (alert: { type: 'warning' | 'error' | 'info'; message: string } | null) => void;
 }

+ 46 - 0
frontend/src/components/spoolbuddy/SpoolBuddyStatusBar.tsx

@@ -0,0 +1,46 @@
+import { useTranslation } from 'react-i18next';
+
+interface Alert {
+  type: 'warning' | 'error' | 'info';
+  message: string;
+}
+
+interface SpoolBuddyStatusBarProps {
+  alert?: Alert | null;
+}
+
+export function SpoolBuddyStatusBar({ alert }: SpoolBuddyStatusBarProps) {
+  const { t } = useTranslation();
+
+  const statusColor = !alert
+    ? 'bg-bambu-green'
+    : alert.type === 'error'
+    ? 'bg-red-500'
+    : alert.type === 'warning'
+    ? 'bg-amber-500'
+    : 'bg-bambu-green';
+
+  const borderColor = !alert
+    ? 'border-bambu-dark-tertiary'
+    : alert.type === 'error'
+    ? 'border-red-500'
+    : alert.type === 'warning'
+    ? 'border-amber-500'
+    : 'border-bambu-dark-tertiary';
+
+  return (
+    <div className={`h-8 bg-bambu-dark-secondary border-t-2 ${borderColor} flex items-center px-3 gap-3 shrink-0`}>
+      {/* Status LED */}
+      <div className={`w-3 h-3 rounded-full ${statusColor} animate-pulse`} />
+
+      {/* Status message */}
+      <div className="flex-1 text-sm text-white/50 truncate">
+        {alert ? (
+          <span>{alert.message}</span>
+        ) : (
+          <span className="text-bambu-green">{t('spoolbuddy.status.systemReady', 'System Ready')}</span>
+        )}
+      </div>
+    </div>
+  );
+}

+ 28 - 10
frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect } from 'react';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
+import { WifiOff } from 'lucide-react';
 import { api, type Printer } from '../../api/client';
 
 interface SpoolBuddyTopBarProps {
@@ -25,9 +26,9 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     }
   }, [printers, selectedPrinterId, onPrinterChange]);
 
-  // Clock
+  // Clock - update every second for kiosk display
   useEffect(() => {
-    const timer = setInterval(() => setCurrentTime(new Date()), 30000);
+    const timer = setInterval(() => setCurrentTime(new Date()), 1000);
     return () => clearInterval(timer);
   }, []);
 
@@ -35,15 +36,15 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
 
   return (
-    <div className="h-11 bg-zinc-950 border-b border-zinc-800 flex items-center px-3 gap-4 shrink-0">
+    <div className="h-11 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
       {/* Logo */}
       <div className="flex items-center gap-2 shrink-0">
-        <div className="w-6 h-6 rounded bg-green-600 flex items-center justify-center">
+        <div className="w-6 h-6 rounded bg-bambu-green flex items-center justify-center">
           <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
             <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
           </svg>
         </div>
-        <span className="text-white font-semibold text-sm hidden sm:inline">SpoolBuddy</span>
+        <span className="text-white font-semibold text-sm">SpoolBuddy</span>
       </div>
 
       {/* Printer selector - centered */}
@@ -51,7 +52,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
         <select
           value={selectedPrinterId ?? ''}
           onChange={(e) => onPrinterChange(Number(e.target.value))}
-          className="bg-zinc-800 text-white text-sm px-3 py-1.5 rounded border border-zinc-700 focus:outline-none focus:border-green-500 min-w-[150px]"
+          className="bg-bambu-dark text-white text-sm px-3 py-1.5 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[150px]"
         >
           {printers.length === 0 ? (
             <option value="">{t('spoolbuddy.status.noPrinters', 'No printers')}</option>
@@ -67,14 +68,31 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
 
       {/* Right side indicators */}
       <div className="flex items-center gap-3 shrink-0">
+        {/* WiFi signal bars */}
+        <div className="flex items-center" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
+          {deviceOnline ? (
+            <div className="flex items-end gap-0.5 h-4">
+              {[1, 2, 3, 4].map((level) => (
+                <div
+                  key={level}
+                  className={`w-1 rounded-sm ${level <= 4 ? 'bg-white' : 'bg-bambu-dark-tertiary'}`}
+                  style={{ height: `${level * 4}px` }}
+                />
+              ))}
+            </div>
+          ) : (
+            <WifiOff className="w-5 h-5 text-red-400" />
+          )}
+        </div>
+
         {/* Device LED */}
-        <div className="flex items-center gap-1.5" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
-          <div className={`w-2.5 h-2.5 rounded-full ${deviceOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-zinc-600'}`} />
-          <span className="text-xs text-zinc-400">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
+        <div className="flex items-center gap-1.5">
+          <div className={`w-2.5 h-2.5 rounded-full ${deviceOnline ? 'bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-bambu-gray'}`} />
+          <span className="text-xs text-white/50">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
         </div>
 
         {/* Clock */}
-        <span className="text-zinc-400 text-sm font-mono min-w-[50px] text-right">
+        <span className="text-white/50 text-sm font-mono min-w-[50px] text-right">
           {formatTime(currentTime)}
         </span>
       </div>

+ 27 - 0
frontend/src/components/spoolbuddy/SpoolIcon.tsx

@@ -0,0 +1,27 @@
+interface SpoolIconProps {
+  color: string;
+  isEmpty: boolean;
+  size?: number;
+}
+
+export function SpoolIcon({ color, isEmpty, size = 32 }: SpoolIconProps) {
+  if (isEmpty) {
+    return (
+      <div
+        className="rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center"
+        style={{ width: size, height: size }}
+      >
+        <div className="w-2 h-2 rounded-full bg-zinc-600" />
+      </div>
+    );
+  }
+
+  return (
+    <svg width={size} height={size} viewBox="0 0 32 32">
+      {/* Outer ring with white stroke for visibility */}
+      <circle cx="16" cy="16" r="14" fill={color} stroke="white" strokeWidth="1.5" strokeOpacity="0.7" />
+      {/* Inner shadow/depth */}
+      <circle cx="16" cy="16" r="11" fill={color} style={{ filter: 'brightness(0.85)' }} />
+    </svg>
+  );
+}

+ 188 - 89
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -1,23 +1,69 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
+import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
 import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
 import { spoolbuddyApi } from '../../api/client';
+import { SpoolIcon } from './SpoolIcon';
+
+// Storage key for default core weight
+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; // Default 250g (typical Bambu spool core)
+}
 
 interface SpoolInfoCardProps {
   spool: MatchedSpool;
   scaleWeight: number | null;
   weightStable: boolean;
   onClose?: () => void;
+  onSyncWeight?: () => void;
 }
 
-export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: SpoolInfoCardProps) {
+export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyncWeight }: SpoolInfoCardProps) {
   const { t } = useTranslation();
   const [syncing, setSyncing] = useState(false);
   const [synced, setSynced] = useState(false);
 
-  const remaining = Math.max(0, spool.label_weight - spool.weight_used);
-  const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
-  const netWeight = scaleWeight !== null ? Math.max(0, scaleWeight - spool.core_weight) : null;
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  // Use spool's core_weight if set, otherwise fall back to default
+  const coreWeight = (spool.core_weight && spool.core_weight > 0)
+    ? spool.core_weight
+    : getDefaultCoreWeight();
+
+  // Gross weight from scale (live) or fallback
+  const grossWeight = scaleWeight !== null
+    ? Math.round(Math.max(0, scaleWeight))
+    : null;
+
+  // Remaining filament = gross - core
+  const remaining = grossWeight !== null
+    ? Math.round(Math.max(0, grossWeight - coreWeight))
+    : null;
+
+  const labelWeight = Math.round(spool.label_weight || 1000);
+  const fillPercent = remaining !== null ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
+  const fillColor = fillPercent !== null
+    ? fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444'
+    : '#808080';
+
+  // Weight comparison (scale vs calculated expected)
+  const netWeight = Math.max(0,
+    (spool.label_weight || 0) - (spool.weight_used || 0)
+  );
+  const calculatedWeight = netWeight + coreWeight;
+  const difference = grossWeight !== null ? grossWeight - calculatedWeight : null;
+  const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
 
   const handleSyncWeight = async () => {
     if (scaleWeight === null || !weightStable) return;
@@ -25,6 +71,7 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: Spo
     try {
       await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
       setSynced(true);
+      onSyncWeight?.();
       setTimeout(() => setSynced(false), 3000);
     } catch (e) {
       console.error('Failed to sync weight:', e);
@@ -33,89 +80,123 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: Spo
     }
   };
 
-  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
-
   return (
-    <div className="bg-zinc-800 rounded-xl p-4 relative">
-      {/* Close button */}
-      {onClose && (
-        <button
-          onClick={onClose}
-          className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
-        >
-          <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
-          </svg>
-        </button>
-      )}
-
-      {/* Header: color swatch + material info */}
-      <div className="flex items-center gap-3 mb-4">
-        <div
-          className="w-10 h-10 rounded-full border-2 border-zinc-700 shrink-0"
-          style={{ backgroundColor: colorHex }}
-        />
-        <div className="min-w-0">
-          <div className="text-base font-medium text-zinc-100 truncate">
-            {spool.material}
-            {spool.color_name && <span className="text-zinc-400 ml-1.5">- {spool.color_name}</span>}
-          </div>
-          {spool.brand && (
-            <div className="text-sm text-zinc-400 truncate">{spool.brand}</div>
+    <div className="flex flex-col items-center space-y-4 max-w-md">
+      {/* Top section: Spool icon + main info */}
+      <div className="flex items-start gap-5">
+        {/* Spool visualization */}
+        <div className="relative shrink-0">
+          <SpoolIcon color={colorHex} isEmpty={false} size={100} />
+          {fillPercent !== null && (
+            <div
+              className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
+              style={{ backgroundColor: fillColor }}
+            >
+              {fillPercent}%
+            </div>
           )}
         </div>
-      </div>
 
-      {/* Remaining weight bar */}
-      <div className="mb-4">
-        <div className="flex justify-between text-xs text-zinc-400 mb-1">
-          <span>{t('spoolbuddy.spool.remaining', 'Remaining')}</span>
-          <span>{Math.round(remaining)}g / {spool.label_weight}g</span>
-        </div>
-        <div className="w-full h-2 bg-zinc-700 rounded-full overflow-hidden">
-          <div
-            className="h-full rounded-full transition-all"
-            style={{
-              width: `${Math.min(100, remainingPct)}%`,
-              backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
-            }}
-          />
+        {/* Main info */}
+        <div className="flex-1 min-w-0 pt-1">
+          <h3 className="text-lg font-semibold text-zinc-100">
+            {spool.color_name || 'Unknown color'}
+          </h3>
+          <p className="text-sm text-zinc-400">
+            {spool.brand} &bull; {spool.material}
+            {spool.subtype && ` ${spool.subtype}`}
+          </p>
+
+          {/* Filament remaining - big number */}
+          {remaining !== null && (
+            <div className="mt-3">
+              <div className="flex items-baseline gap-2">
+                <span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
+                <span className="text-sm text-zinc-500">/ {labelWeight}g</span>
+              </div>
+              <p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
+
+              {/* Fill bar */}
+              <div className="mt-2 max-w-xs">
+                <div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
+                  <div
+                    className="h-full rounded-full transition-all duration-500"
+                    style={{ width: `${fillPercent}%`, backgroundColor: fillColor }}
+                  />
+                </div>
+              </div>
+            </div>
+          )}
         </div>
       </div>
 
-      {/* Weight details grid */}
-      <div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-4">
+      {/* Details grid */}
+      <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-3 w-full">
         <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.spool.labelWeight', 'Label')}</span>
-          <span className="text-zinc-300">{spool.label_weight}g</span>
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
+          <span className="font-mono text-zinc-300">{grossWeight !== null ? `${grossWeight}g` : '\u2014'}</span>
         </div>
         <div className="flex justify-between">
           <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
-          <span className="text-zinc-300">{spool.core_weight}g</span>
+          <span className="font-mono text-zinc-300">{coreWeight}g</span>
         </div>
         <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
+          <span className="font-mono text-zinc-300">{labelWeight}g</span>
+        </div>
+        <div className="flex justify-between items-center">
           <span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
-          <span className="text-zinc-300">{scaleWeight !== null ? `${scaleWeight.toFixed(1)}g` : '--'}</span>
+          {grossWeight !== null ? (
+            <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
+              {grossWeight}g
+              {isMatch ? (
+                <Check className="w-3 h-3" />
+              ) : (
+                <>
+                  <AlertTriangle className="w-3 h-3" />
+                  <button
+                    onClick={handleSyncWeight}
+                    className="p-1 hover:bg-green-500/20 rounded transition-colors text-green-500"
+                    title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
+                  >
+                    <RefreshCw className="w-3.5 h-3.5" />
+                  </button>
+                </>
+              )}
+            </span>
+          ) : (
+            <span className="text-zinc-500">{'\u2014'}</span>
+          )}
         </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.spool.netWeight', 'Net')}</span>
-          <span className="text-zinc-300">{netWeight !== null ? `${netWeight.toFixed(1)}g` : '--'}</span>
+        <div className="flex justify-between items-center">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
+          <span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
+            {spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
+          </span>
         </div>
       </div>
 
-      {/* Actions */}
-      <div className="flex gap-2">
+      {/* Action buttons */}
+      <div className="flex gap-2 justify-center">
         <button
           onClick={handleSyncWeight}
           disabled={!weightStable || scaleWeight === null || syncing}
-          className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
+          className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
             synced
               ? 'bg-green-600/20 text-green-400'
               : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
           }`}
         >
-          {syncing ? t('common.saving', 'Saving...') : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
+          {syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
         </button>
+        {onClose && (
+          <button
+            onClick={onClose}
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.dashboard.close', 'Close')}
+          </button>
+        )}
       </div>
     </div>
   );
@@ -123,44 +204,62 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: Spo
 
 interface UnknownTagCardProps {
   tagUid: string;
+  scaleWeight: number | null;
+  coreWeight?: number;
   onLinkSpool?: () => void;
   onClose?: () => void;
 }
 
-export function UnknownTagCard({ tagUid, onLinkSpool, onClose }: UnknownTagCardProps) {
+export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onClose }: UnknownTagCardProps) {
   const { t } = useTranslation();
+  const defaultCoreWeight = coreWeight ?? getDefaultCoreWeight();
+  const grossWeight = scaleWeight !== null
+    ? Math.round(Math.max(0, scaleWeight))
+    : null;
+  const estimatedRemaining = grossWeight !== null
+    ? Math.round(Math.max(0, grossWeight - defaultCoreWeight))
+    : null;
 
   return (
-    <div className="bg-zinc-800 rounded-xl p-4 relative">
-      {onClose && (
-        <button
-          onClick={onClose}
-          className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
-        >
-          <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
-          </svg>
-        </button>
-      )}
-
-      <div className="flex items-center gap-3 mb-4">
-        <div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0">
-          <svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
-          </svg>
-        </div>
-        <div>
-          <div className="text-base font-medium text-zinc-100">{t('spoolbuddy.dashboard.unknownTag', 'Unknown Tag')}</div>
-          <div className="text-xs text-zinc-500 font-mono">{tagUid}</div>
+    <div className="flex flex-col items-center text-center space-y-5">
+      <div className="w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center">
+        <svg className="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
+        </svg>
+      </div>
+      <div>
+        <h3 className="text-lg font-semibold text-zinc-100">{t('spoolbuddy.dashboard.newTag', 'New Tag Detected')}</h3>
+        <p className="text-sm text-zinc-500 font-mono mt-1">{tagUid}</p>
+      </div>
+      {grossWeight !== null && (
+        <div className="text-sm text-zinc-400">
+          <span className="font-mono font-semibold">{grossWeight}g</span> {t('spoolbuddy.dashboard.onScale', 'on scale')}
+          {estimatedRemaining !== null && estimatedRemaining > 0 && (
+            <span className="text-zinc-500"> &bull; ~{estimatedRemaining}g filament</span>
+          )}
         </div>
+      )}
+      <div className="flex flex-wrap gap-2 justify-center">
+        {onLinkSpool && (
+          <button
+            onClick={onLinkSpool}
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+          >
+            <svg className="w-4 h-4 inline-block mr-1.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
+            </svg>
+            {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
+          </button>
+        )}
+        {onClose && (
+          <button
+            onClick={onClose}
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.dashboard.close', 'Close')}
+          </button>
+        )}
       </div>
-
-      <button
-        onClick={onLinkSpool}
-        className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-amber-600 text-white hover:bg-amber-700 transition-colors min-h-[44px]"
-      >
-        {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
-      </button>
     </div>
   );
 }

+ 2 - 0
frontend/src/hooks/useSpoolBuddyState.ts

@@ -4,6 +4,7 @@ export interface MatchedSpool {
   id: number;
   tag_uid: string;
   material: string;
+  subtype: string | null;
   color_name: string | null;
   rgba: string | null;
   brand: string | null;
@@ -114,6 +115,7 @@ export function useSpoolBuddyState() {
           id: spool.id,
           tag_uid: detail.tag_uid ?? detail.data?.tag_uid ?? '',
           material: spool.material ?? '',
+          subtype: spool.subtype ?? null,
           color_name: spool.color_name ?? null,
           rgba: spool.rgba ?? null,
           brand: spool.brand ?? null,

+ 16 - 0
frontend/src/i18n/locales/de.ts

@@ -3542,15 +3542,30 @@ export default {
       noPrinters: 'Keine Drucker',
       deviceOffline: 'Gerät offline',
       waitingConnection: 'Warte auf Geräteverbindung...',
+      systemReady: 'System bereit',
       status: 'Status',
     },
     dashboard: {
       readyToScan: 'Bereit zum Scannen',
       idleMessage: 'Spule auf die Waage legen zum Identifizieren',
+      nfcHint: 'NFC-Tag wird automatisch gelesen',
+      device: 'Gerät',
       syncWeight: 'Gewicht sync.',
       weightSynced: 'Synchronisiert!',
       unknownTag: 'Unbekannter Tag',
+      newTag: 'Neuer Tag erkannt',
+      onScale: 'auf der Waage',
       linkSpool: 'Mit Spule verknüpfen',
+      linkTagTitle: 'Tag mit Spule verknüpfen',
+      linkTag: 'Tag verknüpfen',
+      selectSpool: 'Spule zum Verknüpfen auswählen:',
+      noUntagged: 'Keine Spulen ohne Tags gefunden',
+      tagDetected: 'Tag erkannt',
+      noTag: 'Kein Tag',
+      tagId: 'Tag',
+      grossWeight: 'Bruttogewicht',
+      spoolSize: 'Spulengröße',
+      close: 'Schließen',
     },
     weight: {
       noReading: 'Kein Messwert',
@@ -3575,6 +3590,7 @@ export default {
       connectAms: 'AMS anschließen um Filament-Slots zu sehen',
       noPrinter: 'Kein Drucker ausgewählt',
       selectPrinter: 'Drucker in der oberen Leiste auswählen',
+      printerDisconnected: 'Drucker getrennt',
       humidity: 'Feuchtigkeit',
       level: 'Stufe',
       active: 'Aktiv',

+ 16 - 0
frontend/src/i18n/locales/en.ts

@@ -3547,15 +3547,30 @@ export default {
       noPrinters: 'No printers',
       deviceOffline: 'Device Offline',
       waitingConnection: 'Waiting for device connection...',
+      systemReady: 'System Ready',
       status: 'Status',
     },
     dashboard: {
       readyToScan: 'Ready to scan',
       idleMessage: 'Place a spool on the scale to identify it',
+      nfcHint: 'NFC tag will be read automatically',
+      device: 'Device',
       syncWeight: 'Sync Weight',
       weightSynced: 'Synced!',
       unknownTag: 'Unknown Tag',
+      newTag: 'New Tag Detected',
+      onScale: 'on scale',
       linkSpool: 'Link to Spool',
+      linkTagTitle: 'Link Tag to Spool',
+      linkTag: 'Link Tag',
+      selectSpool: 'Select a spool to link this tag to:',
+      noUntagged: 'No spools without tags found',
+      tagDetected: 'Tag detected',
+      noTag: 'No tag',
+      tagId: 'Tag',
+      grossWeight: 'Gross weight',
+      spoolSize: 'Spool size',
+      close: 'Close',
     },
     weight: {
       noReading: 'No reading',
@@ -3580,6 +3595,7 @@ export default {
       connectAms: 'Connect an AMS to see filament slots',
       noPrinter: 'No printer selected',
       selectPrinter: 'Select a printer from the top bar',
+      printerDisconnected: 'Printer disconnected',
       humidity: 'Humidity',
       level: 'Level',
       active: 'Active',

+ 16 - 0
frontend/src/i18n/locales/fr.ts

@@ -3510,15 +3510,30 @@ export default {
       noPrinters: 'Aucune imprimante',
       deviceOffline: 'Appareil hors ligne',
       waitingConnection: 'En attente de connexion...',
+      systemReady: 'Système prêt',
       status: 'Statut',
     },
     dashboard: {
       readyToScan: 'Prêt à scanner',
       idleMessage: 'Placez une bobine sur la balance pour l\'identifier',
+      nfcHint: 'Le tag NFC sera lu automatiquement',
+      device: 'Appareil',
       syncWeight: 'Sync. poids',
       weightSynced: 'Synchronisé !',
       unknownTag: 'Tag inconnu',
+      newTag: 'Nouveau tag détecté',
+      onScale: 'sur la balance',
       linkSpool: 'Lier à une bobine',
+      linkTagTitle: 'Lier le tag à une bobine',
+      linkTag: 'Lier le tag',
+      selectSpool: 'Sélectionnez une bobine à lier à ce tag :',
+      noUntagged: 'Aucune bobine sans tag trouvée',
+      tagDetected: 'Tag détecté',
+      noTag: 'Pas de tag',
+      tagId: 'Tag',
+      grossWeight: 'Poids brut',
+      spoolSize: 'Taille bobine',
+      close: 'Fermer',
     },
     weight: {
       noReading: 'Pas de lecture',
@@ -3543,6 +3558,7 @@ export default {
       connectAms: 'Connectez un AMS pour voir les slots',
       noPrinter: 'Aucune imprimante sélectionnée',
       selectPrinter: 'Sélectionnez une imprimante dans la barre supérieure',
+      printerDisconnected: 'Imprimante déconnectée',
       humidity: 'Humidité',
       level: 'Niveau',
       active: 'Actif',

+ 16 - 0
frontend/src/i18n/locales/it.ts

@@ -2898,15 +2898,30 @@ export default {
       noPrinters: 'Nessuna stampante',
       deviceOffline: 'Dispositivo offline',
       waitingConnection: 'In attesa della connessione...',
+      systemReady: 'Sistema pronto',
       status: 'Stato',
     },
     dashboard: {
       readyToScan: 'Pronto per la scansione',
       idleMessage: 'Posiziona una bobina sulla bilancia per identificarla',
+      nfcHint: 'Il tag NFC verrà letto automaticamente',
+      device: 'Dispositivo',
       syncWeight: 'Sincronizza peso',
       weightSynced: 'Sincronizzato!',
       unknownTag: 'Tag sconosciuto',
+      newTag: 'Nuovo tag rilevato',
+      onScale: 'sulla bilancia',
       linkSpool: 'Collega a bobina',
+      linkTagTitle: 'Collega tag a bobina',
+      linkTag: 'Collega tag',
+      selectSpool: 'Seleziona una bobina da collegare a questo tag:',
+      noUntagged: 'Nessuna bobina senza tag trovata',
+      tagDetected: 'Tag rilevato',
+      noTag: 'Nessun tag',
+      tagId: 'Tag',
+      grossWeight: 'Peso lordo',
+      spoolSize: 'Dimensione bobina',
+      close: 'Chiudi',
     },
     weight: {
       noReading: 'Nessuna lettura',
@@ -2931,6 +2946,7 @@ export default {
       connectAms: 'Collega un AMS per vedere gli slot',
       noPrinter: 'Nessuna stampante selezionata',
       selectPrinter: 'Seleziona una stampante dalla barra superiore',
+      printerDisconnected: 'Stampante disconnessa',
       humidity: 'Umidità',
       level: 'Livello',
       active: 'Attivo',

+ 16 - 0
frontend/src/i18n/locales/ja.ts

@@ -3376,15 +3376,30 @@ export default {
       noPrinters: 'プリンターなし',
       deviceOffline: 'デバイスオフライン',
       waitingConnection: 'デバイス接続を待っています...',
+      systemReady: 'システム準備完了',
       status: 'ステータス',
     },
     dashboard: {
       readyToScan: 'スキャン準備完了',
       idleMessage: 'スプールを計量台に置いて識別します',
+      nfcHint: 'NFCタグは自動的に読み取られます',
+      device: 'デバイス',
       syncWeight: '重量同期',
       weightSynced: '同期完了!',
       unknownTag: '不明なタグ',
+      newTag: '新しいタグを検出',
+      onScale: '計量中',
       linkSpool: 'スプールにリンク',
+      linkTagTitle: 'タグをスプールにリンク',
+      linkTag: 'タグをリンク',
+      selectSpool: 'このタグにリンクするスプールを選択:',
+      noUntagged: 'タグなしのスプールが見つかりません',
+      tagDetected: 'タグ検出',
+      noTag: 'タグなし',
+      tagId: 'タグ',
+      grossWeight: '総重量',
+      spoolSize: 'スプールサイズ',
+      close: '閉じる',
     },
     weight: {
       noReading: '読み取りなし',
@@ -3409,6 +3424,7 @@ export default {
       connectAms: 'AMSを接続してスロットを表示',
       noPrinter: 'プリンター未選択',
       selectPrinter: '上部バーからプリンターを選択',
+      printerDisconnected: 'プリンター切断',
       humidity: '湿度',
       level: 'レベル',
       active: 'アクティブ',

+ 16 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3508,15 +3508,30 @@ export default {
       noPrinters: 'Sem impressoras',
       deviceOffline: 'Dispositivo offline',
       waitingConnection: 'Aguardando conexão do dispositivo...',
+      systemReady: 'Sistema pronto',
       status: 'Status',
     },
     dashboard: {
       readyToScan: 'Pronto para escanear',
       idleMessage: 'Coloque um carretel na balança para identificá-lo',
+      nfcHint: 'A tag NFC será lida automaticamente',
+      device: 'Dispositivo',
       syncWeight: 'Sincronizar peso',
       weightSynced: 'Sincronizado!',
       unknownTag: 'Tag desconhecida',
+      newTag: 'Nova tag detectada',
+      onScale: 'na balança',
       linkSpool: 'Vincular ao carretel',
+      linkTagTitle: 'Vincular tag ao carretel',
+      linkTag: 'Vincular tag',
+      selectSpool: 'Selecione um carretel para vincular a esta tag:',
+      noUntagged: 'Nenhum carretel sem tag encontrado',
+      tagDetected: 'Tag detectada',
+      noTag: 'Sem tag',
+      tagId: 'Tag',
+      grossWeight: 'Peso bruto',
+      spoolSize: 'Tamanho do carretel',
+      close: 'Fechar',
     },
     weight: {
       noReading: 'Sem leitura',
@@ -3541,6 +3556,7 @@ export default {
       connectAms: 'Conecte um AMS para ver os slots',
       noPrinter: 'Nenhuma impressora selecionada',
       selectPrinter: 'Selecione uma impressora na barra superior',
+      printerDisconnected: 'Impressora desconectada',
       humidity: 'Umidade',
       level: 'Nível',
       active: 'Ativo',

+ 53 - 19
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -1,20 +1,32 @@
+import { useEffect, useMemo } from 'react';
 import { useOutletContext } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
+import { Layers } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
+import { api } from '../../api/client';
 import type { PrinterStatus } from '../../api/client';
 import { AmsUnitCard } from '../../components/spoolbuddy/AmsUnitCard';
 
+function getAmsName(amsId: number): string {
+  if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
+  if (amsId >= 128 && amsId <= 135) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`;
+  return `AMS ${amsId}`;
+}
+
 export function SpoolBuddyAmsPage() {
-  const { selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
+  const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
 
   const { data: status } = useQuery<PrinterStatus>({
     queryKey: ['printerStatus', selectedPrinterId],
+    queryFn: () => api.getPrinterStatus(selectedPrinterId!),
     enabled: selectedPrinterId !== null,
+    staleTime: 30 * 1000,
   });
 
-  const amsUnits = status?.ams ?? [];
+  const isConnected = status?.connected ?? false;
+  const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
   const trayNow = status?.tray_now ?? 255;
 
   const getActiveSlotForAms = (amsId: number): number | null => {
@@ -23,38 +35,60 @@ export function SpoolBuddyAmsPage() {
       const activeAmsId = Math.floor(trayNow / 4);
       if (activeAmsId === amsId) return trayNow % 4;
     }
+    // AMS-HT: tray_now 16-23 maps to AMS-HT 128-135
+    if (amsId >= 128 && amsId <= 135) {
+      const htIndex = amsId - 128;
+      if (trayNow === 16 + htIndex) return 0;
+    }
     return null;
   };
 
+  // Set alert for low filament in status bar
+  useEffect(() => {
+    if (!isConnected && selectedPrinterId) {
+      setAlert({ type: 'warning', message: t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected') });
+      return;
+    }
+    for (const unit of amsUnits) {
+      for (const tray of unit.tray || []) {
+        if (tray.remain !== null && tray.remain >= 0 && tray.remain < 15 && tray.tray_type) {
+          setAlert({
+            type: 'warning',
+            message: `Low Filament: ${tray.tray_type} (${getAmsName(unit.id)}) - ${tray.remain}% remaining`,
+          });
+          return;
+        }
+      }
+    }
+    setAlert(null);
+  }, [amsUnits, isConnected, selectedPrinterId, setAlert, t]);
+
   return (
     <div className="h-full flex flex-col p-4">
-      <h1 className="text-lg font-semibold text-zinc-100 mb-4">
-        {t('spoolbuddy.nav.ams', 'AMS')}
-      </h1>
-
       <div className="flex-1 min-h-0 overflow-y-auto">
         {!selectedPrinterId ? (
-          <div className="flex items-center justify-center h-full">
-            <div className="text-center text-zinc-500">
-              <svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
-              </svg>
-              <p className="text-lg mb-1">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>
+          <div className="flex-1 flex items-center justify-center h-full">
+            <div className="text-center text-white/50">
+              <p className="text-lg mb-2">{t('spoolbuddy.ams.noPrinter', 'No printer selected')}</p>
               <p className="text-sm">{t('spoolbuddy.ams.selectPrinter', 'Select a printer from the top bar')}</p>
             </div>
           </div>
+        ) : !isConnected ? (
+          <div className="flex-1 flex items-center justify-center h-full">
+            <div className="text-center text-white/50">
+              <p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
+            </div>
+          </div>
         ) : amsUnits.length === 0 ? (
-          <div className="flex items-center justify-center h-full">
-            <div className="text-center text-zinc-500">
-              <svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
-              </svg>
-              <p className="text-lg mb-1">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
+          <div className="flex-1 flex items-center justify-center h-full">
+            <div className="text-center text-white/50">
+              <Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
+              <p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
               <p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
             </div>
           </div>
         ) : (
-          <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
             {amsUnits.map((unit) => (
               <AmsUnitCard
                 key={unit.id}

+ 270 - 81
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -1,18 +1,21 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
 import { useOutletContext } from 'react-router-dom';
-import { useNavigate } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
-import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
+import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
+import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
 import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
+import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
 
-// Color palette for idle animation
+// Color palette for the cycling spool animation
 const SPOOL_COLORS = [
   '#00AE42', '#FF6B35', '#3B82F6', '#EF4444', '#A855F7',
   '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
 ];
 
-function IdleState() {
+// --- Idle state with color-cycling spool and NFC waves ---
+function ColorCyclingSpool() {
   const { t } = useTranslation();
   const [colorIndex, setColorIndex] = useState(0);
 
@@ -23,151 +26,337 @@ function IdleState() {
     return () => clearInterval(interval);
   }, []);
 
-  const color = SPOOL_COLORS[colorIndex];
+  const currentColor = SPOOL_COLORS[colorIndex];
 
   return (
-    <div className="flex flex-col items-center justify-center h-full text-center px-4">
+    <div className="flex flex-col items-center text-center">
       {/* Animated spool with NFC waves */}
-      <div className="relative mb-6 flex items-center justify-center" style={{ width: 140, height: 140 }}>
+      <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
         {/* NFC wave rings */}
-        <div className="absolute w-20 h-20 rounded-full border-2 border-green-500/30 animate-ping" style={{ animationDuration: '2.5s' }} />
-        <div className="absolute w-28 h-28 rounded-full border border-green-500/20 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.4s' }} />
-        <div className="absolute w-36 h-36 rounded-full border border-green-500/10 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.8s' }} />
+        <div className="absolute w-24 h-24 rounded-full border-2 border-green-500/30 animate-ping" style={{ animationDuration: '2.5s' }} />
+        <div className="absolute w-32 h-32 rounded-full border border-green-500/20 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.4s' }} />
+        <div className="absolute w-40 h-40 rounded-full border border-green-500/10 animate-ping" style={{ animationDuration: '2.5s', animationDelay: '0.8s' }} />
 
-        {/* Spool circle */}
+        {/* Spool icon with color transition and glow */}
         <div className="relative">
           <div
             className="absolute -inset-4 rounded-full blur-2xl opacity-30 transition-colors duration-1000"
-            style={{ backgroundColor: color }}
+            style={{ backgroundColor: currentColor }}
           />
-          <svg viewBox="0 0 80 80" className="w-20 h-20 transition-all duration-1000">
-            <circle cx="40" cy="40" r="38" fill={color} />
-            <circle cx="40" cy="40" r="30" fill={color} style={{ filter: 'brightness(0.85)' }} />
-            <ellipse cx="30" cy="30" rx="8" ry="5" fill="white" opacity="0.3" />
-            <circle cx="40" cy="40" r="12" fill="#27272a" />
-            <circle cx="40" cy="40" r="7" fill="#18181b" />
-          </svg>
+          <div className="transition-all duration-1000">
+            <SpoolIcon color={currentColor} isEmpty={false} size={100} />
+          </div>
         </div>
       </div>
 
-      <p className="text-lg text-zinc-300 mb-1">{t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}</p>
-      <p className="text-sm text-zinc-500">{t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}</p>
+      {/* Text content */}
+      <div className="space-y-2">
+        <p className="text-lg font-medium text-zinc-300">
+          {t('spoolbuddy.dashboard.readyToScan', 'Ready to scan')}
+        </p>
+        <p className="text-sm text-zinc-500">
+          {t('spoolbuddy.dashboard.idleMessage', 'Place a spool on the scale to identify it')}
+        </p>
+      </div>
+
+      {/* Subtle hint */}
+      <div className="mt-6 flex items-center gap-2 text-xs text-zinc-600">
+        <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+        </svg>
+        <span>{t('spoolbuddy.dashboard.nfcHint', 'NFC tag will be read automatically')}</span>
+      </div>
     </div>
   );
 }
 
+// --- Offline state ---
 function DeviceOfflineState() {
   const { t } = useTranslation();
 
   return (
-    <div className="flex flex-col items-center justify-center h-full text-center px-4">
-      <div className="w-20 h-20 rounded-full bg-zinc-800 flex items-center justify-center mb-6">
-        <svg className="w-10 h-10 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728" />
+    <div className="flex flex-col items-center text-center">
+      {/* Offline icon */}
+      <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
+        <div className="w-24 h-24 rounded-full bg-zinc-800 flex items-center justify-center">
+          <svg className="w-12 h-12 text-zinc-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M18.364 5.636a9 9 0 010 12.728m0 0l-12.728-12.728m12.728 12.728L5.636 5.636m12.728 0a9 9 0 00-12.728 0m0 12.728a9 9 0 010-12.728" />
+          </svg>
+        </div>
+      </div>
+
+      <div className="space-y-2">
+        <p className="text-lg font-medium text-zinc-500">
+          {t('spoolbuddy.status.deviceOffline', 'Device Offline')}
+        </p>
+        <p className="text-sm text-zinc-600">
+          {t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}
+        </p>
+      </div>
+
+      <div className="mt-6 flex items-center gap-2 text-xs text-zinc-600">
+        <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
         </svg>
+        <span>{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</span>
       </div>
-      <p className="text-lg text-zinc-400 mb-1">{t('spoolbuddy.status.deviceOffline', 'Device Offline')}</p>
-      <p className="text-sm text-zinc-600">{t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}</p>
     </div>
   );
 }
 
+// --- Main Dashboard ---
 export function SpoolBuddyDashboard() {
   const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
-  const navigate = useNavigate();
   const { t } = useTranslation();
 
-  // Persist the displayed card (tag stays until user dismisses or new tag)
+  // Fetch spools for stats, tag lookup, and untagged list
+  const { data: spools = [], refetch: refetchSpools } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(false),
+  });
+
+  // Current Spool card state - persists until user closes or new tag detected
   const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
+  const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
   const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
+  const [showLinkModal, setShowLinkModal] = useState(false);
 
   // Track current tag from state
   const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
+  const currentWeight = sbState.weight;
+  const weightStable = sbState.weightStable;
+
+  // Find spool by tag_id in the loaded spools list
+  const displayedSpool = useMemo(() => {
+    if (!displayedTagId) return null;
+    return spools.find((s) => s.tag_uid === displayedTagId) ?? null;
+  }, [displayedTagId, spools]);
+
+  // Untagged spools for the Link feature
+  const untaggedSpools = useMemo(() => {
+    return spools.filter((s) => !s.tag_uid && !s.archived_at);
+  }, [spools]);
 
+  // Handle tag detection - show card when tag detected, keep until user closes or new tag
   useEffect(() => {
     if (currentTagId) {
       const isHidden = hiddenTagId === currentTagId;
-      const isDifferent = displayedTagId !== null && displayedTagId !== currentTagId;
+      const isDifferentTag = displayedTagId !== null && displayedTagId !== currentTagId;
 
-      if (isDifferent || (!isHidden && displayedTagId !== currentTagId)) {
+      if (isDifferentTag || (!isHidden && displayedTagId !== currentTagId)) {
         setDisplayedTagId(currentTagId);
+        setDisplayedWeight(null);
         setHiddenTagId(null);
       }
+
+      // Update weight when stable and card is visible
+      if (!isHidden && currentWeight !== null && weightStable) {
+        setDisplayedWeight(Math.round(Math.max(0, currentWeight)));
+      }
     } else {
+      // Tag removed - clear hidden state so same tag can show when re-placed
       if (hiddenTagId) {
         setDisplayedTagId(null);
         setHiddenTagId(null);
+        setDisplayedWeight(null);
       }
     }
-  }, [currentTagId, displayedTagId, hiddenTagId]);
+  }, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
+
+  // Auto-sync weight once when known spool first detected
+  const [weightUpdatedForSpool, setWeightUpdatedForSpool] = useState<number | null>(null);
 
-  const handleClose = () => {
+  useEffect(() => {
+    if (displayedSpool?.id !== weightUpdatedForSpool) {
+      setWeightUpdatedForSpool(null);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [displayedSpool?.id]);
+
+  useEffect(() => {
+    if (displayedSpool && currentTagId && weightStable && weightUpdatedForSpool !== displayedSpool.id) {
+      setWeightUpdatedForSpool(displayedSpool.id);
+      const newWeight = currentWeight !== null ? Math.round(Math.max(0, currentWeight)) : null;
+      if (newWeight !== null) {
+        spoolbuddyApi.updateSpoolWeight(displayedSpool.id, newWeight)
+          .catch((err) => console.error('Failed to update spool weight:', err));
+      }
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [displayedSpool?.id, currentTagId, weightStable]);
+
+  const handleCloseSpoolCard = () => {
     setHiddenTagId(displayedTagId);
   };
 
-  const handleLinkSpool = () => {
-    navigate('/spoolbuddy/inventory');
+  const handleLinkTagToSpool = async (spool: InventorySpool) => {
+    if (!displayedTagId) return;
+    try {
+      await api.updateSpool(spool.id, { tag_uid: displayedTagId });
+      setShowLinkModal(false);
+      refetchSpools();
+    } catch (e) {
+      console.error('Failed to link tag:', e);
+    }
   };
 
+  // Close handler for the Current Spool card
   const showCard = displayedTagId && hiddenTagId !== displayedTagId;
-  const isMatchedSpool = sbState.matchedSpool && displayedTagId === sbState.matchedSpool.tag_uid;
+  const isMatchedSpool = displayedSpool !== null;
+  const isUnknownTag = showCard && !isMatchedSpool;
 
-  // If device offline
-  if (!sbState.deviceOnline) {
-    return (
-      <div className="h-full flex flex-col">
-        <DeviceOfflineState />
-      </div>
-    );
-  }
+  // For unknown tags, use live weight or stored displayed weight
+  const useScaleWeight = currentWeight !== null &&
+    (currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null));
+  const liveWeight = useScaleWeight ? currentWeight : null;
+
+  // Stats
+  const totalSpools = spools.length;
+  const materials = new Set(spools.map((s) => s.material)).size;
+  const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;
 
   return (
     <div className="h-full flex flex-col p-4">
-      <h1 className="text-lg font-semibold text-zinc-100 mb-4">
-        {t('spoolbuddy.nav.dashboard', 'Dashboard')}
-      </h1>
+      {/* Compact stats bar */}
+      <div className="flex items-center gap-6 px-4 py-2 bg-zinc-800 rounded-xl border border-zinc-700 mb-4 shrink-0">
+        <div className="flex items-center gap-2">
+          <span className="text-2xl font-bold text-zinc-100">{totalSpools}</span>
+          <span className="text-sm text-zinc-500">{t('spoolbuddy.inventory.spools', 'Spools')}</span>
+        </div>
+        <div className="w-px h-6 bg-zinc-700" />
+        <div className="flex items-center gap-2">
+          <span className="text-2xl font-bold text-zinc-100">{materials}</span>
+          <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.material', 'Materials')}</span>
+        </div>
+        <div className="w-px h-6 bg-zinc-700" />
+        <div className="flex items-center gap-2">
+          <span className="text-2xl font-bold text-zinc-100">{brands}</span>
+          <span className="text-sm text-zinc-500">{t('spoolbuddy.spool.brand', 'Brands')}</span>
+        </div>
+      </div>
 
+      {/* Main content: Device status (left) + Hero spool card (right) */}
       <div className="flex-1 flex gap-4 min-h-0">
-        {/* Left column: Weight + status */}
-        <div className="w-[40%] flex flex-col items-center justify-center gap-4">
-          <WeightDisplay
-            weight={sbState.weight}
-            weightStable={sbState.weightStable}
-            deviceOnline={sbState.deviceOnline}
-            deviceId={sbState.deviceId}
-          />
+        {/* Left column: Device Status */}
+        <div className="w-1/3 flex flex-col gap-3">
+          <div className="bg-zinc-800 rounded-lg p-4">
+            <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-3">
+              {t('spoolbuddy.dashboard.device', 'Device')}
+            </h2>
+
+            <div className="space-y-3">
+              {/* Connection status */}
+              <div className="flex items-center gap-3">
+                <div className={`w-2.5 h-2.5 rounded-full ${sbState.deviceOnline ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
+                <span className="text-sm text-zinc-400">
+                  {sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
+                </span>
+              </div>
+
+              {/* Scale weight */}
+              <div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
+                <div className="flex items-center gap-2">
+                  <svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
+                  </svg>
+                  <span className="text-xs text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
+                </div>
+                <div className="flex items-center gap-2">
+                  <span className="text-lg font-mono font-semibold text-zinc-100">
+                    {currentWeight !== null ? `${Math.abs(currentWeight) <= 20 ? 0 : Math.round(Math.max(0, currentWeight))}g` : '\u2014'}
+                  </span>
+                  {weightStable && currentWeight !== null && (
+                    <span className="w-2 h-2 rounded-full bg-green-500" title={t('spoolbuddy.weight.stable', 'Stable')} />
+                  )}
+                </div>
+              </div>
 
-          {/* NFC status */}
-          <div className="flex items-center gap-2 text-xs">
-            <div className={`w-2 h-2 rounded-full ${sbState.deviceOnline ? 'bg-green-500' : 'bg-zinc-600'}`} />
-            <span className="text-zinc-400">
-              {sbState.deviceOnline
-                ? t('spoolbuddy.status.nfcReady', 'NFC Ready')
-                : t('spoolbuddy.status.nfcOff', 'NFC Off')}
+              {/* NFC status */}
+              <div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
+                <div className="flex items-center gap-2">
+                  <svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
+                  </svg>
+                  <span className="text-xs text-zinc-500">NFC</span>
+                </div>
+                <span className={`text-sm font-medium ${currentTagId ? 'text-green-500' : 'text-zinc-500'}`}>
+                  {currentTagId ? t('spoolbuddy.dashboard.tagDetected', 'Tag detected') : t('spoolbuddy.dashboard.noTag', 'No tag')}
+                </span>
+              </div>
+            </div>
+          </div>
+
+          {/* Weight display */}
+          <div className="bg-zinc-800 rounded-lg p-4 flex flex-col items-center">
+            <span className="text-4xl font-light tabular-nums text-zinc-100">
+              {currentWeight !== null ? currentWeight.toFixed(1) : '--.-'}
             </span>
+            <span className="text-lg text-zinc-500">g</span>
+            <div className="flex items-center gap-2 mt-2">
+              <div className={`w-2 h-2 rounded-full ${
+                !sbState.deviceOnline
+                  ? 'bg-zinc-600'
+                  : weightStable
+                  ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
+                  : 'bg-amber-500 animate-pulse'
+              }`} />
+              <span className="text-xs text-zinc-400">
+                {!sbState.deviceOnline
+                  ? t('spoolbuddy.weight.noReading', 'No reading')
+                  : weightStable
+                  ? t('spoolbuddy.weight.stable', 'Stable')
+                  : t('spoolbuddy.weight.measuring', 'Measuring...')}
+              </span>
+            </div>
           </div>
         </div>
 
-        {/* Right column: Spool card or idle */}
-        <div className="w-[60%] flex flex-col justify-center">
-          {showCard && isMatchedSpool && sbState.matchedSpool ? (
-            <SpoolInfoCard
-              spool={sbState.matchedSpool}
-              scaleWeight={sbState.weight}
-              weightStable={sbState.weightStable}
-              onClose={handleClose}
-            />
-          ) : showCard && sbState.unknownTagUid ? (
-            <UnknownTagCard
-              tagUid={sbState.unknownTagUid}
-              onLinkSpool={handleLinkSpool}
-              onClose={handleClose}
-            />
-          ) : (
-            <IdleState />
-          )}
+        {/* Right column: Hero Spool Card */}
+        <div className="w-2/3">
+          <div className="bg-zinc-800 rounded-lg p-6 h-full flex items-center justify-center">
+            {showCard && isMatchedSpool && displayedSpool ? (
+              <SpoolInfoCard
+                spool={{
+                  id: displayedSpool.id,
+                  tag_uid: displayedTagId!,
+                  material: displayedSpool.material,
+                  subtype: displayedSpool.subtype,
+                  color_name: displayedSpool.color_name,
+                  rgba: displayedSpool.rgba,
+                  brand: displayedSpool.brand,
+                  label_weight: displayedSpool.label_weight,
+                  core_weight: displayedSpool.core_weight,
+                  weight_used: displayedSpool.weight_used,
+                }}
+                scaleWeight={liveWeight}
+                weightStable={weightStable}
+                onClose={handleCloseSpoolCard}
+                onSyncWeight={() => refetchSpools()}
+              />
+            ) : showCard && isUnknownTag ? (
+              <UnknownTagCard
+                tagUid={displayedTagId!}
+                scaleWeight={liveWeight ?? (displayedWeight !== null ? displayedWeight : null)}
+                onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
+                onClose={handleCloseSpoolCard}
+              />
+            ) : (
+              sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />
+            )}
+          </div>
         </div>
       </div>
+
+      {/* Link Tag to Spool Modal */}
+      {displayedTagId && (
+        <LinkSpoolModal
+          isOpen={showLinkModal}
+          onClose={() => setShowLinkModal(false)}
+          tagId={displayedTagId}
+          untaggedSpools={untaggedSpools}
+          onLink={handleLinkTagToSpool}
+        />
+      )}
     </div>
   );
 }

+ 15 - 15
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { api, type InventorySpool } from '../../api/client';
+import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
 
 function formatDate(dateStr: string | null): string {
   if (!dateStr) return '-';
@@ -20,6 +21,7 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
   const remaining = Math.max(0, spool.label_weight - spool.weight_used);
   const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
   const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+  const fillColor = remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444';
 
   return (
     <button
@@ -27,23 +29,24 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
       onClick={() => setExpanded(!expanded)}
     >
       <div className="flex items-center gap-3">
-        {/* Color swatch */}
-        <div
-          className="w-8 h-8 rounded-full border border-zinc-700 shrink-0"
-          style={{ backgroundColor: colorHex }}
-        />
+        {/* Spool icon */}
+        <SpoolIcon color={colorHex} isEmpty={false} size={36} />
 
         {/* Info */}
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2">
             <span className="text-sm font-medium text-zinc-100 truncate">
-              {spool.material}
-              {spool.color_name && <span className="text-zinc-400 ml-1">- {spool.color_name}</span>}
+              {spool.color_name || spool.material}
             </span>
+            {spool.tag_uid && (
+              <svg className="w-3 h-3 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
+              </svg>
+            )}
           </div>
-          {spool.brand && (
-            <span className="text-xs text-zinc-500 truncate block">{spool.brand}</span>
-          )}
+          <span className="text-xs text-zinc-500 truncate block">
+            {spool.brand ? `${spool.brand} \u2022 ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
+          </span>
         </div>
 
         {/* Weight */}
@@ -57,10 +60,7 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
       <div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-2">
         <div
           className="h-full rounded-full transition-all"
-          style={{
-            width: `${Math.min(100, remainingPct)}%`,
-            backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
-          }}
+          style={{ width: `${Math.min(100, remainingPct)}%`, backgroundColor: fillColor }}
         />
       </div>
 
@@ -86,7 +86,7 @@ function SpoolCard({ spool }: { spool: InventorySpool }) {
           {spool.tag_uid && (
             <div className="col-span-2 flex justify-between">
               <span className="text-zinc-500">Tag</span>
-              <span className="text-zinc-400 font-mono">{spool.tag_uid}</span>
+              <span className="text-zinc-400 font-mono text-[10px]">{spool.tag_uid}</span>
             </div>
           )}
         </div>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BU6YJVpb.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-COFMpymr.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-VMGdyBYb.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DLCtwgng.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-COFMpymr.css">
+    <script type="module" crossorigin src="/assets/index-VMGdyBYb.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BU6YJVpb.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff