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

38 KiB
Raw Permalink Blame History

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:

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.

// 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

npm install qrcode @resvg/resvg-js
npm install --save-dev @types/qrcode

Vollständige Implementierung

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

// 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)

// 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)

// 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)

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

// 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:

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):

form.setFieldsValue({
  SplitBefore: false,
  DateinameTemplate: '',
  LabelEnabled: false,
  LabelWidthMm: 57,       // Standard: DYMO 57mm
  LabelHeightMm: 32,
  LabelInputFields: [],
  LabelLayout: [],
});

Testetikett-Funktion (handleTestLabel):

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:

// 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:

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:

<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:

<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
  Etikett drucken
</Button>

openPrintDialog

Lädt beim Öffnen parallel Templates und User-Settings (für Standard-Template):

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

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

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

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
  },
};