fix: Etiketten werden beim Drucken abgeschnitten, wenn Spalten/Zeilen zu gross gewaehlt sind
Eine explizit im Etiketten-Designer eingetragene Spalten-/Zeilenzahl wurde beim PDF-Rendern ungeprueft uebernommen. Passt sie nicht auf ein A4-Blatt (z.B. 90mm breite Etiketten x 4 Spalten bei 5mm Rand), ragen die hinteren Spalten/Zeilen ueber den Blattrand hinaus - der Drucker schneidet sie dann einfach ab, statt sie zu drucken. - Backend (label-templates.service.ts): Spalten/Zeilen werden jetzt auf das tatsaechlich auf A4 passende Maximum begrenzt, bevor gerendert wird - betrifft auch bereits gespeicherte Vorlagen mit zu grossen Werten. - Frontend (label-designer.tsx): zeigt die maximal passende Spalten-/ Zeilenzahl direkt im Seiten-Layout-Panel an und warnt inline, wenn der eingetragene Wert das Blatt sprengen wuerde. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
19d5e5d65c
commit
8b8616ce03
3 files changed
+74
-12
No files matched your search
@@ -247,6 +247,33 @@ describe('LabelTemplatesService', () => {
|
|||||||
expect(countPages(pdf)).toBe(3);
|
expect(countPages(pdf)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clamps an explicit columns/rows override that would run labels off the A4 page', async () => {
|
||||||
|
// widthMm:90, marginLeftMm:5, gapXMm:2 only fit 2 columns on A4 - an
|
||||||
|
// explicit 99 would place columns 3+ past the page's right edge,
|
||||||
|
// where a printer just clips them instead of printing anything.
|
||||||
|
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||||
|
...template,
|
||||||
|
columns: 99,
|
||||||
|
rows: 1,
|
||||||
|
});
|
||||||
|
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||||
|
id: 'batch-1',
|
||||||
|
jarSize: 500,
|
||||||
|
jarCount: 3,
|
||||||
|
lotNumber: 'L-001',
|
||||||
|
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||||
|
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||||
|
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||||
|
product: { name: 'Glas 500g' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||||
|
|
||||||
|
// Clamped to the real max of 2 columns x 1 row = 2 per page, so 3
|
||||||
|
// copies still need 2 pages - same as the explicit columns:2 test.
|
||||||
|
expect(countPages(pdf)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => {
|
it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => {
|
||||||
prisma.labelTemplate.findUnique.mockResolvedValue({
|
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||||
...template,
|
...template,
|
||||||
|
|||||||
@@ -237,15 +237,21 @@ export class LabelTemplatesService {
|
|||||||
layout;
|
layout;
|
||||||
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
|
const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm;
|
||||||
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
|
const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm;
|
||||||
const columns =
|
const maxColumns = Math.max(
|
||||||
layout.columns ??
|
1,
|
||||||
Math.max(1, Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)));
|
Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm)),
|
||||||
const rows =
|
);
|
||||||
layout.rows ??
|
const maxRows = Math.max(
|
||||||
Math.max(
|
1,
|
||||||
1,
|
Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)),
|
||||||
Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)),
|
);
|
||||||
);
|
// An explicit columns/rows value from the sheet-layout designer is
|
||||||
|
// clamped to what actually fits the A4 page - without this, a value
|
||||||
|
// typed in by the user (or already saved from before this fix) would
|
||||||
|
// place labels past the page's right/bottom edge, where printers just
|
||||||
|
// clip them instead of printing anything.
|
||||||
|
const columns = Math.min(layout.columns ?? maxColumns, maxColumns);
|
||||||
|
const rows = Math.min(layout.rows ?? maxRows, maxRows);
|
||||||
const perPage = columns * rows;
|
const perPage = columns * rows;
|
||||||
// startPosition only skips ahead on the first sheet (partially-used
|
// startPosition only skips ahead on the first sheet (partially-used
|
||||||
// physical sheet, e.g. a few labels already peeled off) - every sheet
|
// physical sheet, e.g. a few labels already peeled off) - every sheet
|
||||||
|
|||||||
+32
-3
@@ -35,6 +35,8 @@ import { LabelCanvas } from "./label-canvas";
|
|||||||
import { ElementPropertiesPanel } from "./element-properties-panel";
|
import { ElementPropertiesPanel } from "./element-properties-panel";
|
||||||
|
|
||||||
const ELEMENT_TYPES = ["text", "qrCode", "image", "line"] as const;
|
const ELEMENT_TYPES = ["text", "qrCode", "image", "line"] as const;
|
||||||
|
const A4_WIDTH_MM = 210;
|
||||||
|
const A4_HEIGHT_MM = 297;
|
||||||
const ZOOM_STEPS: number[] = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
const ZOOM_STEPS: number[] = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||||
const ZOOM_MIN = ZOOM_STEPS[0];
|
const ZOOM_MIN = ZOOM_STEPS[0];
|
||||||
const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1];
|
const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1];
|
||||||
@@ -152,6 +154,21 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
|
|
||||||
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
|
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
// Mirrors the backend's clamp in label-templates.service.ts exactly, so
|
||||||
|
// the hint shown here always matches what actually prints - a value typed
|
||||||
|
// in above this is silently capped server-side rather than running labels
|
||||||
|
// off the physical A4 page.
|
||||||
|
const maxColumns = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((A4_WIDTH_MM - 2 * marginLeftMm + gapXMm) / (widthMm + gapXMm)),
|
||||||
|
);
|
||||||
|
const maxRows = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((A4_HEIGHT_MM - 2 * marginTopMm + gapYMm) / (heightMm + gapYMm)),
|
||||||
|
);
|
||||||
|
const columnsExceedsMax = columns !== null && columns > maxColumns;
|
||||||
|
const rowsExceedsMax = rows !== null && rows > maxRows;
|
||||||
|
|
||||||
function addElement(element: LabelElement) {
|
function addElement(element: LabelElement) {
|
||||||
setElements((current) => [...current, element]);
|
setElements((current) => [...current, element]);
|
||||||
setSelectedId(element.id);
|
setSelectedId(element.id);
|
||||||
@@ -365,9 +382,11 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Spalten/Zeilen leer lassen, um automatisch so viele einzuplanen wie passen. Feste
|
Spalten/Zeilen leer lassen, um automatisch so viele einzuplanen wie passen (aktuell{" "}
|
||||||
Werte eintragen, um exakt zum vorgedruckten Raster eines Etikettenbogens (z.B.
|
{maxColumns} × {maxRows}). Feste Werte eintragen, um exakt zum vorgedruckten Raster
|
||||||
Avery-Vorlage) zu passen.
|
eines Etikettenbogens (z.B. Avery-Vorlage) zu passen - mehr als {maxColumns} Spalten
|
||||||
|
bzw. {maxRows} Zeilen passen bei dieser Etikettengröße und diesen Rändern nicht auf
|
||||||
|
ein A4-Blatt und werden beim Drucken abgeschnitten.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
@@ -382,6 +401,11 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
setColumns(event.target.value === "" ? null : Number(event.target.value))
|
setColumns(event.target.value === "" ? null : Number(event.target.value))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{columnsExceedsMax ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Passt nicht auf A4 - maximal {maxColumns} Spalte{maxColumns === 1 ? "" : "n"}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<Label htmlFor="designer-rows">Zeilen</Label>
|
<Label htmlFor="designer-rows">Zeilen</Label>
|
||||||
@@ -395,6 +419,11 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
setRows(event.target.value === "" ? null : Number(event.target.value))
|
setRows(event.target.value === "" ? null : Number(event.target.value))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{rowsExceedsMax ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Passt nicht auf A4 - maximal {maxRows} Zeile{maxRows === 1 ? "" : "n"}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
Reference in new issue
Block a user