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
+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>
);
}