feat: Duplikate im Import als neue Version behandeln #6
No files matched your search
@@ -56,6 +56,8 @@ export interface ActionResult {
|
|||||||
duplikatKandidaten?: number[];
|
duplikatKandidaten?: number[];
|
||||||
/** Der Benutzer hat das Duplikat verworfen */
|
/** Der Benutzer hat das Duplikat verworfen */
|
||||||
verworfen?: boolean;
|
verworfen?: boolean;
|
||||||
|
/** Aufgelöste interne Belegnummer (bei Duplikaten) */
|
||||||
|
belegnummer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunForDocumentResult {
|
export interface RunForDocumentResult {
|
||||||
@@ -69,6 +71,7 @@ interface PaperlessRunResult {
|
|||||||
duplicateOfDocumentId?: number;
|
duplicateOfDocumentId?: number;
|
||||||
versionMoeglich?: boolean;
|
versionMoeglich?: boolean;
|
||||||
duplikatKandidaten?: number[];
|
duplikatKandidaten?: number[];
|
||||||
|
belegnummer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -302,6 +305,7 @@ export class InboxPostprocessorService {
|
|||||||
duplicateOfDocumentId: res.duplicateOfDocumentId,
|
duplicateOfDocumentId: res.duplicateOfDocumentId,
|
||||||
versionMoeglich: res.versionMoeglich,
|
versionMoeglich: res.versionMoeglich,
|
||||||
duplikatKandidaten: res.duplikatKandidaten,
|
duplikatKandidaten: res.duplikatKandidaten,
|
||||||
|
belegnummer: res.belegnummer,
|
||||||
});
|
});
|
||||||
if (res.skipped) {
|
if (res.skipped) {
|
||||||
abortProcessing = true;
|
abortProcessing = true;
|
||||||
@@ -490,6 +494,7 @@ export class InboxPostprocessorService {
|
|||||||
duplicateOfDocumentId: pruefung.documentId ?? pruefung.kandidaten[0],
|
duplicateOfDocumentId: pruefung.documentId ?? pruefung.kandidaten[0],
|
||||||
versionMoeglich: pruefung.status === 'eindeutig',
|
versionMoeglich: pruefung.status === 'eindeutig',
|
||||||
duplikatKandidaten: pruefung.kandidaten,
|
duplikatKandidaten: pruefung.kandidaten,
|
||||||
|
belegnummer: interneBelegnummer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +511,7 @@ export class InboxPostprocessorService {
|
|||||||
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
|
message: `Duplikat – Belegnummer ${interneBelegnummer} bereits vorhanden`,
|
||||||
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
|
duplicateOfDocumentId: existingTask.PaperlessDocumentID ?? undefined,
|
||||||
versionMoeglich: false,
|
versionMoeglich: false,
|
||||||
|
belegnummer: interneBelegnummer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export interface PostprocessActionResult {
|
|||||||
versionMoeglich?: boolean;
|
versionMoeglich?: boolean;
|
||||||
duplikatKandidaten?: number[];
|
duplikatKandidaten?: number[];
|
||||||
verworfen?: boolean;
|
verworfen?: boolean;
|
||||||
|
belegnummer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clientsApi = {
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
|
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
|
||||||
import { emailsApi } from '../api/emails';
|
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 dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
|
PrinterOutlined, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import PdfSplitViewer from './PdfSplitViewer';
|
import PdfSplitViewer from './PdfSplitViewer';
|
||||||
import BarcodePositioner from './BarcodePositioner';
|
import BarcodePositioner from './BarcodePositioner';
|
||||||
|
import { DuplikatVergleichModal } from './DuplikatVergleichModal';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
// Eingangsdatum per item
|
// Eingangsdatum per item
|
||||||
const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({});
|
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
|
// Step 3 specific state
|
||||||
const [importSuccess, setImportSuccess] = useState(false);
|
const [importSuccess, setImportSuccess] = useState(false);
|
||||||
const [importStatus, setImportStatus] = useState('');
|
const [importStatus, setImportStatus] = useState('');
|
||||||
@@ -78,6 +84,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
});
|
});
|
||||||
setEingangsdaten(initialDates);
|
setEingangsdaten(initialDates);
|
||||||
setBarcodes(initialBarcodes);
|
setBarcodes(initialBarcodes);
|
||||||
|
setVersionEntscheidung({});
|
||||||
|
setVergleichQueue([]);
|
||||||
|
|
||||||
// Load correspondents and try to find suggestion
|
// Load correspondents and try to find suggestion
|
||||||
loadCorrespondents();
|
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 () => {
|
const nextStep = async () => {
|
||||||
if (currentStep === 0) {
|
if (currentStep === 0) {
|
||||||
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
|
if (importData.length > 0 && importData.every(i => i.type === 'IGNORE')) {
|
||||||
@@ -372,6 +446,20 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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();
|
await preFetchBelegnummern();
|
||||||
}
|
}
|
||||||
setCurrentStep(currentStep + 1);
|
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 finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`;
|
||||||
|
const entscheidung = versionEntscheidung[item.virtualId];
|
||||||
|
|
||||||
finalData.push({
|
finalData.push({
|
||||||
...item,
|
...item,
|
||||||
|
versionZuDokumentId: entscheidung?.belegnummer === finalBelegnummer ? entscheidung.documentId : null,
|
||||||
splitRanges: item.pages ? [item.pages] : undefined,
|
splitRanges: item.pages ? [item.pages] : undefined,
|
||||||
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
|
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
|
||||||
belegnummer: finalBelegnummer,
|
belegnummer: finalBelegnummer,
|
||||||
@@ -765,6 +855,11 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
|
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
|
||||||
</Text>
|
</Text>
|
||||||
|
{versionEntscheidung[main.virtualId]?.belegnummer === belegnr && (
|
||||||
|
<Tag color="purple">
|
||||||
|
wird als neue Version zu Dokument {versionEntscheidung[main.virtualId].documentId} hinzugefügt
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
{mainAttachments.length > 0 && (
|
{mainAttachments.length > 0 && (
|
||||||
<div style={{ marginTop: 12, paddingLeft: 16 }}>
|
<div style={{ marginTop: 12, paddingLeft: 16 }}>
|
||||||
@@ -855,6 +950,26 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
{currentStep === 2 && renderStep3()}
|
{currentStep === 2 && renderStep3()}
|
||||||
</div>
|
</div>
|
||||||
</Spin>
|
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
ZoomOutOutlined,
|
ZoomOutOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox';
|
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 { userSettingsApi, type SenderOption } from '../api/userSettings';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
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';
|
type WizardStepStatus = 'pending' | 'processing' | 'success' | 'duplicate' | 'error' | 'skipped';
|
||||||
|
|
||||||
interface WizardStep {
|
interface WizardStep {
|
||||||
@@ -274,6 +171,10 @@ function PostprocessWizardModal({
|
|||||||
if (skipped) {
|
if (skipped) {
|
||||||
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
|
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
|
||||||
setAwaitingDecision(true);
|
setAwaitingDecision(true);
|
||||||
|
// Treffer in Paperless → Vergleich direkt öffnen
|
||||||
|
if (skipped.duplicateOfDocumentId !== undefined && skipped.duplikatKandidaten?.length) {
|
||||||
|
setCompareForSection(idx);
|
||||||
|
}
|
||||||
} else if (failed.length > 0) {
|
} else if (failed.length > 0) {
|
||||||
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
|
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
|
||||||
setStepAt(idx, { status: 'error', message: msg, results });
|
setStepAt(idx, { status: 'error', message: msg, results });
|
||||||
@@ -281,6 +182,9 @@ function PostprocessWizardModal({
|
|||||||
} else if (results.length === 0) {
|
} else if (results.length === 0) {
|
||||||
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
|
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
|
||||||
setAwaitingDecision(true);
|
setAwaitingDecision(true);
|
||||||
|
} else if (entscheidung?.aktion === 'verwerfen') {
|
||||||
|
setStepAt(idx, { status: 'skipped', message: 'Verworfen', results });
|
||||||
|
setCurrentSection((s) => s + 1);
|
||||||
} else {
|
} else {
|
||||||
setStepAt(idx, { status: 'success', results });
|
setStepAt(idx, { status: 'success', results });
|
||||||
setCurrentSection((s) => s + 1);
|
setCurrentSection((s) => s + 1);
|
||||||
@@ -303,13 +207,21 @@ function PostprocessWizardModal({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
|
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
|
||||||
|
|
||||||
const handleReplace = () => {
|
const handleVersion = () => {
|
||||||
const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId;
|
const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId;
|
||||||
if (documentId === undefined) return;
|
if (documentId === undefined) return;
|
||||||
|
setCompareForSection(null);
|
||||||
setAwaitingDecision(false);
|
setAwaitingDecision(false);
|
||||||
void processSection(currentSection, { aktion: 'version', documentId });
|
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 = () => {
|
const handleSkip = () => {
|
||||||
setSteps((prev) =>
|
setSteps((prev) =>
|
||||||
prev.map((s, i) =>
|
prev.map((s, i) =>
|
||||||
@@ -390,16 +302,13 @@ function PostprocessWizardModal({
|
|||||||
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
|
<span style={{ color: '#faad14' }}>{step.message ?? 'Duplikat erkannt'}</span>
|
||||||
{idx === currentSection && awaitingDecision && (
|
{idx === currentSection && awaitingDecision && (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button type="primary" size="small" onClick={handleReplace}>
|
|
||||||
Neue Version erstellen
|
|
||||||
</Button>
|
|
||||||
{dupDocId !== null && (
|
{dupDocId !== null && (
|
||||||
<Button size="small" onClick={() => setCompareForSection(idx)}>
|
<Button type="primary" size="small" onClick={() => setCompareForSection(idx)}>
|
||||||
Vergleichen
|
Vergleichen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="small" onClick={handleSkip}>
|
<Button size="small" danger onClick={handleVerwerfen}>
|
||||||
Überspringen
|
Verwerfen
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
@@ -510,23 +419,32 @@ function PostprocessWizardModal({
|
|||||||
) : (
|
) : (
|
||||||
<Steps direction="vertical" current={stepperCurrent} items={allItems} />
|
<Steps direction="vertical" current={stepperCurrent} items={allItems} />
|
||||||
)}
|
)}
|
||||||
<CompareModal
|
{compareForSection !== null && (() => {
|
||||||
open={compareForSection !== null}
|
const dup = steps[compareForSection]?.results?.find((r) => r.skipped);
|
||||||
paperlessDocumentId={
|
const docId = dup?.duplicateOfDocumentId ?? null;
|
||||||
compareForSection !== null
|
const pages = documents[compareForSection]?.pages ?? [];
|
||||||
? steps[compareForSection]?.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null
|
const kandidaten = dup?.duplikatKandidaten ?? [];
|
||||||
: null
|
const pruefung: BelegnummerPruefung | null = kandidaten.length
|
||||||
|
? {
|
||||||
|
status: dup?.versionMoeglich ? 'eindeutig' : 'mehrdeutig',
|
||||||
|
documentId: dup?.versionMoeglich ? docId : null,
|
||||||
|
kandidaten,
|
||||||
}
|
}
|
||||||
inboxDocumentId={documentId}
|
: 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)}
|
onClose={() => setCompareForSection(null)}
|
||||||
onCreateNewVersion={() => {
|
|
||||||
// TODO: wird im nächsten Schritt implementiert
|
|
||||||
}}
|
|
||||||
onSkip={() => {
|
|
||||||
setCompareForSection(null);
|
|
||||||
handleSkip();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user