Browse Source

feat(inventory): sort-by-colour toggle in label-print modal (#1410)

  Reporter asked for an option to order printed label sheets by colour
  instead of spool number so multi-colour rolls group related colours
  together physically on the sheet.

  Backend (labels.py) already preserves caller order, so this is
  frontend-only. LabelTemplatePickerModal gains a "Sort: By ID / By
  colour" chip pair next to the material filter. Colour mode converts
  each spool's rgba to HSL: chromatic colours (s >= 0.1) cluster in
  bucket 0 ordered by hue 0..360, achromatic colours go in bucket 1
  ordered by lightness so neutrals trail the rainbow black -> white.
  Stable tiebreaker on spool ID.

  Also fixes a latent issue exposed by the same code: the submit was
  always re-sorting selected IDs ascending, which would have clobbered
  any frontend order. Submit now uses sortedSpools.filter().map() so
  the visible order flows through to the PDF.

  Session-only state; toggle resets to "By ID" each time the modal
  opens. 3 new i18n keys translated across all 8 locales (parity 4852
  leaves). 2 new modal tests pin the colour-sort payload order and
  the unchanged ID-default. 17 modal tests + i18n parity + build all
  green.
maziggy 1 week ago
parent
commit
fcd1801aab

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 60 - 0
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -319,4 +319,64 @@ describe('LabelTemplatePickerModal', () => {
     expect(spoolListScroller!.className).toContain('min-h-0');
     expect(spoolListScroller!.className).not.toMatch(/min-h-\[\d/);
   });
+
+  // #1410: an "ID | colour" sort toggle in the modal must flow through to the
+  // PDF — the backend (labels.py) prints in the order it receives spool_ids,
+  // so the modal's "submit in ID order" default was forcing every PDF to
+  // appear in spool-number order regardless of user choice. Toggling to
+  // colour mode must reorder both the visible list AND the payload so the
+  // printed sheet groups colours together.
+  it('sorts the submit payload by HSL hue when sort mode is "By colour" (#1410)', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2, 3, 4]}  // Red / Blue / Black / Ivory all picked
+        spoolmanMode={false}
+      />,
+    );
+
+    // Default is ID-sorted; flip to colour.
+    fireEvent.click(screen.getByRole('button', { name: 'By colour' }));
+    fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
+
+    await waitFor(() => {
+      // Expected colour-sort order for the SPOOLS fixture:
+      //   Red    (1) — hue 0°   — chromatic
+      //   Ivory  (4) — hue ≈34° — chromatic
+      //   Blue   (2) — hue 240° — chromatic
+      //   Black  (3) — saturation ≈0 → neutrals bucket, lightness 0 → last
+      // Rainbow first, then neutrals (dark→light) per design choice for #1410.
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1, 4, 2, 3],
+        template: 'box_62x29',
+      });
+    });
+  });
+
+  it('keeps ID-order submission by default (#1410 regression guard)', async () => {
+    // Adding the sort toggle must NOT change the default behaviour — IDs go
+    // in ascending order unless the user explicitly clicks "By colour".
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2, 3, 4]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1, 2, 3, 4],
+        template: 'box_40x30',
+      });
+    });
+  });
 });

+ 94 - 5
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -98,6 +98,50 @@ function searchableText(s: SpoolForLabel): string {
     .toLowerCase();
 }
 
+type SortMode = 'id' | 'color';
+
+/** Sort key for the "by colour" mode (#1410).
+ *
+ * Returns a 2-tuple so JS array compare does the right thing without us having
+ * to spell out a comparator: ``[bucket, position]``. Chromatic colours
+ * (saturation above the threshold) go in bucket 0 ordered by HSL hue, so the
+ * sheet reads as a continuous rainbow. Achromatic colours (white / grey /
+ * black, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the
+ * neutrals trail at the end of the rainbow going dark → light. Multi-colour
+ * spools sort on their primary ``rgba``; their ``extra_colors`` stripe is
+ * still rendered on the label itself but doesn't drive the sort.
+ */
+function colorSortKey(rgba: string | null | undefined): [number, number] {
+  if (!rgba) return [1, 0]; // unknown colour — bucket with the neutrals at black
+  const cleaned = rgba.replace(/^#/, '').slice(0, 6);
+  if (cleaned.length !== 6) return [1, 0];
+  const r = parseInt(cleaned.slice(0, 2), 16);
+  const g = parseInt(cleaned.slice(2, 4), 16);
+  const b = parseInt(cleaned.slice(4, 6), 16);
+  if ([r, g, b].some(Number.isNaN)) return [1, 0];
+
+  const rn = r / 255;
+  const gn = g / 255;
+  const bn = b / 255;
+  const max = Math.max(rn, gn, bn);
+  const min = Math.min(rn, gn, bn);
+  const l = (max + min) / 2;
+  const delta = max - min;
+  // Saturation in the HSL definition. Achromatic cutoff at 0.1 is generous —
+  // matches what feels "grey enough" to a user picking colours, without
+  // sending dark muted colours like deep navy into the neutrals bucket.
+  const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
+  if (s < 0.1) return [1, l]; // neutrals: ordered black → white
+
+  let h = 0;
+  if (max === rn) h = ((gn - bn) / delta) % 6;
+  else if (max === gn) h = (bn - rn) / delta + 2;
+  else h = (rn - gn) / delta + 4;
+  h = h * 60;
+  if (h < 0) h += 360;
+  return [0, h]; // chromatic: ordered by hue 0..360
+}
+
 export function LabelTemplatePickerModal({
   isOpen,
   onClose,
@@ -111,6 +155,7 @@ export function LabelTemplatePickerModal({
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
   const [search, setSearch] = useState('');
   const [materialFilter, setMaterialFilter] = useState<string>('');
+  const [sortMode, setSortMode] = useState<SortMode>('id');
 
   // Sync from caller and reset transient state on open. Intentionally not
   // reactive to props while open — once the user starts editing we don't want
@@ -121,15 +166,29 @@ export function LabelTemplatePickerModal({
       setSelectedIds(new Set(initialSelectedIds.filter((id) => allowed.has(id))));
       setSearch('');
       setMaterialFilter('');
+      setSortMode('id');
       setPending(null);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isOpen]);
 
-  const sortedSpools = useMemo(
-    () => [...availableSpools].sort((a, b) => a.id - b.id),
-    [availableSpools],
-  );
+  const sortedSpools = useMemo(() => {
+    const copy = [...availableSpools];
+    if (sortMode === 'color') {
+      copy.sort((a, b) => {
+        const ka = colorSortKey(a.rgba);
+        const kb = colorSortKey(b.rgba);
+        if (ka[0] !== kb[0]) return ka[0] - kb[0];
+        if (ka[1] !== kb[1]) return ka[1] - kb[1];
+        // Stable tiebreaker on ID so identical colours print in a deterministic
+        // order across renders.
+        return a.id - b.id;
+      });
+      return copy;
+    }
+    copy.sort((a, b) => a.id - b.id);
+    return copy;
+  }, [availableSpools, sortMode]);
 
   // Material chips are derived from the *full* available set so they stay
   // stable when search/material filter narrows the visible list.
@@ -189,7 +248,10 @@ export function LabelTemplatePickerModal({
 
   async function handlePick(template: SpoolLabelTemplate) {
     if (noSelection || pending) return;
-    const ids = [...selectedIds].sort((a, b) => a - b);
+    // Order matters: the backend (labels.py) prints labels in the same order
+    // we send IDs. Use the sorted list so a "by colour" sort flows through to
+    // the PDF instead of being clobbered by an ascending-ID re-sort.
+    const ids = sortedSpools.filter((s) => selectedIds.has(s.id)).map((s) => s.id);
     setPending(template);
     try {
       const blob = spoolmanMode
@@ -282,6 +344,33 @@ export function LabelTemplatePickerModal({
               ))}
             </div>
           )}
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="text-xs text-bambu-gray mr-1">
+              {t('inventory.labels.sortBy.label')}
+            </span>
+            <button
+              type="button"
+              onClick={() => setSortMode('id')}
+              className={`px-2 py-0.5 text-xs rounded-full border transition ${
+                sortMode === 'id'
+                  ? 'bg-bambu-green text-bambu-dark border-bambu-green'
+                  : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
+              }`}
+            >
+              {t('inventory.labels.sortBy.id')}
+            </button>
+            <button
+              type="button"
+              onClick={() => setSortMode('color')}
+              className={`px-2 py-0.5 text-xs rounded-full border transition ${
+                sortMode === 'color'
+                  ? 'bg-bambu-green text-bambu-dark border-bambu-green'
+                  : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
+              }`}
+            >
+              {t('inventory.labels.sortBy.color')}
+            </button>
+          </div>
         </div>
 
         {/* Action bar */}

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

@@ -3536,6 +3536,11 @@ export default {
       bulkTitle: 'Spulen aus den aktuell angezeigten {{count}} zum Etikettieren auswählen',
       noSpoolsTitle: 'Keine Spulen zum Etikettieren',
       error: 'Etiketten konnten nicht erstellt werden: {{msg}}',
+      sortBy: {
+        label: 'Sortieren:',
+        id: 'Nach ID',
+        color: 'Nach Farbe',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

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

@@ -3539,6 +3539,11 @@ export default {
       bulkTitle: 'Pick spools to print labels for from the {{count}} currently shown',
       noSpoolsTitle: 'No spools to label',
       error: 'Could not generate labels: {{msg}}',
+      sortBy: {
+        label: 'Sort:',
+        id: 'By ID',
+        color: 'By colour',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

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

@@ -3525,6 +3525,11 @@ export default {
       bulkTitle: 'Choisissez les bobines à étiqueter parmi les {{count}} affichées',
       noSpoolsTitle: 'Aucune bobine à étiqueter',
       error: 'Impossible de générer les étiquettes : {{msg}}',
+      sortBy: {
+        label: 'Trier :',
+        id: 'Par ID',
+        color: 'Par couleur',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

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

@@ -3524,6 +3524,11 @@ export default {
       bulkTitle: 'Scegli le bobine da etichettare tra le {{count}} mostrate',
       noSpoolsTitle: 'Nessuna bobina da etichettare',
       error: 'Impossibile generare etichette: {{msg}}',
+      sortBy: {
+        label: 'Ordina:',
+        id: 'Per ID',
+        color: 'Per colore',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

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

@@ -3536,6 +3536,11 @@ export default {
       bulkTitle: '現在表示中の{{count}}件からラベルを印刷するスプールを選択',
       noSpoolsTitle: 'ラベル付けするスプールなし',
       error: 'ラベルを生成できませんでした: {{msg}}',
+      sortBy: {
+        label: '並べ替え:',
+        id: 'ID順',
+        color: '色順',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

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

@@ -3524,6 +3524,11 @@ export default {
       bulkTitle: 'Escolha bobinas para etiquetar entre as {{count}} exibidas',
       noSpoolsTitle: 'Nenhuma bobina para rotular',
       error: 'Não foi possível gerar etiquetas: {{msg}}',
+      sortBy: {
+        label: 'Ordenar:',
+        id: 'Por ID',
+        color: 'Por cor',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

+ 5 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3524,6 +3524,11 @@ export default {
       bulkTitle: '从当前显示的 {{count}} 个线材中选择要打印标签的',
       noSpoolsTitle: '没有要打标签的线材',
       error: '无法生成标签:{{msg}}',
+      sortBy: {
+        label: '排序:',
+        id: '按 ID',
+        color: '按颜色',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

+ 5 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3524,6 +3524,11 @@ export default {
       bulkTitle: '從目前顯示的 {{count}} 個線材中選擇要列印標籤的',
       noSpoolsTitle: '沒有要貼標籤的線材',
       error: '無法產生標籤:{{msg}}',
+      sortBy: {
+        label: '排序:',
+        id: '按 ID',
+        color: '按顏色',
+      },
       templates: {
         ams: {
           label: 'AMS holder (30 × 15 mm)',

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