Files
paperlessmanager/paperless-frontend/src/components/MailImportWizard.tsx
T
bjoernpoettker 4c75a1ded2
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s
feat: filter digest tiles by user permissions and add import progress status
- Store UserGroups from OIDC in UserSettings entity, sync on each request
- Filter daily digest tiles based on user's permission groups
- Add in-memory job status tracking to EmailImportService
- Poll import job status in MailImportWizard and show progress in Spin tip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:29:56 +02:00

771 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, Alert, 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, PaperClipOutlined, ArrowRightOutlined, WarningOutlined
} from '@ant-design/icons';
import PdfSplitViewer from './PdfSplitViewer';
import BarcodePositioner from './BarcodePositioner';
const { Text } = Typography;
interface MailImportWizardProps {
visible: boolean;
onClose: () => void;
onSuccess?: () => void;
email: any;
attachments: any[];
}
export default function MailImportWizard({ visible, onClose, onSuccess, 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);
const [importStatus, setImportStatus] = useState('');
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: 7,
y: 7,
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 = async (virtualId: string, splitPage: number) => {
const idx = importData.findIndex(i => i.virtualId === virtualId);
if (idx === -1) return;
const itemToSplit = importData[idx];
const start = itemToSplit.pages?.start || 1;
const end = itemToSplit.pages?.end || 999;
const part1Pages = { start, end: splitPage };
const part2Pages = { start: splitPage + 1, end };
const part1 = { ...itemToSplit, virtualId: `${itemToSplit.attachmentId}_${start}_${splitPage}`, pages: part1Pages, fileName: `${itemToSplit.fileName} (Teil 1)` };
const part2 = { ...itemToSplit, virtualId: `${itemToSplit.attachmentId}_${splitPage+1}_${end}`, pages: part2Pages, fileName: `${itemToSplit.fileName} (Teil 2)` };
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 },
}));
}
setImportData(prev => {
const newArray = [...prev];
newArray.splice(idx, 1, part1, part2);
return newArray;
});
// Checksumme der geteilten Teile prüfen
try {
const [dup1, dup2] = await Promise.all([
emailImportApi.checkSplitChecksum(itemToSplit.attachmentId, part1Pages),
emailImportApi.checkSplitChecksum(itemToSplit.attachmentId, part2Pages),
]);
if (dup1 || dup2) {
setImportData(prev => prev.map(item => {
if (item.virtualId === part1.virtualId && dup1) return { ...item, isDuplicate: true, type: 'IGNORE' as const };
if (item.virtualId === part2.virtualId && dup2) return { ...item, isDuplicate: true, type: 'IGNORE' as const };
return item;
}));
}
} catch (e) {
console.error('Fehler bei Checksummen-Prüfung nach Split', e);
}
};
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);
setImportStatus('Import wird gestartet...');
const jobId = crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}`;
const statusPoll = setInterval(async () => {
try {
const status = await emailImportApi.getJobStatus(jobId);
if (status?.message) setImportStatus(status.message);
} catch {}
}, 1500);
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, jobId);
setImportSuccess(true);
setCurrentStep(2);
} catch (e: any) {
message.error(`Fehler beim Import: ${e.message}`);
} finally {
clearInterval(statusPoll);
setImportStatus('');
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 renderDocumentList = (showPrint: boolean) => {
const mainDocs = importData.filter(i => i.type === 'MAIN');
const attachmentsToImport = importData.filter(i => i.type === 'ATTACHMENT');
return (
<>
{mainDocs.length === 0 && (
<Alert type="warning" message="Keine Hauptdokumente zum Importieren ausgewählt." style={{ marginBottom: 16 }} />
)}
{mainDocs.map(main => {
const mainAttachments = attachmentsToImport.filter(a => a.parentVirtualId === main.virtualId);
const num = belegnummern[main.virtualId] || '000000';
const yr = barcodes[main.virtualId]?.jahr || dayjs(email.Date).format('YYYY');
const datum = eingangsdaten[main.virtualId];
const belegnr = `${yr}-${String(num).padStart(6, '0')}`;
return (
<div key={main.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{main.fileName}</Text>
<Row gutter={24} align="middle">
<Col span={showPrint ? 20 : 24}>
<Space size={24}>
<Text type="secondary">
Eingangsdatum: <Text strong>{datum?.format('DD.MM.YYYY') ?? '—'}</Text>
</Text>
<Text type="secondary">
Belegnummer: <Tag color="blue" style={{ fontSize: 13 }}>{belegnr}</Tag>
</Text>
</Space>
{mainAttachments.length > 0 && (
<div style={{ marginTop: 12, paddingLeft: 16 }}>
{mainAttachments.map(att => (
<div key={att.virtualId} style={{ marginBottom: 8, padding: '6px 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>
)}
</Col>
{showPrint && (
<Col span={4} style={{ textAlign: 'right' }}>
<Button icon={<PrinterOutlined />} onClick={() => printDocument(main.virtualId, main.attachmentId)}>
Drucken
</Button>
</Col>
)}
</Row>
</div>
);
})}
{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>
))}
</>
);
};
const renderStep3 = () => {
if (importSuccess) {
return (
<div>
<Alert
type="success"
message="Import erfolgreich!"
description="Die Dokumente wurden nach Paperless importiert und die Belegnummern verbucht."
style={{ marginBottom: 24 }}
/>
{renderDocumentList(true)}
</div>
);
}
return renderDocumentList(false);
};
return (
<Modal
title="Paperless Import-Wizard"
open={visible}
onCancel={onClose}
width={1000}
footer={
importSuccess ? (
<Button type="primary" onClick={onSuccess ?? onClose}>Schließen</Button>
) : (
<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} tip={importStatus || undefined}>
<div style={{ minHeight: 300 }}>
{currentStep === 0 && renderStep1()}
{currentStep === 1 && renderStep2()}
{currentStep === 2 && renderStep3()}
</div>
</Spin>
</Modal>
);
}