7076eef57b
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
- 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>
539 lines
16 KiB
TypeScript
539 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|