feat: add mobile-responsive layout and card views across all pages
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>
This commit is contained in:
2026-07-14 09:22:58 +02:00
parent 0765d14d3b
commit 7076eef57b
28 changed files with 1081 additions and 462 deletions
+100 -59
View File
@@ -37,6 +37,8 @@ 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;
@@ -51,6 +53,18 @@ function formatDate(iso: string): string {
});
}
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>;
@@ -137,6 +151,7 @@ function buildInitialFieldValues(template: BarcodeTemplate | null): Record<strin
export default function InboxPage() {
const navigate = useNavigate();
const isMobile = useIsMobile();
const [files, setFiles] = useState<InboxFile[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -261,6 +276,50 @@ export default function InboxPage() {
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',
@@ -283,16 +342,7 @@ export default function InboxPage() {
{ 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>
),
render: (src: InboxFile['source']) => renderSourceTag(src),
},
{
title: 'QR-Code / Vorlage',
@@ -321,46 +371,7 @@ export default function InboxPage() {
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)}`)}
>
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>
),
render: (_, record) => renderActions(record),
},
];
@@ -371,6 +382,8 @@ export default function InboxPage() {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
marginBottom: 16,
}}
>
@@ -383,11 +396,11 @@ export default function InboxPage() {
Scan-Ordner.
</Typography.Text>
</div>
<Space>
<Space wrap>
<Input
prefix={<SearchOutlined />}
placeholder="Suchen …"
style={{ width: 260 }}
style={{ width: isMobile ? '100%' : 260 }}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
@@ -478,20 +491,48 @@ export default function InboxPage() {
</Form>
</Modal>
<Card>
<Table<InboxFile>
{isMobile ? (
<MobileCardList<InboxFile>
dataSource={sortedForMobile}
rowKey="id"
columns={columns}
dataSource={filtered}
loading={loading}
pagination={{
pageSize: 25,
showSizeChanger: true,
showTotal: (t) => `${t} Dateien`,
}}
locale={{ emptyText: 'Keine Dateien vorhanden' }}
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>
) : (
<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>
);
}