Browse Source

Add SpoolBuddy Inventory page to kiosk UI

  Catalog-style spool grid with colored spool icons, material/subtype
  labels, color dots, remaining weight percentages, and AMS location
  badges. Search bar with inline filter pills (All, In AMS, per-material).
  Detail bottom sheet on tap. Shows Spoolman iframe when enabled.
  Navigation item added between Write and Settings in the bottom bar.
maziggy 2 months ago
parent
commit
0969ec0a59

+ 1 - 1
CHANGELOG.md

@@ -19,7 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.
 - **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.
 - **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
-- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a compact 2-column card grid of all spools with color banners, material pills, brand labels, and remaining-weight bars. Includes a search bar (filters by material, subtype, brand, color, notes), material filter chips, and sort options (Recent, Name, Material, Low Stock). Tapping a spool opens a detail sheet with full weight info, temperature range, cost, tag ID, and notes. When Spoolman is enabled, the page shows the Spoolman UI instead.
+- **SpoolBuddy Inventory Page** — Added a new Inventory page to the SpoolBuddy kiosk UI, accessible from the bottom navigation bar between Write and Settings. Shows a responsive catalog grid of spools with colored spool icons, material/subtype labels, color dots, remaining weight with percentage, and green AMS location badges (A1, B2, etc.) for assigned spools. Includes a search bar (filters by material, subtype, brand, color, notes) and inline filter pills ("All", "In AMS", per-material). Tapping a spool opens a detail bottom sheet with spool icon, remaining bar, AMS assignment, weight breakdown, temperature range, cost, tag ID, and notes. Assigned spools sort first. When Spoolman is enabled, the page shows the Spoolman UI instead.
 - **SpoolBuddy Auto-Navigate on Tag Scan** — When an NFC tag is detected while the SpoolBuddy UI is on a non-dashboard page (Settings, AMS, Write Tag, etc.), the frontend automatically navigates back to the main dashboard to show the scanned spool. Also wakes the screen if the display was blanked.
 - **SpoolBuddy Swipe to Switch Printers** — Swiping left/right on the SpoolBuddy touchscreen now cycles through online printers instead of triggering browser back/forward navigation. The selected printer updates in the top bar dropdown. Requires at least two online printers; single-printer setups are unaffected.
 - **SpoolBuddy Virtual Keyboard No Longer Overlays Input Fields** — The virtual keyboard now adds temporary scroll padding to the content area when it opens, ensuring the focused input field scrolls above the keyboard instead of being hidden behind it. Fixes text entry on the SpoolBuddy Settings device tab (backend URL, API token fields).

+ 236 - 227
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -1,31 +1,13 @@
 import { useState, useMemo } from 'react';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { Search, X, Filter, Package } from 'lucide-react';
+import { Search, X, Package } from 'lucide-react';
 import { api } from '../../api/client';
-import type { InventorySpool } from '../../api/client';
+import type { InventorySpool, SpoolAssignment } from '../../api/client';
 import { resolveSpoolColorName } from '../../utils/colors';
+import { formatSlotLabel } from '../../utils/amsHelpers';
 
-type MaterialFilter = string | null;
-type SortKey = 'name' | 'material' | 'remaining' | 'recent';
-
-const MATERIAL_COLORS: Record<string, string> = {
-  PLA: 'bg-green-500/20 text-green-400 border-green-500/30',
-  ABS: 'bg-red-500/20 text-red-400 border-red-500/30',
-  PETG: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
-  TPU: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
-  ASA: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
-  PA: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
-  PC: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
-  PET: 'bg-sky-500/20 text-sky-400 border-sky-500/30',
-  PVA: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
-  HIPS: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
-};
-
-function getMaterialPillClass(material: string): string {
-  const base = material.split('-')[0].split(' ')[0].toUpperCase();
-  return MATERIAL_COLORS[base] || 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30';
-}
+type FilterMode = 'all' | 'in_ams' | string; // string = material name
 
 function spoolColor(spool: InventorySpool): string {
   if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`;
@@ -47,15 +29,55 @@ function spoolDisplayName(spool: InventorySpool): string {
   return parts.join(' ');
 }
 
+function assignmentLabel(a: SpoolAssignment): string {
+  const isExternal = a.ams_id === 254 || a.ams_id === 255;
+  const isHt = !isExternal && a.ams_id >= 128;
+  return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);
+}
+
+/* SVG spool icon — cylindrical spool with colored filament */
+function SpoolSvg({ color, size = 64 }: { color: string; size?: number }) {
+  // Determine if the color is very dark to add a subtle outline
+  const r = parseInt(color.slice(1, 3), 16) || 0;
+  const g = parseInt(color.slice(3, 5), 16) || 0;
+  const b = parseInt(color.slice(5, 7), 16) || 0;
+  const luma = 0.299 * r + 0.587 * g + 0.114 * b;
+  const strokeColor = luma < 40 ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.2)';
+
+  return (
+    <svg width={size} height={size} viewBox="0 0 64 64" fill="none">
+      {/* Spool flanges (gray side plates) */}
+      <ellipse cx="32" cy="14" rx="18" ry="6" fill="#555" stroke="#444" strokeWidth="0.5" />
+      <rect x="14" y="14" width="36" height="36" fill="#555" />
+      <ellipse cx="32" cy="50" rx="18" ry="6" fill="#4a4a4a" stroke="#444" strokeWidth="0.5" />
+
+      {/* Filament wrap (colored center) */}
+      <rect x="18" y="14" width="28" height="36" fill={color} />
+      <ellipse cx="32" cy="14" rx="14" ry="4.5" fill={color} />
+      <ellipse cx="32" cy="50" rx="14" ry="4.5" fill={color} style={{ filter: 'brightness(0.85)' }} />
+
+      {/* Highlight on filament */}
+      <rect x="18" y="14" width="8" height="36" fill="white" opacity="0.10" />
+      <ellipse cx="32" cy="14" rx="14" ry="4.5" fill="white" opacity="0.08" />
+
+      {/* Center hub (dark hole) */}
+      <ellipse cx="32" cy="32" rx="5" ry="14" fill="#333" stroke="#222" strokeWidth="0.5" />
+      <ellipse cx="32" cy="32" rx="3.5" ry="10" fill="#2a2a2a" />
+
+      {/* Top flange rim */}
+      <ellipse cx="32" cy="14" rx="18" ry="6" fill="none" stroke={strokeColor} strokeWidth="1" />
+      {/* Bottom flange rim */}
+      <ellipse cx="32" cy="50" rx="18" ry="6" fill="none" stroke={strokeColor} strokeWidth="1" />
+    </svg>
+  );
+}
+
 export function SpoolBuddyInventoryPage() {
   const { t } = useTranslation();
   const [searchQuery, setSearchQuery] = useState('');
-  const [materialFilter, setMaterialFilter] = useState<MaterialFilter>(null);
-  const [sortKey, setSortKey] = useState<SortKey>('recent');
-  const [showFilters, setShowFilters] = useState(false);
+  const [filterMode, setFilterMode] = useState<FilterMode>('all');
   const [selectedSpool, setSelectedSpool] = useState<InventorySpool | null>(null);
 
-  // Check if Spoolman is enabled — if so, show iframe
   const { data: spoolmanSettings } = useQuery({
     queryKey: ['spoolman-settings'],
     queryFn: api.getSpoolmanSettings,
@@ -68,6 +90,12 @@ export function SpoolBuddyInventoryPage() {
     refetchInterval: 30000,
   });
 
+  const { data: assignments = [] } = useQuery({
+    queryKey: ['spool-assignments'],
+    queryFn: () => api.getAssignments(),
+    refetchInterval: 30000,
+  });
+
   // Spoolman iframe mode
   const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
   if (spoolmanEnabled) {
@@ -83,19 +111,34 @@ export function SpoolBuddyInventoryPage() {
     );
   }
 
-  // Collect unique materials for filter chips
+  // Build assignment lookup: spool_id → assignment
+  const assignmentMap = useMemo(() => {
+    const map: Record<number, SpoolAssignment> = {};
+    assignments.forEach(a => { map[a.spool_id] = a; });
+    return map;
+  }, [assignments]);
+
+  const activeSpools = useMemo(() => spools.filter(s => !s.archived_at), [spools]);
+
+  // Spools that have an AMS assignment
+  const assignedSpoolIds = useMemo(() => new Set(assignments.map(a => a.spool_id)), [assignments]);
+  const inAmsCount = useMemo(() => activeSpools.filter(s => assignedSpoolIds.has(s.id)).length, [activeSpools, assignedSpoolIds]);
+
+  // Unique materials for filter pills
   const materials = useMemo(() => {
     const set = new Set<string>();
-    spools.forEach(s => set.add(s.material));
+    activeSpools.forEach(s => set.add(s.material));
     return Array.from(set).sort();
-  }, [spools]);
+  }, [activeSpools]);
 
   // Filter and sort
   const filteredSpools = useMemo(() => {
-    let list = spools.filter(s => !s.archived_at);
+    let list = activeSpools;
 
-    if (materialFilter) {
-      list = list.filter(s => s.material === materialFilter);
+    if (filterMode === 'in_ams') {
+      list = list.filter(s => assignedSpoolIds.has(s.id));
+    } else if (filterMode !== 'all') {
+      list = list.filter(s => s.material === filterMode);
     }
 
     if (searchQuery.trim()) {
@@ -109,125 +152,63 @@ export function SpoolBuddyInventoryPage() {
       );
     }
 
-    // Sort
-    list = [...list];
-    switch (sortKey) {
-      case 'name':
-        list.sort((a, b) => spoolDisplayName(a).localeCompare(spoolDisplayName(b)));
-        break;
-      case 'material':
-        list.sort((a, b) => a.material.localeCompare(b.material) || spoolDisplayName(a).localeCompare(spoolDisplayName(b)));
-        break;
-      case 'remaining':
-        list.sort((a, b) => spoolPct(a) - spoolPct(b));
-        break;
-      case 'recent':
-      default:
-        list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
-        break;
-    }
-
-    return list;
-  }, [spools, materialFilter, searchQuery, sortKey]);
+    // Sort: assigned spools first (by slot label), then by most recently updated
+    return [...list].sort((a, b) => {
+      const aAssigned = assignedSpoolIds.has(a.id) ? 0 : 1;
+      const bAssigned = assignedSpoolIds.has(b.id) ? 0 : 1;
+      if (aAssigned !== bAssigned) return aAssigned - bAssigned;
+      return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
+    });
+  }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
 
   return (
     <div className="h-full flex flex-col">
-      {/* Search bar */}
-      <div className="px-3 pt-3 pb-2 space-y-2">
-        <div className="flex gap-2">
-          <div className="flex-1 relative">
-            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
-            <input
-              type="text"
-              value={searchQuery}
-              onChange={e => setSearchQuery(e.target.value)}
-              placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')}
-              className="w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green"
-            />
-            {searchQuery && (
-              <button
-                onClick={() => setSearchQuery('')}
-                className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60"
-              >
-                <X className="w-4 h-4" />
-              </button>
-            )}
-          </div>
-          <button
-            onClick={() => setShowFilters(!showFilters)}
-            className={`px-3 py-2 rounded-lg border transition-colors ${
-              showFilters || materialFilter
-                ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
-                : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-white/50 hover:text-white/70'
-            }`}
-          >
-            <Filter className="w-4 h-4" />
-          </button>
+      {/* Search + filter pills */}
+      <div className="px-3 pt-3 pb-2 space-y-2.5">
+        {/* Search */}
+        <div className="relative">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
+          <input
+            type="text"
+            value={searchQuery}
+            onChange={e => setSearchQuery(e.target.value)}
+            placeholder={t('spoolbuddy.inventory.searchPlaceholder', 'Search spools...')}
+            className="w-full pl-9 pr-8 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-bambu-green"
+          />
+          {searchQuery && (
+            <button
+              onClick={() => setSearchQuery('')}
+              className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/60"
+            >
+              <X className="w-4 h-4" />
+            </button>
+          )}
         </div>
 
-        {/* Filter panel */}
-        {showFilters && (
-          <div className="space-y-2">
-            {/* Material chips */}
-            <div className="flex flex-wrap gap-1.5">
-              <button
-                onClick={() => setMaterialFilter(null)}
-                className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
-                  !materialFilter
-                    ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/40'
-                    : 'bg-bambu-dark-secondary text-white/50 border-bambu-dark-tertiary hover:text-white/70'
-                }`}
-              >
-                {t('spoolbuddy.inventory.all', 'All')}
-              </button>
-              {materials.map(mat => (
-                <button
-                  key={mat}
-                  onClick={() => setMaterialFilter(materialFilter === mat ? null : mat)}
-                  className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
-                    materialFilter === mat
-                      ? getMaterialPillClass(mat)
-                      : 'bg-bambu-dark-secondary text-white/50 border-bambu-dark-tertiary hover:text-white/70'
-                  }`}
-                >
-                  {mat}
-                </button>
-              ))}
-            </div>
-
-            {/* Sort */}
-            <div className="flex items-center gap-2">
-              <span className="text-xs text-white/40">{t('spoolbuddy.inventory.sortBy', 'Sort:')}</span>
-              <div className="flex gap-1">
-                {([
-                  ['recent', t('spoolbuddy.inventory.sortRecent', 'Recent')],
-                  ['name', t('spoolbuddy.inventory.sortName', 'Name')],
-                  ['material', t('spoolbuddy.inventory.sortMaterial', 'Material')],
-                  ['remaining', t('spoolbuddy.inventory.sortRemaining', 'Low Stock')],
-                ] as const).map(([key, label]) => (
-                  <button
-                    key={key}
-                    onClick={() => setSortKey(key)}
-                    className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
-                      sortKey === key
-                        ? 'bg-bambu-green/20 text-bambu-green'
-                        : 'text-white/40 hover:text-white/60'
-                    }`}
-                  >
-                    {label}
-                  </button>
-                ))}
-              </div>
-            </div>
-          </div>
-        )}
-      </div>
-
-      {/* Results count */}
-      <div className="px-3 pb-2 flex items-center justify-between">
-        <span className="text-xs text-white/40">
-          {filteredSpools.length} {filteredSpools.length === 1 ? 'spool' : 'spools'}
-        </span>
+        {/* Filter pills — inline scrollable row */}
+        <div className="flex gap-1.5 overflow-x-auto no-scrollbar">
+          <FilterPill
+            active={filterMode === 'all'}
+            onClick={() => setFilterMode('all')}
+            label={`${t('spoolbuddy.inventory.all', 'All')} (${activeSpools.length})`}
+            green
+          />
+          {inAmsCount > 0 && (
+            <FilterPill
+              active={filterMode === 'in_ams'}
+              onClick={() => setFilterMode('in_ams')}
+              label={`${t('spoolbuddy.inventory.inAms', 'In AMS')} (${inAmsCount})`}
+            />
+          )}
+          {materials.map(mat => (
+            <FilterPill
+              key={mat}
+              active={filterMode === mat}
+              onClick={() => setFilterMode(filterMode === mat ? 'all' : mat)}
+              label={mat}
+            />
+          ))}
+        </div>
       </div>
 
       {/* Spool grid */}
@@ -240,17 +221,18 @@ export function SpoolBuddyInventoryPage() {
           <div className="flex flex-col items-center justify-center py-16 text-white/30">
             <Package className="w-12 h-12 mb-3" />
             <p className="text-sm">
-              {searchQuery || materialFilter
+              {searchQuery || filterMode !== 'all'
                 ? t('spoolbuddy.inventory.noResults', 'No spools match your filters')
                 : t('spoolbuddy.inventory.empty', 'No spools in inventory')}
             </p>
           </div>
         ) : (
-          <div className="grid grid-cols-2 gap-2">
+          <div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-2">
             {filteredSpools.map(spool => (
-              <CompactSpoolCard
+              <CatalogCard
                 key={spool.id}
                 spool={spool}
+                assignment={assignmentMap[spool.id]}
                 onClick={() => setSelectedSpool(spool)}
               />
             ))}
@@ -262,6 +244,7 @@ export function SpoolBuddyInventoryPage() {
       {selectedSpool && (
         <SpoolDetailModal
           spool={selectedSpool}
+          assignment={assignmentMap[selectedSpool.id]}
           onClose={() => setSelectedSpool(null)}
         />
       )}
@@ -269,8 +252,35 @@ export function SpoolBuddyInventoryPage() {
   );
 }
 
-/* Compact spool card for the grid */
-function CompactSpoolCard({ spool, onClick }: { spool: InventorySpool; onClick: () => void }) {
+/* Filter pill button */
+function FilterPill({ active, onClick, label, green }: {
+  active: boolean;
+  onClick: () => void;
+  label: string;
+  green?: boolean;
+}) {
+  return (
+    <button
+      onClick={onClick}
+      className={`px-3 py-1 rounded-full text-xs font-medium border whitespace-nowrap shrink-0 transition-colors ${
+        active
+          ? green
+            ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/50'
+            : 'bg-white/10 text-white border-white/20'
+          : 'bg-transparent text-white/40 border-bambu-dark-tertiary hover:text-white/60'
+      }`}
+    >
+      {label}
+    </button>
+  );
+}
+
+/* Catalog-style spool card matching the mockup */
+function CatalogCard({ spool, assignment, onClick }: {
+  spool: InventorySpool;
+  assignment?: SpoolAssignment;
+  onClick: () => void;
+}) {
   const color = spoolColor(spool);
   const pct = spoolPct(spool);
   const remaining = spoolRemaining(spool);
@@ -279,52 +289,48 @@ function CompactSpoolCard({ spool, onClick }: { spool: InventorySpool; onClick:
   return (
     <button
       onClick={onClick}
-      className="bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary hover:border-bambu-green/60 transition-colors text-left overflow-hidden"
+      className="bg-bambu-dark-secondary rounded-xl p-3 flex flex-col items-center text-center gap-1.5 border border-transparent hover:border-bambu-green/50 transition-colors"
     >
-      {/* Color banner */}
-      <div className="h-8 relative" style={{ backgroundColor: color }}>
-        {colorName && (
-          <span className="absolute inset-0 flex items-center justify-center">
-            <span className="bg-black/50 text-white text-[10px] px-1.5 py-0.5 rounded-full truncate max-w-[90%]">
-              {colorName}
-            </span>
-          </span>
-        )}
+      {/* Spool icon */}
+      <SpoolSvg color={color} size={56} />
+
+      {/* Material + Subtype */}
+      <p className="text-xs font-semibold text-white leading-tight truncate w-full">
+        {spoolDisplayName(spool)}
+      </p>
+
+      {/* Color dot + name */}
+      <div className="flex items-center gap-1 min-w-0 max-w-full">
+        <span
+          className="w-2.5 h-2.5 rounded-full shrink-0 border border-white/10"
+          style={{ backgroundColor: color }}
+        />
+        <span className="text-[11px] text-white/50 truncate">
+          {colorName || '-'}
+        </span>
       </div>
 
-      <div className="p-2 space-y-1.5">
-        {/* Material + subtype */}
-        <div className="flex items-center gap-1.5 min-w-0">
-          <span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold border shrink-0 ${getMaterialPillClass(spool.material)}`}>
-            {spool.material}
-          </span>
-          {spool.subtype && (
-            <span className="text-[11px] text-white/50 truncate">{spool.subtype}</span>
-          )}
-        </div>
+      {/* Weight + pct */}
+      <p className="text-[11px] text-white/40">
+        {Math.round(remaining)}g ({Math.round(pct)}%)
+      </p>
 
-        {/* Brand */}
-        {spool.brand && (
-          <p className="text-[11px] text-white/40 truncate">{spool.brand}</p>
-        )}
-
-        {/* Remaining bar */}
-        <div className="flex items-center gap-1.5">
-          <div className="flex-1 h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
-            <div
-              className={`h-full rounded-full ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
-              style={{ width: `${Math.min(pct, 100)}%` }}
-            />
-          </div>
-          <span className="text-[10px] text-white/40 min-w-[32px] text-right">{Math.round(remaining)}g</span>
-        </div>
-      </div>
+      {/* AMS location badge */}
+      {assignment && (
+        <span className="px-2 py-0.5 rounded text-[10px] font-bold bg-bambu-green/20 text-bambu-green">
+          {assignmentLabel(assignment)}
+        </span>
+      )}
     </button>
   );
 }
 
-/* Full detail modal */
-function SpoolDetailModal({ spool, onClose }: { spool: InventorySpool; onClose: () => void }) {
+/* Detail bottom sheet */
+function SpoolDetailModal({ spool, assignment, onClose }: {
+  spool: InventorySpool;
+  assignment?: SpoolAssignment;
+  onClose: () => void;
+}) {
   const { t } = useTranslation();
   const color = spoolColor(spool);
   const pct = spoolPct(spool);
@@ -333,66 +339,69 @@ function SpoolDetailModal({ spool, onClose }: { spool: InventorySpool; onClose:
 
   return (
     <div className="fixed inset-0 z-50 flex items-end justify-center" onClick={onClose}>
-      {/* Backdrop */}
       <div className="absolute inset-0 bg-black/60" />
 
-      {/* Sheet */}
       <div
-        className="relative w-full max-h-[85vh] bg-bambu-dark rounded-t-2xl overflow-y-auto animate-slide-up"
+        className="relative w-full max-h-[85vh] bg-bambu-dark rounded-t-2xl overflow-y-auto"
         onClick={e => e.stopPropagation()}
       >
-        {/* Color header */}
-        <div className="h-20 relative" style={{ backgroundColor: color }}>
+        {/* Header with spool icon */}
+        <div className="flex items-center gap-4 p-4 pb-3">
+          <SpoolSvg 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>
           <button
             onClick={onClose}
-            className="absolute top-3 right-3 bg-black/40 hover:bg-black/60 text-white rounded-full p-1.5 transition-colors"
+            className="self-start bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/50 hover:text-white rounded-full p-1.5 transition-colors"
           >
             <X className="w-5 h-5" />
           </button>
-          <div className="absolute bottom-3 left-4">
-            <span className="bg-black/50 text-white text-sm px-2.5 py-1 rounded-full">
-              {colorName || t('spoolbuddy.inventory.unknownColor', 'Unknown Color')}
-            </span>
-          </div>
         </div>
 
-        <div className="p-4 space-y-4">
-          {/* Title row */}
-          <div className="flex items-start justify-between gap-3">
-            <div>
-              <h2 className="text-lg font-semibold text-white">
-                {spoolDisplayName(spool)}
-              </h2>
-              {spool.brand && (
-                <p className="text-sm text-white/50">{spool.brand}</p>
-              )}
-            </div>
-            <span className="text-xs font-mono text-white/30 bg-bambu-dark-secondary px-2 py-1 rounded">
-              #{spool.id}
-            </span>
-          </div>
-
+        <div className="px-4 pb-5 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(pct)}%</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 */}
+          {assignment && (
             <div className="flex items-center gap-2">
-              <div className="flex-1 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>
-              <span className="text-sm font-medium text-white min-w-[48px] text-right">
-                {Math.round(remaining)}g
+              <span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
+                {assignmentLabel(assignment)}
               </span>
+              {assignment.printer_name && (
+                <span className="text-xs text-white/40">{assignment.printer_name}</span>
+              )}
             </div>
-          </div>
+          )}
 
           {/* Detail grid */}
-          <div className="grid grid-cols-2 gap-3">
+          <div className="grid grid-cols-2 gap-2.5">
             <DetailItem
               label={t('spoolbuddy.inventory.labelWeight', 'Label Weight')}
               value={`${spool.label_weight}g`}

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


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


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


+ 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-tcT3So11.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BlzSKlaL.css">
+    <script type="module" crossorigin src="/assets/index-1F1LrQ9s.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C5XeRH2I.css">
   </head>
   <body>
     <div id="root"></div>

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