Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 changed files with 67739 additions and 0 deletions
@@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Typography, Row, Col, Card, Badge, Result, Button, Spin, theme } from 'antd';
import {
InboxOutlined,
FileTextOutlined,
MailOutlined,
EditOutlined,
ArrowRightOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext';
import { Permission } from '../auth/permissions';
import { statsApi, type StatsCounts } from '../api/stats';
const { Title, Text, Paragraph } = Typography;
interface DashboardTile {
key: keyof StatsCounts;
path: string;
title: string;
description: string;
icon: ReactNode;
permission: Permission;
accent: string;
accentSoft: string;
accentSoftDark: string;
}
const tiles: DashboardTile[] = [
{
key: 'inbox',
path: '/inbox',
title: 'Eingangsbox',
description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.',
icon: <InboxOutlined />,
permission: Permission.VIEW_SCANNER,
accent: '#1677ff',
accentSoft: '#e6f0ff',
accentSoftDark: 'rgba(22, 119, 255, 0.16)',
},
{
key: 'posteingang',
path: '/posteingang',
title: 'Posteingang',
description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.',
icon: <FileTextOutlined />,
permission: Permission.VIEW_INBOX,
accent: '#13c2c2',
accentSoft: '#e6fffb',
accentSoftDark: 'rgba(19, 194, 194, 0.16)',
},
{
key: 'manuell',
path: '/manuell',
title: 'Manuell bearbeiten',
description: 'Dokumente mit fehlender Erkennung manuell ergänzen.',
icon: <EditOutlined />,
permission: Permission.PROCESS_MANUALLY,
accent: '#fa8c16',
accentSoft: '#fff7e6',
accentSoftDark: 'rgba(250, 140, 22, 0.18)',
},
{
key: 'mailpostfach',
path: '/mailpostfach',
title: 'Mailpostfach',
description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.',
icon: <MailOutlined />,
permission: Permission.VIEW_MAIL,
accent: '#722ed1',
accentSoft: '#f9f0ff',
accentSoftDark: 'rgba(114, 46, 209, 0.18)',
},
];
export default function DashboardPage() {
const navigate = useNavigate();
const { user, hasPermission, isAuthenticated } = useAuth();
const { isDark } = useTheme();
const { token } = theme.useToken();
const [counts, setCounts] = useState<StatsCounts | null>(null);
const [loading, setLoading] = useState(true);
const visibleTiles = tiles.filter((tile) => hasPermission(tile.permission));
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const fetchCounts = async () => {
try {
const data = await statsApi.getCounts();
if (!cancelled) setCounts(data);
} catch (err) {
console.error('Fehler beim Abrufen der Zählerstände:', err);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchCounts();
const interval = setInterval(fetchCounts, 30000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [isAuthenticated]);
const userName =
(user?.profile?.given_name as string | undefined) ||
(user?.profile?.name as string | undefined) ||
'Willkommen';
if (visibleTiles.length === 0) {
return (
<Result
status="info"
title="Keine Abschnitte freigegeben"
subTitle="Für Ihr Konto sind aktuell keine Bereiche freigeschaltet. Bitte wenden Sie sich an einen Administrator."
/>
);
}
const totalPending = visibleTiles.reduce(
(sum, tile) => sum + (counts?.[tile.key] ?? 0),
0,
);
return (
<div>
<div style={{ marginBottom: 32 }}>
<Title level={2} style={{ marginBottom: 4 }}>
Hallo, {userName}
</Title>
<Text type="secondary" style={{ fontSize: 15 }}>
{loading
? 'Daten werden geladen …'
: totalPending > 0
? `Sie haben ${totalPending} offene Vorgänge in Ihren Bereichen.`
: 'Alle Bereiche sind auf dem aktuellen Stand.'}
</Text>
</div>
{loading && !counts ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spin size="large" />
</div>
) : (
<Row gutter={[24, 24]}>
{visibleTiles.map((tile) => {
const count = counts?.[tile.key] ?? 0;
const iconBg = isDark ? tile.accentSoftDark : tile.accentSoft;
return (
<Col key={tile.key} xs={24} sm={12} lg={8} xxl={6}>
<Card
hoverable
onClick={() => navigate(tile.path)}
styles={{
body: {
padding: 24,
height: '100%',
display: 'flex',
flexDirection: 'column',
},
}}
style={{
height: '100%',
borderRadius: 12,
borderTop: `3px solid ${tile.accent}`,
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: iconBg,
color: tile.accent,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 22,
}}
>
{tile.icon}
</div>
{count > 0 && (
<Badge
count={count}
overflowCount={999}
style={{
backgroundColor: tile.accent,
boxShadow: 'none',
}}
/>
)}
</div>
<Title level={4} style={{ marginTop: 0, marginBottom: 8 }}>
{tile.title}
</Title>
<Paragraph
type="secondary"
style={{ marginBottom: 16, flex: 1 }}
>
{tile.description}
</Paragraph>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 12,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text style={{ fontSize: 13, color: token.colorTextSecondary }}>
{count > 0
? `${count} offen`
: 'Keine offenen Vorgänge'}
</Text>
<Button
type="link"
size="small"
style={{ padding: 0, color: tile.accent }}
>
Öffnen <ArrowRightOutlined />
</Button>
</div>
</Card>
</Col>
);
})}
</Row>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Card,
Input,
Popconfirm,
Popover,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
DeleteOutlined,
EyeOutlined,
FolderOpenOutlined,
QrcodeOutlined,
ReloadOutlined,
ScanOutlined,
SearchOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox';
const { Title } = Typography;
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function renderBarcodes(barcodes: InboxBarcode[]): ReactNode {
if (!barcodes || barcodes.length === 0) {
return <Typography.Text type="secondary"></Typography.Text>;
}
return (
<Space size={[4, 4]} wrap>
{barcodes.map((b, idx) => {
const label = b.templateName ?? b.value;
const color = b.templateName ? 'green' : 'default';
return (
<Tooltip
key={`${b.page}-${idx}`}
title={
<div>
<div>Seite {b.page}</div>
<div style={{ fontFamily: 'monospace' }}>{b.value}</div>
{!b.templateName && <div>Keine passende Vorlage</div>}
</div>
}
>
<Tag icon={<QrcodeOutlined />} color={color}>
S.{b.page}: {label}
</Tag>
</Tooltip>
);
})}
</Space>
);
}
function DocumentPreviewPopover({ record, children }: { record: InboxFile; children: ReactNode }) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleOpenChange = async (open: boolean) => {
if (open && !blobUrl && !loading) {
setLoading(true);
try {
const blob = await inboxApi.thumbnailBlob(record.id, 1);
setBlobUrl(URL.createObjectURL(blob));
} catch {
// error handling handled implicitly
} finally {
setLoading(false);
}
}
};
useEffect(() => {
return () => {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [blobUrl]);
const content = (
<div style={{ width: 250, minHeight: 150, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{loading ? (
<Spin />
) : blobUrl ? (
<img src={blobUrl} alt="Vorschau" style={{ maxWidth: '100%', maxHeight: 350, objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">Vorschau nicht verfügbar</Typography.Text>
)}
</div>
);
return (
<Popover content={content} title="Vorschau (Seite 1)" onOpenChange={handleOpenChange} placement="right" mouseEnterDelay={0.5}>
<span style={{ cursor: 'pointer', textDecoration: 'underline dashed #ccc', display: 'inline-block' }}>{children}</span>
</Popover>
);
}
export default function InboxPage() {
const navigate = useNavigate();
const [files, setFiles] = useState<InboxFile[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [rescanning, setRescanning] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await inboxApi.list();
setFiles(data);
} catch {
message.error('Dateien konnten nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleRescan = async () => {
setRescanning(true);
const hide = message.loading('Rescan läuft das kann je nach Anzahl der Dokumente dauern …', 0);
try {
const { scanned, failed } = await inboxApi.rescan();
hide();
if (failed > 0) {
message.warning(`Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`);
} else {
message.success(`Rescan abgeschlossen: ${scanned} Dokument(e) neu gescannt`);
}
await load();
} catch {
hide();
message.error('Rescan fehlgeschlagen');
} finally {
setRescanning(false);
}
};
const handleDelete = async (id: string) => {
try {
await inboxApi.remove(id);
message.success('Dokument gelöscht');
await load();
} catch {
message.error('Löschen fehlgeschlagen');
}
};
const filtered = files.filter((f) =>
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
);
const columns: ColumnsType<InboxFile> = [
{
title: 'Dateiname',
dataIndex: 'name',
key: 'name',
sorter: (a, b) => a.name.localeCompare(b.name),
render: (name: string, record) => (
<DocumentPreviewPopover record={record}>
<Typography.Text>{name}</Typography.Text>
</DocumentPreviewPopover>
),
},
{
title: 'Quelle',
dataIndex: 'source',
key: 'source',
width: 160,
filters: [
{ text: 'Gemeinsam', value: 'all' },
{ text: 'Persönlich', value: 'user' },
],
onFilter: (value, record) => record.source === value,
render: (src: InboxFile['source']) =>
src === 'user' ? (
<Tag icon={<UserOutlined />} color="purple">
Persönlich
</Tag>
) : (
<Tag icon={<FolderOpenOutlined />} color="blue">
Gemeinsam
</Tag>
),
},
{
title: 'QR-Code / Vorlage',
key: 'barcodes',
width: 260,
render: (_, record) => renderBarcodes(record.barcodes),
},
{
title: 'Seiten',
dataIndex: 'pageCount',
key: 'pageCount',
width: 100,
sorter: (a, b) => a.pageCount - b.pageCount,
render: (n: number) => (n > 0 ? n : <Typography.Text type="secondary"></Typography.Text>),
},
{
title: 'Empfangen',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
defaultSortOrder: 'descend',
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
render: (iso: string) => formatDate(iso),
},
{
title: 'Aktionen',
key: 'actions',
width: 140,
render: (_, record) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<Tooltip title="Vorschau öffnen">
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => navigate(`/inbox/${encodeURIComponent(record.id)}`)}
>
Vorschau
</Button>
</Tooltip>
<Popconfirm
title="Dokument löschen?"
description="Datei und Datenbank-Eintrag werden dauerhaft entfernt."
okText="Löschen"
cancelText="Abbrechen"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
Löschen
</Button>
</Popconfirm>
</div>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={3} style={{ margin: 0 }}>
Eingangsbox
</Title>
<Typography.Text type="secondary">
Dateien aus <code>/mnt/scans/all</code> und Ihrem persönlichen
Scan-Ordner.
</Typography.Text>
</div>
<Space>
<Input
prefix={<SearchOutlined />}
placeholder="Suchen …"
style={{ width: 260 }}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={load}>
Aktualisieren
</Button>
<Popconfirm
title="Alle Dokumente neu scannen?"
description="Alle PDFs in der Eingangsbox werden erneut auf QR-Codes geprüft. Das kann je nach Anzahl der Dokumente einige Zeit dauern."
okText="Rescan starten"
cancelText="Abbrechen"
onConfirm={handleRescan}
>
<Button icon={<ScanOutlined />} loading={rescanning}>
Rescan
</Button>
</Popconfirm>
</Space>
</div>
<Card>
<Table<InboxFile>
rowKey="id"
columns={columns}
dataSource={filtered}
loading={loading}
pagination={{
pageSize: 25,
showSizeChanger: true,
showTotal: (t) => `${t} Dateien`,
}}
locale={{ emptyText: 'Keine Dateien vorhanden' }}
/>
</Card>
</div>
);
}
@@ -0,0 +1,44 @@
import { Button, Typography, Space } from 'antd';
import { LoginOutlined } from '@ant-design/icons';
import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext';
const { Title, Paragraph } = Typography;
export default function LoginPage() {
const { login } = useAuth();
const { isDark } = useTheme();
const backgroundStyle = isDark
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'linear-gradient(135deg, #e8ecf8 0%, #f0f4ff 50%, #e6f0ff 100%)';
const titleColor = isDark ? '#fff' : '#1a1a2e';
const subtitleColor = isDark ? 'rgba(255,255,255,0.8)' : '#4a4a6a';
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: backgroundStyle,
}}>
<Space direction="vertical" align="center" style={{ textAlign: 'center' }}>
<Title style={{ color: titleColor, margin: 0 }}>Paperless Manager</Title>
<Paragraph style={{ color: subtitleColor, fontSize: 16 }}>
Dokumenten-Middleware für Paperless-ngx
</Paragraph>
<Button
type="primary"
size="large"
icon={<LoginOutlined />}
onClick={login}
style={{ marginTop: 24 }}
>
Mit Authentik anmelden
</Button>
</Space>
</div>
);
}
@@ -0,0 +1,259 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Button, Space, Spin, Tag, Typography, Table, message, Empty, Popconfirm
} from 'antd';
import { ArrowLeftOutlined, FileTextOutlined, CloseCircleOutlined } 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';
const { Title, Text } = Typography;
export default function MailDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
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);
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.updateStatus(email.Id, 3);
message.success('E-Mail als ignoriert markiert');
navigate('/mailpostfach');
} catch (err) {
message.error('Fehler beim Markieren der E-Mail');
}
};
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) return null;
const entries = Object.entries(ids);
if (entries.length === 0) return null;
return (
<Space size={[0, 4]} wrap>
{entries.map(([, id]) => (
<Tag
color="blue"
key={id}
style={{ cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const paperlessUrl = getEnv('VITE_PAPERLESS_URL');
if (paperlessUrl) {
window.open(`${paperlessUrl}/documents/${id}`, '_blank');
}
}}
>
{id}
</Tag>
))}
</Space>
);
}
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
Zurück
</Button>
<Title level={3} style={{ margin: 0 }}>{email.Subject}</Title>
{hasErechnung && <Tag color="green">eRechnung</Tag>}
</Space>
<Space>
<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 />}>
Als ignoriert markieren
</Button>
</Popconfirm>
<Button
type="primary"
icon={<FileTextOutlined />}
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: '2fr 3fr', gap: 16, height: 'calc(100vh - 140px)' }}>
{/* Linke Seite: E-Mail-Inhalt */}
<Card
title="E-Mail"
size="small"
styles={{ body: { overflow: 'auto', height: '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: 'calc(100vh - 200px)' } }}>
<div style={{ flex: '0 0 auto', borderBottom: '1px solid #303030', 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={{ flex: 1, minHeight: 0, background: '#1a1a2e' }}>
{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)}
email={email}
attachments={attachments}
/>
)}
</div>
);
}
@@ -0,0 +1,204 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Table, Card, Typography, Button, Space, Tag, message, Input, Select } from 'antd';
import { ReloadOutlined, DownloadOutlined, CheckCircleOutlined, SearchOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { emailsApi, type EmailItem } from '../api/emails';
import { useAuth } from '../auth/AuthContext';
import { Permission } from '../auth/permissions';
const { Title } = Typography;
export default function MailpostfachPage() {
const navigate = useNavigate();
const [emails, setEmails] = useState<EmailItem[]>([]);
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [checking, setChecking] = useState(false);
const [searchText, setSearchText] = useState(() => sessionStorage.getItem('mailSearch') || '');
const [statusFilter, setStatusFilter] = useState<number | 'all'>(() => {
const saved = sessionStorage.getItem('mailStatus');
return saved !== null && saved !== 'all' ? Number(saved) : 'all';
});
const { hasPermission } = useAuth();
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await emailsApi.list({ limit: 9999 });
setEmails(data);
} catch (err) {
message.error('E-Mails konnten nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
sessionStorage.setItem('mailSearch', searchText);
}, [searchText]);
useEffect(() => {
sessionStorage.setItem('mailStatus', String(statusFilter));
}, [statusFilter]);
const columns: ColumnsType<EmailItem> = [
{
title: 'Datum',
dataIndex: 'Date',
key: 'Date',
width: 160,
render: (d: string) => (d ? dayjs(d).format('DD.MM.YYYY HH:mm') : '-'),
sorter: (a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime(),
defaultSortOrder: 'descend',
},
{
title: 'Absender',
dataIndex: 'SenderAddress',
key: 'SenderAddress',
ellipsis: true,
},
{
title: 'Betreff',
dataIndex: 'Subject',
key: 'Subject',
ellipsis: true,
render: (subject: string, record) => {
const hasErechnung = record.Attachments?.some((a) => a.Erechnung);
return (
<Space size={8}>
<span>{subject}</span>
{hasErechnung && <Tag color="green">eRechnung</Tag>}
</Space>
);
},
},
{
title: 'Status',
dataIndex: 'Status',
key: 'Status',
width: 110,
render: (s: number) => {
if (s === 0) return <Tag color="blue">Neu</Tag>;
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
if (s === 2) return <Tag color="red">Fehler</Tag>;
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
return <Tag>{s}</Tag>;
},
filters: [
{ text: 'Neu', value: 0 },
{ text: 'Verarbeitet', value: 1 },
{ text: 'Fehler', value: 2 },
{ text: 'Ignoriert', value: 3 },
],
onFilter: (value, record) => record.Status === value,
},
];
const filteredEmails = emails.filter((email) => {
if (statusFilter !== 'all' && email.Status !== statusFilter) {
return false;
}
if (searchText) {
const lowerSearch = searchText.toLowerCase();
const matchSender = email.SenderAddress?.toLowerCase().includes(lowerSearch) || false;
const matchSubject = email.Subject?.toLowerCase().includes(lowerSearch) || false;
if (!matchSender && !matchSubject) return false;
}
return true;
});
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>Mailpostfach</Title>
<Space>
<Button
icon={<DownloadOutlined />}
onClick={async () => {
setFetching(true);
try {
const result = await emailsApi.triggerFetch();
message.success(result.message || 'E-Mails wurden abgerufen.');
await loadData();
} catch {
message.error('E-Mail-Abruf fehlgeschlagen.');
} finally {
setFetching(false);
}
}}
loading={fetching}
>
Abrufen
</Button>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}>
Aktualisieren
</Button>
{hasPermission(Permission.MANAGE_ALL) && (
<Button
icon={<CheckCircleOutlined />}
onClick={async () => {
setChecking(true);
try {
const result = await emailsApi.checkAttachments();
message.success(`${result.updatedCount} E-Mail(s) wurden als verarbeitet markiert.`);
if (result.updatedCount > 0) {
await loadData();
}
} catch {
message.error('Prüfung fehlgeschlagen.');
} finally {
setChecking(false);
}
}}
loading={checking}
>
Anhänge prüfen
</Button>
)}
</Space>
</div>
<Card>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder="Suchen (Absender, Betreff)"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 200 }}
options={[
{ value: 'all', label: 'Alle Status' },
{ value: 0, label: 'Neu' },
{ value: 1, label: 'Verarbeitet' },
{ value: 2, label: 'Fehler' },
{ value: 3, label: 'Ignoriert' },
]}
/>
</div>
<Table<EmailItem>
columns={columns}
dataSource={filteredEmails}
loading={loading}
rowKey="Id"
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} E-Mails` }}
onRow={(record) => ({
onClick: () => navigate(`/mailpostfach/${record.Id}`),
style: { cursor: 'pointer' },
})}
/>
</Card>
</div>
);
}
@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { Table, Popover, Image, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
import type { PosteingangDocument } from '../api/posteingang';
import DocumentEditModal from '../components/DocumentEditModal';
import { getEnv } from '../utils/env';
export default function ManuellBearbeitenPage() {
const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<PosteingangDocument | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const docs = await posteingangApi.getManuellList();
setData(docs || []);
} catch (e) {
message.error("Dokumente konnten nicht geladen werden.");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// Refresh interval every 30 seconds
const interval = setInterval(() => {
fetchData();
}, 30000);
return () => clearInterval(interval);
}, []);
const handleEdit = (doc: PosteingangDocument) => {
setSelectedDoc(doc);
setEditModalOpen(true);
};
const handleCloseModal = (openNext?: boolean) => {
setEditModalOpen(false);
if (openNext && data.length > 0) {
const currentIndex = data.findIndex(d => d.id === selectedDoc?.id);
const nextIndex = currentIndex !== -1 && currentIndex + 1 < data.length ? currentIndex + 1 : 0;
const nextDoc = data[nextIndex];
setTimeout(() => {
setSelectedDoc(nextDoc);
setEditModalOpen(true);
}, 300);
} else {
setSelectedDoc(null);
}
};
const popoverContent = (id: number) => (
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
width={400}
preview={false}
/>
);
const columns = [
{
title: 'Vorschau',
key: 'preview',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={100}
preview={false}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
</Popover>
),
},
{
title: 'Titel',
dataIndex: 'title',
key: 'title',
width: '35%',
},
{
title: 'Eingangsdatum',
key: 'eingangsdatum',
render: (_: any, record: PosteingangDocument) => {
return record.created ? dayjs(record.created).format('DD.MM.YYYY') : '-';
}
},
{
title: 'Importiert am',
dataIndex: 'added',
key: 'added',
render: (text: string) => dayjs(text).format('DD.MM.YYYY HH:mm'),
},
{
title: 'Aktion',
key: 'action',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Button type="primary" onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
),
},
];
return (
<ConfigProvider>
<div style={{ padding: '0 24px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Manuell bearbeiten</h2>
<Space>
<Tooltip title="Manuell aktualisieren">
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading} />
</Tooltip>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<DocumentEditModal
documentId={selectedDoc?.id || null}
document={selectedDoc}
open={editModalOpen}
onClose={handleCloseModal}
onSave={fetchData}
isPosteingang={false}
hasNextDocument={data.length > 1}
/>
</div>
</ConfigProvider>
);
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import { Table, Popover, Image, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
import type { PosteingangDocument } from '../api/posteingang';
import DocumentEditModal from '../components/DocumentEditModal';
import { getEnv } from '../utils/env';
export default function PosteingangPage() {
const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<PosteingangDocument | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const docs = await posteingangApi.getList();
setData(docs || []);
} catch (e) {
message.error("Posteingang konnte nicht geladen werden.");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// Refresh interval every 30 seconds
const interval = setInterval(() => {
fetchData();
}, 30000);
return () => clearInterval(interval);
}, []);
const handleEdit = (doc: PosteingangDocument) => {
setSelectedDoc(doc);
setEditModalOpen(true);
};
const handleCloseModal = (openNext?: boolean) => {
setEditModalOpen(false);
if (openNext && data.length > 0) {
const currentIndex = data.findIndex(d => d.id === selectedDoc?.id);
const nextIndex = currentIndex !== -1 && currentIndex + 1 < data.length ? currentIndex + 1 : 0;
const nextDoc = data[nextIndex];
setTimeout(() => {
setSelectedDoc(nextDoc);
setEditModalOpen(true);
}, 300);
} else {
setSelectedDoc(null);
}
};
const popoverContent = (id: number) => (
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
width={400}
preview={false}
/>
);
const columns = [
{
title: 'Vorschau',
key: 'preview',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={100}
preview={false}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
</Popover>
),
},
{
title: 'Titel',
dataIndex: 'title',
key: 'title',
width: '35%',
},
{
title: 'Eingangsdatum',
key: 'eingangsdatum',
render: (_: any, record: PosteingangDocument) => {
const cf = record.customFields?.find((f) => f.field === 9);
return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-';
}
},
{
title: 'Importiert am',
dataIndex: 'added',
key: 'added',
render: (text: string) => dayjs(text).format('DD.MM.YYYY HH:mm'),
},
{
title: 'Aktion',
key: 'action',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Button type="primary" onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
),
},
];
return (
<ConfigProvider>
<div style={{ padding: '0 24px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Posteingang</h2>
<Space>
<Tooltip title="Manuell aktualisieren">
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading} />
</Tooltip>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<DocumentEditModal
documentId={selectedDoc?.id || null}
document={selectedDoc}
open={editModalOpen}
onClose={handleCloseModal}
onSave={fetchData}
isPosteingang={true}
hasNextDocument={data.length > 1}
/>
</div>
</ConfigProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react';
import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider } from 'antd';
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { tasksApi } from '../api/tasks';
import type { Task } from '../api/tasks';
function statusTag(fertig: number | null) {
if (fertig === 1) return <Tag color="success">Fertig</Tag>;
if (fertig === 0) return <Tag color="warning">Ausstehend</Tag>;
return <Tag color="default">Neu</Tag>;
}
export default function TaskLogPage() {
const [data, setData] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const tasks = await tasksApi.getAll();
setData(tasks || []);
} catch {
message.error('Task-Log konnte nicht geladen werden.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
const handleDeleteFertige = async () => {
setDeleting(true);
try {
const result = await tasksApi.deleteFertige();
message.success(`${result.deleted} erledigte Task(s) gelöscht.`);
await fetchData();
} catch {
message.error('Fehler beim Löschen der erledigten Tasks.');
} finally {
setDeleting(false);
}
};
const handleDeleteOne = async (taskId: string) => {
try {
await tasksApi.deleteOne(taskId);
message.success('Task gelöscht.');
await fetchData();
} catch {
message.error('Task konnte nicht gelöscht werden.');
}
};
const columns = [
{
title: 'Task-ID',
dataIndex: 'TaskId',
key: 'TaskId',
width: 100,
render: (id: string) => (
<Tooltip title={id}>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{id.slice(0, 8)}</span>
</Tooltip>
),
},
{
title: 'Interne Belegnr.',
dataIndex: 'InterneBelegnummer',
key: 'InterneBelegnummer',
},
{
title: 'Lieferant',
dataIndex: 'Lieferant',
key: 'Lieferant',
render: (v: string | null) => v || '-',
},
{
title: 'Belegdatum',
dataIndex: 'Belegdatum',
key: 'Belegdatum',
render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY') : '-'),
},
{
title: 'Eingangsdatum',
dataIndex: 'Eingangsdatum',
key: 'Eingangsdatum',
render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY') : '-'),
},
{
title: 'Paperless-Dok.-ID',
dataIndex: 'PaperlessDocumentID',
key: 'PaperlessDocumentID',
render: (v: number | null) => v ?? '-',
},
{
title: 'Status',
dataIndex: 'Fertig',
key: 'Fertig',
render: (v: number | null) => statusTag(v),
filters: [
{ text: 'Fertig', value: 1 },
{ text: 'Ausstehend', value: 0 },
{ text: 'Neu', value: null as any },
],
onFilter: (value: any, record: Task) => record.Fertig === value,
},
{
title: '',
key: 'actions',
width: 60,
render: (_: any, record: Task) => (
<Popconfirm
title="Task löschen"
description={`Task ${record.TaskId.slice(0, 8)}… dauerhaft entfernen?`}
onConfirm={() => handleDeleteOne(record.TaskId)}
okText="Löschen"
cancelText="Abbrechen"
okButtonProps={{ danger: true }}
>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<ConfigProvider>
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 }}>
<Space>
<Tooltip title="Manuell aktualisieren">
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading} />
</Tooltip>
<Popconfirm
title="Erledigte Tasks löschen"
description="Alle Tasks mit Status 'Fertig' werden dauerhaft aus der Datenbank entfernt."
onConfirm={handleDeleteFertige}
okText="Löschen"
cancelText="Abbrechen"
okButtonProps={{ danger: true }}
>
<Button
danger
icon={<DeleteOutlined />}
loading={deleting}
>
Erledigte löschen
</Button>
</Popconfirm>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="TaskId"
loading={loading}
pagination={{ pageSize: 20 }}
/>
</div>
</ConfigProvider>
);
}