Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import QRCode from 'qrcode';
|
||||
import { getEnv } from '../utils/env';
|
||||
import { getAccessToken } from '../auth/oidc';
|
||||
|
||||
// A4 page dimensions in mm
|
||||
const PAGE_WIDTH_MM = 210;
|
||||
const PAGE_HEIGHT_MM = 297;
|
||||
|
||||
// Barcode label dimensions in mm (from C# template)
|
||||
const BARCODE_WIDTH_MM = 57;
|
||||
const BARCODE_HEIGHT_MM = 32;
|
||||
|
||||
interface BarcodePositionerProps {
|
||||
attachmentId: number;
|
||||
startPage?: number;
|
||||
belegnummer: string;
|
||||
isNeu?: boolean;
|
||||
datum?: string;
|
||||
jahr?: string;
|
||||
position: { x: number; y: number };
|
||||
onPositionChange: (pos: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
export default function BarcodePositioner({
|
||||
attachmentId,
|
||||
startPage,
|
||||
belegnummer,
|
||||
isNeu,
|
||||
datum,
|
||||
jahr,
|
||||
position,
|
||||
onPositionChange,
|
||||
}: BarcodePositionerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [imgSrc, setImgSrc] = useState<string | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const [innerWidth, setInnerWidth] = useState(0);
|
||||
|
||||
// Generate preview QR code
|
||||
useEffect(() => {
|
||||
QRCode.toDataURL('Vorschau', {
|
||||
margin: 0,
|
||||
width: 200,
|
||||
errorCorrectionLevel: 'H'
|
||||
}).then(setQrDataUrl);
|
||||
}, []);
|
||||
|
||||
// Load first page preview image
|
||||
useEffect(() => {
|
||||
let objectUrl: string;
|
||||
const page = startPage || 1;
|
||||
const url = `${getEnv('VITE_API_URL')}/api/email-import/attachments/${attachmentId}/pages/${page}/preview`;
|
||||
|
||||
getAccessToken().then(token => {
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Not found');
|
||||
return res.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImgSrc(objectUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachmentId, startPage]);
|
||||
|
||||
// Measure inner page width
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
if (innerRef.current) {
|
||||
setInnerWidth(innerRef.current.getBoundingClientRect().width);
|
||||
}
|
||||
};
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [imgSrc]);
|
||||
|
||||
// Scale factor: pixels per mm
|
||||
const scale = innerWidth > 0 ? innerWidth / PAGE_WIDTH_MM : 1;
|
||||
|
||||
const barcodeW = BARCODE_WIDTH_MM * scale;
|
||||
const barcodeH = BARCODE_HEIGHT_MM * scale;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!dragging || !innerRef.current) return;
|
||||
const cr = innerRef.current.getBoundingClientRect();
|
||||
|
||||
let px = e.clientX - cr.left - dragOffset.current.x;
|
||||
let py = e.clientY - cr.top - dragOffset.current.y;
|
||||
|
||||
// account for scroll offset
|
||||
if (containerRef.current) {
|
||||
py += containerRef.current.scrollTop;
|
||||
}
|
||||
|
||||
// Constraints: 6mm margin from edge
|
||||
const margin = 6 * scale;
|
||||
px = Math.max(margin, Math.min(px, cr.width - barcodeW - margin));
|
||||
const pageHeightPx = PAGE_HEIGHT_MM * scale;
|
||||
py = Math.max(margin, Math.min(py, pageHeightPx - barcodeH - margin));
|
||||
|
||||
onPositionChange({ x: Math.round(px / scale), y: Math.round(py / scale) });
|
||||
},
|
||||
[dragging, scale, barcodeW, barcodeH, onPositionChange],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const barcodeLeft = position.x * scale;
|
||||
const barcodeTop = position.y * scale;
|
||||
|
||||
// Format the date for display
|
||||
const displayDate = datum ? (() => {
|
||||
const d = new Date(datum);
|
||||
if (isNaN(d.getTime())) return datum;
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
||||
})() : '';
|
||||
|
||||
if (!imgSrc) {
|
||||
return (
|
||||
<div style={{ height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fafafa', border: '1px solid #d9d9d9', borderRadius: 4 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// QR Size: 30mm, Position: 3.2mm - 4px (~1mm) = 2.2mm from left
|
||||
// QR Size: 27mm, Position: 2.5mm
|
||||
const qrSize = 27 * scale;
|
||||
const qrLeft = 2.5 * scale;
|
||||
const qrTop = 2.5 * scale;
|
||||
|
||||
// Text area X: 33.3mm, Width: 21mm
|
||||
const textAreaLeft = 33.3 * scale;
|
||||
const textAreaWidth = 21 * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: 400,
|
||||
overflow: 'auto',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
background: '#e8e8e8',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: `${(PAGE_HEIGHT_MM / PAGE_WIDTH_MM) * 100}%`,
|
||||
background: '#fff',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt="Seite 1"
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: barcodeLeft,
|
||||
top: barcodeTop,
|
||||
width: barcodeW,
|
||||
height: barcodeH,
|
||||
border: '1px solid #000',
|
||||
background: '#fff',
|
||||
cursor: dragging ? 'grabbing' : 'grab',
|
||||
userSelect: 'none',
|
||||
zIndex: 10,
|
||||
boxShadow: dragging ? '0 4px 12px rgba(0,0,0,0.25)' : '0 1px 3px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* QR code image */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: qrLeft,
|
||||
top: qrTop,
|
||||
width: qrSize,
|
||||
height: qrSize,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{qrDataUrl ? (
|
||||
<img src={qrDataUrl} alt="QR Vorschau" style={{ width: '100%', height: '100%', display: 'block' }} />
|
||||
) : (
|
||||
<div style={{ fontSize: 8 }}>...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: textAreaLeft,
|
||||
top: 0,
|
||||
width: textAreaWidth,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: '"Times New Roman", Times, serif',
|
||||
}}
|
||||
>
|
||||
{/* Year */}
|
||||
<div style={{
|
||||
marginTop: 3 * scale,
|
||||
height: 7.5 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12 * scale * 0.35,
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
}}>
|
||||
{String(jahr || '').padStart(4, '0')}
|
||||
</div>
|
||||
|
||||
{/* Sequential Number */}
|
||||
<div style={{
|
||||
height: 7.5 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12 * scale * 0.35,
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
}}>
|
||||
{isNeu ? '← neu →' : String(belegnummer || '').padStart(6, '0')}
|
||||
</div>
|
||||
|
||||
{/* "Eingegangen" - Smaller and closer */}
|
||||
<div style={{
|
||||
marginTop: 1 * scale, // Gap to number
|
||||
height: 4 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 8 * scale * 0.35,
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
Eingegangen
|
||||
</div>
|
||||
|
||||
{/* Date - Smaller and closer */}
|
||||
<div style={{
|
||||
height: 4 * scale,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 8 * scale * 0.35,
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{displayDate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider } from 'antd';
|
||||
import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { posteingangApi } from '../api/posteingang';
|
||||
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
|
||||
import { clientsApi } from '../api/inbox';
|
||||
import type { Client } from '../api/inbox';
|
||||
import { paperlessApi } from '../api/paperless';
|
||||
import type { PaperlessDocType, PaperlessCorrespondent } from '../api/paperless';
|
||||
import { getEnv } from '../utils/env';
|
||||
import DocumentSearchModal from './DocumentSearchModal';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Props {
|
||||
documentId: number | null;
|
||||
document: PosteingangDocument | null;
|
||||
open: boolean;
|
||||
onClose: (next?: boolean) => void;
|
||||
onSave: () => void;
|
||||
isPosteingang?: boolean;
|
||||
hasNextDocument?: boolean;
|
||||
}
|
||||
|
||||
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [documentTypes, setDocumentTypes] = useState<PaperlessDocType[]>([]);
|
||||
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
|
||||
const [requirements, setRequirements] = useState<DocumentRequirement[]>([]);
|
||||
|
||||
const [kontonummerMissing, setKontonummerMissing] = useState<{ correspondentId: number, nummer: string } | null>(null);
|
||||
const [docTitles, setDocTitles] = useState<Record<number, string>>({});
|
||||
const [searchModalOpen, setSearchModalOpen] = useState<{ field: string, reqId: number } | null>(null);
|
||||
|
||||
// Kontonummer state
|
||||
const [kontonummern, setKontonummern] = useState<Kontonummer[]>([]);
|
||||
const [kontonummernLoading, setKontonummernLoading] = useState(false);
|
||||
const [newKontonummer, setNewKontonummer] = useState('');
|
||||
|
||||
const selectedCorrespondent = Form.useWatch('correspondent', form);
|
||||
|
||||
const loadKontonummern = useCallback(async (correspondentId: number) => {
|
||||
setKontonummernLoading(true);
|
||||
try {
|
||||
const data = await posteingangApi.getKontonummern(correspondentId);
|
||||
setKontonummern(data);
|
||||
} catch {
|
||||
setKontonummern([]);
|
||||
} finally {
|
||||
setKontonummernLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCorrespondent) {
|
||||
loadKontonummern(selectedCorrespondent);
|
||||
} else {
|
||||
setKontonummern([]);
|
||||
}
|
||||
}, [selectedCorrespondent, loadKontonummern]);
|
||||
|
||||
const handleAddKontonummer = async () => {
|
||||
const trimmed = newKontonummer.trim();
|
||||
if (!trimmed || !selectedCorrespondent) return;
|
||||
try {
|
||||
await posteingangApi.createKontonummer({ correspondentId: selectedCorrespondent, nummer: trimmed });
|
||||
message.success(`Kontonummer "${trimmed}" hinzugefügt.`);
|
||||
setNewKontonummer('');
|
||||
await loadKontonummern(selectedCorrespondent);
|
||||
form.setFieldValue('cf_5', trimmed);
|
||||
} catch {
|
||||
message.error('Kontonummer konnte nicht angelegt werden.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const resolveLinkTitles = async () => {
|
||||
const linkFields = requirements.filter(r => r.feldTyp === 'documentlink');
|
||||
for (const req of linkFields) {
|
||||
const fieldName = req.isCustomField ? `cf_${req.customFieldIndex}` : req.feldId;
|
||||
const val = form.getFieldValue(fieldName);
|
||||
if (val && !docTitles[val]) {
|
||||
try {
|
||||
const d = await paperlessApi.getDocument(val);
|
||||
setDocTitles(prev => ({ ...prev, [val]: d.title }));
|
||||
} catch (e) {
|
||||
// Handle or ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (open && requirements.length > 0) {
|
||||
resolveLinkTitles();
|
||||
}
|
||||
}, [requirements, open]); // Removed docTitles to avoid loop, we check val existence anyway
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadInitialData();
|
||||
} else {
|
||||
form.resetFields();
|
||||
setRequirements([]);
|
||||
setKontonummerMissing(null);
|
||||
setKontonummern([]);
|
||||
setNewKontonummer('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && document) {
|
||||
|
||||
let customFieldsObj: any = {};
|
||||
if (document.customFields) {
|
||||
document.customFields.forEach(cf => {
|
||||
customFieldsObj[`cf_${cf.field}`] = cf.value;
|
||||
});
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
mandant: document.owner,
|
||||
documentType: document.documentType,
|
||||
correspondent: document.correspondent,
|
||||
title: document.title,
|
||||
asn: document.asn,
|
||||
belegdatum: document.created ? dayjs(document.created) : null,
|
||||
eingangsdatum: customFieldsObj['cf_9'] ? dayjs(customFieldsObj['cf_9']) : (document.added ? dayjs(document.added) : null),
|
||||
...customFieldsObj,
|
||||
});
|
||||
|
||||
if (document.documentType) {
|
||||
fetchRequirements(document.documentType);
|
||||
}
|
||||
}
|
||||
}, [document, form, open]);
|
||||
|
||||
const ensureCorrespondentInList = async (correspondentId: number | null | undefined) => {
|
||||
if (!correspondentId) return;
|
||||
|
||||
setCorrespondents(prev => {
|
||||
const exists = prev.some(c => Number(c.id) === Number(correspondentId));
|
||||
if (!exists) {
|
||||
// We need to fetch it. But we can't easily do it inside setCorrespondents.
|
||||
// So we'll do it outside.
|
||||
return prev;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
const exists = correspondents.some(c => Number(c.id) === Number(correspondentId));
|
||||
if (!exists) {
|
||||
try {
|
||||
const currentCorr = await paperlessApi.getCorrespondent(correspondentId);
|
||||
if (currentCorr) {
|
||||
setCorrespondents(prev => {
|
||||
if (prev.some(c => Number(c.id) === Number(currentCorr.id))) return prev;
|
||||
return [...prev, currentCorr];
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Laden des zugewiesenen Absenders:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && document?.correspondent) {
|
||||
ensureCorrespondentInList(document.correspondent);
|
||||
}
|
||||
}, [document?.correspondent, open]);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [clientsData, docTypesData, correspondentsData] = await Promise.all([
|
||||
clientsApi.getMyClients(),
|
||||
paperlessApi.getDocumentTypes(),
|
||||
paperlessApi.getCorrespondents()
|
||||
]);
|
||||
setClients(clientsData);
|
||||
setDocumentTypes(docTypesData);
|
||||
setCorrespondents(correspondentsData);
|
||||
|
||||
// If document is already there, ensure its correspondent is in the list
|
||||
if (document?.correspondent) {
|
||||
const exists = correspondentsData.some(c => Number(c.id) === Number(document.correspondent));
|
||||
if (!exists) {
|
||||
const currentCorr = await paperlessApi.getCorrespondent(document.correspondent);
|
||||
if (currentCorr) {
|
||||
setCorrespondents([...correspondentsData, currentCorr]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
message.error("Fehler beim Laden der Stammdaten.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRequirements = async (docType: number) => {
|
||||
try {
|
||||
const reqs = await posteingangApi.getRequirements(docType, isPosteingang);
|
||||
setRequirements(reqs);
|
||||
} catch (e) {
|
||||
message.error("Fehler beim Laden der Pflichtfelder.");
|
||||
}
|
||||
};
|
||||
|
||||
const [fetchingCorrespondents, setFetchingCorrespondents] = useState(false);
|
||||
const [searchTimeout, setSearchTimeout] = useState<any>(null);
|
||||
|
||||
const handleCorrespondentSearch = async (value: string) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
|
||||
setSearchTimeout(setTimeout(async () => {
|
||||
setFetchingCorrespondents(true);
|
||||
try {
|
||||
const data = await paperlessApi.getCorrespondents(value);
|
||||
setCorrespondents(data);
|
||||
} catch (e) {
|
||||
message.error("Absender konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setFetchingCorrespondents(false);
|
||||
}
|
||||
}, 500));
|
||||
};
|
||||
|
||||
const handleDocumentTypeChange = (value: number) => {
|
||||
fetchRequirements(value);
|
||||
};
|
||||
|
||||
const handleSaveDocument = async (values: any, isNext: boolean = false) => {
|
||||
// Collect custom fields into an array
|
||||
const customFieldsObj: any = {};
|
||||
Object.keys(values).forEach(key => {
|
||||
if (key.startsWith('cf_')) {
|
||||
const fieldId = parseInt(key.replace('cf_', ''), 10);
|
||||
customFieldsObj[fieldId] = values[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (values.eingangsdatum) {
|
||||
customFieldsObj[9] = values.eingangsdatum.format('YYYY-MM-DD');
|
||||
} else {
|
||||
customFieldsObj[9] = null;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
mandant: values.mandant,
|
||||
documentType: values.documentType,
|
||||
correspondent: values.correspondent,
|
||||
title: values.title,
|
||||
date: values.belegdatum ? values.belegdatum.format('YYYY-MM-DD') : null,
|
||||
customFields: customFieldsObj,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await posteingangApi.updateDocument(documentId!, payload);
|
||||
message.success("Dokument erfolgreich aktualisiert.");
|
||||
onSave();
|
||||
onClose(isNext);
|
||||
} catch (e) {
|
||||
message.error("Fehler beim Speichern des Dokuments.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (isNext: boolean = false) => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Kontonummer Logic
|
||||
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
|
||||
if (kontonummerReq && values[`cf_5`] && values.correspondent) {
|
||||
const kNummer = values[`cf_5`];
|
||||
const knData = await posteingangApi.getKontonummern(values.correspondent);
|
||||
const exists = knData.some(k => k.Nummer === kNummer);
|
||||
|
||||
if (!exists) {
|
||||
// Prompt user to save kontonummer
|
||||
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
|
||||
return; // Stop saving, wait for confirmation
|
||||
}
|
||||
}
|
||||
|
||||
await handleSaveDocument(values, isNext);
|
||||
} catch (e) {
|
||||
// Validation failed
|
||||
console.error("Form validation failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmKontonummerSave = async (shouldSave: boolean, isNext: boolean = false) => {
|
||||
if (shouldSave && kontonummerMissing) {
|
||||
try {
|
||||
await posteingangApi.createKontonummer(kontonummerMissing);
|
||||
message.success("Kontonummer hinterlegt.");
|
||||
} catch (e) {
|
||||
message.error("Fehler beim Speichern der Kontonummer.");
|
||||
}
|
||||
}
|
||||
setKontonummerMissing(null);
|
||||
|
||||
const values = form.getFieldsValue();
|
||||
await handleSaveDocument(values, isNext);
|
||||
};
|
||||
|
||||
const handleSelectLink = (doc: any) => {
|
||||
if (searchModalOpen) {
|
||||
form.setFieldValue(searchModalOpen.field, doc.id);
|
||||
setDocTitles(prev => ({ ...prev, [doc.id]: doc.title }));
|
||||
setSearchModalOpen(null);
|
||||
form.validateFields([searchModalOpen.field]);
|
||||
}
|
||||
};
|
||||
|
||||
if (documentId === null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={`Dokument bearbeiten (${document?.title || ''})`}
|
||||
open={open && !kontonummerMissing}
|
||||
onCancel={() => onClose(false)}
|
||||
width={1400}
|
||||
style={{ top: 20 }}
|
||||
footer={
|
||||
hasNextDocument ? [
|
||||
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
|
||||
<Button key="save" type="primary" loading={saving} onClick={() => handleSubmit(false)}>Speichern</Button>,
|
||||
<Button key="saveNext" type="primary" ghost loading={saving} onClick={() => handleSubmit(true)}>Speichern & Nächstes</Button>
|
||||
] : [
|
||||
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
|
||||
<Button key="save" type="primary" loading={saving} onClick={() => handleSubmit(false)}>Speichern</Button>
|
||||
]
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={16} style={{ height: '75vh', overflow: 'hidden' }}>
|
||||
<Col span={10} style={{ overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}>
|
||||
<Form form={form} layout="vertical" disabled={saving}>
|
||||
|
||||
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
|
||||
<Select showSearch optionFilterProp="children" allowClear>
|
||||
{clients.map(c => (
|
||||
<Option key={c.Id} value={c.PaperlessUserId}>{c.Name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="documentType" label="Dokumentart" rules={[{ required: true, message: 'Wähle eine Dokumentart' }]}>
|
||||
<Select showSearch optionFilterProp="children" onChange={handleDocumentTypeChange}>
|
||||
{documentTypes.map(d => (
|
||||
<Option key={d.id} value={d.id}>{d.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="belegdatum" label="Belegdatum">
|
||||
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="eingangsdatum" label="Eingangsdatum">
|
||||
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Rendering dynamic requirements based on DocumentType */}
|
||||
{requirements.map(req => {
|
||||
if (req.feldId === '2' || (req.isCustomField && req.customFieldIndex === 9)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let inputElement = <Input />;
|
||||
|
||||
if (req.isCustomField && req.customFieldIndex === 5) {
|
||||
// Kontonummer — Select mit Möglichkeit neue anzulegen
|
||||
inputElement = (
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
optionFilterProp="children"
|
||||
placeholder={!selectedCorrespondent ? 'Bitte zuerst einen Absender wählen' : 'Kontonummer wählen'}
|
||||
disabled={!selectedCorrespondent}
|
||||
loading={kontonummernLoading}
|
||||
notFoundContent={kontonummernLoading ? <Spin size="small" /> : 'Keine Kontonummern vorhanden'}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<div style={{ display: 'flex', gap: 8, padding: '0 8px 8px' }}>
|
||||
<Input
|
||||
placeholder="Neue Kontonummer"
|
||||
value={newKontonummer}
|
||||
onChange={(e) => setNewKontonummer(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddKontonummer}
|
||||
disabled={!newKontonummer.trim()}
|
||||
>
|
||||
Anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{kontonummern.map((k) => (
|
||||
<Option key={k.KontonummerId ?? k.Nummer} value={k.Nummer}>
|
||||
{k.Nummer}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (req.feldId === '1') {
|
||||
inputElement = (
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleCorrespondentSearch}
|
||||
notFoundContent={fetchingCorrespondents ? <Spin size="small" /> : null}
|
||||
>
|
||||
{correspondents.map(c => (
|
||||
<Option key={c.id} value={c.id}>{c.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (req.feldTyp === 'date') {
|
||||
inputElement = <DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />;
|
||||
} else if (req.feldTyp === 'select' && req.fieldOptions) {
|
||||
inputElement = (
|
||||
<Select showSearch optionFilterProp="children">
|
||||
{req.fieldOptions.map(opt => (
|
||||
<Option key={opt.id} value={opt.id}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (req.feldTyp === 'documentlink') {
|
||||
const fieldName = req.isCustomField ? `cf_${req.customFieldIndex}` :
|
||||
req.feldId === '1' ? 'correspondent' :
|
||||
req.feldId === '2' ? 'belegdatum' :
|
||||
req.feldId === '3' ? 'asn' :
|
||||
req.feldId === '5' ? 'title' : req.feldId;
|
||||
|
||||
const currentId = form.getFieldValue(fieldName);
|
||||
|
||||
inputElement = (
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="Klicken zum Suchen..."
|
||||
value={currentId ? (docTitles[currentId] || `Dokument #${currentId}`) : ''}
|
||||
onClick={() => setSearchModalOpen({ field: fieldName, reqId: req.id })}
|
||||
suffix={
|
||||
<Space>
|
||||
{currentId && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`, '_blank');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSearchModalOpen({ field: fieldName, reqId: req.id });
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const name = req.isCustomField ? `cf_${req.customFieldIndex}` :
|
||||
req.feldId === '1' ? 'correspondent' :
|
||||
req.feldId === '2' ? 'belegdatum' :
|
||||
req.feldId === '3' ? 'asn' :
|
||||
req.feldId === '5' ? 'title' : req.feldId;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={req.id}
|
||||
name={name}
|
||||
label={req.feldName}
|
||||
rules={[{ required: req.required, message: `${req.feldName} ist ein Pflichtfeld` }]}
|
||||
tooltip={req.hinweis}
|
||||
>
|
||||
{inputElement}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
</Col>
|
||||
<Col span={14} style={{ height: '100%' }}>
|
||||
<iframe
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
title="PDF Preview"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
||||
{/* Kontonummer Dialog */}
|
||||
<Modal
|
||||
title="Neue Kontonummer hinterlegen?"
|
||||
open={!!kontonummerMissing}
|
||||
onCancel={() => confirmKontonummerSave(false)}
|
||||
onOk={() => confirmKontonummerSave(true)}
|
||||
okText="Hinterlegen"
|
||||
cancelText="Nein, nur Speichern"
|
||||
>
|
||||
<p>Die Kontonummer <b>{kontonummerMissing?.nummer}</b> ist bei diesem Absender noch nicht hinterlegt.</p>
|
||||
<p>Möchtest du sie dem Absender fest zuordnen?</p>
|
||||
</Modal>
|
||||
|
||||
<DocumentSearchModal
|
||||
open={!!searchModalOpen}
|
||||
onCancel={() => setSearchModalOpen(null)}
|
||||
onSelect={handleSelectLink}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal, Input, List, Image, Typography, Space, Pagination, Spin, Button } from 'antd';
|
||||
import { SearchOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { paperlessApi } from '../api/paperless';
|
||||
import { getEnv } from '../utils/env';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSelect: (doc: any) => void;
|
||||
}
|
||||
|
||||
export default function DocumentSearchModal({ open, onCancel, onSelect }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
const load = useCallback(async (q: string, p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await paperlessApi.searchDocuments({
|
||||
search: q,
|
||||
page: p,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
setData(response.results);
|
||||
setTotal(response.count);
|
||||
} catch (e) {
|
||||
console.error("Error searching documents", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load(search, page);
|
||||
}
|
||||
}, [open, page, load]); // We don't trigger on search immediately to allow 'Search' button or Enter
|
||||
|
||||
const handleSearch = (val: string) => {
|
||||
setSearch(val);
|
||||
setPage(1);
|
||||
load(val, 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Dokument suchen"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={800}
|
||||
style={{ top: 50 }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Input.Search
|
||||
placeholder="Titel oder Inhalt suchen..."
|
||||
enterButton={<SearchOutlined />}
|
||||
onSearch={handleSearch}
|
||||
loading={loading}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={data}
|
||||
renderItem={(doc) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
key="select"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => onSelect(doc)}
|
||||
>
|
||||
Auswählen
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Image
|
||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`}
|
||||
width={80}
|
||||
height={110}
|
||||
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
|
||||
preview={false}
|
||||
/>
|
||||
}
|
||||
title={doc.title}
|
||||
description={
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Erstellt: {dayjs(doc.created_date).format('DD.MM.YYYY')}</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
{total > pageSize && (
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={(p) => setPage(p)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
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 blob = await emailImportApi.printPreview(attachmentId, barcode);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Tooltip, Spin } from 'antd';
|
||||
import { ScissorOutlined } from '@ant-design/icons';
|
||||
import { getEnv } from '../utils/env';
|
||||
import { getAccessToken } from '../auth/oidc';
|
||||
|
||||
interface PdfSplitViewerProps {
|
||||
attachmentId: number;
|
||||
pageCount: number;
|
||||
startPage?: number;
|
||||
endPage?: number;
|
||||
onSplit: (pageIndex: number) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function PdfSplitViewer({ attachmentId, pageCount, startPage, endPage, onSplit, disabled }: PdfSplitViewerProps) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAccessToken().then(t => setToken(t));
|
||||
}, []);
|
||||
|
||||
if (!token) return <Spin />;
|
||||
|
||||
if (pageCount === 0) {
|
||||
return (
|
||||
<div style={{ padding: 16, background: '#fafafa', borderRadius: 8, textAlign: 'center' }}>
|
||||
<p>Vorschau nicht verfügbar (Dokument wurde vor dem Update geladen).</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actualStart = startPage || 1;
|
||||
const actualEnd = endPage || pageCount;
|
||||
|
||||
const pagesToRender = [];
|
||||
for (let i = actualStart; i <= actualEnd; i++) {
|
||||
pagesToRender.push(i);
|
||||
}
|
||||
|
||||
const getImageUrl = (page: number) => {
|
||||
// We add the token as a query parameter because <img> tags don't support Authorization headers easily.
|
||||
// However, it's more secure to fetch the blob and create an object URL, or rely on cookie auth.
|
||||
// Let's fetch the blob and use object URLs to pass the bearer token securely.
|
||||
return `${getEnv('VITE_API_URL')}/api/email-import/attachments/${attachmentId}/pages/${page}/thumb`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', overflowX: 'auto', padding: '16px 8px', background: '#fafafa', borderRadius: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{pagesToRender.map((pageNum) => (
|
||||
<div key={pageNum} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ border: '1px solid #d9d9d9', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', background: 'white', height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ImageWithAuth url={getImageUrl(pageNum)} token={token} />
|
||||
</div>
|
||||
|
||||
{!disabled && pageNum < actualEnd && (
|
||||
<div style={{ width: 24, display: 'flex', justifyContent: 'center', zIndex: 10, marginLeft: 8 }}>
|
||||
<Tooltip title="Hier trennen">
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<ScissorOutlined />}
|
||||
onClick={() => onSplit(pageNum)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageWithAuth({ url, token }: { url: string; token: string }) {
|
||||
const [imgSrc, setImgSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string;
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Network response was not ok');
|
||||
return res.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImgSrc(objectUrl);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading image', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [url, token]);
|
||||
|
||||
if (!imgSrc) return <Spin style={{ margin: '0 20px' }} />;
|
||||
return <img src={imgSrc} alt="PDF Page" style={{ height: '100%', objectFit: 'contain' }} />;
|
||||
}
|
||||
Reference in New Issue
Block a user