Allow Avery label sheets to start at an unused position (#2879) (#2918)

This commit is contained in:
Whitigol
2026-08-26 08:57:44 +02:00
committed by GitHub
parent 14f46510ba
commit a68933cb2f
21 changed files with 452 additions and 38 deletions
@@ -71,6 +71,13 @@ const TEMPLATE_OPTIONS: TemplateOption[] = [
},
];
const SHEET_CAPACITIES: Partial<Record<SpoolLabelTemplate, number>> = {
avery_l7160: 21,
avery_5160: 30,
};
const MAX_SHEET_CAPACITY = Math.max(...Object.values(SHEET_CAPACITIES));
function openBlobInNewTab(blob: Blob): void {
const url = window.URL.createObjectURL(blob);
// Do NOT pass `noopener,noreferrer`: per the WindowFeatures spec, `noopener`
@@ -175,6 +182,7 @@ export function LabelTemplatePickerModal({
const [materialFilter, setMaterialFilter] = useState<string>('');
const [sortMode, setSortMode] = useState<SortMode>('id');
const [monochrome, setMonochrome] = useState(false);
const [startingPositionInput, setStartingPositionInput] = useState('1');
// 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
@@ -187,6 +195,7 @@ export function LabelTemplatePickerModal({
setMaterialFilter('');
setSortMode('id');
setMonochrome(false);
setStartingPositionInput('1');
setPending(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -236,6 +245,11 @@ export function LabelTemplatePickerModal({
const selectedCount = selectedIds.size;
const noSelection = selectedCount === 0;
const startingPosition = Number(startingPositionInput);
const startingPositionIsValid =
Number.isInteger(startingPosition) &&
startingPosition >= 1 &&
startingPosition <= MAX_SHEET_CAPACITY;
function toggleOne(id: number) {
setSelectedIds((prev) => {
@@ -268,6 +282,21 @@ export function LabelTemplatePickerModal({
async function handlePick(template: SpoolLabelTemplate) {
if (noSelection || pending) return;
const sheetCapacity = SHEET_CAPACITIES[template];
if (
sheetCapacity !== undefined &&
(!startingPositionIsValid || startingPosition > sheetCapacity)
) {
showToast(
t(
'inventory.labels.startingPositionRangeError',
'Starting position must be between 1 and {{capacity}} for this sheet.',
{ capacity: sheetCapacity },
),
'error',
);
return;
}
// 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.
@@ -275,8 +304,18 @@ export function LabelTemplatePickerModal({
setPending(template);
try {
const blob = spoolmanMode
? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template, monochrome })
: await api.printSpoolLabels({ spool_ids: ids, template, monochrome });
? await api.printSpoolmanSpoolLabels({
spool_ids: ids,
template,
monochrome,
starting_position: sheetCapacity === undefined ? 1 : startingPosition,
})
: await api.printSpoolLabels({
spool_ids: ids,
template,
monochrome,
starting_position: sheetCapacity === undefined ? 1 : startingPosition,
});
openBlobInNewTab(blob);
onClose();
} catch (err) {
@@ -297,7 +336,10 @@ export function LabelTemplatePickerModal({
onClick={onClose}
/>
<div className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto">
<div
data-testid="label-template-picker-panel"
className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-clip flex flex-col my-auto"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
@@ -467,7 +509,7 @@ export function LabelTemplatePickerModal({
</div>
{/* Print options */}
<div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary">
<div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary space-y-2">
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
{monochrome ? (
<CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
@@ -487,6 +529,52 @@ export function LabelTemplatePickerModal({
{t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
</span>
</label>
<div className="flex items-start gap-3">
<label
htmlFor="label-starting-position"
className="text-sm text-white whitespace-nowrap pt-1.5"
>
{t('inventory.labels.startingPosition', 'Starting label position')}
</label>
<input
id="label-starting-position"
data-testid="label-starting-position"
type="number"
min={1}
max={MAX_SHEET_CAPACITY}
step={1}
value={startingPositionInput}
onChange={(event) => setStartingPositionInput(event.target.value)}
aria-describedby="label-starting-position-help"
className="w-20 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
/>
<div id="label-starting-position-help" className="text-xs text-bambu-gray pt-1.5">
<div>
{t(
'inventory.labels.startingPositionRange',
'Sheet templates only: L7160 supports 1–21; 5160 supports 1–30.',
)}
</div>
<div
data-testid="label-starting-position-status"
className={startingPositionIsValid ? '' : 'text-red-400'}
>
{!startingPositionIsValid
? t(
'inventory.labels.startingPositionInvalid',
'Enter a whole number from 1 to {{capacity}}.',
{ capacity: MAX_SHEET_CAPACITY },
)
: startingPosition === 1
? t('inventory.labels.startingPositionFirst', 'Printing starts at position 1.')
: t(
'inventory.labels.startingPositionSkipped',
'Positions 1 through {{lastPosition}} will be left blank on the first sheet.',
{ lastPosition: startingPosition - 1 },
)}
</div>
</div>
</div>
</div>
{/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
@@ -495,12 +583,23 @@ export function LabelTemplatePickerModal({
<div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
{TEMPLATE_OPTIONS.map((opt) => {
const isPending = pending === opt.value;
const sheetCapacity = SHEET_CAPACITIES[opt.value];
const startingPositionExceedsSheet =
sheetCapacity !== undefined &&
(!startingPositionIsValid || startingPosition > sheetCapacity);
const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);
const hint = t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
const hint = startingPositionExceedsSheet
? t(
'inventory.labels.startingPositionRangeError',
'Starting position must be between 1 and {{capacity}} for this sheet.',
{ capacity: sheetCapacity },
)
: t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
return (
<button
key={opt.value}
disabled={noSelection || pending !== null}
data-testid={`print-labels-${opt.value}`}
disabled={noSelection || pending !== null || startingPositionExceedsSheet}
onClick={() => handlePick(opt.value)}
title={`${label} — ${hint}`}
className="w-full text-left p-2.5 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-green hover:bg-bambu-green/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-bambu-dark-tertiary disabled:hover:bg-bambu-dark transition flex items-center gap-3"