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

@@ -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>
);
}