Files
paperlessmanager/paperless-frontend/src/components/DocumentEditModal.tsx
T
bjoernpoettker 66aeab282c
Build and Push Multi-Platform Images / build-and-push (push) Successful in 19s
Revert "fix: resolve all ESLint errors in backend and frontend"
This reverts commit 07dfd7e840.
2026-06-16 16:19:11 +02:00

591 lines
26 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd';
import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
import { clientsApi } from '../api/inbox';
import type { Client } from '../api/inbox';
import { paperlessApi } from '../api/paperless';
import type { PaperlessDocType, PaperlessCorrespondent, PaperlessTag } from '../api/paperless';
import { getEnv } from '../utils/env';
import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
import DocumentSearchModal from './DocumentSearchModal';
const { Option } = Select;
interface Props {
documentId: number | null;
document: PosteingangDocument | null;
open: boolean;
onClose: (next?: boolean) => void;
onSave: () => void;
isPosteingang?: boolean;
hasNextDocument?: boolean;
}
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [clients, setClients] = useState<Client[]>([]);
const [documentTypes, setDocumentTypes] = useState<PaperlessDocType[]>([]);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [requirements, setRequirements] = useState<DocumentRequirement[]>([]);
// Tags: alle Tags + als Steuertags markierte IDs; bearbeitbar sind nur Inhaltstags
const [allTags, setAllTags] = useState<PaperlessTag[]>([]);
const [steuertagIds, setSteuertagIds] = useState<number[]>([]);
const contentTags = allTags.filter(t => !steuertagIds.includes(t.id));
const [kontonummerMissing, setKontonummerMissing] = useState<{ correspondentId: number, nummer: string } | null>(null);
const [docTitles, setDocTitles] = useState<Record<number, string>>({});
const [searchModalOpen, setSearchModalOpen] = useState<{ field: string, reqId: number } | null>(null);
// Kontonummer state
const [kontonummern, setKontonummern] = useState<Kontonummer[]>([]);
const [kontonummernLoading, setKontonummernLoading] = useState(false);
const [newKontonummer, setNewKontonummer] = useState('');
const selectedCorrespondent = Form.useWatch('correspondent', form);
const loadKontonummern = useCallback(async (correspondentId: number) => {
setKontonummernLoading(true);
try {
const data = await posteingangApi.getKontonummern(correspondentId);
setKontonummern(data);
} catch {
setKontonummern([]);
} finally {
setKontonummernLoading(false);
}
}, []);
useEffect(() => {
if (selectedCorrespondent) {
loadKontonummern(selectedCorrespondent);
} else {
setKontonummern([]);
}
}, [selectedCorrespondent, loadKontonummern]);
const handleAddKontonummer = async () => {
const trimmed = newKontonummer.trim();
if (!trimmed || !selectedCorrespondent) return;
try {
await posteingangApi.createKontonummer({ correspondentId: selectedCorrespondent, nummer: trimmed });
message.success(`Kontonummer "${trimmed}" hinzugefügt.`);
setNewKontonummer('');
await loadKontonummern(selectedCorrespondent);
form.setFieldValue('cf_5', trimmed);
} catch {
message.error('Kontonummer konnte nicht angelegt werden.');
}
};
useEffect(() => {
const resolveLinkTitles = async () => {
const linkFields = requirements.filter(r => r.feldTyp === 'documentlink');
for (const req of linkFields) {
const fieldName = req.isCustomField ? `cf_${req.customFieldIndex}` : req.feldId;
const val = form.getFieldValue(fieldName);
if (val && !docTitles[val]) {
try {
const d = await paperlessApi.getDocument(val);
setDocTitles(prev => ({ ...prev, [val]: d.title }));
} catch (e) {
// Handle or ignore
}
}
}
};
if (open && requirements.length > 0) {
resolveLinkTitles();
}
}, [requirements, open]); // Removed docTitles to avoid loop, we check val existence anyway
useEffect(() => {
if (open) {
loadInitialData();
} else {
form.resetFields();
setRequirements([]);
setKontonummerMissing(null);
setKontonummern([]);
setNewKontonummer('');
}
}, [open]);
useEffect(() => {
if (open && document) {
let customFieldsObj: any = {};
if (document.customFields) {
document.customFields.forEach(cf => {
customFieldsObj[`cf_${cf.field}`] = cf.value;
});
}
form.setFieldsValue({
mandant: document.owner,
documentType: document.documentType,
correspondent: document.correspondent,
title: document.title,
asn: document.asn,
belegdatum: document.created ? dayjs(document.created) : null,
eingangsdatum: customFieldsObj['cf_9'] ? dayjs(customFieldsObj['cf_9']) : (document.added ? dayjs(document.added) : null),
...customFieldsObj,
});
if (document.documentType) {
fetchRequirements(document.documentType);
}
}
}, [document, form, open]);
// Tags getrennt setzen: nur Inhaltstags (Steuertags werden nicht angezeigt/bearbeitet)
useEffect(() => {
if (open && document) {
const contentTagIds = (document.tags || []).filter(id => !steuertagIds.includes(id));
form.setFieldValue('tags', contentTagIds);
}
}, [document, open, steuertagIds, form]);
const ensureCorrespondentInList = async (correspondentId: number | null | undefined) => {
if (!correspondentId) return;
setCorrespondents(prev => {
const exists = prev.some(c => Number(c.id) === Number(correspondentId));
if (!exists) {
// We need to fetch it. But we can't easily do it inside setCorrespondents.
// So we'll do it outside.
return prev;
}
return prev;
});
const exists = correspondents.some(c => Number(c.id) === Number(correspondentId));
if (!exists) {
try {
const currentCorr = await paperlessApi.getCorrespondent(correspondentId);
if (currentCorr) {
setCorrespondents(prev => {
if (prev.some(c => Number(c.id) === Number(currentCorr.id))) return prev;
return [...prev, currentCorr];
});
}
} catch (e) {
console.error("Fehler beim Laden des zugewiesenen Absenders:", e);
}
}
};
useEffect(() => {
if (open && document?.correspondent) {
ensureCorrespondentInList(document.correspondent);
}
}, [document?.correspondent, open]);
const loadInitialData = async () => {
setLoading(true);
try {
const [clientsData, docTypesData, correspondentsData, tagsData, steuertagData] = await Promise.all([
clientsApi.getMyClients(),
paperlessApi.getDocumentTypes(),
paperlessApi.getCorrespondents(),
paperlessApi.getTags(),
paperlessApi.getSteuertagIds(),
]);
setClients(clientsData);
setDocumentTypes(docTypesData);
setCorrespondents(correspondentsData);
setAllTags(tagsData);
setSteuertagIds(steuertagData);
// If document is already there, ensure its correspondent is in the list
if (document?.correspondent) {
const exists = correspondentsData.some(c => Number(c.id) === Number(document.correspondent));
if (!exists) {
const currentCorr = await paperlessApi.getCorrespondent(document.correspondent);
if (currentCorr) {
setCorrespondents([...correspondentsData, currentCorr]);
}
}
}
} catch (e) {
message.error("Fehler beim Laden der Stammdaten.");
} finally {
setLoading(false);
}
};
const fetchRequirements = async (docType: number) => {
try {
const reqs = await posteingangApi.getRequirements(docType, isPosteingang);
setRequirements(reqs);
} catch (e) {
message.error("Fehler beim Laden der Pflichtfelder.");
}
};
const [fetchingCorrespondents, setFetchingCorrespondents] = useState(false);
const [searchTimeout, setSearchTimeout] = useState<any>(null);
const handleCorrespondentSearch = async (value: string) => {
if (searchTimeout) clearTimeout(searchTimeout);
setSearchTimeout(setTimeout(async () => {
setFetchingCorrespondents(true);
try {
const data = await paperlessApi.getCorrespondents(value);
setCorrespondents(data);
} catch (e) {
message.error("Absender konnten nicht geladen werden.");
} finally {
setFetchingCorrespondents(false);
}
}, 500));
};
const handleDocumentTypeChange = (value: number) => {
fetchRequirements(value);
};
const handleSaveDocument = async (values: any, isNext: boolean = false) => {
// Collect custom fields into an array
const customFieldsObj: any = {};
Object.keys(values).forEach(key => {
if (key.startsWith('cf_')) {
const fieldId = parseInt(key.replace('cf_', ''), 10);
customFieldsObj[fieldId] = values[key];
}
});
if (values.eingangsdatum) {
customFieldsObj[9] = values.eingangsdatum.format('YYYY-MM-DD');
} else {
customFieldsObj[9] = null;
}
const payload = {
mandant: values.mandant,
documentType: values.documentType,
correspondent: values.correspondent,
title: values.title,
date: values.belegdatum ? values.belegdatum.format('YYYY-MM-DD') : null,
tags: values.tags || [],
customFields: customFieldsObj,
};
setSaving(true);
try {
await posteingangApi.updateDocument(documentId!, payload);
message.success("Dokument erfolgreich aktualisiert.");
onSave();
onClose(isNext);
} catch (e) {
message.error("Fehler beim Speichern des Dokuments.");
} finally {
setSaving(false);
}
};
const handleSubmit = async (isNext: boolean = false) => {
try {
const values = await form.validateFields();
// Kontonummer Logic
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
if (kontonummerReq && values[`cf_5`] && values.correspondent) {
const kNummer = values[`cf_5`];
const knData = await posteingangApi.getKontonummern(values.correspondent);
const exists = knData.some(k => k.Nummer === kNummer);
if (!exists) {
// Prompt user to save kontonummer
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
return; // Stop saving, wait for confirmation
}
}
await handleSaveDocument(values, isNext);
} catch (e) {
// Validation failed
console.error("Form validation failed:", e);
}
};
const confirmKontonummerSave = async (shouldSave: boolean, isNext: boolean = false) => {
if (shouldSave && kontonummerMissing) {
try {
await posteingangApi.createKontonummer(kontonummerMissing);
message.success("Kontonummer hinterlegt.");
} catch (e) {
message.error("Fehler beim Speichern der Kontonummer.");
}
}
setKontonummerMissing(null);
const values = form.getFieldsValue();
await handleSaveDocument(values, isNext);
};
const handleSelectLink = (doc: any) => {
if (searchModalOpen) {
form.setFieldValue(searchModalOpen.field, doc.id);
setDocTitles(prev => ({ ...prev, [doc.id]: doc.title }));
setSearchModalOpen(null);
form.validateFields([searchModalOpen.field]);
}
};
if (documentId === null) return null;
return (
<>
<Modal
title={`Dokument bearbeiten (${document?.title || ''})`}
open={open && !kontonummerMissing}
onCancel={() => onClose(false)}
width={1400}
style={{ top: 20 }}
footer={
hasNextDocument ? [
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
<Button key="save" type="primary" loading={saving} onClick={() => handleSubmit(false)}>Speichern</Button>,
<Button key="saveNext" type="primary" ghost loading={saving} onClick={() => handleSubmit(true)}>Speichern & Nächstes</Button>
] : [
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
<Button key="save" type="primary" loading={saving} onClick={() => handleSubmit(false)}>Speichern</Button>
]
}
>
<Spin spinning={loading}>
<Row gutter={16} style={{ height: '75vh', overflow: 'hidden' }}>
<Col span={10} style={{ overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}>
<Form form={form} layout="vertical" disabled={saving}>
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
<Select showSearch optionFilterProp="children" allowClear>
{clients.map(c => (
<Option key={c.Id} value={c.PaperlessUserId}>{c.Name}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="documentType" label="Dokumentart" rules={[{ required: true, message: 'Wähle eine Dokumentart' }]}>
<Select showSearch optionFilterProp="children" onChange={handleDocumentTypeChange}>
{documentTypes.map(d => (
<Option key={d.id} value={d.id}>{d.name}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="belegdatum" label="Belegdatum">
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
</Form.Item>
<Form.Item name="eingangsdatum" label="Eingangsdatum">
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
</Form.Item>
<Form.Item name="tags" label="Tags">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="Tags auswählen"
options={contentTags.map(t => ({ value: t.id, label: t.name }))}
tagRender={({ value, closable, onClose }) => {
const tag = allTags.find(t => t.id === value);
return (
<Tag
color={tag?.color}
style={{ color: tag?.text_color, marginInlineEnd: 4 }}
closable={closable}
onClose={onClose}
>
{tag?.name ?? value}
</Tag>
);
}}
optionRender={(opt) => {
const tag = contentTags.find(t => t.id === opt.value);
return tag ? (
<Tag color={tag.color} style={{ color: tag.text_color }}>{tag.name}</Tag>
) : opt.label;
}}
/>
</Form.Item>
{/* Rendering dynamic requirements based on DocumentType */}
{requirements.map(req => {
if (req.feldId === '2' || (req.isCustomField && req.customFieldIndex === 9)) {
return null;
}
let inputElement = <Input />;
if (req.isCustomField && req.customFieldIndex === 5) {
// Kontonummer — Select mit Möglichkeit neue anzulegen
inputElement = (
<Select
showSearch
allowClear
optionFilterProp="children"
placeholder={!selectedCorrespondent ? 'Bitte zuerst einen Absender wählen' : 'Kontonummer wählen'}
disabled={!selectedCorrespondent}
loading={kontonummernLoading}
notFoundContent={kontonummernLoading ? <Spin size="small" /> : 'Keine Kontonummern vorhanden'}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<div style={{ display: 'flex', gap: 8, padding: '0 8px 8px' }}>
<Input
placeholder="Neue Kontonummer"
value={newKontonummer}
onChange={(e) => setNewKontonummer(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
/>
<Button
type="text"
icon={<PlusOutlined />}
onClick={handleAddKontonummer}
disabled={!newKontonummer.trim()}
>
Anlegen
</Button>
</div>
</>
)}
>
{kontonummern.map((k) => (
<Option key={k.KontonummerId ?? k.Nummer} value={k.Nummer}>
{k.Nummer}
</Option>
))}
</Select>
);
} else if (req.feldId === '1') {
inputElement = (
<Select
showSearch
filterOption={false}
onSearch={handleCorrespondentSearch}
notFoundContent={fetchingCorrespondents ? <Spin size="small" /> : null}
>
{correspondents.map(c => (
<Option key={c.id} value={c.id}>{c.name}</Option>
))}
</Select>
);
} else if (req.feldTyp === 'date') {
inputElement = <DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />;
} else if (req.feldTyp === 'select' && req.fieldOptions) {
inputElement = (
<Select showSearch optionFilterProp="children">
{req.fieldOptions.map(opt => (
<Option key={opt.id} value={opt.id}>{opt.label}</Option>
))}
</Select>
);
} else if (req.feldTyp === 'documentlink') {
const fieldName = req.isCustomField ? `cf_${req.customFieldIndex}` :
req.feldId === '1' ? 'correspondent' :
req.feldId === '2' ? 'belegdatum' :
req.feldId === '3' ? 'asn' :
req.feldId === '5' ? 'title' : req.feldId;
const currentId = form.getFieldValue(fieldName);
inputElement = (
<Input
readOnly
placeholder="Klicken zum Suchen..."
value={currentId ? (docTitles[currentId] || `Dokument #${currentId}`) : ''}
onClick={() => setSearchModalOpen({ field: fieldName, reqId: req.id })}
suffix={
<Space>
{currentId && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`);
}}
/>
)}
<Button
type="text"
size="small"
icon={<SearchOutlined />}
onClick={(e) => {
e.stopPropagation();
setSearchModalOpen({ field: fieldName, reqId: req.id });
}}
/>
</Space>
}
/>
);
}
const name = req.isCustomField ? `cf_${req.customFieldIndex}` :
req.feldId === '1' ? 'correspondent' :
req.feldId === '2' ? 'belegdatum' :
req.feldId === '3' ? 'asn' :
req.feldId === '5' ? 'title' : req.feldId;
return (
<Form.Item
key={req.id}
name={name}
label={req.feldName}
rules={[{ required: req.required, message: `${req.feldName} ist ein Pflichtfeld` }]}
tooltip={req.hinweis}
>
{inputElement}
</Form.Item>
);
})}
</Form>
</Col>
<Col span={14} style={{ height: '100%' }}>
<AuthIframe
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
style={{ width: '100%', height: '100%' }}
title="PDF Preview"
/>
</Col>
</Row>
</Spin>
</Modal>
{/* Kontonummer Dialog */}
<Modal
title="Neue Kontonummer hinterlegen?"
open={!!kontonummerMissing}
onCancel={() => confirmKontonummerSave(false)}
onOk={() => confirmKontonummerSave(true)}
okText="Hinterlegen"
cancelText="Nein, nur Speichern"
>
<p>Die Kontonummer <b>{kontonummerMissing?.nummer}</b> ist bei diesem Absender noch nicht hinterlegt.</p>
<p>Möchtest du sie dem Absender fest zuordnen?</p>
</Modal>
<DocumentSearchModal
open={!!searchModalOpen}
onCancel={() => setSearchModalOpen(null)}
onSelect={handleSelectLink}
/>
</>
);
}