Initial commit with Email Import Wizard and Task Processor updates
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user