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
+6
View File
@@ -13,6 +13,12 @@ Paperless Manager is a document automation platform that extends [Paperless-NGX]
UI labels and comments are in **German**.
## Sprache
Alle Antworten an den Nutzer, Erklärungen, Commit-Messages, Code-Kommentare und UI-Texte
sollen — wo möglich — auf **Deutsch** verfasst werden. Technische Bezeichner (Variablen-,
Funktions- und Dateinamen) bleiben unverändert in der bestehenden Konvention.
## Structure
```
@@ -85,8 +85,15 @@ export class AgrarmonitorPollingService implements OnModuleInit {
importWartezeitMinuten: string;
notizMarker: string;
}> {
const [fertig, verbucht, hochgeladen, linkField, manuell, wartezeit, marker] =
await Promise.all([
const [
fertig,
verbucht,
hochgeladen,
linkField,
manuell,
wartezeit,
marker,
] = await Promise.all([
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
@@ -648,10 +648,16 @@ export class EmailImportService {
this.logger.log(
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`,
);
const emailEntity = await this.emailRepo.findOne({ where: { Id: firstAtt.EmailMessageId } });
const emailEntity = await this.emailRepo.findOne({
where: { Id: firstAtt.EmailMessageId },
});
if (emailEntity) {
this.imapFolderService.moveToImportiert(emailEntity.MessageId).catch(err =>
this.logger.error('IMAP-Verschieben fehlgeschlagen: ' + err.message),
this.imapFolderService
.moveToImportiert(emailEntity.MessageId)
.catch((err) =>
this.logger.error(
'IMAP-Verschieben fehlgeschlagen: ' + err.message,
),
);
}
}
@@ -204,7 +204,9 @@ export class EmailController {
this.logger.log(
`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`,
);
this.imapFolderService.cleanupImportedEmails().catch(err =>
this.imapFolderService
.cleanupImportedEmails()
.catch((err) =>
this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message),
);
@@ -214,8 +216,13 @@ export class EmailController {
where: [{ Status: 1 }, { Status: 3 }],
select: ['MessageId'],
});
const messageIds = processedEmails.map((e) => e.MessageId).filter(Boolean);
movedToImportiert = await this.imapFolderService.moveProcessedInboxToImportiert(messageIds);
const messageIds = processedEmails
.map((e) => e.MessageId)
.filter(Boolean);
movedToImportiert =
await this.imapFolderService.moveProcessedInboxToImportiert(
messageIds,
);
}
return { updatedCount, idsUpdated, movedToImportiert };
@@ -25,8 +25,14 @@ export class ImapFolderService {
@Cron('0 3 * * *', { timeZone: 'Europe/Berlin' })
async cleanupImportedEmails(): Promise<void> {
if (!this.configService.get<string>('IMAP_HOST')) return;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
const trashFolder = this.configService.get<string>('IMAP_TRASH_FOLDER', 'Trash');
const importedFolder = this.configService.get<string>(
'IMAP_IMPORTED_FOLDER',
'importiert',
);
const trashFolder = this.configService.get<string>(
'IMAP_TRASH_FOLDER',
'Trash',
);
const client = this.createClient();
try {
await client.connect();
@@ -39,10 +45,14 @@ export class ImapFolderService {
const oldUids = await client.search({ before: cutoff }, { uid: true });
if (Array.isArray(oldUids) && oldUids.length > 0) {
await client.messageMove(oldUids, trashFolder, { uid: true });
this.logger.log(`${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`);
this.logger.log(
`${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`,
);
}
} catch (err: any) {
this.logger.warn(`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`);
this.logger.warn(
`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`,
);
}
// Papierkorb leeren
@@ -51,10 +61,14 @@ export class ImapFolderService {
const trashUids = await client.search({ all: true }, { uid: true });
if (Array.isArray(trashUids) && trashUids.length > 0) {
await client.messageDelete(trashUids, { uid: true });
this.logger.log(`${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`);
this.logger.log(
`${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`,
);
}
} catch (err: any) {
this.logger.warn(`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`);
this.logger.warn(
`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`,
);
}
} catch (err: any) {
this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`);
@@ -64,9 +78,13 @@ export class ImapFolderService {
}
async moveProcessedInboxToImportiert(messageIds: string[]): Promise<number> {
if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0) return 0;
if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0)
return 0;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
const importedFolder = this.configService.get<string>(
'IMAP_IMPORTED_FOLDER',
'importiert',
);
const client = this.createClient();
let movedCount = 0;
@@ -74,7 +92,7 @@ export class ImapFolderService {
await client.connect();
const mailboxes = await client.list();
if (!mailboxes.some(m => m.path === importedFolder)) {
if (!mailboxes.some((m) => m.path === importedFolder)) {
await client.mailboxCreate(importedFolder);
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
}
@@ -86,7 +104,10 @@ export class ImapFolderService {
const idSet = new Set(messageIds.map(normalize));
const uidsToMove: number[] = [];
for await (const msg of client.fetch('1:*', { uid: true, envelope: true })) {
for await (const msg of client.fetch('1:*', {
uid: true,
envelope: true,
})) {
const msgId = msg.envelope?.messageId;
if (msgId && idSet.has(normalize(msgId))) {
uidsToMove.push(msg.uid);
@@ -96,10 +117,14 @@ export class ImapFolderService {
if (uidsToMove.length > 0) {
await client.messageMove(uidsToMove, importedFolder, { uid: true });
movedCount = uidsToMove.length;
this.logger.log(`${movedCount} E-Mail(s) aus INBOX → "${importedFolder}" verschoben.`);
this.logger.log(
`${movedCount} E-Mail(s) aus INBOX → "${importedFolder}" verschoben.`,
);
}
} catch (err: any) {
this.logger.error(`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`);
this.logger.error(
`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`,
);
} finally {
await client.logout().catch(() => {});
}
@@ -110,24 +135,34 @@ export class ImapFolderService {
async moveToImportiert(messageId: string): Promise<void> {
if (!this.configService.get<string>('IMAP_HOST')) return;
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
const importedFolder = this.configService.get<string>(
'IMAP_IMPORTED_FOLDER',
'importiert',
);
const client = this.createClient();
try {
await client.connect();
const mailboxes = await client.list();
if (!mailboxes.some(m => m.path === importedFolder)) {
if (!mailboxes.some((m) => m.path === importedFolder)) {
await client.mailboxCreate(importedFolder);
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
}
await client.mailboxOpen('INBOX');
const uids = await client.search({ header: { 'message-id': messageId } }, { uid: true });
const uids = await client.search(
{ header: { 'message-id': messageId } },
{ uid: true },
);
if (Array.isArray(uids) && uids.length > 0) {
await client.messageMove(uids, importedFolder, { uid: true });
this.logger.log(`E-Mail ${messageId} → "${importedFolder}" verschoben.`);
this.logger.log(
`E-Mail ${messageId} → "${importedFolder}" verschoben.`,
);
} else {
this.logger.warn(`E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`);
this.logger.warn(
`E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`,
);
}
} catch (err: any) {
this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`);
@@ -181,7 +181,7 @@ export class PaperlessProcessorService {
doc.correspondent !== null && doc.correspondent !== undefined;
break;
case 2:
isFilled = !!doc.created || !!doc.created_date;
isFilled = !!doc.created;
break;
case 3:
isFilled =
@@ -155,7 +155,7 @@ export class PaperlessController {
asn: doc.archive_serial_number,
documentType: doc.document_type,
correspondent: doc.correspondent,
created: doc.created_date,
created: doc.created,
added: doc.added,
tags: doc.tags,
customFields: doc.custom_fields,
@@ -179,7 +179,7 @@ export class PaperlessController {
asn: doc.archive_serial_number,
documentType: doc.document_type,
correspondent: doc.correspondent,
created: doc.created_date,
created: doc.created,
added: doc.added,
tags: doc.tags,
customFields: doc.custom_fields,
@@ -338,7 +338,7 @@ export class PaperlessController {
docDate.getHours() * 60 * 60 * 1000,
);
}
oldDocument.created_date = docDate.toISOString().split('T')[0];
oldDocument.created = docDate.toISOString();
}
const cfDefinitions = await this.paperlessService.getCustomFields();
@@ -390,7 +390,7 @@ export class PaperlessController {
for (const req of reqs) {
let isFieldValid = false;
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
if (req.Type === 2) isFieldValid = oldDocument.created_date !== null;
if (req.Type === 2) isFieldValid = oldDocument.created !== null;
if (req.Type === 3)
isFieldValid = oldDocument.archive_serial_number !== null;
if (req.Type === 4)
@@ -446,10 +446,8 @@ export class PaperlessController {
);
}
}
titleTemplate = titleTemplate.replace(
'{{DATE}}',
oldDocument.created_date,
);
const createdDatePart = String(oldDocument.created ?? '').split('T')[0];
titleTemplate = titleTemplate.replace('{{DATE}}', createdDatePart);
oldDocument.title = titleTemplate;
}
} else {
@@ -471,6 +469,9 @@ export class PaperlessController {
}
}
// Veraltetes Feld nicht mit-PATCHen (löst Paperless-Deprecation-Warnung aus)
delete oldDocument.created_date;
await this.paperlessService.updateDocument(documentId, oldDocument);
return { success: true };
}
@@ -22,10 +22,7 @@ export class ZahlungController {
}
@Put('documents/:id/zahlung')
setZahlung(
@Param('id') id: string,
@Body('value') value: string | null,
) {
setZahlung(@Param('id') id: string, @Body('value') value: string | null) {
return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null);
}
@@ -20,7 +20,11 @@ export class ZahlungService {
private readonly paperlessService: PaperlessService,
) {}
async getZahlungDocuments(page: number, pageSize: number, filter: ZahlungFilter) {
async getZahlungDocuments(
page: number,
pageSize: number,
filter: ZahlungFilter,
) {
const docTypes = await this.documentTypeRepo.find({
where: { FreigabeErforderlich: true as any },
});
@@ -44,7 +48,10 @@ export class ZahlungService {
const result = await this.paperlessService.getDocuments(params);
allDocs = result.results ?? [];
} catch (err: any) {
this.logger.warn('Fehler beim Laden der Belege für Zahlung', err?.message);
this.logger.warn(
'Fehler beim Laden der Belege für Zahlung',
err?.message,
);
return { count: 0, results: [] };
}
@@ -73,9 +80,12 @@ export class ZahlungService {
private getCfValue(doc: any, fieldId: number): string | null {
const cf = (doc.custom_fields ?? []).find((f: any) => f.field === fieldId);
if (!cf || cf.value === null || cf.value === undefined || cf.value === '') return null;
if (!cf || cf.value === null || cf.value === undefined || cf.value === '')
return null;
if (typeof cf.value === 'object') {
return String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null;
return (
String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null
);
}
return String(cf.value);
}
@@ -91,20 +101,24 @@ export class ZahlungService {
}
const customFields: any[] = [...(doc.custom_fields ?? [])];
const existing = customFields.find((f: any) => f.field === ZAHLUNG_FIELD_ID);
const existing = customFields.find(
(f: any) => f.field === ZAHLUNG_FIELD_ID,
);
if (existing) {
existing.value = value;
} else if (value !== null && value !== '') {
customFields.push({ field: ZAHLUNG_FIELD_ID, value });
}
await this.paperlessService.updateDocument(documentId, { custom_fields: customFields });
await this.paperlessService.updateDocument(documentId, {
custom_fields: customFields,
});
return { success: true };
}
async getZahlungOptions(): Promise<{ id: string; label: string }[]> {
const fields = await this.paperlessService.getCustomFields();
const field = (fields as any[]).find((f: any) => f.id === ZAHLUNG_FIELD_ID);
const field = fields.find((f: any) => f.id === ZAHLUNG_FIELD_ID);
if (!field) return [];
const rawOptions: any[] = field.extra_data?.select_options ?? [];
-1
View File
@@ -4,7 +4,6 @@ export interface FreigabeDocument {
id: number;
title: string;
created: string;
created_date: string;
correspondent: number | null;
document_type: number | null;
archive_serial_number: number | null;
-1
View File
@@ -6,7 +6,6 @@ export interface ZahlungDocument {
id: number;
title: string;
created: string;
created_date: string;
correspondent: number | null;
document_type: number | null;
archive_serial_number: number | null;
@@ -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>
)}
/>
);
}
@@ -0,0 +1,11 @@
import { Grid } from 'antd';
/**
* Zentraler Breakpoint-Hook: mobil = Viewport unter `md` (768px).
* Beim allerersten Render liefert useBreakpoint() noch keine Werte —
* dann Desktop annehmen, um ein Layout-Flackern zu vermeiden.
*/
export function useIsMobile(): boolean {
const screens = Grid.useBreakpoint();
return screens.md === undefined ? false : !screens.md;
}
+26
View File
@@ -139,3 +139,29 @@ body {
.ant-picker-input > input {
font-size: 14px !important;
}
/* ── Responsive / Mobile ─────────────────────────────────────── */
html {
-webkit-text-size-adjust: 100%;
}
@media (max-width: 767px) {
.ant-modal {
top: 8px;
}
/* Modal-Inhalte scrollbar statt abgeschnitten (v. a. Settings-Dialoge) */
.ant-modal .ant-modal-body {
max-height: calc(100dvh - 160px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* Sicherheitsnetz: Tabellen ohne Karten-Ansicht seitlich scrollbar */
.ant-table-content,
.ant-table-body {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
+211 -131
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge } from 'antd';
import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge, Drawer, Button } from 'antd';
import {
InboxOutlined,
FileTextOutlined,
@@ -15,9 +15,11 @@ import {
GlobalOutlined,
CheckCircleOutlined,
EuroOutlined,
MenuOutlined,
} from '@ant-design/icons';
import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { Permission } from '../auth/permissions';
import { statsApi, type StatsCounts } from '../api/stats';
@@ -47,14 +49,19 @@ const allMenuItems: MenuItemDef[] = [
export default function AppLayout() {
const [collapsed, setCollapsed] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout, hasPermission, isAuthenticated } = useAuth();
const { token: themeToken } = theme.useToken();
const { isDark, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const [counts, setCounts] = useState<StatsCounts | null>(null);
// Im Drawer (mobil) ist die Navigation nie eingeklappt
const effectiveCollapsed = isMobile ? false : collapsed;
useEffect(() => {
if (!isAuthenticated) return;
@@ -80,7 +87,7 @@ export default function AppLayout() {
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}>
<span>{item.label}</span>
{item.countKey && counts && counts[item.countKey] > 0 && !collapsed && (
{item.countKey && counts && counts[item.countKey] > 0 && !effectiveCollapsed && (
<Badge
count={counts[item.countKey]}
overflowCount={99}
@@ -107,9 +114,194 @@ export default function AppLayout() {
const logoColor = isDark ? '#fff' : '#1a1a2e';
const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a';
const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea';
// Sidebar-Inhalt (Logo, Menü, Bottom-Sektion) — identisch für Sider (Desktop)
// und Drawer (mobil). `onNavigate` schließt auf Mobil den Drawer.
const renderSidebarContent = (isCollapsed: boolean, onNavigate?: () => void) => (
<>
{/* Logo / Collapse-Toggle */}
<button
onClick={() => (onNavigate ? onNavigate() : setCollapsed(!collapsed))}
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
userSelect: 'none',
border: 'none',
borderBottom: `1px solid ${dividerColor}`,
background: 'transparent',
width: '100%',
padding: 0,
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Text strong style={{ color: logoColor, fontSize: isCollapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
{isCollapsed ? 'PM' : 'Paperless'}
</Text>
</button>
{/* Navigation Menu */}
<Menu
theme={isDark ? 'dark' : 'light'}
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => {
const item = allMenuItems.find((i) => i.key === key);
if (item?.externalUrl) {
window.open(item.externalUrl, '_blank', 'noopener,noreferrer');
} else {
navigate(key);
}
onNavigate?.();
}}
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
/>
{/* Bottom Section: User + Theme Toggle */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderTop: `1px solid ${dividerColor}`,
padding: isCollapsed ? '12px 0' : '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
transition: 'padding 0.2s',
}}
>
{/* Theme Toggle */}
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
<button
onClick={toggleTheme}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: isCollapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
color: subtleColor,
justifyContent: isCollapsed ? 'center' : 'flex-start',
border: 'none',
background: 'transparent',
width: '100%',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
{!isCollapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
</button>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{
items: [
{
key: 'user-settings',
icon: <SettingOutlined />,
label: 'Benutzereinstellungen',
onClick: () => {
navigate('/user-settings');
onNavigate?.();
},
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Abmelden',
onClick: () => logout(),
},
],
}}
placement="topRight"
trigger={['click']}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: isCollapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
justifyContent: isCollapsed ? 'center' : 'flex-start',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Avatar size="small" icon={<UserOutlined />} />
{!isCollapsed && (
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
{user?.profile?.name || 'Benutzer'}
</Text>
)}
</div>
</Dropdown>
</div>
</>
);
return (
<Layout style={{ minHeight: '100vh' }}>
{isMobile ? (
<>
{/* Mobile Top-Bar mit Hamburger */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 48,
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 8px',
background: themeToken.colorBgContainer,
borderBottom: `1px solid ${dividerColor}`,
}}
>
<Button
type="text"
icon={<MenuOutlined />}
aria-label="Menü öffnen"
onClick={() => setDrawerOpen(true)}
/>
<Text strong style={{ color: logoColor, fontSize: 18 }}>Paperless</Text>
</div>
<Drawer
placement="left"
width={260}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
closable={false}
styles={{
body: {
padding: 0,
position: 'relative',
background: isDark ? '#001529' : '#f0f2f7',
},
}}
>
{renderSidebarContent(false, () => setDrawerOpen(false))}
</Drawer>
</>
) : (
<Sider
width={240}
trigger={null}
@@ -128,137 +320,25 @@ export default function AppLayout() {
...siderStyle,
}}
>
{/* Logo / Collapse-Toggle */}
<button
onClick={() => setCollapsed(!collapsed)}
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
userSelect: 'none',
border: 'none',
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
background: 'transparent',
width: '100%',
padding: 0,
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Text strong style={{ color: logoColor, fontSize: collapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
{collapsed ? 'PM' : 'Paperless'}
</Text>
</button>
{/* Navigation Menu */}
<Menu
theme={isDark ? 'dark' : 'light'}
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => {
const item = allMenuItems.find((i) => i.key === key);
if (item?.externalUrl) {
window.open(item.externalUrl, '_blank', 'noopener,noreferrer');
} else {
navigate(key);
}
}}
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
/>
{/* Bottom Section: User + Theme Toggle */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
padding: collapsed ? '12px 0' : '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
transition: 'padding 0.2s',
}}
>
{/* Theme Toggle */}
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
<button
onClick={toggleTheme}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
color: subtleColor,
justifyContent: collapsed ? 'center' : 'flex-start',
border: 'none',
background: 'transparent',
width: '100%',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
{!collapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
</button>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{
items: [
{
key: 'user-settings',
icon: <SettingOutlined />,
label: 'Benutzereinstellungen',
onClick: () => navigate('/user-settings'),
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Abmelden',
onClick: () => logout(),
},
],
}}
placement="topRight"
trigger={['click']}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
justifyContent: collapsed ? 'center' : 'flex-start',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Avatar size="small" icon={<UserOutlined />} />
{!collapsed && (
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
{user?.profile?.name || 'Benutzer'}
</Text>
)}
</div>
</Dropdown>
</div>
{renderSidebarContent(collapsed)}
</Sider>
)}
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8 }}>
<Layout
style={{
marginLeft: isMobile ? 0 : collapsed ? 80 : 240,
marginTop: isMobile ? 48 : 0,
transition: 'margin-left 0.2s',
}}
>
<Content
style={{
margin: isMobile ? 8 : 24,
padding: isMobile ? 12 : 24,
background: themeToken.colorBgContainer,
borderRadius: 8,
}}
>
<Outlet />
</Content>
</Layout>
+65 -14
View File
@@ -7,11 +7,14 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe';
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Title } = Typography;
const { Title, Text } = Typography;
const FREIGABE_FIELD_ID = 15;
export default function FreigabePage() {
const isMobile = useIsMobile();
const [data, setData] = useState<FreigabeDocument[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
@@ -119,7 +122,7 @@ export default function FreigabePage() {
},
{
title: 'Erstellt',
dataIndex: 'created_date',
dataIndex: 'created',
key: 'created',
width: 110,
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
@@ -153,10 +156,37 @@ export default function FreigabePage() {
},
];
const paginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t: number) => `${t} Belege`,
};
return (
<>
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title>
{isMobile ? (
<Select
style={{ width: '100%', marginBottom: 16 }}
value={nurNichtFreigegeben}
onChange={(v) => {
setPage(1);
setNurNichtFreigegeben(v);
}}
options={[
{ value: true, label: 'Nicht freigegeben' },
{ value: false, label: 'Alle' },
]}
/>
) : (
<Space style={{ marginBottom: 16 }}>
<Radio.Group
value={nurNichtFreigegeben}
@@ -171,26 +201,47 @@ export default function FreigabePage() {
<Radio.Button value={false}>Alle</Radio.Button>
</Radio.Group>
</Space>
)}
{isMobile ? (
<MobileCardList<FreigabeDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={paginationConfig}
emptyText="Keine Belege vorhanden"
renderCard={(doc) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Text strong>{doc.title || '—'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Dokumenttyp: {getDocTypeName(doc.document_type)}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Absender: {getCorrespondentName(doc.correspondent)}</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'}
</Text>
{getFreigabeValue(doc)}
</div>
<Button
icon={<CheckCircleOutlined />}
type="primary"
block
onClick={() => openModal(doc)}
>
Freigabe setzen
</Button>
</div>
)}
/>
) : (
<Table<FreigabeDocument>
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t) => `${t} Belege`,
}}
pagination={paginationConfig}
/>
)}
<Modal
title="Freigabe setzen"
@@ -25,6 +25,7 @@ import {
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
import { paperlessApi } from '../api/paperless';
import { userSettingsApi, type SenderOption } from '../api/userSettings';
import { useIsMobile } from '../hooks/useIsMobile';
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3;
@@ -113,6 +114,7 @@ function CompareModal({
onCreateNewVersion,
onSkip,
}: CompareModalProps) {
const compareIsMobile = useIsMobile();
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
const [inboxUrl, setInboxUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -165,8 +167,8 @@ function CompareModal({
</Button>,
]}
>
<div style={{ display: 'flex', gap: 12, height: '75vh' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: compareIsMobile ? 'column' : 'row', gap: 12, height: '75vh' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Original (Paperless)
</Typography.Text>
@@ -180,7 +182,7 @@ function CompareModal({
)}
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
<Typography.Text strong style={{ marginBottom: 4 }}>
Aktueller Abschnitt (Inbox)
</Typography.Text>
@@ -858,6 +860,7 @@ function SendEmailDialog({ open, fileId, fileName, documents, thumbUrls, onClose
export default function InboxDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [file, setFile] = useState<InboxFile | null>(null);
const [loading, setLoading] = useState(true);
const [thumbUrls, setThumbUrls] = useState<Map<number, string>>(new Map());
@@ -1211,13 +1214,13 @@ export default function InboxDetailPage() {
})();
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12, gap: 12 }}>
<Space>
<div style={{ display: 'flex', flexDirection: 'column', height: isMobile ? 'calc(100dvh - 180px)' : 'calc(100vh - 120px)' }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', rowGap: 8, marginBottom: 12, gap: 12 }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
Zurück
</Button>
<Title level={3} style={{ margin: 0 }}>
<Title level={isMobile ? 5 : 3} style={{ margin: 0, maxWidth: isMobile ? 180 : undefined }} ellipsis={{ tooltip: file.name }}>
{file.name}
</Title>
<SourceTag source={file.source} />
@@ -1399,12 +1402,13 @@ export default function InboxDetailPage() {
>
<div
style={{
width: 140,
width: isMobile ? 84 : 140,
overflowY: 'auto',
padding: 6,
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
flexShrink: 0,
}}
>
{sidebarPages.map((n, idx) => {
@@ -1428,7 +1432,7 @@ export default function InboxDetailPage() {
<div
style={{
width: '100%',
height: 170,
height: isMobile ? 100 : 170,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -1439,7 +1443,7 @@ export default function InboxDetailPage() {
<img
src={url}
alt={`Seite ${docPage}`}
style={thumbImageStyle(rotationFor(n), 130)}
style={thumbImageStyle(rotationFor(n), isMobile ? 72 : 130)}
/>
) : (
<Spin size="small" />
+105 -64
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,68 +276,11 @@ export default function InboxPage() {
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
);
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']) =>
src === 'user' ? (
<Tag icon={<UserOutlined />} color="purple">
Persönlich
</Tag>
) : (
<Tag icon={<FolderOpenOutlined />} color="blue">
Gemeinsam
</Tag>
),
},
{
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) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
// 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"
@@ -360,8 +318,61 @@ export default function InboxPage() {
</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 (
@@ -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,6 +491,33 @@ export default function InboxPage() {
</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"
@@ -492,6 +532,7 @@ export default function InboxPage() {
locale={{ emptyText: 'Keine Dateien vorhanden' }}
/>
</Card>
)}
</div>
);
}
@@ -10,12 +10,14 @@ import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails';
import { emailImportApi } from '../api/email-import';
import { getEnv } from '../utils/env';
import MailImportWizard from '../components/MailImportWizard';
import { useIsMobile } from '../hooks/useIsMobile';
const { Title, Text } = Typography;
export default function MailDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [email, setEmail] = useState<EmailItem | null>(null);
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
const [selected, setSelected] = useState<EmailAttachment | null>(null);
@@ -136,16 +138,16 @@ export default function MailDetailPage() {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
Zurück
</Button>
<Title level={3} style={{ margin: 0 }}>{email.Subject}</Title>
<Title level={isMobile ? 5 : 3} style={{ margin: 0 }}>{email.Subject}</Title>
{hasErechnung && <Tag color="green">eRechnung</Tag>}
</Space>
<Space>
<Space wrap>
<Popconfirm
title="E-Mail ignorieren"
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
@@ -183,12 +185,26 @@ export default function MailDetailPage() {
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 3fr', gap: 16, height: 'calc(100vh - 140px)' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '2fr 3fr',
gap: 16,
height: isMobile ? 'auto' : 'calc(100vh - 140px)',
}}
>
{/* Linke Seite: E-Mail-Inhalt */}
<Card
title="E-Mail"
size="small"
styles={{ body: { overflow: 'auto', height: 'calc(100vh - 200px)', display: 'flex', flexDirection: 'column' } }}
styles={{
body: {
overflow: 'auto',
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
display: 'flex',
flexDirection: 'column',
},
}}
>
<div style={{ marginBottom: 12 }}>
<div><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div>
@@ -208,7 +224,17 @@ export default function MailDetailPage() {
</Card>
{/* Rechte Seite: Anhänge + Vorschau */}
<Card size="small" styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 200px)' } }}>
<Card
size="small"
styles={{
body: {
padding: 0,
display: 'flex',
flexDirection: 'column',
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
},
}}
>
<div style={{ flex: '0 0 auto', borderBottom: `1px solid ${token.colorBorder}`, maxHeight: 240, overflow: 'auto' }}>
<Table<EmailAttachment>
columns={columns}
@@ -224,7 +250,7 @@ export default function MailDetailPage() {
locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/>
</div>
<div style={{ flex: 1, minHeight: 0, background: token.colorBgLayout }}>
<div style={isMobile ? { height: '60vh', background: token.colorBgLayout } : { flex: 1, minHeight: 0, background: token.colorBgLayout }}>
{previewLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin />
@@ -7,11 +7,22 @@ import dayjs from 'dayjs';
import { emailsApi, type EmailItem } from '../api/emails';
import { useAuth } from '../auth/AuthContext';
import { Permission } from '../auth/permissions';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Title } = Typography;
const { Title, Text } = Typography;
function renderStatusTag(s: number) {
if (s === 0) return <Tag color="blue">Neu</Tag>;
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
if (s === 2) return <Tag color="red">Fehler</Tag>;
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
return <Tag>{s}</Tag>;
}
export default function MailpostfachPage() {
const navigate = useNavigate();
const isMobile = useIsMobile();
const [emails, setEmails] = useState<EmailItem[]>([]);
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
@@ -84,13 +95,7 @@ export default function MailpostfachPage() {
dataIndex: 'Status',
key: 'Status',
width: 110,
render: (s: number) => {
if (s === 0) return <Tag color="blue">Neu</Tag>;
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
if (s === 2) return <Tag color="red">Fehler</Tag>;
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
return <Tag>{s}</Tag>;
},
render: renderStatusTag,
filters: [
{ text: 'Neu', value: 0 },
{ text: 'Verarbeitet', value: 1 },
@@ -116,9 +121,9 @@ export default function MailpostfachPage() {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>Mailpostfach</Title>
<Space>
<Space wrap>
<Button
icon={<DownloadOutlined />}
onClick={async () => {
@@ -198,13 +203,13 @@ export default function MailpostfachPage() {
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
style={{ width: isMobile ? '100%' : 300 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 200 }}
style={{ width: isMobile ? '100%' : 200 }}
options={[
{ value: 'all', label: 'Alle Status' },
{ value: 0, label: 'Neu' },
@@ -214,6 +219,35 @@ export default function MailpostfachPage() {
]}
/>
</div>
{isMobile ? (
<MobileCardList<EmailItem>
dataSource={[...filteredEmails].sort(
(a, b) => new Date(b.Date).getTime() - new Date(a.Date).getTime(),
)}
rowKey="Id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} E-Mails` }}
onCardClick={(record) => navigate(`/mailpostfach/${record.Id}`)}
renderCard={(record) => {
const hasErechnung = record.Attachments?.some((a) => a.Erechnung);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Text strong>{record.Subject || '—'}</Text>
{hasErechnung && <Tag color="green">eRechnung</Tag>}
</div>
<Text type="secondary" style={{ fontSize: 13 }}>{record.SenderAddress}</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{record.Date ? dayjs(record.Date).format('DD.MM.YYYY HH:mm') : '-'}
</Text>
{renderStatusTag(record.Status)}
</div>
</div>
);
}}
/>
) : (
<Table<EmailItem>
columns={columns}
dataSource={filteredEmails}
@@ -226,6 +260,7 @@ export default function MailpostfachPage() {
style: { cursor: 'pointer' },
})}
/>
)}
</Card>
</div>
);
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Table, Popover, Button, Space, message, Tooltip, Typography, Tag } from 'antd';
const { Title } = Typography;
const { Title, Text } = Typography;
import { ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
@@ -11,8 +11,11 @@ import type { PaperlessTag } from '../api/paperless';
import DocumentEditModal from '../components/DocumentEditModal';
import { getEnv } from '../utils/env';
import { AuthImage } from '../utils/auth-resource';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
export default function ManuellBearbeitenPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -77,6 +80,26 @@ export default function ManuellBearbeitenPage() {
/>
);
const getContentTags = (record: PosteingangDocument) =>
(record.tags || [])
.filter(id => !steuertagIds.includes(id))
.map(id => allTags.find(t => t.id === id))
.filter((t): t is PaperlessTag => !!t);
const renderContentTags = (record: PosteingangDocument) => {
const contentTags = getContentTags(record);
if (contentTags.length === 0) return null;
return (
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
{contentTags.map(t => (
<Tag key={t.id} color={t.color} style={{ color: t.text_color, margin: 0 }}>
{t.name}
</Tag>
))}
</Space>
);
};
const columns = [
{
title: 'Vorschau',
@@ -97,26 +120,12 @@ export default function ManuellBearbeitenPage() {
dataIndex: 'title',
key: 'title',
width: '35%',
render: (_: any, record: PosteingangDocument) => {
const contentTags = (record.tags || [])
.filter(id => !steuertagIds.includes(id))
.map(id => allTags.find(t => t.id === id))
.filter((t): t is PaperlessTag => !!t);
return (
render: (_: any, record: PosteingangDocument) => (
<div>
<div>{record.title}</div>
{contentTags.length > 0 && (
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
{contentTags.map(t => (
<Tag key={t.id} color={t.color} style={{ color: t.text_color, margin: 0 }}>
{t.name}
</Tag>
))}
</Space>
)}
{renderContentTags(record)}
</div>
);
},
),
},
{
title: 'Eingangsdatum',
@@ -153,6 +162,38 @@ export default function ManuellBearbeitenPage() {
</Tooltip>
</Space>
</div>
{isMobile ? (
<MobileCardList<PosteingangDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }}
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<Text strong>{record.title || '—'}</Text>
{renderContentTags(record)}
<Text type="secondary" style={{ fontSize: 13 }}>
Eingangsdatum: {record.created ? dayjs(record.created).format('DD.MM.YYYY') : '-'}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')}
</Text>
</div>
</div>
<Button type="primary" block onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
</div>
)}
/>
) : (
<Table
columns={columns}
dataSource={data}
@@ -161,6 +202,7 @@ export default function ManuellBearbeitenPage() {
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
locale={{ emptyText: 'Keine Einträge vorhanden' }}
/>
)}
<DocumentEditModal
documentId={selectedDoc?.id || null}
@@ -3,14 +3,17 @@ import { Table, Popover, Button, Space, message, Tooltip, Typography } from 'ant
import { AuthImage } from '../utils/auth-resource';
import { ReloadOutlined } from '@ant-design/icons';
const { Title } = Typography;
const { Title, Text } = Typography;
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
import type { PosteingangDocument } from '../api/posteingang';
import DocumentEditModal from '../components/DocumentEditModal';
import { getEnv } from '../utils/env';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
export default function PosteingangPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -64,6 +67,11 @@ export default function PosteingangPage() {
/>
);
const getEingangsdatum = (record: PosteingangDocument) => {
const cf = record.customFields?.find((f) => f.field === 9);
return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-';
};
const columns = [
{
title: 'Vorschau',
@@ -88,10 +96,7 @@ export default function PosteingangPage() {
{
title: 'Eingangsdatum',
key: 'eingangsdatum',
render: (_: any, record: PosteingangDocument) => {
const cf = record.customFields?.find((f) => f.field === 9);
return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-';
}
render: (_: any, record: PosteingangDocument) => getEingangsdatum(record),
},
{
title: 'Importiert am',
@@ -121,6 +126,37 @@ export default function PosteingangPage() {
</Tooltip>
</Space>
</div>
{isMobile ? (
<MobileCardList<PosteingangDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }}
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 12 }}>
<AuthImage
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={72}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<Text strong>{record.title || '—'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Eingangsdatum: {getEingangsdatum(record)}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')}
</Text>
</div>
</div>
<Button type="primary" block onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
</div>
)}
/>
) : (
<Table
columns={columns}
dataSource={data}
@@ -129,6 +165,7 @@ export default function PosteingangPage() {
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
locale={{ emptyText: 'Keine Einträge vorhanden' }}
/>
)}
<DocumentEditModal
documentId={selectedDoc?.id || null}
+10 -5
View File
@@ -321,7 +321,7 @@ function UserClientsTab() {
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}>
Zuordnung hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Divider />
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe Agrarmonitor-Zuordnung</Typography.Title>
@@ -335,6 +335,7 @@ function UserClientsTab() {
rowKey="Id"
size="small"
pagination={false}
scroll={{ x: 'max-content' }}
/>
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
@@ -606,6 +607,7 @@ function DocTypesTab() {
rowKey="Id"
size="small"
pagination={false}
scroll={{ x: 'max-content' }}
/>
<Modal
title="Dokumenttyp bearbeiten"
@@ -1039,7 +1041,7 @@ function PostprocessingTab() {
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
Regel hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Modal
title={isNew ? 'Neue Postprocessing-Regel' : 'Regel bearbeiten'}
@@ -1181,7 +1183,7 @@ function ExportTargetsTab() {
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
Export-Ziel hinzufügen
</Button>
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Modal title={isNew ? 'Neues Export-Ziel' : 'Export-Ziel bearbeiten'} open={!!editing} onOk={handleSave} onCancel={() => setEditing(null)}>
<Form form={form} layout="vertical">
@@ -1247,6 +1249,7 @@ function PostprocessingLogsTab() {
loading={loading}
rowKey="Id"
size="small"
scroll={{ x: 'max-content' }}
pagination={{
current: page,
total,
@@ -1352,7 +1355,7 @@ function ApiKeysTab() {
</Button>
</div>
<Table dataSource={data} columns={columns} loading={loading} rowKey="id" size="small" pagination={false} />
<Table dataSource={data} columns={columns} loading={loading} rowKey="id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
<Modal
title="Neuen API-Key erstellen"
@@ -1578,6 +1581,7 @@ function CorrespondentsTab() {
loading={loading}
rowKey="id"
size="small"
scroll={{ x: 'max-content' }}
pagination={{
current: currentPage,
pageSize: pageSize,
@@ -1911,7 +1915,7 @@ function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) {
<h4 style={{ margin: 0 }}>Weiterverarbeitungs-Aktionen</h4>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button>
</Space>
<Table<InboxAction> rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" />
<Table<InboxAction> rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" scroll={{ x: 'max-content' }} />
<Modal
title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'}
@@ -2168,6 +2172,7 @@ function BarcodeTemplatesTab() {
dataSource={data}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
/>
<Modal
+47 -1
View File
@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react';
import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider } from 'antd';
import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider, Typography } from 'antd';
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { tasksApi } from '../api/tasks';
import type { Task } from '../api/tasks';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Text } = Typography;
function statusTag(fertig: number | null) {
if (fertig === 1) return <Tag color="success">Fertig</Tag>;
@@ -12,6 +16,7 @@ function statusTag(fertig: number | null) {
}
export default function TaskLogPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -155,6 +160,46 @@ export default function TaskLogPage() {
</Popconfirm>
</Space>
</div>
{isMobile ? (
<MobileCardList<Task>
dataSource={data}
rowKey="TaskId"
loading={loading}
pagination={{ pageSize: 20 }}
renderCard={(record) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<Text strong>{record.InterneBelegnummer || '—'}</Text>
{statusTag(record.Fertig)}
</div>
<Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 12 }}>
{record.TaskId.slice(0, 8)}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Lieferant: {record.Lieferant || '-'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Belegdatum: {record.Belegdatum ? dayjs(record.Belegdatum).format('DD.MM.YYYY') : '-'}
{' · '}
Eingang: {record.Eingangsdatum ? dayjs(record.Eingangsdatum).format('DD.MM.YYYY') : '-'}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Paperless-Dok.-ID: {record.PaperlessDocumentID ?? '-'}
</Text>
<Popconfirm
title="Task löschen"
description={`Task ${record.TaskId.slice(0, 8)}… dauerhaft entfernen?`}
onConfirm={() => handleDeleteOne(record.TaskId)}
okText="Löschen"
cancelText="Abbrechen"
okButtonProps={{ danger: true }}
>
<Button danger size="small" icon={<DeleteOutlined />} block>
Löschen
</Button>
</Popconfirm>
</div>
)}
/>
) : (
<Table
columns={columns}
dataSource={data}
@@ -163,6 +208,7 @@ export default function TaskLogPage() {
pagination={{ pageSize: 20 }}
locale={{ emptyText: 'Keine Einträge vorhanden' }}
/>
)}
</div>
</ConfigProvider>
);
+81 -17
View File
@@ -5,13 +5,22 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung';
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
import { useIsMobile } from '../hooks/useIsMobile';
import MobileCardList from '../components/MobileCardList';
const { Title } = Typography;
const { Title, Text } = Typography;
const FREIGABE_FIELD_ID = 15;
const ZAHLUNG_FIELD_ID = 16;
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
const FILTER_OPTIONS: { value: ZahlungFilter; label: string }[] = [
{ value: 'ausstehend', label: 'Freigegeben, noch nicht bezahlt' },
{ value: 'freigegeben', label: 'Alle freigegebenen' },
{ value: 'alle', label: 'Alle' },
];
export default function ZahlungPage() {
const isMobile = useIsMobile();
const [data, setData] = useState<ZahlungDocument[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
@@ -132,7 +141,7 @@ export default function ZahlungPage() {
},
{
title: 'Erstellt',
dataIndex: 'created_date',
dataIndex: 'created',
key: 'created',
width: 110,
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
@@ -178,10 +187,34 @@ export default function ZahlungPage() {
},
];
const paginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t: number) => `${t} Belege`,
};
return (
<>
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Zahlung</Title>
{isMobile ? (
<Select
style={{ width: '100%', marginBottom: 16 }}
value={filter}
onChange={(v) => {
setPage(1);
setFilter(v);
}}
options={FILTER_OPTIONS}
/>
) : (
<Space style={{ marginBottom: 16 }}>
<Radio.Group
value={filter}
@@ -192,31 +225,62 @@ export default function ZahlungPage() {
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="ausstehend">Freigegeben, noch nicht bezahlt</Radio.Button>
<Radio.Button value="freigegeben">Alle freigegebenen</Radio.Button>
<Radio.Button value="alle">Alle</Radio.Button>
{FILTER_OPTIONS.map((o) => (
<Radio.Button key={o.value} value={o.value}>{o.label}</Radio.Button>
))}
</Radio.Group>
</Space>
)}
{isMobile ? (
<MobileCardList<ZahlungDocument>
dataSource={data}
rowKey="id"
loading={loading}
pagination={paginationConfig}
emptyText="Keine Belege vorhanden"
renderCard={(doc) => {
const freigegeben = istFreigegeben(doc);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Text strong>{doc.title || '—'}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Dokumenttyp: {getDocTypeName(doc.document_type)}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>Absender: {getCorrespondentName(doc.correspondent)}</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'}
</Text>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{renderFreigabeTag(doc)}
{renderZahlungTag(doc)}
</div>
<Button
icon={<EuroOutlined />}
type="primary"
block
disabled={!freigegeben}
onClick={() => openModal(doc)}
>
Zahlung verbuchen
</Button>
{!freigegeben && (
<Text type="secondary" style={{ fontSize: 12 }}>
Beleg muss zuerst freigegeben werden
</Text>
)}
</div>
);
}}
/>
) : (
<Table<ZahlungDocument>
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['25', '50', '100'],
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
showTotal: (t) => `${t} Belege`,
}}
pagination={paginationConfig}
/>
)}
<Modal
title="Zahlung verbuchen"