4c75a1ded2
Build and Push Multi-Platform Images / build-and-push (push) Successful in 42s
- 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>
771 lines
30 KiB
TypeScript
771 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|