Files
paperlessmanager/paperless-frontend/src/pages/InboxPage.tsx
T
bjoernpoettker 7076eef57b
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
feat: add mobile-responsive layout and card views across all pages
- New useIsMobile hook and MobileCardList component
- AppLayout: hamburger menu + Drawer navigation on mobile
- All list pages (Inbox, Posteingang, Manuell, Mail, Freigabe, Zahlung, TaskLog)
  show card layout on mobile instead of tables
- CSS: responsive modal height, horizontal table scroll, text-size-adjust
- Backend: Agrarmonitor polling fixes, Zahlung service improvements,
  IMAP folder service extended, misc controller fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 09:22:58 +02:00

539 lines
16 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 dayjs from 'dayjs';
import {
Button,
Card,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Popover,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
DeleteOutlined,
EyeOutlined,
FolderOpenOutlined,
PrinterOutlined,
QrcodeOutlined,
ReloadOutlined,
ScanOutlined,
SearchOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox';
import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates';
import { labelPrintAgentApi } from '../api/labelPrintAgent';
import { userSettingsApi } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
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 renderSourceTag(src: InboxFile['source']): ReactNode {
return src === 'user' ? (
<Tag icon={<UserOutlined />} color="purple">
Persönlich
</Tag>
) : (
<Tag icon={<FolderOpenOutlined />} color="blue">
Gemeinsam
</Tag>
);
}
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>
);
}
function buildInitialFieldValues(template: BarcodeTemplate | null): Record<string, string> {
const today = dayjs().format('YYYY-MM-DD');
const values: Record<string, string> = {};
for (const field of template?.LabelInputFields ?? []) {
if (field.type === 'date') values[field.name] = today;
}
return values;
}
export default function InboxPage() {
const navigate = useNavigate();
const isMobile = useIsMobile();
const [files, setFiles] = useState<InboxFile[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [rescanning, setRescanning] = useState(false);
const [printDialogOpen, setPrintDialogOpen] = useState(false);
const [labelTemplates, setLabelTemplates] = useState<BarcodeTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<BarcodeTemplate | null>(null);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [printing, setPrinting] = useState(false);
const [labelCount, setLabelCount] = useState(1);
const openPrintDialog = async () => {
try {
const [all, settings] = await Promise.all([
barcodeTemplatesApi.list(),
userSettingsApi.get(),
]);
const enabled = all.filter((t) => t.LabelEnabled);
setLabelTemplates(enabled);
const defaultId = settings.defaultLabelTemplateId;
const defaultTemplate = defaultId != null
? (enabled.find((t) => t.Id === defaultId) ?? null)
: null;
setSelectedTemplate(defaultTemplate);
setFieldValues(buildInitialFieldValues(defaultTemplate));
} catch {
message.error('Vorlagen konnten nicht geladen werden');
return;
}
setLabelCount(1);
setPrintDialogOpen(true);
};
const handleTemplateSelect = (id: number) => {
const t = labelTemplates.find((t) => t.Id === id) ?? null;
setSelectedTemplate(t);
setFieldValues(buildInitialFieldValues(t));
};
const handlePrint = async () => {
if (!selectedTemplate) return;
setPrinting(true);
try {
for (let i = 0; i < labelCount; i++) {
await labelPrintAgentApi.createJob(selectedTemplate.Id, fieldValues);
}
message.success(labelCount === 1 ? 'Druckauftrag erstellt' : `${labelCount} Druckaufträge erstellt`);
setPrintDialogOpen(false);
} catch (err: any) {
message.error(err?.response?.data?.message ?? 'Druckauftrag fehlgeschlagen');
} finally {
setPrinting(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]);
useEffect(() => {
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [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 handleUpdateSource = async (id: string, current: InboxFile['source']) => {
const next = current === 'all' ? 'user' : 'all';
try {
await inboxApi.updateSource(id, next);
message.success(
next === 'all' ? 'Zu Öffentlich verschoben' : 'Zu Persönlich verschoben',
);
await load();
} catch {
message.error('Verschieben fehlgeschlagen');
}
};
const filtered = files.filter((f) =>
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
);
// Karten-Ansicht sortiert wie die Tabelle (neueste zuerst)
const sortedForMobile = [...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const renderActions = (record: InboxFile, direction: 'column' | 'row' = 'column') => (
<div style={{ display: 'flex', flexDirection: direction, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<Tooltip title="Vorschau öffnen">
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => navigate(`/inbox/${encodeURIComponent(record.id)}`)}
>
Weiterverarbeiten
</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>
<Tooltip
title={
record.source === 'all'
? 'In meinen persönlichen Scan-Ordner verschieben'
: 'In den gemeinsamen Ordner (Öffentlich) verschieben'
}
>
<Button
type="link"
icon={record.source === 'all' ? <UserOutlined /> : <TeamOutlined />}
onClick={() => handleUpdateSource(record.id, record.source)}
>
{record.source === 'all' ? 'Zu Persönlich' : 'Zu Öffentlich'}
</Button>
</Tooltip>
</div>
);
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']) => renderSourceTag(src),
},
{
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) => renderActions(record),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
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 wrap>
<Input
prefix={<SearchOutlined />}
placeholder="Suchen …"
style={{ width: isMobile ? '100%' : 260 }}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
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>
<Button icon={<PrinterOutlined />} onClick={openPrintDialog}>
Etikett drucken
</Button>
</Space>
</div>
<Modal
title="Etikett drucken"
open={printDialogOpen}
onCancel={() => setPrintDialogOpen(false)}
onOk={handlePrint}
okText="Drucken"
cancelText="Abbrechen"
confirmLoading={printing}
okButtonProps={{ disabled: !selectedTemplate }}
>
<Form layout="vertical">
<Form.Item label="Eingangsdokumentart">
<Select
placeholder="Vorlage wählen…"
options={labelTemplates.map((t) => ({ value: t.Id, label: t.Name }))}
onChange={handleTemplateSelect}
value={selectedTemplate?.Id}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Anzahl Etiketten">
<InputNumber
min={1}
max={100}
value={labelCount}
onChange={(v) => setLabelCount(v ?? 1)}
style={{ width: '100%' }}
/>
</Form.Item>
{selectedTemplate?.LabelInputFields?.map((field) => (
<Form.Item key={field.name} label={field.label || field.name}>
{field.type === 'date' ? (
<DatePicker
style={{ width: '100%' }}
format="DD.MM.YYYY"
value={fieldValues[field.name] ? dayjs(fieldValues[field.name]) : null}
onChange={(d) =>
setFieldValues((prev) => ({
...prev,
[field.name]: d ? d.format('YYYY-MM-DD') : '',
}))
}
/>
) : field.type === 'number' ? (
<InputNumber
style={{ width: '100%' }}
value={fieldValues[field.name] ? Number(fieldValues[field.name]) : undefined}
onChange={(v) =>
setFieldValues((prev) => ({ ...prev, [field.name]: String(v ?? '') }))
}
/>
) : (
<Input
value={fieldValues[field.name] ?? ''}
onChange={(e) =>
setFieldValues((prev) => ({ ...prev, [field.name]: e.target.value }))
}
/>
)}
</Form.Item>
))}
</Form>
</Modal>
{isMobile ? (
<MobileCardList<InboxFile>
dataSource={sortedForMobile}
rowKey="id"
loading={loading}
pagination={{
pageSize: 25,
showTotal: (t) => `${t} Dateien`,
}}
emptyText="Keine Dateien vorhanden"
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<DocumentPreviewPopover record={record}>
<Typography.Text strong>{record.name}</Typography.Text>
</DocumentPreviewPopover>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{renderSourceTag(record.source)}
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{record.pageCount > 0 ? `${record.pageCount} Seiten` : '—'} · {formatDate(record.createdAt)}
</Typography.Text>
</div>
{renderBarcodes(record.barcodes)}
{renderActions(record, 'row')}
</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>
);
}