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
@@ -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 */}