|
@@ -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 {
|
|
function openBlobInNewTab(blob: Blob): void {
|
|
|
const url = window.URL.createObjectURL(blob);
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
// Do NOT pass `noopener,noreferrer`: per the WindowFeatures spec, `noopener`
|
|
// Do NOT pass `noopener,noreferrer`: per the WindowFeatures spec, `noopener`
|
|
@@ -175,6 +182,7 @@ export function LabelTemplatePickerModal({
|
|
|
const [materialFilter, setMaterialFilter] = useState<string>('');
|
|
const [materialFilter, setMaterialFilter] = useState<string>('');
|
|
|
const [sortMode, setSortMode] = useState<SortMode>('id');
|
|
const [sortMode, setSortMode] = useState<SortMode>('id');
|
|
|
const [monochrome, setMonochrome] = useState(false);
|
|
const [monochrome, setMonochrome] = useState(false);
|
|
|
|
|
+ const [startingPositionInput, setStartingPositionInput] = useState('1');
|
|
|
|
|
|
|
|
// Sync from caller and reset transient state on open. Intentionally not
|
|
// 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
|
|
// reactive to props while open — once the user starts editing we don't want
|
|
@@ -187,6 +195,7 @@ export function LabelTemplatePickerModal({
|
|
|
setMaterialFilter('');
|
|
setMaterialFilter('');
|
|
|
setSortMode('id');
|
|
setSortMode('id');
|
|
|
setMonochrome(false);
|
|
setMonochrome(false);
|
|
|
|
|
+ setStartingPositionInput('1');
|
|
|
setPending(null);
|
|
setPending(null);
|
|
|
}
|
|
}
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -236,6 +245,11 @@ export function LabelTemplatePickerModal({
|
|
|
|
|
|
|
|
const selectedCount = selectedIds.size;
|
|
const selectedCount = selectedIds.size;
|
|
|
const noSelection = selectedCount === 0;
|
|
const noSelection = selectedCount === 0;
|
|
|
|
|
+ const startingPosition = Number(startingPositionInput);
|
|
|
|
|
+ const startingPositionIsValid =
|
|
|
|
|
+ Number.isInteger(startingPosition) &&
|
|
|
|
|
+ startingPosition >= 1 &&
|
|
|
|
|
+ startingPosition <= MAX_SHEET_CAPACITY;
|
|
|
|
|
|
|
|
function toggleOne(id: number) {
|
|
function toggleOne(id: number) {
|
|
|
setSelectedIds((prev) => {
|
|
setSelectedIds((prev) => {
|
|
@@ -268,6 +282,21 @@ export function LabelTemplatePickerModal({
|
|
|
|
|
|
|
|
async function handlePick(template: SpoolLabelTemplate) {
|
|
async function handlePick(template: SpoolLabelTemplate) {
|
|
|
if (noSelection || pending) return;
|
|
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
|
|
// 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
|
|
// 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.
|
|
// the PDF instead of being clobbered by an ascending-ID re-sort.
|
|
@@ -275,8 +304,18 @@ export function LabelTemplatePickerModal({
|
|
|
setPending(template);
|
|
setPending(template);
|
|
|
try {
|
|
try {
|
|
|
const blob = spoolmanMode
|
|
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);
|
|
openBlobInNewTab(blob);
|
|
|
onClose();
|
|
onClose();
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
@@ -297,7 +336,10 @@ export function LabelTemplatePickerModal({
|
|
|
onClick={onClose}
|
|
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 */}
|
|
{/* Header */}
|
|
|
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
|
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
@@ -467,7 +509,7 @@ export function LabelTemplatePickerModal({
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Print options */}
|
|
{/* 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">
|
|
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
|
|
{monochrome ? (
|
|
{monochrome ? (
|
|
|
<CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
|
|
<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')}
|
|
{t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
|
|
|
</span>
|
|
</span>
|
|
|
</label>
|
|
</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>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
|
|
{/* 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">
|
|
<div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
|
{TEMPLATE_OPTIONS.map((opt) => {
|
|
{TEMPLATE_OPTIONS.map((opt) => {
|
|
|
const isPending = pending === opt.value;
|
|
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 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 (
|
|
return (
|
|
|
<button
|
|
<button
|
|
|
key={opt.value}
|
|
key={opt.value}
|
|
|
- disabled={noSelection || pending !== null}
|
|
|
|
|
|
|
+ data-testid={`print-labels-${opt.value}`}
|
|
|
|
|
+ disabled={noSelection || pending !== null || startingPositionExceedsSheet}
|
|
|
onClick={() => handlePick(opt.value)}
|
|
onClick={() => handlePick(opt.value)}
|
|
|
title={`${label} — ${hint}`}
|
|
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"
|
|
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"
|