Files
paperlessmanager/paperless-frontend/src/pages/InboxPage.tsx
T

325 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}