feat: add mobile-responsive layout and card views across all pages
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
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:
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user