Files
paperlessmanager/docs/LABEL_PRINT_AGENT_ARCHITEKTUR.md
bjoernpoettker dad0136365
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s
chore: apply ESLint auto-fix across entire backend
Reformats code style (line breaks, indentation, type annotations)
without changing logic. Also includes minor feature additions bundled
in the same lint run (stats service, user-settings groups, agrarmonitor
polling improvements).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:02:02 +02:00

989 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Label-Print-Agent Architektur & Implementierungsreferenz
Dieses Dokument beschreibt vollständig das Label-Print-Agent-System aus dem Paperless Manager. Es dient als Grundlage für die Reimplementierung in einem anderen Projekt.
---
## 1. Systemübersicht
Das System ermöglicht es, **Etiketten-Templates** zu definieren (mit Layout, Variablen-Feldern und Abmessungen) und daraus **Druckjobs** zu erzeugen, die von einem oder mehreren **Print Agents** abgeholt und gedruckt werden.
### Beteiligte Komponenten
| Komponente | Aufgabe |
|---|---|
| **Frontend** | Template-Verwaltung, Job-Erstellung, Label-Vorschau |
| **Backend API** | REST-Endpunkte, Job-Queue, Rendering |
| **Datenbank** | Persistenz für Templates und Jobs |
| **LabelRenderer** | SVG → PNG (300 DPI) |
| **Print Agent** | Eigenständiger Client, der Jobs abholt und druckt |
### Datenfluss (ASCII)
```
┌────────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ • Template anlegen (Name, Layout, Felder, Maße) │
│ • Vorschau rendern (POST /preview → PNG) │
│ • Druckjob erstellen (POST /jobs → { jobId }) │
└──────────────────────────────┬─────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ BACKEND: createJob() │
│ 1. Template laden │
│ 2. Variablen aufbauen (Felder + Datumssubfelder + {number}) │
│ 3. LabelGetUrl aufrufen → {number} reservieren (optional) │
│ 4. Job speichern (Status=pending, imageData=null) │
│ 5. newJob$.next() → SSE-Notification an alle Agents │
└──────────────────────────────┬─────────────────────────────────────┘
┌───────────────┴──────────────────┐
│ SSE-Push │ Polling (Fallback)
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ PRINT AGENT │
│ GET /jobs/next?agentId=MEIN-PC │
│ → Lock setzen (lockedAt = now) │
│ → Lazy Render: Layout → SVG → PNG → in DB speichern │
│ → { jobId, labelImageBase64, widthMm, heightMm } │
│ │
│ Drucken → POST /jobs/:id/printed (Erfolg) │
│ → POST /jobs/:id/error (Fehler) │
└──────────────────────────────────────────────────────────────────┘
```
---
## 2. Datenmodelle
### 2.1 LabelTemplate (in PM: Teil des `BarcodeTemplate`)
Für das neue Projekt empfiehlt sich eine eigenständige `LabelTemplate`-Entity:
```typescript
export interface LabelInputField {
name: string; // Technischer Feldname (Variablenname)
label: string; // Anzeigetext im Formular
type: 'text' | 'number' | 'date';
}
export type LabelElement =
| {
type: 'text';
content: string; // Inhalt, kann {variablenname} enthalten
x: number; // X-Position in mm ab linker Kante
y: number; // Y-Position in mm ab oberer Kante
fontSize: number; // Schriftgröße in mm
bold?: boolean;
align?: 'left' | 'center' | 'right';
maxWidth?: number; // Maximale Breite in mm (Text wird gestreckt/gestaucht)
}
| {
type: 'qr';
content: string; // QR-Inhalt, kann {variablenname} enthalten
x: number; // X-Position in mm
y: number; // Y-Position in mm
sizeMm: number; // Quadratische Größe in mm
}
| {
type: 'line';
x1: number; y1: number; // Startpunkt in mm
x2: number; y2: number; // Endpunkt in mm
lineWidth?: number; // Linienbreite in mm (default: ~0,085mm = 1px bei 300DPI)
};
// TypeORM Entity (vereinfacht)
@Entity('label_templates')
export class LabelTemplate {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
name: string;
@Column({ type: 'int', default: 57 })
widthMm: number; // Etikettenbreite in mm
@Column({ type: 'int', default: 32 })
heightMm: number; // Etikettenhöhe in mm
@Column({ type: 'simple-json', nullable: true })
inputFields: LabelInputField[] | null; // Formularfelder
@Column({ type: 'simple-json', nullable: true })
layout: LabelElement[] | null; // Etikett-Elemente
@Column({ length: 1000, nullable: true })
getUrl: string | null; // URL zur Nummernreservierung (GET, {var} möglich)
@Column({ length: 1000, nullable: true })
printedUrl: string | null; // Callback nach erfolgreichem Druck (POST)
@Column({ length: 1000, nullable: true })
releaseUrl: string | null; // Callback bei Druckfehler (POST)
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
```
### 2.2 LabelPrintJob
Die DB-Tabelle fungiert als **persistente Job-Queue**.
```typescript
// Originaldatei: paperless-backend/src/database/entities/label-print-job.entity.ts
@Entity('label_print_jobs')
export class LabelPrintJob {
@PrimaryGeneratedColumn()
Id: number;
@Index()
@Column({ type: 'varchar', length: 20, default: 'pending' })
Status: 'pending' | 'printed' | 'error';
@Column({ type: 'mediumblob', nullable: true })
LabelImageData: Buffer | null; // Lazy-rendered PNG (kann null sein bis Claim)
@Column({ type: 'int' })
LabelWidthMm: number;
@Column({ type: 'int' })
LabelHeightMm: number;
@Column({ type: 'int', nullable: true })
BarcodeTemplateId: number | null; // Referenz auf Template (ohne FK-Constraint)
@Column({ type: 'simple-json', nullable: true })
LabelVariables: Record<string, string> | null; // Aufgelöste Variablen zum Druckzeitpunkt
@Index()
@Column({ type: 'datetime', nullable: true })
LockedAt: Date | null; // Zeitstempel des Locks
@Column({ length: 255, nullable: true })
LockedByAgent: string | null; // Agenten-ID (z.B. "BUERO-PC")
@Column({ type: 'datetime', nullable: true })
PrintedAt: Date | null;
@Column({ length: 255, nullable: true })
PrintedByAgent: string | null;
@Column({ length: 255, nullable: true })
PrinterName: string | null;
@Column({ type: 'text', nullable: true })
ErrorMessage: string | null;
@CreateDateColumn()
CreatedAt: Date;
}
```
**Wichtig:** `LabelVariables` speichert die bereits aufgelösten Variablen zum Zeitpunkt der Job-Erstellung. Das Template-Layout kann sich danach ändern — der Job verwendet trotzdem die ursprünglichen Werte.
---
## 3. API-Endpunkte
Alle Endpunkte unter `/api/label-print-agent`:
### Frontend-Endpunkte (erfordern Authentifizierung)
#### `POST /preview` — Label-Vorschau rendern
Rendert ein PNG ohne DB-Eintrag. Zum Testen von Layouts.
```
Request: { templateId: number, fieldValues?: Record<string, string> }
Response: image/png (Buffer)
```
#### `POST /jobs` — Druckjob erstellen
```
Request: { templateId: number, fieldValues?: Record<string, string> }
Response: { jobId: string }
Status: 201 Created
```
### Agent-Endpunkte
#### `GET /events` — SSE-Stream (Server-Sent Events)
Agent hält diese Verbindung dauerhaft offen. Bei neuem Job erhält er eine Notification.
```
Response: text/event-stream
Payload: { type: 'label-job-available' }
Header: X-Accel-Buffering: no (wichtig für nginx-Reverse-Proxy)
```
#### `GET /jobs/next?agentId=MEIN-PC` — Nächsten Job beanspruchen
```
Response 200: {
jobId: string,
labelImageBase64: string | null, // PNG als Base64
labelImageContentType: 'image/png',
labelWidthMm: number,
labelHeightMm: number
}
Response 204: (kein Job verfügbar)
```
#### `GET /jobs/:id/image` — Job-Bild separat abrufen
```
Response: image/png (Buffer)
```
#### `POST /jobs/:id/printed` — Job als gedruckt markieren
```
Request: { agentId?: string, printerName?: string }
Response: { ok: true }
```
#### `POST /jobs/:id/error` — Druckfehler melden
```
Request: { agentId?: string, printerName?: string, errorMessage?: string }
Response: { ok: true }
```
---
## 4. SVG-Rendering-Pipeline
**Originaldatei:** `paperless-backend/src/label-print-agent/label-renderer.service.ts`
### Abhängigkeiten
```bash
npm install qrcode @resvg/resvg-js
npm install --save-dev @types/qrcode
```
### Vollständige Implementierung
```typescript
import * as QRCode from 'qrcode';
import { Resvg } from '@resvg/resvg-js';
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;');
}
// Variablen-Substitution: {varName} oder {varName:6} (zero-padded auf 6 Stellen)
function applyVars(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
const colonIdx = key.indexOf(':');
if (colonIdx !== -1) {
const varName = key.slice(0, colonIdx);
const width = parseInt(key.slice(colonIdx + 1), 10);
if (!isNaN(width)) {
return (vars[varName] ?? '').padStart(width, '0');
}
}
return vars[key] ?? `{${key}}`; // Unbekannte Variablen: Platzhalter stehen lassen
});
}
async function renderLabel(
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 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"`
: '';
// Wichtig: librsvg ignoriert dominant-baseline.
// Y-Koordinate ist die Oberkante des Texts → fontSize addieren für Baseline.
const yBaseline = mm(el.y) + fontSize;
parts.push(
`<text x="${x}" y="${yBaseline}" ` +
`font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" ` +
`font-weight="${fontWeight}" text-anchor="${textAnchor}"${maxWidthAttr}>${content}</text>`,
);
} else if (el.type === 'qr') {
const size = mm(el.sizeMm);
const content = applyVars(el.content, variables);
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="${mm(el.x)}" y="${mm(el.y)}" width="${size}" height="${size}"/>`,
);
} else if (el.type === 'line') {
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
parts.push(
`<line x1="${mm(el.x1)}" y1="${mm(el.y1)}" ` +
`x2="${mm(el.x2)}" y2="${mm(el.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>`,
].join('\n');
const resvg = new Resvg(svg, {
font: {
loadSystemFonts: true,
fontDirs: ['/usr/share/fonts', '/usr/local/share/fonts'],
defaultFontFamily: 'Liberation Sans',
sansSerifFamily: 'Liberation Sans',
},
});
return Buffer.from(resvg.render().asPng());
}
```
### Bekannte Eigenheiten
- **Baseline-Problem:** `librsvg` (verwendet von `@resvg/resvg-js`) ignoriert `dominant-baseline`. Daher muss zur Y-Koordinate die Schriftgröße addiert werden, damit die **Oberkante** des Textes der eingegebenen Y-Position entspricht (s. `yBaseline`-Berechnung).
- **Schriften:** Auf Systemen ohne `Liberation Sans` (z.B. Windows) fällt die Bibliothek auf Arial zurück. Für plattformübergreifende Konsistenz können Schriften auch per Buffer eingebettet werden.
- **QR-Code:** `errorCorrectionLevel: 'M'` ist ein guter Kompromiss zwischen Fehlertoleranz und Dichte.
---
## 5. Job Queue & SSE-Mechanismus
**Originaldateien:**
- `paperless-backend/src/label-print-agent/label-print-agent.service.ts`
- `paperless-backend/src/label-print-agent/label-print-agent.controller.ts`
### Job erstellen
```typescript
// Originaldatei: label-print-agent.service.ts
function applyVars(template: string, vars: Record<string, string>): string {
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
const colonIdx = key.indexOf(':');
if (colonIdx !== -1) {
const varName = key.slice(0, colonIdx);
const width = parseInt(key.slice(colonIdx + 1), 10);
if (!isNaN(width) && varName in vars) {
return vars[varName].padStart(width, '0');
}
}
return vars[key] ?? '';
});
}
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');
const vars: Record<string, string> = { ...fieldValues };
// Datum-Felder aufsplitten: {datum} → dd.MM.yyyy, {datum.year}, {datum.month}, {datum.day}
for (const field of template.LabelInputFields ?? []) {
if (field.type === 'date' && fieldValues[field.name]) {
const parts = fieldValues[field.name].split('-'); // YYYY-MM-DD
if (parts.length === 3) {
vars[field.name] = `${parts[2]}.${parts[1]}.${parts[0]}`;
vars[`${field.name}.year`] = parts[0];
vars[`${field.name}.month`] = parts[1];
vars[`${field.name}.day`] = parts[2];
}
}
}
// Externe Nummern-Reservierung (optional)
if (template.LabelGetUrl) {
const url = applyVars(template.LabelGetUrl, vars);
const res = await fetch(url);
vars['number'] = (await res.text()).trim();
}
const job = this.jobRepo.create({
Status: 'pending',
LabelImageData: null, // Lazy rendering: erst beim Claim rendern
LabelWidthMm: template.LabelWidthMm ?? 57,
LabelHeightMm: template.LabelHeightMm ?? 32,
BarcodeTemplateId: template.Id,
LabelVariables: vars,
LockedAt: null,
LockedByAgent: null,
});
const saved = await this.jobRepo.save(job);
this.jobCreated$.next(); // SSE-Notification an alle verbundenen Agents
return saved;
}
```
### Job beanspruchen (Optimistic Locking)
```typescript
// Lock-Ablaufzeit: 5 Minuten in der Vergangenheit
function lockExpiry(): Date {
const d = new Date();
d.setMinutes(d.getMinutes() - 5);
return d;
}
async claimNextJob(agentId: string): Promise<LabelPrintJob | null> {
// Finde den ältesten pending Job ohne Lock oder mit abgelaufenem Lock
const candidate = await this.jobRepo.findOne({
where: [
{ Status: 'pending', LockedAt: IsNull() },
{ Status: 'pending', LockedAt: LessThan(lockExpiry()) },
],
order: { CreatedAt: 'ASC' }, // FIFO
});
if (!candidate) return null;
// Lock setzen (optimistisch, kein DB-Row-Lock)
candidate.LockedAt = new Date();
candidate.LockedByAgent = agentId;
await this.jobRepo.save(candidate);
// Lazy Render: PNG erst jetzt erzeugen
if (!candidate.LabelImageData) {
const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId } });
if (template?.LabelLayout?.length) {
candidate.LabelImageData = await this.renderer.render(
template.LabelLayout,
candidate.LabelWidthMm,
candidate.LabelHeightMm,
candidate.LabelVariables ?? {},
);
await this.jobRepo.save(candidate);
}
}
return candidate;
}
```
### SSE-Stream (NestJS)
```typescript
// Originaldatei: label-print-agent.controller.ts
private readonly jobCreated$ = new Subject<void>(); // im Service
// Controller:
@Sse('events')
sseEvents(@Res({ passthrough: true }) res: Response): Observable<MessageEvent> {
res.setHeader('X-Accel-Buffering', 'no'); // nginx: kein Buffering
return this.service.newJob$.pipe(
map(() => ({ data: { type: 'label-job-available' } } as MessageEvent)),
);
}
```
### SSE-Client (Agent-Seite, Plain JavaScript)
```javascript
const evtSource = new EventSource('http://backend/api/label-print-agent/events', {
headers: { Authorization: `Bearer ${token}` },
});
evtSource.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data.type === 'label-job-available') {
await pollAndPrint();
}
};
// Fallback: alle 30 Sekunden pollen
setInterval(pollAndPrint, 30_000);
async function pollAndPrint() {
const res = await fetch(`http://backend/api/label-print-agent/jobs/next?agentId=MEIN-PC`);
if (res.status === 204) return; // kein Job
const job = await res.json();
const imageBytes = Buffer.from(job.labelImageBase64, 'base64');
try {
await printToPrinter(imageBytes, job.labelWidthMm, job.labelHeightMm);
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/printed`, {
method: 'POST',
body: JSON.stringify({ agentId: 'MEIN-PC', printerName: 'DYMO 450' }),
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
await fetch(`http://backend/api/label-print-agent/jobs/${job.jobId}/error`, {
method: 'POST',
body: JSON.stringify({ agentId: 'MEIN-PC', errorMessage: String(err) }),
headers: { 'Content-Type': 'application/json' },
});
}
}
```
---
## 6. Variablen-System
### Template-Syntax
| Syntax | Bedeutung | Beispiel |
|---|---|---|
| `{feldname}` | Wert des Feldes | `{name}``Müller GmbH` |
| `{feldname:6}` | Zero-padded auf N Stellen | `{nr:6}``000042` |
| `{datum}` (date-Feld) | Formatiert als `dd.MM.yyyy` | `{datum}``15.05.2026` |
| `{datum.year}` | Nur das Jahr | `{datum.year}``2026` |
| `{datum.month}` | Nur den Monat | `{datum.month}``05` |
| `{datum.day}` | Nur den Tag | `{datum.day}``15` |
| `{number}` | Reservierte Nummer (via GetUrl) | `{number}``1234` |
### URL-Templates
Die Felder `LabelGetUrl`, `LabelPrintedUrl`, `LabelReleaseUrl` unterstützen ebenfalls Variablen-Substitution:
```
LabelGetUrl: https://api.example.com/numbers/next?batch={batch}
LabelPrintedUrl: https://api.example.com/numbers/{number}/confirm
LabelReleaseUrl: https://api.example.com/numbers/{number}/release
```
**Sicherheitscheck:** Nur `http://` und `https://` werden akzeptiert.
---
## 7. Vollständiger Job-Lebenszyklus
```
Status: pending → (Lock gesetzt) → printed / error
┌─ createJob() ──────────────────────────────────────────────────┐
│ Template laden → Vars aufbauen → GetUrl aufrufen (optional) │
│ Job speichern: Status=pending, LockedAt=null, imageData=null │
│ newJob$.next() → SSE-Push │
└─────────────────────────────────────────────────────────────────┘
┌─ claimNextJob() ───────────────────────────────────────────────┐
│ Kandidat suchen: pending AND (LockedAt IS NULL OR < -5min) │
│ Lock setzen: LockedAt=now, LockedByAgent=agentId │
│ Lazy Render: Layout + Vars → SVG → PNG → LabelImageData │
│ Rückgabe: Job + PNG (base64) │
└─────────────────────────────────────────────────────────────────┘
┌─ markPrinted() ────────────────────────────────────────────────┐
│ Status = printed, PrintedAt = now, PrintedByAgent, Printer │
│ POST LabelPrintedUrl (optional, Variablen aufgelöst) │
└─────────────────────────────────────────────────────────────────┘
┌─ markError() ──────────────────────────────────────────────────┐
│ Status = error, ErrorMessage │
│ POST LabelReleaseUrl (optional) │
└─────────────────────────────────────────────────────────────────┘
```
**Lock-TTL:** 5 Minuten. Stürzt ein Agent ab, kann ein anderer Agent (oder derselbe nach Neustart) den Job nach 5 Minuten erneut beanspruchen.
---
## 8. NestJS-Modul-Setup
```typescript
// label-print-agent.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([LabelPrintJob, LabelTemplate]),
],
controllers: [LabelPrintAgentController],
providers: [LabelPrintAgentService, LabelRendererService],
})
export class LabelPrintAgentModule {}
```
---
## 9. Empfehlungen für das neue Projekt
### Architektur übernehmen
Das Muster **DB als Queue + SSE als Push + Polling als Fallback** ist einfach und robust. Es benötigt keine externe Message-Queue (Redis, RabbitMQ etc.) und funktioniert gut für geringe bis mittlere Job-Volumina.
### Locking-Strategie
| Szenario | Empfehlung |
|---|---|
| Ein Agent | Optimistic Lock (5-Min-TTL) reicht aus |
| Mehrere Agents, wenige Jobs | Optimistic Lock reicht aus |
| Viele Agents, hoher Durchsatz | DB-Row-Lock (`SELECT ... FOR UPDATE`) oder Redis-Lock |
### Rendering-Alternativen
| Umgebung | Alternative |
|---|---|
| Node.js | `@resvg/resvg-js` + `qrcode` (wie hier) |
| Node.js (Docker) | Puppeteer (HTML → PNG/PDF, braucht Chrome) |
| Python | `reportlab` oder `cairosvg` |
| Kein Node.js | Server-seitiges Rendering via separatem Microservice |
### SSE-Alternativen
| Alternative | Wann |
|---|---|
| SSE (wie hier) | Einfachste Lösung, funktioniert durch nginx |
| WebSocket | Wenn bidirektionale Kommunikation nötig ist |
| Polling-only | Wenn der Agent kein persistentes HTTP unterstützt |
### Template-Verwaltung im neuen Projekt
Im Paperless Manager sind Label-Templates Teil der `BarcodeTemplate`-Entity (historisch gewachsen). Für ein neues Projekt empfiehlt sich eine **eigenständige `LabelTemplate`-Entity** ohne nicht-Label-relevante Felder — das vereinfacht CRUD und API erheblich.
---
## 10. Quell-Dateien (Paperless Manager)
| Datei | Inhalt |
|---|---|
| `paperless-backend/src/label-print-agent/label-print-agent.service.ts` | Job-Lifecycle, Locking, Callbacks |
| `paperless-backend/src/label-print-agent/label-print-agent.controller.ts` | REST-Endpunkte, SSE |
| `paperless-backend/src/label-print-agent/label-renderer.service.ts` | SVG→PNG Rendering |
| `paperless-backend/src/database/entities/label-print-job.entity.ts` | Job-Entity |
| `paperless-backend/src/database/entities/barcode-template.entity.ts` | Template-Entity (inkl. `LabelElement`, `LabelInputField` Typen) |
| `paperless-frontend/src/api/labelPrintAgent.ts` | Frontend-API-Client |
---
## 11. Frontend-UI: Template-Editor (SettingsPage)
**Originaldatei:** `paperless-frontend/src/pages/SettingsPage.tsx`
Der Template-Editor ist ein **Modal-Formular** (720px breit) mit Ant Design. Er enthält zwei eigenständige React-Komponenten.
### Komponenten-Übersicht
| Komponente | Zweck |
|---|---|
| `BarcodeTemplatesTab` | Tabelle mit allen Templates + CRUD-Logik |
| `LabelElementRow` | Eine Zeile im Layout-Editor (typ-abhängige Felder) |
---
### BarcodeTemplatesTab
Verwaltet den gesamten Template-Lebenszyklus. State:
```typescript
const [data, setData] = useState<BarcodeTemplate[]>([]);
const [editing, setEditing] = useState<BarcodeTemplate | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [testPrinting, setTestPrinting] = useState(false);
const [form] = Form.useForm();
```
**Formular-Initialwerte** (beim Neu-Anlegen):
```typescript
form.setFieldsValue({
SplitBefore: false,
DateinameTemplate: '',
LabelEnabled: false,
LabelWidthMm: 57, // Standard: DYMO 57mm
LabelHeightMm: 32,
LabelInputFields: [],
LabelLayout: [],
});
```
**Testetikett-Funktion** (`handleTestLabel`):
```typescript
const handleTestLabel = async () => {
const values = await form.validateFields();
await barcodeTemplatesApi.update(editing.Id, values); // erst speichern
// Auto-Testwerte für alle Felder generieren
const testFieldValues: Record<string, string> = {};
const today = new Date().toISOString().slice(0, 10);
for (const f of values.LabelInputFields ?? []) {
if (f.type === 'date') testFieldValues[f.name] = today;
else if (f.type === 'number') testFieldValues[f.name] = '1';
else testFieldValues[f.name] = 'Test';
}
const url = await labelPrintAgentApi.previewLabel(editing.Id, testFieldValues);
setPreviewUrl(url); // öffnet zweites Modal mit PNG
};
```
**Modal-Footer-Buttons:**
```
[Testetikett erstellen] [Abbrechen] [Speichern]
Nur sichtbar wenn bestehende Vorlage (editing && !isNew)
```
---
### Formular-Aufbau (innerhalb des Modals)
```
┌─ Name ────────────────────────────────────────────────────────┐
│ Input: "z. B. Lager-Etikett" (required) │
└───────────────────────────────────────────────────────────────┘
┌─ Regex ───────────────────────────────────────────────────────┐
│ Input mit Regex-Validator │
│ Extra: "JavaScript-Syntax, z. B. ^\d{4}-\d{6}-\d{8}$" │
└───────────────────────────────────────────────────────────────┘
┌─ Regex testen ────────────────────────────────────────────────┐
│ Input (live) → Tag: [Treffer] / [Kein Treffer] / [Ungültig] │
└───────────────────────────────────────────────────────────────┘
┌─ Checkbox: "Vor diesem Barcode ein neues Dokument starten" ───┐
└───────────────────────────────────────────────────────────────┘
┌─ Belegname ───────────────────────────────────────────────────┐
│ Input: Platzhalter {barcode}, {datum}, {barcode.gruppe} │
└───────────────────────────────────────────────────────────────┘
────────────── Etikett ──────────────
┌─ Switch: "Etikett-Druck aktivieren" ─────────────────────────┐
└───────────────────────────────────────────────────────────────┘
[Nur sichtbar wenn Switch aktiv]
┌─ Breite (mm) │ Höhe (mm) ─────────────────────────────────┐
│ InputNumber │ InputNumber (Standard: 57 / 32) │
└──────────────────────────────────────────────────────────────┘
┌─ Eingabefelder ─────────────────────────────────────────────┐
│ Form.List: je Zeile: │
│ [Feldname Input] [Bezeichnung Input] [Typ-Select] [] │
│ Typen: text | number | date │
│ [+ Feld hinzufügen] │
└──────────────────────────────────────────────────────────────┘
┌─ GET-URL ───────────────────────────────────────────────────┐
│ Extra zeigt verfügbare Platzhalter live aus Eingabefeldern │
└──────────────────────────────────────────────────────────────┘
┌─ PRINTED-URL ───────────────────────────────────────────────┐
│ Extra: Platzhalter + {number} │
└──────────────────────────────────────────────────────────────┘
┌─ RELEASE-URL ───────────────────────────────────────────────┐
│ Extra: Platzhalter + {number} │
└──────────────────────────────────────────────────────────────┘
┌─ Layout-Elemente ───────────────────────────────────────────┐
│ Extra: verfügbare Platzhalter │
│ Form.List → je Element: LabelElementRow-Komponente │
│ [+ Text] [+ QR-Code] [+ Linie] │
└──────────────────────────────────────────────────────────────┘
```
**Platzhalter-Chips werden live berechnet:**
```typescript
// Aus den aktuellen LabelInputFields dynamisch erzeugt
const chips: string[] = [];
for (const f of inputFields) {
chips.push(`{${f.name}}`);
if (f.type === 'date') {
chips.push(`{${f.name}.year}`, `{${f.name}.month}`, `{${f.name}.day}`);
}
}
const chipsWithNumber = [...chips, '{number}'];
// → wird als `extra`-Text bei URL-Feldern und Layout-Elementen angezeigt
```
---
### LabelElementRow (je Layout-Element eine Card)
Typ wird per `Form.useWatch` reaktiv ausgelesen → zeigt typ-spezifische Felder:
```typescript
const type = Form.useWatch(['LabelLayout', listName, 'type'], form);
```
**Typ: Text**
```
[X mm] [Y mm] [Schrift (mm)] [Max. Breite (mm)] [Fett ☐] [Ausrichtung ▼]
[Inhalt: Input mit Platzhalter-Hinweis "{nummer} oder {datum}"]
```
Default beim Hinzufügen: `{ type: 'text', x: 0, y: 0, fontSize: 3, content: '', bold: false }`
**Typ: QR-Code**
```
[X mm] [Y mm] [Größe (mm)] [Inhalt: Input mit "{number}"]
```
Default: `{ type: 'qr', x: 0, y: 0, sizeMm: 20, content: '' }`
**Typ: Linie**
```
[X1 mm] [Y1 mm] [X2 mm] [Y2 mm] [Stärke (mm, step: 0.1)]
```
Default: `{ type: 'line', x1: 0, y1: 0, x2: 50, y2: 0 }`
Jede Card hat rechts oben einen Löschen-Button (`DeleteOutlined`).
---
### Vorschau-Modal
Nach „Testetikett erstellen" öffnet sich ein zweites Modal (520px) mit dem gerenderten PNG:
```tsx
<Modal title="Etikett-Vorschau" width={520}>
<div style={{ background: '#e0e0e0', padding: 24, display: 'flex', justifyContent: 'center' }}>
<img src={previewUrl} style={{ maxWidth: '100%', boxShadow: '0 2px 8px rgba(0,0,0,0.3)' }} />
</div>
</Modal>
```
Das `previewUrl` ist ein `URL.createObjectURL(blob)` — muss mit `URL.revokeObjectURL()` beim Schließen freigegeben werden.
---
## 12. Frontend-UI: Druck-Dialog (InboxPage)
**Originaldatei:** `paperless-frontend/src/pages/InboxPage.tsx`
### Einstiegspunkt
Button in der Toolbar der InboxPage:
```tsx
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
Etikett drucken
</Button>
```
### openPrintDialog
Lädt beim Öffnen parallel Templates und User-Settings (für Standard-Template):
```typescript
const openPrintDialog = async () => {
const [all, settings] = await Promise.all([
barcodeTemplatesApi.list(),
userSettingsApi.get(),
]);
const enabled = all.filter((t) => t.LabelEnabled); // nur aktive Templates
setLabelTemplates(enabled);
// Standard-Template aus User-Settings vorauswählen
const defaultId = settings.defaultLabelTemplateId;
const defaultTemplate = defaultId != null
? (enabled.find((t) => t.Id === defaultId) ?? null)
: null;
setSelectedTemplate(defaultTemplate);
setFieldValues(buildInitialFieldValues(defaultTemplate));
setLabelCount(1);
setPrintDialogOpen(true);
};
```
### Datum-Vorausfüllung
```typescript
function buildInitialFieldValues(template: BarcodeTemplate | null): Record<string, string> {
const today = dayjs().format('YYYY-MM-DD');
const values: Record<string, string> = {};
for (const field of template?.LabelInputFields ?? []) {
if (field.type === 'date') values[field.name] = today;
// text- und number-Felder bleiben leer
}
return values;
}
```
### Druck-Modal-Aufbau
```
┌─ Eingangsdokumentart ─────────────────────────────────────────┐
│ Select → nur Templates mit LabelEnabled=true │
└───────────────────────────────────────────────────────────────┘
┌─ Anzahl Etiketten ────────────────────────────────────────────┐
│ InputNumber (min: 1, max: 100, default: 1) │
└───────────────────────────────────────────────────────────────┘
┌─ [dynamisch: LabelInputFields des gewählten Templates] ────────┐
│ date → DatePicker (Format: DD.MM.YYYY, Wert: YYYY-MM-DD) │
│ number → InputNumber │
│ text → Input │
└───────────────────────────────────────────────────────────────┘
Footer: [Abbrechen] [Drucken] ← disabled solange kein Template
```
### Batch-Druck
```typescript
const handlePrint = async () => {
setPrinting(true);
for (let i = 0; i < labelCount; i++) {
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
// Erzeugt N separate Jobs sequenziell (selbe fieldValues)
}
message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`);
};
```
---
## 13. Frontend API-Client
**Originaldatei:** `paperless-frontend/src/api/labelPrintAgent.ts`
```typescript
export const labelPrintAgentApi = {
// Job erstellen (von InboxPage)
createJob: (templateId: number, fieldValues: Record<string, string>) =>
api.post('/api/label-print-agent/jobs', { templateId, fieldValues }),
// Vorschau-PNG als Object-URL (von SettingsPage)
previewLabel: async (templateId: number, fieldValues: Record<string, string>): Promise<string> => {
const res = await api.post('/api/label-print-agent/preview',
{ templateId, fieldValues },
{ responseType: 'blob' },
);
return URL.createObjectURL(res.data); // muss nach Verwendung revoked werden
},
};
```