fix: Bug fixed für Rollen
Backend Image / build-and-push (push) Failing after 1m12s
Frontend Image / build-and-push (push) Successful in 1m14s

This commit is contained in:
Nick Szaunig committed 2026-09-01 16:54:42 +02:00
1 parent 8432313fa9
commit 5f4e1a7ba9
9 files changed
+311 -341

No files matched your search

+4 -1
View File
@@ -858,7 +858,10 @@ model Product {
model LabelTemplate { model LabelTemplate {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
content String // Array of LabelElement objects (text/placeholder/qrCode/image/line), see
// label-templates.service.ts. Drives both the in-browser canvas designer
// and the pdfkit print renderer from the same source of truth.
elements Json @default("[]")
widthMm Float @default(90) widthMm Float @default(90)
heightMm Float @default(35) heightMm Float @default(35)
isDefault Boolean @default(false) isDefault Boolean @default(false)
@@ -9,6 +9,14 @@ import { PERMISSION_CATALOG } from './permission-catalog';
* catalog entry missing from the database. Never deletes a Permission row * catalog entry missing from the database. Never deletes a Permission row
* that fell out of the catalog (only warns) — a role's grants must not * that fell out of the catalog (only warns) — a role's grants must not
* silently disappear because of a typo or a reordered catalog edit. * silently disappear because of a typo or a reordered catalog edit.
*
* Every newly-synced permission is also granted to every `isSystem` role
* (ADMIN's seed row documents itself as "Voller Zugriff auf alle Bereiche" -
* full access to every area). Without this, a role that legitimately means
* "everything" would silently lose that guarantee each time a new feature
* ships a new permission, forcing a manual grant in Rollen & Rechte before
* the feature is usable at all - exactly the gap that made the freshly
* shipped Etiketten feature invisible to the seeded admin account.
*/ */
@Injectable() @Injectable()
export class PermissionCatalogSyncService implements OnModuleInit { export class PermissionCatalogSyncService implements OnModuleInit {
@@ -25,15 +33,34 @@ export class PermissionCatalogSyncService implements OnModuleInit {
const missing = PERMISSION_CATALOG.filter( const missing = PERMISSION_CATALOG.filter(
(entry) => !existingKeys.has(entry.key), (entry) => !existingKeys.has(entry.key),
); );
for (const entry of missing) { const createdPermissions = await Promise.all(
await this.prisma.permission.create({ data: entry }); missing.map((entry) => this.prisma.permission.create({ data: entry })),
} );
if (missing.length > 0) { if (missing.length > 0) {
this.logger.log( this.logger.log(
`Synced ${missing.length} new permission(s) from the code catalog: ${missing `Synced ${missing.length} new permission(s) from the code catalog: ${missing
.map((entry) => entry.key) .map((entry) => entry.key)
.join(', ')}`, .join(', ')}`,
); );
const systemRoles = await this.prisma.role.findMany({
where: { isSystem: true },
select: { id: true },
});
if (systemRoles.length > 0) {
await this.prisma.rolePermission.createMany({
data: systemRoles.flatMap((role) =>
createdPermissions.map((permission) => ({
roleId: role.id,
permissionId: permission.id,
})),
),
skipDuplicates: true,
});
this.logger.log(
`Granted ${missing.length} new permission(s) to ${systemRoles.length} system role(s).`,
);
}
} }
const catalogKeys = new Set(PERMISSION_CATALOG.map((entry) => entry.key)); const catalogKeys = new Set(PERMISSION_CATALOG.map((entry) => entry.key));
@@ -1,23 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { import {
ArrayMaxSize,
IsArray,
IsBoolean, IsBoolean,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min, Min,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { LabelElementDto } from './label-element.dto';
export class CreateLabelTemplateDto { export class CreateLabelTemplateDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
name!: string; name!: string;
@ApiProperty({ @ApiProperty({ type: [LabelElementDto] })
description: @IsArray()
'Etikettentext mit Platzhaltern: {{batchNumber}} {{honeyType}} {{lotNumber}} {{bottlingDate}} {{bestBeforeDate}} {{jarSize}} {{productName}}', @ArrayMaxSize(50)
}) @ValidateNested({ each: true })
@IsString() @Type(() => LabelElementDto)
content!: string; elements!: LabelElementDto[];
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
import { LabelTemplatesService } from './label-templates.service'; import { LabelTemplatesService } from './label-templates.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { LabelElement } from './label-element.types';
describe('LabelTemplatesService', () => { describe('LabelTemplatesService', () => {
let service: LabelTemplatesService; let service: LabelTemplatesService;
@@ -23,10 +24,44 @@ describe('LabelTemplatesService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
const elements: LabelElement[] = [
{
id: 'el-1',
type: 'text',
x: 2,
y: 2,
width: 60,
height: 10,
text: '',
placeholderKey: 'batchNumber',
fontSize: 9,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
},
{
id: 'el-2',
type: 'text',
x: 2,
y: 14,
width: 60,
height: 10,
text: 'MHD:',
fontSize: 9,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
},
];
const template = { const template = {
id: 'template-1', id: 'template-1',
name: 'Standard', name: 'Standard',
content: 'Charge {{batchNumber}} - MHD {{bestBeforeDate}}', elements,
widthMm: 90, widthMm: 90,
heightMm: 35, heightMm: 35,
isDefault: false, isDefault: false,
@@ -74,7 +109,7 @@ describe('LabelTemplatesService', () => {
}); });
await service.create( await service.create(
{ name: 'Standard', content: 'x', isDefault: true }, { name: 'Standard', elements: [], isDefault: true },
'user-1', 'user-1',
); );
@@ -90,7 +125,7 @@ describe('LabelTemplatesService', () => {
it('does not touch other defaults when isDefault is not set', async () => { it('does not touch other defaults when isDefault is not set', async () => {
tx.labelTemplate.create.mockResolvedValue(template); tx.labelTemplate.create.mockResolvedValue(template);
await service.create({ name: 'Standard', content: 'x' }, 'user-1'); await service.create({ name: 'Standard', elements: [] }, 'user-1');
expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled(); expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled();
}); });
@@ -119,7 +154,7 @@ describe('LabelTemplatesService', () => {
}); });
describe('renderLabelsPdf', () => { describe('renderLabelsPdf', () => {
it('fills placeholders and tiles one label per jar', async () => { it('resolves placeholder text and tiles one label per jar', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template); prisma.labelTemplate.findUnique.mockResolvedValue(template);
prisma.bottlingBatch.findUnique.mockResolvedValue({ prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1', id: 'batch-1',
@@ -149,10 +184,17 @@ describe('LabelTemplatesService', () => {
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
it('embeds a QR code pointing at the Herkunft page when the template uses {{qrCode}}', async () => { it('embeds a QR code pointing at the Herkunft page when the template has a qrCode element', async () => {
const qrElement: LabelElement = {
id: 'el-qr',
type: 'qrCode',
x: 60,
y: 2,
size: 20,
};
prisma.labelTemplate.findUnique.mockResolvedValue({ prisma.labelTemplate.findUnique.mockResolvedValue({
...template, ...template,
content: `${template.content} {{qrCode}}`, elements: [...elements, qrElement],
}); });
prisma.bottlingBatch.findUnique.mockResolvedValue({ prisma.bottlingBatch.findUnique.mockResolvedValue({
id: 'batch-1', id: 'batch-1',
@@ -6,25 +6,54 @@ import { AuditLogService } from '../audit/audit-log.service';
import { LabelTemplate } from '../../generated/prisma'; import { LabelTemplate } from '../../generated/prisma';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto'; import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto'; import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import {
LabelElement,
LabelFontFamily,
PlaceholderKey,
} from './label-element.types';
const MM_TO_PT = 72 / 25.4; const MM_TO_PT = 72 / 25.4;
const PAGE_MARGIN_MM = 5; const PAGE_MARGIN_MM = 5;
const LABEL_GAP_MM = 2; const LABEL_GAP_MM = 2;
const A4_WIDTH_MM = 210; const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297; const A4_HEIGHT_MM = 297;
const QR_CODE_PLACEHOLDER = '{{qrCode}}'; const QR_SOURCE_SIZE_PX = 400;
function formatDate(date: Date): string { function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE'); return date.toLocaleDateString('de-DE');
} }
function fillPlaceholders( // Prisma stores `elements` as a plain Json column; the shape is only ever
content: string, // written through CreateLabelTemplateDto/UpdateLabelTemplateDto, so casting
values: Record<string, string>, // back to the app-level type here is safe.
function elementsOf(template: LabelTemplate): LabelElement[] {
return template.elements as unknown as LabelElement[];
}
function pdfFontName(
family: LabelFontFamily,
bold: boolean,
italic: boolean,
): string { ): string {
return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) => const base =
key in values ? values[key] : match, family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
); if (base === 'Times') {
if (bold && italic) return 'Times-BoldItalic';
if (bold) return 'Times-Bold';
if (italic) return 'Times-Italic';
return 'Times-Roman';
}
const suffix =
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
return `${base}${suffix}`;
}
function dataUrlToBuffer(dataUrl: string): Buffer | null {
const match = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(dataUrl);
if (!match) {
return null;
}
return Buffer.from(match[2], 'base64');
} }
@Injectable() @Injectable()
@@ -59,7 +88,9 @@ export class LabelTemplatesService {
data: { isDefault: false }, data: { isDefault: false },
}); });
} }
return tx.labelTemplate.create({ data: { ...dto } }); return tx.labelTemplate.create({
data: { ...dto, elements: dto.elements as never },
});
}); });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'LabelTemplate', entityType: 'LabelTemplate',
@@ -84,7 +115,10 @@ export class LabelTemplatesService {
data: { isDefault: false }, data: { isDefault: false },
}); });
} }
return tx.labelTemplate.update({ where: { id }, data: { ...dto } }); return tx.labelTemplate.update({
where: { id },
data: { ...dto, elements: dto.elements as never },
});
}); });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'LabelTemplate', entityType: 'LabelTemplate',
@@ -127,7 +161,7 @@ export class LabelTemplatesService {
throw new NotFoundException('Bottling batch not found'); throw new NotFoundException('Bottling batch not found');
} }
const values: Record<string, string> = { const values: Record<PlaceholderKey, string> = {
batchNumber: bottlingBatch.honeyBatch.batchNumber, batchNumber: bottlingBatch.honeyBatch.batchNumber,
honeyType: bottlingBatch.honeyBatch.honeyType ?? '', honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
lotNumber: bottlingBatch.lotNumber, lotNumber: bottlingBatch.lotNumber,
@@ -136,25 +170,29 @@ export class LabelTemplatesService {
jarSize: `${bottlingBatch.jarSize} ml`, jarSize: `${bottlingBatch.jarSize} ml`,
productName: bottlingBatch.product?.name ?? '', productName: bottlingBatch.product?.name ?? '',
}; };
const labelText = fillPlaceholders(template.content, values);
// The QR code is generated once (not per copy) - it always points at the const elements = elementsOf(template).map((element) =>
// public Herkunft/traceability page for this batch, which already shows element.type === 'text' && element.placeholderKey
// honey type, dates, and origin, so the label itself only needs to ? { ...element, text: values[element.placeholderKey] }
// reference it rather than duplicate that information. : element,
);
// The QR code always points at the public Herkunft/traceability page for
// this batch (honey type, dates, origin already shown there), generated
// once and reused across every copy and every qrCode element.
let qrCodeBuffer: Buffer | null = null; let qrCodeBuffer: Buffer | null = null;
if (labelText.includes(QR_CODE_PLACEHOLDER)) { if (elements.some((element) => element.type === 'qrCode')) {
const frontendOrigin = const frontendOrigin =
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`; const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`;
qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, { qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, {
margin: 1, margin: 1,
width: 300, width: QR_SOURCE_SIZE_PX,
}); });
} }
return this.renderPdf( return this.renderPdf(
labelText, elements,
template.widthMm, template.widthMm,
template.heightMm, template.heightMm,
bottlingBatch.jarCount, bottlingBatch.jarCount,
@@ -163,7 +201,7 @@ export class LabelTemplatesService {
} }
private renderPdf( private renderPdf(
labelText: string, elements: LabelElement[],
widthMm: number, widthMm: number,
heightMm: number, heightMm: number,
copies: number, copies: number,
@@ -197,40 +235,85 @@ export class LabelTemplatesService {
const row = Math.floor(onPage / columns); const row = Math.floor(onPage / columns);
const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM); const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM);
const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM); const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM);
const x = xMm * MM_TO_PT; const originX = xMm * MM_TO_PT;
const y = yMm * MM_TO_PT; const originY = yMm * MM_TO_PT;
const w = widthMm * MM_TO_PT; const w = widthMm * MM_TO_PT;
const h = heightMm * MM_TO_PT; const h = heightMm * MM_TO_PT;
doc.rect(x, y, w, h).dash(2, { space: 2 }).stroke('#999999').undash(); doc
.rect(originX, originY, w, h)
.dash(2, { space: 2 })
.stroke('#999999')
.undash();
if (qrCodeBuffer) { for (const element of elements) {
const qrSide = Math.min(w, h) - 8; this.drawElement(doc, element, originX, originY, qrCodeBuffer);
const qrX = x + w - qrSide - 4;
const qrY = y + (h - qrSide) / 2;
doc.image(qrCodeBuffer, qrX, qrY, { width: qrSide, height: qrSide });
const textWidth = qrX - 4 - (x + 4);
doc
.fontSize(9)
.fillColor('#000000')
.text(
labelText.replace(QR_CODE_PLACEHOLDER, '').trim(),
x + 4,
y + 4,
{
width: Math.max(textWidth, 0),
height: h - 8,
},
);
} else {
doc
.fontSize(9)
.fillColor('#000000')
.text(labelText, x + 4, y + 4, { width: w - 8, height: h - 8 });
} }
} }
doc.end(); doc.end();
}); });
} }
private drawElement(
doc: PDFKit.PDFDocument,
element: LabelElement,
originX: number,
originY: number,
qrCodeBuffer: Buffer | null,
): void {
const x = originX + element.x * MM_TO_PT;
const y = originY + element.y * MM_TO_PT;
switch (element.type) {
case 'text': {
doc
.font(pdfFontName(element.fontFamily, element.bold, element.italic))
.fontSize(element.fontSize)
.fillColor(element.color)
.text(element.text, x, y, {
width: element.width * MM_TO_PT,
height: element.height * MM_TO_PT,
align: element.align,
});
return;
}
case 'qrCode': {
if (!qrCodeBuffer) {
return;
}
const side = element.size * MM_TO_PT;
doc.image(qrCodeBuffer, x, y, { width: side, height: side });
return;
}
case 'image': {
const buffer = dataUrlToBuffer(element.src);
if (!buffer) {
return;
}
doc.image(buffer, x, y, {
width: element.width * MM_TO_PT,
height: element.height * MM_TO_PT,
});
return;
}
case 'line': {
const endX =
element.orientation === 'horizontal'
? x + element.length * MM_TO_PT
: x;
const endY =
element.orientation === 'vertical'
? y + element.length * MM_TO_PT
: y;
doc
.moveTo(x, y)
.lineTo(endX, endY)
.lineWidth(element.thickness)
.strokeColor(element.color)
.stroke();
return;
}
}
}
} }
@@ -1,130 +0,0 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createLabelTemplate } from "@/lib/label-templates/api";
import { LABEL_PLACEHOLDERS } from "@/lib/label-templates/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function CreateLabelTemplateDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [content, setContent] = useState("");
const [widthMm, setWidthMm] = useState("90");
const [heightMm, setHeightMm] = useState("35");
const [error, setError] = useState<string | null>(null);
function reset() {
setName("");
setContent("");
setWidthMm("90");
setHeightMm("35");
setError(null);
}
const mutation = useMutation({
mutationFn: createLabelTemplate,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
setOpen(false);
reset();
},
onError: () => setError("Vorlage konnte nicht angelegt werden."),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
name,
content,
widthMm: widthMm ? Number(widthMm) : undefined,
heightMm: heightMm ? Number(heightMm) : undefined,
});
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<Button onClick={() => setOpen(true)}>+ Neue Etikettenvorlage</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Etikettenvorlage</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="label-template-name">Name</Label>
<Input
id="label-template-name"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="label-template-width">Breite (mm)</Label>
<Input
id="label-template-width"
type="number"
inputMode="decimal"
value={widthMm}
onChange={(event) => setWidthMm(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="label-template-height">Höhe (mm)</Label>
<Input
id="label-template-height"
type="number"
inputMode="decimal"
value={heightMm}
onChange={(event) => setHeightMm(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="label-template-content">Etikettentext</Label>
<Textarea
id="label-template-content"
required
rows={6}
value={content}
onChange={(event) => setContent(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Platzhalter:{" "}
{LABEL_PLACEHOLDERS.map((placeholder) => `{{${placeholder.key}}}`).join(", ")}
</p>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,128 +0,0 @@
"use client";
import { FormEvent, useState } from "react";
import { Pencil } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateLabelTemplate } from "@/lib/label-templates/api";
import { LABEL_PLACEHOLDERS, LabelTemplate } from "@/lib/label-templates/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function EditLabelTemplateDialog({ template }: { template: LabelTemplate }) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState(template.name);
const [content, setContent] = useState(template.content);
const [widthMm, setWidthMm] = useState(template.widthMm.toString());
const [heightMm, setHeightMm] = useState(template.heightMm.toString());
const [isDefault, setIsDefault] = useState(template.isDefault);
const mutation = useMutation({
mutationFn: () =>
updateLabelTemplate(template.id, {
name,
content,
widthMm: widthMm ? Number(widthMm) : undefined,
heightMm: heightMm ? Number(heightMm) : undefined,
isDefault,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["label-templates"] });
setOpen(false);
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate();
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button variant="ghost" size="sm">
<Pencil className="size-3.5" />
Bearbeiten
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Etikettenvorlage bearbeiten</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="label-template-edit-name">Name</Label>
<Input
id="label-template-edit-name"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="label-template-edit-width">Breite (mm)</Label>
<Input
id="label-template-edit-width"
type="number"
inputMode="decimal"
value={widthMm}
onChange={(event) => setWidthMm(event.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="label-template-edit-height">Höhe (mm)</Label>
<Input
id="label-template-edit-height"
type="number"
inputMode="decimal"
value={heightMm}
onChange={(event) => setHeightMm(event.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="label-template-edit-content">Etikettentext</Label>
<Textarea
id="label-template-edit-content"
required
rows={6}
value={content}
onChange={(event) => setContent(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Platzhalter:{" "}
{LABEL_PLACEHOLDERS.map((placeholder) => `{{${placeholder.key}}}`).join(", ")}
</p>
</div>
<label className="flex items-center gap-2 text-sm" htmlFor="label-template-edit-default">
<input
id="label-template-edit-default"
type="checkbox"
checked={isDefault}
onChange={(event) => setIsDefault(event.target.checked)}
/>
Als Standardvorlage verwenden
</label>
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Tag } from "lucide-react"; import Link from "next/link";
import { Pencil, Plus, Tag } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api"; import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api";
@@ -9,8 +10,6 @@ import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { EntityCard } from "@/components/entity-card"; import { EntityCard } from "@/components/entity-card";
import { CreateLabelTemplateDialog } from "./create-label-template-dialog";
import { EditLabelTemplateDialog } from "./edit-label-template-dialog";
export default function LabelTemplatesPage() { export default function LabelTemplatesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -37,7 +36,12 @@ export default function LabelTemplatesPage() {
title="Etikettenvorlagen" title="Etikettenvorlagen"
description="Vorlagen mit Platzhaltern für den Etikettendruck verwalten" description="Vorlagen mit Platzhaltern für den Etikettendruck verwalten"
gradient="var(--gradient-sky)" gradient="var(--gradient-sky)"
actions={<CreateLabelTemplateDialog />} actions={
<Button render={<Link href="/settings/label-templates/new" />}>
<Plus className="size-4" />
Neue Etikettenvorlage
</Button>
}
/> />
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -69,7 +73,14 @@ export default function LabelTemplatesPage() {
]} ]}
actions={ actions={
<> <>
<EditLabelTemplateDialog template={template} /> <Button
variant="ghost"
size="sm"
render={<Link href={`/settings/label-templates/${template.id}`} />}
>
<Pencil className="size-3.5" />
Bearbeiten
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -1,7 +1,78 @@
export type LabelFontFamily = "sans" | "serif" | "mono";
export type LabelTextAlign = "left" | "center" | "right";
export type LabelLineOrientation = "horizontal" | "vertical";
export const PLACEHOLDER_KEYS = [
"batchNumber",
"honeyType",
"lotNumber",
"bottlingDate",
"bestBeforeDate",
"jarSize",
"productName",
] as const;
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
batchNumber: "Chargennummer",
honeyType: "Honigsorte",
lotNumber: "Los-/Chargennummer der Abfüllung",
bottlingDate: "Abfülldatum",
bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
jarSize: "Glasgröße",
productName: "Produktname",
};
interface LabelElementBase {
id: string;
x: number;
y: number;
}
export interface LabelTextElement extends LabelElementBase {
type: "text";
width: number;
height: number;
text: string;
placeholderKey?: PlaceholderKey | null;
fontSize: number;
fontFamily: LabelFontFamily;
bold: boolean;
italic: boolean;
color: string;
align: LabelTextAlign;
}
export interface LabelQrCodeElement extends LabelElementBase {
type: "qrCode";
size: number;
}
export interface LabelImageElement extends LabelElementBase {
type: "image";
width: number;
height: number;
src: string;
}
export interface LabelLineElement extends LabelElementBase {
type: "line";
length: number;
thickness: number;
color: string;
orientation: LabelLineOrientation;
}
export type LabelElement =
| LabelTextElement
| LabelQrCodeElement
| LabelImageElement
| LabelLineElement;
export interface LabelTemplate { export interface LabelTemplate {
id: string; id: string;
name: string; name: string;
content: string; elements: LabelElement[];
widthMm: number; widthMm: number;
heightMm: number; heightMm: number;
isDefault: boolean; isDefault: boolean;
@@ -12,7 +83,7 @@ export interface LabelTemplate {
export interface CreateLabelTemplateInput { export interface CreateLabelTemplateInput {
name: string; name: string;
content: string; elements: LabelElement[];
widthMm?: number; widthMm?: number;
heightMm?: number; heightMm?: number;
isDefault?: boolean; isDefault?: boolean;
@@ -20,22 +91,8 @@ export interface CreateLabelTemplateInput {
export interface UpdateLabelTemplateInput { export interface UpdateLabelTemplateInput {
name?: string; name?: string;
content?: string; elements?: LabelElement[];
widthMm?: number; widthMm?: number;
heightMm?: number; heightMm?: number;
isDefault?: boolean; isDefault?: boolean;
} }
export const LABEL_PLACEHOLDERS: { key: string; description: string }[] = [
{ key: "batchNumber", description: "Chargennummer der Honigcharge" },
{ key: "honeyType", description: "Honigsorte" },
{ key: "lotNumber", description: "Los-/Chargennummer der Abfüllung" },
{ key: "bottlingDate", description: "Abfülldatum" },
{ key: "bestBeforeDate", description: "Mindesthaltbarkeitsdatum (MHD)" },
{ key: "jarSize", description: "Glasgröße" },
{ key: "productName", description: "Produktname" },
{
key: "qrCode",
description: "QR-Code zur öffentlichen Herkunftsseite der Charge (Honigsorte, Daten, Rückverfolgbarkeit)",
},
];