feat(frontend): Vergleichsdialog für Duplikate mit neuer Version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-02 14:04:42 +01:00
1 parent 21a447e915
commit fffbf692b2
5 files changed
+344 -131

No files matched your search

+1
View File
@@ -147,6 +147,7 @@ export interface PostprocessActionResult {
versionMoeglich?: boolean;
duplikatKandidaten?: number[];
verworfen?: boolean;
belegnummer?: string;
}
export const clientsApi = {
@@ -0,0 +1,173 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Alert, Button, Modal, Space, Spin, Tag, Typography, message } from 'antd';
import { paperlessApi, type BelegnummerPruefung, type VergleichKennzahlen } from '../api/paperless';
import { useIsMobile } from '../hooks/useIsMobile';
export interface DuplikatVergleichModalProps {
open: boolean;
belegnummer: string;
/** eindeutig → Version möglich; mehrdeutig → nur Verwerfen */
pruefung: BelegnummerPruefung | null;
/** Vorhandenes Dokument (linke Seite) */
paperlessDocumentId: number | null;
/** Neuer Beleg (rechte Seite) */
ladeNeuesPdf: () => Promise<Blob>;
ladeKennzahlen: () => Promise<VergleichKennzahlen>;
onVersion: () => void;
onVerwerfen: () => void;
onClose: () => void;
}
function KennzahlZeile({ label, vorhanden, neu }: { label: string; vorhanden: number | null; neu: number | null }) {
const anzeige = (wert: number | null) => (wert === null ? 'nicht ermittelbar' : String(wert));
const vergleichbar = vorhanden !== null && neu !== null;
const abweichung = vergleichbar && vorhanden !== neu;
return (
<div>
<Typography.Text strong>{label}:</Typography.Text> {anzeige(vorhanden)} (vorhanden) → {anzeige(neu)} (neu){' '}
{abweichung && <Tag color="orange">abweichend</Tag>}
{!vergleichbar && <Tag>nicht vergleichbar</Tag>}
</div>
);
}
function PdfSpalte({ titel, url, loading }: { titel: string; url: string | null; loading: boolean }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
{titel}
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !url ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={url} title={titel} style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
);
}
/**
* Vergleich eines Belegs mit einem vorhandenen Paperless-Dokument derselben
* internen Belegnummer: Auffälligkeiten (Seiten-/Wortzahl) oben, beide PDFs
* nebeneinander, Entscheidung „neue Version“ oder „verwerfen“.
*/
export function DuplikatVergleichModal({
open,
belegnummer,
pruefung,
paperlessDocumentId,
ladeNeuesPdf,
ladeKennzahlen,
onVersion,
onVerwerfen,
onClose,
}: DuplikatVergleichModalProps) {
const isMobile = useIsMobile();
const [vorhandenUrl, setVorhandenUrl] = useState<string | null>(null);
const [neuUrl, setNeuUrl] = useState<string | null>(null);
const [kennzahlen, setKennzahlen] = useState<VergleichKennzahlen | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || paperlessDocumentId === null) return;
let cancelled = false;
let urlAlt: string | null = null;
let urlNeu: string | null = null;
setLoading(true);
setKennzahlen(null);
(async () => {
try {
const [altBlob, neuBlob] = await Promise.all([paperlessApi.getDocumentPdfBlob(paperlessDocumentId), ladeNeuesPdf()]);
if (cancelled) return;
urlAlt = URL.createObjectURL(altBlob);
urlNeu = URL.createObjectURL(neuBlob);
setVorhandenUrl(urlAlt);
setNeuUrl(urlNeu);
} catch {
if (!cancelled) message.error('Vorschau konnte nicht geladen werden');
} finally {
if (!cancelled) setLoading(false);
}
})();
ladeKennzahlen()
.then((k) => {
if (!cancelled) setKennzahlen(k);
})
.catch(() => {
if (!cancelled) message.warning('Seiten- und Wortzahl konnten nicht ermittelt werden');
});
return () => {
cancelled = true;
if (urlAlt) URL.revokeObjectURL(urlAlt);
if (urlNeu) URL.revokeObjectURL(urlNeu);
setVorhandenUrl(null);
setNeuUrl(null);
};
// Lade-Funktionen ändern sich bei jedem Render; neu laden nur bei anderem Dokument
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, paperlessDocumentId]);
const versionMoeglich = pruefung?.status === 'eindeutig';
let auffaelligkeiten: ReactNode = <Spin size="small" />;
if (kennzahlen) {
const { vorhanden, neu } = kennzahlen;
const alleBekannt = [vorhanden.seiten, neu.seiten, vorhanden.woerter, neu.woerter].every((w) => w !== null);
const gleich = alleBekannt && vorhanden.seiten === neu.seiten && vorhanden.woerter === neu.woerter;
auffaelligkeiten = gleich ? (
<Alert type="success" showIcon message="Seiten- und Wortzahl gleich" />
) : (
<Alert
type="info"
showIcon
message="Auffälligkeiten"
description={
<Space direction="vertical" size={2}>
<KennzahlZeile label="Seiten" vorhanden={vorhanden.seiten} neu={neu.seiten} />
<KennzahlZeile label="Wörter" vorhanden={vorhanden.woerter} neu={neu.woerter} />
</Space>
}
/>
);
}
return (
<Modal
open={open}
title={`Belegnummer ${belegnummer} existiert bereits`}
onCancel={onClose}
width="90vw"
style={{ top: 20 }}
destroyOnClose
footer={[
<Button key="verwerfen" danger onClick={onVerwerfen}>
Verwerfen
</Button>,
versionMoeglich && (
<Button key="version" type="primary" onClick={onVersion}>
Als neue Version hinzufügen
</Button>
),
]}
>
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{pruefung?.status === 'mehrdeutig' && (
<Alert
type="warning"
showIcon
message={`Mehrere Dokumente in Paperless mit dieser Belegnummer (IDs: ${pruefung.kandidaten.join(', ')}) – eine neue Version ist nicht möglich.`}
/>
)}
{auffaelligkeiten}
</Space>
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 12, height: '70vh' }}>
<PdfSpalte titel={`Vorhanden (Paperless #${paperlessDocumentId ?? '–'})`} url={vorhandenUrl} loading={loading} />
<PdfSpalte titel="Neuer Beleg" url={neuUrl} loading={loading} />
</div>
</Modal>
);
}
@@ -6,13 +6,14 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
import { emailsApi } from '../api/emails';
import { paperlessApi, type PaperlessCorrespondent } from '../api/paperless';
import { paperlessApi, type BelegnummerPruefung, type PaperlessCorrespondent } from '../api/paperless';
import dayjs from 'dayjs';
import {
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
} from '@ant-design/icons';
import PdfSplitViewer from './PdfSplitViewer';
import BarcodePositioner from './BarcodePositioner';
import { DuplikatVergleichModal } from './DuplikatVergleichModal';
const { Text } = Typography;
@@ -43,6 +44,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
// Eingangsdatum per item
const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({});
// Duplikatvergleich: Entscheidung „neue Version“ je Beleg (gilt nur für diese Belegnummer)
const [versionEntscheidung, setVersionEntscheidung] = useState<Record<string, { belegnummer: string; documentId: number }>>({});
// Noch zu entscheidende Treffer, werden nacheinander im Vergleichsdialog gezeigt
const [vergleichQueue, setVergleichQueue] = useState<{ virtualId: string; belegnummer: string; pruefung: BelegnummerPruefung }[]>([]);
// Step 3 specific state
const [importSuccess, setImportSuccess] = useState(false);
const [importStatus, setImportStatus] = useState('');
@@ -78,6 +84,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
});
setEingangsdaten(initialDates);
setBarcodes(initialBarcodes);
setVersionEntscheidung({});
setVergleichQueue([]);
// Load correspondents and try to find suggestion
loadCorrespondents();
@@ -349,6 +357,72 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
}
};
// Manuell eingegebene Belegnummer im Format JJJJ-NNNNNN (wie beim Import), sonst null
const manuelleBelegnummer = (virtualId: string): string | null => {
if ((belegnummerMode[virtualId] || 'neu') !== 'manuell') return null;
let num = belegnummern[virtualId] || '';
if (num.includes('-')) num = num.split('-')[1];
if (!num || /^0+$/.test(num)) return null;
const yr = barcodes[virtualId]?.jahr || (eingangsdaten[virtualId] || dayjs(email.Date)).format('YYYY');
return `${yr}-${String(num).padStart(6, '0')}`;
};
// Prüft manuelle Belegnummern gegen Paperless; liefert die Treffer ohne gültige Entscheidung
const pruefeManuelleBelegnummern = async () => {
const kandidaten = importData
.filter(i => i.type !== 'IGNORE')
.map(i => ({ virtualId: i.virtualId, belegnummer: manuelleBelegnummer(i.virtualId) }))
.filter((k): k is { virtualId: string; belegnummer: string } => k.belegnummer !== null);
// Entscheidungen für inzwischen geänderte Belegnummern verfallen
const gueltig: typeof versionEntscheidung = {};
for (const k of kandidaten) {
const e = versionEntscheidung[k.virtualId];
if (e && e.belegnummer === k.belegnummer) gueltig[k.virtualId] = e;
}
setVersionEntscheidung(gueltig);
const offen = kandidaten.filter(k => !gueltig[k.virtualId]);
const pruefungen = await Promise.all(offen.map(k => paperlessApi.pruefeBelegnummer(k.belegnummer)));
return offen
.map((k, idx) => ({ ...k, pruefung: pruefungen[idx] }))
.filter(k => k.pruefung.status !== 'frei');
};
const weiterZuAbschluss = async (daten: AttachmentImportData[]) => {
if (daten.length > 0 && daten.every(i => i.type === 'IGNORE')) {
await ignoreEmail();
return;
}
await preFetchBelegnummern();
setCurrentStep(2);
};
// Nächsten Treffer zeigen; nach dem letzten geht es zu Schritt 3
const naechsterVergleich = (daten: AttachmentImportData[]) => {
const rest = vergleichQueue.slice(1);
setVergleichQueue(rest);
if (rest.length === 0) void weiterZuAbschluss(daten);
};
const handleVergleichVersion = () => {
const aktuell = vergleichQueue[0];
if (!aktuell?.pruefung.documentId) return;
setVersionEntscheidung(prev => ({
...prev,
[aktuell.virtualId]: { belegnummer: aktuell.belegnummer, documentId: aktuell.pruefung.documentId! },
}));
naechsterVergleich(importData);
};
const handleVergleichVerwerfen = () => {
const aktuell = vergleichQueue[0];
if (!aktuell) return;
const daten = importData.map(i => (i.virtualId === aktuell.virtualId ? { ...i, type: 'IGNORE' as const } : i));
setImportData(daten);
naechsterVergleich(daten);
};
const nextStep = async () => {
if (currentStep === 0) {
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
@@ -372,6 +446,20 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
} finally {
setLoading(false);
}
// Manuelle Belegnummern, die es in Paperless schon gibt → erst vergleichen
setLoading(true);
try {
const treffer = await pruefeManuelleBelegnummern();
if (treffer.length > 0) {
setVergleichQueue(treffer);
return;
}
} catch {
message.error('Belegnummer-Prüfung fehlgeschlagen – bitte erneut versuchen.');
return;
} finally {
setLoading(false);
}
await preFetchBelegnummern();
}
setCurrentStep(currentStep + 1);
@@ -448,9 +536,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
}
const finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`;
const entscheidung = versionEntscheidung[item.virtualId];
finalData.push({
...item,
versionZuDokumentId: entscheidung?.belegnummer === finalBelegnummer ? entscheidung.documentId : null,
splitRanges: item.pages ? [item.pages] : undefined,
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
belegnummer: finalBelegnummer,
@@ -765,6 +855,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
<Text type="secondary">
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
</Text>
{versionEntscheidung[main.virtualId]?.belegnummer === belegnr && (
<Tag color="purple">
wird als neue Version zu Dokument {versionEntscheidung[main.virtualId].documentId} hinzugefügt
</Tag>
)}
</Space>
{mainAttachments.length > 0 && (
<div style={{ marginTop: 12, paddingLeft: 16 }}>
@@ -855,6 +950,26 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
{currentStep === 2 && renderStep3()}
</div>
</Spin>
{vergleichQueue.length > 0 && (() => {
const aktuell = vergleichQueue[0];
const item = importData.find(i => i.virtualId === aktuell.virtualId);
const docId = aktuell.pruefung.documentId ?? aktuell.pruefung.kandidaten[0] ?? null;
if (!item) return null;
return (
<DuplikatVergleichModal
key={aktuell.virtualId}
open
belegnummer={aktuell.belegnummer}
pruefung={aktuell.pruefung}
paperlessDocumentId={docId}
ladeNeuesPdf={() => emailImportApi.attachmentPdfBlob(item.attachmentId, item.pages)}
ladeKennzahlen={() => emailImportApi.vergleich(item.attachmentId, docId ?? 0, item.pages)}
onVersion={handleVergleichVersion}
onVerwerfen={handleVergleichVerwerfen}
onClose={() => setVergleichQueue([])}
/>
);
})()}
</Modal>
);
}
+47 -129
View File
@@ -23,7 +23,8 @@ import {
ZoomOutOutlined,
} from '@ant-design/icons';
import { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox';
import { paperlessApi } from '../api/paperless';
import type { BelegnummerPruefung } from '../api/paperless';
import { DuplikatVergleichModal } from '../components/DuplikatVergleichModal';
import { userSettingsApi, type SenderOption } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -97,110 +98,6 @@ function SourceTag({ source }: { source: InboxFile['source'] }) {
);
}
interface CompareModalProps {
open: boolean;
paperlessDocumentId: number | null;
inboxDocumentId: string;
onClose: () => void;
onCreateNewVersion: () => void;
onSkip: () => void;
}
function CompareModal({
open,
paperlessDocumentId,
inboxDocumentId,
onClose,
onCreateNewVersion,
onSkip,
}: CompareModalProps) {
const compareIsMobile = useIsMobile();
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
const [inboxUrl, setInboxUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || paperlessDocumentId === null) return;
let cancelled = false;
let papUrl: string | null = null;
let inbUrl: string | null = null;
setLoading(true);
(async () => {
try {
const [pBlob, iBlob] = await Promise.all([
paperlessApi.getDocumentPdfBlob(paperlessDocumentId),
inboxApi.previewBlob(inboxDocumentId),
]);
if (cancelled) return;
papUrl = URL.createObjectURL(pBlob);
inbUrl = URL.createObjectURL(iBlob);
setPaperlessUrl(papUrl);
setInboxUrl(inbUrl);
} catch {
if (!cancelled) message.error('Vorschau konnte nicht geladen werden');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (papUrl) URL.revokeObjectURL(papUrl);
if (inbUrl) URL.revokeObjectURL(inbUrl);
setPaperlessUrl(null);
setInboxUrl(null);
};
}, [open, paperlessDocumentId, inboxDocumentId]);
return (
<Modal
open={open}
title="Duplikat vergleichen"
onCancel={onClose}
width="90vw"
style={{ top: 20 }}
footer={[
<Button key="skip" onClick={onSkip}>
Überspringen
</Button>,
<Button key="new" type="primary" onClick={onCreateNewVersion}>
Neue Version anlegen
</Button>,
]}
>
<div style={{ display: 'flex', flexDirection: compareIsMobile ? 'column' : 'row', gap: 12, height: '75vh' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Original (Paperless)
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !paperlessUrl ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={paperlessUrl} title="Paperless Original" style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Aktueller Abschnitt (Inbox)
</Typography.Text>
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 4, overflow: 'hidden' }}>
{loading || !inboxUrl ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : (
<iframe src={inboxUrl} title="Inbox Abschnitt" style={{ width: '100%', height: '100%', border: 0 }} />
)}
</div>
</div>
</div>
</Modal>
);
}
type WizardStepStatus = 'pending' | 'processing' | 'success' | 'duplicate' | 'error' | 'skipped';
interface WizardStep {
@@ -274,6 +171,10 @@ function PostprocessWizardModal({
if (skipped) {
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
setAwaitingDecision(true);
// Treffer in Paperless → Vergleich direkt öffnen
if (skipped.duplicateOfDocumentId !== undefined && skipped.duplikatKandidaten?.length) {
setCompareForSection(idx);
}
} else if (failed.length > 0) {
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
setStepAt(idx, { status: 'error', message: msg, results });
@@ -281,6 +182,9 @@ function PostprocessWizardModal({
} else if (results.length === 0) {
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
setAwaitingDecision(true);
} else if (entscheidung?.aktion === 'verwerfen') {
setStepAt(idx, { status: 'skipped', message: 'Verworfen', results });
setCurrentSection((s) => s + 1);
} else {
setStepAt(idx, { status: 'success', results });
setCurrentSection((s) => s + 1);
@@ -303,13 +207,21 @@ function PostprocessWizardModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
const handleReplace = () => {
const handleVersion = () => {
const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId;
if (documentId === undefined) return;
setCompareForSection(null);
setAwaitingDecision(false);
void processSection(currentSection, { aktion: 'version', documentId });
};
// Folgende Aktionen (z. B. Export) laufen trotzdem – nur der Paperless-Upload entfällt
const handleVerwerfen = () => {
setCompareForSection(null);
setAwaitingDecision(false);
void processSection(currentSection, { aktion: 'verwerfen' });
};
const handleSkip = () => {
setSteps((prev) =>
prev.map((s, i) =>
@@ -390,16 +302,13 @@ function PostprocessWizardModal({
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
{idx === currentSection && awaitingDecision && (
<Space wrap>
<Button type="primary" size="small" onClick={handleReplace}>
Neue Version erstellen
</Button>
{dupDocId !== null && (
<Button size="small" onClick={() => setCompareForSection(idx)}>
<Button type="primary" size="small" onClick={() => setCompareForSection(idx)}>
Vergleichen
</Button>
)}
<Button size="small" onClick={handleSkip}>
Überspringen
<Button size="small" danger onClick={handleVerwerfen}>
Verwerfen
</Button>
</Space>
)}
@@ -510,23 +419,32 @@ function PostprocessWizardModal({
) : (
<Steps direction="vertical" current={stepperCurrent} items={allItems} />
)}
<CompareModal
open={compareForSection !== null}
paperlessDocumentId={
compareForSection !== null
? steps[compareForSection]?.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null
: null
}
inboxDocumentId={documentId}
onClose={() => setCompareForSection(null)}
onCreateNewVersion={() => {
// TODO: wird im nächsten Schritt implementiert
}}
onSkip={() => {
setCompareForSection(null);
handleSkip();
}}
/>
{compareForSection !== null && (() => {
const dup = steps[compareForSection]?.results?.find((r) => r.skipped);
const docId = dup?.duplicateOfDocumentId ?? null;
const pages = documents[compareForSection]?.pages ?? [];
const kandidaten = dup?.duplikatKandidaten ?? [];
const pruefung: BelegnummerPruefung | null = kandidaten.length
? {
status: dup?.versionMoeglich ? 'eindeutig' : 'mehrdeutig',
documentId: dup?.versionMoeglich ? docId : null,
kandidaten,
}
: null;
return (
<DuplikatVergleichModal
open
belegnummer={dup?.belegnummer ?? ''}
pruefung={pruefung}
paperlessDocumentId={docId}
ladeNeuesPdf={() => inboxApi.downloadSegmentBlob(documentId, pages)}
ladeKennzahlen={() => inboxApi.vergleich(documentId, docId ?? 0, pages)}
onVersion={handleVersion}
onVerwerfen={handleVerwerfen}
onClose={() => setCompareForSection(null)}
/>
);
})()}
</Modal>
);
}