Files
paperlessmanager/paperless-frontend/src/pages/SettingsPage.tsx
T
2026-05-05 14:57:26 +02:00

2039 lines
75 KiB
TypeScript

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,
} from 'antd';
import {
UserOutlined, FileTextOutlined, ThunderboltOutlined,
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
QrcodeOutlined, UnorderedListOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
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,
} from '../api/settings';
import { clientsApi, type Client } from '../api/inbox';
import { apiKeysApi, type ApiKey } from '../api/api-keys';
import {
barcodeTemplatesApi,
type BarcodeTemplate,
} from '../api/barcode-templates';
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 [loading, setLoading] = useState(true);
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); }
}, []);
useEffect(() => { load(); }, [load]);
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 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} />
<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: '',
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>
{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 [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 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 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
Neuen Korrespondenten anlegen
</Button>
</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>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// 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 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 [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: '' });
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 ?? '' });
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 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}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditing(null); }}
okText="Speichern"
cancelText="Abbrechen"
width={720}
>
<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>
</Form>
{editing && !isNew && (
<>
<Divider />
<InboxActionsForTemplateEditor templateId={editing.Id} />
</>
)}
</Modal>
</>
);
}
// ═══════════════════════════════════════════════════════════════════
// 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: '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 />,
},
]}
/>
</Card>
</div>
);
}