fix: Bug fixed für Rollen
This commit is contained in:
1 parent
8432313fa9
commit
5f4e1a7ba9
9 files changed
+311
-341
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
-130
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-128
@@ -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)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
Reference in new issue
Block a user