Files
paperlessmanager/paperless-frontend/src/pages/InboxDetailPage.tsx
T
bjoernpoettker 443ab765c9
Build and Push Multi-Platform Images / build-and-push (push) Successful in 33s
feat: implement multi-segment PDF email attachments and add PWA mobile icons
2026-05-06 10:14:16 +02:00

1597 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button, Dropdown, Empty, Form, Input, Modal, Popconfirm, Space, Spin, Steps, Tag, Tooltip, Typography, message } from 'antd';
import type { MenuProps } from 'antd';
import {
ArrowLeftOutlined,
DeleteOutlined,
DownOutlined,
FolderOpenOutlined,
LeftOutlined,
LoadingOutlined,
MailOutlined,
QrcodeOutlined,
RedoOutlined,
RightOutlined,
SaveOutlined,
ScissorOutlined,
ThunderboltOutlined,
UndoOutlined,
UserOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
import { paperlessApi } from '../api/paperless';
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3;
const ZOOM_STEP = 0.25;
const { Title } = Typography;
interface DocumentSegment {
index: number;
pages: number[];
belegname: string | null;
}
function buildDocuments(
pageCount: number,
splitPages: number[],
deletedPages: number[],
manualSplitPages: number[],
barcodes: InboxBarcode[],
): DocumentSegment[] {
if (pageCount === 0) return [];
const deleted = new Set(deletedPages);
const splits = new Set([...splitPages, ...manualSplitPages].filter((p) => !deleted.has(p)));
const docs: DocumentSegment[] = [];
let current: number[] = [];
for (let n = 1; n <= pageCount; n++) {
if (deleted.has(n)) continue;
if (splits.has(n) && current.length > 0) {
docs.push({ index: docs.length, pages: current, belegname: null });
current = [];
}
current.push(n);
}
if (current.length > 0) {
docs.push({ index: docs.length, pages: current, belegname: null });
}
// Belegname pro Segment: DateinameTemplate des zugehörigen Barcodes,
// Fallback auf templateName falls DateinameTemplate nicht konfiguriert.
const sortedBarcodes = [...barcodes].sort((a, b) => a.page - b.page);
return docs.map((seg) => {
const pageSet = new Set(seg.pages);
const barcode = sortedBarcodes.find((b) => pageSet.has(b.page));
const belegname = barcode?.dateinameTemplate || barcode?.templateName || null;
return { ...seg, belegname };
});
}
function thumbImageStyle(rotation: number, shortSidePx: number): React.CSSProperties {
const isQuarter = rotation === 90 || rotation === 270;
return {
maxWidth: isQuarter ? shortSidePx : '100%',
maxHeight: isQuarter ? shortSidePx : '100%',
transform: `rotate(${rotation}deg)`,
transition: 'transform 0.15s ease-out',
};
}
function SourceTag({ source }: { source: InboxFile['source'] }) {
return source === 'user' ? (
<Tag icon={<UserOutlined />} color="purple">
Persönlich
</Tag>
) : (
<Tag icon={<FolderOpenOutlined />} color="blue">
Gemeinsam
</Tag>
);
}
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 [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', gap: 12, height: '75vh' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 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 }}>
<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 {
status: WizardStepStatus;
message?: string;
results?: PostprocessActionResult[];
}
type DeleteStepStatus = 'pending' | 'awaiting' | 'deleting' | 'done' | 'blocked';
interface PostprocessWizardModalProps {
open: boolean;
documentId: string;
documents: DocumentSegment[];
onClose: () => void;
onDeleted: () => void;
}
function PostprocessWizardModal({
open,
documentId,
documents,
onClose,
onDeleted,
}: PostprocessWizardModalProps) {
const [steps, setSteps] = useState<WizardStep[]>([]);
const [currentSection, setCurrentSection] = useState(0);
const [awaitingDecision, setAwaitingDecision] = useState(false);
const [finished, setFinished] = useState(false);
const [busy, setBusy] = useState(false);
const [deleteStatus, setDeleteStatus] = useState<DeleteStepStatus>('pending');
const [compareForSection, setCompareForSection] = useState<number | null>(null);
const sectionCount = documents.length;
useEffect(() => {
if (open) {
setSteps(documents.map(() => ({ status: 'pending' as WizardStepStatus })));
setCurrentSection(0);
setAwaitingDecision(false);
setFinished(false);
setBusy(false);
setDeleteStatus('pending');
setCompareForSection(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, sectionCount]);
useEffect(() => {
if (!finished) return;
const allDone = steps.length > 0 && steps.every((s) => s.status === 'success' || s.status === 'skipped');
setDeleteStatus(allDone ? 'awaiting' : 'blocked');
}, [finished, steps]);
const setStepAt = (idx: number, patch: Partial<WizardStep> | WizardStep) => {
setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s)));
};
const processSection = async (idx: number, replaceDuplicate = false) => {
if (!documentId) return;
setBusy(true);
setStepAt(idx, { status: 'processing', message: undefined });
try {
const { results } = await inboxApi.postprocess(documentId, {
sectionOffset: idx,
processOnlyOne: true,
replaceDuplicate,
});
const skipped = results.find((r) => r.skipped);
const failed = results.filter((r) => !r.ok);
if (skipped) {
setStepAt(idx, { status: 'duplicate', message: skipped.message ?? 'Duplikat erkannt', results });
setAwaitingDecision(true);
} else if (failed.length > 0) {
const msg = failed.map((f) => f.message ?? f.actionType).join(', ');
setStepAt(idx, { status: 'error', message: msg, results });
setAwaitingDecision(true);
} else if (results.length === 0) {
setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results });
setAwaitingDecision(true);
} else {
setStepAt(idx, { status: 'success', results });
setCurrentSection((s) => s + 1);
}
} catch {
setStepAt(idx, { status: 'error', message: 'Netzwerkfehler' });
setAwaitingDecision(true);
} finally {
setBusy(false);
}
};
useEffect(() => {
if (!open || awaitingDecision || finished || busy) return;
if (currentSection >= sectionCount) {
setFinished(true);
return;
}
void processSection(currentSection);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentSection, awaitingDecision, finished, busy, sectionCount]);
const handleReplace = () => {
setAwaitingDecision(false);
void processSection(currentSection, true);
};
const handleSkip = () => {
setSteps((prev) =>
prev.map((s, i) =>
i === currentSection
? { ...s, status: 'skipped' as WizardStepStatus, message: s.message ? `${s.message} (übersprungen)` : 'Übersprungen' }
: s,
),
);
setAwaitingDecision(false);
setCurrentSection((s) => s + 1);
};
const handleConfirmDelete = async () => {
setDeleteStatus('deleting');
try {
await inboxApi.remove(documentId);
setDeleteStatus('done');
message.success('Dokument gelöscht');
setTimeout(() => onDeleted(), 600);
} catch {
message.error('Löschen fehlgeschlagen');
setDeleteStatus('awaiting');
}
};
const handleSkipDelete = () => {
onClose();
};
const stepItems = documents.map((doc, idx) => {
const step = steps[idx] ?? { status: 'pending' as WizardStepStatus };
const firstPage = doc.pages[0];
const lastPage = doc.pages[doc.pages.length - 1];
const pageRange = firstPage === lastPage ? `Seite ${firstPage}` : `Seiten ${firstPage}${lastPage}`;
let antStatus: 'wait' | 'process' | 'finish' | 'error' = 'wait';
let icon: ReactNode | undefined;
if (step.status === 'processing') {
antStatus = 'process';
icon = <LoadingOutlined />;
} else if (step.status === 'success') {
antStatus = 'finish';
} else if (step.status === 'duplicate' || step.status === 'error') {
antStatus = 'error';
} else if (step.status === 'skipped') {
antStatus = 'wait';
}
const actionLabels: Record<string, string> = {
PAPERLESS: 'An Paperless gesendet',
MAIL: 'Per E-Mail versendet',
EXPORT: 'Exportiert',
};
const successLabel = step.results && step.results.length > 0
? [...new Set(step.results.map((r) => actionLabels[r.actionType] ?? r.actionType))].join(', ')
: 'Verarbeitet';
let description: ReactNode = <span style={{ color: '#999' }}>{pageRange}</span>;
if (step.status === 'processing') {
description = (
<Space direction="vertical" size={2}>
<span style={{ color: '#999' }}>{pageRange}</span>
<span>Wird verarbeitet </span>
</Space>
);
} else if (step.status === 'success') {
description = (
<Space direction="vertical" size={2}>
<span style={{ color: '#999' }}>{pageRange}</span>
<span style={{ color: '#52c41a' }}>{successLabel}</span>
</Space>
);
} else if (step.status === 'duplicate') {
const dupDocId = step.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null;
description = (
<Space direction="vertical" size={4}>
<span style={{ color: '#999' }}>{pageRange}</span>
<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)}>
Vergleichen
</Button>
)}
<Button size="small" onClick={handleSkip}>
Überspringen
</Button>
</Space>
)}
</Space>
);
} else if (step.status === 'error') {
description = (
<Space direction="vertical" size={4}>
<span style={{ color: '#999' }}>{pageRange}</span>
<span style={{ color: '#ff4d4f' }}>{step.message ?? 'Fehler'}</span>
{idx === currentSection && awaitingDecision && (
<Button size="small" onClick={handleSkip}>
Überspringen
</Button>
)}
</Space>
);
} else if (step.status === 'skipped') {
description = (
<Space direction="vertical" size={2}>
<span style={{ color: '#999' }}>{pageRange}</span>
<span style={{ color: '#999' }}>{step.message ?? 'Übersprungen'}</span>
</Space>
);
}
return {
title: `Abschnitt ${idx + 1}`,
description,
status: antStatus,
icon,
};
});
// Lösch-Schritt am Ende anhängen
let deleteAntStatus: 'wait' | 'process' | 'finish' | 'error' = 'wait';
let deleteIcon: ReactNode | undefined;
let deleteDescription: ReactNode = (
<span style={{ color: '#999' }}>Wird angeboten, sobald alle Abschnitte erfolgreich sind</span>
);
if (deleteStatus === 'blocked') {
deleteAntStatus = 'error';
deleteDescription = (
<span style={{ color: '#999' }}>Nicht möglich keine Abschnitte verarbeitet</span>
);
} else if (deleteStatus === 'awaiting') {
deleteAntStatus = 'process';
deleteDescription = (
<Space direction="vertical" size={4}>
<span>Soll das Dokument jetzt aus der Inbox entfernt werden?</span>
<Space>
<Popconfirm
title="Dokument wirklich löschen?"
description="Datei und Datenbank-Eintrag werden entfernt."
okText="Ja, löschen"
okButtonProps={{ danger: true }}
cancelText="Abbrechen"
onConfirm={handleConfirmDelete}
>
<Button type="primary" danger size="small" icon={<DeleteOutlined />}>
Ja, löschen
</Button>
</Popconfirm>
<Button size="small" onClick={handleSkipDelete}>
Nein, behalten
</Button>
</Space>
</Space>
);
} else if (deleteStatus === 'deleting') {
deleteAntStatus = 'process';
deleteIcon = <LoadingOutlined />;
deleteDescription = <span>Wird gelöscht </span>;
} else if (deleteStatus === 'done') {
deleteAntStatus = 'finish';
deleteDescription = <span style={{ color: '#52c41a' }}>Dokument gelöscht</span>;
}
const allItems = [
...stepItems,
{
title: 'Dokument löschen',
description: deleteDescription,
status: deleteAntStatus,
icon: deleteIcon,
},
];
const stepperCurrent = finished ? sectionCount : currentSection;
return (
<Modal
open={open}
title="Weiterverarbeitung"
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
{finished ? 'Schließen' : 'Abbrechen'}
</Button>,
]}
maskClosable={false}
width={680}
destroyOnClose
>
{documents.length === 0 ? (
<Empty description="Keine Abschnitte erkannt" />
) : (
<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();
}}
/>
</Modal>
);
}
interface DownloadSegmentsDialogProps {
open: boolean;
fileId: string;
fileName: string;
documents: DocumentSegment[];
onClose: () => void;
}
function DownloadSegmentsDialog({ open, fileId, fileName, documents, onClose }: DownloadSegmentsDialogProps) {
const [filenames, setFilenames] = useState<string[]>([]);
const [downloading, setDownloading] = useState<number | 'all' | null>(null);
useEffect(() => {
if (!open) return;
const base = fileName.replace(/\.pdf$/i, '');
setFilenames(
documents.map((doc) =>
doc.belegname || (documents.length === 1 ? base : `${base}_${doc.index + 1}`),
),
);
setDownloading(null);
}, [open, documents, fileName]);
const triggerDownload = (blob: Blob, name: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name.endsWith('.pdf') ? name : `${name}.pdf`;
a.click();
URL.revokeObjectURL(url);
};
const downloadOne = async (idx: number) => {
setDownloading(idx);
try {
const blob = await inboxApi.downloadSegmentBlob(fileId, documents[idx].pages);
triggerDownload(blob, filenames[idx] || fileName);
} catch {
message.error('Download fehlgeschlagen');
} finally {
setDownloading(null);
}
};
const downloadAll = async () => {
setDownloading('all');
try {
for (let i = 0; i < documents.length; i++) {
const blob = await inboxApi.downloadSegmentBlob(fileId, documents[i].pages);
triggerDownload(blob, filenames[i] || fileName);
if (i < documents.length - 1) await new Promise((r) => setTimeout(r, 300));
}
} catch {
message.error('Download fehlgeschlagen');
} finally {
setDownloading(null);
}
};
return (
<Modal
open={open}
title="Dokumente herunterladen"
onCancel={onClose}
footer={[
<Button key="close" onClick={onClose}>
Schließen
</Button>,
<Button key="all" type="primary" icon={<SaveOutlined />} loading={downloading === 'all'} disabled={downloading !== null && downloading !== 'all'} onClick={downloadAll}>
Alle herunterladen
</Button>,
]}
width={560}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 16 }}>
{documents.map((doc, i) => {
const first = doc.pages[0];
const last = doc.pages[doc.pages.length - 1];
const range = first === last ? `Seite ${first}` : `Seiten ${first}${last}`;
return (
<div key={doc.index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ minWidth: 90, color: '#888', fontSize: 12 }}>{range}</span>
<Input
value={filenames[i] ?? ''}
onChange={(e) =>
setFilenames((prev) => prev.map((f, j) => (j === i ? e.target.value : f)))
}
suffix=".pdf"
style={{ flex: 1 }}
/>
<Button
icon={<SaveOutlined />}
loading={downloading === i}
disabled={downloading !== null && downloading !== i}
onClick={() => downloadOne(i)}
>
Laden
</Button>
</div>
);
})}
</div>
</Modal>
);
}
function TiptapToolbar({ editor }: { editor: ReturnType<typeof useEditor> }) {
if (!editor) return null;
const btnStyle = (active: boolean): React.CSSProperties => ({
padding: '2px 8px',
border: '1px solid #d9d9d9',
borderRadius: 4,
cursor: 'pointer',
background: active ? '#e6f4ff' : '#fff',
fontWeight: active ? 600 : 400,
});
return (
<div style={{ display: 'flex', gap: 4, padding: '4px 0', borderBottom: '1px solid #f0f0f0', marginBottom: 6, flexWrap: 'wrap' }}>
<button style={btnStyle(editor.isActive('bold'))} onMouseDown={(e) => { e.preventDefault(); editor.chain().focus().toggleBold().run(); }}>F</button>
<button style={{ ...btnStyle(editor.isActive('italic')), fontStyle: 'italic' }} onMouseDown={(e) => { e.preventDefault(); editor.chain().focus().toggleItalic().run(); }}>K</button>
<button style={{ ...btnStyle(editor.isActive('underline')), textDecoration: 'underline' }} onMouseDown={(e) => { e.preventDefault(); editor.chain().focus().toggleUnderline().run(); }}>U</button>
<button style={btnStyle(editor.isActive('bulletList'))} onMouseDown={(e) => { e.preventDefault(); editor.chain().focus().toggleBulletList().run(); }}> Liste</button>
<button style={btnStyle(editor.isActive('orderedList'))} onMouseDown={(e) => { e.preventDefault(); editor.chain().focus().toggleOrderedList().run(); }}>1. Liste</button>
</div>
);
}
interface SendEmailDialogProps {
open: boolean;
fileId: string;
fileName: string;
documents: DocumentSegment[];
onClose: () => void;
}
function SendEmailDialog({ open, fileId, fileName, documents, onClose }: SendEmailDialogProps) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [filenames, setFilenames] = useState<string[]>([]);
const editor = useEditor({
extensions: [StarterKit, Underline],
content: '',
});
useEffect(() => {
if (!open) return;
form.resetFields();
editor?.commands.clearContent();
const base = fileName.replace(/\.pdf$/i, '');
setFilenames(
documents.map((doc) =>
doc.belegname || (documents.length === 1 ? base : `${base}_${doc.index + 1}`),
),
);
}, [open, documents, fileName, form, editor]);
const handleOk = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
await inboxApi.sendEmail(fileId, {
to: values.to,
subject: values.subject,
body: editor?.getText() ?? '',
html: editor?.getHTML(),
segments: documents.map((doc, i) => ({
pages: doc.pages,
filename: filenames[i] || fileName,
})),
});
message.success('E-Mail wurde gesendet');
onClose();
} catch (err: any) {
if (err?.errorFields) return;
message.error('E-Mail konnte nicht gesendet werden');
} finally {
setSubmitting(false);
}
};
return (
<Modal
open={open}
title="Als E-Mail-Anhang versenden"
onCancel={onClose}
onOk={handleOk}
okText="Senden"
cancelText="Abbrechen"
confirmLoading={submitting}
destroyOnClose
width={580}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="to" label="Empfänger" rules={[{ required: true, message: 'Bitte Empfänger angeben' }, { type: 'email', message: 'Ungültige E-Mail-Adresse' }]}>
<Input placeholder="empfaenger@beispiel.de" />
</Form.Item>
<Form.Item name="subject" label="Betreff" rules={[{ required: true, message: 'Bitte Betreff angeben' }]}>
<Input />
</Form.Item>
<Form.Item label="Nachricht">
<div style={{ border: '1px solid #d9d9d9', borderRadius: 6, padding: '6px 10px', minHeight: 160 }}>
<TiptapToolbar editor={editor} />
<EditorContent editor={editor} style={{ minHeight: 120, outline: 'none' }} />
</div>
</Form.Item>
<Form.Item label="Anhänge">
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{documents.map((doc, i) => {
const first = doc.pages[0];
const last = doc.pages[doc.pages.length - 1];
const range = first === last ? `Seite ${first}` : `Seiten ${first}${last}`;
return (
<div key={doc.index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ minWidth: 90, color: '#888', fontSize: 12 }}>{range}</span>
<Input
value={filenames[i] ?? ''}
onChange={(e) =>
setFilenames((prev) => prev.map((f, j) => (j === i ? e.target.value : f)))
}
suffix=".pdf"
/>
</div>
);
})}
</div>
</Form.Item>
</Form>
</Modal>
);
}
export default function InboxDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [file, setFile] = useState<InboxFile | null>(null);
const [loading, setLoading] = useState(true);
const [thumbUrls, setThumbUrls] = useState<Map<number, string>>(new Map());
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [selectedPage, setSelectedPage] = useState(1);
const [zoom, setZoom] = useState(1);
const [wizardOpen, setWizardOpen] = useState(false);
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
const [scanMode, setScanMode] = useState(false);
const [scanning, setScanning] = useState(false);
const [dragStart, setDragStart] = useState<{ clientX: number; clientY: number; relX: number; relY: number } | null>(null);
const [dragEnd, setDragEnd] = useState<{ relX: number; relY: number } | null>(null);
const thumbsRef = useRef<Map<number, string>>(new Map());
const previewRef = useRef<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const rotationFor = (page: number): number =>
file?.rotations?.[String(page)] ?? 0;
const currentRotation = rotationFor(selectedPage);
useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
(async () => {
try {
const list = await inboxApi.list();
if (cancelled) return;
const found = list.find((f) => f.id === id) ?? null;
setFile(found);
if (!found) message.error('Datei nicht gefunden');
} catch {
if (!cancelled) message.error('Datei konnte nicht geladen werden');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [id]);
const documents = useMemo<DocumentSegment[]>(() => {
if (!file) return [];
const splitPages = file.barcodes
.filter((b) => b.splitBefore)
.map((b) => b.page);
return buildDocuments(file.pageCount, splitPages, file.deletedPages, file.manualSplitPages, file.barcodes);
}, [file]);
const effectivePages = useMemo<number[]>(() => {
if (!file) return [];
const deleted = new Set(file.deletedPages);
return Array.from({ length: file.pageCount }, (_, i) => i + 1).filter(
(p) => !deleted.has(p),
);
}, [file]);
const selectedDocIndex = useMemo(() => {
if (documents.length === 0) return 0;
const idx = documents.findIndex((d) => d.pages.includes(selectedPage));
return idx >= 0 ? idx : 0;
}, [documents, selectedPage]);
// Falls die aktuell ausgewählte Seite verschwindet (z. B. nach Mark-as-deleted
// oder beim ersten Laden), auf erste verfügbare Seite springen.
useEffect(() => {
if (effectivePages.length === 0) return;
if (!effectivePages.includes(selectedPage)) {
setSelectedPage(effectivePages[0]);
}
}, [effectivePages, selectedPage]);
useEffect(() => {
if (!file || file.pageCount === 0) return;
let cancelled = false;
const fileId = file.id;
const pageCount = file.pageCount;
(async () => {
const pages = Array.from({ length: pageCount }, (_, i) => i + 1);
const results = await Promise.all(
pages.map(async (n) => {
try {
const blob = await inboxApi.thumbnailBlob(fileId, n);
return { page: n, url: URL.createObjectURL(blob) };
} catch {
return null;
}
}),
);
if (cancelled) {
for (const r of results) if (r) URL.revokeObjectURL(r.url);
return;
}
const map = new Map<number, string>();
for (const r of results) if (r) map.set(r.page, r.url);
thumbsRef.current = map;
setThumbUrls(map);
})();
return () => {
cancelled = true;
const current = thumbsRef.current;
thumbsRef.current = new Map();
setThumbUrls(new Map());
for (const url of current.values()) URL.revokeObjectURL(url);
};
}, [file?.id, file?.pageCount]);
useEffect(() => {
setZoom(1);
}, [selectedPage]);
const handlePrev = () => {
const i = effectivePages.indexOf(selectedPage);
if (i > 0) setSelectedPage(effectivePages[i - 1]);
};
const handleNext = () => {
const i = effectivePages.indexOf(selectedPage);
if (i >= 0 && i < effectivePages.length - 1) {
setSelectedPage(effectivePages[i + 1]);
}
};
const handleZoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
const handleZoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
const applyRotation = async (delta: number) => {
if (!file) return;
const target = selectedPage;
const next = (((rotationFor(target) + delta) % 360) + 360) % 360;
try {
await inboxApi.setPageRotation(file.id, target, next);
const list = await inboxApi.list();
const refreshed = list.find((f) => f.id === file.id) ?? null;
if (refreshed) setFile(refreshed);
} catch {
message.error('Rotation konnte nicht gespeichert werden');
}
};
const handleRotateLeft = () => applyRotation(-90);
const handleRotateRight = () => applyRotation(90);
const handleDeletePage = async () => {
if (!file) return;
const target = selectedPage;
try {
await inboxApi.removePage(file.id, target);
const list = await inboxApi.list();
const refreshed = list.find((f) => f.id === file.id) ?? null;
if (!refreshed) {
navigate('/inbox');
return;
}
const newDeleted = new Set(refreshed.deletedPages);
const allPages = Array.from({ length: refreshed.pageCount }, (_, i) => i + 1);
const newEffective = allPages.filter((p) => !newDeleted.has(p));
const nextPage =
newEffective.find((p) => p > target) ??
[...newEffective].reverse().find((p) => p < target) ??
newEffective[0] ??
1;
setFile(refreshed);
setSelectedPage(nextPage);
message.success(`Seite zur Löschung markiert`);
} catch (err: any) {
if (err?.response?.status === 409) {
message.warning('Mindestens eine Seite muss übrig bleiben');
} else {
message.error('Markieren fehlgeschlagen');
}
}
};
const handleToggleSplit = async () => {
if (!file) return;
try {
await inboxApi.toggleSplit(file.id, selectedPage);
const list = await inboxApi.list();
const refreshed = list.find((f) => f.id === file.id) ?? null;
if (refreshed) setFile(refreshed);
} catch {
message.error('Trennung konnte nicht gespeichert werden');
}
};
const handleResetEdits = async () => {
if (!file) return;
try {
await inboxApi.resetEdits(file.id);
const list = await inboxApi.list();
const refreshed = list.find((f) => f.id === file.id) ?? null;
if (refreshed) setFile(refreshed);
message.success('Bearbeitungen zurückgesetzt');
} catch {
message.error('Zurücksetzen fehlgeschlagen');
}
};
const toNormalizedImageCoords = useCallback((clientX: number, clientY: number) => {
const img = imgRef.current;
if (!img) return null;
const rect = img.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = clientX - cx;
const dy = clientY - cy;
const dxU = dx / zoom;
const dyU = dy / zoom;
const Rrad = currentRotation * Math.PI / 180;
const cosR = Math.cos(Rrad);
const sinR = Math.sin(Rrad);
const dxUr = dxU * cosR + dyU * sinR;
const dyUr = -dxU * sinR + dyU * cosR;
const rW = (currentRotation === 90 || currentRotation === 270) ? rect.height / zoom : rect.width / zoom;
const rH = (currentRotation === 90 || currentRotation === 270) ? rect.width / zoom : rect.height / zoom;
return {
x: Math.max(0, Math.min(1, (dxUr + rW / 2) / rW)),
y: Math.max(0, Math.min(1, (dyUr + rH / 2) / rH)),
};
}, [zoom, currentRotation]);
const handleOverlayMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
const overlayRect = e.currentTarget.getBoundingClientRect();
setDragStart({ clientX: e.clientX, clientY: e.clientY, relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top });
setDragEnd(null);
}, []);
const handleOverlayMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!dragStart) return;
const overlayRect = e.currentTarget.getBoundingClientRect();
setDragEnd({ relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top });
}, [dragStart]);
const handleOverlayMouseUp = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (!dragStart || !file) { setDragStart(null); setDragEnd(null); return; }
const overlayRect = e.currentTarget.getBoundingClientRect();
const end = { clientX: e.clientX, clientY: e.clientY, relX: e.clientX - overlayRect.left, relY: e.clientY - overlayRect.top };
setDragStart(null);
setDragEnd(null);
setScanMode(false);
const p1 = toNormalizedImageCoords(dragStart.clientX, dragStart.clientY);
const p2 = toNormalizedImageCoords(end.clientX, end.clientY);
if (!p1 || !p2) return;
const rx = Math.min(p1.x, p2.x);
const ry = Math.min(p1.y, p2.y);
const rw = Math.abs(p2.x - p1.x);
const rh = Math.abs(p2.y - p1.y);
if (rw < 0.01 || rh < 0.01) { message.warning('Bitte einen größeren Bereich auswählen'); return; }
setScanning(true);
try {
const result = await inboxApi.scanRegion(file.id, selectedPage, { x: rx, y: ry, w: rw, h: rh });
if (result.found.length > 0) {
message.success(`QR-Code gefunden: ${result.found.join(', ')}`);
const list = await inboxApi.list();
const refreshed = list.find((f) => f.id === file.id) ?? null;
if (refreshed) setFile(refreshed);
} else {
message.warning('Kein QR-Code in diesem Bereich gefunden');
}
} catch {
message.error('QR-Bereich-Scan fehlgeschlagen');
} finally {
setScanning(false);
}
}, [dragStart, file, selectedPage, toNormalizedImageCoords]);
const handleOverlayMouseLeave = useCallback(() => {
setDragStart(null);
setDragEnd(null);
setScanMode(false);
}, []);
useEffect(() => {
if (!file) return;
let cancelled = false;
(async () => {
try {
const blob = await inboxApi.pagePreviewBlob(file.id, selectedPage);
if (cancelled) return;
const url = URL.createObjectURL(blob);
if (previewRef.current) URL.revokeObjectURL(previewRef.current);
previewRef.current = url;
setPreviewUrl(url);
} catch {
// silent
}
})();
return () => {
cancelled = true;
};
}, [file?.id, selectedPage]);
useEffect(
() => () => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current);
previewRef.current = null;
}
},
[],
);
if (loading) {
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
}
if (!file) {
return (
<div>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/inbox')}
style={{ marginBottom: 16 }}
>
Zurück
</Button>
<Empty description="Datei nicht gefunden" />
</div>
);
}
const currentDoc = documents[selectedDocIndex];
const sidebarPages = currentDoc?.pages ?? [];
const effectiveIndex = effectivePages.indexOf(selectedPage);
const canPrev = effectiveIndex > 0;
const canNext = effectiveIndex >= 0 && effectiveIndex < effectivePages.length - 1;
const canDelete = effectivePages.length > 1;
const isSplitPage = file.manualSplitPages.includes(selectedPage);
const canSplit = selectedPage !== currentDoc?.pages[0];
const rotationCount = Object.keys(file.rotations ?? {}).length;
const manualSplitCount = file.manualSplitPages.length;
const pendingEdits = file.deletedPages.length + rotationCount + manualSplitCount;
const editsLabel = (() => {
const parts: string[] = [];
if (file.deletedPages.length > 0) parts.push(`${file.deletedPages.length} zur Löschung markiert`);
if (rotationCount > 0) parts.push(`${rotationCount} gedreht`);
if (manualSplitCount > 0) parts.push(`${manualSplitCount} manuell getrennt`);
return parts.join(' · ');
})();
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
gap: 12,
}}
>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
Zurück
</Button>
<Title level={4} style={{ margin: 0 }}>
{file.name}
</Title>
<SourceTag source={file.source} />
</Space>
<Dropdown.Button
type="primary"
icon={<DownOutlined />}
disabled={documents.length === 0}
onClick={() => setWizardOpen(true)}
menu={{
items: [
{ key: 'save', label: 'Speichern', icon: <SaveOutlined /> },
{ key: 'email', label: 'Als E-Mail-Anhang versenden', icon: <MailOutlined /> },
] as MenuProps['items'],
onClick: ({ key }) => {
if (key === 'save') {
setDownloadDialogOpen(true);
}
if (key === 'email') setEmailDialogOpen(true);
},
}}
>
<ThunderboltOutlined /> Weiterverarbeiten
</Dropdown.Button>
</div>
{pendingEdits > 0 && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 12px',
background: '#fffbe6',
border: '1px solid #ffe58f',
borderRadius: 8,
marginBottom: 12,
}}
>
<Typography.Text>
{editsLabel}
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
(wird erst bei der Weiterverarbeitung angewendet)
</Typography.Text>
</Typography.Text>
<Button size="small" type="link" onClick={handleResetEdits}>
Zurücksetzen
</Button>
</div>
)}
<div
style={{
display: 'flex',
gap: 8,
padding: 8,
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
overflowX: 'auto',
marginBottom: 12,
}}
>
{documents.length === 0 ? (
<Typography.Text type="secondary">Keine Seiten</Typography.Text>
) : (
documents.map((doc) => {
const active = doc.index === selectedDocIndex;
const cover = doc.pages[0];
const url = thumbUrls.get(cover);
return (
<div
key={doc.index}
style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center' }}
>
{doc.belegname && (
<div
title={doc.belegname}
style={{
fontSize: 10,
color: '#555',
maxWidth: 92,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 3,
textAlign: 'center',
}}
>
{doc.belegname}
</div>
)}
<div
onClick={() => setSelectedPage(cover)}
style={{
cursor: 'pointer',
padding: 2,
border: active ? '2px solid #1677ff' : '2px solid transparent',
borderRadius: 6,
position: 'relative',
width: 92,
height: 120,
}}
>
<div
style={{
width: '100%',
height: '100%',
background: '#fff',
border: '1px solid #e0e0e0',
borderRadius: 4,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{url ? (
<img
src={url}
alt={`Dokument ${doc.index + 1}`}
style={thumbImageStyle(rotationFor(cover), 84)}
/>
) : (
<Spin size="small" />
)}
</div>
<span
style={{
position: 'absolute',
bottom: 4,
right: 4,
padding: '1px 6px',
fontSize: 11,
lineHeight: '14px',
background: 'rgba(0, 0, 0, 0.65)',
color: '#fff',
borderRadius: 8,
}}
>
{doc.pages.length}
</span>
</div>
</div>
);
})
)}
</div>
<div
style={{
display: 'flex',
flex: 1,
minHeight: 0,
gap: 8,
}}
>
<div
style={{
width: 140,
overflowY: 'auto',
padding: 6,
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
}}
>
{sidebarPages.map((n, idx) => {
const active = n === selectedPage;
const url = thumbUrls.get(n);
const docPage = idx + 1;
return (
<div
key={n}
onClick={() => setSelectedPage(n)}
style={{
cursor: 'pointer',
marginBottom: 6,
padding: 2,
border: active ? '2px solid #1677ff' : '2px solid transparent',
borderRadius: 6,
background: '#fff',
position: 'relative',
}}
>
<div
style={{
width: '100%',
height: 170,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
}}
>
{url ? (
<img
src={url}
alt={`Seite ${docPage}`}
style={thumbImageStyle(rotationFor(n), 130)}
/>
) : (
<Spin size="small" />
)}
</div>
<span
style={{
position: 'absolute',
bottom: 4,
left: '50%',
transform: 'translateX(-50%)',
padding: '1px 6px',
fontSize: 11,
lineHeight: '14px',
background: 'rgba(0, 0, 0, 0.65)',
color: '#fff',
borderRadius: 8,
}}
>
{docPage}
</span>
</div>
);
})}
</div>
<div
style={{
flex: 1,
minWidth: 0,
position: 'relative',
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
background: 'rgba(50, 50, 50, 0.6)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.15)',
borderRadius: 999,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.25)',
color: '#fff',
}}
>
<Tooltip title="Vorherige Seite">
<Button
type="text"
shape="circle"
icon={<LeftOutlined style={{ fontSize: 16 }} />}
onClick={handlePrev}
disabled={!canPrev}
style={{ color: '#fff' }}
/>
</Tooltip>
<Tooltip title="Nächste Seite">
<Button
type="text"
shape="circle"
icon={<RightOutlined style={{ fontSize: 16 }} />}
onClick={handleNext}
disabled={!canNext}
style={{ color: '#fff' }}
/>
</Tooltip>
<span style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
<Tooltip title="Reinzoomen">
<Button
type="text"
shape="circle"
icon={<ZoomInOutlined style={{ fontSize: 16 }} />}
onClick={handleZoomIn}
disabled={zoom >= ZOOM_MAX}
style={{ color: '#fff' }}
/>
</Tooltip>
<Tooltip title="Rauszoomen">
<Button
type="text"
shape="circle"
icon={<ZoomOutOutlined style={{ fontSize: 16 }} />}
onClick={handleZoomOut}
disabled={zoom <= ZOOM_MIN}
style={{ color: '#fff' }}
/>
</Tooltip>
<span style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
<Tooltip title="90° nach links drehen">
<Button
type="text"
shape="circle"
icon={<UndoOutlined style={{ fontSize: 16 }} />}
onClick={handleRotateLeft}
style={{ color: '#fff' }}
/>
</Tooltip>
<Tooltip title="90° nach rechts drehen">
<Button
type="text"
shape="circle"
icon={<RedoOutlined style={{ fontSize: 16 }} />}
onClick={handleRotateRight}
style={{ color: '#fff' }}
/>
</Tooltip>
<span style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
{canDelete ? (
<Popconfirm
title="Seite zur Löschung markieren?"
description="Wird erst bei der Weiterverarbeitung tatsächlich entfernt."
okText="Markieren"
cancelText="Abbrechen"
okButtonProps={{ danger: true }}
onConfirm={handleDeletePage}
>
<Tooltip title="Seite zur Löschung markieren">
<Button
type="text"
shape="circle"
icon={<DeleteOutlined style={{ fontSize: 16 }} />}
style={{ color: '#ff7875' }}
/>
</Tooltip>
</Popconfirm>
) : (
<Tooltip title="Mindestens eine Seite muss übrig bleiben">
<Button
type="text"
shape="circle"
icon={<DeleteOutlined style={{ fontSize: 16 }} />}
disabled
style={{ color: 'rgba(255,255,255,0.3)' }}
/>
</Tooltip>
)}
<span style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.2)' }} />
{canSplit && (
<Tooltip title={isSplitPage ? 'Manuelle Trennung aufheben' : 'Vor dieser Seite trennen'}>
<Button
type="text"
shape="circle"
icon={<ScissorOutlined style={{ fontSize: 16 }} />}
onClick={handleToggleSplit}
style={{ color: isSplitPage ? '#ffd666' : '#fff' }}
/>
</Tooltip>
)}
<Tooltip title={scanMode ? 'Bereich-Scan abbrechen' : 'QR-Code in Bereich scannen'}>
<Button
type="text"
shape="circle"
icon={scanning
? <LoadingOutlined style={{ fontSize: 16 }} />
: <QrcodeOutlined style={{ fontSize: 16 }} />}
onClick={() => { setScanMode((v) => !v); setDragStart(null); setDragEnd(null); }}
disabled={scanning}
style={{ color: scanMode ? '#52c41a' : '#fff' }}
/>
</Tooltip>
</div>
<div
style={{
width: '100%',
height: '100%',
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 6,
}}
>
{previewUrl ? (
<img
ref={imgRef}
src={previewUrl}
alt={`Vorschau Seite ${selectedPage}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
transform: `rotate(${currentRotation}deg) scale(${zoom})`,
transformOrigin: 'center center',
transition: 'transform 0.15s ease-out',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
background: '#fff',
}}
/>
) : (
<Spin />
)}
</div>
{/* Overlay für QR-Bereich-Scan */}
<div
ref={overlayRef}
style={{
position: 'absolute',
inset: 0,
zIndex: scanMode || dragStart ? 5 : -1,
cursor: scanMode ? 'crosshair' : 'default',
pointerEvents: scanMode ? 'all' : 'none',
}}
onMouseDown={handleOverlayMouseDown}
onMouseMove={handleOverlayMouseMove}
onMouseUp={handleOverlayMouseUp}
onMouseLeave={handleOverlayMouseLeave}
>
{dragStart && dragEnd && (
<div
style={{
position: 'absolute',
left: Math.min(dragStart.relX, dragEnd.relX),
top: Math.min(dragStart.relY, dragEnd.relY),
width: Math.abs(dragEnd.relX - dragStart.relX),
height: Math.abs(dragEnd.relY - dragStart.relY),
border: '2px dashed #1677ff',
background: 'rgba(22, 119, 255, 0.1)',
pointerEvents: 'none',
}}
/>
)}
</div>
</div>
</div>
<PostprocessWizardModal
open={wizardOpen}
documentId={file.id}
documents={documents}
onClose={() => setWizardOpen(false)}
onDeleted={() => navigate('/inbox')}
/>
<DownloadSegmentsDialog
open={downloadDialogOpen}
fileId={file.id}
fileName={file.name}
documents={documents}
onClose={() => setDownloadDialogOpen(false)}
/>
<SendEmailDialog
open={emailDialogOpen}
fileId={file.id}
fileName={file.name}
documents={documents}
onClose={() => setEmailDialogOpen(false)}
/>
</div>
);
}