chore: apply ESLint auto-fix across entire backend
Build and Push Multi-Platform Images / build-and-push (push) Successful in 41s

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>
This commit is contained in:
2026-06-08 09:02:02 +02:00
parent 4c75a1ded2
commit dad0136365
74 changed files with 4022 additions and 1052 deletions
+988
View File
@@ -0,0 +1,988 @@
# 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
},
};
```