From ed574773245b9a17738fcbb39593c4f7125b71f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 16 Jun 2026 21:58:41 +0200 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20importierte=20E-Mails=20automatis?= =?UTF-8?q?ch=20in=20IMAP-Ordner=20verschieben=20und=20nach=2090=20Tagen?= =?UTF-8?q?=20l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neuer ImapFolderService verschiebt E-Mails nach erfolgreichem Import in den konfigurierbaren Ordner "importiert" (wird bei Bedarf automatisch erstellt) - Täglicher Cron um 03:00 Uhr verschiebt E-Mails älter als 90 Tage in den Papierkorb und leert ihn anschließend - createImapClient()-Hilfsmethode im EmailDownloadService ausgelagert - IMAP_IMPORTED_FOLDER und IMAP_TRASH_FOLDER in docker-compose ergänzt Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 2 + .../email-download/email-download.service.ts | 67 ++++++++++++++++--- .../src/email/email-import.service.ts | 8 +++ paperless-backend/src/email/email.module.ts | 3 +- .../src/email/imap-folder.service.ts | 52 ++++++++++++++ 5 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 paperless-backend/src/email/imap-folder.service.ts diff --git a/docker-compose.yml b/docker-compose.yml index 12a8d0b..11c008d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: - IMAP_USE_SSL=${IMAP_USE_SSL:-true} - IMAP_USERNAME=${IMAP_USERNAME:-} - IMAP_PASSWORD=${IMAP_PASSWORD:-} + - IMAP_IMPORTED_FOLDER=${IMAP_IMPORTED_FOLDER:-importiert} + - IMAP_TRASH_FOLDER=${IMAP_TRASH_FOLDER:-Trash} - BELEGNUMMER_GET_URL=${BELEGNUMMER_GET_URL:-} - BELEGNUMMER_SET_URL=${BELEGNUMMER_SET_URL:-} - AGRARMONITOR_BASE_URL=${AGRARMONITOR_BASE_URL:-https://admin7.agrarmonitor.de} diff --git a/paperless-backend/src/email-download/email-download.service.ts b/paperless-backend/src/email-download/email-download.service.ts index a21283d..83b40b7 100644 --- a/paperless-backend/src/email-download/email-download.service.ts +++ b/paperless-backend/src/email-download/email-download.service.ts @@ -55,11 +55,62 @@ export class EmailDownloadService { } } + private createImapClient(): ImapFlow { + return new ImapFlow({ + host: this.configService.get('IMAP_HOST', ''), + port: this.configService.get('IMAP_PORT', 993), + secure: this.configService.get('IMAP_USE_SSL', 'true') === 'true', + auth: { + user: this.configService.get('IMAP_USERNAME', ''), + pass: this.configService.get('IMAP_PASSWORD', ''), + }, + logger: false, + }); + } + + @Cron('0 3 * * *', { timeZone: 'Europe/Berlin' }) + async cleanupImportedEmails(): Promise { + if (!this.configService.get('IMAP_HOST')) return; + const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); + const trashFolder = this.configService.get('IMAP_TRASH_FOLDER', 'Trash'); + const client = this.createImapClient(); + try { + await client.connect(); + + // E-Mails älter als 90 Tage in Papierkorb verschieben + try { + await client.mailboxOpen(importedFolder); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 90); + 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.`); + } + } catch (err: any) { + this.logger.warn(`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`); + } + + // Papierkorb leeren + try { + await client.mailboxOpen(trashFolder); + 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.`); + } + } catch (err: any) { + this.logger.warn(`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`); + } + } catch (err: any) { + this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`); + } finally { + await client.logout().catch(() => {}); + } + } + private async fetchAndStore(): Promise { const host = this.configService.get('IMAP_HOST'); - const port = this.configService.get('IMAP_PORT', 993); - const secure = - this.configService.get('IMAP_USE_SSL', 'true') === 'true'; const user = this.configService.get('IMAP_USERNAME'); const pass = this.configService.get('IMAP_PASSWORD'); @@ -72,16 +123,10 @@ export class EmailDownloadService { this.logger.log('E-Mail Fetch Job gestartet.'); - const client = new ImapFlow({ - host, - port, - secure, - auth: { user, pass }, - logger: false, - }); + const client = this.createImapClient(); await client.connect(); - this.logger.log(`Verbunden mit IMAP-Server ${host}:${port}`); + this.logger.log(`Verbunden mit IMAP-Server ${host}.`); const lock = await client.getMailboxLock('INBOX'); try { diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index 022b167..cff63e0 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -12,6 +12,7 @@ import { Task } from '../database/entities/task.entity'; import { PaperlessService } from '../paperless/paperless.service'; import * as QRCode from 'qrcode'; import { EmailPageCacheService } from './email-page-cache.service'; +import { ImapFolderService } from './imap-folder.service'; import { PdfService } from '../preprocessing/pdf.service'; import * as path from 'path'; import * as os from 'os'; @@ -52,6 +53,7 @@ export class EmailImportService { private readonly paperlessService: PaperlessService, private readonly pdfService: PdfService, private readonly pageCache: EmailPageCacheService, + private readonly imapFolderService: ImapFolderService, ) {} async ensurePreviews(emailId: number): Promise { @@ -646,6 +648,12 @@ export class EmailImportService { this.logger.log( `Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`, ); + 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), + ); + } } } diff --git a/paperless-backend/src/email/email.module.ts b/paperless-backend/src/email/email.module.ts index 6b52873..bd202dc 100644 --- a/paperless-backend/src/email/email.module.ts +++ b/paperless-backend/src/email/email.module.ts @@ -9,6 +9,7 @@ import { EmailPageCacheService } from './email-page-cache.service'; import { EmailImportController } from './email-import.controller'; import { EmailImportService } from './email-import.service'; +import { ImapFolderService } from './imap-folder.service'; import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity'; import { Task } from '../database/entities/task.entity'; import { PreprocessingModule } from '../preprocessing/preprocessing.module'; @@ -26,7 +27,7 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module'; PreprocessingModule, ], controllers: [EmailController, EmailImportController], - providers: [EmailImportService, EmailPageCacheService], + providers: [EmailImportService, EmailPageCacheService, ImapFolderService], exports: [EmailPageCacheService], }) export class EmailModule {} diff --git a/paperless-backend/src/email/imap-folder.service.ts b/paperless-backend/src/email/imap-folder.service.ts new file mode 100644 index 0000000..c4ff0c5 --- /dev/null +++ b/paperless-backend/src/email/imap-folder.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ImapFlow } from 'imapflow'; + +@Injectable() +export class ImapFolderService { + private readonly logger = new Logger(ImapFolderService.name); + + constructor(private readonly configService: ConfigService) {} + + private createClient(): ImapFlow { + return new ImapFlow({ + host: this.configService.get('IMAP_HOST', ''), + port: this.configService.get('IMAP_PORT', 993), + secure: this.configService.get('IMAP_USE_SSL', 'true') === 'true', + auth: { + user: this.configService.get('IMAP_USERNAME', ''), + pass: this.configService.get('IMAP_PASSWORD', ''), + }, + logger: false, + }); + } + + async moveToImportiert(messageId: string): Promise { + if (!this.configService.get('IMAP_HOST')) return; + + const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); + const client = this.createClient(); + try { + await client.connect(); + + const mailboxes = await client.list(); + 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 }); + if (Array.isArray(uids) && uids.length > 0) { + await client.messageMove(uids, importedFolder, { uid: true }); + this.logger.log(`E-Mail ${messageId} → "${importedFolder}" verschoben.`); + } else { + this.logger.warn(`E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`); + } + } catch (err: any) { + this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`); + } finally { + await client.logout().catch(() => {}); + } + } +} -- 2.52.0 From 41eed1871ea5142cfddad33f2ebb34e2d0934401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 18 Jun 2026 09:27:04 +0200 Subject: [PATCH 02/16] fix: Produktions-Crash durch TypeORM-synchronize beheben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NODE_ENV=production deaktiviert synchronize (zerstörerischer ADD/DROP-COLUMN- Churn auf MariaDB, der die 8126-Byte-Zeilengröße sprengte) und aktiviert migrationsRun. Neue data-source.ts als einzige Konfigquelle (Laufzeit + CLI), Migrations-Workflow (generate/run/revert) inkl. dotenv ergänzt. Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 ++ CLAUDE.md | 28 ++++++- docker-compose.yml | 2 + paperless-backend/package-lock.json | 19 ++++- paperless-backend/package.json | 7 +- paperless-backend/src/database/data-source.ts | 81 +++++++++++++++++++ .../src/database/database.module.ts | 71 +--------------- .../src/database/migrations/.gitkeep | 0 8 files changed, 143 insertions(+), 72 deletions(-) create mode 100644 paperless-backend/src/database/data-source.ts create mode 100644 paperless-backend/src/database/migrations/.gitkeep diff --git a/.env.example b/.env.example index e62dd5b..3bdae21 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ # Produktion: VITE_API_URL leer lassen (nginx Reverse-Proxy leitet /api weiter) # ============================================================================= +# --- Umgebung --- +# Produktion: NODE_ENV=production -> KEIN TypeORM-synchronize (Schema via Migrationen), +# Migrationen werden beim Start automatisch ausgeführt (migrationsRun). +# Aktiviert zudem CORS-Schutz (siehe CORS_ORIGIN weiter unten). +# Entwicklung: NODE_ENV leer lassen -> synchronize ON (Schema folgt den Entities). +NODE_ENV=production + # --- Ports --- BACKEND_PORT=7601 FRONTEND_PORT=7600 diff --git a/CLAUDE.md b/CLAUDE.md index 2f95805..b3558cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,11 +78,37 @@ Use `@Public()` to bypass auth guards entirely. ### Database -- TypeORM with MySQL 8+, UTF8MB4, `synchronize: true` (schema auto-migrates) +- TypeORM with MySQL/MariaDB, UTF8MB4 +- Connection config lives in `src/database/data-source.ts` (single source of truth, + shared by the NestJS runtime and the TypeORM CLI). `database.module.ts` consumes it. +- **Schema strategy depends on `NODE_ENV`:** + - Dev (`NODE_ENV` unset): `synchronize: true` — schema auto-migrates from entities. + - Production (`NODE_ENV=production`): `synchronize: false` + `migrationsRun: true` — + pending migrations in `src/database/migrations/` are applied automatically on boot. + **Never run `synchronize` against the production DB** — on MariaDB it issues + destructive `ADD`/`DROP COLUMN` churn every boot (see caveat below). - 23 entities in `src/database/entities/` - JSON columns use transformers to normalize empty arrays/objects to `null` - Key entities: `InboxDocument`, `Task`, `Email`, `Attachment`, `Postprocessing`, `BarcodeTemplate`, `LabelPrintJob`, `ApiKey`, `Setting` +#### Migrations workflow + +```bash +npm run migration:generate -- src/database/migrations/ # diff entities → DB +npm run migration:run # apply pending +npm run migration:revert # roll back last +``` + +The existing production schema is the implicit baseline (no baseline migration); only +future changes get migration files. + +**Caveat — MariaDB reports `json` as `longtext`:** every `@Column({ type: 'json' })` +is stored as `longtext ... CHECK (json_valid(...))`, so `migration:generate` always +emits spurious no-op `CHANGE` statements (json↔longtext, nullable/default re-declares). +**Hand-trim generated migrations** down to the real change before committing. (This same +false diff is exactly why `synchronize` must stay off in production — left unchecked it +accumulates `ALGORITHM=INSTANT` drops until a table trips the 8126-byte row-size limit.) + ### Document Processing Pipeline **Preprocessing** (`preprocessing/document-pipeline.service.ts`): diff --git a/docker-compose.yml b/docker-compose.yml index 11c008d..1d37bea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: ports: - "${BACKEND_PORT:-7601}:3100" environment: + - NODE_ENV=production + - CORS_ORIGIN=${CORS_ORIGIN:-} - PORT=3100 - DB_HOST=${DB_HOST:-db} - DB_PORT=${DB_PORT:-3306} diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 21116f7..004178f 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -24,6 +24,7 @@ "axios": "^1.14.0", "basic-ftp": "^5.2.1", "chokidar": "^4.0.3", + "dotenv": "^17.4.2", "form-data": "^4.0.5", "imapflow": "^1.3.2", "jsqr": "^1.4.0", @@ -3023,6 +3024,18 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "11.1.17", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz", @@ -6291,9 +6304,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" diff --git a/paperless-backend/package.json b/paperless-backend/package.json index 5f4d168..9ec86d9 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -17,7 +17,11 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs -d ./src/database/data-source.ts", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -35,6 +39,7 @@ "axios": "^1.14.0", "basic-ftp": "^5.2.1", "chokidar": "^4.0.3", + "dotenv": "^17.4.2", "form-data": "^4.0.5", "imapflow": "^1.3.2", "jsqr": "^1.4.0", diff --git a/paperless-backend/src/database/data-source.ts b/paperless-backend/src/database/data-source.ts new file mode 100644 index 0000000..aed7b4d --- /dev/null +++ b/paperless-backend/src/database/data-source.ts @@ -0,0 +1,81 @@ +import 'reflect-metadata'; +import { join } from 'path'; +import { config as loadEnv } from 'dotenv'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import { + Client, + DocumentType, + DocumentField, + Task, + Postprocessing, + PostprocessingAction, + PostprocessingLog, + ExportTarget, + Setting, + Kontonummer, + Document, + UserClient, + Email, + Attachment, + Content, + ApiKey, + CorrespondentSetting, + BarcodeTemplate, + InboxDocument, + InboxPostprocessingAction, + CorrespondentEmailMapping, + UserSettings, + LabelPrintJob, +} from './entities'; + +// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker, +// dotenv ist dort ein No-Op). dotenv überschreibt bereits gesetzte Variablen nicht. +loadEnv(); +loadEnv({ path: join(process.cwd(), '..', '.env') }); + +export const entities = [ + Client, + DocumentType, + DocumentField, + Task, + Postprocessing, + PostprocessingAction, + PostprocessingLog, + ExportTarget, + Setting, + Kontonummer, + Document, + UserClient, + Email, + Attachment, + Content, + ApiKey, + CorrespondentSetting, + BarcodeTemplate, + InboxDocument, + InboxPostprocessingAction, + CorrespondentEmailMapping, + UserSettings, + LabelPrintJob, +]; + +const isProduction = process.env.NODE_ENV === 'production'; + +export const dataSourceOptions: DataSourceOptions = { + type: 'mysql', + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 3306), + username: process.env.DB_USERNAME ?? 'root', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_DATABASE ?? 'paperlessadd', + charset: 'utf8mb4', + entities, + migrations: [join(__dirname, 'migrations', '*.{ts,js}')], + // In Produktion: kein synchronize (zerstörerischer ALTER-Churn), Migrationen + // werden beim Start automatisch ausgeführt. In Dev: synchronize wie bisher. + synchronize: !isProduction, + migrationsRun: isProduction, +}; + +// Wird von der TypeORM-CLI verwendet (migration:generate / :run / :revert). +export default new DataSource(dataSourceOptions); diff --git a/paperless-backend/src/database/database.module.ts b/paperless-backend/src/database/database.module.ts index a3d730b..8ca67f7 100644 --- a/paperless-backend/src/database/database.module.ts +++ b/paperless-backend/src/database/database.module.ts @@ -1,75 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { - Client, - DocumentType, - DocumentField, - Task, - Postprocessing, - PostprocessingAction, - PostprocessingLog, - ExportTarget, - Setting, - Kontonummer, - Document, - UserClient, - Email, - Attachment, - Content, - ApiKey, - CorrespondentSetting, - BarcodeTemplate, - InboxDocument, - InboxPostprocessingAction, - CorrespondentEmailMapping, - UserSettings, - LabelPrintJob, -} from './entities'; - -const entities = [ - Client, - DocumentType, - DocumentField, - Task, - Postprocessing, - PostprocessingAction, - PostprocessingLog, - ExportTarget, - Setting, - Kontonummer, - Document, - UserClient, - Email, - Attachment, - Content, - ApiKey, - CorrespondentSetting, - BarcodeTemplate, - InboxDocument, - InboxPostprocessingAction, - CorrespondentEmailMapping, - UserSettings, - LabelPrintJob, -]; +import { dataSourceOptions, entities } from './data-source'; @Module({ imports: [ - TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - type: 'mysql' as const, - host: config.get('DB_HOST', 'localhost'), - port: config.get('DB_PORT', 3306), - username: config.get('DB_USERNAME', 'root'), - password: config.get('DB_PASSWORD', ''), - database: config.get('DB_DATABASE', 'paperlessadd'), - entities, - synchronize: config.get('NODE_ENV') !== 'production', - charset: 'utf8mb4', - }), - }), + // Eine einzige Konfigurationsquelle (data-source.ts), geteilt zwischen + // NestJS-Laufzeit und TypeORM-CLI (Migrationen). + TypeOrmModule.forRoot(dataSourceOptions), TypeOrmModule.forFeature(entities), ], exports: [TypeOrmModule], diff --git a/paperless-backend/src/database/migrations/.gitkeep b/paperless-backend/src/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 -- 2.52.0 From 969f0ae0b1c6f24c2ef1a761255b2380c9da6a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 18 Jun 2026 13:04:03 +0200 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20verarbeitete/ignorierte=20E-Mails?= =?UTF-8?q?=20beim=20Pr=C3=BCfen=20in=20IMAP-Ordner=20verschieben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cleanup-Cron von EmailDownloadService in ImapFolderService verschoben, damit er auch aus EmailController aufrufbar ist (zirkuläre Abhängigkeit vermieden) - Beim Klick auf „Anhänge prüfen" wird der IMAP-Cleanup fire-and-forget gestartet - Beim Klick auf „Bereits verarbeitete Anhänge prüfen" werden zusätzlich alle E-Mails im IMAP-Posteingang, die in der DB als verarbeitet (Status 1) oder ignoriert (Status 3) markiert sind, in den Ordner „importiert" verschoben - Erfolgsmeldung zeigt Anzahl verschobener E-Mails an Co-Authored-By: Claude Sonnet 4.6 --- .../email-download/email-download.service.ts | 41 --------- .../src/email/email.controller.ts | 18 +++- .../src/email/imap-folder.service.ts | 86 +++++++++++++++++++ paperless-frontend/src/api/emails.ts | 2 +- .../src/pages/MailpostfachPage.tsx | 1 + 5 files changed, 105 insertions(+), 43 deletions(-) diff --git a/paperless-backend/src/email-download/email-download.service.ts b/paperless-backend/src/email-download/email-download.service.ts index 83b40b7..5ff38cc 100644 --- a/paperless-backend/src/email-download/email-download.service.ts +++ b/paperless-backend/src/email-download/email-download.service.ts @@ -68,47 +68,6 @@ export class EmailDownloadService { }); } - @Cron('0 3 * * *', { timeZone: 'Europe/Berlin' }) - async cleanupImportedEmails(): Promise { - if (!this.configService.get('IMAP_HOST')) return; - const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); - const trashFolder = this.configService.get('IMAP_TRASH_FOLDER', 'Trash'); - const client = this.createImapClient(); - try { - await client.connect(); - - // E-Mails älter als 90 Tage in Papierkorb verschieben - try { - await client.mailboxOpen(importedFolder); - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - 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.`); - } - } catch (err: any) { - this.logger.warn(`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`); - } - - // Papierkorb leeren - try { - await client.mailboxOpen(trashFolder); - 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.`); - } - } catch (err: any) { - this.logger.warn(`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`); - } - } catch (err: any) { - this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`); - } finally { - await client.logout().catch(() => {}); - } - } - private async fetchAndStore(): Promise { const host = this.configService.get('IMAP_HOST'); const user = this.configService.get('IMAP_USERNAME'); diff --git a/paperless-backend/src/email/email.controller.ts b/paperless-backend/src/email/email.controller.ts index dc18b9b..2b0b0d2 100644 --- a/paperless-backend/src/email/email.controller.ts +++ b/paperless-backend/src/email/email.controller.ts @@ -17,6 +17,7 @@ import { Email } from '../database/entities/email.entity'; import { Attachment } from '../database/entities/attachment.entity'; import { Content } from '../database/entities/content.entity'; import { PaperlessService } from '../paperless/paperless.service'; +import { ImapFolderService } from './imap-folder.service'; import { RequirePermissions } from '../auth/permissions.decorator'; import { Permission } from '../auth/permissions.enum'; @@ -31,6 +32,7 @@ export class EmailController { @InjectRepository(Content) private readonly contentRepo: Repository, private readonly paperlessService: PaperlessService, + private readonly imapFolderService: ImapFolderService, ) {} @Get() @@ -202,7 +204,21 @@ export class EmailController { this.logger.log( `Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`, ); - return { updatedCount, idsUpdated }; + this.imapFolderService.cleanupImportedEmails().catch(err => + this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message), + ); + + let movedToImportiert = 0; + if (body.includeProcessed) { + const processedEmails = await this.emailRepo.find({ + where: [{ Status: 1 }, { Status: 3 }], + select: ['MessageId'], + }); + const messageIds = processedEmails.map((e) => e.MessageId).filter(Boolean); + movedToImportiert = await this.imapFolderService.moveProcessedInboxToImportiert(messageIds); + } + + return { updatedCount, idsUpdated, movedToImportiert }; } catch (error: any) { this.logger.error( `Kritischer Fehler bei checkAttachments: ${error.message}`, diff --git a/paperless-backend/src/email/imap-folder.service.ts b/paperless-backend/src/email/imap-folder.service.ts index c4ff0c5..4fd4733 100644 --- a/paperless-backend/src/email/imap-folder.service.ts +++ b/paperless-backend/src/email/imap-folder.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; import { ImapFlow } from 'imapflow'; @Injectable() @@ -21,6 +22,91 @@ export class ImapFolderService { }); } + @Cron('0 3 * * *', { timeZone: 'Europe/Berlin' }) + async cleanupImportedEmails(): Promise { + if (!this.configService.get('IMAP_HOST')) return; + const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); + const trashFolder = this.configService.get('IMAP_TRASH_FOLDER', 'Trash'); + const client = this.createClient(); + try { + await client.connect(); + + // E-Mails älter als 90 Tage in Papierkorb verschieben + try { + await client.mailboxOpen(importedFolder); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 90); + 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.`); + } + } catch (err: any) { + this.logger.warn(`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`); + } + + // Papierkorb leeren + try { + await client.mailboxOpen(trashFolder); + 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.`); + } + } catch (err: any) { + this.logger.warn(`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`); + } + } catch (err: any) { + this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`); + } finally { + await client.logout().catch(() => {}); + } + } + + async moveProcessedInboxToImportiert(messageIds: string[]): Promise { + if (!this.configService.get('IMAP_HOST') || messageIds.length === 0) return 0; + + const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); + const client = this.createClient(); + let movedCount = 0; + + try { + await client.connect(); + + const mailboxes = await client.list(); + if (!mailboxes.some(m => m.path === importedFolder)) { + await client.mailboxCreate(importedFolder); + this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`); + } + + const status = await client.mailboxOpen('INBOX'); + if (status.exists === 0) return 0; + + const normalize = (id: string) => id.replace(/^<|>$/g, '').toLowerCase(); + const idSet = new Set(messageIds.map(normalize)); + const uidsToMove: number[] = []; + + 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); + } + } + + 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.`); + } + } catch (err: any) { + this.logger.error(`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`); + } finally { + await client.logout().catch(() => {}); + } + + return movedCount; + } + async moveToImportiert(messageId: string): Promise { if (!this.configService.get('IMAP_HOST')) return; diff --git a/paperless-frontend/src/api/emails.ts b/paperless-frontend/src/api/emails.ts index b006378..f7c0915 100644 --- a/paperless-frontend/src/api/emails.ts +++ b/paperless-frontend/src/api/emails.ts @@ -44,7 +44,7 @@ export const emailsApi = { api.post<{ message: string }>('/api/emails/fetch').then((r) => r.data), checkAttachments: (includeProcessed = false) => - api.post<{ updatedCount: number; idsUpdated: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data), + api.post<{ updatedCount: number; idsUpdated: number; movedToImportiert: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data), updateStatus: (id: number, status: number) => api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data), diff --git a/paperless-frontend/src/pages/MailpostfachPage.tsx b/paperless-frontend/src/pages/MailpostfachPage.tsx index 5eec79f..a865c79 100644 --- a/paperless-frontend/src/pages/MailpostfachPage.tsx +++ b/paperless-frontend/src/pages/MailpostfachPage.tsx @@ -172,6 +172,7 @@ export default function MailpostfachPage() { const parts = []; if (result.updatedCount > 0) parts.push(`${result.updatedCount} E-Mail(s) aktualisiert`); if (result.idsUpdated > 0) parts.push(`${result.idsUpdated} Paperless-ID(s) ergänzt`); + if (result.movedToImportiert > 0) parts.push(`${result.movedToImportiert} E-Mail(s) in „importiert" verschoben`); message.success(parts.length > 0 ? parts.join(', ') + '.' : 'Keine Änderungen.'); if (result.updatedCount > 0 || result.idsUpdated > 0) await loadData(); } catch { -- 2.52.0 From 4d05a94681069d812492f1cc9affb56e3a2b27bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Thu, 18 Jun 2026 21:29:50 +0200 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20implement=20two-step=20Freigabe?= =?UTF-8?q?=E2=86=92Zahlung=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a payment step after document approval: PM_Freigabe approves (Field 15 = "freigegeben"), then PM_Zahlung can mark as paid (Field 16). - Backend: VIEW_ZAHLUNG permission mapped to PM_Zahlung OIDC group - Backend: ZahlungModule with endpoints to list documents by filter (ausstehend/freigegeben/alle), set Field 16, fetch options from Paperless - Backend: setZahlung() throws ForbiddenException if Field 15 ≠ "freigegeben" - Frontend: /zahlung route with 3-way filter, two status columns (Freigabe + Zahlung) - Frontend: "Zahlung verbuchen" button disabled with tooltip for non-approved docs - Frontend: Zahlung menu item with EuroOutlined icon Co-Authored-By: Claude Sonnet 4.6 --- paperless-backend/src/app.module.ts | 2 + .../src/auth/permissions.enum.ts | 3 + .../src/zahlung/zahlung.controller.ts | 36 +++ .../src/zahlung/zahlung.module.ts | 13 + .../src/zahlung/zahlung.service.ts | 124 +++++++++ paperless-frontend/src/App.tsx | 2 + paperless-frontend/src/api/zahlung.ts | 40 +++ paperless-frontend/src/auth/permissions.ts | 5 + paperless-frontend/src/layouts/AppLayout.tsx | 2 + paperless-frontend/src/pages/ZahlungPage.tsx | 244 ++++++++++++++++++ 10 files changed, 471 insertions(+) create mode 100644 paperless-backend/src/zahlung/zahlung.controller.ts create mode 100644 paperless-backend/src/zahlung/zahlung.module.ts create mode 100644 paperless-backend/src/zahlung/zahlung.service.ts create mode 100644 paperless-frontend/src/api/zahlung.ts create mode 100644 paperless-frontend/src/pages/ZahlungPage.tsx diff --git a/paperless-backend/src/app.module.ts b/paperless-backend/src/app.module.ts index f28e841..a70dfeb 100644 --- a/paperless-backend/src/app.module.ts +++ b/paperless-backend/src/app.module.ts @@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module'; import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module'; import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module'; import { FreigabeModule } from './freigabe/freigabe.module'; +import { ZahlungModule } from './zahlung/zahlung.module'; import { DailyDigestModule } from './daily-digest/daily-digest.module'; import * as path from 'path'; @@ -52,6 +53,7 @@ import * as path from 'path'; LabelPrintAgentModule, AgrarmonitorModule, FreigabeModule, + ZahlungModule, DailyDigestModule, ], }) diff --git a/paperless-backend/src/auth/permissions.enum.ts b/paperless-backend/src/auth/permissions.enum.ts index 5564e78..ac28159 100644 --- a/paperless-backend/src/auth/permissions.enum.ts +++ b/paperless-backend/src/auth/permissions.enum.ts @@ -6,6 +6,7 @@ export const Permission = { VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', VIEW_FREIGABE: 'VIEW_FREIGABE', + VIEW_ZAHLUNG: 'VIEW_ZAHLUNG', } as const; export type Permission = (typeof Permission)[keyof typeof Permission]; @@ -27,6 +28,7 @@ export function mapGroupsToPermissions( permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.VIEW_FREIGABE); + permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } @@ -36,6 +38,7 @@ export function mapGroupsToPermissions( if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX); if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER); if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE); + if (groups.includes('PM_Zahlung')) permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } diff --git a/paperless-backend/src/zahlung/zahlung.controller.ts b/paperless-backend/src/zahlung/zahlung.controller.ts new file mode 100644 index 0000000..b8dad7e --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Put, Param, Body, Query } from '@nestjs/common'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { Permission } from '../auth/permissions.enum'; +import { ZahlungService, type ZahlungFilter } from './zahlung.service'; + +@Controller('api/zahlung') +@RequirePermissions(Permission.VIEW_ZAHLUNG) +export class ZahlungController { + constructor(private readonly zahlungService: ZahlungService) {} + + @Get('documents') + getDocuments( + @Query('page') page = '1', + @Query('pageSize') pageSize = '25', + @Query('filter') filter: ZahlungFilter = 'ausstehend', + ) { + return this.zahlungService.getZahlungDocuments( + parseInt(page, 10), + Math.min(parseInt(pageSize, 10), 100), + filter, + ); + } + + @Put('documents/:id/zahlung') + setZahlung( + @Param('id') id: string, + @Body('value') value: string | null, + ) { + return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null); + } + + @Get('options') + getOptions() { + return this.zahlungService.getZahlungOptions(); + } +} diff --git a/paperless-backend/src/zahlung/zahlung.module.ts b/paperless-backend/src/zahlung/zahlung.module.ts new file mode 100644 index 0000000..4ad660d --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DocumentType } from '../database/entities/document-type.entity'; +import { PaperlessModule } from '../paperless/paperless.module'; +import { ZahlungController } from './zahlung.controller'; +import { ZahlungService } from './zahlung.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule], + controllers: [ZahlungController], + providers: [ZahlungService], +}) +export class ZahlungModule {} diff --git a/paperless-backend/src/zahlung/zahlung.service.ts b/paperless-backend/src/zahlung/zahlung.service.ts new file mode 100644 index 0000000..4d7ceda --- /dev/null +++ b/paperless-backend/src/zahlung/zahlung.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DocumentType } from '../database/entities/document-type.entity'; +import { PaperlessService } from '../paperless/paperless.service'; + +const FREIGABE_FIELD_ID = 15; +const ZAHLUNG_FIELD_ID = 16; +const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; + +export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle'; + +@Injectable() +export class ZahlungService { + private readonly logger = new Logger(ZahlungService.name); + + constructor( + @InjectRepository(DocumentType) + private readonly documentTypeRepo: Repository, + private readonly paperlessService: PaperlessService, + ) {} + + async getZahlungDocuments(page: number, pageSize: number, filter: ZahlungFilter) { + const docTypes = await this.documentTypeRepo.find({ + where: { FreigabeErforderlich: true as any }, + }); + + if (docTypes.length === 0) { + return { count: 0, results: [] }; + } + + const docTypeIds = docTypes.map((dt) => dt.DocumentTypeId).join(','); + + const params: Record = { + page: 1, + page_size: 9999, + document_type__id__in: docTypeIds, + ordering: '-created', + truncate_content: true, + }; + + let allDocs: any[]; + try { + 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); + return { count: 0, results: [] }; + } + + const filtered = this.applyFilter(allDocs, filter); + const start = (page - 1) * pageSize; + return { + count: filtered.length, + results: filtered.slice(start, start + pageSize), + }; + } + + private applyFilter(docs: any[], filter: ZahlungFilter): any[] { + if (filter === 'alle') return docs; + + return docs.filter((doc: any) => { + const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID); + const istFreigegeben = freigabeValue === FREIGABE_WERT_FREIGEGEBEN; + + if (filter === 'freigegeben') return istFreigegeben; + + // 'ausstehend': freigegeben aber noch nicht bezahlt + const zahlungValue = this.getCfValue(doc, ZAHLUNG_FIELD_ID); + return istFreigegeben && !zahlungValue; + }); + } + + 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 (typeof cf.value === 'object') { + return String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null; + } + return String(cf.value); + } + + async setZahlung(documentId: number, value: string | null) { + const doc = await this.paperlessService.getDocument(documentId); + + const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID); + if (freigabeValue !== FREIGABE_WERT_FREIGEGEBEN) { + throw new ForbiddenException( + 'Zahlung kann nur für freigegebene Belege gesetzt werden', + ); + } + + const customFields: any[] = [...(doc.custom_fields ?? [])]; + 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 }); + 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); + if (!field) return []; + + const rawOptions: any[] = field.extra_data?.select_options ?? []; + return rawOptions + .filter((o) => o !== null && o !== undefined && o !== '') + .map((o) => { + if (typeof o === 'object') { + return { + id: String(o.id ?? o.value ?? o.label ?? ''), + label: String(o.label ?? o.name ?? o.id ?? ''), + }; + } + return { id: String(o), label: String(o) }; + }) + .filter((o) => o.id !== ''); + } +} diff --git a/paperless-frontend/src/App.tsx b/paperless-frontend/src/App.tsx index 45cd76b..21bdd9f 100644 --- a/paperless-frontend/src/App.tsx +++ b/paperless-frontend/src/App.tsx @@ -21,6 +21,7 @@ const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage')); const LoginPage = lazy(() => import('./pages/LoginPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage')); const FreigabePage = lazy(() => import('./pages/FreigabePage')); +const ZahlungPage = lazy(() => import('./pages/ZahlungPage')); import { Permission } from './auth/permissions'; function UnauthorizedPage() { @@ -133,6 +134,7 @@ function ThemedApp() { } /> } /> } /> + } /> } /> diff --git a/paperless-frontend/src/api/zahlung.ts b/paperless-frontend/src/api/zahlung.ts new file mode 100644 index 0000000..3f33137 --- /dev/null +++ b/paperless-frontend/src/api/zahlung.ts @@ -0,0 +1,40 @@ +import api from './client'; + +export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle'; + +export interface ZahlungDocument { + id: number; + title: string; + created: string; + created_date: string; + correspondent: number | null; + document_type: number | null; + archive_serial_number: number | null; + tags: number[]; + custom_fields: { field: number; value: any }[]; +} + +export interface ZahlungOption { + id: string; + label: string; +} + +export interface ZahlungResult { + count: number; + results: ZahlungDocument[]; +} + +export const zahlungApi = { + getDocuments: (page = 1, pageSize = 25, filter: ZahlungFilter = 'ausstehend') => + api + .get('/api/zahlung/documents', { params: { page, pageSize, filter } }) + .then((r) => r.data), + + setZahlung: (docId: number, value: string | null) => + api + .put<{ success: boolean }>(`/api/zahlung/documents/${docId}/zahlung`, { value }) + .then((r) => r.data), + + getOptions: () => + api.get('/api/zahlung/options').then((r) => r.data), +}; diff --git a/paperless-frontend/src/auth/permissions.ts b/paperless-frontend/src/auth/permissions.ts index ed90aa4..518f0e6 100644 --- a/paperless-frontend/src/auth/permissions.ts +++ b/paperless-frontend/src/auth/permissions.ts @@ -6,6 +6,7 @@ export const Permission = { VIEW_SCANNER: 'VIEW_SCANNER', MANAGE_SETTINGS: 'MANAGE_SETTINGS', VIEW_FREIGABE: 'VIEW_FREIGABE', + VIEW_ZAHLUNG: 'VIEW_ZAHLUNG', } as const; export type Permission = typeof Permission[keyof typeof Permission]; @@ -26,6 +27,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per permissions.add(Permission.VIEW_SCANNER); permissions.add(Permission.MANAGE_SETTINGS); permissions.add(Permission.VIEW_FREIGABE); + permissions.add(Permission.VIEW_ZAHLUNG); return Array.from(permissions); } @@ -44,6 +46,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per if (groups.includes('PM_Freigabe')) { permissions.add(Permission.VIEW_FREIGABE); } + if (groups.includes('PM_Zahlung')) { + permissions.add(Permission.VIEW_ZAHLUNG); + } return Array.from(permissions); } diff --git a/paperless-frontend/src/layouts/AppLayout.tsx b/paperless-frontend/src/layouts/AppLayout.tsx index 2cfc2c7..7e1294b 100644 --- a/paperless-frontend/src/layouts/AppLayout.tsx +++ b/paperless-frontend/src/layouts/AppLayout.tsx @@ -14,6 +14,7 @@ import { AppstoreOutlined, GlobalOutlined, CheckCircleOutlined, + EuroOutlined, } from '@ant-design/icons'; import { useAuth } from '../auth/AuthContext'; import { useTheme } from '../theme/ThemeContext'; @@ -40,6 +41,7 @@ const allMenuItems: MenuItemDef[] = [ { key: '/mailpostfach', icon: , label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' }, { key: 'agrarmonitor', icon: , label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' }, { key: '/freigabe', icon: , label: 'Freigabe', permission: Permission.VIEW_FREIGABE }, + { key: '/zahlung', icon: , label: 'Zahlung', permission: Permission.VIEW_ZAHLUNG }, { key: '/settings', icon: , label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS }, ]; diff --git a/paperless-frontend/src/pages/ZahlungPage.tsx b/paperless-frontend/src/pages/ZahlungPage.tsx new file mode 100644 index 0000000..1062ab7 --- /dev/null +++ b/paperless-frontend/src/pages/ZahlungPage.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Table, Typography, Tag, Button, Modal, Select, message, Space, Radio, Tooltip } from 'antd'; +import { EuroOutlined } from '@ant-design/icons'; +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'; + +const { Title } = Typography; +const FREIGABE_FIELD_ID = 15; +const ZAHLUNG_FIELD_ID = 16; +const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben'; + +export default function ZahlungPage() { + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [filter, setFilter] = useState('ausstehend'); + + const [docTypes, setDocTypes] = useState([]); + const [correspondents, setCorrespondents] = useState([]); + const [zahlungOptions, setZahlungOptions] = useState([]); + + const [selectedDoc, setSelectedDoc] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + Promise.all([ + paperlessApi.getDocumentTypes(), + paperlessApi.getCorrespondents(), + zahlungApi.getOptions(), + ]).then(([dts, corrs, opts]) => { + setDocTypes(dts); + setCorrespondents(corrs); + setZahlungOptions(opts); + }); + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const result = await zahlungApi.getDocuments(page, pageSize, filter); + setData(result.results ?? []); + setTotal(result.count ?? 0); + } catch { + message.error('Fehler beim Laden der Belege'); + } finally { + setLoading(false); + } + }, [page, pageSize, filter]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const getDocTypeName = (id: number | null) => { + if (!id) return '—'; + return docTypes.find((d) => d.id === id)?.name ?? String(id); + }; + + const getCorrespondentName = (id: number | null) => { + if (!id) return '—'; + return correspondents.find((c) => c.id === id)?.name ?? String(id); + }; + + const toCfString = (value: any): string | null => { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'object') return String(value?.id ?? value?.value ?? value?.label ?? '') || null; + return String(value); + }; + + const getCfValue = (doc: ZahlungDocument, fieldId: number) => { + const cf = doc.custom_fields?.find((f) => f.field === fieldId); + return toCfString(cf?.value); + }; + + const renderFreigabeTag = (doc: ZahlungDocument) => { + const val = getCfValue(doc, FREIGABE_FIELD_ID); + if (!val) return Nicht gesetzt; + if (val === FREIGABE_WERT_FREIGEGEBEN) return Freigegeben; + return {val}; + }; + + const renderZahlungTag = (doc: ZahlungDocument) => { + const val = getCfValue(doc, ZAHLUNG_FIELD_ID); + if (!val) return Nicht gesetzt; + const opt = zahlungOptions.find((o) => o.id === val); + return {opt?.label ?? val}; + }; + + const istFreigegeben = (doc: ZahlungDocument) => + getCfValue(doc, FREIGABE_FIELD_ID) === FREIGABE_WERT_FREIGEGEBEN; + + const openModal = (doc: ZahlungDocument) => { + setSelectedDoc(doc); + setSelectedValue(getCfValue(doc, ZAHLUNG_FIELD_ID)); + setModalOpen(true); + }; + + const handleZahlung = async () => { + if (!selectedDoc) return; + setSaving(true); + try { + await zahlungApi.setZahlung(selectedDoc.id, selectedValue); + message.success('Zahlung gesetzt'); + setModalOpen(false); + setSelectedDoc(null); + fetchData(); + } catch { + message.error('Fehler beim Speichern der Zahlung'); + } finally { + setSaving(false); + } + }; + + const columns: ColumnsType = [ + { + title: 'Dokumenttyp', + dataIndex: 'document_type', + key: 'doctype', + render: getDocTypeName, + }, + { + title: 'Titel', + dataIndex: 'title', + key: 'title', + ellipsis: true, + }, + { + title: 'Erstellt', + dataIndex: 'created_date', + key: 'created', + width: 110, + render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—', + }, + { + title: 'Absender', + dataIndex: 'correspondent', + key: 'correspondent', + render: getCorrespondentName, + }, + { + title: 'Freigabe', + key: 'freigabe', + width: 130, + render: (_, doc) => renderFreigabeTag(doc), + }, + { + title: 'Zahlung', + key: 'zahlung', + width: 130, + render: (_, doc) => renderZahlungTag(doc), + }, + { + title: '', + key: 'action', + width: 150, + render: (_, doc) => { + const freigegeben = istFreigegeben(doc); + return ( + + + + ); + }, + }, + ]; + + return ( + <> + Zahlung + + + { + setPage(1); + setFilter(e.target.value); + }} + optionType="button" + buttonStyle="solid" + > + Freigegeben, noch nicht bezahlt + Alle freigegebenen + Alle + + + + + 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`, + }} + /> + + { setModalOpen(false); setSelectedDoc(null); }} + okText="Speichern" + cancelText="Abbrechen" + confirmLoading={saving} + > +

+ {selectedDoc?.title} +

+ + + + + + + -- 2.52.0 From 7076eef57bd49ca3ac718c291bf227712a107980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Tue, 14 Jul 2026 09:22:58 +0200 Subject: [PATCH 15/16] feat: add mobile-responsive layout and card views across all pages - 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 --- CLAUDE.md | 6 + .../agrarmonitor-polling.service.ts | 31 +- .../src/email/email-import.service.ts | 14 +- .../src/email/email.controller.ts | 17 +- .../src/email/imap-folder.service.ts | 69 +++- .../paperless/paperless-processor.service.ts | 2 +- .../src/paperless/paperless.controller.ts | 17 +- .../src/zahlung/zahlung.controller.ts | 5 +- .../src/zahlung/zahlung.service.ts | 28 +- paperless-frontend/src/api/freigabe.ts | 1 - paperless-frontend/src/api/zahlung.ts | 1 - .../src/components/DocumentEditModal.tsx | 30 +- .../src/components/DocumentSearchModal.tsx | 4 +- .../src/components/MailImportWizard.tsx | 16 +- .../src/components/MobileCardList.tsx | 64 +++ paperless-frontend/src/hooks/useIsMobile.ts | 11 + paperless-frontend/src/index.css | 26 ++ paperless-frontend/src/layouts/AppLayout.tsx | 376 +++++++++++------- paperless-frontend/src/pages/FreigabePage.tsx | 115 ++++-- .../src/pages/InboxDetailPage.tsx | 24 +- paperless-frontend/src/pages/InboxPage.tsx | 159 +++++--- .../src/pages/MailDetailPage.tsx | 44 +- .../src/pages/MailpostfachPage.tsx | 83 ++-- .../src/pages/ManuellBearbeitenPage.tsx | 100 +++-- .../src/pages/PosteingangPage.tsx | 63 ++- paperless-frontend/src/pages/SettingsPage.tsx | 43 +- paperless-frontend/src/pages/TaskLogPage.tsx | 64 ++- paperless-frontend/src/pages/ZahlungPage.tsx | 130 ++++-- 28 files changed, 1081 insertions(+), 462 deletions(-) create mode 100644 paperless-frontend/src/components/MobileCardList.tsx create mode 100644 paperless-frontend/src/hooks/useIsMobile.ts diff --git a/CLAUDE.md b/CLAUDE.md index b3558cb..5b7b70d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` diff --git a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts index 669c4ee..1702c11 100644 --- a/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts +++ b/paperless-backend/src/agrarmonitor/agrarmonitor-polling.service.ts @@ -85,18 +85,25 @@ export class AgrarmonitorPollingService implements OnModuleInit { importWartezeitMinuten: string; notizMarker: string; }> { - 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' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), - this.settingRepo.findOneBy({ - Tag: 'agrarmonitor_import_wartezeit_minuten', - }), - this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }), - ]); + 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' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }), + this.settingRepo.findOneBy({ + Tag: 'agrarmonitor_import_wartezeit_minuten', + }), + this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }), + ]); return { tagFertig: fertig?.Wert ?? '4', tagVerbucht: verbucht?.Wert ?? '9', diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index cff63e0..38b2b47 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -648,11 +648,17 @@ 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, + ), + ); } } } diff --git a/paperless-backend/src/email/email.controller.ts b/paperless-backend/src/email/email.controller.ts index 2b0b0d2..b08776e 100644 --- a/paperless-backend/src/email/email.controller.ts +++ b/paperless-backend/src/email/email.controller.ts @@ -204,9 +204,11 @@ 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.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message), - ); + this.imapFolderService + .cleanupImportedEmails() + .catch((err) => + this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message), + ); let movedToImportiert = 0; if (body.includeProcessed) { @@ -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 }; diff --git a/paperless-backend/src/email/imap-folder.service.ts b/paperless-backend/src/email/imap-folder.service.ts index 4fd4733..8f5496a 100644 --- a/paperless-backend/src/email/imap-folder.service.ts +++ b/paperless-backend/src/email/imap-folder.service.ts @@ -25,8 +25,14 @@ export class ImapFolderService { @Cron('0 3 * * *', { timeZone: 'Europe/Berlin' }) async cleanupImportedEmails(): Promise { if (!this.configService.get('IMAP_HOST')) return; - const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); - const trashFolder = this.configService.get('IMAP_TRASH_FOLDER', 'Trash'); + const importedFolder = this.configService.get( + 'IMAP_IMPORTED_FOLDER', + 'importiert', + ); + const trashFolder = this.configService.get( + '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 { - if (!this.configService.get('IMAP_HOST') || messageIds.length === 0) return 0; + if (!this.configService.get('IMAP_HOST') || messageIds.length === 0) + return 0; - const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); + const importedFolder = this.configService.get( + '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 { if (!this.configService.get('IMAP_HOST')) return; - const importedFolder = this.configService.get('IMAP_IMPORTED_FOLDER', 'importiert'); + const importedFolder = this.configService.get( + '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}`); diff --git a/paperless-backend/src/paperless/paperless-processor.service.ts b/paperless-backend/src/paperless/paperless-processor.service.ts index 7c7a034..0426529 100644 --- a/paperless-backend/src/paperless/paperless-processor.service.ts +++ b/paperless-backend/src/paperless/paperless-processor.service.ts @@ -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 = diff --git a/paperless-backend/src/paperless/paperless.controller.ts b/paperless-backend/src/paperless/paperless.controller.ts index 0b719cc..e9cec6f 100644 --- a/paperless-backend/src/paperless/paperless.controller.ts +++ b/paperless-backend/src/paperless/paperless.controller.ts @@ -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 }; } diff --git a/paperless-backend/src/zahlung/zahlung.controller.ts b/paperless-backend/src/zahlung/zahlung.controller.ts index b8dad7e..c627acc 100644 --- a/paperless-backend/src/zahlung/zahlung.controller.ts +++ b/paperless-backend/src/zahlung/zahlung.controller.ts @@ -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); } diff --git a/paperless-backend/src/zahlung/zahlung.service.ts b/paperless-backend/src/zahlung/zahlung.service.ts index 4d7ceda..aeb8fbc 100644 --- a/paperless-backend/src/zahlung/zahlung.service.ts +++ b/paperless-backend/src/zahlung/zahlung.service.ts @@ -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 ?? []; diff --git a/paperless-frontend/src/api/freigabe.ts b/paperless-frontend/src/api/freigabe.ts index 1f4163e..607d0a0 100644 --- a/paperless-frontend/src/api/freigabe.ts +++ b/paperless-frontend/src/api/freigabe.ts @@ -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; diff --git a/paperless-frontend/src/api/zahlung.ts b/paperless-frontend/src/api/zahlung.ts index 3f33137..9079974 100644 --- a/paperless-frontend/src/api/zahlung.ts +++ b/paperless-frontend/src/api/zahlung.ts @@ -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; diff --git a/paperless-frontend/src/components/DocumentEditModal.tsx b/paperless-frontend/src/components/DocumentEditModal.tsx index 433c24d..dde6890 100644 --- a/paperless-frontend/src/components/DocumentEditModal.tsx +++ b/paperless-frontend/src/components/DocumentEditModal.tsx @@ -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 ? [ , @@ -363,8 +365,12 @@ export default function DocumentEditModal({ documentId, document, open, onClose, } > - - + +
@@ -556,10 +562,20 @@ export default function DocumentEditModal({ documentId, document, open, onClose, })} - + + {isMobile && ( + + )} diff --git a/paperless-frontend/src/components/DocumentSearchModal.tsx b/paperless-frontend/src/components/DocumentSearchModal.tsx index 3a5573b..57d804e 100644 --- a/paperless-frontend/src/components/DocumentSearchModal.tsx +++ b/paperless-frontend/src/components/DocumentSearchModal.tsx @@ -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 }} >
@@ -100,7 +100,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props) description={ ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'} - Erstellt: {dayjs(doc.created_date).format('DD.MM.YYYY')} + Erstellt: {dayjs(doc.created).format('DD.MM.YYYY')} } /> diff --git a/paperless-frontend/src/components/MailImportWizard.tsx b/paperless-frontend/src/components/MailImportWizard.tsx index e9465d8..10b4644 100644 --- a/paperless-frontend/src/components/MailImportWizard.tsx +++ b/paperless-frontend/src/components/MailImportWizard.tsx @@ -538,8 +538,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a {toProcess.map(item => (
{item.fileName} - - + + {/* Eingangsdatum */}
Eingangsdatum: @@ -626,7 +626,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
- + {main.fileName} - - - + + + Eingangsdatum: {datum?.format('DD.MM.YYYY') ?? '—'} @@ -690,7 +690,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a )} {showPrint && ( - + @@ -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 ? ( diff --git a/paperless-frontend/src/components/MobileCardList.tsx b/paperless-frontend/src/components/MobileCardList.tsx new file mode 100644 index 0000000..1b55de3 --- /dev/null +++ b/paperless-frontend/src/components/MobileCardList.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react'; +import { List, Card } from 'antd'; +import type { TablePaginationConfig } from 'antd'; + +interface MobileCardListProps { + dataSource: T[]; + rowKey: keyof T | ((record: T) => React.Key); + loading?: boolean; + /** Gleiche Pagination-Config wie bei — 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({ + dataSource, + rowKey, + loading, + pagination, + renderCard, + onCardClick, + emptyText = 'Keine Einträge vorhanden', +}: MobileCardListProps) { + const getKey = (record: T): React.Key => + typeof rowKey === 'function' ? rowKey(record) : (record[rowKey] as React.Key); + + return ( + ( + + onCardClick(record) : undefined} + > + {renderCard(record)} + + + )} + /> + ); +} diff --git a/paperless-frontend/src/hooks/useIsMobile.ts b/paperless-frontend/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..e308c26 --- /dev/null +++ b/paperless-frontend/src/hooks/useIsMobile.ts @@ -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; +} diff --git a/paperless-frontend/src/index.css b/paperless-frontend/src/index.css index 4ab9339..19d4fa7 100644 --- a/paperless-frontend/src/index.css +++ b/paperless-frontend/src/index.css @@ -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; + } +} diff --git a/paperless-frontend/src/layouts/AppLayout.tsx b/paperless-frontend/src/layouts/AppLayout.tsx index 7e1294b..f021793 100644 --- a/paperless-frontend/src/layouts/AppLayout.tsx +++ b/paperless-frontend/src/layouts/AppLayout.tsx @@ -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,17 +49,22 @@ 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(null); + // Im Drawer (mobil) ist die Navigation nie eingeklappt + const effectiveCollapsed = isMobile ? false : collapsed; + useEffect(() => { if (!isAuthenticated) return; - + const fetchCounts = async () => { try { const data = await statsApi.getCounts(); @@ -66,10 +73,10 @@ export default function AppLayout() { console.error('Fehler beim Abrufen der Zählerstände:', err); } }; - + fetchCounts(); const interval = setInterval(fetchCounts, 30000); // 30 Sekunden Polling - + return () => clearInterval(interval); }, [isAuthenticated, location.pathname]); // Update after navigation or auth change @@ -80,9 +87,9 @@ export default function AppLayout() { label: (
{item.label} - {item.countKey && counts && counts[item.countKey] > 0 && !collapsed && ( - 0 && !effectiveCollapsed && ( + void) => ( + <> + {/* Logo / Collapse-Toggle */} + + + {/* Navigation Menu */} + { + 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 */} +
+ {/* Theme Toggle */} + + + + + {/* User Menu */} + , + label: 'Benutzereinstellungen', + onClick: () => { + navigate('/user-settings'); + onNavigate?.(); + }, + }, + { + key: 'logout', + icon: , + label: 'Abmelden', + onClick: () => logout(), + }, + ], + }} + placement="topRight" + trigger={['click']} + > +
(e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')} + onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} + > + } /> + {!isCollapsed && ( + + {user?.profile?.name || 'Benutzer'} + + )} +
+
+
+ + ); return ( - - {/* Logo / Collapse-Toggle */} - + {isMobile ? ( + <> + {/* Mobile Top-Bar mit Hamburger */} +
+
- {/* Navigation Menu */} - setDrawerOpen(false)} + closable={false} + styles={{ + body: { + padding: 0, + position: 'relative', + background: isDark ? '#001529' : '#f0f2f7', + }, + }} + > + {renderSidebarContent(false, () => setDrawerOpen(false))} + + + ) : ( + { - 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 */} -
- {/* Theme Toggle */} - - - + {renderSidebarContent(collapsed)} + + )} - {/* User Menu */} - , - label: 'Benutzereinstellungen', - onClick: () => navigate('/user-settings'), - }, - { - key: 'logout', - icon: , - label: 'Abmelden', - onClick: () => logout(), - }, - ], - }} - placement="topRight" - trigger={['click']} - > -
(e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')} - onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} - > - } /> - {!collapsed && ( - - {user?.profile?.name || 'Benutzer'} - - )} -
-
-
-
- - - + + diff --git a/paperless-frontend/src/pages/FreigabePage.tsx b/paperless-frontend/src/pages/FreigabePage.tsx index d6c1b18..78275be 100644 --- a/paperless-frontend/src/pages/FreigabePage.tsx +++ b/paperless-frontend/src/pages/FreigabePage.tsx @@ -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([]); 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,44 +156,92 @@ 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 ( <> Freigabe - - { + onChange={(v) => { setPage(1); - setNurNichtFreigegeben(e.target.value); + setNurNichtFreigegeben(v); }} - optionType="button" - buttonStyle="solid" - > - Nicht freigegeben - Alle - - + options={[ + { value: true, label: 'Nicht freigegeben' }, + { value: false, label: 'Alle' }, + ]} + /> + ) : ( + + { + setPage(1); + setNurNichtFreigegeben(e.target.value); + }} + optionType="button" + buttonStyle="solid" + > + Nicht freigegeben + Alle + + + )} - - 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`, - }} - /> + {isMobile ? ( + + dataSource={data} + rowKey="id" + loading={loading} + pagination={paginationConfig} + emptyText="Keine Belege vorhanden" + renderCard={(doc) => ( +
+ {doc.title || '—'} + Dokumenttyp: {getDocTypeName(doc.document_type)} + Absender: {getCorrespondentName(doc.correspondent)} +
+ + Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'} + + {getFreigabeValue(doc)} +
+ +
+ )} + /> + ) : ( + + dataSource={data} + columns={columns} + rowKey="id" + loading={loading} + size="small" + pagination={paginationConfig} + /> + )} (null); const [inboxUrl, setInboxUrl] = useState(null); const [loading, setLoading] = useState(false); @@ -165,8 +167,8 @@ function CompareModal({ , ]} > -
-
+
+
Original (Paperless) @@ -180,7 +182,7 @@ function CompareModal({ )}
-
+
Aktueller Abschnitt (Inbox) @@ -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(null); const [loading, setLoading] = useState(true); const [thumbUrls, setThumbUrls] = useState>(new Map()); @@ -1211,13 +1214,13 @@ export default function InboxDetailPage() { })(); return ( -
-
- +
+
+ - + <Title level={isMobile ? 5 : 3} style={{ margin: 0, maxWidth: isMobile ? 180 : undefined }} ellipsis={{ tooltip: file.name }}> {file.name} @@ -1399,12 +1402,13 @@ export default function InboxDetailPage() { >
{sidebarPages.map((n, idx) => { @@ -1428,7 +1432,7 @@ export default function InboxDetailPage() {
) : ( diff --git a/paperless-frontend/src/pages/InboxPage.tsx b/paperless-frontend/src/pages/InboxPage.tsx index 943c1ca..b8372a1 100644 --- a/paperless-frontend/src/pages/InboxPage.tsx +++ b/paperless-frontend/src/pages/InboxPage.tsx @@ -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' ? ( + } color="purple"> + Persönlich + + ) : ( + } color="blue"> + Gemeinsam + + ); +} + function renderBarcodes(barcodes: InboxBarcode[]): ReactNode { if (!barcodes || barcodes.length === 0) { return ; @@ -137,6 +151,7 @@ function buildInitialFieldValues(template: BarcodeTemplate | null): Record([]); 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') => ( +
+ + + + handleDelete(record.id)} + > + + + + + +
+ ); + const columns: ColumnsType = [ { 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' ? ( - } color="purple"> - Persönlich - - ) : ( - } color="blue"> - Gemeinsam - - ), + 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) => ( -
- - - - handleDelete(record.id)} - > - - - - - -
- ), + 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.
- + } 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() { - - + {isMobile ? ( + + 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) => ( +
+ + {record.name} + +
+ {renderSourceTag(record.source)} + + {record.pageCount > 0 ? `${record.pageCount} Seiten` : '—'} · {formatDate(record.createdAt)} + +
+ {renderBarcodes(record.barcodes)} + {renderActions(record, 'row')} +
+ )} /> -
+ ) : ( + + + rowKey="id" + columns={columns} + dataSource={filtered} + loading={loading} + pagination={{ + pageSize: 25, + showSizeChanger: true, + showTotal: (t) => `${t} Dateien`, + }} + locale={{ emptyText: 'Keine Dateien vorhanden' }} + /> + + )}
); } diff --git a/paperless-frontend/src/pages/MailDetailPage.tsx b/paperless-frontend/src/pages/MailDetailPage.tsx index d821e3b..e42ee07 100644 --- a/paperless-frontend/src/pages/MailDetailPage.tsx +++ b/paperless-frontend/src/pages/MailDetailPage.tsx @@ -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(null); const [attachments, setAttachments] = useState([]); const [selected, setSelected] = useState(null); @@ -136,16 +138,16 @@ export default function MailDetailPage() { return (
-
- +
+ - {email.Subject} + {email.Subject} {hasErechnung && eRechnung} - - + +
-
+
{/* Linke Seite: E-Mail-Inhalt */}
Von: {email.SenderAddress}
@@ -208,7 +224,17 @@ export default function MailDetailPage() { {/* Rechte Seite: Anhänge + Vorschau */} - +
columns={columns} @@ -224,7 +250,7 @@ export default function MailDetailPage() { locale={{ emptyText: }} />
-
+
{previewLoading ? (
diff --git a/paperless-frontend/src/pages/MailpostfachPage.tsx b/paperless-frontend/src/pages/MailpostfachPage.tsx index a865c79..07c0062 100644 --- a/paperless-frontend/src/pages/MailpostfachPage.tsx +++ b/paperless-frontend/src/pages/MailpostfachPage.tsx @@ -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 Neu; + if (s === 1) return Verarbeitet; + if (s === 2) return Fehler; + if (s === 3) return Ignoriert; + return {s}; +} export default function MailpostfachPage() { const navigate = useNavigate(); + const isMobile = useIsMobile(); const [emails, setEmails] = useState([]); 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 Neu; - if (s === 1) return Verarbeitet; - if (s === 2) return Fehler; - if (s === 3) return Ignoriert; - return {s}; - }, + render: renderStatusTag, filters: [ { text: 'Neu', value: 0 }, { text: 'Verarbeitet', value: 1 }, @@ -116,9 +121,9 @@ export default function MailpostfachPage() { return (
-
+
Mailpostfach - +
`${t} Dokumente` }} - locale={{ emptyText: 'Keine Einträge vorhanden' }} - /> + {isMobile ? ( + + dataSource={data} + rowKey="id" + loading={loading} + pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }} + renderCard={(record) => ( +
+
+ +
+ {record.title || '—'} + {renderContentTags(record)} + + Eingangsdatum: {record.created ? dayjs(record.created).format('DD.MM.YYYY') : '-'} + + + Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')} + +
+
+ +
+ )} + /> + ) : ( +
`${t} Dokumente` }} + locale={{ emptyText: 'Keine Einträge vorhanden' }} + /> + )} ([]); 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,14 +126,46 @@ export default function PosteingangPage() { -
`${t} Dokumente` }} - locale={{ emptyText: 'Keine Einträge vorhanden' }} - /> + {isMobile ? ( + + dataSource={data} + rowKey="id" + loading={loading} + pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }} + renderCard={(record) => ( +
+
+ +
+ {record.title || '—'} + + Eingangsdatum: {getEingangsdatum(record)} + + + Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')} + +
+
+ +
+ )} + /> + ) : ( +
`${t} Dokumente` }} + locale={{ emptyText: 'Keine Einträge vorhanden' }} + /> + )} } onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}> Zuordnung hinzufügen -
+
Betriebe — Agrarmonitor-Zuordnung @@ -335,6 +335,7 @@ function UserClientsTab() { rowKey="Id" size="small" pagination={false} + scroll={{ x: 'max-content' }} /> setModalOpen(false)}> @@ -599,13 +600,14 @@ function DocTypesTab() { return ( <> -
} onClick={openNew} style={{ marginBottom: 16 }}> Regel hinzufügen -
+
} onClick={openNew} style={{ marginBottom: 16 }}> Export-Ziel hinzufügen -
+
setEditing(null)}>
@@ -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() { -
+
-
Weiterverarbeitungs-Aktionen - rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" /> + rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" scroll={{ x: 'max-content' }} /> Fertig; @@ -12,6 +16,7 @@ function statusTag(fertig: number | null) { } export default function TaskLogPage() { + const isMobile = useIsMobile(); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [deleting, setDeleting] = useState(false); @@ -155,14 +160,55 @@ export default function TaskLogPage() { -
+ {isMobile ? ( + + dataSource={data} + rowKey="TaskId" + loading={loading} + pagination={{ pageSize: 20 }} + renderCard={(record) => ( +
+
+ {record.InterneBelegnummer || '—'} + {statusTag(record.Fertig)} +
+ + {record.TaskId.slice(0, 8)}… + + Lieferant: {record.Lieferant || '-'} + + Belegdatum: {record.Belegdatum ? dayjs(record.Belegdatum).format('DD.MM.YYYY') : '-'} + {' · '} + Eingang: {record.Eingangsdatum ? dayjs(record.Eingangsdatum).format('DD.MM.YYYY') : '-'} + + + Paperless-Dok.-ID: {record.PaperlessDocumentID ?? '-'} + + handleDeleteOne(record.TaskId)} + okText="Löschen" + cancelText="Abbrechen" + okButtonProps={{ danger: true }} + > + + +
+ )} + /> + ) : ( +
+ )} ); diff --git a/paperless-frontend/src/pages/ZahlungPage.tsx b/paperless-frontend/src/pages/ZahlungPage.tsx index 1062ab7..077a526 100644 --- a/paperless-frontend/src/pages/ZahlungPage.tsx +++ b/paperless-frontend/src/pages/ZahlungPage.tsx @@ -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([]); 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,45 +187,100 @@ 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 ( <> Zahlung - - { + onChange={(v) => { setPage(1); - setFilter(e.target.value); + setFilter(v); }} - optionType="button" - buttonStyle="solid" - > - Freigegeben, noch nicht bezahlt - Alle freigegebenen - Alle - - + options={FILTER_OPTIONS} + /> + ) : ( + + { + setPage(1); + setFilter(e.target.value); + }} + optionType="button" + buttonStyle="solid" + > + {FILTER_OPTIONS.map((o) => ( + {o.label} + ))} + + + )} - - 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`, - }} - /> + {isMobile ? ( + + dataSource={data} + rowKey="id" + loading={loading} + pagination={paginationConfig} + emptyText="Keine Belege vorhanden" + renderCard={(doc) => { + const freigegeben = istFreigegeben(doc); + return ( +
+ {doc.title || '—'} + Dokumenttyp: {getDocTypeName(doc.document_type)} + Absender: {getCorrespondentName(doc.correspondent)} + + Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'} + +
+ {renderFreigabeTag(doc)} + {renderZahlungTag(doc)} +
+ + {!freigegeben && ( + + Beleg muss zuerst freigegeben werden + + )} +
+ ); + }} + /> + ) : ( + + dataSource={data} + columns={columns} + rowKey="id" + loading={loading} + size="small" + pagination={paginationConfig} + /> + )} Date: Tue, 14 Jul 2026 09:43:53 +0200 Subject: [PATCH 16/16] chore: add .gitea_token to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 621fa9d..ef6731f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ dist/ # Docker docker-compose.override.yml + +# Secrets +.gitea_token -- 2.52.0