Files
paperlessmanager/paperless-frontend/src/pages/SettingsPage.tsx
T
bjoernpoettker d96e06e86d
Build and Push Multi-Platform Images / build-and-push (push) Successful in 38s
feat: add Steuertags concept to separate workflow from content tags
- New steuertag_ids setting to mark tags as workflow-only (not editable)
- DocumentEditModal shows only content tags (non-Steuertags) as editable chips
- Backend preserves Steuertags when saving document tag changes
- ManuellBearbeitenPage renders content tag chips under document title
- New Steuertags settings tab with multi-select and color preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:46:39 +02:00

2819 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Row, Col, Radio, Alert,
} from 'antd';
import {
UserOutlined, FileTextOutlined, ThunderboltOutlined,
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
TagsOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd';
import {
settingsApi,
type SettingDocType, type SettingPostprocessing, type SettingUserClient,
type SettingDocField, type SettingPostprocessingAction, type SettingExportTarget,
type SettingPostprocessingLog, type FilterGroup, type FilterCondition,
INBOX_ACTION_LABELS,
type InboxAction, type InboxActionType,
agrarmonitorApi, type AgrarmonitorStatusData,
type SettingClient, type AgrarmonitorPollingConfig, type AgrarmonitorPollingResult,
type SyncConflict,
} from '../api/settings';
import { clientsApi, type Client } from '../api/inbox';
import { apiKeysApi, type ApiKey } from '../api/api-keys';
import {
barcodeTemplatesApi,
type BarcodeTemplate,
type LabelInputField,
} from '../api/barcode-templates';
import { labelPrintAgentApi } from '../api/labelPrintAgent';
import {
paperlessApi, type PaperlessTag, type PaperlessDocType,
type PaperlessCustomField, type PaperlessCorrespondent,
} from '../api/paperless';
import TaskLogPage from './TaskLogPage';
const { Title } = Typography;
// ═══════════════════════════════════════════════════════════════════
// Filter Builder Component
// ═══════════════════════════════════════════════════════════════════
const FIELD_OPTIONS = [
{ value: 'document_type', label: 'Dokumenttyp' },
{ value: 'correspondent', label: 'Absender' },
{ value: 'owner', label: 'Eigentümer' },
{ value: 'tag', label: 'Tag' },
{ value: 'title', label: 'Titel' },
{ value: 'archive_serial_number', label: 'Ablagenummer' },
];
const OPERATOR_OPTIONS = [
{ value: 'equals', label: '=' },
{ value: 'not_equals', label: '≠' },
{ value: 'contains', label: 'enthält' },
{ value: 'not_contains', label: 'enthält nicht' },
{ value: 'is_set', label: 'ist gesetzt' },
{ value: 'is_not_set', label: 'ist nicht gesetzt' },
{ value: 'gt', label: '>' },
{ value: 'lt', label: '<' },
];
function isFilterGroup(rule: FilterCondition | FilterGroup): rule is FilterGroup {
return 'combinator' in rule;
}
interface FilterBuilderProps {
value: FilterGroup;
onChange: (val: FilterGroup) => void;
tags: PaperlessTag[];
docTypes: PaperlessDocType[];
correspondents: PaperlessCorrespondent[];
customFields: PaperlessCustomField[];
}
function FilterBuilder({ value, onChange, tags, docTypes, correspondents, customFields }: FilterBuilderProps) {
const allFieldOptions = [
...FIELD_OPTIONS,
...customFields.map(cf => ({ value: `custom_field_${cf.id}`, label: `CF: ${cf.name}` })),
];
const updateRule = (index: number, updated: FilterCondition | FilterGroup) => {
const newRules = [...value.rules];
newRules[index] = updated;
onChange({ ...value, rules: newRules });
};
const removeRule = (index: number) => {
const newRules = value.rules.filter((_, i) => i !== index);
onChange({ ...value, rules: newRules });
};
const addCondition = () => {
onChange({
...value,
rules: [...value.rules, { field: 'document_type', operator: 'equals', value: null }],
});
};
const addGroup = () => {
onChange({
...value,
rules: [...value.rules, { combinator: 'AND', rules: [] }],
});
};
const renderValueInput = (cond: FilterCondition, onUpdate: (val: any) => void) => {
if (cond.operator === 'is_set' || cond.operator === 'is_not_set') return null;
if (cond.field === 'document_type') {
return (
<Select value={cond.value ?? undefined} onChange={onUpdate} style={{ minWidth: 160 }} showSearch optionFilterProp="children" allowClear>
{(docTypes || []).map(d => <Select.Option key={d.id} value={d.id}>{d.name}</Select.Option>)}
</Select>
);
}
if (cond.field === 'correspondent') {
return (
<Select value={cond.value ?? undefined} onChange={onUpdate} style={{ minWidth: 160 }} showSearch optionFilterProp="children" allowClear>
{(correspondents || []).map(c => <Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>)}
</Select>
);
}
if (cond.field === 'tag') {
return (
<Select value={cond.value ?? undefined} onChange={onUpdate} style={{ minWidth: 160 }} showSearch optionFilterProp="children" allowClear>
{(tags || []).map(t => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
</Select>
);
}
if (cond.field.startsWith('custom_field_')) {
const cfId = parseInt(cond.field.replace('custom_field_', ''), 10);
const cf = (customFields || []).find(c => c.id === cfId);
const selectOptions = cf?.extra_data?.select_options;
if (Array.isArray(selectOptions)) {
return (
<Select value={cond.value ?? undefined} onChange={onUpdate} style={{ minWidth: 160 }} showSearch optionFilterProp="children" allowClear>
{selectOptions.filter(opt => opt && (opt.id || opt.label)).map((opt: any) => (
<Select.Option key={opt.id ?? opt.label} value={opt.label}>{opt.label}</Select.Option>
))}
</Select>
);
}
}
return <Input value={cond.value ?? ''} onChange={e => onUpdate(e.target.value)} style={{ minWidth: 160 }} placeholder="Wert" />;
};
return (
<div style={{ border: '1px solid #d9d9d9', borderRadius: 8, padding: 12, background: '#fafafa' }}>
<Space style={{ marginBottom: 8 }}>
<Select
value={value.combinator}
onChange={c => onChange({ ...value, combinator: c })}
style={{ width: 80 }}
>
<Select.Option value="AND">UND</Select.Option>
<Select.Option value="OR">ODER</Select.Option>
</Select>
<span style={{ color: '#888', fontSize: 12 }}>Alle Bedingungen dieser Gruppe müssen zutreffen</span>
</Space>
{value.rules.map((rule, idx) => (
<div key={idx} style={{ marginBottom: 8 }}>
{isFilterGroup(rule) ? (
<div style={{ marginLeft: 16 }}>
<FilterBuilder
value={rule}
onChange={updated => updateRule(idx, updated)}
tags={tags} docTypes={docTypes} correspondents={correspondents} customFields={customFields}
/>
<Button type="link" danger icon={<MinusCircleOutlined />} onClick={() => removeRule(idx)} size="small">
Gruppe entfernen
</Button>
</div>
) : (
<Space wrap>
<Select
value={rule.field}
onChange={f => updateRule(idx, { ...rule, field: f, value: null })}
style={{ minWidth: 160 }}
showSearch optionFilterProp="children"
>
{allFieldOptions.map(o => <Select.Option key={o.value} value={o.value}>{o.label}</Select.Option>)}
</Select>
<Select
value={rule.operator}
onChange={op => updateRule(idx, { ...rule, operator: op })}
style={{ width: 140 }}
>
{OPERATOR_OPTIONS.map(o => <Select.Option key={o.value} value={o.value}>{o.label}</Select.Option>)}
</Select>
{renderValueInput(rule, val => updateRule(idx, { ...rule, value: val }))}
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => removeRule(idx)} />
</Space>
)}
</div>
))}
<Space style={{ marginTop: 4 }}>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={addCondition}>Bedingung</Button>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={addGroup}>Gruppe</Button>
</Space>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Benutzer & Betriebe Tab
// ═══════════════════════════════════════════════════════════════════
function UserClientsTab() {
const [data, setData] = useState<SettingUserClient[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [allClients, setAllClients] = useState<SettingClient[]>([]);
const [loading, setLoading] = useState(true);
const [clientsLoading, setClientsLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
const [ucs, cls] = await Promise.all([
settingsApi.getUserClients(),
clientsApi.getMyClients(),
]);
setData(ucs);
setClients(cls);
} finally { setLoading(false); }
}, []);
const loadAllClients = useCallback(async () => {
setClientsLoading(true);
try {
const cls = await settingsApi.getClients();
setAllClients(cls);
} finally { setClientsLoading(false); }
}, []);
useEffect(() => { load(); loadAllClients(); }, [load, loadAllClients]);
const handleAdd = async () => {
const values = await form.validateFields();
await settingsApi.createUserClient(values);
message.success('Zuordnung erstellt');
setModalOpen(false);
form.resetFields();
load();
};
const handleDelete = async (id: number) => {
await settingsApi.deleteUserClient(id);
message.success('Gelöscht');
load();
};
const handleUpdateBetriebId = async (id: number, val: number | null) => {
try {
const updated = await settingsApi.updateClient(id, val);
setAllClients(prev => prev.map(c => c.Id === id ? updated : c));
} catch {
message.error('Speichern fehlgeschlagen');
}
};
const allClientColumns: ColumnsType<SettingClient> = [
{ title: 'Name', dataIndex: 'Name', key: 'name' },
{
title: 'Agrarmonitor-BetriebId',
dataIndex: 'AgrarmonitorBetriebId',
key: 'betriebId',
render: (val: number | null, record) => (
<InputNumber
value={val ?? undefined}
placeholder=""
min={1}
style={{ width: 120 }}
onBlur={(e) => {
const parsed = e.target.value ? parseInt(e.target.value, 10) : null;
const current = val ?? null;
if (parsed !== current) handleUpdateBetriebId(record.Id, isNaN(parsed as number) ? null : parsed);
}}
/>
),
},
];
const columns: ColumnsType<SettingUserClient> = [
{ title: 'User ID', dataIndex: 'UserId', key: 'userId' },
{
title: 'Betrieb',
key: 'client',
render: (_, r) => clients.find(c => c.Id === r.ClientId)?.Name ?? r.ClientId,
},
{
title: 'Rolle',
dataIndex: 'Role',
key: 'role',
render: (r: string) => <Tag color={r === 'admin' ? 'red' : r === 'editor' ? 'blue' : 'default'}>{r}</Tag>,
},
{
title: '',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm title="Wirklich löschen?" onConfirm={() => handleDelete(record.Id)}>
<Button danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
),
},
];
return (
<>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}>
Zuordnung hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Divider />
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe Agrarmonitor-Zuordnung</Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Ordne jedem Betrieb die zugehörige Agrarmonitor-BetriebId zu. Wird beim Polling verwendet.
</Typography.Text>
<Table
dataSource={allClients}
columns={allClientColumns}
loading={clientsLoading}
rowKey="Id"
size="small"
pagination={false}
/>
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
<Form form={form} layout="vertical">
<Form.Item name="UserId" label="User ID (Authentik)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="ClientId" label="Betrieb" rules={[{ required: true }]}>
<Select>
{clients.map(c => <Select.Option key={c.Id} value={c.Id}>{c.Name}</Select.Option>)}
</Select>
</Form.Item>
<Form.Item name="Role" label="Rolle" initialValue="editor">
<Select>
<Select.Option value="viewer">Viewer</Select.Option>
<Select.Option value="editor">Editor</Select.Option>
<Select.Option value="admin">Admin</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Document Fields Tab
// ═══════════════════════════════════════════════════════════════════
const FIELD_TYPE_OPTIONS = [
{ value: 1, label: 'Absender' },
{ value: 2, label: 'Belegdatum' },
{ value: 3, label: 'Ablagenummer' },
{ value: 4, label: 'Custom Field' },
{ value: 5, label: 'Titel' },
];
function DocTypeFieldsTable({ docTypeId }: { docTypeId: number }) {
const [fields, setFields] = useState<SettingDocField[]>([]);
const [customFields, setCustomFields] = useState<PaperlessCustomField[]>([]);
const [loading, setLoading] = useState(true);
const [editField, setEditField] = useState<SettingDocField | null>(null);
const [isNew, setIsNew] = useState(false);
const [form] = Form.useForm();
const [fieldType, setFieldType] = useState(4);
const load = useCallback(async () => {
setLoading(true);
try {
const [f, cf] = await Promise.all([
settingsApi.getDocFields(docTypeId),
paperlessApi.getCustomFields(),
]);
setFields(f);
setCustomFields(cf);
} finally { setLoading(false); }
}, [docTypeId]);
useEffect(() => { load(); }, [load]);
const openNew = () => {
setIsNew(true);
setFieldType(4);
form.resetFields();
form.setFieldsValue({ Type: 4, TypeIndex: null, IsRequired: false, IsRequiredPosteingang: false, VisiblePosteingang: true, Hinweis: '' });
setEditField({} as any);
};
const openEdit = (f: SettingDocField) => {
setIsNew(false);
setFieldType(f.Type);
form.setFieldsValue(f);
setEditField(f);
};
const handleSave = async () => {
const values = await form.validateFields();
// For non-custom-field types, TypeIndex is not relevant
if (values.Type !== 4) values.TypeIndex = null;
if (isNew) {
await settingsApi.createDocField(docTypeId, values);
message.success('Feld erstellt');
} else if (editField) {
await settingsApi.updateDocField(editField.Id, values);
message.success('Feld gespeichert');
}
setEditField(null);
load();
};
const handleDelete = async (id: number) => {
await settingsApi.deleteDocField(id);
message.success('Feld gelöscht');
load();
};
const handleToggle = async (id: number, key: string, val: boolean) => {
await settingsApi.updateDocField(id, { [key]: val } as any);
load();
};
const getFieldLabel = (record: SettingDocField) => {
switch (record.Type) {
case 1: return <Tag>Absender</Tag>;
case 2: return <Tag>Belegdatum</Tag>;
case 3: return <Tag>Ablagenummer</Tag>;
case 4:
const cf = customFields.find(c => c.id === record.TypeIndex);
return <Tag color="blue">{cf ? cf.name : `CF #${record.TypeIndex}`}</Tag>;
case 5: return <Tag>Titel</Tag>;
default: return <Tag>Unbekannt ({record.Type})</Tag>;
}
};
const columns: ColumnsType<SettingDocField> = [
{ title: 'Feldtyp', key: 'type', render: (_, record) => getFieldLabel(record) },
{
title: 'Pflichtfeld', dataIndex: 'IsRequired', key: 'req', width: 100,
render: (v, r) => <Switch checked={v} size="small" onChange={val => handleToggle(r.Id, 'IsRequired', val)} />,
},
{
title: 'Pflicht (Posteingang)', dataIndex: 'IsRequiredPosteingang', key: 'reqPE', width: 140,
render: (v, r) => <Switch checked={v} size="small" onChange={val => handleToggle(r.Id, 'IsRequiredPosteingang', val)} />,
},
{
title: 'Sichtbar (Posteingang)', dataIndex: 'VisiblePosteingang', key: 'visPE', width: 150,
render: (v, r) => <Switch checked={v} size="small" onChange={val => handleToggle(r.Id, 'VisiblePosteingang', val)} />,
},
{ title: 'Hinweis', dataIndex: 'Hinweis', key: 'hinweis', ellipsis: true },
{
title: '', key: 'actions', width: 100,
render: (_, rec) => (
<Space>
<Button icon={<EditOutlined />} size="small" onClick={() => openEdit(rec)} />
<Popconfirm title="Feld wirklich löschen?" onConfirm={() => handleDelete(rec.Id)}>
<Button danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 8 }}>
Feld hinzufügen
</Button>
<Table dataSource={fields} columns={columns} pagination={false} size="small" loading={loading} rowKey="Id" scroll={{ x: 'max-content' }} />
<Modal
title={isNew ? 'Neues Feld' : 'Feld bearbeiten'}
open={!!editField}
onOk={handleSave}
onCancel={() => setEditField(null)}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item name="Type" label="Feldtyp" rules={[{ required: true }]}>
<Select onChange={(v: number) => setFieldType(v)}>
{FIELD_TYPE_OPTIONS.map(o => (
<Select.Option key={o.value} value={o.value}>{o.label}</Select.Option>
))}
</Select>
</Form.Item>
{fieldType === 4 && (
<Form.Item name="TypeIndex" label="Custom Field" rules={[{ required: true, message: 'Bitte Custom Field auswählen' }]}>
<Select showSearch optionFilterProp="children" placeholder="Custom Field wählen...">
{customFields.map(cf => (
<Select.Option key={cf.id} value={cf.id}>{cf.name}</Select.Option>
))}
</Select>
</Form.Item>
)}
<Form.Item name="IsRequired" label="Pflichtfeld" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="IsRequiredPosteingang" label="Pflichtfeld (Posteingang)" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="VisiblePosteingang" label="Sichtbar im Posteingang" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="Hinweis" label="Hinweis">
<Input placeholder="Optionaler Hinweistext für Benutzer" />
</Form.Item>
</Form>
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Dokumenttypen Tab
// ═══════════════════════════════════════════════════════════════════
function DocTypesTab() {
const [data, setData] = useState<SettingDocType[]>([]);
const [tags, setTags] = useState<PaperlessTag[]>([]);
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<SettingDocType | null>(null);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
const [docs, fetchedTags, fetchedDocTypes] = await Promise.all([
settingsApi.getDocTypes(),
paperlessApi.getTags(),
paperlessApi.getDocumentTypes()
]);
setData(docs);
setTags(fetchedTags);
setDocTypes(fetchedDocTypes);
}
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async () => {
if (!editing) return;
const values = await form.validateFields();
await settingsApi.updateDocType(editing.Id, values);
message.success('Gespeichert');
setEditing(null);
load();
};
const getTagName = (id: number | null) => {
if (!id) return '—';
const tag = tags.find(t => t.id === id);
return tag ? <Tag color={tag.color}>{tag.name}</Tag> : id;
};
const getDocTypeName = (id: number) => {
const dt = docTypes.find(d => d.id === id);
return dt ? dt.name : id;
};
const columns: ColumnsType<SettingDocType> = [
{ title: 'Dokumenttyp', dataIndex: 'DocumentTypeId', key: 'id', render: getDocTypeName },
{ title: 'Titel-Template', dataIndex: 'TitelTemplate', key: 'template', ellipsis: true },
{ title: 'Tag (nicht bereit)', dataIndex: 'TagNotReady', key: 'tagNR', render: getTagName },
{ title: 'Tag (bereit)', dataIndex: 'TagReady', key: 'tagR', render: getTagName },
{
title: 'Freigabe erf.',
dataIndex: 'FreigabeErforderlich',
key: 'freigabe',
render: (v: boolean | null) => v ? <Tag color="blue">Ja</Tag> : '—',
},
{
title: '',
key: 'actions',
width: 60,
render: (_, rec) => (
<Button icon={<EditOutlined />} size="small" onClick={() => { setEditing(rec); form.setFieldsValue(rec); }} />
),
},
];
return (
<>
<Table
dataSource={data}
columns={columns}
loading={loading}
rowKey="Id"
size="small"
pagination={false}
/>
<Modal
title="Dokumenttyp bearbeiten"
open={!!editing}
onOk={handleSave}
onCancel={() => setEditing(null)}
width={900}
>
<Form form={form} layout="vertical">
<Form.Item
name="TitelTemplate"
label="Titel-Template"
tooltip="Verfügbare Platzhalter: {{CUSTOM[ID]}} (Wert eines Custom Fields), {{DATE}} (Datum im Format DD.MM.YYYY), {{ZEITSTEMPEL}} (aktueller Zeitstempel)"
>
<Input placeholder="z.B. Rechnung_{{CUSTOM[5]}}_{{DATE}}" />
</Form.Item>
<Form.Item name="TagNotReady" label="Tag (nicht bereit)">
<Select allowClear showSearch optionFilterProp="children">
{tags.map(t => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
</Select>
</Form.Item>
<Form.Item name="TagReady" label="Tag (bereit)">
<Select allowClear showSearch optionFilterProp="children">
{tags.map(t => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
</Select>
</Form.Item>
<Form.Item name="FreigabeErforderlich" valuePropName="checked" label="Freigabe erforderlich">
<Checkbox />
</Form.Item>
</Form>
{editing && (
<div style={{ marginTop: 24 }}>
<h4>Dokumentenfelder</h4>
<DocTypeFieldsTable key={editing.DocumentTypeId} docTypeId={editing.DocumentTypeId} />
</div>
)}
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Action Editor (Sub-component for Postprocessing)
// ═══════════════════════════════════════════════════════════════════
const ACTION_TYPE_LABELS: Record<number, string> = {
1: 'Export (FTP/WebDAV)',
2: 'Mail versenden',
3: 'Tags setzen/entfernen',
4: 'Custom Field setzen',
5: 'Webhook',
6: 'Notiz hinzufügen',
};
function ActionEditor({
ppId,
tags,
customFields,
exportTargets,
}: {
ppId: number;
tags: PaperlessTag[];
customFields: PaperlessCustomField[];
exportTargets: SettingExportTarget[];
}) {
const [actions, setActions] = useState<SettingPostprocessingAction[]>([]);
const [loading, setLoading] = useState(true);
const [editAction, setEditAction] = useState<SettingPostprocessingAction | null>(null);
const [isNew, setIsNew] = useState(false);
const [form] = Form.useForm();
const [actionType, setActionType] = useState(1);
const load = useCallback(async () => {
setLoading(true);
try { setActions(await settingsApi.getActions(ppId)); }
finally { setLoading(false); }
}, [ppId]);
useEffect(() => { load(); }, [load]);
const openNew = () => {
setIsNew(true);
setActionType(1);
form.resetFields();
form.setFieldsValue({ ActionType: 1, IsActive: true, Order: actions.length + 1, Content: {} });
setEditAction({} as any);
};
const openEdit = (a: SettingPostprocessingAction) => {
setIsNew(false);
setActionType(a.ActionType);
form.setFieldsValue({ ActionType: a.ActionType, IsActive: a.IsActive, Order: a.Order, ...a.Content });
setEditAction(a);
};
const buildContent = (values: any): Record<string, any> => {
const { ActionType: _, IsActive: __, Order: ___, ...rest } = values;
return rest;
};
const handleSave = async () => {
const values = await form.validateFields();
const content = buildContent(values);
if (isNew) {
await settingsApi.createAction(ppId, {
ActionType: values.ActionType,
Content: content,
Order: values.Order,
IsActive: values.IsActive,
});
message.success('Aktion erstellt');
} else if (editAction) {
await settingsApi.updateAction(editAction.Id, {
ActionType: values.ActionType,
Content: content,
Order: values.Order,
IsActive: values.IsActive,
});
message.success('Aktion gespeichert');
}
setEditAction(null);
load();
};
const handleDelete = async (id: number) => {
await settingsApi.deleteAction(id);
message.success('Gelöscht');
load();
};
const columns: ColumnsType<SettingPostprocessingAction> = [
{ title: '#', dataIndex: 'Order', key: 'order', width: 50 },
{ title: 'Typ', key: 'type', render: (_, r) => <Tag>{ACTION_TYPE_LABELS[r.ActionType] || r.ActionType}</Tag> },
{ title: 'Aktiv', key: 'active', width: 60, render: (_, r) => <Switch size="small" checked={r.IsActive} disabled /> },
{
title: '', key: 'actions', width: 100,
render: (_, r) => (
<Space>
<Button icon={<EditOutlined />} size="small" onClick={() => openEdit(r)} />
<Popconfirm title="Löschen?" onConfirm={() => handleDelete(r.Id)}>
<Button danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
</Space>
),
},
];
const renderContentFields = () => {
switch (actionType) {
case 1: // Export
return (
<>
<Form.Item name="exportTargetId" label="Export-Ziel" rules={[{ required: true }]}>
<Select>
{exportTargets.filter(t => t.IsActive).map(t => (
<Select.Option key={t.Id} value={t.Id}>{t.Name} ({t.Protocol})</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="fileType" label="Datei-Typ" initialValue="archive">
<Select>
<Select.Option value="archive">Archiv (OCR)</Select.Option>
<Select.Option value="original">Original</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="filenameTemplate"
label="Dateiname"
tooltip="Platzhalter: {titel}, {absender_name}, {dokumenttyp_name}, {datum}, {jahr}, {monat}, {tag}, {id}, {ablagenummer}, {besitzer}, {custom_field_<ID>}, {zeitstempel}"
>
<Input placeholder="z.B. {datum}_{absender_name}_{titel}" />
</Form.Item>
<div style={{ marginTop: -16, marginBottom: 16, fontSize: 12, color: '#888' }}>
Verfügbare Platzhalter: <code>{'{titel}'}</code> <code>{'{absender_name}'}</code> <code>{'{dokumenttyp_name}'}</code> <code>{'{datum}'}</code> <code>{'{jahr}'}</code> <code>{'{monat}'}</code> <code>{'{tag}'}</code> <code>{'{id}'}</code> <code>{'{ablagenummer}'}</code> <code>{'{besitzer}'}</code> <code>{'{custom_field_<ID>}'}</code>
</div>
</>
);
case 2: // Mail
return (
<>
<Form.Item name="to" label="Empfänger" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item
name="subject"
label="Betreff"
tooltip="Platzhalter: {titel}, {absender_name}, {dokumenttyp_name}, {datum}, {jahr}, {monat}, {tag}, {id}, {ablagenummer}, {besitzer}, {custom_field_<ID>}, {zeitstempel}"
>
<Input placeholder="z.B. Rechnung {absender_name} vom {datum}" />
</Form.Item>
<Form.Item
name="body"
label="Text"
tooltip="Platzhalter: {titel}, {absender_name}, {dokumenttyp_name}, {datum}, {jahr}, {monat}, {tag}, {id}, {ablagenummer}, {besitzer}, {custom_field_<ID>}, {zeitstempel}"
>
<Input.TextArea rows={3} placeholder="z.B. Anbei die Rechnung {titel} von {absender_name}." />
</Form.Item>
<Form.Item name="fileType" label="Anhang-Typ" initialValue="archive">
<Select>
<Select.Option value="archive">Archiv (OCR)</Select.Option>
<Select.Option value="original">Original</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="filenameTemplate"
label="Dateiname (Anhang)"
tooltip="Platzhalter: {titel}, {absender_name}, {dokumenttyp_name}, {datum}, {jahr}, {monat}, {tag}, {id}, {ablagenummer}, {besitzer}, {custom_field_<ID>}, {zeitstempel}"
>
<Input placeholder="z.B. {datum}_{absender_name}_{titel}" />
</Form.Item>
</>
);
case 3: // Tags
return (
<>
<Form.Item name="addTags" label="Tags hinzufügen">
<Select mode="multiple" allowClear showSearch optionFilterProp="children">
{tags.map(t => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
</Select>
</Form.Item>
<Form.Item name="removeTags" label="Tags entfernen">
<Select mode="multiple" allowClear showSearch optionFilterProp="children">
{tags.map(t => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
</Select>
</Form.Item>
</>
);
case 4: // Custom Field
return (
<>
<Form.Item name="fieldId" label="Custom Field" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="children">
{customFields.map(cf => <Select.Option key={cf.id} value={cf.id}>{cf.name}</Select.Option>)}
</Select>
</Form.Item>
<Form.Item name="value" label="Wert"><Input /></Form.Item>
</>
);
case 5: // Webhook
return (
<>
<Form.Item name="url" label="URL" rules={[{ required: true, type: 'url' }]}><Input /></Form.Item>
<Form.Item name="method" label="HTTP Methode" initialValue="POST">
<Select>
<Select.Option value="GET">GET</Select.Option>
<Select.Option value="POST">POST</Select.Option>
<Select.Option value="PUT">PUT</Select.Option>
</Select>
</Form.Item>
</>
);
case 6: // Note
return (
<>
<Form.Item
name="note"
label="Notiz"
rules={[{ required: true }]}
tooltip="Platzhalter: {titel}, {absender_name}, {dokumenttyp_name}, {datum}, {jahr}, {monat}, {tag}, {id}, {ablagenummer}, {besitzer}, {custom_field_<ID>}"
>
<Input.TextArea rows={3} placeholder="z.B. Dokument wurde automatisch exportiert am {datum}." />
</Form.Item>
</>
);
default: return null;
}
};
return (
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 8 }}>
<h4 style={{ margin: 0 }}>Aktionen</h4>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button>
</Space>
<Table dataSource={actions} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Modal
title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'}
open={!!editAction}
onOk={handleSave}
onCancel={() => setEditAction(null)}
width={600}
>
<Form form={form} layout="vertical">
<Space style={{ width: '100%' }}>
<Form.Item name="ActionType" label="Aktionstyp" rules={[{ required: true }]}>
<Select style={{ width: 200 }} onChange={v => setActionType(v)}>
{Object.entries(ACTION_TYPE_LABELS).map(([k, l]) => (
<Select.Option key={k} value={Number(k)}>{l}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="Order" label="Reihenfolge"><InputNumber min={1} /></Form.Item>
<Form.Item name="IsActive" label="Aktiv" valuePropName="checked"><Switch /></Form.Item>
</Space>
<Divider />
{renderContentFields()}
</Form>
</Modal>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Postprocessing Tab
// ═══════════════════════════════════════════════════════════════════
function PostprocessingTab() {
const [data, setData] = useState<SettingPostprocessing[]>([]);
const [tags, setTags] = useState<PaperlessTag[]>([]);
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [customFields, setCustomFields] = useState<PaperlessCustomField[]>([]);
const [exportTargets, setExportTargets] = useState<SettingExportTarget[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<SettingPostprocessing | null>(null);
const [isNew, setIsNew] = useState(false);
const [form] = Form.useForm();
const [filterJson, setFilterJson] = useState<FilterGroup>({ combinator: 'AND', rules: [] });
const load = useCallback(async () => {
setLoading(true);
try {
const [pp, t, dt, c, cf, et] = await Promise.all([
settingsApi.getPostprocessing(),
paperlessApi.getTags(),
paperlessApi.getDocumentTypes(),
paperlessApi.getCorrespondents(),
paperlessApi.getCustomFields(),
settingsApi.getExportTargets(),
]);
setData(pp);
setTags(t);
setDocTypes(dt);
setCorrespondents(c);
setCustomFields(cf);
setExportTargets(et);
} finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const openNew = () => {
setIsNew(true);
const emptyFilter: FilterGroup = { combinator: 'AND', rules: [] };
setFilterJson(emptyFilter);
form.resetFields();
form.setFieldsValue({ Name: '', IsActive: true, NoFurther: false, Order: data.length + 1 });
setEditing({} as any);
};
const openEdit = (pp: SettingPostprocessing) => {
setIsNew(false);
setFilterJson(pp.FilterJson || { combinator: 'AND', rules: [] });
form.setFieldsValue({ Name: pp.Name, IsActive: pp.IsActive, NoFurther: pp.NoFurther, Order: pp.Order });
setEditing(pp);
};
const handleSave = async () => {
const values = await form.validateFields();
const payload = { ...values, FilterJson: filterJson };
if (isNew) {
await settingsApi.createPostprocessing(payload);
message.success('Regel erstellt');
} else if (editing) {
await settingsApi.updatePostprocessing(editing.Id, payload);
message.success('Gespeichert');
}
setEditing(null);
load();
};
const handleToggle = async (id: number, active: boolean) => {
await settingsApi.updatePostprocessing(id, { IsActive: active });
load();
};
const handleDelete = async (id: number) => {
await settingsApi.deletePostprocessing(id);
message.success('Gelöscht');
load();
};
const handleDuplicate = async (id: number) => {
await settingsApi.duplicatePostprocessing(id);
message.success('Regel dupliziert');
load();
};
const columns: ColumnsType<SettingPostprocessing> = [
{ title: '#', dataIndex: 'Order', key: 'order', width: 50 },
{ title: 'Name', dataIndex: 'Name', key: 'name' },
{
title: 'Bedingungen',
key: 'conditions',
render: (_, r) => <Tag>{r.FilterJson?.rules?.length || 0}</Tag>,
},
{
title: 'Aktiv',
key: 'active',
width: 70,
render: (_, r) => <Switch size="small" checked={r.IsActive} onChange={v => handleToggle(r.Id, v)} />,
},
{
title: 'Stop',
dataIndex: 'NoFurther',
key: 'stop',
width: 50,
render: (v: boolean) => v ? <Tag color="red">Stop</Tag> : null,
},
{
title: '',
key: 'actions',
width: 130,
render: (_, rec) => (
<Space>
<Button icon={<EditOutlined />} size="small" onClick={() => openEdit(rec)} />
<Button icon={<CopyOutlined />} size="small" onClick={() => handleDuplicate(rec.Id)} />
<Popconfirm title="Wirklich löschen?" onConfirm={() => handleDelete(rec.Id)}>
<Button danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
Regel hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Modal
title={isNew ? 'Neue Postprocessing-Regel' : 'Regel bearbeiten'}
open={!!editing}
onOk={handleSave}
onCancel={() => setEditing(null)}
width={900}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
>
<Form form={form} layout="vertical">
<Space style={{ width: '100%' }}>
<Form.Item name="Name" label="Name" rules={[{ required: true }]} style={{ minWidth: 300 }}>
<Input />
</Form.Item>
<Form.Item name="Order" label="Reihenfolge"><InputNumber min={1} /></Form.Item>
<Form.Item name="IsActive" label="Aktiv" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="NoFurther" label="Stop nach Treffer" valuePropName="checked"><Switch /></Form.Item>
</Space>
</Form>
<Divider>Filter</Divider>
<FilterBuilder
value={filterJson}
onChange={setFilterJson}
tags={tags}
docTypes={docTypes}
correspondents={correspondents}
customFields={customFields}
/>
{editing && !isNew && (
<>
<Divider />
<ActionEditor
ppId={editing.Id}
tags={tags}
customFields={customFields}
exportTargets={exportTargets}
/>
</>
)}
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Export-Ziele Tab
// ═══════════════════════════════════════════════════════════════════
function ExportTargetsTab() {
const [data, setData] = useState<SettingExportTarget[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<SettingExportTarget | null>(null);
const [isNew, setIsNew] = useState(false);
const [form] = Form.useForm();
const [testing, setTesting] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
try { setData(await settingsApi.getExportTargets()); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const openNew = () => {
setIsNew(true);
form.resetFields();
form.setFieldsValue({ Protocol: 'ftp', IsActive: true });
setEditing({} as any);
};
const openEdit = (t: SettingExportTarget) => {
setIsNew(false);
form.setFieldsValue(t);
setEditing(t);
};
const handleSave = async () => {
const values = await form.validateFields();
if (isNew) {
await settingsApi.createExportTarget(values);
message.success('Export-Ziel erstellt');
} else if (editing) {
await settingsApi.updateExportTarget(editing.Id, values);
message.success('Gespeichert');
}
setEditing(null);
load();
};
const handleDelete = async (id: number) => {
await settingsApi.deleteExportTarget(id);
message.success('Gelöscht');
load();
};
const handleTest = async (id: number) => {
setTesting(id);
try {
const result = await settingsApi.testExportTarget(id);
if (result.success) {
message.success(result.message);
} else {
message.error(result.message);
}
} catch {
message.error('Verbindungstest fehlgeschlagen');
} finally {
setTesting(null);
}
};
const columns: ColumnsType<SettingExportTarget> = [
{ title: 'Name', dataIndex: 'Name', key: 'name' },
{ title: 'Protokoll', dataIndex: 'Protocol', key: 'protocol', render: v => <Tag>{v.toUpperCase()}</Tag> },
{ title: 'Host', dataIndex: 'Host', key: 'host' },
{ title: 'Pfad', dataIndex: 'RemotePath', key: 'path', ellipsis: true },
{ title: 'Aktiv', key: 'active', width: 60, render: (_, r) => <Switch size="small" checked={r.IsActive} disabled /> },
{
title: '', key: 'actions', width: 160,
render: (_, rec) => (
<Space>
<Button size="small" loading={testing === rec.Id} onClick={() => handleTest(rec.Id)}>
Test
</Button>
<Button icon={<EditOutlined />} size="small" onClick={() => openEdit(rec)} />
<Popconfirm title="Löschen?" onConfirm={() => handleDelete(rec.Id)}>
<Button danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
Export-Ziel hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Modal title={isNew ? 'Neues Export-Ziel' : 'Export-Ziel bearbeiten'} open={!!editing} onOk={handleSave} onCancel={() => setEditing(null)}>
<Form form={form} layout="vertical">
<Form.Item name="Name" label="Name" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="Protocol" label="Protokoll" rules={[{ required: true }]}>
<Select>
<Select.Option value="ftp">FTP</Select.Option>
<Select.Option value="webdav">WebDAV</Select.Option>
</Select>
</Form.Item>
<Form.Item name="Host" label="Host" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="Port" label="Port"><InputNumber style={{ width: '100%' }} /></Form.Item>
<Form.Item name="Username" label="Benutzername"><Input /></Form.Item>
<Form.Item name="Password" label="Passwort"><Input.Password /></Form.Item>
<Form.Item name="RemotePath" label="Remote-Pfad"><Input /></Form.Item>
<Form.Item name="IsActive" label="Aktiv" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Postprocessing Logs Tab
// ═══════════════════════════════════════════════════════════════════
function PostprocessingLogsTab() {
const [data, setData] = useState<SettingPostprocessingLog[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 50;
const load = useCallback(async () => {
setLoading(true);
try {
const result = await settingsApi.getPostprocessingLogs(pageSize, (page - 1) * pageSize);
setData(result.data);
setTotal(result.total);
} finally { setLoading(false); }
}, [page]);
useEffect(() => { load(); }, [load]);
const columns: ColumnsType<SettingPostprocessingLog> = [
{ title: 'Zeitpunkt', dataIndex: 'CreatedAt', key: 'time', width: 180, render: v => new Date(v).toLocaleString('de-DE') },
{ title: 'Dokument', dataIndex: 'DocumentId', key: 'doc', width: 100 },
{ title: 'Regel', dataIndex: 'PostprocessingId', key: 'rule', width: 80 },
{ title: 'Aktion', dataIndex: 'ActionId', key: 'action', width: 80, render: v => v || '—' },
{
title: 'Status', dataIndex: 'Status', key: 'status', width: 100,
render: (v: string) => (
<Badge status={v === 'success' ? 'success' : v === 'error' ? 'error' : 'default'} text={v} />
),
},
{ title: 'Nachricht', dataIndex: 'Message', key: 'msg', ellipsis: true },
];
return (
<Table
dataSource={data}
columns={columns}
loading={loading}
rowKey="Id"
size="small"
pagination={{
current: page,
total,
pageSize,
onChange: setPage,
showTotal: t => `${t} Einträge`,
}}
/>
);
}
// ═══════════════════════════════════════════════════════════════════
// API Keys Tab
// ═══════════════════════════════════════════════════════════════════
function ApiKeysTab() {
const [data, setData] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [newKey, setNewKey] = useState<string | null>(null);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
const keys = await apiKeysApi.getApiKeys();
setData(keys);
} catch (err) {
console.error('Error loading API keys:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleCreate = async () => {
const values = await form.validateFields();
try {
const result = await apiKeysApi.createApiKey(values.name);
setNewKey(result.plainKey);
setCreateModalOpen(false);
setSuccessModalOpen(true);
form.resetFields();
load();
} catch (err) {
message.error('Fehler beim Erstellen des API-Keys');
}
};
const handleDelete = async (id: string) => {
try {
await apiKeysApi.deleteApiKey(id);
message.success('API-Key gelöscht');
load();
} catch (err) {
message.error('Fehler beim Löschen des API-Keys');
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
message.success('In die Zwischenablage kopiert');
};
const columns: ColumnsType<ApiKey> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Präfix', dataIndex: 'keyPrefix', key: 'prefix', render: (v) => <Tag>{v}</Tag> },
{
title: 'Erstellt am',
dataIndex: 'createdAt',
key: 'createdAt',
render: (v) => dayjs(v).format('DD.MM.YYYY HH:mm')
},
{
title: 'Zuletzt genutzt',
dataIndex: 'lastUsedAt',
key: 'lastUsedAt',
render: (v) => v ? dayjs(v).format('DD.MM.YYYY HH:mm') : 'Nie'
},
{
title: '',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm title="API-Key wirklich löschen? Er wird sofort ungültig." onConfirm={() => handleDelete(record.id)}>
<Button danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
),
},
];
return (
<>
<div style={{ marginBottom: 16 }}>
<Title level={4}>API Keys</Title>
<Typography.Paragraph type="secondary">
Hier kannst du API-Keys verwalten, um externe Dienste anzubinden oder Funktionen über spezialisierte URLs aufzurufen.
</Typography.Paragraph>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
Neuen API-Key erstellen
</Button>
</div>
<Table dataSource={data} columns={columns} loading={loading} rowKey="id" size="small" pagination={false} />
<Modal
title="Neuen API-Key erstellen"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => setCreateModalOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="Anzeigename (z.B. Scanner-Dienst)"
rules={[{ required: true, message: 'Bitte gib einen Namen an' }]}
>
<Input placeholder="Welchen Zweck hat dieser Key?" />
</Form.Item>
</Form>
</Modal>
<Modal
title="API-Key erfolgreich erstellt"
open={successModalOpen}
footer={[
<Button key="close" type="primary" onClick={() => setSuccessModalOpen(false)}>
Ich habe den Key kopiert
</Button>
]}
closable={false}
maskClosable={false}
>
<Typography.Paragraph type="danger">
<strong>Wichtig:</strong> Kopiere den API-Key jetzt. Er wird aus Sicherheitsgründen **nie wieder** im Klartext angezeigt!
</Typography.Paragraph>
<div style={{
background: '#f5f5f5',
padding: '12px',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: 'monospace',
border: '1px solid #d9d9d9'
}}>
<span>{newKey}</span>
<Button
icon={<CopyOutlined />}
onClick={() => newKey && copyToClipboard(newKey)}
>
Kopieren
</Button>
</div>
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Correspondents Tab
// ═══════════════════════════════════════════════════════════════════
function CorrespondentsTab() {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [searchText, setSearchText] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [syncLoading, setSyncLoading] = useState(false);
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const [conflictsModalOpen, setConflictsModalOpen] = useState(false);
const [conflictSelections, setConflictSelections] = useState<Record<number, number>>({});
const [mergeLoading, setMergeLoading] = useState(false);
const [form] = Form.useForm();
const load = useCallback(async (page: number, size: number, search?: string) => {
setLoading(true);
try {
const result = await settingsApi.getCorrespondents(page, size, search);
setData(result.data);
setTotal(result.total);
} catch (err) {
message.error('Fehler beim Laden der Korrespondenten');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(currentPage, pageSize, searchText);
}, [currentPage, pageSize, searchText, load]);
const handleCreate = async () => {
const values = await form.validateFields();
try {
await settingsApi.createCorrespondent({ name: values.name });
message.success('Korrespondent erstellt');
setCreateModalOpen(false);
form.resetFields();
load(currentPage, pageSize, searchText);
} catch (err) {
message.error('Fehler beim Erstellen des Korrespondenten');
}
};
const handleSync = async () => {
setSyncLoading(true);
try {
const result = await agrarmonitorApi.syncCorrespondents();
const parts: string[] = [
`${result.matched} zugeordnet`,
`${result.unmatched} ohne Treffer`,
];
if (result.autoMerged > 0) parts.push(`${result.autoMerged} automatisch bereinigt`);
message.success(`Abgleich abgeschlossen: ${parts.join(', ')} (${result.total} gesamt)`);
load(currentPage, pageSize, searchText);
if (result.conflicts.length > 0) {
setConflicts(result.conflicts);
setConflictSelections(
Object.fromEntries(result.conflicts.map(c => [c.agrarmonitorId, c.correspondents[0].id]))
);
setConflictsModalOpen(true);
}
} catch (err) {
message.error('Fehler beim Agrarmonitor-Abgleich');
} finally {
setSyncLoading(false);
}
};
const handleMergeConflicts = async () => {
setMergeLoading(true);
try {
for (const conflict of conflicts) {
const keepId = conflictSelections[conflict.agrarmonitorId];
if (keepId === undefined) continue;
const deleteIds = conflict.correspondents.map(c => c.id).filter(id => id !== keepId);
for (const deleteId of deleteIds) {
await agrarmonitorApi.mergeCorrespondents(keepId, deleteId);
}
}
message.success(`${conflicts.length} Konflikt(e) aufgelöst`);
setConflictsModalOpen(false);
setConflicts([]);
load(currentPage, pageSize, searchText);
} catch (err) {
message.error('Fehler beim Zusammenführen');
} finally {
setMergeLoading(false);
}
};
const updateAgrarmonitorId = async (id: number, val: number | null) => {
try {
await settingsApi.updateCorrespondentSetting(id, val);
message.success('ID aktualisiert');
// No full reload needed to keep focus, but sync might be better.
// We update local state to reflect change immediately without glitchy reload
setData(prev => prev.map(item => item.id === id ? { ...item, agrarmonitorId: val } : item));
} catch (err) {
message.error('Fehler beim Aktualisieren der ID');
}
};
const columns: ColumnsType<any> = [
{ title: 'Name', dataIndex: 'name', key: 'name', sorter: false }, // Sorting is tricky with server-side pagination unless implemented in backend
{ title: 'Paperless ID', dataIndex: 'id', key: 'id', width: 120 },
{
title: 'Agrarmonitor ID',
dataIndex: 'agrarmonitorId',
key: 'agrarmonitorId',
width: 200,
render: (val, record) => (
<InputNumber
value={val}
placeholder="AM ID"
style={{ width: '100%' }}
onBlur={(e) => {
const v = e.target.value ? parseInt(e.target.value.replace(/\D/g, ''), 10) : null;
if (v !== val) updateAgrarmonitorId(record.id, v);
}}
controls={false}
/>
)
},
{
title: 'Dokumente',
dataIndex: 'document_count',
key: 'docCount',
width: 100,
render: (v) => <Badge count={v} showZero color="#108ee9" />
},
];
return (
<>
<div style={{ marginBottom: 16 }}>
<Title level={4}>Korrespondenten</Title>
<Typography.Paragraph type="secondary">
Hier kannst du Korrespondenten verwalten und deren Agrarmonitor-ID hinterlegen.
Neue Korrespondenten werden direkt in Paperless erstellt.
</Typography.Paragraph>
<Space style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Input.Search
placeholder="Suchen..."
allowClear
onSearch={(v) => { setSearchText(v); setCurrentPage(1); }}
style={{ width: 300 }}
/>
<Space>
<Button icon={<GlobalOutlined />} loading={syncLoading} onClick={handleSync}>
Agrarmonitor-Abgleich
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
Neuen Korrespondenten anlegen
</Button>
</Space>
</Space>
</div>
<Table
dataSource={data}
columns={columns}
loading={loading}
rowKey="id"
size="small"
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
}
}}
/>
<Modal
title="Neuen Korrespondenten anlegen"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => setCreateModalOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="Name des Korrespondenten"
rules={[{ required: true, message: 'Bitte gib einen Namen an' }]}
>
<Input placeholder="Vollständiger Name" />
</Form.Item>
</Form>
</Modal>
<Modal
title="Doppelte Korrespondenten Namenskonflikt"
open={conflictsModalOpen}
onOk={handleMergeConflicts}
onCancel={() => setConflictsModalOpen(false)}
okText="Zusammenführen"
cancelText="Abbrechen"
confirmLoading={mergeLoading}
width={560}
>
<Alert
type="warning"
style={{ marginBottom: 16 }}
message="Mehrere Korrespondenten haben dieselbe Agrarmonitor-ID, aber unterschiedliche Namen. Wähle jeweils den Namen, der behalten werden soll. Alle Dokumente des anderen werden übertragen, der leere Eintrag wird gelöscht."
/>
{conflicts.map((conflict, idx) => (
<div key={conflict.agrarmonitorId} style={{ marginBottom: idx < conflicts.length - 1 ? 24 : 0 }}>
<div style={{ marginBottom: 8, color: '#888', fontSize: 12 }}>
Agrarmonitor-ID: {conflict.agrarmonitorId}
</div>
<Radio.Group
value={conflictSelections[conflict.agrarmonitorId]}
onChange={e => setConflictSelections(prev => ({ ...prev, [conflict.agrarmonitorId]: e.target.value as number }))}
>
<Space direction="vertical">
{conflict.correspondents.map(c => (
<Radio key={c.id} value={c.id}>
<strong>{c.name}</strong>
<span style={{ marginLeft: 8, color: '#888' }}>({c.documentCount} Dokumente)</span>
</Radio>
))}
</Space>
</Radio.Group>
</div>
))}
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Inbox-Aktionen pro Barcode-Vorlage (Sub-Editor)
// ═══════════════════════════════════════════════════════════════════
const VARIABLE_HINT =
'Platzhalter: {datum} {jahr} {monat} {tag} {zeitstempel}, {barcode} (gesamter Barcode-Wert), {barcode.<Gruppe>} für Named Capture Groups der Regex (z.B. (?<Gruppe>\\d+))';
function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) {
const [actions, setActions] = useState<InboxAction[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<InboxAction | null>(null);
const [isNew, setIsNew] = useState(false);
const [actionType, setActionType] = useState<InboxActionType>('MAIL');
const [tags, setTags] = useState<PaperlessTag[]>([]);
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [customFields, setCustomFields] = useState<PaperlessCustomField[]>([]);
const [users, setUsers] = useState<{ id: number; username: string }[]>([]);
const [exportTargets, setExportTargets] = useState<SettingExportTarget[]>([]);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
setActions(await settingsApi.listInboxActionsForTemplate(templateId));
} finally {
setLoading(false);
}
}, [templateId]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
Promise.all([
paperlessApi.getTags().catch(() => []),
paperlessApi.getDocumentTypes().catch(() => []),
paperlessApi.getCorrespondents().catch(() => []),
paperlessApi.getCustomFields().catch(() => []),
paperlessApi.getUsers().catch(() => []),
settingsApi.getExportTargets().catch(() => []),
]).then(([t, dt, c, cf, u, et]) => {
setTags(t);
setDocTypes(dt);
setCorrespondents(c);
setCustomFields(cf);
setUsers(u as { id: number; username: string }[]);
setExportTargets(et);
});
}, []);
const openNew = () => {
setIsNew(true);
setEditing({} as InboxAction);
setActionType('MAIL');
form.resetFields();
form.setFieldsValue({ ActionType: 'MAIL', IsActive: true, Order: actions.length + 1 });
};
const openEdit = (a: InboxAction) => {
setIsNew(false);
setEditing(a);
setActionType(a.ActionType);
form.setFieldsValue({ ActionType: a.ActionType, IsActive: a.IsActive, Order: a.Order, ...a.Content });
};
const buildContent = (values: any): Record<string, any> => {
const { ActionType: _, IsActive: __, Order: ___, ...rest } = values;
return rest;
};
const handleDelete = async (id: number) => {
await settingsApi.deleteInboxAction(id);
message.success('Gelöscht');
load();
};
useEffect(() => {
if (!editing) return;
if (isNew) { form.setFieldsValue({ customFieldsList: [] }); return; }
const cf = (editing.Content?.customFields ?? {}) as Record<string, string>;
form.setFieldsValue({
customFieldsList: Object.entries(cf).map(([fieldId, value]) => ({ fieldId: Number(fieldId), value })),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, isNew]);
const handleSaveWrapped = async () => {
const values = await form.validateFields();
if (values.ActionType === 'PAPERLESS' && Array.isArray(values.customFieldsList)) {
const obj: Record<string, string> = {};
for (const row of values.customFieldsList) {
if (row?.fieldId !== undefined && row?.fieldId !== null) obj[String(row.fieldId)] = row.value ?? '';
}
values.customFields = obj;
delete values.customFieldsList;
}
const payload = {
ActionType: values.ActionType as InboxActionType,
Content: buildContent(values),
Order: values.Order,
IsActive: values.IsActive,
};
try {
if (isNew) {
await settingsApi.createInboxActionForTemplate(templateId, payload);
message.success('Aktion erstellt');
} else if (editing) {
await settingsApi.updateInboxAction(editing.Id, payload);
message.success('Aktion gespeichert');
}
setEditing(null);
load();
} catch (err: any) {
message.error(err?.response?.data?.message ?? 'Speichern fehlgeschlagen');
}
};
const renderContentFields = () => {
switch (actionType) {
case 'MAIL':
return (
<>
<Form.Item name="to" label="Empfänger" rules={[{ required: true }]}>
<Input placeholder="empfaenger@example.com" />
</Form.Item>
<Form.Item name="subject" label="Betreff" tooltip={VARIABLE_HINT}>
<Input placeholder="z.B. Beleg {Beleg}" />
</Form.Item>
<Form.Item name="body" label="Text" tooltip={VARIABLE_HINT}>
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="filenameTemplate" label="Dateiname (Anhang)" tooltip={VARIABLE_HINT}>
<Input placeholder="z.B. {datum}_{Beleg}" />
</Form.Item>
</>
);
case 'EXPORT':
return (
<>
<Form.Item name="exportTargetId" label="Export-Ziel" rules={[{ required: true }]}>
<Select>
{exportTargets.filter((t) => t.IsActive).map((t) => (
<Select.Option key={t.Id} value={t.Id}>{t.Name} ({t.Protocol})</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="filenameTemplate" label="Dateiname" tooltip={VARIABLE_HINT}>
<Input placeholder="z.B. {datum}_{Beleg}" />
</Form.Item>
</>
);
case 'PAPERLESS':
return (
<>
<Form.Item
name="interneBelegnummer"
label="Interne Belegnummer"
required
tooltip={VARIABLE_HINT}
extra="Pflichtfeld. Wird zur Duplikatprüfung verwendet."
>
<Input placeholder="{barcode} oder {barcode.nr}" />
</Form.Item>
<Form.Item
name="asn"
label="ASN"
tooltip={VARIABLE_HINT}
extra="Optional. Archivnummer (archive_serial_number) des Dokuments. Überschreibt die aus der Internen Belegnummer abgeleitete ASN."
>
<Input placeholder="{barcode.asn} oder feste Zahl" />
</Form.Item>
<Form.Item
name="eingangsdatum"
label="Eingangsdatum"
tooltip={VARIABLE_HINT}
extra="Optional. Datum des Dokumenteneingangs (YYYY-MM-DD oder TT.MM.JJJJ). Leer = heutiges Datum."
>
<Input placeholder="{barcode.datum} oder 23.04.2026" />
</Form.Item>
<Form.Item name="title" label="Titel" tooltip={VARIABLE_HINT}>
<Input placeholder="z.B. Beleg {barcode} vom {datum}" />
</Form.Item>
<Form.Item name="documentType" label="Dokumenttyp">
<Select allowClear showSearch optionFilterProp="children">
{docTypes.map((dt) => (
<Select.Option key={dt.id} value={dt.id}>{dt.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="correspondent" label="Korrespondent">
<Select allowClear showSearch optionFilterProp="children">
{correspondents.map((c) => (
<Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="owner" label="Besitzer">
<Select allowClear showSearch optionFilterProp="children">
{users.map((u) => (
<Select.Option key={u.id} value={u.id}>{u.username}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="tags" label="Tags">
<Select mode="multiple" allowClear showSearch optionFilterProp="children">
{tags.map((t) => (
<Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="Custom Fields" tooltip={VARIABLE_HINT}>
<Form.List name="customFieldsList">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Space key={key} align="baseline" style={{ display: 'flex', marginBottom: 4 }}>
<Form.Item {...rest} name={[name, 'fieldId']} rules={[{ required: true }]} noStyle>
<Select style={{ width: 200 }} placeholder="Feld" showSearch optionFilterProp="children">
{customFields.map((cf) => (
<Select.Option key={cf.id} value={cf.id}>{cf.name}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item {...rest} name={[name, 'value']} noStyle>
<Input placeholder="Wert / Template" style={{ width: 260 }} />
</Form.Item>
<Button icon={<MinusCircleOutlined />} onClick={() => remove(name)} />
</Space>
))}
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined />}>Custom Field</Button>
</>
)}
</Form.List>
</Form.Item>
</>
);
default:
return null;
}
};
const columns: ColumnsType<InboxAction> = [
{ title: '#', dataIndex: 'Order', key: 'order', width: 60 },
{ title: 'Typ', key: 'type', render: (_, r) => <Tag color="blue">{INBOX_ACTION_LABELS[r.ActionType]}</Tag> },
{ title: 'Aktiv', key: 'active', width: 70, render: (_, r) => <Switch size="small" checked={r.IsActive} disabled /> },
{
title: '', key: 'edit', width: 110,
render: (_, r) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
<Popconfirm title="Löschen?" onConfirm={() => handleDelete(r.Id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 8 }}>
<h4 style={{ margin: 0 }}>Weiterverarbeitungs-Aktionen</h4>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button>
</Space>
<Table<InboxAction> rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" />
<Modal
title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'}
open={!!editing}
onOk={handleSaveWrapped}
onCancel={() => setEditing(null)}
okText="Speichern"
cancelText="Abbrechen"
width={680}
>
<Form form={form} layout="vertical">
<Space style={{ width: '100%' }}>
<Form.Item name="ActionType" label="Aktionstyp" rules={[{ required: true }]}>
<Select style={{ width: 220 }} onChange={(v) => setActionType(v)}>
{(Object.keys(INBOX_ACTION_LABELS) as InboxActionType[]).map((k) => (
<Select.Option key={k} value={k}>{INBOX_ACTION_LABELS[k]}</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="Order" label="Reihenfolge"><InputNumber min={1} /></Form.Item>
<Form.Item name="IsActive" label="Aktiv" valuePropName="checked"><Switch /></Form.Item>
</Space>
<Divider />
{renderContentFields()}
</Form>
</Modal>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Eingangsdokumentarten Tab (ehemals Barcode-Vorlagen)
// ═══════════════════════════════════════════════════════════════════
function LabelElementRow({ form, listName, remove }: { form: FormInstance; listName: number; remove: (n: number) => void }) {
const type = Form.useWatch(['LabelLayout', listName, 'type'], form);
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>
{type === 'text' && (
<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>
)}
{type === 'qr' && (
<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>
)}
{type === 'line' && (
<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>
)}
</Card>
);
}
function BarcodeTemplatesTab() {
const [data, setData] = useState<BarcodeTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<BarcodeTemplate | null>(null);
const [isNew, setIsNew] = useState(false);
const [testValue, setTestValue] = useState('');
const [testPrinting, setTestPrinting] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [form] = Form.useForm();
const load = useCallback(async () => {
setLoading(true);
try {
setData(await barcodeTemplatesApi.list());
} catch {
message.error('Vorlagen konnten nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setIsNew(true);
setEditing(null);
setTestValue('');
form.resetFields();
form.setFieldsValue({
SplitBefore: false,
DateinameTemplate: '',
LabelEnabled: false,
LabelWidthMm: 57,
LabelHeightMm: 32,
LabelInputFields: [],
LabelLayout: [],
});
setModalOpen(true);
};
const openEdit = (row: BarcodeTemplate) => {
setIsNew(false);
setEditing(row);
setTestValue('');
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);
};
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editing) {
await barcodeTemplatesApi.update(editing.Id, values);
message.success('Eingangsdokumentart aktualisiert');
} else {
await barcodeTemplatesApi.create({ ...values, Actions: [] });
message.success('Eingangsdokumentart angelegt');
}
if (isNew) setModalOpen(false);
load();
} catch (err: any) {
message.error(err?.response?.data?.message ?? 'Speichern fehlgeschlagen');
}
};
const handleTestLabel = async () => {
if (!editing) return;
setTestPrinting(true);
try {
const values = await form.validateFields();
await barcodeTemplatesApi.update(editing.Id, values);
const inputFields: LabelInputField[] = values.LabelInputFields ?? [];
const testFieldValues: Record<string, string> = {};
const today = new Date().toISOString().slice(0, 10);
for (const f of inputFields) {
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);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(url);
load();
} catch (err: any) {
message.error(err?.response?.data?.message ?? 'Vorschau fehlgeschlagen');
} finally {
setTestPrinting(false);
}
};
const handleDelete = async (id: number) => {
await barcodeTemplatesApi.remove(id);
message.success('Vorlage gelöscht');
load();
};
const columns: ColumnsType<BarcodeTemplate> = [
{ title: 'Name', dataIndex: 'Name', key: 'name' },
{
title: 'Regex',
dataIndex: 'Regex',
key: 'regex',
render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code>,
},
{
title: 'Trennen',
dataIndex: 'SplitBefore',
key: 'splitBefore',
width: 100,
render: (v: boolean) =>
v ? <Tag color="orange">vor Barcode</Tag> : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '',
key: 'edit',
width: 140,
render: (_, row) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)} />
<Popconfirm title="Vorlage löschen?" onConfirm={() => handleDelete(row.Id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const currentRegex = Form.useWatch('Regex', form);
let testResult: { valid: boolean; matches: boolean } | null = null;
if (currentRegex && testValue) {
try {
testResult = { valid: true, matches: new RegExp(currentRegex).test(testValue) };
} catch {
testResult = { valid: false, matches: false };
}
}
return (
<>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Neue Eingangsdokumentart
</Button>
</Space>
<Table<BarcodeTemplate>
rowKey="Id"
columns={columns}
dataSource={data}
loading={loading}
pagination={false}
/>
<Modal
title={editing ? 'Eingangsdokumentart bearbeiten' : 'Neue Eingangsdokumentart'}
open={modalOpen}
onCancel={() => { setModalOpen(false); setEditing(null); }}
width={720}
footer={[
...(editing && !isNew ? [
<Button key="test" icon={<PrinterOutlined />} loading={testPrinting} onClick={handleTestLabel}>
Testetikett erstellen
</Button>,
] : []),
<Button key="cancel" onClick={() => { setModalOpen(false); setEditing(null); }}>Abbrechen</Button>,
<Button key="save" type="primary" onClick={handleSave}>Speichern</Button>,
]}
>
<Form form={form} layout="vertical">
<Form.Item name="Name" label="Name" rules={[{ required: true, message: 'Name ist erforderlich' }]}>
<Input placeholder="z. B. Lager-Etikett" />
</Form.Item>
<Form.Item
name="Regex"
label="Regex"
rules={[
{ required: true, message: 'Regex ist erforderlich' },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
try { new RegExp(value); return Promise.resolve(); }
catch { return Promise.reject(new Error('Ungültige Regex')); }
},
},
]}
extra="JavaScript-Syntax, z. B. ^\d{4}-\d{6}-\d{8}$ oder ^AM-\d{8}$"
>
<Input placeholder="^\d{4}-\d{6}-\d{8}$" />
</Form.Item>
<Form.Item label="Regex testen">
<Input
placeholder="Beispiel-Barcode-Inhalt eingeben"
value={testValue}
onChange={(e) => setTestValue(e.target.value)}
/>
{testResult && (
<div style={{ marginTop: 4, fontSize: 12 }}>
{!testResult.valid ? (
<Tag color="red">Regex ungültig</Tag>
) : testResult.matches ? (
<Tag color="green">Treffer</Tag>
) : (
<Tag>Kein Treffer</Tag>
)}
</div>
)}
</Form.Item>
<Form.Item name="SplitBefore" valuePropName="checked">
<Checkbox>Vor diesem Barcode ein neues Dokument starten</Checkbox>
</Form.Item>
<Form.Item
name="DateinameTemplate"
label="Belegname"
extra="Platzhalter z. B. {barcode}, {datum}, {barcode.gruppe}. Wird als Standard-Belegname bei Export und E-Mail verwendet."
>
<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} form={form} 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 && (
<>
<Divider />
<InboxActionsForTemplateEditor templateId={editing.Id} />
</>
)}
</Modal>
<Modal
title="Etikett-Vorschau"
open={!!previewUrl}
onCancel={() => { URL.revokeObjectURL(previewUrl!); setPreviewUrl(null); }}
footer={<Button onClick={() => { URL.revokeObjectURL(previewUrl!); setPreviewUrl(null); }}>Schließen</Button>}
width={520}
>
{previewUrl && (
<div style={{ background: '#e0e0e0', padding: 24, display: 'flex', justifyContent: 'center', borderRadius: 4 }}>
<img
src={previewUrl}
alt="Etikett-Vorschau"
style={{ maxWidth: '100%', boxShadow: '0 2px 8px rgba(0,0,0,0.3)', display: 'block' }}
/>
</div>
)}
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// Agrarmonitor Tab
// ═══════════════════════════════════════════════════════════════════
function AgrarmonitorTab() {
const [form] = Form.useForm();
const [pollingForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [registering, setRegistering] = useState(false);
const [pollingConfigLoading, setPollingConfigLoading] = useState(false);
const [pollingSaving, setPollingSaving] = useState(false);
const [pollingRunning, setPollingRunning] = useState(false);
const [uploadCheckRunning, setUploadCheckRunning] = useState(false);
const [status, setStatus] = useState<AgrarmonitorStatusData | null>(null);
const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null);
const [pollingResult, setPollingResult] = useState<AgrarmonitorPollingResult | null>(null);
const [uploadCheckResult, setUploadCheckResult] = useState<AgrarmonitorPollingResult | null>(null);
const [customFields, setCustomFields] = useState<PaperlessCustomField[]>([]);
const handleLoadStatus = async () => {
setLoading(true);
setRegisterResult(null);
try {
const data = await agrarmonitorApi.getStatus();
setStatus(data);
} catch (err: any) {
const msg = err?.code === 'ECONNABORTED'
? 'Timeout Backend antwortet nicht rechtzeitig'
: (err?.response?.data?.message ?? err?.message ?? 'Netzwerkfehler');
setStatus({ connected: false, registriert: null, freigeschaltet: null, error: msg });
} finally {
setLoading(false);
}
};
const handleRegister = async () => {
const values = await form.validateFields();
setRegistering(true);
setRegisterResult(null);
try {
const result = await agrarmonitorApi.registerDevice(values.pcName, values.agrarmonitorId);
setRegisterResult(result);
if (result.success) {
message.success('Gerät erfolgreich registriert');
await handleLoadStatus();
}
} catch {
setRegisterResult({ success: false, message: 'Registrierung fehlgeschlagen' });
} finally {
setRegistering(false);
}
};
const handleLoadPollingConfig = useCallback(async () => {
setPollingConfigLoading(true);
try {
const cfg = await agrarmonitorApi.getPollingConfig();
pollingForm.setFieldsValue(cfg);
} catch {
message.error('Polling-Konfiguration konnte nicht geladen werden');
} finally {
setPollingConfigLoading(false);
}
}, [pollingForm]);
useEffect(() => {
handleLoadPollingConfig();
paperlessApi.getCustomFields().then(setCustomFields).catch(() => {});
}, [handleLoadPollingConfig]);
const handleSavePollingConfig = async () => {
const values = await pollingForm.validateFields() as AgrarmonitorPollingConfig;
setPollingSaving(true);
try {
await agrarmonitorApi.updatePollingConfig(values);
message.success('Konfiguration gespeichert');
} catch {
message.error('Speichern fehlgeschlagen');
} finally {
setPollingSaving(false);
}
};
const handleRunPolling = async () => {
setPollingRunning(true);
setPollingResult(null);
try {
const result = await agrarmonitorApi.runPolling();
setPollingResult(result);
} catch {
message.error('Polling fehlgeschlagen');
} finally {
setPollingRunning(false);
}
};
const handleProcessUploads = async () => {
setUploadCheckRunning(true);
setUploadCheckResult(null);
try {
const result = await agrarmonitorApi.processUploads();
setUploadCheckResult(result);
} catch {
message.error('Upload-Check fehlgeschlagen');
} finally {
setUploadCheckRunning(false);
}
};
const renderStatusTag = (value: boolean | null, labelTrue: string, labelFalse: string) => {
if (value === null) return <Tag></Tag>;
return value
? <Tag color="success">{labelTrue}</Tag>
: <Tag color="error">{labelFalse}</Tag>;
};
return (
<div style={{ maxWidth: 600 }}>
<Typography.Title level={4}>Agrarmonitor</Typography.Title>
<Typography.Paragraph type="secondary">
Verbindungsstatus und Geräte-Registrierung für die Agrarmonitor-Schnittstelle.
Zugangsdaten werden in der <code>.env</code> konfiguriert.
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Button loading={loading} onClick={handleLoadStatus}>
Status abrufen
</Button>
</div>
{status && (
<Card size="small" title="Status">
<Space direction="vertical">
<div>
<strong>Verbindung: </strong>
{status.connected
? <Tag color="success">Verbunden</Tag>
: <Tag color="error">Nicht verbunden</Tag>}
</div>
<div>
<strong>Registriert: </strong>
{renderStatusTag(status.registriert, 'Ja', 'Nein')}
</div>
<div>
<strong>Freigeschaltet: </strong>
{renderStatusTag(status.freigeschaltet, 'Ja', 'Nein')}
</div>
{status.error && (
<div style={{ color: '#ff4d4f' }}>{status.error}</div>
)}
</Space>
</Card>
)}
{status?.registriert === false && (
<Card size="small" title="Gerät registrieren">
<Form form={form} layout="vertical">
<Form.Item
name="pcName"
label="PC-Name"
rules={[{ required: true, message: 'Bitte PC-Name eingeben' }]}
>
<Input placeholder="BUERO-PC-01" />
</Form.Item>
<Form.Item
name="agrarmonitorId"
label="Agrarmonitor-ID / Firma"
rules={[{ required: true, message: 'Bitte Agrarmonitor-ID eingeben' }]}
>
<Input placeholder="Agrarmonitor-ID" />
</Form.Item>
<Button type="primary" loading={registering} onClick={handleRegister}>
Gerät registrieren
</Button>
</Form>
{registerResult && (
<div style={{ marginTop: 12 }}>
<Tag color={registerResult.success ? 'success' : 'error'}>
{registerResult.message}
</Tag>
</div>
)}
</Card>
)}
<Card size="small" title="Polling-Konfiguration" loading={pollingConfigLoading}>
<Form form={pollingForm} layout="vertical">
<Form.Item
name="tagFertig"
label="Tag-ID: Fertig in Agrarmonitor"
rules={[{ required: true, message: 'Pflichtfeld' }]}
>
<Input placeholder="4" style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="tagVerbucht"
label="Tag-ID: Verbucht"
rules={[{ required: true, message: 'Pflichtfeld' }]}
>
<Input placeholder="9" style={{ width: 120 }} />
</Form.Item>
<Form.Item name="tagHochgeladen" label="Tag-ID: Hochgeladen in Agrarmonitor">
<Input placeholder="3" style={{ width: 120 }} />
</Form.Item>
<Form.Item name="tagManuell" label="Tag-ID: Manuell bearbeiten (bei fehlendem AM-Beleg)">
<Input placeholder="" style={{ width: 120 }} />
</Form.Item>
<Form.Item name="linkField" label="Custom Field: Agrarmonitor-Link">
<Select allowClear placeholder="Kein Feld ausgewählt" style={{ width: 280 }}>
{customFields.map(f => (
<Select.Option key={f.id} value={String(f.id)}>{f.id}: {f.name}</Select.Option>
))}
</Select>
</Form.Item>
<Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}>
Speichern
</Button>
</Form>
</Card>
<Card size="small" title="Polling ausführen">
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={pollingRunning} onClick={handleRunPolling}>
Jetzt ausführen
</Button>
{pollingResult && (
<div>
<Tag color="blue">{pollingResult.processed} verarbeitet</Tag>
<Tag color="success">{pollingResult.updated} aktualisiert</Tag>
<Tag>{pollingResult.skipped} übersprungen</Tag>
{pollingResult.errors.length > 0 && (
<Tag color="error">{pollingResult.errors.length} Fehler</Tag>
)}
{pollingResult.errors.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{pollingResult.errors.map((e, i) => (
<li key={i} style={{ color: '#ff4d4f' }}>{e}</li>
))}
</ul>
)}
</div>
)}
</Space>
</Card>
<Card size="small" title="Dokumenten-Verarbeitung">
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig",
sobald sie im Agrarmonitor-Buchungssystem erscheinen.
</Typography.Text>
<Space direction="vertical" style={{ width: '100%' }}>
<Button loading={uploadCheckRunning} onClick={handleProcessUploads}>
Jetzt prüfen
</Button>
{uploadCheckResult && (
<div>
<Tag color="blue">{uploadCheckResult.processed} geprüft</Tag>
<Tag color="success">{uploadCheckResult.updated} aktualisiert</Tag>
<Tag>{uploadCheckResult.skipped} übersprungen</Tag>
{uploadCheckResult.errors.length > 0 && (
<Tag color="error">{uploadCheckResult.errors.length} Fehler</Tag>
)}
{uploadCheckResult.errors.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{uploadCheckResult.errors.map((e, i) => (
<li key={i} style={{ color: '#ff4d4f' }}>{e}</li>
))}
</ul>
)}
</div>
)}
</Space>
</Card>
</Space>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Steuertags Tab
// ═══════════════════════════════════════════════════════════════════
function SteuertagsTab() {
const [tags, setTags] = useState<PaperlessTag[]>([]);
const [selected, setSelected] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [fetchedTags, steuertagIds] = await Promise.all([
paperlessApi.getTags(),
settingsApi.getSteuertagIds(),
]);
setTags(fetchedTags);
setSelected(steuertagIds);
} catch {
message.error('Tags konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleSave = async () => {
setSaving(true);
try {
await settingsApi.updateSteuertagIds(selected);
message.success('Steuertags gespeichert.');
} catch {
message.error('Steuertags konnten nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
return (
<div>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Steuertags"
description="Als Steuertag markierte Tags dienen der Workflow-Steuerung (z. B. Fertig/Nicht fertig, manuell bearbeiten) und werden NICHT als bearbeitbare Chips im Dokument angezeigt. Alle übrigen Tags gelten als inhaltliche Tags und erscheinen unter dem Titel sowie als Auswahlfeld im Bearbeiten-Dialog."
/>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
loading={loading}
style={{ width: '100%', maxWidth: 700 }}
placeholder="Steuertags auswählen"
value={selected}
onChange={setSelected}
options={tags.map((t) => ({ value: t.id, label: t.name }))}
optionRender={(opt) => {
const tag = tags.find((t) => t.id === opt.value);
return tag ? (
<Tag color={tag.color} style={{ color: tag.text_color }}>{tag.name}</Tag>
) : (
opt.label
);
}}
/>
<div style={{ marginTop: 16 }}>
<Button type="primary" loading={saving} onClick={handleSave}>
Speichern
</Button>
</div>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════
// Settings Page
// ═══════════════════════════════════════════════════════════════════
export default function SettingsPage() {
return (
<div>
<Title level={3}>Einstellungen</Title>
<Card>
<Tabs
items={[
{
key: 'users',
label: <span><UserOutlined /> Benutzer & Betriebe</span>,
children: <UserClientsTab />,
},
{
key: 'doctypes',
label: <span><FileTextOutlined /> Dokumenttypen</span>,
children: <DocTypesTab />,
},
{
key: 'postprocessing',
label: <span><ThunderboltOutlined /> Postprocessing</span>,
children: <PostprocessingTab />,
},
{
key: 'barcode-templates',
label: <span><QrcodeOutlined /> Eingangsdokumentarten</span>,
children: <BarcodeTemplatesTab />,
},
{
key: 'correspondents',
label: <span><UserOutlined /> Korrespondenten</span>,
children: <CorrespondentsTab />,
},
{
key: 'steuertags',
label: <span><TagsOutlined /> Steuertags</span>,
children: <SteuertagsTab />,
},
{
key: 'export-targets',
label: <span><CloudUploadOutlined /> Export-Ziele</span>,
children: <ExportTargetsTab />,
},
{
key: 'logs',
label: <span><HistoryOutlined /> Logs</span>,
children: <PostprocessingLogsTab />,
},
{
key: 'task-log',
label: <span><UnorderedListOutlined /> Task-Log</span>,
children: <TaskLogPage />,
},
{
key: 'api-keys',
label: <span><KeyOutlined /> API-Keys</span>,
children: <ApiKeysTab />,
},
{
key: 'agrarmonitor',
label: <span><GlobalOutlined /> Agrarmonitor</span>,
children: <AgrarmonitorTab />,
},
]}
/>
</Card>
</div>
);
}