feat: implement backend label print agent system for remote label rendering and job management
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
# Backend API für LabelPrintAgent
|
||||
|
||||
Diese Datei beschreibt die Endpunkte, die der PaperlessManager bereitstellen muss, damit der LabelPrintAgent Etiketten abholen, drucken und das Ergebnis zurückmelden kann.
|
||||
|
||||
Der LabelPrintAgent rendert keine Layouts selbst. Das Backend liefert ein fertiges Etikettbild.
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Alle Endpunkte sollten denselben Bearer Token akzeptieren:
|
||||
|
||||
```http
|
||||
Authorization: Bearer {apiToken}
|
||||
```
|
||||
|
||||
Der Token wird im Agent lokal verschlüsselt gespeichert.
|
||||
|
||||
## 1. Nächsten Druckjob abrufen
|
||||
|
||||
```http
|
||||
GET /api/label-print-agent/jobs/next?agentId={agentId}
|
||||
```
|
||||
|
||||
Der Agent ruft diesen Endpunkt alle X Sekunden auf.
|
||||
|
||||
### Query-Parameter
|
||||
|
||||
| Name | Pflicht | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `agentId` | ja | Eindeutige ID des Agents, z. B. Rechnername |
|
||||
|
||||
### Antwort, wenn kein Job vorhanden ist
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```
|
||||
|
||||
### Antwort mit Bild direkt im JSON
|
||||
|
||||
```http
|
||||
200 OK
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "12345",
|
||||
"labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...",
|
||||
"labelImageContentType": "image/png",
|
||||
"labelWidthMm": 57,
|
||||
"labelHeightMm": 32
|
||||
}
|
||||
```
|
||||
|
||||
### Antwort mit separater Bild-URL
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "12345",
|
||||
"labelImageUrl": "/api/label-print-agent/jobs/12345/image",
|
||||
"labelImageContentType": "image/png",
|
||||
"labelWidthMm": 57,
|
||||
"labelHeightMm": 32
|
||||
}
|
||||
```
|
||||
|
||||
### Felder
|
||||
|
||||
| Feld | Pflicht | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `jobId` | ja | Eindeutige Job-ID für Rückmeldungen |
|
||||
| `labelImageBase64` | bedingt | Base64-kodiertes Etikettbild |
|
||||
| `labelImageUrl` | bedingt | URL zum Nachladen des Etikettbilds |
|
||||
| `labelImageContentType` | empfohlen | z. B. `image/png` |
|
||||
| `labelWidthMm` | ja | Etikettenbreite in mm, z. B. `57` |
|
||||
| `labelHeightMm` | ja | Etikettenhöhe in mm, z. B. `32` |
|
||||
|
||||
`labelImageBase64` oder `labelImageUrl` muss gesetzt sein.
|
||||
|
||||
## 2. Etikettbild nachladen
|
||||
|
||||
Nur erforderlich, wenn `labelImageUrl` verwendet wird.
|
||||
|
||||
```http
|
||||
GET /api/label-print-agent/jobs/{jobId}/image
|
||||
```
|
||||
|
||||
### Antwort
|
||||
|
||||
```http
|
||||
200 OK
|
||||
Content-Type: image/png
|
||||
```
|
||||
|
||||
Body: Binärdaten des fertigen Etikettbilds.
|
||||
|
||||
Empfehlung: PNG, schwarz/weiß, passend zum Etikettenformat, z. B. 57 x 32 mm bei 300 dpi.
|
||||
|
||||
## 3. Erfolgreichen Druck melden
|
||||
|
||||
```http
|
||||
POST /api/label-print-agent/jobs/{jobId}/printed
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"agentId": "PC-BUERO",
|
||||
"printerName": "DYMO LabelWriter 450"
|
||||
}
|
||||
```
|
||||
|
||||
### Antwort
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```
|
||||
|
||||
Das Backend sollte den Job erst hier endgültig als gedruckt markieren.
|
||||
|
||||
## 4. Fehler melden
|
||||
|
||||
```http
|
||||
POST /api/label-print-agent/jobs/{jobId}/error
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"agentId": "PC-BUERO",
|
||||
"printerName": "DYMO LabelWriter 450",
|
||||
"errorMessage": "Drucker ist nicht verfügbar."
|
||||
}
|
||||
```
|
||||
|
||||
### Antwort
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```http
|
||||
204 No Content
|
||||
```
|
||||
|
||||
Das Backend entscheidet danach, ob der Job erneut angeboten wird oder auf Fehler bleibt.
|
||||
|
||||
## Backend-Verhalten
|
||||
|
||||
Empfohlener Ablauf im Backend:
|
||||
|
||||
1. Job erstellen und serverseitig Layout, Nummern, QR-Code und Bild erzeugen.
|
||||
2. Job bleibt wartend, bis ein Agent ihn abholt.
|
||||
3. `jobs/next` liefert jeweils höchstens einen Job.
|
||||
4. Backend reserviert oder lockt den Job beim Ausliefern, damit zwei Agents ihn nicht parallel drucken.
|
||||
5. Agent druckt lokal.
|
||||
6. Agent meldet `printed` oder `error`.
|
||||
7. Backend setzt den finalen Status.
|
||||
|
||||
## Empfohlene Statuscodes
|
||||
|
||||
| Situation | Status |
|
||||
| --- | --- |
|
||||
| Kein Job vorhanden | `204 No Content` |
|
||||
| Job vorhanden | `200 OK` |
|
||||
| Token fehlt/ungültig | `401 Unauthorized` |
|
||||
| Agent darf nicht drucken | `403 Forbidden` |
|
||||
| Job-ID unbekannt | `404 Not Found` |
|
||||
| Backend-Fehler | `500 Internal Server Error` |
|
||||
|
||||
## Server-Sent Events optional
|
||||
|
||||
Später kann das Backend zusätzlich einen Event-Endpunkt anbieten:
|
||||
|
||||
```http
|
||||
GET /api/label-print-agent/events?agentId={agentId}
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
event: label-job-available
|
||||
data: {"count":1}
|
||||
```
|
||||
|
||||
Der Agent könnte dann bei einem Event sofort `jobs/next` aufrufen. Polling bleibt trotzdem als Fallback sinnvoll.
|
||||
|
||||
## Wichtige Designentscheidung
|
||||
|
||||
Der Agent kennt keine fachlichen Layouts mehr:
|
||||
|
||||
- keine `layout_key`
|
||||
- keine lokalen LabelTemplates
|
||||
- keine MySQL-Verbindung
|
||||
- keine Nummernreservierung
|
||||
- kein QR-Code-Rendering
|
||||
|
||||
Das Backend liefert ein fertiges Bild. Der Agent ist nur noch lokaler Windows-Druck-Connector.
|
||||
@@ -17,6 +17,7 @@ import { StatsModule } from './stats/stats.module';
|
||||
import { BarcodeModule } from './barcode/barcode.module';
|
||||
import { InboxPostprocessorModule } from './inbox-postprocessor/inbox-postprocessor.module';
|
||||
import { UserSettingsModule } from './user-settings/user-settings.module';
|
||||
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
|
||||
import * as path from 'path';
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
} from './entities';
|
||||
|
||||
const entities = [
|
||||
@@ -49,6 +50,7 @@ const entities = [
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
];
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -8,6 +8,17 @@ import {
|
||||
|
||||
export type BarcodeActionType = 'SEND_TO_PAPERLESS' | 'SEND_BY_EMAIL';
|
||||
|
||||
export interface LabelInputField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'date';
|
||||
}
|
||||
|
||||
export type LabelElement =
|
||||
| { type: 'text'; content: string; x: number; y: number; fontSize: number; bold?: boolean; align?: 'left' | 'center' | 'right'; maxWidth?: number }
|
||||
| { type: 'qr'; content: string; x: number; y: number; sizeMm: number }
|
||||
| { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number };
|
||||
|
||||
@Entity('barcode_templates')
|
||||
export class BarcodeTemplate {
|
||||
@PrimaryGeneratedColumn()
|
||||
@@ -28,6 +39,31 @@ export class BarcodeTemplate {
|
||||
@Column({ type: 'json' })
|
||||
Actions!: BarcodeActionType[];
|
||||
|
||||
// ── Label-Konfiguration ─────────────────────────────────────
|
||||
@Column({ type: 'boolean', default: false })
|
||||
LabelEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
LabelWidthMm!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
LabelHeightMm!: number | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
LabelInputFields!: LabelInputField[] | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000, nullable: true })
|
||||
LabelGetUrl!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000, nullable: true })
|
||||
LabelPrintedUrl!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000, nullable: true })
|
||||
LabelReleaseUrl!: string | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
LabelLayout!: LabelElement[] | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
CreatedAt!: Date;
|
||||
|
||||
|
||||
@@ -20,3 +20,4 @@ export { InboxDocument } from './inbox-document.entity';
|
||||
export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity';
|
||||
export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity';
|
||||
export { UserSettings } from './user-settings.entity';
|
||||
export { LabelPrintJob } from './label-print-job.entity';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('label_print_jobs')
|
||||
export class LabelPrintJob {
|
||||
@PrimaryGeneratedColumn()
|
||||
Id!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
Status!: 'pending' | 'printed' | 'error';
|
||||
|
||||
@Column({ type: 'mediumblob', nullable: true })
|
||||
LabelImageData!: Buffer | null;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
LabelWidthMm!: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
LabelHeightMm!: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
BarcodeTemplateId!: number | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
LabelVariables!: Record<string, string> | null;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
LockedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
LockedByAgent!: string | null;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
PrintedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
PrintedByAgent!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
PrinterName!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
ErrorMessage!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
CreatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
import { LabelPrintAgentService } from './label-print-agent.service';
|
||||
|
||||
@Controller('api/label-print-agent')
|
||||
@UseGuards(JwtOrApiKeyGuard)
|
||||
export class LabelPrintAgentController {
|
||||
constructor(private readonly service: LabelPrintAgentService) {}
|
||||
|
||||
// Manuell einen Job anlegen (Frontend → Backend)
|
||||
@Post('jobs')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@RequirePermissions(Permission.VIEW_SCANNER)
|
||||
async createJob(
|
||||
@Body() body: { templateId: number; fieldValues?: Record<string, string> },
|
||||
) {
|
||||
const job = await this.service.createJob(body.templateId, body.fieldValues ?? {});
|
||||
return { jobId: String(job.Id) };
|
||||
}
|
||||
|
||||
// Agent: nächsten Job abholen (Polling)
|
||||
@Get('jobs/next')
|
||||
async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) {
|
||||
const job = await this.service.claimNextJob(agentId ?? 'unknown');
|
||||
if (!job) {
|
||||
res.status(HttpStatus.NO_CONTENT).send();
|
||||
return;
|
||||
}
|
||||
return {
|
||||
jobId: String(job.Id),
|
||||
labelImageBase64: job.LabelImageData ? job.LabelImageData.toString('base64') : null,
|
||||
labelImageContentType: 'image/png',
|
||||
labelWidthMm: job.LabelWidthMm,
|
||||
labelHeightMm: job.LabelHeightMm,
|
||||
};
|
||||
}
|
||||
|
||||
// Agent: Bild separat abrufen
|
||||
@Get('jobs/:id/image')
|
||||
async getImage(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const buf = await this.service.getJobImage(id);
|
||||
if (!buf) throw new NotFoundException('Bild nicht gefunden');
|
||||
const { Readable } = await import('stream');
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
return new StreamableFile(Readable.from(buf));
|
||||
}
|
||||
|
||||
// Agent: Druck erfolgreich
|
||||
@Post('jobs/:id/printed')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markPrinted(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { agentId?: string; printerName?: string },
|
||||
) {
|
||||
await this.service.markPrinted(id, body.agentId ?? 'unknown', body.printerName ?? '');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Agent: Druckfehler
|
||||
@Post('jobs/:id/error')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async markError(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: { agentId?: string; printerName?: string; errorMessage?: string },
|
||||
) {
|
||||
await this.service.markError(id, body.agentId ?? 'unknown', body.printerName ?? '', body.errorMessage ?? '');
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
|
||||
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
||||
import { LabelPrintAgentService } from './label-print-agent.service';
|
||||
import { LabelRendererService } from './label-renderer.service';
|
||||
import { LabelPrintAgentController } from './label-print-agent.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LabelPrintJob, BarcodeTemplate]),
|
||||
AuthModule,
|
||||
],
|
||||
providers: [LabelPrintAgentService, LabelRendererService],
|
||||
controllers: [LabelPrintAgentController],
|
||||
exports: [LabelPrintAgentService, LabelRendererService],
|
||||
})
|
||||
export class LabelPrintAgentModule {}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan, IsNull, Or } from 'typeorm';
|
||||
import { LabelPrintJob } from '../database/entities/label-print-job.entity';
|
||||
import { BarcodeTemplate } from '../database/entities/barcode-template.entity';
|
||||
import { LabelRendererService } from './label-renderer.service';
|
||||
|
||||
function applyVars(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] ?? '');
|
||||
}
|
||||
|
||||
function lockExpiry(): Date {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - 5);
|
||||
return d;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LabelPrintAgentService {
|
||||
private readonly logger = new Logger(LabelPrintAgentService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LabelPrintJob)
|
||||
private readonly jobRepo: Repository<LabelPrintJob>,
|
||||
@InjectRepository(BarcodeTemplate)
|
||||
private readonly templateRepo: Repository<BarcodeTemplate>,
|
||||
private readonly renderer: LabelRendererService,
|
||||
) {}
|
||||
|
||||
async createJob(
|
||||
templateId: number,
|
||||
fieldValues: Record<string, string>,
|
||||
): Promise<LabelPrintJob> {
|
||||
const template = await this.templateRepo.findOne({ where: { Id: templateId } });
|
||||
if (!template) throw new NotFoundException('Template nicht gefunden');
|
||||
if (!template.LabelEnabled) throw new BadRequestException('Etikett-Druck für dieses Template nicht aktiviert');
|
||||
|
||||
// Variablen aufbauen
|
||||
const vars: Record<string, string> = { ...fieldValues };
|
||||
|
||||
// Datum-Felder: year/month/day separat ablegen
|
||||
for (const field of template.LabelInputFields ?? []) {
|
||||
if (field.type === 'date' && fieldValues[field.name]) {
|
||||
const raw = fieldValues[field.name]; // erwartet YYYY-MM-DD
|
||||
const parts = raw.split('-');
|
||||
if (parts.length === 3) {
|
||||
vars[`${field.name}.year`] = parts[0];
|
||||
vars[`${field.name}.month`] = parts[1];
|
||||
vars[`${field.name}.day`] = parts[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET-URL aufrufen → {number}
|
||||
if (template.LabelGetUrl) {
|
||||
const url = applyVars(template.LabelGetUrl, vars);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const text = (await res.text()).trim();
|
||||
vars['number'] = text;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`GET-URL fehlgeschlagen (${url}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const job = this.jobRepo.create({
|
||||
Status: 'pending',
|
||||
LabelImageData: null,
|
||||
LabelWidthMm: template.LabelWidthMm ?? 57,
|
||||
LabelHeightMm: template.LabelHeightMm ?? 32,
|
||||
BarcodeTemplateId: template.Id,
|
||||
LabelVariables: vars,
|
||||
LockedAt: null,
|
||||
LockedByAgent: null,
|
||||
});
|
||||
|
||||
return this.jobRepo.save(job);
|
||||
}
|
||||
|
||||
async claimNextJob(agentId: string): Promise<LabelPrintJob | null> {
|
||||
// Kandidat: pending und kein Lock oder Lock abgelaufen (> 5 Min)
|
||||
const candidate = await this.jobRepo.findOne({
|
||||
where: [
|
||||
{ Status: 'pending', LockedAt: IsNull() },
|
||||
{ Status: 'pending', LockedAt: LessThan(lockExpiry()) },
|
||||
],
|
||||
order: { CreatedAt: 'ASC' },
|
||||
});
|
||||
if (!candidate) return null;
|
||||
|
||||
// Lock setzen
|
||||
candidate.LockedAt = new Date();
|
||||
candidate.LockedByAgent = agentId;
|
||||
await this.jobRepo.save(candidate);
|
||||
|
||||
// Lazy render
|
||||
if (!candidate.LabelImageData) {
|
||||
const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId ?? undefined } });
|
||||
if (template?.LabelLayout?.length) {
|
||||
try {
|
||||
candidate.LabelImageData = await this.renderer.render(
|
||||
template.LabelLayout,
|
||||
candidate.LabelWidthMm,
|
||||
candidate.LabelHeightMm,
|
||||
candidate.LabelVariables ?? {},
|
||||
);
|
||||
await this.jobRepo.save(candidate);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Label-Rendering fehlgeschlagen für Job ${candidate.Id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async getJobImage(jobId: number): Promise<Buffer | null> {
|
||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||
return job?.LabelImageData ?? null;
|
||||
}
|
||||
|
||||
async markPrinted(jobId: number, agentId: string, printerName: string): Promise<void> {
|
||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
||||
|
||||
job.Status = 'printed';
|
||||
job.PrintedAt = new Date();
|
||||
job.PrintedByAgent = agentId;
|
||||
job.PrinterName = printerName;
|
||||
await this.jobRepo.save(job);
|
||||
|
||||
await this.callUrl('PRINTED', job);
|
||||
}
|
||||
|
||||
async markError(jobId: number, agentId: string, printerName: string, errorMessage: string): Promise<void> {
|
||||
const job = await this.jobRepo.findOne({ where: { Id: jobId } });
|
||||
if (!job) throw new NotFoundException('Job nicht gefunden');
|
||||
|
||||
job.Status = 'error';
|
||||
job.PrintedByAgent = agentId;
|
||||
job.PrinterName = printerName;
|
||||
job.ErrorMessage = errorMessage;
|
||||
await this.jobRepo.save(job);
|
||||
|
||||
await this.callUrl('RELEASE', job);
|
||||
}
|
||||
|
||||
private async callUrl(type: 'PRINTED' | 'RELEASE', job: LabelPrintJob): Promise<void> {
|
||||
const template = job.BarcodeTemplateId
|
||||
? await this.templateRepo.findOne({ where: { Id: job.BarcodeTemplateId } })
|
||||
: null;
|
||||
if (!template) return;
|
||||
|
||||
const urlTemplate = type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl;
|
||||
if (!urlTemplate) return;
|
||||
|
||||
const url = applyVars(urlTemplate, job.LabelVariables ?? {});
|
||||
try {
|
||||
await fetch(url, { method: 'POST' });
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`${type}-URL fehlgeschlagen (${url}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as QRCode from 'qrcode';
|
||||
import sharp from 'sharp';
|
||||
import type { LabelElement } from '../database/entities/barcode-template.entity';
|
||||
|
||||
const MM_TO_PX = 300 / 25.4; // 300 DPI
|
||||
|
||||
function mm(v: number): number {
|
||||
return Math.round(v * MM_TO_PX);
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function applyVars(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LabelRendererService {
|
||||
private readonly logger = new Logger(LabelRendererService.name);
|
||||
|
||||
async render(
|
||||
layout: LabelElement[],
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
variables: Record<string, string>,
|
||||
): Promise<Buffer> {
|
||||
const W = mm(widthMm);
|
||||
const H = mm(heightMm);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const el of layout) {
|
||||
if (el.type === 'text') {
|
||||
const x = mm(el.x);
|
||||
const y = mm(el.y);
|
||||
const fontSize = mm(el.fontSize);
|
||||
const content = escape(applyVars(el.content, variables));
|
||||
const fontWeight = el.bold ? 'bold' : 'normal';
|
||||
const textAnchor = el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start';
|
||||
const maxWidthAttr = el.maxWidth ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` : '';
|
||||
parts.push(
|
||||
`<text x="${x}" y="${y}" font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" font-weight="${fontWeight}" text-anchor="${textAnchor}" dominant-baseline="hanging"${maxWidthAttr}>${content}</text>`,
|
||||
);
|
||||
} else if (el.type === 'qr') {
|
||||
const x = mm(el.x);
|
||||
const y = mm(el.y);
|
||||
const size = mm(el.sizeMm);
|
||||
const content = applyVars(el.content, variables);
|
||||
try {
|
||||
const qrBuffer = await QRCode.toBuffer(content, {
|
||||
type: 'png',
|
||||
margin: 0,
|
||||
width: size,
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
const b64 = qrBuffer.toString('base64');
|
||||
parts.push(`<image href="data:image/png;base64,${b64}" x="${x}" y="${y}" width="${size}" height="${size}"/>`);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`);
|
||||
}
|
||||
} else if (el.type === 'line') {
|
||||
const x1 = mm(el.x1);
|
||||
const y1 = mm(el.y1);
|
||||
const x2 = mm(el.x2);
|
||||
const y2 = mm(el.y2);
|
||||
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
|
||||
parts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="black" stroke-width="${strokeWidth}"/>`);
|
||||
}
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||||
<rect width="${W}" height="${H}" fill="white"/>
|
||||
${parts.join('\n ')}
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,17 @@ import api from './client';
|
||||
|
||||
export type BarcodeActionType = 'SEND_TO_PAPERLESS' | 'SEND_BY_EMAIL';
|
||||
|
||||
export interface LabelInputField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'date';
|
||||
}
|
||||
|
||||
export type LabelElement =
|
||||
| { type: 'text'; content: string; x: number; y: number; fontSize: number; bold?: boolean; align?: 'left' | 'center' | 'right'; maxWidth?: number }
|
||||
| { type: 'qr'; content: string; x: number; y: number; sizeMm: number }
|
||||
| { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number };
|
||||
|
||||
export interface BarcodeTemplate {
|
||||
Id: number;
|
||||
Name: string;
|
||||
@@ -9,6 +20,14 @@ export interface BarcodeTemplate {
|
||||
SplitBefore: boolean;
|
||||
DateinameTemplate: string | null;
|
||||
Actions: BarcodeActionType[];
|
||||
LabelEnabled: boolean;
|
||||
LabelWidthMm: number | null;
|
||||
LabelHeightMm: number | null;
|
||||
LabelInputFields: LabelInputField[] | null;
|
||||
LabelGetUrl: string | null;
|
||||
LabelPrintedUrl: string | null;
|
||||
LabelReleaseUrl: string | null;
|
||||
LabelLayout: LabelElement[] | null;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
}
|
||||
@@ -19,6 +38,14 @@ export interface BarcodeTemplateInput {
|
||||
SplitBefore: boolean;
|
||||
DateinameTemplate?: string | null;
|
||||
Actions: BarcodeActionType[];
|
||||
LabelEnabled?: boolean;
|
||||
LabelWidthMm?: number | null;
|
||||
LabelHeightMm?: number | null;
|
||||
LabelInputFields?: LabelInputField[] | null;
|
||||
LabelGetUrl?: string | null;
|
||||
LabelPrintedUrl?: string | null;
|
||||
LabelReleaseUrl?: string | null;
|
||||
LabelLayout?: LabelElement[] | null;
|
||||
}
|
||||
|
||||
export const BARCODE_ACTION_LABELS: Record<BarcodeActionType, string> = {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import api from './client';
|
||||
|
||||
export const labelPrintAgentApi = {
|
||||
createJob: (templateId: number, fieldValues: Record<string, string>) =>
|
||||
api
|
||||
.post<{ jobId: string }>('/api/label-print-agent/jobs', { templateId, fieldValues })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -1107,15 +1107,7 @@ export default function InboxDetailPage() {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
|
||||
Zurück
|
||||
@@ -1136,9 +1128,7 @@ export default function InboxDetailPage() {
|
||||
{ key: 'email', label: 'Als E-Mail-Anhang versenden', icon: <MailOutlined /> },
|
||||
] as MenuProps['items'],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'save') {
|
||||
setDownloadDialogOpen(true);
|
||||
}
|
||||
if (key === 'save') setDownloadDialogOpen(true);
|
||||
if (key === 'email') setEmailDialogOpen(true);
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
@@ -18,6 +24,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FolderOpenOutlined,
|
||||
PrinterOutlined,
|
||||
QrcodeOutlined,
|
||||
ReloadOutlined,
|
||||
ScanOutlined,
|
||||
@@ -27,6 +34,8 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox';
|
||||
import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates';
|
||||
import { labelPrintAgentApi } from '../api/labelPrintAgent';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -123,6 +132,45 @@ export default function InboxPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
|
||||
const [printDialogOpen, setPrintDialogOpen] = useState(false);
|
||||
const [labelTemplates, setLabelTemplates] = useState<BarcodeTemplate[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<BarcodeTemplate | null>(null);
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||
const [printing, setPrinting] = useState(false);
|
||||
|
||||
const openPrintDialog = async () => {
|
||||
try {
|
||||
const all = await barcodeTemplatesApi.list();
|
||||
setLabelTemplates(all.filter((t) => t.LabelEnabled));
|
||||
} catch {
|
||||
message.error('Vorlagen konnten nicht geladen werden');
|
||||
return;
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
setFieldValues({});
|
||||
setPrintDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (id: number) => {
|
||||
const t = labelTemplates.find((t) => t.Id === id) ?? null;
|
||||
setSelectedTemplate(t);
|
||||
setFieldValues({});
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
if (!selectedTemplate) return;
|
||||
setPrinting(true);
|
||||
try {
|
||||
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
|
||||
message.success('Druckauftrag erstellt');
|
||||
setPrintDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message ?? 'Druckauftrag fehlgeschlagen');
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -331,9 +379,67 @@ export default function InboxPage() {
|
||||
Rescan
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
|
||||
Etikett drucken
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="Etikett drucken"
|
||||
open={printDialogOpen}
|
||||
onCancel={() => setPrintDialogOpen(false)}
|
||||
onOk={handlePrint}
|
||||
okText="Drucken"
|
||||
cancelText="Abbrechen"
|
||||
confirmLoading={printing}
|
||||
okButtonProps={{ disabled: !selectedTemplate }}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Eingangsdokumentart">
|
||||
<Select
|
||||
placeholder="Vorlage wählen…"
|
||||
options={labelTemplates.map((t) => ({ value: t.Id, label: t.Name }))}
|
||||
onChange={handleTemplateSelect}
|
||||
value={selectedTemplate?.Id}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{selectedTemplate?.LabelInputFields?.map((field) => (
|
||||
<Form.Item key={field.name} label={field.label || field.name}>
|
||||
{field.type === 'date' ? (
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
value={fieldValues[field.name] ? dayjs(fieldValues[field.name]) : null}
|
||||
onChange={(d) =>
|
||||
setFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.name]: d ? d.format('YYYY-MM-DD') : '',
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={fieldValues[field.name] ? Number(fieldValues[field.name]) : undefined}
|
||||
onChange={(v) =>
|
||||
setFieldValues((prev) => ({ ...prev, [field.name]: String(v ?? '') }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={fieldValues[field.name] ?? ''}
|
||||
onChange={(e) =>
|
||||
setFieldValues((prev) => ({ ...prev, [field.name]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Card>
|
||||
<Table<InboxFile>
|
||||
rowKey="id"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Tabs, Typography, Table, Button, Modal, Form, Input, Select,
|
||||
Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge,
|
||||
Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, FileTextOutlined, ThunderboltOutlined,
|
||||
@@ -24,6 +24,7 @@ import { apiKeysApi, type ApiKey } from '../api/api-keys';
|
||||
import {
|
||||
barcodeTemplatesApi,
|
||||
type BarcodeTemplate,
|
||||
type LabelInputField,
|
||||
} from '../api/barcode-templates';
|
||||
import {
|
||||
paperlessApi, type PaperlessTag, type PaperlessDocType,
|
||||
@@ -1779,6 +1780,64 @@ function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) {
|
||||
// Eingangsdokumentarten Tab (ehemals Barcode-Vorlagen)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function LabelElementRow({ listName, remove }: { listName: number; remove: (n: number) => void }) {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
extra={<Button size="small" danger icon={<DeleteOutlined />} onClick={() => remove(listName)} />}
|
||||
>
|
||||
<Form.Item name={[listName, 'type']} label="Typ" style={{ marginBottom: 8 }}>
|
||||
<Select style={{ width: 120 }} options={[
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'qr', label: 'QR-Code' },
|
||||
{ value: 'line', label: 'Linie' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue(['LabelLayout', listName, 'type']);
|
||||
if (type === 'text') return (
|
||||
<Row gutter={8}>
|
||||
<Col span={4}><Form.Item name={[listName, 'x']} label="X (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'y']} label="Y (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'fontSize']} label="Schrift (mm)"><InputNumber min={0.5} step={0.5} style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'maxWidth']} label="Max. B. (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'bold']} label="Fett" valuePropName="checked"><Checkbox /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'align']} label="Ausrichtung">
|
||||
<Select allowClear style={{ width: '100%' }} options={[
|
||||
{ value: 'left', label: 'Links' },
|
||||
{ value: 'center', label: 'Mitte' },
|
||||
{ value: 'right', label: 'Rechts' },
|
||||
]} />
|
||||
</Form.Item></Col>
|
||||
<Col span={24}><Form.Item name={[listName, 'content']} label="Inhalt"><Input placeholder="{nummer} oder {datum}" /></Form.Item></Col>
|
||||
</Row>
|
||||
);
|
||||
if (type === 'qr') return (
|
||||
<Row gutter={8}>
|
||||
<Col span={4}><Form.Item name={[listName, 'x']} label="X (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'y']} label="Y (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'sizeMm']} label="Größe (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name={[listName, 'content']} label="Inhalt"><Input placeholder="{number}" /></Form.Item></Col>
|
||||
</Row>
|
||||
);
|
||||
if (type === 'line') return (
|
||||
<Row gutter={8}>
|
||||
<Col span={4}><Form.Item name={[listName, 'x1']} label="X1 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'y1']} label="Y1 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'x2']} label="X2 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'y2']} label="Y2 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
|
||||
<Col span={4}><Form.Item name={[listName, 'lineWidth']} label="Stärke (mm)"><InputNumber step={0.1} style={{ width: '100%' }} /></Form.Item></Col>
|
||||
</Row>
|
||||
);
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BarcodeTemplatesTab() {
|
||||
const [data, setData] = useState<BarcodeTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -1806,7 +1865,15 @@ function BarcodeTemplatesTab() {
|
||||
setEditing(null);
|
||||
setTestValue('');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ SplitBefore: false, DateinameTemplate: '' });
|
||||
form.setFieldsValue({
|
||||
SplitBefore: false,
|
||||
DateinameTemplate: '',
|
||||
LabelEnabled: false,
|
||||
LabelWidthMm: 57,
|
||||
LabelHeightMm: 32,
|
||||
LabelInputFields: [],
|
||||
LabelLayout: [],
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -1814,7 +1881,20 @@ function BarcodeTemplatesTab() {
|
||||
setIsNew(false);
|
||||
setEditing(row);
|
||||
setTestValue('');
|
||||
form.setFieldsValue({ Name: row.Name, Regex: row.Regex, SplitBefore: row.SplitBefore, DateinameTemplate: row.DateinameTemplate ?? '' });
|
||||
form.setFieldsValue({
|
||||
Name: row.Name,
|
||||
Regex: row.Regex,
|
||||
SplitBefore: row.SplitBefore,
|
||||
DateinameTemplate: row.DateinameTemplate ?? '',
|
||||
LabelEnabled: row.LabelEnabled ?? false,
|
||||
LabelWidthMm: row.LabelWidthMm ?? 57,
|
||||
LabelHeightMm: row.LabelHeightMm ?? 32,
|
||||
LabelInputFields: row.LabelInputFields ?? [],
|
||||
LabelGetUrl: row.LabelGetUrl ?? '',
|
||||
LabelPrintedUrl: row.LabelPrintedUrl ?? '',
|
||||
LabelReleaseUrl: row.LabelReleaseUrl ?? '',
|
||||
LabelLayout: row.LabelLayout ?? [],
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -1960,6 +2040,121 @@ function BarcodeTemplatesTab() {
|
||||
>
|
||||
<Input placeholder="{barcode}_{datum}" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>Etikett</Divider>
|
||||
|
||||
<Form.Item name="LabelEnabled" valuePropName="checked" label="Etikett-Druck aktivieren">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.LabelEnabled !== curr.LabelEnabled}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('LabelEnabled') ? (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="LabelWidthMm" label="Breite (mm)">
|
||||
<InputNumber min={10} max={300} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="LabelHeightMm" label="Höhe (mm)">
|
||||
<InputNumber min={10} max={300} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="Eingabefelder">
|
||||
<Form.List name="LabelInputFields">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...rest }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 6 }} align="baseline">
|
||||
<Form.Item {...rest} name={[name, 'name']} rules={[{ required: true, message: 'Feldname' }]} noStyle>
|
||||
<Input placeholder="Feldname (z. B. datum)" style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...rest} name={[name, 'label']} noStyle>
|
||||
<Input placeholder="Bezeichnung" style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...rest} name={[name, 'type']} noStyle>
|
||||
<Select style={{ width: 110 }} options={[
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'number', label: 'Nummer' },
|
||||
{ value: 'date', label: 'Datum' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ type: 'text' })} icon={<PlusOutlined />} size="small">
|
||||
Feld hinzufügen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue: gfv }) => {
|
||||
const inputFields: LabelInputField[] = gfv('LabelInputFields') ?? [];
|
||||
const chips: string[] = [];
|
||||
for (const f of inputFields) {
|
||||
if (!f?.name) continue;
|
||||
chips.push(`{${f.name}}`);
|
||||
if (f.type === 'date') {
|
||||
chips.push(`{${f.name}.year}`, `{${f.name}.month}`, `{${f.name}.day}`);
|
||||
}
|
||||
}
|
||||
const chipsWithNumber = [...chips, '{number}'];
|
||||
return (
|
||||
<>
|
||||
<Form.Item name="LabelGetUrl" label="GET-URL (liefert Nummer)"
|
||||
extra={chips.length > 0 ? `Platzhalter: ${chips.join(' ')}` : undefined}>
|
||||
<Input placeholder="https://example.com/nummer?feld={feldname}" />
|
||||
</Form.Item>
|
||||
<Form.Item name="LabelPrintedUrl" label="PRINTED-URL (nach erfolgreichem Druck)"
|
||||
extra={chipsWithNumber.length > 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}>
|
||||
<Input placeholder="https://example.com/gedruckt?nr={number}" />
|
||||
</Form.Item>
|
||||
<Form.Item name="LabelReleaseUrl" label="RELEASE-URL (bei Druckfehler)"
|
||||
extra={chipsWithNumber.length > 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}>
|
||||
<Input placeholder="https://example.com/freigeben?nr={number}" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Layout-Elemente"
|
||||
extra={chipsWithNumber.length > 0 ? `Platzhalter für Inhalt: ${chipsWithNumber.join(' ')}` : undefined}>
|
||||
<Form.List name="LabelLayout">
|
||||
{(layoutFields, { add: addEl, remove: removeEl }) => (
|
||||
<>
|
||||
{layoutFields.map(({ key, name: elName }) => (
|
||||
<LabelElementRow key={key} listName={elName} remove={removeEl} />
|
||||
))}
|
||||
<Space>
|
||||
<Button size="small" type="dashed" icon={<PlusOutlined />}
|
||||
onClick={() => addEl({ type: 'text', x: 0, y: 0, fontSize: 3, content: '', bold: false })}>
|
||||
Text
|
||||
</Button>
|
||||
<Button size="small" type="dashed" icon={<PlusOutlined />}
|
||||
onClick={() => addEl({ type: 'qr', x: 0, y: 0, sizeMm: 20, content: '' })}>
|
||||
QR-Code
|
||||
</Button>
|
||||
<Button size="small" type="dashed" icon={<PlusOutlined />}
|
||||
onClick={() => addEl({ type: 'line', x1: 0, y1: 0, x2: 50, y2: 0 })}>
|
||||
Linie
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{editing && !isNew && (
|
||||
|
||||
Reference in New Issue
Block a user