325 lines
8.8 KiB
TypeScript
325 lines
8.8 KiB
TypeScript
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>
|
||
);
|
||
}
|