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
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd';
import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
import { PlusOutlined, EyeOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
@@ -11,6 +11,7 @@ import type { PaperlessDocType, PaperlessCorrespondent, PaperlessTag } from '../
import { getEnv } from '../utils/env';
import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
import DocumentSearchModal from './DocumentSearchModal';
import { useIsMobile } from '../hooks/useIsMobile';
const { Option } = Select;
@@ -26,6 +27,7 @@ interface Props {
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
const [form] = Form.useForm();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -349,8 +351,8 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
title={`Dokument bearbeiten (${document?.title || ''})`}
open={open && !kontonummerMissing}
onCancel={() => onClose(false)}
width={1400}
style={{ top: 20 }}
width={{ xs: '100vw', md: 900, xl: 1400 }}
style={isMobile ? {} : { top: 20 }}
footer={
hasNextDocument ? [
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
@@ -363,8 +365,12 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
}
>
<Spin spinning={loading}>
<Row gutter={16} style={{ height: '75vh', overflow: 'hidden' }}>
<Col span={10} style={{ overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}>
<Row gutter={[16, 16]} style={isMobile ? {} : { height: '75vh', overflow: 'hidden' }}>
<Col
xs={24}
lg={10}
style={isMobile ? {} : { overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}
>
<Form form={form} layout="vertical" disabled={saving}>
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
@@ -556,10 +562,20 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
})}
</Form>
</Col>
<Col span={14} style={{ height: '100%' }}>
<Col xs={24} lg={14} style={isMobile ? {} : { height: '100%' }}>
{isMobile && (
<Button
icon={<ExportOutlined />}
block
style={{ marginBottom: 8 }}
onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}`)}
>
PDF in neuem Tab öffnen
</Button>
)}
<AuthIframe
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
style={{ width: '100%', height: '100%' }}
style={{ width: '100%', height: isMobile ? '50vh' : '100%' }}
title="PDF Preview"
/>
</Col>
@@ -57,7 +57,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
open={open}
onCancel={onCancel}
footer={null}
width={800}
width={{ xs: '100vw', md: 800 }}
style={{ top: 50 }}
>
<div style={{ marginBottom: 16 }}>
@@ -100,7 +100,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
description={
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>Erstellt: {dayjs(doc.created_date).format('DD.MM.YYYY')}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>Erstellt: {dayjs(doc.created).format('DD.MM.YYYY')}</Text>
</Space>
}
/>
@@ -538,8 +538,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
{toProcess.map(item => (
<div key={item.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{item.fileName}</Text>
<Row gutter={24}>
<Col span={8}>
<Row gutter={[24, 16]}>
<Col xs={24} lg={8}>
{/* Eingangsdatum */}
<div style={{ marginBottom: 16 }}>
<Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text>
@@ -626,7 +626,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
</Text>
</div>
</Col>
<Col span={16}>
<Col xs={24} lg={16}>
<BarcodePositioner
attachmentId={item.attachmentId}
startPage={item.pages?.start}
@@ -666,9 +666,9 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
return (
<div key={main.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{main.fileName}</Text>
<Row gutter={24} align="middle">
<Col span={showPrint ? 20 : 24}>
<Space size={24}>
<Row gutter={[24, 12]} align="middle">
<Col xs={24} md={showPrint ? 20 : 24}>
<Space size={24} wrap>
<Text type="secondary">
Eingangsdatum: <Text strong>{datum?.format('DD.MM.YYYY') ?? '—'}</Text>
</Text>
@@ -690,7 +690,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
)}
</Col>
{showPrint && (
<Col span={4} style={{ textAlign: 'right' }}>
<Col xs={24} md={4} style={{ textAlign: 'right' }}>
<Button icon={<PrinterOutlined />} onClick={() => printDocument(main.virtualId, main.attachmentId)}>
Drucken
</Button>
@@ -735,7 +735,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
title="Paperless Import-Wizard"
open={visible}
onCancel={onClose}
width={1000}
width={{ xs: '100vw', md: 900, lg: 1000 }}
footer={
importSuccess ? (
<Button type="primary" onClick={onSuccess ?? onClose}>Schließen</Button>
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { List, Card } from 'antd';
import type { TablePaginationConfig } from 'antd';
interface MobileCardListProps<T> {
dataSource: T[];
rowKey: keyof T | ((record: T) => React.Key);
loading?: boolean;
/** Gleiche Pagination-Config wie bei <Table> — wird durchgereicht (mobil kompakt). */
pagination?: TablePaginationConfig | false;
/** Seitenspezifischer Karteninhalt für einen Datensatz. */
renderCard: (record: T) => ReactNode;
onCardClick?: (record: T) => void;
emptyText?: string;
}
/**
* Mobile Karten-Ansicht als Ersatz für breite Tabellen:
* eine Karte pro Datensatz, Pagination wie bei der Tabelle.
*/
export default function MobileCardList<T>({
dataSource,
rowKey,
loading,
pagination,
renderCard,
onCardClick,
emptyText = 'Keine Einträge vorhanden',
}: MobileCardListProps<T>) {
const getKey = (record: T): React.Key =>
typeof rowKey === 'function' ? rowKey(record) : (record[rowKey] as React.Key);
return (
<List
dataSource={dataSource}
loading={loading}
rowKey={getKey}
locale={{ emptyText }}
pagination={
pagination
? {
...pagination,
position: undefined,
simple: true,
showSizeChanger: false,
}
: false
}
renderItem={(record) => (
<List.Item style={{ padding: 0, marginBottom: 8, borderBlockEnd: 'none' }}>
<Card
size="small"
style={{ width: '100%' }}
styles={{ body: { padding: 12 } }}
hoverable={!!onCardClick}
onClick={onCardClick ? () => onCardClick(record) : undefined}
>
{renderCard(record)}
</Card>
</List.Item>
)}
/>
);
}