Der Button „Als ignoriert markieren“ war für alle mit VIEW_MAIL sichtbar, der Aufruf (PATCH :id/status) verlangte aber MANAGE_ALL und scheiterte mit 403. Neuer Endpunkt POST /api/emails/:id/ignore mit VIEW_MAIL, der nur Status 3 setzen kann; bereits verarbeitete E-Mails werden abgelehnt. Das allgemeine Statusändern bleibt Admins vorbehalten. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Card, Button, Space, Spin, Tag, Typography, Table, message, Empty, Popconfirm, theme
|
|
} from 'antd';
|
|
import { ArrowLeftOutlined, FileTextOutlined, CloseCircleOutlined, LinkOutlined, RedoOutlined } from '@ant-design/icons';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import dayjs from 'dayjs';
|
|
import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails';
|
|
import { emailImportApi } from '../api/email-import';
|
|
import { getEnv } from '../utils/env';
|
|
import MailImportWizard from '../components/MailImportWizard';
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
|
import { useAuth } from '../auth/AuthContext';
|
|
import { Permission } from '../auth/permissions';
|
|
|
|
const { Title, Text } = Typography;
|
|
|
|
export default function MailDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const isMobile = useIsMobile();
|
|
const [email, setEmail] = useState<EmailItem | null>(null);
|
|
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
|
const [selected, setSelected] = useState<EmailAttachment | null>(null);
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [wizardOpen, setWizardOpen] = useState(false);
|
|
const [reimporting, setReimporting] = useState(false);
|
|
const { token } = theme.useToken();
|
|
const { hasPermission } = useAuth();
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
const emailId = parseInt(id, 10);
|
|
Promise.all([emailsApi.get(emailId), emailsApi.listAttachments(emailId)])
|
|
.then(([mail, att]) => {
|
|
setEmail(mail);
|
|
setAttachments(att);
|
|
if (att.length > 0) setSelected(att[0]);
|
|
})
|
|
.catch(() => message.error('E-Mail nicht gefunden'))
|
|
.finally(() => setLoading(false));
|
|
}, [id]);
|
|
|
|
const handleIgnore = async () => {
|
|
if (!email) return;
|
|
try {
|
|
await emailsApi.ignore(email.Id);
|
|
message.success('E-Mail als ignoriert markiert');
|
|
navigate('/mailpostfach');
|
|
} catch {
|
|
message.error('Fehler beim Markieren der E-Mail');
|
|
}
|
|
};
|
|
|
|
// Gibt eine abgeschlossene E-Mail wieder zur Bearbeitung frei (Status 4).
|
|
// Danach ist der Import-Wizard erneut nutzbar.
|
|
const handleReimport = async () => {
|
|
if (!email) return;
|
|
setReimporting(true);
|
|
try {
|
|
const updated = await emailsApi.reimport(email.Id);
|
|
setEmail(updated);
|
|
message.success('E-Mail wurde zur Nachbearbeitung freigegeben');
|
|
} catch {
|
|
message.error('Fehler beim Freigeben der E-Mail');
|
|
} finally {
|
|
setReimporting(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!selected) {
|
|
setPreviewUrl(null);
|
|
return;
|
|
}
|
|
setPreviewLoading(true);
|
|
let objectUrl: string | null = null;
|
|
emailsApi.getAttachmentContent(selected.Id)
|
|
.then((blob) => {
|
|
objectUrl = URL.createObjectURL(blob);
|
|
setPreviewUrl(objectUrl);
|
|
})
|
|
.catch(() => message.error('Vorschau konnte nicht geladen werden'))
|
|
.finally(() => setPreviewLoading(false));
|
|
|
|
return () => {
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
};
|
|
}, [selected]);
|
|
|
|
if (loading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
|
if (!email) return <Text>E-Mail nicht gefunden.</Text>;
|
|
|
|
const isHtml = /<[a-z][\s\S]*>/i.test(email.Body);
|
|
const hasErechnung = attachments.some((a) => a.Erechnung);
|
|
|
|
const columns: ColumnsType<EmailAttachment> = [
|
|
{
|
|
title: 'Dateiname',
|
|
dataIndex: 'FileName',
|
|
key: 'FileName',
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: 'Typ',
|
|
dataIndex: 'ContentType',
|
|
key: 'ContentType',
|
|
width: 160,
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: 'eRechnung',
|
|
dataIndex: 'Erechnung',
|
|
key: 'Erechnung',
|
|
width: 100,
|
|
align: 'center',
|
|
render: (v: boolean) => (v ? <Tag color="green">Ja</Tag> : <Tag>Nein</Tag>),
|
|
},
|
|
{
|
|
title: 'Paperless ID',
|
|
dataIndex: 'PaperlessDocumentIds',
|
|
key: 'PaperlessDocumentIds',
|
|
width: 130,
|
|
render: (ids: Record<string, number> | null) => {
|
|
if (!ids || typeof ids !== 'object') return null;
|
|
const entries = Object.entries(ids).filter(([, v]) => v != null);
|
|
if (entries.length === 0) return null;
|
|
|
|
const paperlessUrl = getEnv('PAPERLESS_URL');
|
|
return (
|
|
<Space size={[0, 4]} wrap>
|
|
{entries.map(([rangeKey, id]) =>
|
|
paperlessUrl ? (
|
|
<a
|
|
key={rangeKey}
|
|
href={`${paperlessUrl}/documents/${id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
|
<LinkOutlined style={{ marginRight: 4 }} />
|
|
{id}
|
|
</Tag>
|
|
</a>
|
|
) : (
|
|
<Tag color="blue" key={rangeKey}>{id}</Tag>
|
|
)
|
|
)}
|
|
</Space>
|
|
);
|
|
}
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
|
|
<Space wrap>
|
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
|
|
Zurück
|
|
</Button>
|
|
<Title level={isMobile ? 5 : 3} style={{ margin: 0 }}>{email.Subject}</Title>
|
|
{hasErechnung && <Tag color="green">eRechnung</Tag>}
|
|
</Space>
|
|
|
|
<Space wrap>
|
|
{hasPermission(Permission.MANAGE_ALL) && email.Status !== 0 && email.Status !== 4 && (
|
|
<Popconfirm
|
|
title="Erneut importieren"
|
|
description="Diese E-Mail wurde bereits abgeschlossen. Möchten Sie sie wieder zur Bearbeitung freigeben?"
|
|
onConfirm={handleReimport}
|
|
okText="Ja"
|
|
cancelText="Nein"
|
|
placement="bottomRight"
|
|
>
|
|
<Button icon={<RedoOutlined />} loading={reimporting}>
|
|
Erneut importieren
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
<Popconfirm
|
|
title="E-Mail ignorieren"
|
|
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
|
|
onConfirm={handleIgnore}
|
|
okText="Ja"
|
|
cancelText="Nein"
|
|
placement="bottomRight"
|
|
>
|
|
<Button danger icon={<CloseCircleOutlined />} disabled={email.Status === 1}>
|
|
Als ignoriert markieren
|
|
</Button>
|
|
</Popconfirm>
|
|
<Button
|
|
type="primary"
|
|
icon={<FileTextOutlined />}
|
|
disabled={email.Status === 1}
|
|
onClick={async () => {
|
|
if (!email) return;
|
|
const hide = message.loading('Prüfe Vorschaubilder...', 0);
|
|
try {
|
|
await emailImportApi.ensurePreviews(email.Id);
|
|
// Re-fetch attachments to get updated PageCount
|
|
const att = await emailsApi.listAttachments(email.Id);
|
|
setAttachments(att);
|
|
setWizardOpen(true);
|
|
} catch (err) {
|
|
message.error('Fehler bei der Vorschau-Prüfung');
|
|
} finally {
|
|
hide();
|
|
}
|
|
}}
|
|
>
|
|
Import-Wizard starten
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: isMobile ? '1fr' : '2fr 3fr',
|
|
gap: 16,
|
|
height: isMobile ? 'auto' : 'calc(100vh - 140px)',
|
|
}}
|
|
>
|
|
{/* Linke Seite: E-Mail-Inhalt */}
|
|
<Card
|
|
title="E-Mail"
|
|
size="small"
|
|
styles={{
|
|
body: {
|
|
overflow: 'auto',
|
|
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
},
|
|
}}
|
|
>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<div><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div>
|
|
<div><Text type="secondary">An:</Text> <Text>{email.RecipientAddress}</Text></div>
|
|
<div><Text type="secondary">Datum:</Text> <Text>{dayjs(email.Date).format('DD.MM.YYYY HH:mm')}</Text></div>
|
|
</div>
|
|
{isHtml ? (
|
|
<iframe
|
|
title="E-Mail Body"
|
|
srcDoc={email.Body}
|
|
sandbox=""
|
|
style={{ width: '100%', minHeight: '400px', flex: 1, border: '1px solid #303030', borderRadius: 4, background: '#fff' }}
|
|
/>
|
|
) : (
|
|
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit', margin: 0 }}>{email.Body}</pre>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Rechte Seite: Anhänge + Vorschau */}
|
|
<Card
|
|
size="small"
|
|
styles={{
|
|
body: {
|
|
padding: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
|
|
},
|
|
}}
|
|
>
|
|
<div style={{ flex: '0 0 auto', borderBottom: `1px solid ${token.colorBorder}`, maxHeight: 240, overflow: 'auto' }}>
|
|
<Table<EmailAttachment>
|
|
columns={columns}
|
|
dataSource={attachments}
|
|
rowKey="Id"
|
|
size="small"
|
|
pagination={false}
|
|
rowClassName={(r) => (selected?.Id === r.Id ? 'ant-table-row-selected' : '')}
|
|
onRow={(record) => ({
|
|
onClick: () => setSelected(record),
|
|
style: { cursor: 'pointer' },
|
|
})}
|
|
locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
|
/>
|
|
</div>
|
|
<div style={isMobile ? { height: '60vh', background: token.colorBgLayout } : { flex: 1, minHeight: 0, background: token.colorBgLayout }}>
|
|
{previewLoading ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
|
<Spin />
|
|
</div>
|
|
) : previewUrl && selected ? (
|
|
selected.ContentType?.startsWith('image/') ? (
|
|
<img src={previewUrl} alt={selected.FileName} style={{ maxWidth: '100%', maxHeight: '100%', display: 'block', margin: '0 auto' }} />
|
|
) : (
|
|
<iframe
|
|
title={selected.FileName}
|
|
src={previewUrl}
|
|
style={{ width: '100%', height: '100%', border: 'none' }}
|
|
/>
|
|
)
|
|
) : (
|
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#888' }}>
|
|
<Space direction="vertical" align="center">
|
|
<FileTextOutlined style={{ fontSize: 48 }} />
|
|
<Text type="secondary">Kein Anhang ausgewählt</Text>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{wizardOpen && email && (
|
|
<MailImportWizard
|
|
visible={wizardOpen}
|
|
onClose={() => setWizardOpen(false)}
|
|
onSuccess={() => navigate('/mailpostfach')}
|
|
email={email}
|
|
attachments={attachments}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|