Parcourir la source

fix(inventory): show group totals on collapsed grouped rows (issue #1368)

  With "Group similar" enabled, the collapsed group row showed a single
  member's values — a group of five 1 kg spools displayed 1000 g instead
  of 5 kg (#1368).

  The group header now aggregates across all members: the table view's
  Label / Net / Gross / Used / Remaining columns and the grid card's
  weight figure show group totals. Identity columns (Material, Brand,
  Colour) and the Cost/kg rate stay per-spool-correct since they are the
  group key; per-spool-only fields with no meaningful total (dates,
  location, note, tag ID) keep the representative member's value. The
  expanded per-spool rows are unchanged.

  New aggregateGroupSpool helper with 4 unit tests; the table group
  component's header prop is renamed representative -> headerSpool.
  Frontend-only — all data was already in the spool list.
maziggy il y a 6 jours
Parent
commit
7aad3fb395

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
CHANGELOG.md


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

@@ -10,6 +10,7 @@
 
 
 import { describe, it, expect } from 'vitest';
 import { describe, it, expect } from 'vitest';
 import type { InventorySpool, SpoolAssignment } from '../../api/client';
 import type { InventorySpool, SpoolAssignment } from '../../api/client';
+import { aggregateGroupSpool } from '../../utils/inventoryGrouping';
 
 
 // Replicate the grouping key function from InventoryPage (not exported).
 // Replicate the grouping key function from InventoryPage (not exported).
 // Must stay in lockstep with InventoryPage.tsx::spoolGroupKey — extra_colors
 // Must stay in lockstep with InventoryPage.tsx::spoolGroupKey — extra_colors
@@ -290,3 +291,50 @@ describe('computeDisplayItems', () => {
     expect(items).toHaveLength(0);
     expect(items).toHaveLength(0);
   });
   });
 });
 });
+
+describe('aggregateGroupSpool (#1368)', () => {
+  it('sums label_weight, weight_used and core_weight across members', () => {
+    const agg = aggregateGroupSpool([
+      makeSpool({ id: 1, label_weight: 1000, weight_used: 200, core_weight: 250 }),
+      makeSpool({ id: 2, label_weight: 1000, weight_used: 50, core_weight: 250 }),
+      makeSpool({ id: 3, label_weight: 1000, weight_used: 0, core_weight: 250 }),
+    ]);
+    expect(agg.label_weight).toBe(3000);
+    expect(agg.weight_used).toBe(250);
+    expect(agg.core_weight).toBe(750);
+  });
+
+  it('aggregate remaining equals the sum of per-member remaining', () => {
+    const members = [
+      makeSpool({ id: 1, label_weight: 1000, weight_used: 200 }), // 800
+      makeSpool({ id: 2, label_weight: 1000, weight_used: 600 }), // 400
+    ];
+    const agg = aggregateGroupSpool(members);
+    const perMember = members.reduce(
+      (sum, s) => sum + Math.max(0, s.label_weight - s.weight_used),
+      0,
+    );
+    expect(agg.label_weight - agg.weight_used).toBe(perMember);
+    expect(agg.label_weight - agg.weight_used).toBe(1200);
+  });
+
+  it('carries identity fields from the first member', () => {
+    const agg = aggregateGroupSpool([
+      makeSpool({ id: 7, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
+      makeSpool({ id: 8, material: 'PETG', brand: 'Bambu Lab', color_name: 'Blue', rgba: '0000FFFF' }),
+    ]);
+    expect(agg.id).toBe(7);
+    expect(agg.material).toBe('PETG');
+    expect(agg.brand).toBe('Bambu Lab');
+    expect(agg.color_name).toBe('Blue');
+  });
+
+  it('returns a member equivalent for a single-element group', () => {
+    const agg = aggregateGroupSpool([
+      makeSpool({ id: 1, label_weight: 750, weight_used: 100, core_weight: 200 }),
+    ]);
+    expect(agg.label_weight).toBe(750);
+    expect(agg.weight_used).toBe(100);
+    expect(agg.core_weight).toBe(200);
+  });
+});

+ 23 - 9
frontend/src/pages/InventoryPage.tsx

@@ -25,6 +25,7 @@ import { getCurrencySymbol } from '../utils/currency';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatSlotLabel } from '../utils/amsHelpers';
 import { formatSlotLabel } from '../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
+import { aggregateGroupSpool } from '../utils/inventoryGrouping';
 
 
 type ArchiveFilter = 'active' | 'archived';
 type ArchiveFilter = 'active' | 'archived';
 type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
 type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
@@ -1584,6 +1585,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
               {pagedItems.map((item) => {
               {pagedItems.map((item) => {
                 if (item.type === 'group') {
                 if (item.type === 'group') {
                   const { key, spools: groupSpools, representative: rep } = item;
                   const { key, spools: groupSpools, representative: rep } = item;
+                  // Total remaining filament across the group (#1368) — the
+                  // headline number for the collapsed card, vs one member's.
+                  const groupRemaining = groupSpools.reduce(
+                    (sum, s) => sum + Math.max(0, s.label_weight - s.weight_used),
+                    0,
+                  );
                   const groupBannerStyle = buildFilamentBackground({
                   const groupBannerStyle = buildFilamentBackground({
                     rgba: rep.rgba,
                     rgba: rep.rgba,
                     extraColors: rep.extra_colors,
                     extraColors: rep.extra_colors,
@@ -1613,7 +1620,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                             </div>
                             </div>
                           </div>
                           </div>
                           <div className="flex items-center gap-2">
                           <div className="flex items-center gap-2">
-                            <span className="text-sm text-bambu-gray">{formatWeight(rep.label_weight)}</span>
+                            <span className="text-sm text-bambu-gray" title={t('inventory.remaining')}>
+                              {formatWeight(groupRemaining)}
+                            </span>
                             <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
                             <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 })}
                               {t('inventory.groupedSpools', { count: groupSpools.length })}
                             </span>
                             </span>
@@ -1717,15 +1726,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                 <tbody>
                 <tbody>
                   {pagedItems.map((item) => {
                   {pagedItems.map((item) => {
                     if (item.type === 'group') {
                     if (item.type === 'group') {
-                      const { key, spools: groupSpools, representative: rep } = item;
+                      const { key, spools: groupSpools } = item;
                       const isExpanded = expandedGroups.has(key);
                       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;
+                      // Header row shows group totals (#1368): an aggregate
+                      // spool plus remaining / pct summed across all members.
+                      const headerSpool = aggregateGroupSpool(groupSpools);
+                      const remaining = Math.max(0, headerSpool.label_weight - headerSpool.weight_used);
+                      const pct = headerSpool.label_weight > 0 ? (remaining / headerSpool.label_weight) * 100 : 0;
                       return (
                       return (
                         <SpoolTableGroup
                         <SpoolTableGroup
                           key={`group-${key}`}
                           key={`group-${key}`}
                           spools={groupSpools}
                           spools={groupSpools}
-                          representative={rep}
+                          headerSpool={headerSpool}
                           remaining={remaining}
                           remaining={remaining}
                           pct={pct}
                           pct={pct}
                           isExpanded={isExpanded}
                           isExpanded={isExpanded}
@@ -2180,12 +2192,14 @@ function SpoolTableRow({
 
 
 /* Grouped spool rows for table view */
 /* Grouped spool rows for table view */
 function SpoolTableGroup({
 function SpoolTableGroup({
-  spools, representative, remaining, pct, isExpanded, onToggle,
+  spools, headerSpool, remaining, pct, isExpanded, onToggle,
   onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetUsage,
   onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetUsage,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
 }: {
   spools: InventorySpool[];
   spools: InventorySpool[];
-  representative: InventorySpool;
+  // Aggregate of all members (summed quantities, shared identity) — rendered
+  // in the collapsed header row so it shows group totals (#1368).
+  headerSpool: InventorySpool;
   remaining: number;
   remaining: number;
   pct: number;
   pct: number;
   isExpanded: boolean;
   isExpanded: boolean;
@@ -2216,14 +2230,14 @@ function SpoolTableGroup({
             {idx === 0 ? (
             {idx === 0 ? (
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
                 <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
-                {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
+                {columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
               </div>
               </div>
             ) : colId === 'id' ? (
             ) : colId === 'id' ? (
               <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
               <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 })}
                 {t('inventory.groupedSpools', { count: spools.length })}
               </span>
               </span>
             ) : (
             ) : (
-              columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })
+              columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })
             )}
             )}
           </td>
           </td>
         ))}
         ))}

+ 24 - 0
frontend/src/utils/inventoryGrouping.ts

@@ -0,0 +1,24 @@
+import type { InventorySpool } from '../api/client';
+
+// Synthesize a spool-shaped object for a group's collapsed header row: the
+// quantity fields (label_weight, weight_used, core_weight) are summed across
+// members so the header shows group totals (#1368), while identity fields are
+// carried from the first member — all members share them, they are the group
+// key. The expanded per-spool rows keep using the real per-member spools.
+export function aggregateGroupSpool(spools: InventorySpool[]): InventorySpool {
+  const base = spools[0];
+  let labelWeight = 0;
+  let weightUsed = 0;
+  let coreWeight = 0;
+  for (const s of spools) {
+    labelWeight += s.label_weight;
+    weightUsed += s.weight_used;
+    coreWeight += s.core_weight;
+  }
+  return {
+    ...base,
+    label_weight: labelWeight,
+    weight_used: weightUsed,
+    core_weight: coreWeight,
+  };
+}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DG9hmpJq.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- 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-D23KVJz4.js"></script>
+    <script type="module" crossorigin src="/assets/index-DG9hmpJq.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-QwBJJHPy.css">
     <link rel="stylesheet" crossorigin href="/assets/index-QwBJJHPy.css">
   </head>
   </head>
   <body>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff