d96e06e86d
Build and Push Multi-Platform Images / build-and-push (push) Successful in 38s
- 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>
2819 lines
107 KiB
TypeScript
2819 lines
107 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, 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>
|
||
);
|
||
}
|