Files
paperlessmanager/paperless-frontend/src/components/MailImportWizard.tsx
T
bjoernpoettker b47ad17568
Build and Push Multi-Platform Images / build-and-push (push) Successful in 35s
feat: support selective page range extraction for email attachment print previews
2026-05-05 08:06:52 +02:00

750 lines
30 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 { useState, useEffect } from 'react';
import {
Modal, Steps, Button, Table, Radio, Select, Input, DatePicker,
Space, Row, Col, Typography, message, Spin, Result, Alert, Card, Tag
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
import { paperlessApi, type PaperlessCorrespondent } from '../api/paperless';
import dayjs from 'dayjs';
import {
PrinterOutlined, FileTextOutlined, PaperClipOutlined, ArrowRightOutlined,
WarningOutlined
} from '@ant-design/icons';
import PdfSplitViewer from './PdfSplitViewer';
import BarcodePositioner from './BarcodePositioner';
const { Text, Title } = Typography;
interface MailImportWizardProps {
visible: boolean;
onClose: () => void;
email: any;
attachments: any[];
}
export default function MailImportWizard({ visible, onClose, email, attachments }: MailImportWizardProps) {
const [currentStep, setCurrentStep] = useState(0);
const [importData, setImportData] = useState<AttachmentImportData[]>([]);
const [loading, setLoading] = useState(false);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [suggestedCorrespondentId, setSuggestedCorrespondentId] = useState<number | null>(null);
// Step 2 specific state
const [belegnummern, setBelegnummern] = useState<Record<string, string>>({});
const [barcodes, setBarcodes] = useState<Record<string, any>>({});
// Step 1 expand/collapse state
const [expandedRows, setExpandedRows] = useState<string[]>([]);
// Belegnummer mode per item: 'neu' (auto from API) or 'manuell'
const [belegnummerMode, setBelegnummerMode] = useState<Record<string, 'neu' | 'manuell'>>({});
// Eingangsdatum per item
const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({});
// Step 3 specific state
const [importSuccess, setImportSuccess] = useState(false);
useEffect(() => {
if (visible && attachments.length > 0) {
// Initialize import data
const initialData = attachments.map(att => ({
attachmentId: att.Id,
virtualId: `${att.Id}_full`,
type: 'MAIN' as 'MAIN' | 'ATTACHMENT' | 'IGNORE',
fileName: att.FileName,
pages: undefined, // full document initially
}));
setImportData(initialData);
setExpandedRows(initialData.length > 0 ? [initialData[0].virtualId] : []);
// Initialize eingangsdaten and barcodes with email date
const mailDate = dayjs(email.Date);
const initialDates: Record<string, dayjs.Dayjs> = {};
const initialBarcodes: Record<string, any> = {};
initialData.forEach(d => {
initialDates[d.virtualId] = mailDate;
initialBarcodes[d.virtualId] = {
x: 6,
y: 6,
datum: mailDate.format('YYYY-MM-DD'),
jahr: mailDate.format('YYYY'),
isNeu: true,
nummer: '000000'
};
});
setEingangsdaten(initialDates);
setBarcodes(initialBarcodes);
// Load correspondents and try to find suggestion
loadCorrespondents();
// Check for duplicates in Paperless
checkDuplicates(initialData);
}
}, [visible, attachments]);
const checkDuplicates = async (initialData: AttachmentImportData[]) => {
setLoading(true);
try {
const updatedData = [...initialData];
for (let i = 0; i < updatedData.length; i++) {
const item = updatedData[i];
const attachment = attachments.find(a => a.Id === item.attachmentId);
if (attachment && attachment.Checksum) {
const exists = await paperlessApi.checksumExists(attachment.Checksum);
if (exists) {
updatedData[i] = { ...updatedData[i], type: 'IGNORE', isDuplicate: true };
}
}
}
setImportData(updatedData);
} catch (e) {
console.error('Error checking duplicates', e);
} finally {
setLoading(false);
}
};
const loadCorrespondents = async () => {
try {
const data = await paperlessApi.getCorrespondents();
setCorrespondents(data || []);
// Try to find matching correspondent by email
// We parse the from address, which might be "Name <email@domain.com>"
const match = email.From.match(/<([^>]+)>/);
const emailAddress = match ? match[1] : email.From;
const mapping = await emailImportApi.getCorrespondentByEmail(emailAddress);
if (mapping && mapping.paperlessCorrespondentId) {
setSuggestedCorrespondentId(mapping.paperlessCorrespondentId);
setImportData(prev => prev.map(item => ({ ...item, paperlessCorrespondentId: mapping.paperlessCorrespondentId })));
}
} catch (e) {
// silently fail
}
};
const updateImportData = (virtualId: string, key: string, value: any) => {
setImportData(prev => prev.map(item => item.virtualId === virtualId ? { ...item, [key]: value } : item));
};
const handleSplit = (virtualId: string, splitPage: number) => {
setImportData(prev => {
const idx = prev.findIndex(i => i.virtualId === virtualId);
if (idx === -1) return prev;
const itemToSplit = prev[idx];
const start = itemToSplit.pages?.start || 1;
const end = itemToSplit.pages?.end || 999; // 999 means to the end
const part1 = { ...itemToSplit, virtualId: `${itemToSplit.attachmentId}_${start}_${splitPage}`, pages: { start, end: splitPage }, fileName: `${itemToSplit.fileName} (Teil 1)` };
const part2 = { ...itemToSplit, virtualId: `${itemToSplit.attachmentId}_${splitPage+1}_${end}`, pages: { start: splitPage + 1, end }, fileName: `${itemToSplit.fileName} (Teil 2)` };
// Propagate date and barcode
const parentDate = eingangsdaten[virtualId] || dayjs(email.Date);
const parentBarcode = barcodes[virtualId];
setEingangsdaten(prev => ({
...prev,
[part1.virtualId]: parentDate,
[part2.virtualId]: parentDate,
}));
if (parentBarcode) {
setBarcodes(prev => ({
...prev,
[part1.virtualId]: { ...parentBarcode },
[part2.virtualId]: { ...parentBarcode },
}));
}
const newArray = [...prev];
newArray.splice(idx, 1, part1, part2);
return newArray;
});
};
const loadBelegnummern = async () => {
// No longer fetching from API here, just initializing state for Step 2
for (const item of importData) {
if (item.type === 'IGNORE') continue;
const vid = item.virtualId;
const mode = belegnummerMode[vid] || 'neu';
const itemDate = eingangsdaten[vid] || dayjs(email.Date);
const dateStr = itemDate.format('YYYY-MM-DD');
const jahr = itemDate.format('YYYY');
if (mode === 'neu') {
setBarcodes(prev => ({
...prev,
[vid]: { ...prev[vid], x: prev[vid]?.x ?? 6, y: prev[vid]?.y ?? 6, datum: dateStr, jahr, isNeu: true, nummer: '000000' }
}));
} else if (mode === 'manuell') {
const manJahr = barcodes[vid]?.jahr || jahr;
const manNummer = belegnummern[vid] || '000000';
setBelegnummern(prev => ({ ...prev, [vid]: manNummer }));
setBarcodes(prev => ({
...prev,
[vid]: { ...prev[vid], x: prev[vid]?.x ?? 6, y: prev[vid]?.y ?? 6, datum: dateStr, jahr: manJahr, nummer: manNummer, isNeu: false }
}));
}
if (!eingangsdaten[vid]) {
setEingangsdaten(prev => ({ ...prev, [vid]: itemDate }));
}
if (!belegnummerMode[vid]) setBelegnummerMode(prev => ({ ...prev, [vid]: 'neu' }));
}
};
const preFetchBelegnummern = async () => {
setLoading(true);
try {
const newBelegnummern = { ...belegnummern };
const newBarcodes = { ...barcodes };
for (const item of importData) {
if (item.type === 'IGNORE') continue;
const vid = item.virtualId;
const mode = belegnummerMode[vid] || 'neu';
if (mode === 'neu' && (!newBelegnummern[vid] || newBelegnummern[vid] === '000000')) {
const itemDate = eingangsdaten[vid] || dayjs(email.Date);
const dateStr = itemDate.format('YYYY-MM-DD');
try {
const res = await emailImportApi.getBelegnummer(dateStr);
let num = res.nummer;
let yr = itemDate.format('YYYY');
if (num.includes('-')) {
const parts = num.split('-');
if (parts.length === 2 && parts[0].length === 4) {
yr = parts[0];
num = parts[1];
}
}
newBelegnummern[vid] = num;
newBarcodes[vid] = {
...newBarcodes[vid],
nummer: num,
jahr: yr,
datum: dateStr,
isNeu: false // We show it as "fixed" in summary
};
} catch (e) {
message.error(`Belegnummer für ${item.fileName} konnte nicht geladen werden.`);
}
}
}
setBelegnummern(newBelegnummern);
setBarcodes(newBarcodes);
} finally {
setLoading(false);
}
};
const handleBack = async () => {
if (currentStep === 2) {
setLoading(true);
try {
for (const item of importData) {
const vid = item.virtualId;
const mode = belegnummerMode[vid] || 'neu';
const num = belegnummern[vid];
if (mode === 'neu' && num && num !== '000000') {
const dateStr = (eingangsdaten[vid] || dayjs(email.Date)).format('YYYY-MM-DD');
await emailImportApi.releaseBelegnummer(dateStr, num);
}
}
// Clear numbers for "neu" mode so they get re-fetched
const clearedBelegnummern = { ...belegnummern };
const clearedBarcodes = { ...barcodes };
for (const vid in belegnummerMode) {
if (belegnummerMode[vid] === 'neu') {
clearedBelegnummern[vid] = '000000';
if (clearedBarcodes[vid]) {
clearedBarcodes[vid] = { ...clearedBarcodes[vid], nummer: '000000', isNeu: true };
}
}
}
setBelegnummern(clearedBelegnummern);
setBarcodes(clearedBarcodes);
} catch (e) {
console.error('Failed to release numbers', e);
} finally {
setLoading(false);
}
}
setCurrentStep(currentStep - 1);
};
const nextStep = async () => {
if (currentStep === 0) {
await loadBelegnummern();
} else if (currentStep === 1) {
await preFetchBelegnummern();
}
setCurrentStep(currentStep + 1);
};
const executeImport = async () => {
setLoading(true);
try {
const finalData = [];
for (const item of importData) {
if (item.type === 'IGNORE') continue;
let num = belegnummern[item.virtualId] || '000000';
let yr = barcodes[item.virtualId]?.jahr || eingangsdaten[item.virtualId]?.format('YYYY') || dayjs(email.Date).format('YYYY');
const mode = belegnummerMode[item.virtualId] || 'neu';
if (mode === 'neu' && (!num || num === '000000')) {
// Fallback in case pre-fetch failed or was skipped
const dateStr = (eingangsdaten[item.virtualId] || dayjs(email.Date)).format('YYYY-MM-DD');
try {
const res = await emailImportApi.getBelegnummer(dateStr);
num = res.nummer;
if (num.includes('-')) {
const parts = num.split('-');
if (parts.length === 2 && parts[0].length === 4) {
yr = parts[0];
num = parts[1];
}
}
} catch (e) {
throw new Error(`Konnte keine neue Belegnummer für ${item.fileName} abrufen.`);
}
} else {
// Manuell or already fetched: Ensure num is just the 6-digit part if it contains a dash
if (num.includes('-')) {
num = num.split('-')[1];
}
}
const finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`;
finalData.push({
...item,
splitRanges: item.pages ? [item.pages] : undefined,
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
belegnummer: finalBelegnummer,
});
}
await emailImportApi.executeImport(email.Date, finalData);
setImportSuccess(true);
setCurrentStep(2);
} catch (e: any) {
message.error(`Fehler beim Import: ${e.message}`);
} finally {
setLoading(false);
}
};
const printDocument = async (virtualId: string, attachmentId: number) => {
// Open a new tab immediately to satisfy pop-up blockers
const printWindow = window.open('', '_blank');
if (!printWindow) {
message.warning('Bitte Pop-ups erlauben, um direkt zu drucken.');
return;
}
printWindow.document.write('<html><head><title>Druckvorschau</title></head><body style="margin:0;display:flex;justify-content:center;align-items:center;height:100vh;background:#f0f0f0;font-family:sans-serif;"><div>Lade Dokument...</div></body></html>');
try {
const barcode = barcodes[virtualId];
if (!barcode) {
printWindow.close();
return;
}
const item = importData.find(i => i.virtualId === virtualId);
const pages = item?.pages;
const blob = await emailImportApi.printPreview(attachmentId, barcode, pages);
const url = window.URL.createObjectURL(blob);
// Navigate the already open window to the PDF
printWindow.location.href = url;
// Some browsers allow triggering print() on the new window
// but it's inconsistent for PDFs. Most PDF viewers have their own print button.
// So we just leave it open for the user.
} catch (e) {
message.error('Fehler beim Generieren der Druckvorschau');
printWindow.close();
}
};
// --- Step 1: Zuordnung Render ---
const renderStep1 = () => {
const columns: ColumnsType<any> = [
{
title: 'Dateiname',
dataIndex: 'fileName',
key: 'fileName',
render: (text, record) => (
<Space>
<Text delete={record.isDuplicate} type={record.isDuplicate ? 'secondary' : undefined}>{text}</Text>
{attachments.find(a => a.Id === record.attachmentId)?.Erechnung && (
<span style={{ color: 'green', border: '1px solid green', padding: '0 4px', borderRadius: '4px' }}>eRechnung</span>
)}
{record.isDuplicate && (
<Tag color="orange" icon={<WarningOutlined />}>Bereits vorhanden</Tag>
)}
</Space>
)
},
{
title: 'Aktion',
key: 'action',
render: (_, record) => {
const hasOtherMain = importData.some(item => item.type === 'MAIN' && item.virtualId !== record.virtualId);
const showAttachmentOption = importData.length > 1 && hasOtherMain;
return (
<Radio.Group
value={record.type}
disabled={record.isDuplicate}
onChange={e => updateImportData(record.virtualId, 'type', e.target.value)}
>
<Radio value="MAIN">Importieren</Radio>
<Radio value="IGNORE">Ignorieren</Radio>
{showAttachmentOption && <Radio value="ATTACHMENT">Als Anlage</Radio>}
</Radio.Group>
);
}
},
{
title: 'Hauptdokument',
key: 'parent',
render: (_, record) => {
if (record.type === 'ATTACHMENT') {
const mainItems = importData.filter(i => i.type === 'MAIN' && i.virtualId !== record.virtualId);
return (
<Select
style={{ width: 250 }}
showSearch
placeholder="Hauptdokument auswählen..."
optionFilterProp="children"
value={record.parentVirtualId}
disabled={record.isDuplicate}
onChange={(val) => updateImportData(record.virtualId, 'parentVirtualId', val)}
>
{mainItems.map(item => <Select.Option key={item.virtualId} value={item.virtualId}>{item.fileName}</Select.Option>)}
</Select>
);
}
return null;
}
}
];
return (
<div>
<div style={{ marginBottom: 16 }}>
<Text strong>Absender: </Text> <Text>{email.From}</Text>
<div style={{ marginTop: 8 }}>
<Text strong>Korrespondent (Paperless): </Text>
<Select
style={{ width: 300 }}
showSearch
allowClear
placeholder="Korrespondent auswählen"
value={suggestedCorrespondentId}
onChange={(val) => {
setSuggestedCorrespondentId(val);
setImportData(prev => prev.map(i => ({ ...i, paperlessCorrespondentId: val })));
}}
>
{correspondents.map(c => <Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>)}
</Select>
</div>
</div>
<Table
columns={columns}
dataSource={importData}
rowKey="virtualId"
pagination={false}
expandable={{
expandedRowKeys: expandedRows,
onExpand: (expanded, record) => {
setExpandedRows(expanded ? [record.virtualId] : []);
},
expandedRowRender: record => {
const originalAtt = attachments.find(a => a.Id === record.attachmentId);
if (!originalAtt) return null;
return (
<PdfSplitViewer
attachmentId={record.attachmentId}
pageCount={originalAtt.PageCount || 0}
startPage={record.pages?.start}
endPage={record.pages?.end === 999 ? undefined : record.pages?.end}
onSplit={(page) => handleSplit(record.virtualId, page)}
disabled={originalAtt.Erechnung}
/>
);
}
}}
/>
</div>
);
};
// --- Step 2: Bearbeitung Render ---
const renderStep2 = () => {
const toProcess = importData.filter(i => i.type !== 'IGNORE');
if (toProcess.length === 0) return <Text>Keine Dokumente zum Importieren ausgewählt.</Text>;
return (
<div>
{toProcess.map(item => (
<div key={item.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{item.fileName}</Text>
<Row gutter={24}>
<Col span={8}>
{/* Eingangsdatum */}
<div style={{ marginBottom: 16 }}>
<Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text>
<DatePicker
value={eingangsdaten[item.virtualId]}
onChange={(date) => {
if (date) {
setEingangsdaten(prev => ({ ...prev, [item.virtualId]: date }));
setBarcodes(prev => ({
...prev,
[item.virtualId]: { ...prev[item.virtualId], datum: date.format('YYYY-MM-DD'), jahr: date.format('YYYY') }
}));
}
}}
style={{ width: '100%' }}
format="DD.MM.YYYY"
/>
</div>
{/* Belegnummer */}
<Text strong style={{ display: 'block', marginBottom: 4 }}>Belegnummer</Text>
<Radio.Group
value={belegnummerMode[item.virtualId] || 'neu'}
onChange={e => {
const mode = e.target.value;
setBelegnummerMode(prev => ({ ...prev, [item.virtualId]: mode }));
if (mode === 'manuell') {
const jahr = (eingangsdaten[item.virtualId] || dayjs(email.Date)).format('YYYY');
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], jahr, isNeu: false } }));
if (!belegnummern[item.virtualId] || belegnummerMode[item.virtualId] === 'neu') {
setBelegnummern(prev => ({ ...prev, [item.virtualId]: '000000' }));
}
} else {
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], isNeu: true } }));
}
}}
style={{ marginBottom: 12 }}
>
<Radio value="neu">Neu</Radio>
<Radio value="manuell">Manuell</Radio>
</Radio.Group>
{(belegnummerMode[item.virtualId] || 'neu') === 'manuell' && (
<div>
<div style={{ marginBottom: 8 }}>
<Text style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>Jahr (4-stellig):</Text>
<Input
value={barcodes[item.virtualId]?.jahr || ''}
maxLength={4}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').slice(0, 4);
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], jahr: val } }));
}}
style={{ width: '100%' }}
placeholder="2026"
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>Nummer (6-stellig):</Text>
<Input
value={belegnummern[item.virtualId] || ''}
maxLength={6}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').slice(0, 6);
setBelegnummern(prev => ({ ...prev, [item.virtualId]: val }));
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], nummer: val } }));
}}
style={{ width: '100%' }}
placeholder="000000"
/>
</div>
</div>
)}
{(belegnummerMode[item.virtualId] || 'neu') === 'neu' && belegnummern[item.virtualId] && (
<div style={{ marginTop: 4 }}>
<Text style={{ fontSize: 13 }}>Reserviert: <Text strong>{belegnummern[item.virtualId]}</Text></Text>
</div>
)}
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Position: {barcodes[item.virtualId]?.x || 0} × {barcodes[item.virtualId]?.y || 0} mm
</Text>
</div>
</Col>
<Col span={16}>
<BarcodePositioner
attachmentId={item.attachmentId}
startPage={item.pages?.start}
belegnummer={belegnummern[item.virtualId] || ''}
isNeu={(belegnummerMode[item.virtualId] || 'neu') === 'neu'}
datum={barcodes[item.virtualId]?.datum}
jahr={barcodes[item.virtualId]?.jahr}
position={{ x: barcodes[item.virtualId]?.x || 0, y: barcodes[item.virtualId]?.y || 0 }}
onPositionChange={(pos) => setBarcodes(prev => ({
...prev,
[item.virtualId]: { ...prev[item.virtualId], x: pos.x, y: pos.y }
}))}
/>
</Col>
</Row>
</div>
))}
</div>
);
};
// --- Step 3: Abschluss Render ---
const renderStep3 = () => {
if (importSuccess) {
return (
<Result
status="success"
title="Import Erfolgreich!"
subTitle="Die Dokumente wurden erfolgreich nach Paperless importiert und die Belegnummern verbucht."
extra={[
<Button type="primary" key="close" onClick={onClose}>
Schließen
</Button>,
...importData.filter(i => i.type !== 'IGNORE').map(item => (
<Button key={`print-${item.virtualId}`} icon={<PrinterOutlined />} onClick={() => printDocument(item.virtualId, item.attachmentId)}>
Drucken: {item.fileName}
</Button>
))
]}
/>
);
}
// Summary view before import
const mainDocs = importData.filter(i => i.type === 'MAIN');
const attachmentsToImport = importData.filter(i => i.type === 'ATTACHMENT');
return (
<div style={{ padding: '0 24px' }}>
<Title level={4} style={{ marginBottom: 24 }}>Zusammenfassung des Imports</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
Bitte überprüfe die folgende Struktur. Mit Klick auf "Import Ausführen" werden die Belegnummern reserviert und die Dokumente hochgeladen.
</Text>
{mainDocs.length === 0 && <Alert type="warning" message="Keine Hauptdokumente zum Importieren ausgewählt." />}
{mainDocs.map(main => {
const mainAttachments = attachmentsToImport.filter(a => a.parentVirtualId === main.virtualId);
const mode = belegnummerMode[main.virtualId] || 'neu';
const num = belegnummern[main.virtualId] || '000000';
const yr = barcodes[main.virtualId]?.jahr || dayjs(email.Date).format('YYYY');
return (
<div key={main.virtualId} style={{ marginBottom: 24 }}>
<Card size="small" style={{ borderLeft: '4px solid #1890ff' }}>
<Row align="middle">
<Col span={16}>
<Space>
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div>
<Text strong style={{ fontSize: 16 }}>{main.fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>Hauptdokument</Text>
</div>
</Space>
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
<Tag color="blue" style={{ fontSize: 14, padding: '4px 12px' }}>
{mode === 'neu' ? `Belegnr.: ${yr}-${num}` : `Belegnr.: ${yr}-${num}`}
</Tag>
</Col>
</Row>
{mainAttachments.length > 0 && (
<div style={{ marginTop: 12, paddingLeft: 32 }}>
{mainAttachments.map(att => (
<div key={att.virtualId} style={{ marginBottom: 8, padding: '8px 12px', background: '#fafafa', borderRadius: 4, display: 'flex', alignItems: 'center' }}>
<ArrowRightOutlined style={{ marginRight: 12, color: '#8c8c8c' }} />
<PaperClipOutlined style={{ marginRight: 8 }} />
<Text style={{ flex: 1 }}>{att.fileName}</Text>
<Tag>Anlage</Tag>
</div>
))}
</div>
)}
</Card>
</div>
);
})}
{/* Orphans (Attachments without parent or parents ignored) */}
{attachmentsToImport.filter(a => !mainDocs.find(m => m.virtualId === a.parentVirtualId)).map(orphan => (
<div key={orphan.virtualId} style={{ marginBottom: 12 }}>
<Alert
type="error"
message={`Anlage ohne Hauptdokument: ${orphan.fileName}`}
description="Bitte gehe zurück und ordne diese Anlage einem Hauptdokument zu oder ignoriere sie."
/>
</div>
))}
</div>
);
};
return (
<Modal
title="Paperless Import-Wizard"
open={visible}
onCancel={onClose}
width={1000}
footer={
importSuccess ? null : (
<Space>
{currentStep > 0 && <Button onClick={handleBack}>Zurück</Button>}
{currentStep < 2 && <Button type="primary" onClick={nextStep} loading={loading}>Weiter</Button>}
{currentStep === 2 && <Button type="primary" onClick={executeImport} loading={loading}>Import Ausführen</Button>}
</Space>
)
}
>
<Steps
current={currentStep}
items={[
{ title: 'Zuordnung', description: 'Dateien auswählen' },
{ title: 'Bearbeitung', description: 'Barcode & Splitting' },
{ title: 'Abschluss', description: 'Import & Druck' },
]}
style={{ marginBottom: 24 }}
/>
<Spin spinning={loading}>
<div style={{ minHeight: 300 }}>
{currentStep === 0 && renderStep1()}
{currentStep === 1 && renderStep2()}
{currentStep === 2 && renderStep3()}
</div>
</Spin>
</Modal>
);
}