Browse Source

[Feature] Spoolbuddy spool detail card and UI improvements (#866)

[Feature] Spoolbuddy spool detail card and UI improvements (#866)
Keybored 1 month ago
parent
commit
4a2a0b1a10

+ 283 - 0
frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx

@@ -0,0 +1,283 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
+import type { InventorySpool } from '../../api/client';
+import { spoolbuddyApi, api } from '../../api/client';
+import { SpoolIcon } from './SpoolIcon';
+
+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;
+  className?: string;
+}
+
+export function InventorySpoolInfoCard({
+  spool,
+  liveScaleWeight,
+  persistedGrossWeight,
+  onClose,
+  onSyncWeight,
+  onAssignToAms,
+  className,
+}: InventorySpoolInfoCardProps) {
+  const { t } = useTranslation();
+  const [syncing, setSyncing] = useState(false);
+  const [synced, setSynced] = useState(false);
+  const [syncedGrossWeight, setSyncedGrossWeight] = useState<number | null>(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 = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  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 (
+    <div className={`flex flex-col items-center space-y-4 max-w-md ${className ?? ''}`}>
+      <div className="flex items-start gap-5">
+        <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 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>
+
+          <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>
+
+            <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 ?? 0}%`, backgroundColor: fillColor }}
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full">
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
+          <span className="font-mono text-zinc-300">{resolvedGrossWeight}g</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</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>
+          {grossWeightFromScale !== null ? (
+            <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
+              {grossWeightFromScale}g
+              {isMatch ? (
+                <Check className="w-3.5 h-3.5" />
+              ) : (
+                <>
+                  <AlertTriangle className="w-3.5 h-3.5" />
+                  <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-4 h-4" />
+                  </button>
+                </>
+              )}
+            </span>
+          ) : (
+            <span className="text-zinc-500">{'\u2014'}</span>
+          )}
+        </div>
+        <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>
+        {nozzleTempRange && (
+          <div className="flex justify-between items-center">
+            <span className="text-zinc-500">{t('spoolbuddy.inventory.nozzleTemp', 'Nozzle')}</span>
+            <span className="font-mono text-zinc-300">{nozzleTempRange}</span>
+          </div>
+        )}
+        {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
+          <div className="flex justify-between items-center">
+            <span className="text-zinc-500">{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}</span>
+            <span className="font-mono text-zinc-300">{spool.cost_per_kg.toFixed(2)}/kg</span>
+          </div>
+        )}
+        {kFactorSummary && (
+          <div className="flex justify-between items-center">
+            <span className="text-zinc-500">{t('spoolbuddy.inventory.kProfiles', 'K-Profile')}</span>
+            <span className="font-mono text-zinc-300 truncate max-w-[220px] text-right" title={kFactorSummary}>{kFactorSummary}</span>
+          </div>
+        )}
+        {slicerPreset && (
+          <div className="min-w-0">
+            <p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}</p>
+            <p className="text-sm text-zinc-300 whitespace-pre-wrap break-words">{slicerPreset}</p>
+          </div>
+        )}
+        {note && (
+          <div className="col-span-2">
+            <p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
+            <p className="text-sm leading-5 text-zinc-300 whitespace-pre-wrap break-words max-h-[3.75rem] overflow-y-auto pr-1">{note}</p>
+          </div>
+        )}
+      </div>
+
+      <div className="flex gap-2 justify-center">
+        {onAssignToAms && (
+          <button
+            onClick={onAssignToAms}
+            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]"
+          >
+            {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
+          </button>
+        )}
+        <button
+          onClick={handleSyncWeight}
+          disabled={liveScaleWeight === null || syncing}
+          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'
+              : onAssignToAms
+                ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
+                : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
+          }`}
+        >
+          {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>
+  );
+}

+ 37 - 0
frontend/src/index.css

@@ -387,6 +387,43 @@ body {
   animation: slide-in-left 0.3s ease-out;
   animation: slide-in-left 0.3s ease-out;
 }
 }
 
 
+/* SpoolBuddy idle scan animation: transform/opacity only for low-power devices */
+@keyframes spoolbuddy-optimized-ping {
+  0% {
+    transform: scale(0.8);
+    opacity: 0.8;
+  }
+  80%,
+  100% {
+    transform: scale(2.2);
+    opacity: 0;
+  }
+}
+
+.spoolbuddy-optimized-ping {
+  will-change: auto;
+  contain: layout;
+  animation: spoolbuddy-optimized-ping 3.5s cubic-bezier(0, 0, 0.2, 1) infinite;
+}
+
+.spoolbuddy-spool-glow {
+  transform: scale(2.0);
+  will-change: auto;
+  transition: background 140ms linear, opacity 140ms linear;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .spoolbuddy-optimized-ping {
+    animation: none;
+    opacity: 0.25;
+    transform: scale(1.2);
+  }
+
+  .spoolbuddy-spool-glow {
+    transition: none;
+  }
+}
+
 /* Card shadows - uses theme-specific shadow */
 /* Card shadows - uses theme-specific shadow */
 .card-shadow {
 .card-shadow {
   box-shadow: var(--card-shadow);
   box-shadow: var(--card-shadow);

+ 29 - 9
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -46,16 +46,36 @@ function IdleSpool() {
 
 
   return (
   return (
     <div className="flex flex-col items-center text-center">
     <div className="flex flex-col items-center text-center">
-      {/* Spool with single subtle NFC ring */}
+      {/* Animated spool with optimized NFC waves */}
       <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
       <div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
-        {/* Static NFC wave rings */}
-        <div className="absolute w-24 h-24 rounded-full border-2 border-green-500/30" />
-        <div className="absolute w-32 h-32 rounded-full border border-green-500/20" />
-        <div className="absolute w-40 h-40 rounded-full border border-green-500/10" />
-
-        {/* Spool icon with slow color transition */}
-        <div className="relative transition-colors duration-[2000ms]">
-          <SpoolIcon color={currentColor} isEmpty={false} size={100} />
+        {/* NFC wave rings: transform + opacity only for Pi-friendly rendering */}
+        <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+          {[0, 1].map((i) => (
+            <div
+              key={i}
+              className="absolute rounded-full border spoolbuddy-optimized-ping"
+              style={{
+                width: 80,
+                height: 80,
+                borderColor: `${currentColor}4D`,
+                transition: 'border-color 140ms linear',
+                animationDelay: `${i * 0.8}s`,
+              }}
+            />
+          ))}
+        </div>
+
+        {/* Spool icon with lightweight radial glow */}
+        <div className="relative overflow-hidden rounded-full">
+          <div
+            className="absolute inset-0 rounded-full opacity-30 spoolbuddy-spool-glow"
+            style={{
+              background: `radial-gradient(circle, ${currentColor} 0%, transparent 70%)`,
+            }}
+          />
+          <div className="relative" style={{ transition: 'opacity 140ms linear' }}>
+            <SpoolIcon color={currentColor} isEmpty={false} size={100} />
+          </div>
         </div>
         </div>
       </div>
       </div>
 
 

+ 52 - 146
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -1,11 +1,15 @@
 import { useState, useMemo } from 'react';
 import { useState, useMemo } from 'react';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { useOutletContext } from 'react-router-dom';
 import { Search, X, Package } from 'lucide-react';
 import { Search, X, Package } from 'lucide-react';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import type { InventorySpool, SpoolAssignment } from '../../api/client';
 import type { InventorySpool, SpoolAssignment } from '../../api/client';
 import { resolveSpoolColorName } from '../../utils/colors';
 import { resolveSpoolColorName } from '../../utils/colors';
 import { formatSlotLabel } from '../../utils/amsHelpers';
 import { formatSlotLabel } from '../../utils/amsHelpers';
+import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
+import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
+import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 
 
 type FilterMode = 'all' | 'in_ams' | string; // string = material name
 type FilterMode = 'all' | 'in_ams' | string; // string = material name
 
 
@@ -49,10 +53,12 @@ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
 }
 }
 
 
 export function SpoolBuddyInventoryPage() {
 export function SpoolBuddyInventoryPage() {
+  const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [filterMode, setFilterMode] = useState<FilterMode>('all');
   const [filterMode, setFilterMode] = useState<FilterMode>('all');
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
+  const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
 
 
   const { data: spoolmanSettings } = useQuery({
   const { data: spoolmanSettings } = useQuery({
     queryKey: ['spoolman-settings'],
     queryKey: ['spoolman-settings'],
@@ -60,7 +66,7 @@ export function SpoolBuddyInventoryPage() {
     staleTime: 5 * 60 * 1000,
     staleTime: 5 * 60 * 1000,
   });
   });
 
 
-  const { data: spools = [], isLoading } = useQuery({
+  const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({
     queryKey: ['inventory-spools'],
     queryKey: ['inventory-spools'],
     queryFn: () => api.getSpools(false),
     queryFn: () => api.getSpools(false),
     refetchInterval: 30000,
     refetchInterval: 30000,
@@ -220,12 +226,29 @@ export function SpoolBuddyInventoryPage() {
       {selectedSpoolId != null && (() => {
       {selectedSpoolId != null && (() => {
         const liveSpool = spools.find(s => s.id === selectedSpoolId);
         const liveSpool = spools.find(s => s.id === selectedSpoolId);
         if (!liveSpool) return null;
         if (!liveSpool) return null;
+        const handleCloseDetail = () => {
+          setSelectedSpoolId(null);
+          setShowAssignAmsModal(false);
+        };
         return (
         return (
-          <SpoolDetailModal
-            spool={liveSpool}
-            assignment={assignmentMap[liveSpool.id]}
-            onClose={() => setSelectedSpoolId(null)}
-          />
+          <>
+            <SpoolDetailModal
+              spool={liveSpool}
+              assignment={assignmentMap[liveSpool.id]}
+              sbState={sbState}
+              onSyncWeight={() => {
+                void refetchSpools();
+              }}
+              onAssignToAms={() => setShowAssignAmsModal(true)}
+              onClose={handleCloseDetail}
+            />
+            <AssignToAmsModal
+              isOpen={showAssignAmsModal}
+              onClose={() => setShowAssignAmsModal(false)}
+              spool={liveSpool}
+              printerId={selectedPrinterId}
+            />
+          </>
         );
         );
       })()}
       })()}
     </div>
     </div>
@@ -314,168 +337,51 @@ function CatalogCard({ spool, assignment, onClick }: {
 }
 }
 
 
 /* Detail bottom sheet */
 /* Detail bottom sheet */
-function SpoolDetailModal({ spool, assignment, onClose }: {
+function SpoolDetailModal({ spool, assignment, sbState, onSyncWeight, onAssignToAms, onClose }: {
   spool: InventorySpool;
   spool: InventorySpool;
   assignment?: SpoolAssignment;
   assignment?: SpoolAssignment;
+  sbState: SpoolBuddyOutletContext['sbState'];
+  onSyncWeight: () => void;
+  onAssignToAms: () => void;
   onClose: () => void;
   onClose: () => void;
 }) {
 }) {
-  const { t } = useTranslation();
-  const color = spoolColor(spool);
-  const pct = spoolPct(spool);
-  const remaining = spoolRemaining(spool);
-  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
+  const useLiveScaleWeight = sbState.deviceOnline && sbState.weight !== null;
+  const modalScaleWeight = useLiveScaleWeight
+    ? Math.round(sbState.weight as number)
+    : null;
+  const persistedGrossWeight = spool.last_scale_weight != null ? Math.round(spool.last_scale_weight) : null;
 
 
   return (
   return (
-    <div className="fixed inset-0 z-50" onClick={onClose}>
+    <div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" onClick={onClose}>
       <div
       <div
-        className="h-full w-full bg-bambu-dark overflow-y-auto"
+        className="w-full max-w-md bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-2xl p-4 overflow-y-auto max-h-[90vh]"
         onClick={e => e.stopPropagation()}
         onClick={e => e.stopPropagation()}
       >
       >
-        {/* Header with spool icon */}
-        <div className="flex items-center gap-4 p-4 pb-3">
-          <SpoolCircle color={color} size={72} />
-          <div className="flex-1 min-w-0">
-            <h2 className="text-lg font-semibold text-white">
-              {spoolDisplayName(spool)}
-            </h2>
-            {spool.brand && (
-              <p className="text-sm text-white/50">{spool.brand}</p>
-            )}
-            <div className="flex items-center gap-1.5 mt-1">
-              <span
-                className="w-3 h-3 rounded-full border border-white/10"
-                style={{ backgroundColor: color }}
-              />
-              <span className="text-sm text-white/60">
-                {colorName || '-'}
-              </span>
-            </div>
-          </div>
-        </div>
-
-        <div className="px-4 pb-4 space-y-4">
-          {/* Remaining bar */}
-          <div>
-            <div className="flex justify-between text-xs text-white/50 mb-1.5">
-              <span>{t('spoolbuddy.inventory.remaining', 'Remaining')}</span>
-              <span>{Math.round(remaining)}g ({Math.round(pct)}%)</span>
-            </div>
-            <div className="h-3 bg-bambu-dark-secondary rounded-full overflow-hidden">
-              <div
-                className={`h-full rounded-full transition-all ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
-                style={{ width: `${Math.min(pct, 100)}%` }}
-              />
-            </div>
-          </div>
-
-          {/* AMS location */}
+        <div className="space-y-3">
           {assignment && (
           {assignment && (
-            <div className="flex items-center gap-2">
+            <div className="flex items-center justify-center gap-2">
               <span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
               <span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
                 {assignmentLabel(assignment)}
                 {assignmentLabel(assignment)}
               </span>
               </span>
               {assignment.printer_name && (
               {assignment.printer_name && (
-                <span className="text-xs text-white/40">{assignment.printer_name}</span>
+                <span className="text-xs text-zinc-400">{assignment.printer_name}</span>
               )}
               )}
             </div>
             </div>
           )}
           )}
 
 
-          {/* Detail grid */}
-          <div className="grid grid-cols-2 gap-2.5">
-            <DetailItem
-              label={t('spoolbuddy.inventory.labelWeight', 'Label Weight')}
-              value={`${spool.label_weight}g`}
-            />
-            <DetailItem
-              label={t('spoolbuddy.inventory.weightUsed', 'Used')}
-              value={spool.weight_used > 0 ? `${Math.round(spool.weight_used)}g` : '-'}
+          <div className="flex justify-center">
+            <InventorySpoolInfoCard
+              spool={spool}
+              liveScaleWeight={modalScaleWeight}
+              persistedGrossWeight={persistedGrossWeight}
+              onSyncWeight={onSyncWeight}
+              onAssignToAms={onAssignToAms}
+              onClose={onClose}
+              className="max-w-md"
             />
             />
-            <DetailItem
-              label={t('spoolbuddy.inventory.coreWeight', 'Core Weight')}
-              value={spool.core_weight > 0 ? `${spool.core_weight}g` : '-'}
-            />
-            <DetailItem
-              label={t('spoolbuddy.inventory.grossWeight', 'Gross Weight')}
-              value={`${spool.label_weight + spool.core_weight}g`}
-            />
-            {spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && (
-              <DetailItem
-                label={t('spoolbuddy.inventory.nozzleTemp', 'Nozzle Temp')}
-                value={`${spool.nozzle_temp_min}-${spool.nozzle_temp_max}°C`}
-              />
-            )}
-            {spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
-              <DetailItem
-                label={t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}
-                value={`${spool.cost_per_kg.toFixed(2)}/kg`}
-              />
-            )}
-            {spool.last_scale_weight != null && (
-              <DetailItem
-                label={t('spoolbuddy.inventory.lastScaleWeight', 'Scale Weight')}
-                value={`${Math.round(spool.last_scale_weight)}g`}
-              />
-            )}
-            {spool.tag_uid && (
-              <DetailItem
-                label={t('spoolbuddy.inventory.tagId', 'Tag')}
-                value={spool.tag_uid}
-                mono
-              />
-            )}
-            {(spool.slicer_filament_name || spool.slicer_filament) && (
-              <DetailItem
-                label={t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}
-                value={spool.slicer_filament_name || spool.slicer_filament || ''}
-              />
-            )}
           </div>
           </div>
-
-          {/* K-Profiles */}
-          {spool.k_profiles && spool.k_profiles.length > 0 && (
-            <div>
-              <p className="text-xs text-white/40 mb-1.5">{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}</p>
-              <div className="space-y-1">
-                {spool.k_profiles.map(kp => (
-                  <div key={kp.id} className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg px-3 py-2">
-                    <span className="text-sm text-white/70 truncate">
-                      {kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`}
-                    </span>
-                    <span className="text-sm font-mono text-bambu-green shrink-0 ml-2">
-                      {kp.k_value.toFixed(3)}
-                    </span>
-                  </div>
-                ))}
-              </div>
-            </div>
-          )}
-
-          {/* Note */}
-          {spool.note && (
-            <div className="bg-bambu-dark-secondary rounded-lg p-3">
-              <p className="text-xs text-white/40 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
-              <p className="text-sm text-white/70">{spool.note}</p>
-            </div>
-          )}
-
-          {/* Close button */}
-          <button
-            onClick={onClose}
-            className="w-full py-3 rounded-xl bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/60 hover:text-white text-sm font-medium transition-colors"
-          >
-            {t('spoolbuddy.inventory.close', 'Close')}
-          </button>
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
   );
   );
 }
 }
-
-function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
-  return (
-    <div className="bg-bambu-dark-secondary rounded-lg px-3 py-2">
-      <p className="text-[10px] text-white/40 uppercase tracking-wide">{label}</p>
-      <p className={`text-sm text-white mt-0.5 truncate ${mono ? 'font-mono text-xs' : ''}`}>{value}</p>
-    </div>
-  );
-}