import { cloneElement, useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'; import { WysiwygEditor, type WysiwygEditorHandle } from '../components/WysiwygEditor'; import { useNavigate, useParams } from 'react-router-dom'; import { Button, Dropdown, Empty, Form, Input, Modal, Popconfirm, Select, 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 { inboxApi, type InboxBarcode, type InboxFile, type PaperlessEntscheidung, type PostprocessActionResult } from '../api/inbox'; import type { BelegnummerPruefung } from '../api/paperless'; import { DuplikatVergleichModal } from '../components/DuplikatVergleichModal'; import { userSettingsApi, type SenderOption } from '../api/userSettings'; import { useIsMobile } from '../hooks/useIsMobile'; 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' ? ( } color="purple"> Persönlich ) : ( } color="blue"> Gemeinsam ); } 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([]); const [currentSection, setCurrentSection] = useState(0); const [awaitingDecision, setAwaitingDecision] = useState(false); const [finished, setFinished] = useState(false); const [busy, setBusy] = useState(false); const [deleteStatus, setDeleteStatus] = useState('pending'); const [compareForSection, setCompareForSection] = useState(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) => { setSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } as WizardStep : s))); }; const processSection = async (idx: number, entscheidung?: PaperlessEntscheidung) => { if (!documentId) return; setBusy(true); setStepAt(idx, { status: 'processing', message: undefined }); try { const { results } = await inboxApi.postprocess(documentId, { sectionOffset: idx, processOnlyOne: true, entscheidung, }); 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); // Treffer in Paperless → Vergleich direkt öffnen if (skipped.duplicateOfDocumentId !== undefined && skipped.duplikatKandidaten?.length) { setCompareForSection(idx); } } else if (failed.length > 0) { const msg = failed.map((f) => f.message ?? f.actionType).join(', '); setStepAt(idx, { status: 'error', message: msg, results }); setAwaitingDecision(true); } else if (results.length === 0) { setStepAt(idx, { status: 'error', message: 'Keine aktive Aktion konfiguriert', results }); setAwaitingDecision(true); } else if (entscheidung?.aktion === 'verwerfen') { setStepAt(idx, { status: 'skipped', message: 'Verworfen', results }); setCurrentSection((s) => s + 1); } else { setStepAt(idx, { status: 'success', results }); setCurrentSection((s) => s + 1); } } 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 handleVersion = () => { const documentId = steps[currentSection]?.results?.find((r) => r.versionMoeglich)?.duplicateOfDocumentId; if (documentId === undefined) return; setCompareForSection(null); setAwaitingDecision(false); void processSection(currentSection, { aktion: 'version', documentId }); }; // Folgende Aktionen (z. B. Export) laufen trotzdem – nur der Paperless-Upload entfällt const handleVerwerfen = () => { setCompareForSection(null); setAwaitingDecision(false); void processSection(currentSection, { aktion: 'verwerfen' }); }; const handleSkip = () => { setSteps((prev) => prev.map((s, i) => 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 = ; } 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 = { 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 = {pageRange}; if (step.status === 'processing') { description = ( {pageRange} Wird verarbeitet … ); } else if (step.status === 'success') { description = ( {pageRange} {successLabel} ); } else if (step.status === 'duplicate') { const dupDocId = step.results?.find((r) => r.duplicateOfDocumentId)?.duplicateOfDocumentId ?? null; description = ( {pageRange} {step.message ?? 'Duplikat erkannt'} {idx === currentSection && awaitingDecision && ( {dupDocId !== null && ( )} )} ); } else if (step.status === 'error') { description = ( {pageRange} {step.message ?? 'Fehler'} {idx === currentSection && awaitingDecision && ( )} ); } else if (step.status === 'skipped') { description = ( {pageRange} {step.message ?? 'Übersprungen'} ); } 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 = ( Wird angeboten, sobald alle Abschnitte erfolgreich sind ); if (deleteStatus === 'blocked') { deleteAntStatus = 'error'; deleteDescription = ( Nicht möglich – keine Abschnitte verarbeitet ); } else if (deleteStatus === 'awaiting') { deleteAntStatus = 'process'; deleteDescription = ( Soll das Dokument jetzt aus der Inbox entfernt werden? ); } else if (deleteStatus === 'deleting') { deleteAntStatus = 'process'; deleteIcon = ; deleteDescription = Wird gelöscht …; } else if (deleteStatus === 'done') { deleteAntStatus = 'finish'; deleteDescription = Dokument gelöscht; } const allItems = [ ...stepItems, { title: 'Dokument löschen', description: deleteDescription, status: deleteAntStatus, icon: deleteIcon, }, ]; const stepperCurrent = finished ? sectionCount : currentSection; return ( {finished ? 'Schließen' : 'Abbrechen'} , ]} maskClosable={false} width={680} destroyOnClose > {documents.length === 0 ? ( ) : ( )} {compareForSection !== null && (() => { const dup = steps[compareForSection]?.results?.find((r) => r.skipped); const docId = dup?.duplicateOfDocumentId ?? null; const pages = documents[compareForSection]?.pages ?? []; const kandidaten = dup?.duplikatKandidaten ?? []; const pruefung: BelegnummerPruefung | null = kandidaten.length ? { status: dup?.versionMoeglich ? 'eindeutig' : 'mehrdeutig', documentId: dup?.versionMoeglich ? docId : null, kandidaten, } : null; return ( inboxApi.downloadSegmentBlob(documentId, pages)} ladeKennzahlen={() => inboxApi.vergleich(documentId, docId ?? 0, pages)} onVersion={handleVersion} onVerwerfen={handleVerwerfen} onClose={() => setCompareForSection(null)} /> ); })()} ); } interface DownloadSegmentsDialogProps { open: boolean; fileId: string; fileName: string; documents: DocumentSegment[]; thumbUrls: Map; onClose: () => void; } function DownloadSegmentsDialog({ open, fileId, fileName, documents, thumbUrls, onClose }: DownloadSegmentsDialogProps) { const navigate = useNavigate(); const [filenames, setFilenames] = useState([]); const [downloading, setDownloading] = useState(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'); let success = false; 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)); } success = true; } catch { message.error('Download fehlgeschlagen'); } finally { setDownloading(null); } if (success) { Modal.confirm({ title: 'Dokument löschen?', content: 'Das Dokument wurde heruntergeladen. Soll es jetzt aus der Eingangsbox entfernt werden?', okText: 'Ja, löschen', okButtonProps: { danger: true }, cancelText: 'Nein, behalten', onOk: async () => { try { await inboxApi.remove(fileId); navigate('/inbox'); } catch { message.error('Löschen fehlgeschlagen'); } finally { onClose(); } }, onCancel: onClose, }); } }; return ( Schließen , , ]} width={560} destroyOnClose >
{documents.map((doc, i) => { const thumbUrl = thumbUrls.get(doc.pages[0]); return (
: null} placement="right" mouseEnterDelay={0.2} >
{thumbUrl ? : }
setFilenames((prev) => prev.map((f, j) => (j === i ? e.target.value : f))) } suffix=".pdf" style={{ flex: 1 }} />
); })}
); } interface SendEmailDialogProps { open: boolean; fileId: string; fileName: string; documents: DocumentSegment[]; thumbUrls: Map; onClose: () => void; } function SendEmailDialog({ open, fileId, fileName, documents, thumbUrls, onClose }: SendEmailDialogProps) { const navigate = useNavigate(); const [form] = Form.useForm(); const [submitting, setSubmitting] = useState(false); const [filenames, setFilenames] = useState([]); const [senders, setSenders] = useState([]); const [selectedSender, setSelectedSender] = useState('default'); const [recipientHistory, setRecipientHistory] = useState([]); const editorRef = useRef(null); useEffect(() => { if (!open) return; form.resetFields(); const base = fileName.replace(/\.pdf$/i, ''); setFilenames( documents.map((doc) => doc.belegname || (documents.length === 1 ? base : `${base}_${doc.index + 1}`), ), ); userSettingsApi.get().then((settings) => { editorRef.current?.setContent(settings.mailSignatureHtml ?? ''); setRecipientHistory(settings.emailRecipientHistory ?? []); }).catch(() => {}); userSettingsApi.getSenders().then((s) => { setSenders(s); setSelectedSender(s.length > 1 ? 'user' : 'default'); }).catch(() => setSenders([])); }, [open, documents, fileName, form]); const removeRecipient = (addr: string, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const updated = recipientHistory.filter((a) => a !== addr); setRecipientHistory(updated); userSettingsApi.update({ emailRecipientHistory: updated }).catch(() => {}); }; const handleOk = async () => { try { const values = await form.validateFields(); setSubmitting(true); const addresses: string[] = values.to ?? []; await inboxApi.sendEmail(fileId, { to: addresses.join(', '), subject: values.subject, body: editorRef.current?.getText() ?? '', html: editorRef.current?.getHTML(), segments: documents.map((doc, i) => ({ pages: doc.pages, filename: filenames[i] || fileName, })), sender: senders.length > 1 ? selectedSender : undefined, }); const updated = [ ...addresses, ...recipientHistory.filter((a) => !addresses.includes(a)), ].slice(0, 20); setRecipientHistory(updated); userSettingsApi.update({ emailRecipientHistory: updated }).catch(() => {}); message.success('E-Mail wurde gesendet'); Modal.confirm({ title: 'Dokument löschen?', content: 'Die E-Mail wurde gesendet. Soll das Dokument jetzt aus der Eingangsbox entfernt werden?', okText: 'Ja, löschen', okButtonProps: { danger: true }, cancelText: 'Nein, behalten', onOk: async () => { try { await inboxApi.remove(fileId); navigate('/inbox'); } catch { message.error('Löschen fehlgeschlagen'); } finally { onClose(); } }, onCancel: onClose, }); } catch (err: any) { if (err?.errorFields) return; message.error('E-Mail konnte nicht gesendet werden'); } finally { setSubmitting(false); } }; return (
{senders.length > 1 && ( ({ value: addr, label: addr }))} optionRender={(option) => (
{option.label}
)} notFoundContent={Kein Verlauf vorhanden} />
{documents.map((doc, i) => { const thumbUrl = thumbUrls.get(doc.pages[0]); return (
: null} placement="right" mouseEnterDelay={0.2} >
{thumbUrl ? : }
setFilenames((prev) => prev.map((f, j) => (j === i ? e.target.value : f))) } suffix=".pdf" />
); })}
); } export default function InboxDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const isMobile = useIsMobile(); const [file, setFile] = useState(null); const [loading, setLoading] = useState(true); const [thumbUrls, setThumbUrls] = useState>(new Map()); const [previewUrl, setPreviewUrl] = useState(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>(new Map()); const previewRef = useRef(null); const imgRef = useRef(null); const overlayRef = useRef(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(() => { 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(() => { 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(); 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) => { 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) => { 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) => { 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 ; } if (!file) { return (
); } 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 canProcess = documents.length > 0 && documents.every((doc) => doc.belegname); 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 (
{file.name} } onClick={() => { if (canProcess) setWizardOpen(true); }} buttonsRender={([leftButton, rightButton]) => [ cloneElement(leftButton as ReactElement<{ disabled?: boolean }>, { disabled: !canProcess }), rightButton, ]} menu={{ items: [ { key: 'save', label: 'Speichern', icon: }, { key: 'email', label: 'Als E-Mail-Anhang versenden', icon: }, { type: 'divider' }, { key: 'delete', label: 'Dokument löschen', icon: , danger: true }, ] as MenuProps['items'], onClick: ({ key }) => { if (key === 'save') setDownloadDialogOpen(true); if (key === 'email') setEmailDialogOpen(true); if (key === 'delete') { Modal.confirm({ title: 'Dokument löschen?', content: 'Datei und Datenbank-Eintrag werden dauerhaft entfernt.', okText: 'Löschen', okButtonProps: { danger: true }, cancelText: 'Abbrechen', onOk: async () => { try { await inboxApi.remove(file.id); message.success('Dokument gelöscht'); navigate('/inbox'); } catch { message.error('Löschen fehlgeschlagen'); } }, }); } }, }} > Weiterverarbeiten
{pendingEdits > 0 && (
{editsLabel} (wird erst bei der Weiterverarbeitung angewendet)
)}
{documents.length === 0 ? ( Keine Seiten ) : ( documents.map((doc) => { const active = doc.index === selectedDocIndex; const cover = doc.pages[0]; const url = thumbUrls.get(cover); return (
{doc.belegname && (
{doc.belegname}
)}
setSelectedPage(cover)} style={{ cursor: 'pointer', padding: 2, border: active ? '2px solid #1677ff' : '2px solid transparent', borderRadius: 6, position: 'relative', width: 92, height: 120, }} >
{url ? ( {`Dokument ) : ( )}
{doc.pages.length}
); }) )}
{sidebarPages.map((n, idx) => { const active = n === selectedPage; const url = thumbUrls.get(n); const docPage = idx + 1; return (
setSelectedPage(n)} style={{ cursor: 'pointer', marginBottom: 6, padding: 2, border: active ? '2px solid #1677ff' : '2px solid transparent', borderRadius: 6, background: '#fff', position: 'relative', }} >
{url ? ( {`Seite ) : ( )}
{docPage}
); })}
{previewUrl ? ( {`Vorschau ) : ( )}
{/* Overlay für QR-Bereich-Scan */}
{dragStart && dragEnd && (
)}
setWizardOpen(false)} onDeleted={() => navigate('/inbox')} /> setDownloadDialogOpen(false)} /> setEmailDialogOpen(false)} />
); }