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

This commit is contained in:
2026-05-07 22:46:29 +02:00
parent 0c94e7b999
commit 80f862a0c0
15 changed files with 995 additions and 15 deletions
+210
View File
@@ -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.
+1
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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);
},
}}
+106
View File
@@ -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"
+198 -3
View File
@@ -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 && (