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

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