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
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
This commit is contained in:
@@ -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);
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user