1597 lines
53 KiB
TypeScript
1597 lines
53 KiB
TypeScript
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>
|
||
);
|
||
}
|