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 ( ); } if (cond.field === 'correspondent') { return ( ); } if (cond.field === 'tag') { return ( ); } 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 ( ); } } return onUpdate(e.target.value)} style={{ minWidth: 160 }} placeholder="Wert" />; }; return (
Alle Bedingungen dieser Gruppe müssen zutreffen {value.rules.map((rule, idx) => (
{isFilterGroup(rule) ? (
updateRule(idx, updated)} tags={tags} docTypes={docTypes} correspondents={correspondents} customFields={customFields} />
) : ( {renderValueInput(rule, val => updateRule(idx, { ...rule, value: val }))}
))}
); } // ═══════════════════════════════════════════════════════════════════ // Benutzer & Betriebe Tab // ═══════════════════════════════════════════════════════════════════ function UserClientsTab() { const [data, setData] = useState([]); const [clients, setClients] = useState([]); const [allClients, setAllClients] = useState([]); 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 = [ { title: 'Name', dataIndex: 'Name', key: 'name' }, { title: 'Agrarmonitor-BetriebId', dataIndex: 'AgrarmonitorBetriebId', key: 'betriebId', render: (val: number | null, record) => ( { 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 = [ { 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) => {r}, }, { title: '', key: 'actions', width: 80, render: (_, record) => ( handleDelete(record.Id)}> Betriebe — Agrarmonitor-Zuordnung Ordne jedem Betrieb die zugehörige Agrarmonitor-BetriebId zu. Wird beim Polling verwendet.
setModalOpen(false)}>
); } // ═══════════════════════════════════════════════════════════════════ // 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([]); const [customFields, setCustomFields] = useState([]); const [loading, setLoading] = useState(true); const [editField, setEditField] = useState(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 Absender; case 2: return Belegdatum; case 3: return Ablagenummer; case 4: const cf = customFields.find(c => c.id === record.TypeIndex); return {cf ? cf.name : `CF #${record.TypeIndex}`}; case 5: return Titel; default: return Unbekannt ({record.Type}); } }; const columns: ColumnsType = [ { title: 'Feldtyp', key: 'type', render: (_, record) => getFieldLabel(record) }, { title: 'Pflichtfeld', dataIndex: 'IsRequired', key: 'req', width: 100, render: (v, r) => handleToggle(r.Id, 'IsRequired', val)} />, }, { title: 'Pflicht (Posteingang)', dataIndex: 'IsRequiredPosteingang', key: 'reqPE', width: 140, render: (v, r) => handleToggle(r.Id, 'IsRequiredPosteingang', val)} />, }, { title: 'Sichtbar (Posteingang)', dataIndex: 'VisiblePosteingang', key: 'visPE', width: 150, render: (v, r) => handleToggle(r.Id, 'VisiblePosteingang', val)} />, }, { title: 'Hinweis', dataIndex: 'Hinweis', key: 'hinweis', ellipsis: true }, { title: '', key: 'actions', width: 100, render: (_, rec) => (
setEditField(null)} width={500} >
{fieldType === 4 && ( )}
); } // ═══════════════════════════════════════════════════════════════════ // Dokumenttypen Tab // ═══════════════════════════════════════════════════════════════════ function DocTypesTab() { const [data, setData] = useState([]); const [tags, setTags] = useState([]); const [docTypes, setDocTypes] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(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.name} : id; }; const getDocTypeName = (id: number) => { const dt = docTypes.find(d => d.id === id); return dt ? dt.name : id; }; const columns: ColumnsType = [ { 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 ? Ja : '—', }, { title: '', key: 'actions', width: 60, render: (_, rec) => (
setEditing(null)} width={900} >
{editing && (

Dokumentenfelder

)}
); } // ═══════════════════════════════════════════════════════════════════ // Action Editor (Sub-component for Postprocessing) // ═══════════════════════════════════════════════════════════════════ const ACTION_TYPE_LABELS: Record = { 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([]); const [loading, setLoading] = useState(true); const [editAction, setEditAction] = useState(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 => { 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 = [ { title: '#', dataIndex: 'Order', key: 'order', width: 50 }, { title: 'Typ', key: 'type', render: (_, r) => {ACTION_TYPE_LABELS[r.ActionType] || r.ActionType} }, { title: 'Aktiv', key: 'active', width: 60, render: (_, r) => }, { title: '', key: 'actions', width: 100, render: (_, r) => (
setEditAction(null)} width={600} >
{renderContentFields()}
); } // ═══════════════════════════════════════════════════════════════════ // Postprocessing Tab // ═══════════════════════════════════════════════════════════════════ function PostprocessingTab() { const [data, setData] = useState([]); const [tags, setTags] = useState([]); const [docTypes, setDocTypes] = useState([]); const [correspondents, setCorrespondents] = useState([]); const [customFields, setCustomFields] = useState([]); const [exportTargets, setExportTargets] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const [isNew, setIsNew] = useState(false); const [form] = Form.useForm(); const [filterJson, setFilterJson] = useState({ 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 = [ { title: '#', dataIndex: 'Order', key: 'order', width: 50 }, { title: 'Name', dataIndex: 'Name', key: 'name' }, { title: 'Bedingungen', key: 'conditions', render: (_, r) => {r.FilterJson?.rules?.length || 0}, }, { title: 'Aktiv', key: 'active', width: 70, render: (_, r) => handleToggle(r.Id, v)} />, }, { title: 'Stop', dataIndex: 'NoFurther', key: 'stop', width: 50, render: (v: boolean) => v ? Stop : null, }, { title: '', key: 'actions', width: 130, render: (_, rec) => (
setEditing(null)} width={900} styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }} >
Filter {editing && !isNew && ( <> )}
); } // ═══════════════════════════════════════════════════════════════════ // Export-Ziele Tab // ═══════════════════════════════════════════════════════════════════ function ExportTargetsTab() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const [isNew, setIsNew] = useState(false); const [form] = Form.useForm(); const [testing, setTesting] = useState(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 = [ { title: 'Name', dataIndex: 'Name', key: 'name' }, { title: 'Protokoll', dataIndex: 'Protocol', key: 'protocol', render: v => {v.toUpperCase()} }, { title: 'Host', dataIndex: 'Host', key: 'host' }, { title: 'Pfad', dataIndex: 'RemotePath', key: 'path', ellipsis: true }, { title: 'Aktiv', key: 'active', width: 60, render: (_, r) => }, { title: '', key: 'actions', width: 160, render: (_, rec) => (
setEditing(null)}>
); } // ═══════════════════════════════════════════════════════════════════ // Postprocessing Logs Tab // ═══════════════════════════════════════════════════════════════════ function PostprocessingLogsTab() { const [data, setData] = useState([]); 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 = [ { 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) => ( ), }, { title: 'Nachricht', dataIndex: 'Message', key: 'msg', ellipsis: true }, ]; return (
`${t} Einträge`, }} /> ); } // ═══════════════════════════════════════════════════════════════════ // API Keys Tab // ═══════════════════════════════════════════════════════════════════ function ApiKeysTab() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [createModalOpen, setCreateModalOpen] = useState(false); const [successModalOpen, setSuccessModalOpen] = useState(false); const [newKey, setNewKey] = useState(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 = [ { title: 'Name', dataIndex: 'name', key: 'name' }, { title: 'Präfix', dataIndex: 'keyPrefix', key: 'prefix', render: (v) => {v} }, { 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) => ( handleDelete(record.id)}>
setCreateModalOpen(false)} >
setSuccessModalOpen(false)}> Ich habe den Key kopiert ]} closable={false} maskClosable={false} > Wichtig: Kopiere den API-Key jetzt. Er wird aus Sicherheitsgründen **nie wieder** im Klartext angezeigt!
{newKey}
); } // ═══════════════════════════════════════════════════════════════════ // Correspondents Tab // ═══════════════════════════════════════════════════════════════════ function CorrespondentsTab() { const [data, setData] = useState([]); 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([]); const [conflictsModalOpen, setConflictsModalOpen] = useState(false); const [conflictSelections, setConflictSelections] = useState>({}); 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 = [ { 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) => ( { 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) => }, ]; return ( <>
Korrespondenten Hier kannst du Korrespondenten verwalten und deren Agrarmonitor-ID hinterlegen. Neue Korrespondenten werden direkt in Paperless erstellt. { setSearchText(v); setCurrentPage(1); }} style={{ width: 300 }} />
{ setCurrentPage(page); setPageSize(size); } }} /> setCreateModalOpen(false)} >
setConflictsModalOpen(false)} okText="Zusammenführen" cancelText="Abbrechen" confirmLoading={mergeLoading} width={560} > {conflicts.map((conflict, idx) => (
Agrarmonitor-ID: {conflict.agrarmonitorId}
setConflictSelections(prev => ({ ...prev, [conflict.agrarmonitorId]: e.target.value as number }))} > {conflict.correspondents.map(c => ( {c.name} ({c.documentCount} Dokumente) ))}
))}
); } // ═══════════════════════════════════════════════════════════════════ // Inbox-Aktionen pro Barcode-Vorlage (Sub-Editor) // ═══════════════════════════════════════════════════════════════════ const VARIABLE_HINT = 'Platzhalter: {datum} {jahr} {monat} {tag} {zeitstempel}, {barcode} (gesamter Barcode-Wert), {barcode.} für Named Capture Groups der Regex (z.B. (?\\d+))'; function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) { const [actions, setActions] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const [isNew, setIsNew] = useState(false); const [actionType, setActionType] = useState('MAIL'); const [tags, setTags] = useState([]); const [docTypes, setDocTypes] = useState([]); const [correspondents, setCorrespondents] = useState([]); const [customFields, setCustomFields] = useState([]); const [users, setUsers] = useState<{ id: number; username: string }[]>([]); const [exportTargets, setExportTargets] = useState([]); 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 => { 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; 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 = {}; 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 ( <> ); case 'EXPORT': return ( <> ); case 'PAPERLESS': return ( <> {(fields, { add, remove }) => ( <> {fields.map(({ key, name, ...rest }) => ( )} ); default: return null; } }; const columns: ColumnsType = [ { title: '#', dataIndex: 'Order', key: 'order', width: 60 }, { title: 'Typ', key: 'type', render: (_, r) => {INBOX_ACTION_LABELS[r.ActionType]} }, { title: 'Aktiv', key: 'active', width: 70, render: (_, r) => }, { title: '', key: 'edit', width: 110, render: (_, r) => ( rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" /> setEditing(null)} okText="Speichern" cancelText="Abbrechen" width={680} >
{renderContentFields()}
); } // ═══════════════════════════════════════════════════════════════════ // 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 ( } onClick={() => remove(listName)} />} >
)} {type === 'qr' && ( )} {type === 'line' && ( )} ); } function BarcodeTemplatesTab() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [isNew, setIsNew] = useState(false); const [testValue, setTestValue] = useState(''); const [testPrinting, setTestPrinting] = useState(false); const [previewUrl, setPreviewUrl] = useState(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 = {}; 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 = [ { title: 'Name', dataIndex: 'Name', key: 'name' }, { title: 'Regex', dataIndex: 'Regex', key: 'regex', render: (v: string) => {v}, }, { title: 'Trennen', dataIndex: 'SplitBefore', key: 'splitBefore', width: 100, render: (v: boolean) => v ? vor Barcode : , }, { title: '', key: 'edit', width: 140, render: (_, row) => ( rowKey="Id" columns={columns} dataSource={data} loading={loading} pagination={false} /> { setModalOpen(false); setEditing(null); }} width={720} footer={[ ...(editing && !isNew ? [ , ] : []), , , ]} >
{ 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}$" > setTestValue(e.target.value)} /> {testResult && (
{!testResult.valid ? ( Regex ungültig ) : testResult.matches ? ( Treffer ) : ( Kein Treffer )}
)}
Vor diesem Barcode ein neues Dokument starten Etikett prev.LabelEnabled !== curr.LabelEnabled}> {({ getFieldValue }) => getFieldValue('LabelEnabled') ? ( <>
{(fields, { add, remove }) => ( <> {fields.map(({ key, name, ...rest }) => ( 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}> 0 ? `Platzhalter: ${chipsWithNumber.join(' ')}` : undefined}> 0 ? `Platzhalter für Inhalt: ${chipsWithNumber.join(' ')}` : undefined}> {(layoutFields, { add: addEl, remove: removeEl }) => ( <> {layoutFields.map(({ key, name: elName }) => ( ))} )} ); }} ) : null } {editing && !isNew && ( <> )} { URL.revokeObjectURL(previewUrl!); setPreviewUrl(null); }} footer={} width={520} > {previewUrl && (
Etikett-Vorschau
)}
); } // ═══════════════════════════════════════════════════════════════════ // 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(null); const [registerResult, setRegisterResult] = useState<{ success: boolean; message: string } | null>(null); const [pollingResult, setPollingResult] = useState(null); const [uploadCheckResult, setUploadCheckResult] = useState(null); const [customFields, setCustomFields] = useState([]); 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 ; return value ? {labelTrue} : {labelFalse}; }; return (
Agrarmonitor Verbindungsstatus und Geräte-Registrierung für die Agrarmonitor-Schnittstelle. Zugangsdaten werden in der .env konfiguriert.
{status && (
Verbindung: {status.connected ? Verbunden : Nicht verbunden}
Registriert: {renderStatusTag(status.registriert, 'Ja', 'Nein')}
Freigeschaltet: {renderStatusTag(status.freigeschaltet, 'Ja', 'Nein')}
{status.error && (
{status.error}
)}
)} {status?.registriert === false && (
{registerResult && (
{registerResult.message}
)}
)}
{pollingResult && (
{pollingResult.processed} verarbeitet {pollingResult.updated} aktualisiert {pollingResult.skipped} übersprungen {pollingResult.errors.length > 0 && ( {pollingResult.errors.length} Fehler )} {pollingResult.errors.length > 0 && (
    {pollingResult.errors.map((e, i) => (
  • {e}
  • ))}
)}
)}
Prüft Belege mit Tag "Hochgeladen in Agrarmonitor" und setzt den Tag auf "AMfertig", sobald sie im Agrarmonitor-Buchungssystem erscheinen. {uploadCheckResult && (
{uploadCheckResult.processed} geprüft {uploadCheckResult.updated} aktualisiert {uploadCheckResult.skipped} übersprungen {uploadCheckResult.errors.length > 0 && ( {uploadCheckResult.errors.length} Fehler )} {uploadCheckResult.errors.length > 0 && (
    {uploadCheckResult.errors.map((e, i) => (
  • {e}
  • ))}
)}
)}
); } // ═══════════════════════════════════════════════════════════════════ // Steuertags Tab // ═══════════════════════════════════════════════════════════════════ function SteuertagsTab() { const [tags, setTags] = useState([]); const [selected, setSelected] = useState([]); 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 (