diff --git a/docs/BACKEND_API.md b/docs/BACKEND_API.md new file mode 100644 index 0000000..fa48065 --- /dev/null +++ b/docs/BACKEND_API.md @@ -0,0 +1,210 @@ +# Backend API für LabelPrintAgent + +Diese Datei beschreibt die Endpunkte, die der PaperlessManager bereitstellen muss, damit der LabelPrintAgent Etiketten abholen, drucken und das Ergebnis zurückmelden kann. + +Der LabelPrintAgent rendert keine Layouts selbst. Das Backend liefert ein fertiges Etikettbild. + +## Authentifizierung + +Alle Endpunkte sollten denselben Bearer Token akzeptieren: + +```http +Authorization: Bearer {apiToken} +``` + +Der Token wird im Agent lokal verschlüsselt gespeichert. + +## 1. Nächsten Druckjob abrufen + +```http +GET /api/label-print-agent/jobs/next?agentId={agentId} +``` + +Der Agent ruft diesen Endpunkt alle X Sekunden auf. + +### Query-Parameter + +| Name | Pflicht | Beschreibung | +| --- | --- | --- | +| `agentId` | ja | Eindeutige ID des Agents, z. B. Rechnername | + +### Antwort, wenn kein Job vorhanden ist + +```http +204 No Content +``` + +### Antwort mit Bild direkt im JSON + +```http +200 OK +Content-Type: application/json +``` + +```json +{ + "jobId": "12345", + "labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...", + "labelImageContentType": "image/png", + "labelWidthMm": 57, + "labelHeightMm": 32 +} +``` + +### Antwort mit separater Bild-URL + +```json +{ + "jobId": "12345", + "labelImageUrl": "/api/label-print-agent/jobs/12345/image", + "labelImageContentType": "image/png", + "labelWidthMm": 57, + "labelHeightMm": 32 +} +``` + +### Felder + +| Feld | Pflicht | Beschreibung | +| --- | --- | --- | +| `jobId` | ja | Eindeutige Job-ID für Rückmeldungen | +| `labelImageBase64` | bedingt | Base64-kodiertes Etikettbild | +| `labelImageUrl` | bedingt | URL zum Nachladen des Etikettbilds | +| `labelImageContentType` | empfohlen | z. B. `image/png` | +| `labelWidthMm` | ja | Etikettenbreite in mm, z. B. `57` | +| `labelHeightMm` | ja | Etikettenhöhe in mm, z. B. `32` | + +`labelImageBase64` oder `labelImageUrl` muss gesetzt sein. + +## 2. Etikettbild nachladen + +Nur erforderlich, wenn `labelImageUrl` verwendet wird. + +```http +GET /api/label-print-agent/jobs/{jobId}/image +``` + +### Antwort + +```http +200 OK +Content-Type: image/png +``` + +Body: Binärdaten des fertigen Etikettbilds. + +Empfehlung: PNG, schwarz/weiß, passend zum Etikettenformat, z. B. 57 x 32 mm bei 300 dpi. + +## 3. Erfolgreichen Druck melden + +```http +POST /api/label-print-agent/jobs/{jobId}/printed +Content-Type: application/json +``` + +### Request Body + +```json +{ + "agentId": "PC-BUERO", + "printerName": "DYMO LabelWriter 450" +} +``` + +### Antwort + +```http +200 OK +``` + +oder: + +```http +204 No Content +``` + +Das Backend sollte den Job erst hier endgültig als gedruckt markieren. + +## 4. Fehler melden + +```http +POST /api/label-print-agent/jobs/{jobId}/error +Content-Type: application/json +``` + +### Request Body + +```json +{ + "agentId": "PC-BUERO", + "printerName": "DYMO LabelWriter 450", + "errorMessage": "Drucker ist nicht verfügbar." +} +``` + +### Antwort + +```http +200 OK +``` + +oder: + +```http +204 No Content +``` + +Das Backend entscheidet danach, ob der Job erneut angeboten wird oder auf Fehler bleibt. + +## Backend-Verhalten + +Empfohlener Ablauf im Backend: + +1. Job erstellen und serverseitig Layout, Nummern, QR-Code und Bild erzeugen. +2. Job bleibt wartend, bis ein Agent ihn abholt. +3. `jobs/next` liefert jeweils höchstens einen Job. +4. Backend reserviert oder lockt den Job beim Ausliefern, damit zwei Agents ihn nicht parallel drucken. +5. Agent druckt lokal. +6. Agent meldet `printed` oder `error`. +7. Backend setzt den finalen Status. + +## Empfohlene Statuscodes + +| Situation | Status | +| --- | --- | +| Kein Job vorhanden | `204 No Content` | +| Job vorhanden | `200 OK` | +| Token fehlt/ungültig | `401 Unauthorized` | +| Agent darf nicht drucken | `403 Forbidden` | +| Job-ID unbekannt | `404 Not Found` | +| Backend-Fehler | `500 Internal Server Error` | + +## Server-Sent Events optional + +Später kann das Backend zusätzlich einen Event-Endpunkt anbieten: + +```http +GET /api/label-print-agent/events?agentId={agentId} +Accept: text/event-stream +``` + +Beispiel: + +```text +event: label-job-available +data: {"count":1} +``` + +Der Agent könnte dann bei einem Event sofort `jobs/next` aufrufen. Polling bleibt trotzdem als Fallback sinnvoll. + +## Wichtige Designentscheidung + +Der Agent kennt keine fachlichen Layouts mehr: + +- keine `layout_key` +- keine lokalen LabelTemplates +- keine MySQL-Verbindung +- keine Nummernreservierung +- kein QR-Code-Rendering + +Das Backend liefert ein fertiges Bild. Der Agent ist nur noch lokaler Windows-Druck-Connector. diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index da8b64f..663cab9 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -17,6 +17,7 @@ import { StatsModule } from './stats/stats.module'; import { BarcodeModule } from './barcode/barcode.module'; import { InboxPostprocessorModule } from './inbox-postprocessor/inbox-postprocessor.module'; import { UserSettingsModule } from './user-settings/user-settings.module'; +import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module'; import * as path from 'path'; @Module({ diff --git a/paperless-backend/src/database/database.module.ts b/paperless-backend/src/database/database.module.ts index 10862d6..8c9838b 100644 --- a/paperless-backend/src/database/database.module.ts +++ b/paperless-backend/src/database/database.module.ts @@ -24,6 +24,7 @@ import { InboxPostprocessingAction, CorrespondentEmailMapping, UserSettings, + LabelPrintJob, } from './entities'; const entities = [ @@ -49,6 +50,7 @@ const entities = [ InboxPostprocessingAction, CorrespondentEmailMapping, UserSettings, + LabelPrintJob, ]; @Module({ diff --git a/paperless-backend/src/database/entities/barcode-template.entity.ts b/paperless-backend/src/database/entities/barcode-template.entity.ts index f27d4af..543ca2a 100644 --- a/paperless-backend/src/database/entities/barcode-template.entity.ts +++ b/paperless-backend/src/database/entities/barcode-template.entity.ts @@ -8,6 +8,17 @@ import { export type BarcodeActionType = 'SEND_TO_PAPERLESS' | 'SEND_BY_EMAIL'; +export interface LabelInputField { + name: string; + label: string; + type: 'text' | 'number' | 'date'; +} + +export type LabelElement = + | { type: 'text'; content: string; x: number; y: number; fontSize: number; bold?: boolean; align?: 'left' | 'center' | 'right'; maxWidth?: number } + | { type: 'qr'; content: string; x: number; y: number; sizeMm: number } + | { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number }; + @Entity('barcode_templates') export class BarcodeTemplate { @PrimaryGeneratedColumn() @@ -28,6 +39,31 @@ export class BarcodeTemplate { @Column({ type: 'json' }) Actions!: BarcodeActionType[]; + // ── Label-Konfiguration ───────────────────────────────────── + @Column({ type: 'boolean', default: false }) + LabelEnabled!: boolean; + + @Column({ type: 'int', nullable: true }) + LabelWidthMm!: number | null; + + @Column({ type: 'int', nullable: true }) + LabelHeightMm!: number | null; + + @Column({ type: 'json', nullable: true }) + LabelInputFields!: LabelInputField[] | null; + + @Column({ type: 'varchar', length: 1000, nullable: true }) + LabelGetUrl!: string | null; + + @Column({ type: 'varchar', length: 1000, nullable: true }) + LabelPrintedUrl!: string | null; + + @Column({ type: 'varchar', length: 1000, nullable: true }) + LabelReleaseUrl!: string | null; + + @Column({ type: 'json', nullable: true }) + LabelLayout!: LabelElement[] | null; + @CreateDateColumn() CreatedAt!: Date; diff --git a/paperless-backend/src/database/entities/index.ts b/paperless-backend/src/database/entities/index.ts index 31b738b..34082d7 100644 --- a/paperless-backend/src/database/entities/index.ts +++ b/paperless-backend/src/database/entities/index.ts @@ -20,3 +20,4 @@ export { InboxDocument } from './inbox-document.entity'; export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity'; export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity'; export { UserSettings } from './user-settings.entity'; +export { LabelPrintJob } from './label-print-job.entity'; diff --git a/paperless-backend/src/database/entities/label-print-job.entity.ts b/paperless-backend/src/database/entities/label-print-job.entity.ts new file mode 100644 index 0000000..3904f3b --- /dev/null +++ b/paperless-backend/src/database/entities/label-print-job.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity('label_print_jobs') +export class LabelPrintJob { + @PrimaryGeneratedColumn() + Id!: number; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + Status!: 'pending' | 'printed' | 'error'; + + @Column({ type: 'mediumblob', nullable: true }) + LabelImageData!: Buffer | null; + + @Column({ type: 'int' }) + LabelWidthMm!: number; + + @Column({ type: 'int' }) + LabelHeightMm!: number; + + @Column({ type: 'int', nullable: true }) + BarcodeTemplateId!: number | null; + + @Column({ type: 'json', nullable: true }) + LabelVariables!: Record | null; + + @Column({ type: 'datetime', nullable: true }) + LockedAt!: Date | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + LockedByAgent!: string | null; + + @Column({ type: 'datetime', nullable: true }) + PrintedAt!: Date | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + PrintedByAgent!: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + PrinterName!: string | null; + + @Column({ type: 'text', nullable: true }) + ErrorMessage!: string | null; + + @CreateDateColumn() + CreatedAt!: Date; +} diff --git a/paperless-backend/src/label-print-agent/label-print-agent.controller.ts b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts new file mode 100644 index 0000000..bef4bf0 --- /dev/null +++ b/paperless-backend/src/label-print-agent/label-print-agent.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + ParseIntPipe, + Post, + Query, + Res, + StreamableFile, + UseGuards, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { JwtOrApiKeyGuard } from '../auth/jwt-or-apikey.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; +import { LabelPrintAgentService } from './label-print-agent.service'; + +@Controller('api/label-print-agent') +@UseGuards(JwtOrApiKeyGuard) +export class LabelPrintAgentController { + constructor(private readonly service: LabelPrintAgentService) {} + + // Manuell einen Job anlegen (Frontend → Backend) + @Post('jobs') + @HttpCode(HttpStatus.CREATED) + @RequirePermissions(Permission.VIEW_SCANNER) + async createJob( + @Body() body: { templateId: number; fieldValues?: Record }, + ) { + const job = await this.service.createJob(body.templateId, body.fieldValues ?? {}); + return { jobId: String(job.Id) }; + } + + // Agent: nächsten Job abholen (Polling) + @Get('jobs/next') + async getNextJob(@Query('agentId') agentId: string, @Res({ passthrough: true }) res: Response) { + const job = await this.service.claimNextJob(agentId ?? 'unknown'); + if (!job) { + res.status(HttpStatus.NO_CONTENT).send(); + return; + } + return { + jobId: String(job.Id), + labelImageBase64: job.LabelImageData ? job.LabelImageData.toString('base64') : null, + labelImageContentType: 'image/png', + labelWidthMm: job.LabelWidthMm, + labelHeightMm: job.LabelHeightMm, + }; + } + + // Agent: Bild separat abrufen + @Get('jobs/:id/image') + async getImage( + @Param('id', ParseIntPipe) id: number, + @Res({ passthrough: true }) res: Response, + ): Promise { + const buf = await this.service.getJobImage(id); + if (!buf) throw new NotFoundException('Bild nicht gefunden'); + const { Readable } = await import('stream'); + res.setHeader('Content-Type', 'image/png'); + return new StreamableFile(Readable.from(buf)); + } + + // Agent: Druck erfolgreich + @Post('jobs/:id/printed') + @HttpCode(HttpStatus.OK) + async markPrinted( + @Param('id', ParseIntPipe) id: number, + @Body() body: { agentId?: string; printerName?: string }, + ) { + await this.service.markPrinted(id, body.agentId ?? 'unknown', body.printerName ?? ''); + return { ok: true }; + } + + // Agent: Druckfehler + @Post('jobs/:id/error') + @HttpCode(HttpStatus.OK) + async markError( + @Param('id', ParseIntPipe) id: number, + @Body() body: { agentId?: string; printerName?: string; errorMessage?: string }, + ) { + await this.service.markError(id, body.agentId ?? 'unknown', body.printerName ?? '', body.errorMessage ?? ''); + return { ok: true }; + } +} diff --git a/paperless-backend/src/label-print-agent/label-print-agent.module.ts b/paperless-backend/src/label-print-agent/label-print-agent.module.ts new file mode 100644 index 0000000..06d6736 --- /dev/null +++ b/paperless-backend/src/label-print-agent/label-print-agent.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LabelPrintJob } from '../database/entities/label-print-job.entity'; +import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; +import { LabelPrintAgentService } from './label-print-agent.service'; +import { LabelRendererService } from './label-renderer.service'; +import { LabelPrintAgentController } from './label-print-agent.controller'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([LabelPrintJob, BarcodeTemplate]), + AuthModule, + ], + providers: [LabelPrintAgentService, LabelRendererService], + controllers: [LabelPrintAgentController], + exports: [LabelPrintAgentService, LabelRendererService], +}) +export class LabelPrintAgentModule {} diff --git a/paperless-backend/src/label-print-agent/label-print-agent.service.ts b/paperless-backend/src/label-print-agent/label-print-agent.service.ts new file mode 100644 index 0000000..5076e12 --- /dev/null +++ b/paperless-backend/src/label-print-agent/label-print-agent.service.ts @@ -0,0 +1,164 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan, IsNull, Or } from 'typeorm'; +import { LabelPrintJob } from '../database/entities/label-print-job.entity'; +import { BarcodeTemplate } from '../database/entities/barcode-template.entity'; +import { LabelRendererService } from './label-renderer.service'; + +function applyVars(template: string, vars: Record): string { + return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] ?? ''); +} + +function lockExpiry(): Date { + const d = new Date(); + d.setMinutes(d.getMinutes() - 5); + return d; +} + +@Injectable() +export class LabelPrintAgentService { + private readonly logger = new Logger(LabelPrintAgentService.name); + + constructor( + @InjectRepository(LabelPrintJob) + private readonly jobRepo: Repository, + @InjectRepository(BarcodeTemplate) + private readonly templateRepo: Repository, + private readonly renderer: LabelRendererService, + ) {} + + async createJob( + templateId: number, + fieldValues: Record, + ): Promise { + const template = await this.templateRepo.findOne({ where: { Id: templateId } }); + if (!template) throw new NotFoundException('Template nicht gefunden'); + if (!template.LabelEnabled) throw new BadRequestException('Etikett-Druck für dieses Template nicht aktiviert'); + + // Variablen aufbauen + const vars: Record = { ...fieldValues }; + + // Datum-Felder: year/month/day separat ablegen + for (const field of template.LabelInputFields ?? []) { + if (field.type === 'date' && fieldValues[field.name]) { + const raw = fieldValues[field.name]; // erwartet YYYY-MM-DD + const parts = raw.split('-'); + if (parts.length === 3) { + vars[`${field.name}.year`] = parts[0]; + vars[`${field.name}.month`] = parts[1]; + vars[`${field.name}.day`] = parts[2]; + } + } + } + + // GET-URL aufrufen → {number} + if (template.LabelGetUrl) { + const url = applyVars(template.LabelGetUrl, vars); + try { + const res = await fetch(url); + const text = (await res.text()).trim(); + vars['number'] = text; + } catch (err: any) { + this.logger.warn(`GET-URL fehlgeschlagen (${url}): ${err.message}`); + } + } + + const job = this.jobRepo.create({ + Status: 'pending', + LabelImageData: null, + LabelWidthMm: template.LabelWidthMm ?? 57, + LabelHeightMm: template.LabelHeightMm ?? 32, + BarcodeTemplateId: template.Id, + LabelVariables: vars, + LockedAt: null, + LockedByAgent: null, + }); + + return this.jobRepo.save(job); + } + + async claimNextJob(agentId: string): Promise { + // Kandidat: pending und kein Lock oder Lock abgelaufen (> 5 Min) + const candidate = await this.jobRepo.findOne({ + where: [ + { Status: 'pending', LockedAt: IsNull() }, + { Status: 'pending', LockedAt: LessThan(lockExpiry()) }, + ], + order: { CreatedAt: 'ASC' }, + }); + if (!candidate) return null; + + // Lock setzen + candidate.LockedAt = new Date(); + candidate.LockedByAgent = agentId; + await this.jobRepo.save(candidate); + + // Lazy render + if (!candidate.LabelImageData) { + const template = await this.templateRepo.findOne({ where: { Id: candidate.BarcodeTemplateId ?? undefined } }); + if (template?.LabelLayout?.length) { + try { + candidate.LabelImageData = await this.renderer.render( + template.LabelLayout, + candidate.LabelWidthMm, + candidate.LabelHeightMm, + candidate.LabelVariables ?? {}, + ); + await this.jobRepo.save(candidate); + } catch (err: any) { + this.logger.error(`Label-Rendering fehlgeschlagen für Job ${candidate.Id}: ${err.message}`); + } + } + } + + return candidate; + } + + async getJobImage(jobId: number): Promise { + const job = await this.jobRepo.findOne({ where: { Id: jobId } }); + return job?.LabelImageData ?? null; + } + + async markPrinted(jobId: number, agentId: string, printerName: string): Promise { + const job = await this.jobRepo.findOne({ where: { Id: jobId } }); + if (!job) throw new NotFoundException('Job nicht gefunden'); + + job.Status = 'printed'; + job.PrintedAt = new Date(); + job.PrintedByAgent = agentId; + job.PrinterName = printerName; + await this.jobRepo.save(job); + + await this.callUrl('PRINTED', job); + } + + async markError(jobId: number, agentId: string, printerName: string, errorMessage: string): Promise { + const job = await this.jobRepo.findOne({ where: { Id: jobId } }); + if (!job) throw new NotFoundException('Job nicht gefunden'); + + job.Status = 'error'; + job.PrintedByAgent = agentId; + job.PrinterName = printerName; + job.ErrorMessage = errorMessage; + await this.jobRepo.save(job); + + await this.callUrl('RELEASE', job); + } + + private async callUrl(type: 'PRINTED' | 'RELEASE', job: LabelPrintJob): Promise { + const template = job.BarcodeTemplateId + ? await this.templateRepo.findOne({ where: { Id: job.BarcodeTemplateId } }) + : null; + if (!template) return; + + const urlTemplate = type === 'PRINTED' ? template.LabelPrintedUrl : template.LabelReleaseUrl; + if (!urlTemplate) return; + + const url = applyVars(urlTemplate, job.LabelVariables ?? {}); + try { + await fetch(url, { method: 'POST' }); + } catch (err: any) { + this.logger.warn(`${type}-URL fehlgeschlagen (${url}): ${err.message}`); + } + } +} diff --git a/paperless-backend/src/label-print-agent/label-renderer.service.ts b/paperless-backend/src/label-print-agent/label-renderer.service.ts new file mode 100644 index 0000000..9daa5a6 --- /dev/null +++ b/paperless-backend/src/label-print-agent/label-renderer.service.ts @@ -0,0 +1,81 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as QRCode from 'qrcode'; +import sharp from 'sharp'; +import type { LabelElement } from '../database/entities/barcode-template.entity'; + +const MM_TO_PX = 300 / 25.4; // 300 DPI + +function mm(v: number): number { + return Math.round(v * MM_TO_PX); +} + +function escape(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function applyVars(template: string, vars: Record): string { + return template.replace(/\{([^}]+)\}/g, (_, key) => vars[key] ?? `{${key}}`); +} + +@Injectable() +export class LabelRendererService { + private readonly logger = new Logger(LabelRendererService.name); + + async render( + layout: LabelElement[], + widthMm: number, + heightMm: number, + variables: Record, + ): 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 y = mm(el.y); + const fontSize = mm(el.fontSize); + const content = escape(applyVars(el.content, variables)); + const fontWeight = el.bold ? 'bold' : 'normal'; + const textAnchor = el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start'; + const maxWidthAttr = el.maxWidth ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` : ''; + parts.push( + `${content}`, + ); + } else if (el.type === 'qr') { + const x = mm(el.x); + const y = mm(el.y); + const size = mm(el.sizeMm); + const content = applyVars(el.content, variables); + try { + const qrBuffer = await QRCode.toBuffer(content, { + type: 'png', + margin: 0, + width: size, + errorCorrectionLevel: 'M', + }); + const b64 = qrBuffer.toString('base64'); + parts.push(``); + } catch (err: any) { + this.logger.warn(`QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`); + } + } else if (el.type === 'line') { + const x1 = mm(el.x1); + const y1 = mm(el.y1); + const x2 = mm(el.x2); + const y2 = mm(el.y2); + const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1; + parts.push(``); + } + } + + const svg = ` + + ${parts.join('\n ')} +`; + + return sharp(Buffer.from(svg)).png().toBuffer(); + } +} diff --git a/paperless-frontend/src/api/barcode-templates.ts b/paperless-frontend/src/api/barcode-templates.ts index fe1caf1..fe687a9 100644 --- a/paperless-frontend/src/api/barcode-templates.ts +++ b/paperless-frontend/src/api/barcode-templates.ts @@ -2,6 +2,17 @@ import api from './client'; export type BarcodeActionType = 'SEND_TO_PAPERLESS' | 'SEND_BY_EMAIL'; +export interface LabelInputField { + name: string; + label: string; + type: 'text' | 'number' | 'date'; +} + +export type LabelElement = + | { type: 'text'; content: string; x: number; y: number; fontSize: number; bold?: boolean; align?: 'left' | 'center' | 'right'; maxWidth?: number } + | { type: 'qr'; content: string; x: number; y: number; sizeMm: number } + | { type: 'line'; x1: number; y1: number; x2: number; y2: number; lineWidth?: number }; + export interface BarcodeTemplate { Id: number; Name: string; @@ -9,6 +20,14 @@ export interface BarcodeTemplate { SplitBefore: boolean; DateinameTemplate: string | null; Actions: BarcodeActionType[]; + LabelEnabled: boolean; + LabelWidthMm: number | null; + LabelHeightMm: number | null; + LabelInputFields: LabelInputField[] | null; + LabelGetUrl: string | null; + LabelPrintedUrl: string | null; + LabelReleaseUrl: string | null; + LabelLayout: LabelElement[] | null; CreatedAt: string; UpdatedAt: string; } @@ -19,6 +38,14 @@ export interface BarcodeTemplateInput { SplitBefore: boolean; DateinameTemplate?: string | null; Actions: BarcodeActionType[]; + LabelEnabled?: boolean; + LabelWidthMm?: number | null; + LabelHeightMm?: number | null; + LabelInputFields?: LabelInputField[] | null; + LabelGetUrl?: string | null; + LabelPrintedUrl?: string | null; + LabelReleaseUrl?: string | null; + LabelLayout?: LabelElement[] | null; } export const BARCODE_ACTION_LABELS: Record = { diff --git a/paperless-frontend/src/api/labelPrintAgent.ts b/paperless-frontend/src/api/labelPrintAgent.ts new file mode 100644 index 0000000..de725b7 --- /dev/null +++ b/paperless-frontend/src/api/labelPrintAgent.ts @@ -0,0 +1,8 @@ +import api from './client'; + +export const labelPrintAgentApi = { + createJob: (templateId: number, fieldValues: Record) => + api + .post<{ jobId: string }>('/api/label-print-agent/jobs', { templateId, fieldValues }) + .then((r) => r.data), +}; diff --git a/paperless-frontend/src/pages/InboxDetailPage.tsx b/paperless-frontend/src/pages/InboxDetailPage.tsx index 1fb0864..979a4f8 100644 --- a/paperless-frontend/src/pages/InboxDetailPage.tsx +++ b/paperless-frontend/src/pages/InboxDetailPage.tsx @@ -1107,15 +1107,7 @@ export default function InboxDetailPage() { return (
-
+
+
+ setPrintDialogOpen(false)} + onOk={handlePrint} + okText="Drucken" + cancelText="Abbrechen" + confirmLoading={printing} + okButtonProps={{ disabled: !selectedTemplate }} + > +
+ + + setFieldValues((prev) => ({ ...prev, [field.name]: e.target.value })) + } + /> + )} + + ))} +
+
+ rowKey="id" diff --git a/paperless-frontend/src/pages/SettingsPage.tsx b/paperless-frontend/src/pages/SettingsPage.tsx index 0f5e1c2..3714cb5 100644 --- a/paperless-frontend/src/pages/SettingsPage.tsx +++ b/paperless-frontend/src/pages/SettingsPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import dayjs from 'dayjs'; import { Tabs, Typography, Table, Button, Modal, Form, Input, Select, - Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, + Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, } from 'antd'; import { UserOutlined, FileTextOutlined, ThunderboltOutlined, @@ -24,6 +24,7 @@ import { apiKeysApi, type ApiKey } from '../api/api-keys'; import { barcodeTemplatesApi, type BarcodeTemplate, + type LabelInputField, } from '../api/barcode-templates'; import { paperlessApi, type PaperlessTag, type PaperlessDocType, @@ -1779,6 +1780,64 @@ function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) { // Eingangsdokumentarten Tab (ehemals Barcode-Vorlagen) // ═══════════════════════════════════════════════════════════════════ +function LabelElementRow({ listName, remove }: { listName: number; remove: (n: number) => void }) { + return ( + } onClick={() => remove(listName)} />} + > + + + + + + ); + if (type === 'qr') return ( + + + + + + + ); + if (type === 'line') return ( + + + + + + + + ); + return null; + }} + + + ); +} + function BarcodeTemplatesTab() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -1806,7 +1865,15 @@ function BarcodeTemplatesTab() { setEditing(null); setTestValue(''); form.resetFields(); - form.setFieldsValue({ SplitBefore: false, DateinameTemplate: '' }); + form.setFieldsValue({ + SplitBefore: false, + DateinameTemplate: '', + LabelEnabled: false, + LabelWidthMm: 57, + LabelHeightMm: 32, + LabelInputFields: [], + LabelLayout: [], + }); setModalOpen(true); }; @@ -1814,7 +1881,20 @@ function BarcodeTemplatesTab() { setIsNew(false); setEditing(row); setTestValue(''); - form.setFieldsValue({ Name: row.Name, Regex: row.Regex, SplitBefore: row.SplitBefore, DateinameTemplate: row.DateinameTemplate ?? '' }); + form.setFieldsValue({ + Name: row.Name, + Regex: row.Regex, + SplitBefore: row.SplitBefore, + DateinameTemplate: row.DateinameTemplate ?? '', + LabelEnabled: row.LabelEnabled ?? false, + LabelWidthMm: row.LabelWidthMm ?? 57, + LabelHeightMm: row.LabelHeightMm ?? 32, + LabelInputFields: row.LabelInputFields ?? [], + LabelGetUrl: row.LabelGetUrl ?? '', + LabelPrintedUrl: row.LabelPrintedUrl ?? '', + LabelReleaseUrl: row.LabelReleaseUrl ?? '', + LabelLayout: row.LabelLayout ?? [], + }); setModalOpen(true); }; @@ -1960,6 +2040,121 @@ function BarcodeTemplatesTab() { > + + Etikett + + + + + + prev.LabelEnabled !== curr.LabelEnabled}> + {({ getFieldValue }) => + getFieldValue('LabelEnabled') ? ( + <> + + + + + + + + + + + + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...rest }) => ( + + + + + + + + + + + 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}> + + + 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}> + + + + 0 ? `Platzhalter für Inhalt: ${chipsWithNumber.join(' ')}` : undefined}> + + {(layoutFields, { add: addEl, remove: removeEl }) => ( + <> + {layoutFields.map(({ key, name: elName }) => ( + + ))} + + + + + + + )} + + + + ); + }} + + + ) : null + } + {editing && !isNew && (