feat: implement backend label print agent system for remote label rendering and job management
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s

This commit is contained in:
2026-05-07 22:46:29 +02:00
parent 0c94e7b999
commit 80f862a0c0
15 changed files with 995 additions and 15 deletions
@@ -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<BarcodeActionType, string> = {
@@ -0,0 +1,8 @@
import api from './client';
export const labelPrintAgentApi = {
createJob: (templateId: number, fieldValues: Record<string, string>) =>
api
.post<{ jobId: string }>('/api/label-print-agent/jobs', { templateId, fieldValues })
.then((r) => r.data),
};
@@ -1107,15 +1107,7 @@ export default function InboxDetailPage() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
gap: 12,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
Zurück
@@ -1136,9 +1128,7 @@ export default function InboxDetailPage() {
{ key: 'email', label: 'Als E-Mail-Anhang versenden', icon: <MailOutlined /> },
] as MenuProps['items'],
onClick: ({ key }) => {
if (key === 'save') {
setDownloadDialogOpen(true);
}
if (key === 'save') setDownloadDialogOpen(true);
if (key === 'email') setEmailDialogOpen(true);
},
}}
+106
View File
@@ -1,11 +1,17 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import {
Button,
Card,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Popover,
Select,
Space,
Spin,
Table,
@@ -18,6 +24,7 @@ import {
DeleteOutlined,
EyeOutlined,
FolderOpenOutlined,
PrinterOutlined,
QrcodeOutlined,
ReloadOutlined,
ScanOutlined,
@@ -27,6 +34,8 @@ import {
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox';
import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates';
import { labelPrintAgentApi } from '../api/labelPrintAgent';
const { Title } = Typography;
@@ -123,6 +132,45 @@ export default function InboxPage() {
const [search, setSearch] = useState('');
const [rescanning, setRescanning] = useState(false);
const [printDialogOpen, setPrintDialogOpen] = useState(false);
const [labelTemplates, setLabelTemplates] = useState<BarcodeTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<BarcodeTemplate | null>(null);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [printing, setPrinting] = useState(false);
const openPrintDialog = async () => {
try {
const all = await barcodeTemplatesApi.list();
setLabelTemplates(all.filter((t) => t.LabelEnabled));
} catch {
message.error('Vorlagen konnten nicht geladen werden');
return;
}
setSelectedTemplate(null);
setFieldValues({});
setPrintDialogOpen(true);
};
const handleTemplateSelect = (id: number) => {
const t = labelTemplates.find((t) => t.Id === id) ?? null;
setSelectedTemplate(t);
setFieldValues({});
};
const handlePrint = async () => {
if (!selectedTemplate) return;
setPrinting(true);
try {
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
message.success('Druckauftrag erstellt');
setPrintDialogOpen(false);
} catch (err: any) {
message.error(err?.response?.data?.message ?? 'Druckauftrag fehlgeschlagen');
} finally {
setPrinting(false);
}
};
const load = useCallback(async () => {
setLoading(true);
try {
@@ -331,9 +379,67 @@ export default function InboxPage() {
Rescan
</Button>
</Popconfirm>
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
Etikett drucken
</Button>
</Space>
</div>
<Modal
title="Etikett drucken"
open={printDialogOpen}
onCancel={() => setPrintDialogOpen(false)}
onOk={handlePrint}
okText="Drucken"
cancelText="Abbrechen"
confirmLoading={printing}
okButtonProps={{ disabled: !selectedTemplate }}
>
<Form layout="vertical">
<Form.Item label="Eingangsdokumentart">
<Select
placeholder="Vorlage wählen…"
options={labelTemplates.map((t) => ({ value: t.Id, label: t.Name }))}
onChange={handleTemplateSelect}
value={selectedTemplate?.Id}
style={{ width: '100%' }}
/>
</Form.Item>
{selectedTemplate?.LabelInputFields?.map((field) => (
<Form.Item key={field.name} label={field.label || field.name}>
{field.type === 'date' ? (
<DatePicker
style={{ width: '100%' }}
value={fieldValues[field.name] ? dayjs(fieldValues[field.name]) : null}
onChange={(d) =>
setFieldValues((prev) => ({
...prev,
[field.name]: d ? d.format('YYYY-MM-DD') : '',
}))
}
/>
) : field.type === 'number' ? (
<InputNumber
style={{ width: '100%' }}
value={fieldValues[field.name] ? Number(fieldValues[field.name]) : undefined}
onChange={(v) =>
setFieldValues((prev) => ({ ...prev, [field.name]: String(v ?? '') }))
}
/>
) : (
<Input
value={fieldValues[field.name] ?? ''}
onChange={(e) =>
setFieldValues((prev) => ({ ...prev, [field.name]: e.target.value }))
}
/>
)}
</Form.Item>
))}
</Form>
</Modal>
<Card>
<Table<InboxFile>
rowKey="id"
+198 -3
View File
@@ -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 (
<Card
size="small"
style={{ marginBottom: 8 }}
extra={<Button size="small" danger icon={<DeleteOutlined />} onClick={() => remove(listName)} />}
>
<Form.Item name={[listName, 'type']} label="Typ" style={{ marginBottom: 8 }}>
<Select style={{ width: 120 }} options={[
{ value: 'text', label: 'Text' },
{ value: 'qr', label: 'QR-Code' },
{ value: 'line', label: 'Linie' },
]} />
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) => {
const type = getFieldValue(['LabelLayout', listName, 'type']);
if (type === 'text') return (
<Row gutter={8}>
<Col span={4}><Form.Item name={[listName, 'x']} label="X (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'y']} label="Y (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'fontSize']} label="Schrift (mm)"><InputNumber min={0.5} step={0.5} style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'maxWidth']} label="Max. B. (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'bold']} label="Fett" valuePropName="checked"><Checkbox /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'align']} label="Ausrichtung">
<Select allowClear style={{ width: '100%' }} options={[
{ value: 'left', label: 'Links' },
{ value: 'center', label: 'Mitte' },
{ value: 'right', label: 'Rechts' },
]} />
</Form.Item></Col>
<Col span={24}><Form.Item name={[listName, 'content']} label="Inhalt"><Input placeholder="{nummer} oder {datum}" /></Form.Item></Col>
</Row>
);
if (type === 'qr') return (
<Row gutter={8}>
<Col span={4}><Form.Item name={[listName, 'x']} label="X (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'y']} label="Y (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'sizeMm']} label="Größe (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={12}><Form.Item name={[listName, 'content']} label="Inhalt"><Input placeholder="{number}" /></Form.Item></Col>
</Row>
);
if (type === 'line') return (
<Row gutter={8}>
<Col span={4}><Form.Item name={[listName, 'x1']} label="X1 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'y1']} label="Y1 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'x2']} label="X2 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'y2']} label="Y2 (mm)"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
<Col span={4}><Form.Item name={[listName, 'lineWidth']} label="Stärke (mm)"><InputNumber step={0.1} style={{ width: '100%' }} /></Form.Item></Col>
</Row>
);
return null;
}}
</Form.Item>
</Card>
);
}
function BarcodeTemplatesTab() {
const [data, setData] = useState<BarcodeTemplate[]>([]);
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() {
>
<Input placeholder="{barcode}_{datum}" />
</Form.Item>
<Divider>Etikett</Divider>
<Form.Item name="LabelEnabled" valuePropName="checked" label="Etikett-Druck aktivieren">
<Switch />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.LabelEnabled !== curr.LabelEnabled}>
{({ getFieldValue }) =>
getFieldValue('LabelEnabled') ? (
<>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="LabelWidthMm" label="Breite (mm)">
<InputNumber min={10} max={300} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="LabelHeightMm" label="Höhe (mm)">
<InputNumber min={10} max={300} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="Eingabefelder">
<Form.List name="LabelInputFields">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 6 }} align="baseline">
<Form.Item {...rest} name={[name, 'name']} rules={[{ required: true, message: 'Feldname' }]} noStyle>
<Input placeholder="Feldname (z. B. datum)" style={{ width: 160 }} />
</Form.Item>
<Form.Item {...rest} name={[name, 'label']} noStyle>
<Input placeholder="Bezeichnung" style={{ width: 160 }} />
</Form.Item>
<Form.Item {...rest} name={[name, 'type']} noStyle>
<Select style={{ width: 110 }} options={[
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Nummer' },
{ value: 'date', label: 'Datum' },
]} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Space>
))}
<Button type="dashed" onClick={() => add({ type: 'text' })} icon={<PlusOutlined />} size="small">
Feld hinzufügen
</Button>
</>
)}
</Form.List>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue: gfv }) => {
const inputFields: LabelInputField[] = gfv('LabelInputFields') ?? [];
const chips: string[] = [];
for (const f of inputFields) {
if (!f?.name) continue;
chips.push(`{${f.name}}`);
if (f.type === 'date') {
chips.push(`{${f.name}.year}`, `{${f.name}.month}`, `{${f.name}.day}`);
}
}
const chipsWithNumber = [...chips, '{number}'];
return (
<>
<Form.Item name="LabelGetUrl" label="GET-URL (liefert Nummer)"
extra={chips.length > 0 ? `Platzhalter: ${chips.join(' ')}` : undefined}>
<Input placeholder="https://example.com/nummer?feld={feldname}" />
</Form.Item>
<Form.Item name="LabelPrintedUrl" label="PRINTED-URL (nach erfolgreichem Druck)"
extra={chipsWithNumber.length > 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}>
<Input placeholder="https://example.com/gedruckt?nr={number}" />
</Form.Item>
<Form.Item name="LabelReleaseUrl" label="RELEASE-URL (bei Druckfehler)"
extra={chipsWithNumber.length > 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}>
<Input placeholder="https://example.com/freigeben?nr={number}" />
</Form.Item>
<Form.Item label="Layout-Elemente"
extra={chipsWithNumber.length > 0 ? `Platzhalter für Inhalt: ${chipsWithNumber.join(' ')}` : undefined}>
<Form.List name="LabelLayout">
{(layoutFields, { add: addEl, remove: removeEl }) => (
<>
{layoutFields.map(({ key, name: elName }) => (
<LabelElementRow key={key} listName={elName} remove={removeEl} />
))}
<Space>
<Button size="small" type="dashed" icon={<PlusOutlined />}
onClick={() => addEl({ type: 'text', x: 0, y: 0, fontSize: 3, content: '', bold: false })}>
Text
</Button>
<Button size="small" type="dashed" icon={<PlusOutlined />}
onClick={() => addEl({ type: 'qr', x: 0, y: 0, sizeMm: 20, content: '' })}>
QR-Code
</Button>
<Button size="small" type="dashed" icon={<PlusOutlined />}
onClick={() => addEl({ type: 'line', x1: 0, y1: 0, x2: 50, y2: 0 })}>
Linie
</Button>
</Space>
</>
)}
</Form.List>
</Form.Item>
</>
);
}}
</Form.Item>
</>
) : null
}
</Form.Item>
</Form>
{editing && !isNew && (