Browse Source

Add visual spool grouping and optional brand/subtype in quick-add (#480)

Group toggle in inventory toolbar collapses identical unused/unassigned
spools into expandable rows/cards with count badges. Grouping key uses
material, subtype, brand, color, and label weight. Brand and subtype
fields now appear in quick-add mode as optional (no asterisk, no
validation). Quantity field restricted to quick-add mode only.
maziggy 3 months ago
parent
commit
e450c3b728

+ 2 - 2
CHANGELOG.md

@@ -30,7 +30,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
 - **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
 
 
 ### New Features
 ### New Features
-- **Bulk Spool Addition & Stock Spools** — Two inventory enhancements for managing large filament collections. **Quick Add mode**: a toggle on the spool form that hides slicer preset, brand, and subtype fields, requiring only material selection — ideal for inventorying filament without a specific slicer profile ("stock" spools). **Bulk create**: a quantity field (1–100) on the spool form that creates multiple identical spools in one transaction via a new `POST /inventory/spools/bulk` endpoint. Stock spools are computed (no database migration) — any spool without a `slicer_filament` is displayed with an amber "Stock" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
+- **Bulk Spool Addition & Stock Spools** ([#480](https://github.com/maziggy/bambuddy/issues/480)) — Inventory enhancements for managing large filament collections. **Quick Add mode**: a toggle on the spool form that shows only material (required), brand, subtype (both optional), color, label weight, and quantity — ideal for inventorying filament without a specific slicer profile ("stock" spools). The quantity field (1–100) only appears in Quick Add mode and creates multiple identical spools in one transaction via `POST /inventory/spools/bulk`. Stock spools are computed (no database migration) — any spool without a `slicer_filament` is displayed with an amber "Stock" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. **Group similar spools**: a "Group" toggle in the inventory toolbar visually collapses identical unused/unassigned spools into a single expandable row or card with a count badge (e.g., "5 identical spools"). Grouping key uses material, subtype, brand, color, and label weight. Used or AMS-assigned spools always appear individually. Group state persists to localStorage. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.
 - **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.
 - **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
@@ -38,7 +38,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ### Improved
 ### Improved
 - **P2S Dual-AMS tray_now Test Coverage** — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for `_resolve_local_slot_from_mapping` (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.
 - **P2S Dual-AMS tray_now Test Coverage** — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for `_resolve_local_slot_from_mapping` (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.
-- **Bulk Spool & Stock Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 10 frontend tests covering `validateForm` with `quickAdd` flag (6 tests for relaxed vs full validation) and `SpoolFormModal` UI behavior (4 tests for quick-add toggle visibility, PA Profile tab hiding, quantity field rendering).
+- **Bulk Spool, Stock & Grouping Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 29 frontend tests: 13 for `SpoolFormModal` covering `validateForm` with `quickAdd` flag (6 tests), quick-add toggle visibility, PA Profile tab hiding, quantity field gating (hidden by default, visible only in quick-add, hidden in edit mode), and brand/subtype optional asterisk removal in quick-add; 16 for inventory grouping logic covering `spoolGroupKey` identity/differentiation (7 tests) and `computeDisplayItems` grouping rules (9 tests for identical/different/used/assigned/single/order/mixed/empty scenarios).
 - **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
 - **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
 - **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
 - **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
 - **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
 - **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.

+ 88 - 6
frontend/src/__tests__/components/SpoolFormBulk.test.tsx

@@ -3,9 +3,11 @@
  *
  *
  * Verifies:
  * Verifies:
  * - Quick-add toggle appears only in create mode
  * - Quick-add toggle appears only in create mode
- * - Quick-add mode hides slicer preset, brand, subtype fields
+ * - Quick-add mode shows brand and subtype as optional (no asterisk)
+ * - Quick-add mode hides slicer preset field
  * - Quick-add mode hides PA Profile tab
  * - Quick-add mode hides PA Profile tab
- * - Quantity field is rendered in filament section
+ * - Quantity field is only rendered in quick-add mode
+ * - Quantity field is hidden in edit mode
  * - Bulk create calls bulkCreateSpools when quantity > 1
  * - Bulk create calls bulkCreateSpools when quantity > 1
  * - Single quantity calls createSpool as before
  * - Single quantity calls createSpool as before
  * - validateForm with quickAdd=true only requires material
  * - validateForm with quickAdd=true only requires material
@@ -199,7 +201,7 @@ describe('SpoolFormModal quick-add toggle', () => {
     });
     });
   });
   });
 
 
-  it('renders quantity field', async () => {
+  it('hides quantity field by default (non-quick-add)', async () => {
     render(
     render(
       <SpoolFormModal
       <SpoolFormModal
         isOpen={true}
         isOpen={true}
@@ -212,8 +214,88 @@ describe('SpoolFormModal quick-add toggle', () => {
       expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
       expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
     });
     });
 
 
-    // Quantity field should be visible
-    expect(screen.getByText('Quantity')).toBeInTheDocument();
-    expect(screen.getByDisplayValue('1')).toBeInTheDocument();
+    // Quantity field should NOT be visible in normal create mode
+    expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
+  });
+
+  it('shows quantity field only in quick-add mode', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Toggle quick-add on
+    const toggleButtons = screen.getAllByRole('button');
+    const quickAddToggle = toggleButtons.find(btn =>
+      btn.getAttribute('type') === 'button' &&
+      btn.className.includes('rounded-full') &&
+      btn.closest('div')?.textContent?.includes('Quick Add')
+    );
+    expect(quickAddToggle).toBeTruthy();
+    fireEvent.click(quickAddToggle!);
+
+    // Quantity field should now be visible
+    await waitFor(() => {
+      expect(screen.getByText('Quantity')).toBeInTheDocument();
+    });
+  });
+
+  it('hides quantity field in edit mode', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Quantity field should NOT be visible in edit mode
+    expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
+  });
+
+  it('shows brand and subtype in quick-add mode without asterisk', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Toggle quick-add on
+    const toggleButtons = screen.getAllByRole('button');
+    const quickAddToggle = toggleButtons.find(btn =>
+      btn.getAttribute('type') === 'button' &&
+      btn.className.includes('rounded-full') &&
+      btn.closest('div')?.textContent?.includes('Quick Add')
+    );
+    fireEvent.click(quickAddToggle!);
+
+    // Brand and Subtype should be visible (without asterisk = optional)
+    await waitFor(() => {
+      const brandLabel = screen.getByText('Brand');
+      expect(brandLabel).toBeInTheDocument();
+      expect(brandLabel.textContent).not.toContain('*');
+
+      const subtypeLabel = screen.getByText('Subtype');
+      expect(subtypeLabel).toBeInTheDocument();
+      expect(subtypeLabel.textContent).not.toContain('*');
+    });
   });
   });
 });
 });

+ 265 - 0
frontend/src/__tests__/pages/InventoryPageGrouping.test.ts

@@ -0,0 +1,265 @@
+/**
+ * Tests for the spool grouping logic used in InventoryPage.
+ *
+ * The grouping is a pure client-side computation:
+ * - Spools with identical material+subtype+brand+color_name+rgba+label_weight are grouped
+ * - Only unused (weight_used === 0) and unassigned spools are eligible for grouping
+ * - Used or assigned spools always appear individually
+ * - Groups with only 1 member remain as singles
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { InventorySpool, SpoolAssignment } from '../../api/client';
+
+// Replicate the grouping key function from InventoryPage (not exported)
+function spoolGroupKey(s: InventorySpool): string {
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
+}
+
+type DisplayItem =
+  | { type: 'single'; spool: InventorySpool }
+  | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
+
+// Replicate the grouping logic from InventoryPage
+function computeDisplayItems(
+  sortedSpools: InventorySpool[],
+  assignmentMap: Record<number, SpoolAssignment>,
+): DisplayItem[] {
+  const groups = new Map<string, InventorySpool[]>();
+
+  for (const spool of sortedSpools) {
+    if (spool.weight_used > 0 || assignmentMap[spool.id]) {
+      // Will be added as singles in the walk below
+    } else {
+      const key = spoolGroupKey(spool);
+      const arr = groups.get(key);
+      if (arr) arr.push(spool);
+      else groups.set(key, [spool]);
+    }
+  }
+
+  const items: DisplayItem[] = [];
+  const processedKeys = new Set<string>();
+
+  for (const spool of sortedSpools) {
+    if (spool.weight_used > 0 || assignmentMap[spool.id]) {
+      items.push({ type: 'single', spool });
+      continue;
+    }
+    const key = spoolGroupKey(spool);
+    if (processedKeys.has(key)) continue;
+    processedKeys.add(key);
+    const members = groups.get(key)!;
+    if (members.length === 1) {
+      items.push({ type: 'single', spool: members[0] });
+    } else {
+      items.push({ type: 'group', key, spools: members, representative: members[0] });
+    }
+  }
+  return items;
+}
+
+function makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {
+  return {
+    material: 'PLA',
+    subtype: 'Basic',
+    brand: 'Polymaker',
+    color_name: 'Red',
+    rgba: 'FF0000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    core_weight_catalog_id: null,
+    weight_used: 0,
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-01T00:00:00Z',
+    updated_at: '2025-01-01T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    ...overrides,
+  };
+}
+
+describe('spoolGroupKey', () => {
+  it('generates same key for identical spools', () => {
+    const a = makeSpool({ id: 1 });
+    const b = makeSpool({ id: 2 });
+    expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
+  });
+
+  it('generates different key when material differs', () => {
+    const a = makeSpool({ id: 1, material: 'PLA' });
+    const b = makeSpool({ id: 2, material: 'PETG' });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
+  it('generates different key when subtype differs', () => {
+    const a = makeSpool({ id: 1, subtype: 'Basic' });
+    const b = makeSpool({ id: 2, subtype: 'Matte' });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
+  it('generates different key when brand differs', () => {
+    const a = makeSpool({ id: 1, brand: 'Polymaker' });
+    const b = makeSpool({ id: 2, brand: 'Bambu Lab' });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
+  it('generates different key when color_name differs', () => {
+    const a = makeSpool({ id: 1, color_name: 'Red' });
+    const b = makeSpool({ id: 2, color_name: 'Blue' });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
+  it('generates different key when label_weight differs', () => {
+    const a = makeSpool({ id: 1, label_weight: 1000 });
+    const b = makeSpool({ id: 2, label_weight: 500 });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
+  it('treats null and empty string subtype the same', () => {
+    const a = makeSpool({ id: 1, subtype: null as unknown as string });
+    const b = makeSpool({ id: 2, subtype: '' });
+    expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
+  });
+});
+
+describe('computeDisplayItems', () => {
+  it('groups identical unused unassigned spools', () => {
+    const spools = [
+      makeSpool({ id: 1 }),
+      makeSpool({ id: 2 }),
+      makeSpool({ id: 3 }),
+    ];
+    const items = computeDisplayItems(spools, {});
+    expect(items).toHaveLength(1);
+    expect(items[0].type).toBe('group');
+    if (items[0].type === 'group') {
+      expect(items[0].spools).toHaveLength(3);
+      expect(items[0].representative.id).toBe(1);
+    }
+  });
+
+  it('does not group spools with different properties', () => {
+    const spools = [
+      makeSpool({ id: 1, material: 'PLA' }),
+      makeSpool({ id: 2, material: 'PETG' }),
+      makeSpool({ id: 3, material: 'ABS' }),
+    ];
+    const items = computeDisplayItems(spools, {});
+    expect(items).toHaveLength(3);
+    expect(items.every((i) => i.type === 'single')).toBe(true);
+  });
+
+  it('excludes used spools from groups', () => {
+    const spools = [
+      makeSpool({ id: 1, weight_used: 0 }),
+      makeSpool({ id: 2, weight_used: 100 }), // used
+      makeSpool({ id: 3, weight_used: 0 }),
+    ];
+    const items = computeDisplayItems(spools, {});
+    // 1 group (id:1, id:3) + 1 single (id:2)
+    expect(items).toHaveLength(2);
+    const group = items.find((i) => i.type === 'group');
+    const single = items.find((i) => i.type === 'single');
+    expect(group).toBeDefined();
+    expect(single).toBeDefined();
+    if (group?.type === 'group') {
+      expect(group.spools).toHaveLength(2);
+      expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
+    }
+    if (single?.type === 'single') {
+      expect(single.spool.id).toBe(2);
+    }
+  });
+
+  it('excludes assigned spools from groups', () => {
+    const spools = [
+      makeSpool({ id: 1 }),
+      makeSpool({ id: 2 }), // assigned
+      makeSpool({ id: 3 }),
+    ];
+    const assignmentMap: Record<number, SpoolAssignment> = {
+      2: {
+        spool_id: 2,
+        printer_id: 1,
+        printer_name: 'P1S',
+        ams_id: 0,
+        tray_id: 0,
+        configured: true,
+        fingerprint_color: null,
+        fingerprint_type: null,
+      },
+    };
+    const items = computeDisplayItems(spools, assignmentMap);
+    // 1 group (id:1, id:3) + 1 single (id:2)
+    expect(items).toHaveLength(2);
+    const group = items.find((i) => i.type === 'group');
+    expect(group?.type).toBe('group');
+    if (group?.type === 'group') {
+      expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
+    }
+  });
+
+  it('does not group a single spool', () => {
+    const spools = [makeSpool({ id: 1 })];
+    const items = computeDisplayItems(spools, {});
+    expect(items).toHaveLength(1);
+    expect(items[0].type).toBe('single');
+  });
+
+  it('preserves order — group appears at first member position', () => {
+    const spools = [
+      makeSpool({ id: 1, material: 'PETG' }), // unique
+      makeSpool({ id: 2, material: 'PLA' }),   // group member
+      makeSpool({ id: 3, material: 'PLA' }),   // group member
+      makeSpool({ id: 4, material: 'ABS' }),   // unique
+    ];
+    const items = computeDisplayItems(spools, {});
+    expect(items).toHaveLength(3);
+    expect(items[0].type).toBe('single'); // PETG
+    expect(items[1].type).toBe('group');  // PLA group at position of id:2
+    expect(items[2].type).toBe('single'); // ABS
+    if (items[1].type === 'group') {
+      expect(items[1].spools.map((s) => s.id)).toEqual([2, 3]);
+    }
+  });
+
+  it('handles mix of groupable and non-groupable spools', () => {
+    const spools = [
+      makeSpool({ id: 1, material: 'PLA' }),                    // groupable
+      makeSpool({ id: 2, material: 'PLA', weight_used: 50 }),   // used → single
+      makeSpool({ id: 3, material: 'PLA' }),                    // groupable
+      makeSpool({ id: 4, material: 'PETG' }),                   // different → single
+    ];
+    const items = computeDisplayItems(spools, {});
+    // PLA group (id:1,3) + PLA used single (id:2) + PETG single (id:4)
+    expect(items).toHaveLength(3);
+  });
+
+  it('returns all singles when no spools can be grouped', () => {
+    const spools = [
+      makeSpool({ id: 1, material: 'PLA', weight_used: 100 }),
+      makeSpool({ id: 2, material: 'PETG', weight_used: 200 }),
+    ];
+    const items = computeDisplayItems(spools, {});
+    expect(items).toHaveLength(2);
+    expect(items.every((i) => i.type === 'single')).toBe(true);
+  });
+
+  it('returns empty array for empty input', () => {
+    const items = computeDisplayItems([], {});
+    expect(items).toHaveLength(0);
+  });
+});

+ 29 - 27
frontend/src/components/spool-form/FilamentSection.tsx

@@ -255,10 +255,11 @@ export function FilamentSection({
         )}
         )}
       </div>
       </div>
 
 
-      {/* Brand (dropdown with search) — hidden in quick-add mode */}
-      {!quickAdd && (
-        <div>
-          <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
+      {/* Brand (dropdown with search) */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">
+          {t('inventory.brand')}{!quickAdd && ' *'}
+        </label>
           <div className="relative" ref={brandRef}>
           <div className="relative" ref={brandRef}>
             <input
             <input
               type="text"
               type="text"
@@ -317,13 +318,13 @@ export function FilamentSection({
           {errors?.brand && (
           {errors?.brand && (
             <p className="mt-1 text-xs text-red-400">{errors.brand}</p>
             <p className="mt-1 text-xs text-red-400">{errors.brand}</p>
           )}
           )}
-        </div>
-      )}
+      </div>
 
 
-      {/* Variant / Subtype — hidden in quick-add mode */}
-      {!quickAdd && (
-        <div>
-          <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
+      {/* Variant / Subtype */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">
+          {t('inventory.subtype')}{!quickAdd && ' *'}
+        </label>
           <div className="relative" ref={subtypeRef}>
           <div className="relative" ref={subtypeRef}>
             <input
             <input
               type="text"
               type="text"
@@ -381,8 +382,7 @@ export function FilamentSection({
           {errors?.subtype && (
           {errors?.subtype && (
             <p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
             <p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
           )}
           )}
-        </div>
-      )}
+      </div>
 
 
       {/* Label Weight */}
       {/* Label Weight */}
       <div>
       <div>
@@ -412,21 +412,23 @@ export function FilamentSection({
         </div>
         </div>
       </div>
       </div>
 
 
-      {/* Quantity */}
-      <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
-        <input
-          type="number"
-          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
-          value={quantity}
-          min={1}
-          max={100}
-          onChange={(e) => {
-            const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
-            onQuantityChange(val);
-          }}
-        />
-      </div>
+      {/* Quantity — only in quick-add mode */}
+      {quickAdd && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
+          <input
+            type="number"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+            value={quantity}
+            min={1}
+            max={100}
+            onChange={(e) => {
+              const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
+              onQuantityChange(val);
+            }}
+          />
+        </div>
+      )}
 
 
     </div>
     </div>
   );
   );

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

@@ -2668,6 +2668,10 @@ export default {
     table: 'Tabelle',
     table: 'Tabelle',
     cards: 'Karten',
     cards: 'Karten',
     net: 'Netto',
     net: 'Netto',
+    // Grouping
+    groupSimilar: 'Gruppieren',
+    groupedSpools: '{{count}} identische Spulen',
+    groupedRows: 'Zeilen',
     // Column config
     // Column config
     columns: 'Spalten',
     columns: 'Spalten',
     configureColumns: 'Spalten konfigurieren',
     configureColumns: 'Spalten konfigurieren',

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

@@ -2672,6 +2672,10 @@ export default {
     table: 'Table',
     table: 'Table',
     cards: 'Cards',
     cards: 'Cards',
     net: 'Net',
     net: 'Net',
+    // Grouping
+    groupSimilar: 'Group',
+    groupedSpools: '{{count}} identical spools',
+    groupedRows: 'rows',
     // Column config
     // Column config
     columns: 'Columns',
     columns: 'Columns',
     configureColumns: 'Configure Columns',
     configureColumns: 'Configure Columns',

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

@@ -2594,6 +2594,9 @@ export default {
     table: 'テーブル',
     table: 'テーブル',
     cards: 'カード',
     cards: 'カード',
     net: '正味',
     net: '正味',
+    groupSimilar: 'グループ化',
+    groupedSpools: '{{count}}本の同一スプール',
+    groupedRows: '行',
     columns: '列',
     columns: '列',
     configureColumns: '列の設定',
     configureColumns: '列の設定',
     configureColumnsDesc: 'ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。',
     configureColumnsDesc: 'ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。',

+ 407 - 112
frontend/src/pages/InventoryPage.tsx

@@ -5,7 +5,7 @@ import {
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
-  ArrowUp, ArrowDown, ArrowUpDown,
+  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown,
 } from 'lucide-react';
 } 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';
@@ -25,6 +25,14 @@ type ViewMode = 'table' | 'cards';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
 type SortState = { column: string; direction: SortDirection } | null;
 type SortState = { column: string; direction: SortDirection } | null;
 
 
+type DisplayItem =
+  | { type: 'single'; spool: InventorySpool }
+  | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
+
+function spoolGroupKey(s: InventorySpool): string {
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
+}
+
 // Column definitions for the inventory table
 // Column definitions for the inventory table
 const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
 const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
 
 
@@ -101,7 +109,7 @@ const MATERIAL_COLORS: Record<string, string> = {
   PET: 'bg-sky-500/20 text-sky-400',
   PET: 'bg-sky-500/20 text-sky-400',
 };
 };
 
 
-type TFn = (key: string) => string;
+type TFn = (key: string, opts?: Record<string, unknown>) => string;
 
 
 function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
 function formatInventoryDate(dateStr: string | null, dateFormat: DateFormat = 'system'): string {
   if (!dateStr) return '-';
   if (!dateStr) return '-';
@@ -347,6 +355,12 @@ export default function InventoryPage() {
   const [sortState, setSortState] = useState<SortState>(loadSortState);
   const [sortState, setSortState] = useState<SortState>(loadSortState);
   const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
   const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
   const [showColumnModal, setShowColumnModal] = useState(false);
   const [showColumnModal, setShowColumnModal] = useState(false);
+  const [groupSimilar, setGroupSimilar] = useState(() => {
+    try {
+      return localStorage.getItem('bambuddy-inventory-group') === 'true';
+    } catch { return false; }
+  });
+  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
 
 
   // Pagination state (pageSize persisted to localStorage)
   // Pagination state (pageSize persisted to localStorage)
   const [pageIndex, setPageIndex] = useState(0);
   const [pageIndex, setPageIndex] = useState(0);
@@ -552,12 +566,71 @@ export default function InventoryPage() {
     return sorted;
     return sorted;
   }, [filteredSpools, sortState, assignmentMap]);
   }, [filteredSpools, sortState, assignmentMap]);
 
 
+  // Group similar spools when toggle is active
+  const displayItems = useMemo((): DisplayItem[] => {
+    if (!groupSimilar) return sortedSpools.map((s) => ({ type: 'single' as const, spool: s }));
+
+    const groups = new Map<string, InventorySpool[]>();
+
+    for (const spool of sortedSpools) {
+      // Only group unused & unassigned spools
+      if (spool.weight_used > 0 || assignmentMap[spool.id]) {
+        // Will be added as singles in the walk below
+      } else {
+        const key = spoolGroupKey(spool);
+        const arr = groups.get(key);
+        if (arr) arr.push(spool);
+        else groups.set(key, [spool]);
+      }
+    }
+
+    const items: DisplayItem[] = [];
+    const processedKeys = new Set<string>();
+
+    // Walk sortedSpools order so groups appear at the position of their first member
+    for (const spool of sortedSpools) {
+      if (spool.weight_used > 0 || assignmentMap[spool.id]) {
+        items.push({ type: 'single', spool });
+        continue;
+      }
+      const key = spoolGroupKey(spool);
+      if (processedKeys.has(key)) continue;
+      processedKeys.add(key);
+      const members = groups.get(key)!;
+      if (members.length === 1) {
+        items.push({ type: 'single', spool: members[0] });
+      } else {
+        items.push({ type: 'group', key, spools: members, representative: members[0] });
+      }
+    }
+    return items;
+  }, [sortedSpools, groupSimilar, assignmentMap]);
+
   // Pagination (after sorting) — pageSize -1 means "All"
   // Pagination (after sorting) — pageSize -1 means "All"
   const showAll = pageSize === -1;
   const showAll = pageSize === -1;
-  const effectivePageSize = showAll ? sortedSpools.length || 1 : pageSize;
-  const totalPages = Math.max(1, Math.ceil(sortedSpools.length / effectivePageSize));
+  const totalDisplayItems = displayItems.length;
+  const effectivePageSize = showAll ? totalDisplayItems || 1 : pageSize;
+  const totalPages = Math.max(1, Math.ceil(totalDisplayItems / effectivePageSize));
   const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);
   const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);
-  const pagedSpools = showAll ? sortedSpools : sortedSpools.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
+  const pagedItems = showAll
+    ? displayItems
+    : displayItems.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
+  const toggleGroupSimilar = () => {
+    const next = !groupSimilar;
+    setGroupSimilar(next);
+    setExpandedGroups(new Set());
+    resetPage();
+    try { localStorage.setItem('bambuddy-inventory-group', String(next)); } catch { /* ignore */ }
+  };
+
+  const toggleGroupExpand = (key: string) => {
+    setExpandedGroups((prev) => {
+      const next = new Set(prev);
+      if (next.has(key)) next.delete(key);
+      else next.add(key);
+      return next;
+    });
+  };
 
 
   const handlePageSizeChange = (size: number) => {
   const handlePageSizeChange = (size: number) => {
     setPageSize(size);
     setPageSize(size);
@@ -688,6 +761,19 @@ export default function InventoryPage() {
               <span className="hidden sm:inline">{t('inventory.columns')}</span>
               <span className="hidden sm:inline">{t('inventory.columns')}</span>
             </button>
             </button>
           )}
           )}
+          {/* Group similar toggle */}
+          <button
+            onClick={toggleGroupSimilar}
+            className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border rounded-lg transition-colors ${
+              groupSimilar
+                ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
+                : 'text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
+            }`}
+            title={t('inventory.groupSimilar')}
+          >
+            <Group className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('inventory.groupSimilar')}</span>
+          </button>
           {/* Table / Cards toggle */}
           {/* Table / Cards toggle */}
           <div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
           <div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
             <button
             <button
@@ -866,6 +952,7 @@ export default function InventoryPage() {
         {/* Results count */}
         {/* Results count */}
         <span className="ml-auto text-xs text-bambu-gray">
         <span className="ml-auto text-xs text-bambu-gray">
           {sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
           {sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
+          {groupSimilar && totalDisplayItems < sortedSpools.length && ` (${totalDisplayItems} ${t('inventory.groupedRows')})`}
         </span>
         </span>
       </div>
       </div>
 
 
@@ -876,69 +963,80 @@ export default function InventoryPage() {
         </div>
         </div>
       ) : viewMode === 'cards' ? (
       ) : viewMode === 'cards' ? (
         /* Cards view */
         /* Cards view */
-        pagedSpools.length > 0 ? (
+        pagedItems.length > 0 ? (
           <>
           <>
             <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
             <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
-              {pagedSpools.map((spool) => {
-                const remaining = Math.max(0, spool.label_weight - spool.weight_used);
-                const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
-                const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
-                return (
-                  <div
-                    key={spool.id}
-                    className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
-                    onClick={() => setFormModal({ spool })}
-                  >
-                    {/* Color header */}
-                    <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
-                      <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
-                        {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
-                      </span>
-                    </div>
-                    {/* Content */}
-                    <div className="p-4 space-y-3">
-                      <div className="flex items-start justify-between gap-2">
-                        <div>
-                          <h3 className="font-semibold text-white">{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}</h3>
-                          <p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
-                        </div>
-                        <span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">#{spool.id}</span>
-                      </div>
-                      {/* Progress */}
-                      <div>
-                        <div className="flex justify-between text-xs text-bambu-gray mb-1">
-                          <span>{t('inventory.remaining')}</span>
-                          <span>{Math.round(pct)}%</span>
+              {pagedItems.map((item) => {
+                if (item.type === 'group') {
+                  const { key, spools: groupSpools, representative: rep } = item;
+                  const colorStyle = rep.rgba ? `#${rep.rgba.substring(0, 6)}` : '#808080';
+                  const isExpanded = expandedGroups.has(key);
+                  return (
+                    <div key={`group-${key}`} className="col-span-full">
+                      {/* Group header card */}
+                      <div
+                        className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer"
+                        onClick={() => toggleGroupExpand(key)}
+                      >
+                        <div className="h-10 flex items-center px-4 gap-3" style={{ backgroundColor: colorStyle }}>
+                          <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
+                            {resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}
+                          </span>
                         </div>
                         </div>
-                        <div className="flex items-center gap-2">
-                          <div className="flex-1 h-2 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 className="px-4 py-3 flex items-center justify-between">
+                          <div className="flex items-center gap-3">
+                            <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
+                            <div>
+                              <h3 className="font-semibold text-white">{rep.material}{rep.subtype ? ` ${rep.subtype}` : ''}</h3>
+                              <p className="text-sm text-bambu-gray">{rep.brand || '-'}</p>
+                            </div>
+                          </div>
+                          <div className="flex items-center gap-2">
+                            <span className="text-sm text-bambu-gray">{formatWeight(rep.label_weight)}</span>
+                            <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
+                              {t('inventory.groupedSpools', { count: groupSpools.length })}
+                            </span>
                           </div>
                           </div>
-                          <span className="text-xs text-bambu-gray min-w-[40px] text-right">{Math.round(remaining)}g</span>
-                        </div>
-                      </div>
-                      {/* Weight info */}
-                      <div className="grid grid-cols-2 gap-2 text-xs">
-                        <div>
-                          <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
-                          <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
-                        </div>
-                        <div>
-                          <span className="text-bambu-gray/60">{t('inventory.weightUsed')}: </span>
-                          <span className="text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
                         </div>
                         </div>
                       </div>
                       </div>
-                      {/* Note */}
-                      {spool.note && (
-                        <div className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate" title={spool.note}>
-                          {spool.note}
+                      {/* Expanded individual spools */}
+                      {isExpanded && (
+                        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-2 ml-4">
+                          {groupSpools.map((spool) => {
+                            const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+                            const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
+                            const spoolColor = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
+                            return (
+                              <SpoolCard
+                                key={spool.id}
+                                spool={spool}
+                                remaining={remaining}
+                                pct={pct}
+                                colorStyle={spoolColor}
+                                onClick={() => setFormModal({ spool })}
+                                t={t}
+                              />
+                            );
+                          })}
                         </div>
                         </div>
                       )}
                       )}
                     </div>
                     </div>
-                  </div>
+                  );
+                }
+                const spool = item.spool;
+                const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+                const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
+                const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
+                return (
+                  <SpoolCard
+                    key={spool.id}
+                    spool={spool}
+                    remaining={remaining}
+                    pct={pct}
+                    colorStyle={colorStyle}
+                    onClick={() => setFormModal({ spool })}
+                    t={t}
+                  />
                 );
                 );
               })}
               })}
             </div>
             </div>
@@ -946,7 +1044,7 @@ export default function InventoryPage() {
             <PaginationBar
             <PaginationBar
               pageIndex={safePageIndex}
               pageIndex={safePageIndex}
               pageSize={pageSize}
               pageSize={pageSize}
-              totalRows={sortedSpools.length}
+              totalRows={totalDisplayItems}
               totalPages={totalPages}
               totalPages={totalPages}
               onPageChange={setPageIndex}
               onPageChange={setPageIndex}
               onPageSizeChange={handlePageSizeChange}
               onPageSizeChange={handlePageSizeChange}
@@ -962,7 +1060,7 @@ export default function InventoryPage() {
         )
         )
       ) : (
       ) : (
         /* Table view */
         /* Table view */
-        pagedSpools.length > 0 ? (
+        pagedItems.length > 0 ? (
           <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
           <div className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary">
             <div className="overflow-x-auto">
             <div className="overflow-x-auto">
               <table className="w-full">
               <table className="w-full">
@@ -996,58 +1094,51 @@ export default function InventoryPage() {
                   </tr>
                   </tr>
                 </thead>
                 </thead>
                 <tbody>
                 <tbody>
-                  {pagedSpools.map((spool) => {
+                  {pagedItems.map((item) => {
+                    if (item.type === 'group') {
+                      const { key, spools: groupSpools, representative: rep } = item;
+                      const isExpanded = expandedGroups.has(key);
+                      const remaining = Math.max(0, rep.label_weight - rep.weight_used);
+                      const pct = rep.label_weight > 0 ? (remaining / rep.label_weight) * 100 : 0;
+                      return (
+                        <SpoolTableGroup
+                          key={`group-${key}`}
+                          spools={groupSpools}
+                          representative={rep}
+                          remaining={remaining}
+                          pct={pct}
+                          isExpanded={isExpanded}
+                          onToggle={() => toggleGroupExpand(key)}
+                          onEdit={(s) => setFormModal({ spool: s })}
+                          onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
+                          onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
+                          visibleColumns={visibleColumns}
+                          assignmentMap={assignmentMap}
+                          currencySymbol={currencySymbol}
+                          dateFormat={dateFormat}
+                          t={t}
+                        />
+                      );
+                    }
+                    const spool = item.spool;
                     const remaining = Math.max(0, spool.label_weight - spool.weight_used);
                     const remaining = Math.max(0, spool.label_weight - spool.weight_used);
                     const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
                     const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
                     return (
                     return (
-                      <tr
+                      <SpoolTableRow
                         key={spool.id}
                         key={spool.id}
-                        className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
-                          spool.archived_at ? 'opacity-50' : ''
-                        }`}
-                        onClick={() => setFormModal({ spool })}
-                      >
-                        {visibleColumns.map((colId) => (
-                          <td key={colId} className="py-3 px-4">
-                            {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
-                          </td>
-                        ))}
-                        <td className="py-3 px-4">
-                          <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
-                            <button
-                              onClick={() => setFormModal({ spool })}
-                              className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
-                              title={t('inventory.editSpool')}
-                            >
-                              <Edit2 className="w-4 h-4" />
-                            </button>
-                            {spool.archived_at ? (
-                              <button
-                                onClick={() => restoreMutation.mutate(spool.id)}
-                                className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors"
-                                title={t('inventory.restore')}
-                              >
-                                <RotateCcw className="w-4 h-4" />
-                              </button>
-                            ) : (
-                              <button
-                                onClick={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
-                                className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors"
-                                title={t('inventory.archive')}
-                              >
-                                <Archive className="w-4 h-4" />
-                              </button>
-                            )}
-                            <button
-                              onClick={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
-                              className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors"
-                              title={t('common.delete')}
-                            >
-                              <Trash2 className="w-4 h-4" />
-                            </button>
-                          </div>
-                        </td>
-                      </tr>
+                        spool={spool}
+                        remaining={remaining}
+                        pct={pct}
+                        onEdit={() => setFormModal({ spool })}
+                        onRestore={() => restoreMutation.mutate(spool.id)}
+                        onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
+                        onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
+                        visibleColumns={visibleColumns}
+                        assignmentMap={assignmentMap}
+                        currencySymbol={currencySymbol}
+                        dateFormat={dateFormat}
+                        t={t}
+                      />
                     );
                     );
                   })}
                   })}
                 </tbody>
                 </tbody>
@@ -1058,10 +1149,10 @@ export default function InventoryPage() {
             <div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
             <div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
               <span className="text-bambu-gray">
               <span className="text-bambu-gray">
                 {showAll
                 {showAll
-                  ? `${sortedSpools.length} ${sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}`
+                  ? `${totalDisplayItems} ${totalDisplayItems !== 1 ? t('inventory.spools') : t('inventory.spool')}`
                   : <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '}
                   : <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '}
-                    {Math.min((safePageIndex + 1) * effectivePageSize, sortedSpools.length)}{' '}
-                    {t('inventory.of')} {sortedSpools.length} {t('inventory.spools')}</>
+                    {Math.min((safePageIndex + 1) * effectivePageSize, totalDisplayItems)}{' '}
+                    {t('inventory.of')} {totalDisplayItems} {t('inventory.spools')}</>
                 }
                 }
               </span>
               </span>
 
 
@@ -1245,6 +1336,210 @@ function PaginationBar({
   );
   );
 }
 }
 
 
+/* Spool card for cards view */
+function SpoolCard({
+  spool, remaining, pct, colorStyle, onClick, t,
+}: {
+  spool: InventorySpool;
+  remaining: number;
+  pct: number;
+  colorStyle: string;
+  onClick: () => void;
+  t: (key: string, opts?: Record<string, unknown>) => string;
+}) {
+  return (
+    <div
+      className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
+      onClick={onClick}
+    >
+      <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
+        <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
+          {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
+        </span>
+      </div>
+      <div className="p-4 space-y-3">
+        <div className="flex items-start justify-between gap-2">
+          <div>
+            <h3 className="font-semibold text-white">
+              {spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
+            </h3>
+            <p className="text-sm text-bambu-gray">{spool.brand || '-'}</p>
+          </div>
+          <span className="text-xs font-mono text-bambu-gray bg-bambu-dark-tertiary px-2 py-1 rounded">
+            #{spool.id}
+          </span>
+        </div>
+        <div>
+          <div className="flex justify-between text-xs text-bambu-gray mb-1">
+            <span>{t('inventory.remaining')}</span>
+            <span>{Math.round(pct)}%</span>
+          </div>
+          <div className="flex items-center gap-2">
+            <div className="flex-1 h-2 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-xs text-bambu-gray min-w-[40px] text-right">
+              {Math.round(remaining)}g
+            </span>
+          </div>
+        </div>
+        <div className="grid grid-cols-2 gap-2 text-xs">
+          <div>
+            <span className="text-bambu-gray/60">{t('inventory.labelWeight')}: </span>
+            <span className="text-bambu-gray">{formatWeight(spool.label_weight)}</span>
+          </div>
+          <div>
+            <span className="text-bambu-gray/60">{t('inventory.weightUsed')}: </span>
+            <span className="text-bambu-gray">
+              {spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}
+            </span>
+          </div>
+        </div>
+        {spool.note && (
+          <div
+            className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate"
+            title={spool.note}
+          >
+            {spool.note}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+/* Single spool row for table view */
+function SpoolTableRow({
+  spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,
+  visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
+}: {
+  spool: InventorySpool;
+  remaining: number;
+  pct: number;
+  onEdit: () => void;
+  onRestore: () => void;
+  onArchive: () => void;
+  onDelete: () => void;
+  visibleColumns: string[];
+  assignmentMap: Record<number, SpoolAssignment>;
+  currencySymbol: string;
+  dateFormat: DateFormat;
+  t: TFn;
+}) {
+  return (
+    <tr
+      className={`border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer ${
+        spool.archived_at ? 'opacity-50' : ''
+      }`}
+      onClick={onEdit}
+    >
+      {visibleColumns.map((colId) => (
+        <td key={colId} className="py-3 px-4">
+          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
+        </td>
+      ))}
+      <td className="py-3 px-4">
+        <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
+          <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.editSpool')}>
+            <Edit2 className="w-4 h-4" />
+          </button>
+          {spool.archived_at ? (
+            <button onClick={onRestore} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.restore')}>
+              <RotateCcw className="w-4 h-4" />
+            </button>
+          ) : (
+            <button onClick={onArchive} className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors" title={t('inventory.archive')}>
+              <Archive className="w-4 h-4" />
+            </button>
+          )}
+          <button onClick={onDelete} className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors" title={t('common.delete')}>
+            <Trash2 className="w-4 h-4" />
+          </button>
+        </div>
+      </td>
+    </tr>
+  );
+}
+
+/* Grouped spool rows for table view */
+function SpoolTableGroup({
+  spools, representative, remaining, pct, isExpanded, onToggle,
+  onEdit, onArchive, onDelete,
+  visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
+}: {
+  spools: InventorySpool[];
+  representative: InventorySpool;
+  remaining: number;
+  pct: number;
+  isExpanded: boolean;
+  onToggle: () => void;
+  onEdit: (spool: InventorySpool) => void;
+  onArchive: (id: number) => void;
+  onDelete: (id: number) => void;
+  visibleColumns: string[];
+  assignmentMap: Record<number, SpoolAssignment>;
+  currencySymbol: string;
+  dateFormat: DateFormat;
+  t: TFn;
+}) {
+  return (
+    <>
+      {/* Group header row */}
+      <tr
+        className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-tertiary/30 transition-colors cursor-pointer bg-bambu-green/5"
+        onClick={onToggle}
+      >
+        {visibleColumns.map((colId, idx) => (
+          <td key={colId} className="py-3 px-4">
+            {idx === 0 ? (
+              <div className="flex items-center gap-2">
+                <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
+                {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
+              </div>
+            ) : colId === 'id' ? (
+              <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
+                {t('inventory.groupedSpools', { count: spools.length })}
+              </span>
+            ) : (
+              columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })
+            )}
+          </td>
+        ))}
+        <td className="py-3 px-4">
+          <span className="text-xs text-bambu-gray">
+            {spools.map((s) => `#${s.id}`).join(', ')}
+          </span>
+        </td>
+      </tr>
+      {/* Expanded individual rows */}
+      {isExpanded && spools.map((spool) => {
+        const r = Math.max(0, spool.label_weight - spool.weight_used);
+        const p = spool.label_weight > 0 ? (r / spool.label_weight) * 100 : 0;
+        return (
+          <SpoolTableRow
+            key={spool.id}
+            spool={spool}
+            remaining={r}
+            pct={p}
+            onEdit={() => onEdit(spool)}
+            onRestore={() => {}}
+            onArchive={() => onArchive(spool.id)}
+            onDelete={() => onDelete(spool.id)}
+            visibleColumns={visibleColumns}
+            assignmentMap={assignmentMap}
+            currencySymbol={currencySymbol}
+            dateFormat={dateFormat}
+            t={t}
+          />
+        );
+      })}
+    </>
+  );
+}
+
 /* Empty state matching SpoolBuddy's design */
 /* Empty state matching SpoolBuddy's design */
 function EmptyFilterState({
 function EmptyFilterState({
   hasFilters,
   hasFilters,

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


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


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


+ 2 - 2
static/index.html

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

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