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