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.
This commit is contained in:
maziggy
2026-05-18 12:53:07 +02:00
parent cd19e746bd
commit fcd1801aab
11 changed files with 196 additions and 5 deletions
@@ -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',
});
});
});
});