diff --git a/README.md b/README.md index dc0a127f7..ef8fb48f8 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight. - Spool catalog, color catalog, PA profile matching, and low-stock alerts - **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools. -- **Printable spool labels** — Generate PDF labels for any selection of spools in four pre-built sizes: AMS holder (30×15 mm), box label (62×29 mm), Avery L7160 sheet (A4, 21 per page), and Avery 5160 sheet (US Letter, 30 per page). Each label shows the colour swatch, brand, material, name, the **spool ID** (for at-a-glance identification across many similar spools), and a QR code that deep-links straight back to the spool's row in Bambuddy when scanned with a phone. Pick from the inventory page — search, filter by material, multi-select spools, then print or save to PDF. +- **Printable spool labels** — Generate PDF labels for any selection of spools in four pre-built sizes: AMS holder (30×15 mm), box label (62×29 mm), Avery L7160 sheet (A4, 21 per page), and Avery 5160 sheet (US Letter, 30 per page). Each label shows the colour swatch, brand, material, name, the **spool ID** (for at-a-glance identification across many similar spools), and a QR code that deep-links straight back to the spool's row in Bambuddy when scanned with a phone. Pick from the inventory page — search, filter by material, multi-select spools, then print or save to PDF. For a partially used Avery sheet, choose the first unused label position; Bambuddy leaves the earlier positions blank and starts later pages from position 1. ### 🔧 Integrations - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display diff --git a/backend/app/api/routes/labels.py b/backend/app/api/routes/labels.py index 71fa420ec..c36df5423 100644 --- a/backend/app/api/routes/labels.py +++ b/backend/app/api/routes/labels.py @@ -5,7 +5,8 @@ Two endpoints, one per inventory backend: - ``POST /inventory/labels`` — local-DB spools - ``POST /spoolman/labels`` — Spoolman-backed spools -Both accept ``{spool_ids: [int], template: str}`` and return a PDF stream. +Both accept ``{spool_ids: [int], template: str, starting_position: int}`` and +return a PDF stream. The QR code on each label deep-links to ``/inventory?spool=`` so a phone scan jumps straight back into Bambuddy at that spool's row. """ @@ -18,7 +19,7 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -28,7 +29,7 @@ from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.models.spool import Spool from backend.app.models.user import User -from backend.app.services.label_renderer import LabelData, TemplateName, render_labels +from backend.app.services.label_renderer import LabelData, TemplateName, get_sheet_capacity, render_labels from backend.app.services.spoolman import get_spoolman_client from backend.app.utils.http import build_content_disposition @@ -63,6 +64,18 @@ class LabelRequest(BaseModel): # Black-and-white thermal printers: drop the colour swatch (prints as a # muddy grey block) and widen the text column instead (#1870). monochrome: bool = False + starting_position: int = Field(default=1, ge=1) + + @model_validator(mode="after") + def validate_starting_position(self) -> LabelRequest: + capacity = get_sheet_capacity(self.template) + if capacity is None: + if self.starting_position != 1: + raise ValueError("starting_position is only supported for sheet label templates") + return self + if self.starting_position > capacity: + raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}") + return self def _split_extra_colors(raw: str | None) -> list[str] | None: @@ -173,7 +186,12 @@ async def render_local_inventory_labels( deeplink_base = await _resolve_deeplink_base(request, db) data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered] - pdf = render_labels(body.template, data_list, monochrome=body.monochrome) + pdf = render_labels( + body.template, + data_list, + monochrome=body.monochrome, + starting_position=body.starting_position, + ) filename = f"bambuddy-labels-{body.template}.pdf" return _stream_pdf(pdf, filename) @@ -217,6 +235,11 @@ async def render_spoolman_labels( deeplink_base = await _resolve_deeplink_base(request, db) data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids] - pdf = render_labels(body.template, data_list, monochrome=body.monochrome) + pdf = render_labels( + body.template, + data_list, + monochrome=body.monochrome, + starting_position=body.starting_position, + ) filename = f"bambuddy-labels-spoolman-{body.template}.pdf" return _stream_pdf(pdf, filename) diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py index a58d92216..d13b14645 100644 --- a/backend/app/services/label_renderer.py +++ b/backend/app/services/label_renderer.py @@ -154,24 +154,28 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes: def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None: """Draw the colour swatch. Multi-colour spools use vertical stripes (matching the FilamentSwatch convention in the frontend).""" - primary = _color_from_hex(data.rgba) - extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h] - colors = [primary, *extras] + c.saveState() + try: + primary = _color_from_hex(data.rgba) + extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h] + colors = [primary, *extras] - if not colors: - c.setFillColor(HexColor(0x808080)) - c.rect(x, y, w, h, stroke=0, fill=1) - return + if not colors: + c.setFillColor(HexColor(0x808080)) + c.rect(x, y, w, h, stroke=0, fill=1) + return - stripe_w = w / len(colors) - for i, col in enumerate(colors): - c.setFillColor(col) - c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1) + stripe_w = w / len(colors) + for i, col in enumerate(colors): + c.setFillColor(col) + c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1) - # Thin black border so light-colour swatches stay visible on white labels. - c.setStrokeColor(black) - c.setLineWidth(0.3) - c.rect(x, y, w, h, stroke=1, fill=0) + # Thin black border so light-colour swatches stay visible on white labels. + c.setStrokeColor(black) + c.setLineWidth(0.3) + c.rect(x, y, w, h, stroke=1, fill=0) + finally: + c.restoreState() def _roomy_qr_size(inner_w: float, inner_h: float) -> float: @@ -442,7 +446,20 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData], return buf.getvalue() -def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes: +def get_sheet_capacity(template: TemplateName) -> int | None: + """Return the number of slots on a sheet template, or ``None`` for roll labels.""" + layout = _SHEET_TEMPLATES.get(template) + if layout is None: + return None + return layout[3] * layout[4] + + +def _render_sheet_pdf( + template: TemplateName, + data_list: list[LabelData], + monochrome: bool, + starting_position: int, +) -> bytes: page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template] page_w, page_h = page_size @@ -458,21 +475,37 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monoch c.setTitle(f"Bambuddy spool labels ({template})") per_page = cols * rows - for page_start in range(0, len(data_list), per_page): - chunk = data_list[page_start : page_start + per_page] + if starting_position < 1 or starting_position > per_page: + raise ValueError(f"Starting position must be between 1 and {per_page} for {template}") + + data_index = 0 + page_number = 0 + while data_index < len(data_list): + slot_offset = starting_position - 1 if page_number == 0 else 0 + page_capacity = per_page - slot_offset + chunk = data_list[data_index : data_index + page_capacity] for idx, data in enumerate(chunk): - row = idx // cols - col = idx % cols + slot_index = slot_offset + idx + row = slot_index // cols + col = slot_index % cols x = left_margin + col * (label_w + col_gap) y = page_h - top_margin - (row + 1) * label_h - row * row_gap _draw_label(c, x, y, label_w, label_h, data, monochrome) c.showPage() + data_index += len(chunk) + page_number += 1 c.save() return buf.getvalue() -def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes: +def render_labels( + template: TemplateName, + data_list: list[LabelData], + *, + monochrome: bool = False, + starting_position: int = 1, +) -> bytes: """Render ``data_list`` to a PDF using the named template. Returns bytes. Empty ``data_list`` still produces a valid (empty) PDF — callers should @@ -481,14 +514,19 @@ def render_labels(template: TemplateName, data_list: list[LabelData], *, monochr ``monochrome`` drops the colour swatch (which prints as a useless grey block on black-and-white thermal printers) and reclaims the space for text; the hex-code line still carries the colour. See #1870. + + ``starting_position`` is one-based and applies only to the first page of a + sheet template. Later pages always begin at the first slot. """ if template in _SINGLE_LABEL_SIZES_MM: + if starting_position != 1: + raise ValueError("Starting position is only supported for sheet label templates") return _render_single_label_pdf(template, data_list, monochrome) if template in _SHEET_TEMPLATES: - return _render_sheet_pdf(template, data_list, monochrome) + return _render_sheet_pdf(template, data_list, monochrome, starting_position) raise ValueError(f"Unknown label template: {template!r}") -__all__ = ["LabelData", "TemplateName", "render_labels"] +__all__ = ["LabelData", "TemplateName", "get_sheet_capacity", "render_labels"] # white re-exported for completeness; future templates may need a paper-tone variant. _ = white diff --git a/backend/tests/integration/test_labels.py b/backend/tests/integration/test_labels.py index 63919d1db..2753a0078 100644 --- a/backend/tests/integration/test_labels.py +++ b/backend/tests/integration/test_labels.py @@ -141,6 +141,29 @@ class TestLocalInventoryLabels: assert resp.status_code == 200 assert captured["ids"] == [s3.id, s1.id, s2.id] + @pytest.mark.asyncio + @pytest.mark.integration + async def test_forwards_sheet_starting_position(self, async_client: AsyncClient, spool_factory): + spool = await spool_factory() + + from backend.app.api.routes import labels as labels_module + + captured = {} + original = labels_module.render_labels + + def _capture(template, data_list, **kwargs): + captured["starting_position"] = kwargs["starting_position"] + return original(template, data_list, **kwargs) + + with patch.object(labels_module, "render_labels", side_effect=_capture): + resp = await async_client.post( + "/api/v1/inventory/labels", + json={"spool_ids": [spool.id], "template": "avery_5160", "starting_position": 8}, + ) + + assert resp.status_code == 200 + assert captured["starting_position"] == 8 + # ── /spoolman/labels (Spoolman-backed) ─────────────────────────────────────── @@ -237,6 +260,24 @@ class TestSpoolmanLabels: class TestValidation: + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + ("template", "starting_position"), + (("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)), + ) + async def test_invalid_starting_position_rejected( + self, + async_client: AsyncClient, + template: str, + starting_position: int, + ): + resp = await async_client.post( + "/api/v1/inventory/labels", + json={"spool_ids": [1], "template": template, "starting_position": starting_position}, + ) + assert resp.status_code == 422 + @pytest.mark.asyncio @pytest.mark.integration async def test_request_body_size_capped(self, async_client: AsyncClient): diff --git a/backend/tests/unit/services/test_label_renderer.py b/backend/tests/unit/services/test_label_renderer.py index 455b45ad9..2dde556c8 100644 --- a/backend/tests/unit/services/test_label_renderer.py +++ b/backend/tests/unit/services/test_label_renderer.py @@ -2,7 +2,11 @@ from __future__ import annotations +from unittest.mock import patch + import pytest +from reportlab.lib.pagesizes import letter +from reportlab.lib.units import mm from backend.app.services.label_renderer import LabelData, render_labels @@ -96,6 +100,37 @@ def test_sheet_template_paginates_when_count_exceeds_one_sheet(): assert len(two) > len(one) +def test_sheet_starting_position_offsets_first_label(): + with patch("backend.app.services.label_renderer._draw_label") as draw_label: + render_labels("avery_5160", [_sample(1)], starting_position=8) + + first_call = draw_label.call_args_list[0].args + assert first_call[1] == pytest.approx(4.76 * mm + 66.675 * mm + 3.175 * mm) + assert first_call[2] == pytest.approx(letter[1] - 12.7 * mm - 3 * 25.4 * mm) + + +def test_sheet_starting_position_resets_on_second_page(): + data = [_sample(i) for i in range(1, 25)] + with patch("backend.app.services.label_renderer._draw_label") as draw_label: + render_labels("avery_5160", data, starting_position=8) + + last_first_page_call = draw_label.call_args_list[22].args + first_second_page_call = draw_label.call_args_list[23].args + assert last_first_page_call[1] == pytest.approx(4.76 * mm + 2 * (66.675 * mm + 3.175 * mm)) + assert last_first_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 10 * 25.4 * mm) + assert first_second_page_call[1] == pytest.approx(4.76 * mm) + assert first_second_page_call[2] == pytest.approx(letter[1] - 12.7 * mm - 25.4 * mm) + + +@pytest.mark.parametrize( + ("template", "starting_position"), + (("avery_5160", 0), ("avery_5160", 31), ("avery_l7160", 22), ("box_62x29", 2)), +) +def test_invalid_starting_position_raises(template, starting_position): + with pytest.raises(ValueError, match="Starting position"): + render_labels(template, [_sample()], starting_position=starting_position) + + def test_qr_payload_is_present_in_pdf_stream(): """The QR encodes the deeplink URL via embedded PNG; we can at least sanity-check that the PDF contains an image stream when a deeplink is set @@ -172,6 +207,17 @@ def _render_uncompressed(template, data, monochrome=False): return buf.getvalue() +def test_transparent_swatch_does_not_apply_its_alpha_to_qr(): + pdf = _render_uncompressed( + "box_62x29", + [_sample(rgba="FF000000", deeplink_url="https://example.test/inventory?spool=1")], + ) + + alpha_start = pdf.index(b"/gRLs0 gs") + qr_draw = pdf.index(b" Do", alpha_start) + assert b"Q" in pdf[alpha_start:qr_draw] + + def test_ams_template_actually_renders_text(): """Regression: the first cut of the AMS-holder layout produced labels with only swatch + QR and no text at all because the side-by-side layout left diff --git a/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx b/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx index d2448061f..3b90a11f6 100644 --- a/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx +++ b/frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen, waitFor, fireEvent } from '@testing-library/react'; +import { screen, waitFor, fireEvent, within } from '@testing-library/react'; import { render } from '../utils'; import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal'; import { api } from '../../api/client'; @@ -67,6 +67,22 @@ describe('LabelTemplatePickerModal', () => { expect(screen.getByText(/Ivory · Polymaker/)).toBeInTheDocument(); }); + it('keeps the panel from becoming a programmatically scrollable clipping container', () => { + render( + , + ); + + const panel = screen.getByTestId('label-template-picker-panel'); + expect(panel).toHaveClass('overflow-clip'); + expect(panel).not.toHaveClass('overflow-hidden'); + }); + it('shows the live selected count in the header', () => { render( { spool_ids: [1, 3], template: 'box_62x29', monochrome: false, + starting_position: 1, }); }); await waitFor(() => expect(onClose).toHaveBeenCalled()); @@ -234,6 +251,7 @@ describe('LabelTemplatePickerModal', () => { spool_ids: [1], template: 'ams_holder_75x55', monochrome: false, + starting_position: 1, }); }); expect(api.printSpoolLabels).not.toHaveBeenCalled(); @@ -364,6 +382,7 @@ describe('LabelTemplatePickerModal', () => { spool_ids: [1, 4, 2, 3], template: 'box_62x29', monochrome: false, + starting_position: 1, }); }); }); @@ -389,6 +408,7 @@ describe('LabelTemplatePickerModal', () => { spool_ids: [1, 2, 3, 4], template: 'box_40x30', monochrome: false, + starting_position: 1, }); }); }); @@ -413,7 +433,69 @@ describe('LabelTemplatePickerModal', () => { spool_ids: [1], template: 'box_40x30', monochrome: true, + starting_position: 1, }); }); }); + + it('sends the selected starting position for an Avery sheet', async () => { + vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB); + render( + , + ); + + fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '8' } }); + expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent(/Positions 1 through 7/i); + fireEvent.click(screen.getByTestId('print-labels-avery_5160')); + + await waitFor(() => { + expect(api.printSpoolLabels).toHaveBeenCalledWith({ + spool_ids: [1], + template: 'avery_5160', + monochrome: false, + starting_position: 8, + }); + }); + }); + + it('renders a single skipped position without treating the value as a pluralization key', () => { + render( + , + ); + + fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '2' } }); + expect(screen.getByTestId('label-starting-position-status')).toHaveTextContent( + 'Positions 1 through 1 will be left blank on the first sheet.', + ); + }); + + it('explains each Avery template capacity when disabling it', () => { + render( + , + ); + + fireEvent.change(screen.getByTestId('label-starting-position'), { target: { value: '25' } }); + const l7160Button = screen.getByTestId('print-labels-avery_l7160'); + expect(l7160Button).toBeDisabled(); + expect(within(l7160Button).getByText(/between 1 and 21/)).toBeInTheDocument(); + expect(screen.getByTestId('print-labels-avery_5160')).toBeEnabled(); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3e3c7eac2..b632ce96b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3465,6 +3465,13 @@ export type SpoolLabelTemplate = | 'avery_5160' | 'avery_l7160'; +export interface PrintSpoolLabelsRequest { + spool_ids: number[]; + template: SpoolLabelTemplate; + monochrome: boolean; + starting_position: number; +} + export interface InventorySpool { id: number; material: string; @@ -6360,7 +6367,7 @@ export const api = { // ── Spool label printing (#809) ────────────────────────────────────────── // Both endpoints return application/pdf. Frontend opens the resulting Blob // in a new tab so the user can print or save from the browser's PDF viewer. - printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise => { + printSpoolLabels: async (data: PrintSpoolLabelsRequest): Promise => { const headers: Record = { 'Content-Type': 'application/json' }; if (authToken) headers['Authorization'] = `Bearer ${authToken}`; const response = await fetch(`${API_BASE}/inventory/labels`, { @@ -6374,7 +6381,7 @@ export const api = { } return response.blob(); }, - printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise => { + printSpoolmanSpoolLabels: async (data: PrintSpoolLabelsRequest): Promise => { const headers: Record = { 'Content-Type': 'application/json' }; if (authToken) headers['Authorization'] = `Bearer ${authToken}`; const response = await fetch(`${API_BASE}/spoolman/labels`, { diff --git a/frontend/src/components/LabelTemplatePickerModal.tsx b/frontend/src/components/LabelTemplatePickerModal.tsx index f7233ab95..5ca5e03c2 100644 --- a/frontend/src/components/LabelTemplatePickerModal.tsx +++ b/frontend/src/components/LabelTemplatePickerModal.tsx @@ -71,6 +71,13 @@ const TEMPLATE_OPTIONS: TemplateOption[] = [ }, ]; +const SHEET_CAPACITIES: Partial> = { + 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(''); const [sortMode, setSortMode] = useState('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} /> -
+
{/* Header */}
@@ -467,7 +509,7 @@ export function LabelTemplatePickerModal({
{/* Print options */} -
+
+
+ + 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" + /> +
+
+ {t( + 'inventory.labels.startingPositionRange', + 'Sheet templates only: L7160 supports 1–21; 5160 supports 1–30.', + )} +
+
+ {!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 }, + )} +
+
+
{/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit @@ -495,12 +583,23 @@ export function LabelTemplatePickerModal({
{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 (