From c6941a797f3809ebcedf6530f78433ac66e55771 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 1 Sep 2026 19:43:01 +0200 Subject: [PATCH] feat: Etiketten-Seitenlayout auf dem A4-Bogen frei waehlbar - LabelTemplate bekommt columns/rows (optional, leer = automatisch passend), plus konfigurierbare Raender/Abstaende - damit ein Etikett exakt auf das vorgedruckte Raster eines physischen Etikettenbogens (z.B. Avery) gelegt werden kann statt nur automatisch berechnet - Startposition beim Drucken waehlbar, fuer bereits angebrochene Boegen (erste Seite startet ab der gewaehlten Position, Folgeseiten wieder von vorne) --- .../migration.sql | 9 ++ ApiCore-Backend/prisma/schema.prisma | 11 ++ .../dto/create-label-template.dto.ts | 42 ++++++ .../label-templates.controller.ts | 5 + .../label-templates.service.spec.ts | 57 ++++++++ .../label-templates.service.ts | 70 +++++++--- .../(app)/bottling/[id]/print-labels-card.tsx | 26 +++- .../settings/label-templates/[id]/page.tsx | 6 + .../designer/label-designer.tsx | 131 +++++++++++++++++- .../src/lib/label-templates/api.ts | 9 +- .../src/lib/label-templates/types.ts | 18 +++ 11 files changed, 357 insertions(+), 27 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20260901173228_label_template_sheet_layout/migration.sql diff --git a/ApiCore-Backend/prisma/migrations/20260901173228_label_template_sheet_layout/migration.sql b/ApiCore-Backend/prisma/migrations/20260901173228_label_template_sheet_layout/migration.sql new file mode 100644 index 0000000..3993a9a --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260901173228_label_template_sheet_layout/migration.sql @@ -0,0 +1,9 @@ +-- Explicit A4 sheet layout per LabelTemplate: columns/rows (nullable, null = +-- auto-fit) plus configurable page margins and gaps, so a template can be +-- made to match a specific physical label sheet's exact printed grid. +ALTER TABLE "LabelTemplate" ADD COLUMN "columns" INTEGER; +ALTER TABLE "LabelTemplate" ADD COLUMN "rows" INTEGER; +ALTER TABLE "LabelTemplate" ADD COLUMN "marginTopMm" DOUBLE PRECISION NOT NULL DEFAULT 5; +ALTER TABLE "LabelTemplate" ADD COLUMN "marginLeftMm" DOUBLE PRECISION NOT NULL DEFAULT 5; +ALTER TABLE "LabelTemplate" ADD COLUMN "gapXMm" DOUBLE PRECISION NOT NULL DEFAULT 2; +ALTER TABLE "LabelTemplate" ADD COLUMN "gapYMm" DOUBLE PRECISION NOT NULL DEFAULT 2; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 5bca5eb..6ef57f2 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -864,6 +864,17 @@ model LabelTemplate { elements Json @default("[]") widthMm Float @default(90) heightMm Float @default(35) + // A4 sheet layout. columns/rows are nullable: null means "auto-fit as many + // as fit" (the original behaviour); a real label sheet product (e.g. a + // specific Avery SKU) publishes an exact columns/rows/margin/gap grid, so + // setting these lets a template's printed positions match the physical, + // pre-die-cut sheet instead of just whatever the naive fit computes. + columns Int? + rows Int? + marginTopMm Float @default(5) + marginLeftMm Float @default(5) + gapXMm Float @default(2) + gapYMm Float @default(2) isDefault Boolean @default(false) active Boolean @default(true) createdAt DateTime @default(now()) diff --git a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts index f1e957c..1de64af 100644 --- a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts +++ b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts @@ -4,6 +4,7 @@ import { ArrayMaxSize, IsArray, IsBoolean, + IsInt, IsNumber, IsOptional, IsString, @@ -36,6 +37,47 @@ export class CreateLabelTemplateDto { @Min(10) heightMm?: number; + @ApiPropertyOptional({ + description: + 'Spalten pro A4-Bogen. Leer = automatisch so viele wie passen.', + }) + @IsOptional() + @IsInt() + @Min(1) + columns?: number; + + @ApiPropertyOptional({ + description: 'Zeilen pro A4-Bogen. Leer = automatisch so viele wie passen.', + }) + @IsOptional() + @IsInt() + @Min(1) + rows?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + marginTopMm?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + marginLeftMm?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + gapXMm?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + gapYMm?: number; + @ApiPropertyOptional() @IsOptional() @IsBoolean() diff --git a/ApiCore-Backend/src/label-templates/label-templates.controller.ts b/ApiCore-Backend/src/label-templates/label-templates.controller.ts index 791d7dd..572e56c 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.controller.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.controller.ts @@ -4,8 +4,10 @@ import { Delete, Get, Param, + ParseIntPipe, Patch, Post, + Query, Res, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; @@ -65,11 +67,14 @@ export class LabelTemplatesController { async print( @Param('id') id: string, @Param('bottlingBatchId') bottlingBatchId: string, + @Query('startPosition', new ParseIntPipe({ optional: true })) + startPosition: number | undefined, @Res() res: Response, ): Promise { const pdf = await this.labelTemplatesService.renderLabelsPdf( id, bottlingBatchId, + startPosition, ); res.setHeader('Content-Type', 'application/pdf'); res.setHeader( diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts index 5b7cc2b..11fbb3c 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts @@ -64,6 +64,12 @@ describe('LabelTemplatesService', () => { elements, widthMm: 90, heightMm: 35, + columns: null, + rows: null, + marginTopMm: 5, + marginLeftMm: 5, + gapXMm: 2, + gapYMm: 2, isDefault: false, active: true, }; @@ -213,5 +219,56 @@ describe('LabelTemplatesService', () => { // A PDF with an embedded raster image contains an XObject /Image entry. expect(pdf.toString('latin1')).toContain('/Image'); }); + + function countPages(pdf: Buffer): number { + const matches = pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g); + return matches ? matches.length : 0; + } + + it('uses an explicit columns/rows override instead of auto-fitting', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue({ + ...template, + columns: 1, + 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'); + + expect(countPages(pdf)).toBe(3); + }); + + it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue({ + ...template, + columns: 2, + 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', 2); + + // perPage=2, skip=1: label 1 fills the remaining slot on page 1, + // labels 2-3 fill both slots of page 2. + expect(countPages(pdf)).toBe(2); + }); }); }); diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.ts b/ApiCore-Backend/src/label-templates/label-templates.service.ts index 87f6405..1773dbd 100644 --- a/ApiCore-Backend/src/label-templates/label-templates.service.ts +++ b/ApiCore-Backend/src/label-templates/label-templates.service.ts @@ -13,12 +13,21 @@ import { } from './label-element.types'; const MM_TO_PT = 72 / 25.4; -const PAGE_MARGIN_MM = 5; -const LABEL_GAP_MM = 2; const A4_WIDTH_MM = 210; const A4_HEIGHT_MM = 297; const QR_SOURCE_SIZE_PX = 400; +interface SheetLayout { + widthMm: number; + heightMm: number; + columns: number | null; + rows: number | null; + marginTopMm: number; + marginLeftMm: number; + gapXMm: number; + gapYMm: number; +} + function formatDate(date: Date): string { return date.toLocaleDateString('de-DE'); } @@ -151,6 +160,7 @@ export class LabelTemplatesService { async renderLabelsPdf( templateId: string, bottlingBatchId: string, + startPosition = 1, ): Promise { const template = await this.findById(templateId); const bottlingBatch = await this.prisma.bottlingBatch.findUnique({ @@ -193,19 +203,28 @@ export class LabelTemplatesService { return this.renderPdf( elements, - template.widthMm, - template.heightMm, + { + widthMm: template.widthMm, + heightMm: template.heightMm, + columns: template.columns, + rows: template.rows, + marginTopMm: template.marginTopMm, + marginLeftMm: template.marginLeftMm, + gapXMm: template.gapXMm, + gapYMm: template.gapYMm, + }, bottlingBatch.jarCount, qrCodeBuffer, + startPosition, ); } private renderPdf( elements: LabelElement[], - widthMm: number, - heightMm: number, + layout: SheetLayout, copies: number, qrCodeBuffer: Buffer | null, + startPosition: number, ): Promise { return new Promise((resolve, reject) => { const doc = new PDFDocument({ size: 'A4', margin: 0 }); @@ -214,27 +233,34 @@ export class LabelTemplatesService { doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', (error: Error) => reject(error)); - const usableWidthMm = A4_WIDTH_MM - 2 * PAGE_MARGIN_MM; - const usableHeightMm = A4_HEIGHT_MM - 2 * PAGE_MARGIN_MM; - const columns = Math.max( - 1, - Math.floor((usableWidthMm + LABEL_GAP_MM) / (widthMm + LABEL_GAP_MM)), - ); - const rows = Math.max( - 1, - Math.floor((usableHeightMm + LABEL_GAP_MM) / (heightMm + LABEL_GAP_MM)), - ); + const { widthMm, heightMm, marginTopMm, marginLeftMm, gapXMm, gapYMm } = + layout; + const usableWidthMm = A4_WIDTH_MM - 2 * marginLeftMm; + const usableHeightMm = A4_HEIGHT_MM - 2 * marginTopMm; + const columns = + layout.columns ?? + Math.max(1, Math.floor((usableWidthMm + gapXMm) / (widthMm + gapXMm))); + const rows = + layout.rows ?? + Math.max( + 1, + Math.floor((usableHeightMm + gapYMm) / (heightMm + gapYMm)), + ); const perPage = columns * rows; + // startPosition only skips ahead on the first sheet (partially-used + // physical sheet, e.g. a few labels already peeled off) - every sheet + // after that always starts fresh at the top-left slot. + const skip = Math.min(Math.max(0, startPosition - 1), perPage - 1); for (let index = 0; index < copies; index += 1) { - const onPage = index % perPage; - if (index > 0 && onPage === 0) { + const slotOnPage = (skip + index) % perPage; + if (index > 0 && slotOnPage === 0) { doc.addPage({ size: 'A4', margin: 0 }); } - const column = onPage % columns; - const row = Math.floor(onPage / columns); - const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM); - const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM); + const column = slotOnPage % columns; + const row = Math.floor(slotOnPage / columns); + const xMm = marginLeftMm + column * (widthMm + gapXMm); + const yMm = marginTopMm + row * (heightMm + gapYMm); const originX = xMm * MM_TO_PT; const originY = yMm * MM_TO_PT; const w = widthMm * MM_TO_PT; diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx index 9513bcb..6b3e50f 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx @@ -7,6 +7,8 @@ import { toast } from "sonner"; import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -17,6 +19,7 @@ import { export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) { const [templateId, setTemplateId] = useState(""); + const [startPosition, setStartPosition] = useState(1); const { data: templates } = useQuery({ queryKey: ["label-templates"], queryFn: fetchLabelTemplates, @@ -28,9 +31,13 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string } const selectedTemplate = activeTemplates.find( (template) => template.id === effectiveTemplateId, ); + const slotsPerSheet = + selectedTemplate?.columns && selectedTemplate?.rows + ? selectedTemplate.columns * selectedTemplate.rows + : undefined; const mutation = useMutation({ - mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId), + mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId, startPosition), onError: () => toast.error("Etiketten konnten nicht erstellt werden"), }); @@ -65,6 +72,23 @@ export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string } ))} +
+ + setStartPosition(Math.max(1, Number(event.target.value)))} + /> +

+ Für bereits angebrochene Bögen: Position der ersten noch freien Etikette. +

+