# 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 | 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 } Response: image/png (Buffer) ``` #### `POST /jobs` — Druckjob erstellen ``` Request: { templateId: number, fieldValues?: Record } 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, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Variablen-Substitution: {varName} oder {varName:6} (zero-padded auf 6 Stellen) function applyVars(template: string, vars: Record): 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, ): Promise { 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( `${content}`, ); } 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( ``, ); } else if (el.type === 'line') { const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1; parts.push( ``, ); } } const svg = [ ``, ` `, ` ${parts.join('\n ')}`, ``, ].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 { 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): Promise { const template = await this.templateRepo.findOne({ where: { Id: templateId } }); if (!template) throw new NotFoundException('Template nicht gefunden'); const vars: Record = { ...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 { // 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(); // im Service // Controller: @Sse('events') sseEvents(@Res({ passthrough: true }) res: Response): Observable { 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([]); const [editing, setEditing] = useState(null); const [previewUrl, setPreviewUrl] = useState(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 = {}; 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
``` 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 ``` ### 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 { const today = dayjs().format('YYYY-MM-DD'); const values: Record = {}; 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) => api.post('/api/label-print-agent/jobs', { templateId, fieldValues }), // Vorschau-PNG als Object-URL (von SettingsPage) previewLabel: async (templateId: number, fieldValues: Record): Promise => { const res = await api.post('/api/label-print-agent/preview', { templateId, fieldValues }, { responseType: 'blob' }, ); return URL.createObjectURL(res.data); // muss nach Verwendung revoked werden }, }; ```